diff --git a/alembic/versions/2026_04_25_2140-70b5f11e3655_checksum_in_artifacts_workflow_system_.py b/alembic/versions/2026_04_25_2140-70b5f11e3655_checksum_in_artifacts_workflow_system_.py new file mode 100644 index 000000000..3c4712e3e --- /dev/null +++ b/alembic/versions/2026_04_25_2140-70b5f11e3655_checksum_in_artifacts_workflow_system_.py @@ -0,0 +1,42 @@ +"""checksum in artifacts; workflow_system_prompt + +Revision ID: 70b5f11e3655 +Revises: d1474f2d1581 +Create Date: 2026-04-25 21:40:15.008563+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "70b5f11e3655" +down_revision: Union[str, None] = "d1474f2d1581" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("artifacts", sa.Column("checksum", sa.String(), nullable=True)) + op.add_column("observer_cruises", sa.Column("workflow_system_prompt", sa.UnicodeText(), nullable=True)) + op.add_column("tasks", sa.Column("workflow_system_prompt", sa.UnicodeText(), nullable=True)) + op.add_column( + "workflow_runs", + sa.Column( + "ignore_inherited_workflow_system_prompt", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("workflow_runs", "ignore_inherited_workflow_system_prompt") + op.drop_column("tasks", "workflow_system_prompt") + op.drop_column("observer_cruises", "workflow_system_prompt") + op.drop_column("artifacts", "checksum") + # ### end Alembic commands ### diff --git a/skyvern-frontend/src/routes/workflows/components/WorkflowVisualComparisonDrawer.tsx b/skyvern-frontend/src/routes/workflows/components/WorkflowVisualComparisonDrawer.tsx index 604c88fbc..f7f35e9f7 100644 --- a/skyvern-frontend/src/routes/workflows/components/WorkflowVisualComparisonDrawer.tsx +++ b/skyvern-frontend/src/routes/workflows/components/WorkflowVisualComparisonDrawer.tsx @@ -131,6 +131,8 @@ function getWorkflowElements(version: WorkflowVersion) { runSequentially: version.run_sequentially ?? false, sequentialKey: version.sequential_key ?? null, finallyBlockLabel: version.workflow_definition?.finally_block_label ?? null, + workflowSystemPrompt: + version.workflow_definition?.workflow_system_prompt ?? null, }; return getElements( diff --git a/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx b/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx index febd3014a..203f68625 100644 --- a/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx +++ b/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx @@ -90,6 +90,8 @@ function Debugger() { sequentialKey: workflow.sequential_key ?? null, finallyBlockLabel: workflow.workflow_definition?.finally_block_label ?? null, + workflowSystemPrompt: + workflow.workflow_definition?.workflow_system_prompt ?? null, }; const elements = getElements(blocksToRender, settings, true); diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx index 91f25ed0b..aa5f0bb23 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx @@ -82,6 +82,8 @@ function WorkflowEditor() { sequentialKey: workflow.sequential_key ?? null, finallyBlockLabel: workflow.workflow_definition?.finally_block_label ?? null, + workflowSystemPrompt: + workflow.workflow_definition?.workflow_system_prompt ?? null, }; const elements = getElements(blocksToRender, settings, !isGlobalWorkflow); diff --git a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx index 259c63ed2..f884d32ed 100644 --- a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx @@ -1206,6 +1206,8 @@ function Workspace({ sequentialKey: workflowData.sequential_key ?? null, finallyBlockLabel: workflowData.workflow_definition?.finally_block_label ?? null, + workflowSystemPrompt: + workflowData.workflow_definition?.workflow_system_prompt ?? null, }; const elements = getElements( @@ -1254,6 +1256,8 @@ function Workspace({ sequentialKey: selectedVersion.sequential_key ?? null, finallyBlockLabel: selectedVersion.workflow_definition?.finally_block_label ?? null, + workflowSystemPrompt: + selectedVersion.workflow_definition?.workflow_system_prompt ?? null, }; const elements = getElements( @@ -2006,6 +2010,8 @@ function Workspace({ blocks: saveData.blocks, finally_block_label: saveData.settings.finallyBlockLabel ?? undefined, + workflow_system_prompt: + saveData.settings.workflowSystemPrompt ?? undefined, }); // Convert current workflow definition YAML to blocks diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx index ce1b6aea9..752b8574d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx @@ -38,6 +38,7 @@ import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuer import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { DisableCache } from "../DisableCache"; +import { IgnoreWorkflowSystemPrompt } from "../IgnoreWorkflowSystemPrompt"; import { BlockExecutionOptions } from "../components/BlockExecutionOptions"; const urlTooltip = @@ -270,6 +271,17 @@ function ActionNode({ id, data, type }: NodeProps) { update({ disableCache }); }} /> + { + update({ ignoreWorkflowSystemPrompt }); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/types.ts index b804ff6eb..e30b31e85 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/types.ts @@ -36,6 +36,7 @@ export const actionNodeDefaultData: ActionNodeData = { disableCache: false, engine: RunEngine.SkyvernV1, model: null, + ignoreWorkflowSystemPrompt: false, } as const; export function isActionNode(node: Node): node is ActionNode { diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/ExtractionNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/ExtractionNode.tsx index f45b3df1c..11b877598 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/ExtractionNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/ExtractionNode.tsx @@ -38,6 +38,7 @@ import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { useRerender } from "@/hooks/useRerender"; import { DisableCache } from "../DisableCache"; +import { IgnoreWorkflowSystemPrompt } from "../IgnoreWorkflowSystemPrompt"; import { BlockExecutionOptions } from "../components/BlockExecutionOptions"; import { AI_IMPROVE_CONFIGS } from "../../constants"; @@ -242,6 +243,17 @@ function ExtractionNode({ id, data, type }: NodeProps) { update({ disableCache }); }} /> + { + update({ ignoreWorkflowSystemPrompt }); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/types.ts index 4b3c95147..71e619fa6 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ExtractionNode/types.ts @@ -30,6 +30,7 @@ export const extractionNodeDefaultData: ExtractionNodeData = { disableCache: false, engine: RunEngine.SkyvernV1, model: null, + ignoreWorkflowSystemPrompt: false, } as const; export function isExtractionNode(node: Node): node is ExtractionNode { diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx index 9d4b75bbf..c96396784 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx @@ -38,6 +38,7 @@ import { useRerender } from "@/hooks/useRerender"; import { BROWSER_DOWNLOAD_TIMEOUT_SECONDS } from "@/api/types"; import { DisableCache } from "../DisableCache"; +import { IgnoreWorkflowSystemPrompt } from "../IgnoreWorkflowSystemPrompt"; import { BlockExecutionOptions } from "../components/BlockExecutionOptions"; import { AI_IMPROVE_CONFIGS } from "../../constants"; @@ -307,6 +308,17 @@ function FileDownloadNode({ id, data }: NodeProps) { update({ disableCache }); }} /> + { + update({ ignoreWorkflowSystemPrompt }); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/types.ts index ce486f2d2..192a972a1 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/types.ts @@ -38,6 +38,7 @@ export const fileDownloadNodeDefaultData: FileDownloadNodeData = { engine: RunEngine.SkyvernV1, model: null, downloadTimeout: null, + ignoreWorkflowSystemPrompt: false, } as const; export function isFileDownloadNode(node: Node): node is FileDownloadNode { diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx index 860bd99e7..87323718a 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/FileParserNode.tsx @@ -22,6 +22,8 @@ import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuer import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { ModelSelector } from "@/components/ModelSelector"; import { useRecordingStore } from "@/store/useRecordingStore"; +import { Separator } from "@/components/ui/separator"; +import { IgnoreWorkflowSystemPrompt } from "../IgnoreWorkflowSystemPrompt"; const FILE_TYPE_OPTIONS: Array<{ value: FileParserFileType; label: string }> = [ { value: "auto_detect", label: "Auto detect" }, @@ -188,6 +190,18 @@ function FileParserNode({ id, data }: NodeProps) { update({ model: value }); }} /> + + { + update({ ignoreWorkflowSystemPrompt }); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/types.ts index 026f0d01c..ecbfeddd0 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileParserNode/types.ts @@ -32,6 +32,7 @@ export const fileParserNodeDefaultData: FileParserNodeData = { continueOnFailure: false, jsonSchema: "null", model: null, + ignoreWorkflowSystemPrompt: false, } as const; export function isFileParserNode(node: AppNode): node is FileParserNode { diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/IgnoreWorkflowSystemPrompt.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/IgnoreWorkflowSystemPrompt.tsx new file mode 100644 index 000000000..844ea7b95 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/IgnoreWorkflowSystemPrompt.tsx @@ -0,0 +1,37 @@ +import { HelpTooltip } from "@/components/HelpTooltip"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +function IgnoreWorkflowSystemPrompt({ + ignoreWorkflowSystemPrompt, + editable, + onIgnoreWorkflowSystemPromptChange, +}: { + ignoreWorkflowSystemPrompt: boolean; + editable: boolean; + onIgnoreWorkflowSystemPromptChange: (value: boolean) => void; +}) { + return ( +
+
+ + +
+
+ { + if (!editable) { + return; + } + onIgnoreWorkflowSystemPromptChange(checked); + }} + /> +
+
+ ); +} + +export { IgnoreWorkflowSystemPrompt }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx index 654a80548..e05f04c0d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx @@ -38,6 +38,7 @@ import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { useRerender } from "@/hooks/useRerender"; import { DisableCache } from "../DisableCache"; +import { IgnoreWorkflowSystemPrompt } from "../IgnoreWorkflowSystemPrompt"; import { BlockExecutionOptions } from "../components/BlockExecutionOptions"; import { AI_IMPROVE_CONFIGS } from "../../constants"; @@ -318,6 +319,17 @@ function LoginNode({ id, data, type }: NodeProps) { update({ disableCache }); }} /> + { + update({ ignoreWorkflowSystemPrompt }); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/types.ts index a6e874b1f..fd0a52c1c 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/types.ts @@ -39,6 +39,7 @@ export const loginNodeDefaultData: LoginNodeData = { terminateCriterion: "", engine: RunEngine.SkyvernV1, model: null, + ignoreWorkflowSystemPrompt: false, } as const; export function isLoginNode(node: Node): node is LoginNode { diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx index 3c6997168..e2f918e20 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx @@ -40,6 +40,7 @@ import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { RunEngine } from "@/api/types"; import { DisableCache } from "../DisableCache"; +import { IgnoreWorkflowSystemPrompt } from "../IgnoreWorkflowSystemPrompt"; import { BlockExecutionOptions } from "../components/BlockExecutionOptions"; import { AI_IMPROVE_CONFIGS } from "../../constants"; @@ -200,6 +201,17 @@ function NavigationNode({ id, data, type }: NodeProps) { update({ disableCache }); }} /> + { + update({ ignoreWorkflowSystemPrompt }); + }} + />
@@ -465,6 +477,17 @@ function NavigationNode({ id, data, type }: NodeProps) { update({ disableCache }); }} /> + { + update({ ignoreWorkflowSystemPrompt }); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/types.ts index 9894be52f..11eb7afbb 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/types.ts @@ -49,6 +49,7 @@ export const navigationNodeDefaultData: NavigationNodeData = { continueOnFailure: false, disableCache: false, includeActionHistoryInVerification: false, + ignoreWorkflowSystemPrompt: false, // V2-specific fields prompt: "", maxSteps: MAX_STEPS_DEFAULT, diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/PDFParserNode/PDFParserNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/PDFParserNode/PDFParserNode.tsx index 5530acd32..918acc25e 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/PDFParserNode/PDFParserNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/PDFParserNode/PDFParserNode.tsx @@ -15,6 +15,8 @@ import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuer import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { ModelSelector } from "@/components/ModelSelector"; import { useRecordingStore } from "@/store/useRecordingStore"; +import { Separator } from "@/components/ui/separator"; +import { IgnoreWorkflowSystemPrompt } from "../IgnoreWorkflowSystemPrompt"; function PDFParserNode({ id, data }: NodeProps) { const { editable, label } = data; @@ -103,6 +105,18 @@ function PDFParserNode({ id, data }: NodeProps) { update({ model: value }); }} /> + + { + update({ ignoreWorkflowSystemPrompt }); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/PDFParserNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/PDFParserNode/types.ts index bcc4445eb..1ef39f323 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/PDFParserNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/PDFParserNode/types.ts @@ -22,6 +22,7 @@ export const pdfParserNodeDefaultData: PDFParserNodeData = { continueOnFailure: false, jsonSchema: "null", model: null, + ignoreWorkflowSystemPrompt: false, } as const; export function isPdfParserNode(node: AppNode): node is PDFParserNode { diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx index 2e497d454..492c756c5 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx @@ -74,6 +74,7 @@ interface StartSettings { maxScreenshotScrollingTimes: number | null; extraHttpHeaders: string | Record | null; finallyBlockLabel: string | null; + workflowSystemPrompt: string | null; } function StartNode({ id, data, parentId }: NodeProps) { @@ -160,6 +161,9 @@ function StartNode({ id, data, parentId }: NodeProps) { finallyBlockLabel: data.withWorkflowSettings ? data.finallyBlockLabel : null, + workflowSystemPrompt: data.withWorkflowSettings + ? (data.workflowSystemPrompt ?? null) + : null, }; }; @@ -539,6 +543,23 @@ function StartNode({ id, data, parentId }: NodeProps) {
+
+
+ + +
+ { + update({ + workflowSystemPrompt: value.length ? value : null, + }); + }} + value={data.workflowSystemPrompt ?? ""} + placeholder="e.g. Format all dates as YYYY-MM-DD and all currency values as USD with two decimals." + className="nopan text-xs" + /> +
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts index eb64838f9..61c95e2e9 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts @@ -19,6 +19,7 @@ export type WorkflowStartNodeData = { runSequentially: boolean; sequentialKey: string | null; finallyBlockLabel: string | null; + workflowSystemPrompt: string | null; label: "__start_block__"; showCode: boolean; }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx index 85ac49597..7c0eda766 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx @@ -40,6 +40,7 @@ import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { useRerender } from "@/hooks/useRerender"; import { DisableCache } from "../DisableCache"; +import { IgnoreWorkflowSystemPrompt } from "../IgnoreWorkflowSystemPrompt"; import { BlockExecutionOptions } from "../components/BlockExecutionOptions"; function TaskNode({ id, data, type }: NodeProps) { @@ -355,6 +356,17 @@ function TaskNode({ id, data, type }: NodeProps) { update({ disableCache }); }} /> + { + update({ ignoreWorkflowSystemPrompt }); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts index 9c1f47874..8390ce571 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/types.ts @@ -47,6 +47,7 @@ export const taskNodeDefaultData: TaskNodeData = { includeActionHistoryInVerification: false, engine: RunEngine.SkyvernV1, model: null, + ignoreWorkflowSystemPrompt: false, } as const; export function isTaskNode(node: Node): node is TaskNode { diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx index 5c106e9a8..d9a9625c7 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx @@ -29,6 +29,7 @@ import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { useBlockScriptStore } from "@/store/BlockScriptStore"; import { DisableCache } from "../DisableCache"; +import { IgnoreWorkflowSystemPrompt } from "../IgnoreWorkflowSystemPrompt"; function Taskv2Node({ id, data, type }: NodeProps) { const { editable, label } = data; @@ -164,6 +165,17 @@ function Taskv2Node({ id, data, type }: NodeProps) { update({ disableCache }); }} /> + { + update({ ignoreWorkflowSystemPrompt }); + }} + />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/types.ts index b3f477c53..d3d1f16d9 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/types.ts @@ -30,6 +30,7 @@ export const taskv2NodeDefaultData: Taskv2NodeData = { disableCache: false, model: null, maxScreenshotScrolls: null, + ignoreWorkflowSystemPrompt: false, }; export function isTaskV2Node(node: Node): node is Taskv2Node { diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/TextPromptNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/TextPromptNode.tsx index c92b69a83..04136a0ed 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/TextPromptNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/TextPromptNode.tsx @@ -17,6 +17,7 @@ import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuer import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { AI_IMPROVE_CONFIGS } from "../../constants"; import { useRecordingStore } from "@/store/useRecordingStore"; +import { IgnoreWorkflowSystemPrompt } from "../IgnoreWorkflowSystemPrompt"; function TextPromptNode({ id, data }: NodeProps) { const { editable, label } = data; @@ -108,6 +109,14 @@ function TextPromptNode({ id, data }: NodeProps) { }} suggestionContext={{ current_schema: data.jsonSchema }} /> + + { + update({ ignoreWorkflowSystemPrompt }); + }} + />
); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/types.ts index 8733e7f11..671cc8a74 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TextPromptNode/types.ts @@ -20,6 +20,7 @@ export const textPromptNodeDefaultData: TextPromptNodeData = { continueOnFailure: false, parameterKeys: [], model: null, + ignoreWorkflowSystemPrompt: false, } as const; export function isTextPromptNode(node: AppNode): node is TextPromptNode { diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowTriggerNode/WorkflowTriggerNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowTriggerNode/WorkflowTriggerNode.tsx index 10df329a6..33845fb57 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowTriggerNode/WorkflowTriggerNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowTriggerNode/WorkflowTriggerNode.tsx @@ -34,6 +34,7 @@ import { useRecordingStore } from "@/store/useRecordingStore"; import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; import { cn } from "@/util/utils"; import { BlockExecutionOptions } from "../components/BlockExecutionOptions"; +import { IgnoreWorkflowSystemPrompt } from "../IgnoreWorkflowSystemPrompt"; import { BrowserSessionSelector } from "./BrowserSessionSelector"; import { PARENT_SESSION_VALUE, @@ -337,6 +338,17 @@ function WorkflowTriggerNode({ id, data }: NodeProps) { update({ parameterKeys }); }} /> + { + update({ ignoreWorkflowSystemPrompt }); + }} + /> diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowTriggerNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowTriggerNode/types.ts index c1160bdae..31595b5a7 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowTriggerNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowTriggerNode/types.ts @@ -30,6 +30,7 @@ export const workflowTriggerNodeDefaultData: WorkflowTriggerNodeData = { browserSessionId: "", useParentBrowserSession: true, parameterKeys: [], + ignoreWorkflowSystemPrompt: false, }; export function isWorkflowTriggerNode(node: Node): node is WorkflowTriggerNode { diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts index e2d38f4c7..39949e368 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts @@ -12,6 +12,7 @@ export type NodeBaseData = { model: WorkflowModel | null; showCode?: boolean; comparisonColor?: string; + ignoreWorkflowSystemPrompt?: boolean; /** * Optional metadata used for conditional branches. * These values are only set on nodes that live within a conditional block. diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowComparisonPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowComparisonPanel.tsx index 064914af2..642572c67 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowComparisonPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowComparisonPanel.tsx @@ -165,6 +165,8 @@ function getWorkflowElements(version: WorkflowVersion) { runSequentially: version.run_sequentially ?? false, sequentialKey: version.sequential_key ?? null, finallyBlockLabel: version.workflow_definition?.finally_block_label ?? null, + workflowSystemPrompt: + version.workflow_definition?.workflow_system_prompt ?? null, }; // Deep clone the blocks to ensure complete isolation from main editor diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index 732dcdc5a..b03f5bd7d 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -548,6 +548,7 @@ function convertToNode( nextLoopOnFailure: block.next_loop_on_failure, editable, model: block.model, + ignoreWorkflowSystemPrompt: block.ignore_workflow_system_prompt ?? false, }; switch (block.block_type) { case "conditional": { @@ -1595,6 +1596,7 @@ function getElements( runSequentially: settings.runSequentially, sequentialKey: settings.sequentialKey, finallyBlockLabel: settings.finallyBlockLabel ?? null, + workflowSystemPrompt: settings.workflowSystemPrompt ?? null, }), ); @@ -2310,6 +2312,8 @@ function getWorkflowBlock( next_loop_on_failure: node.data.nextLoopOnFailure, model: node.data.model, next_block_label: nextBlockLabel, + ignore_workflow_system_prompt: + node.data.ignoreWorkflowSystemPrompt ?? false, }; switch (node.type) { case "task": { @@ -2924,6 +2928,7 @@ function getWorkflowSettings(nodes: Array): WorkflowSettings { runSequentially: false, sequentialKey: null, finallyBlockLabel: null, + workflowSystemPrompt: null, }; const startNodes = nodes.filter(isStartNode); const startNodeWithWorkflowSettings = startNodes.find( @@ -2951,6 +2956,7 @@ function getWorkflowSettings(nodes: Array): WorkflowSettings { runSequentially: data.runSequentially, sequentialKey: data.sequentialKey, finallyBlockLabel: data.finallyBlockLabel ?? null, + workflowSystemPrompt: data.workflowSystemPrompt ?? null, }; } return defaultSettings; @@ -3804,6 +3810,8 @@ function convertBlocksToBlockYAML( continue_on_failure: block.continue_on_failure, next_loop_on_failure: block.next_loop_on_failure, next_block_label: block.next_block_label, + ignore_workflow_system_prompt: + block.ignore_workflow_system_prompt ?? false, }; switch (block.block_type) { case "task": { @@ -4198,6 +4206,8 @@ function convert(workflow: WorkflowApiResponse): WorkflowCreateYAMLRequest { parameters: convertParametersToParameterYAML(userParameters), blocks: convertBlocksToBlockYAML(workflow.workflow_definition.blocks), finally_block_label: workflow.workflow_definition.finally_block_label, + workflow_system_prompt: + workflow.workflow_definition.workflow_system_prompt, }, is_saved_task: workflow.is_saved_task, status: workflow.status, diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts index 16ab4709e..676aad32a 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -292,6 +292,7 @@ export type WorkflowBlockBase = { next_loop_on_failure?: boolean; model: WorkflowModel | null; next_block_label?: string | null; + ignore_workflow_system_prompt?: boolean; }; export const BranchCriteriaTypes = { @@ -603,6 +604,7 @@ export type WorkflowDefinition = { parameters: Array; blocks: Array; finally_block_label?: string | null; + workflow_system_prompt?: string | null; }; export type WorkflowApiResponse = { @@ -652,6 +654,7 @@ export type WorkflowSettings = { runSequentially: boolean; sequentialKey: string | null; finallyBlockLabel: string | null; + workflowSystemPrompt: string | null; }; export type WorkflowModel = JsonObjectExtendable<{ model_name: string }>; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts index 72d91f417..54d3c1d27 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts @@ -29,6 +29,7 @@ export type WorkflowDefinitionYAML = { parameters: Array; blocks: Array; finally_block_label?: string | null; + workflow_system_prompt?: string | null; }; export type ParameterYAML = @@ -154,6 +155,7 @@ export type BlockYAMLBase = { continue_on_failure?: boolean; next_loop_on_failure?: boolean; next_block_label?: string | null; + ignore_workflow_system_prompt?: boolean; }; export type TaskBlockYAML = BlockYAMLBase & { diff --git a/skyvern-frontend/src/store/WorkflowHasChangesStore.ts b/skyvern-frontend/src/store/WorkflowHasChangesStore.ts index d344e7651..b13db6cf0 100644 --- a/skyvern-frontend/src/store/WorkflowHasChangesStore.ts +++ b/skyvern-frontend/src/store/WorkflowHasChangesStore.ts @@ -164,6 +164,8 @@ const useWorkflowSave = (opts?: WorkflowSaveOpts) => { parameters: saveData.parameters, blocks: saveData.blocks, finally_block_label: saveData.settings.finallyBlockLabel ?? undefined, + workflow_system_prompt: + saveData.settings.workflowSystemPrompt ?? undefined, }, is_saved_task: saveData.workflow.is_saved_task, status: opts?.status ?? saveData.workflow.status, diff --git a/skyvern/core/script_generations/real_skyvern_page_ai.py b/skyvern/core/script_generations/real_skyvern_page_ai.py index 3c8b4d847..a65d56cc4 100644 --- a/skyvern/core/script_generations/real_skyvern_page_ai.py +++ b/skyvern/core/script_generations/real_skyvern_page_ai.py @@ -11,7 +11,8 @@ from playwright.async_api import Page from skyvern.config import settings from skyvern.constants import SKYVERN_PAGE_MAX_SCRAPING_RETRIES, SPECIAL_FIELD_VERIFICATION_CODE -from skyvern.core.script_generations.skyvern_page_ai import SkyvernPageAi +from skyvern.core.script_generations.skyvern_page_ai import SYSTEM_PROMPT_UNSET, SkyvernPageAi +from skyvern.exceptions import WorkflowRunContextNotInitialized from skyvern.forge import app from skyvern.forge.prompts import prompt_engine from skyvern.forge.sdk.api.files import validate_download_url @@ -903,6 +904,7 @@ class RealSkyvernPageAi(SkyvernPageAi): data: str | dict[str, Any] | None = None, skip_refresh: bool = False, include_extracted_text: bool = True, + system_prompt: str | None | Any = SYSTEM_PROMPT_UNSET, ) -> dict[str, Any] | list | str | None: """Extract information from the page using AI.""" @@ -915,6 +917,42 @@ class RealSkyvernPageAi(SkyvernPageAi): prompt = _render_template_with_label(prompt, label=self.current_label) local_datetime_str = datetime.now(tz_info).isoformat() + # Resolve the effective workflow_system_prompt for this run. Order: + # 1. Caller-passed value wins (including None — "block opted out, + # send no system prompt"). + # 2. Block-recorded value from ``WorkflowRunContext``, populated by + # ``Block._apply_workflow_system_prompt`` in both the agent path + # (``format_potential_template_parameters``) and the script path + # (``_execute_single_block`` before ``exec``). Using the recorded + # value makes the block the single source of truth for the + # opt-out + resolved-string decision — script-path extractions + # hash to the same cache key and send the same LLM input the + # agent path would. A recorded ``None`` is a real opt-out, not a + # miss (SKY-9147). + # 3. Fall back to the run-wide effective prompt for non-block + # callers (standalone scripts, sdk routes, etc.) that never set + # ``current_label`` and never went through a Block. + workflow_system_prompt: str | None + if system_prompt is not SYSTEM_PROMPT_UNSET: + workflow_system_prompt = cast("str | None", system_prompt) + else: + workflow_system_prompt = None + workflow_run_context_for_prompt = None + if context and context.workflow_run_id: + try: + workflow_run_context_for_prompt = app.WORKFLOW_CONTEXT_MANAGER.get_workflow_run_context( + context.workflow_run_id + ) + except WorkflowRunContextNotInitialized: + workflow_run_context_for_prompt = None + + if workflow_run_context_for_prompt is not None: + recorded, value = workflow_run_context_for_prompt.get_block_workflow_system_prompt(self.current_label) + if recorded: + workflow_system_prompt = value + else: + workflow_system_prompt = workflow_run_context_for_prompt.resolve_effective_workflow_system_prompt() + # Render the prompt FIRST so the cache key hashes the exact string # that will be sent to the LLM (captures economy-tree swaps and 2/3 # truncation inside load_prompt_with_elements). @@ -981,6 +1019,7 @@ class RealSkyvernPageAi(SkyvernPageAi): extracted_information_schema=post_ceiling_kwargs["extracted_information_schema"], error_code_mapping=error_code_mapping_str, llm_key=None, + workflow_system_prompt=workflow_system_prompt, ) lookup_result = extraction_cache.lookup(workflow_run_id, cache_key) except Exception: @@ -1042,6 +1081,7 @@ class RealSkyvernPageAi(SkyvernPageAi): screenshots=self.scraped_page.screenshots, prompt_name="extract-information", force_dict=False, + system_prompt=workflow_system_prompt, ) # Validate and fill missing fields based on schema diff --git a/skyvern/core/script_generations/skyvern_page.py b/skyvern/core/script_generations/skyvern_page.py index fbf6dde99..af9947b20 100644 --- a/skyvern/core/script_generations/skyvern_page.py +++ b/skyvern/core/script_generations/skyvern_page.py @@ -1323,6 +1323,9 @@ class SkyvernPage(Page): """ data = kwargs.pop("data", None) skip_refresh = kwargs.pop("skip_refresh", False) + extra_kwargs: dict[str, Any] = {} + if "system_prompt" in kwargs: + extra_kwargs["system_prompt"] = kwargs.pop("system_prompt") return await self._ai.ai_extract( prompt=prompt, schema=schema, @@ -1330,6 +1333,7 @@ class SkyvernPage(Page): intention=intention, data=data, skip_refresh=skip_refresh, + **extra_kwargs, ) async def validate( diff --git a/skyvern/core/script_generations/skyvern_page_ai.py b/skyvern/core/script_generations/skyvern_page_ai.py index 716b35eea..b839fe0bd 100644 --- a/skyvern/core/script_generations/skyvern_page_ai.py +++ b/skyvern/core/script_generations/skyvern_page_ai.py @@ -4,6 +4,12 @@ from typing import Any, Protocol from skyvern.config import settings +# Sentinel for the optional ``system_prompt`` parameter on ``ai_extract``. +# Distinguishes "caller omitted the argument" (resolve from workflow context, +# honoring the current block's ``ignore_workflow_system_prompt`` flag) from +# "caller passed None" (opt out, send no system prompt). +SYSTEM_PROMPT_UNSET: Any = object() + class SkyvernPageAi(Protocol): """Protocol defining the interface for AI-powered page interactions.""" @@ -67,6 +73,7 @@ class SkyvernPageAi(Protocol): data: str | dict[str, Any] | None = None, skip_refresh: bool = False, include_extracted_text: bool = True, + system_prompt: str | None | Any = SYSTEM_PROMPT_UNSET, ) -> dict[str, Any] | list | str | None: """Extract information from the page using AI.""" ... diff --git a/skyvern/forge/agent.py b/skyvern/forge/agent.py index 827089490..ba58a2cd3 100644 --- a/skyvern/forge/agent.py +++ b/skyvern/forge/agent.py @@ -393,6 +393,7 @@ class ForgeAgent: retry=task_retry, max_steps_per_run=task_block.max_steps_per_run, error_code_mapping=task_block.error_code_mapping, + workflow_system_prompt=task_block.workflow_system_prompt, include_action_history_in_verification=task_block.include_action_history_in_verification, model=task_block.model, max_screenshot_scrolling_times=workflow_run.max_screenshot_scrolls, @@ -462,6 +463,7 @@ class ForgeAgent: proxy_location=task_request.proxy_location, extracted_information_schema=task_request.extracted_information_schema, error_code_mapping=task_request.error_code_mapping, + workflow_system_prompt=task_request.workflow_system_prompt, application=task_request.application, include_action_history_in_verification=task_request.include_action_history_in_verification, model=task_request.model, @@ -1311,6 +1313,7 @@ class ForgeAgent: prompt_name=prompt_name, step=step, screenshots=scraped_page.screenshots, + system_prompt=task.workflow_system_prompt, ) else: LOG.debug( @@ -1917,6 +1920,7 @@ class ForgeAgent: prompt_name="cua-answer-question", step=step, screenshots=scraped_page.screenshots, + system_prompt=task.workflow_system_prompt, ) LOG.info("Skyvern response to CUA question", skyvern_response=skyvern_response) resp_content = skyvern_response.get("answer") @@ -2172,6 +2176,7 @@ class ForgeAgent: prompt_name=prompt_name, step=next_step, screenshots=scraped_page.screenshots, + system_prompt=task.workflow_system_prompt, ) LOG.info( @@ -2499,6 +2504,7 @@ class ForgeAgent: step=step, screenshots=scraped_page_refreshed.screenshots, prompt_name=prompt_name, + system_prompt=task.workflow_system_prompt, ) result = CompleteVerifyResult.model_validate(verification_result) if result.is_complete: @@ -3286,7 +3292,9 @@ class ForgeAgent: llm_api_handler = LLMAPIHandlerFactory.get_override_llm_api_handler( task.llm_key, default=app.LLM_API_HANDLER ) - json_response = await llm_api_handler(prompt=prompt, step=step, prompt_name="infer-action-type") + json_response = await llm_api_handler( + prompt=prompt, step=step, prompt_name="infer-action-type", system_prompt=task.workflow_system_prompt + ) if json_response.get("error"): raise FailedToParseActionInstruction( reason=json_response.get("thought"), error_type=json_response.get("error") @@ -4736,7 +4744,11 @@ class ForgeAgent: local_datetime=datetime.now(skyvern_context.ensure_context().tz_info).isoformat(), ) json_response = await app.LLM_API_HANDLER( - prompt=prompt, screenshots=screenshots, step=step, prompt_name="summarize-max-steps-reason" + prompt=prompt, + screenshots=screenshots, + step=step, + prompt_name="summarize-max-steps-reason", + system_prompt=task.workflow_system_prompt, ) return MaxStepsReasonResponse.model_validate(json_response) except Exception: @@ -4879,6 +4891,7 @@ class ForgeAgent: screenshots=screenshots, step=step, prompt_name="summarize-max-retries-reason", + system_prompt=task.workflow_system_prompt, ) return MaxStepsReasonResponse.model_validate(json_response) except Exception: @@ -5256,6 +5269,7 @@ class ForgeAgent: step=step, screenshots=scraped_page.screenshots, prompt_name=prompt_name, + system_prompt=task.workflow_system_prompt, ) @staticmethod @@ -5323,6 +5337,7 @@ class ForgeAgent: data_extraction_goal=task.data_extraction_goal, extracted_information_schema=post_ceiling_kwargs["data_extraction_schema"], llm_key=None, + workflow_system_prompt=task.workflow_system_prompt, ) lookup_result = extraction_cache.lookup(workflow_run_id, cache_key) except Exception: @@ -5375,7 +5390,10 @@ class ForgeAgent: cache_path="agent", ) data_extraction_summary_resp = await app.EXTRACTION_LLM_API_HANDLER( - prompt=prompt, step=step, prompt_name="data-extraction-summary" + prompt=prompt, + step=step, + prompt_name="data-extraction-summary", + system_prompt=task.workflow_system_prompt, ) if cache_key and isinstance(data_extraction_summary_resp, dict): extraction_cache.store(workflow_run_id, cache_key, data_extraction_summary_resp) diff --git a/skyvern/forge/sdk/artifact/manager.py b/skyvern/forge/sdk/artifact/manager.py index 0af36fbdc..fd8c80144 100644 --- a/skyvern/forge/sdk/artifact/manager.py +++ b/skyvern/forge/sdk/artifact/manager.py @@ -272,6 +272,56 @@ class ArtifactManager: path=path, ) + async def create_download_artifact( + self, + *, + organization_id: str, + run_id: str, + uri: str, + filename: str, + workflow_run_id: str | None = None, + checksum: str | None = None, + ) -> str: + """Register a downloaded file as an Artifact row without re-uploading. + + The bytes already live at ``uri`` (the uploads bucket). We only record a + row so the file can be served through the signed ``/v1/artifacts/{id}/content`` + endpoint. + """ + # Idempotent on (run_id, uri): if a DOWNLOAD artifact already exists for the + # same physical file (e.g. a loop iteration re-uploads the same download dir), + # return the existing artifact_id so signed URLs stay stable across calls — + # otherwise ``loop_download_filter.to_downloaded_file_signature`` would treat + # every iteration's URL as new. + existing = await app.DATABASE.artifacts.find_download_artifact( + organization_id=organization_id, + run_id=run_id, + uri=uri, + ) + if existing is not None: + return existing.artifact_id + + artifact_id = generate_artifact_id() + context = skyvern_context.current() + if workflow_run_id is None and context is not None: + workflow_run_id = context.workflow_run_id + await app.DATABASE.artifacts.create_artifact( + artifact_id=artifact_id, + artifact_type=ArtifactType.DOWNLOAD, + uri=uri, + organization_id=organization_id, + run_id=run_id, + workflow_run_id=workflow_run_id, + checksum=checksum, + ) + LOG.debug( + "Registered downloaded file as artifact", + artifact_id=artifact_id, + run_id=run_id, + filename=filename, + ) + return artifact_id + async def create_thought_artifact( self, thought: Thought, @@ -836,6 +886,25 @@ class ArtifactManager: async def retrieve_artifact(self, artifact: Artifact) -> bytes | None: return await app.STORAGE.retrieve_artifact(artifact) + def build_signed_content_url( + self, + artifact_id: str, + artifact_name: str | None = None, + artifact_type: str | None = None, + ) -> str: + """Return a signed ``/v1/artifacts/{id}/content`` URL for any artifact. + + Non-bundled artifacts normally get a presigned S3 URL from + ``STORAGE.get_share_link``. This method always builds the Skyvern-origin + signed URL regardless of ``bundle_key`` — used for DOWNLOAD artifacts + so webhook payloads stay short and clients hit our origin. + """ + return self._bundle_content_url( + artifact_id=artifact_id, + artifact_name=artifact_name, + artifact_type=artifact_type, + ) + def _bundle_content_url( self, artifact_id: str, diff --git a/skyvern/forge/sdk/artifact/models.py b/skyvern/forge/sdk/artifact/models.py index 76c6d0ae4..e9161c3dc 100644 --- a/skyvern/forge/sdk/artifact/models.py +++ b/skyvern/forge/sdk/artifact/models.py @@ -61,6 +61,9 @@ class ArtifactType(StrEnum): # Task archive: one ZIP per task containing task-level cleanup artifacts (HAR, console log, trace, final screenshot) TASK_ARCHIVE = "task_archive" + # Files downloaded by the browser during a run (stored in the uploads bucket, not the artifacts bucket). + DOWNLOAD = "download" + class Artifact(BaseModel): created_at: datetime = Field( @@ -82,6 +85,7 @@ class Artifact(BaseModel): artifact_type: ArtifactType uri: str bundle_key: str | None = None + checksum: str | None = None task_id: str | None = None step_id: str | None = None workflow_run_id: str | None = None diff --git a/skyvern/forge/sdk/artifact/storage/azure.py b/skyvern/forge/sdk/artifact/storage/azure.py index 0923c4063..a869eb73a 100644 --- a/skyvern/forge/sdk/artifact/storage/azure.py +++ b/skyvern/forge/sdk/artifact/storage/azure.py @@ -8,6 +8,7 @@ import structlog from skyvern.config import settings from skyvern.constants import BROWSER_DOWNLOADING_SUFFIX, DOWNLOAD_FILE_PREFIX +from skyvern.forge import app from skyvern.forge.sdk.api.azure import AzureUri, StandardBlobTier from skyvern.forge.sdk.api.files import ( calculate_sha256_for_file, @@ -22,6 +23,7 @@ from skyvern.forge.sdk.artifact.models import Artifact, ArtifactType, LogEntityT from skyvern.forge.sdk.artifact.storage.base import ( FILE_EXTENTSION_MAP, BaseStorage, + _file_infos_from_download_artifacts, ) from skyvern.forge.sdk.models import Step from skyvern.forge.sdk.schemas.ai_suggestions import AISuggestion @@ -405,16 +407,84 @@ class AzureStorage(BaseStorage): organization_id=organization_id, storage_tier=tier, ) - # Upload file with checksum metadata - await self.async_client.upload_file_from_path( - uri=uri, - file_path=fpath, - metadata={"sha256_checksum": checksum, "original_filename": file}, - tier=tier, - tags=tags, - ) + # Azure Blob metadata values must be ASCII; preserve the full + # filename via the blob path / Artifact URI instead. + metadata: dict[str, str] = {"sha256_checksum": checksum} + if file.isascii(): + metadata["original_filename"] = file + # Catch upload failures so we never create an Artifact row for + # bytes that didn't actually land in storage. + try: + await self.async_client.upload_file_from_path( + uri=uri, + file_path=fpath, + metadata=metadata, + tier=tier, + tags=tags, + ) + except Exception: + LOG.warning( + "Skipping downloaded file — Azure upload failed", + file=file, + organization_id=organization_id, + run_id=run_id, + exc_info=True, + ) + continue + + # Register the file as an Artifact so GET run output can serve it via + # the signed /v1/artifacts/{id}/content endpoint (SKY-8861). Persist + # the SHA-256 we already computed so retrieval doesn't need an + # extra blob HEAD per file. + if run_id is not None: + try: + await app.ARTIFACT_MANAGER.create_download_artifact( + organization_id=organization_id, + run_id=run_id, + uri=uri, + filename=file, + checksum=checksum, + ) + except Exception: + LOG.warning( + "Failed to register downloaded file as artifact; falling back to SAS URLs for retrieval", + file=file, + organization_id=organization_id, + run_id=run_id, + exc_info=True, + ) async def get_downloaded_files(self, organization_id: str, run_id: str | None) -> list[FileInfo]: + # Artifact-first — see s3.py::get_downloaded_files for rationale. When + # the keyring isn't configured (OSS default) or no artifact rows exist + # (legacy run pre-SKY-8861) we fall back to the legacy listing path so + # downloaded files remain reachable. + if run_id is not None and settings.ARTIFACT_CONTENT_HMAC_KEYRING: + artifacts = await self._list_download_artifacts_safe(organization_id=organization_id, run_id=run_id) + if artifacts: + return _file_infos_from_download_artifacts(artifacts) + + return await self._get_downloaded_files_via_blob_listing(organization_id=organization_id, run_id=run_id) + + async def _list_download_artifacts_safe(self, *, organization_id: str, run_id: str) -> list[Artifact]: + try: + return await app.DATABASE.artifacts.list_artifacts_for_run_by_type( + run_id=run_id, + organization_id=organization_id, + artifact_type=ArtifactType.DOWNLOAD, + ) + except Exception: + LOG.warning( + "Failed to look up download artifacts; falling back to SAS URLs", + organization_id=organization_id, + run_id=run_id, + exc_info=True, + ) + return [] + + async def _get_downloaded_files_via_blob_listing( + self, *, organization_id: str, run_id: str | None + ) -> list[FileInfo]: uri = f"azure://{settings.AZURE_STORAGE_CONTAINER_UPLOADS}/{DOWNLOAD_FILE_PREFIX}/{settings.ENV}/{organization_id}/{run_id}" object_keys = await self.async_client.list_files(uri=uri) if len(object_keys) == 0: @@ -424,24 +494,22 @@ class AzureStorage(BaseStorage): for key in object_keys: object_uri = f"azure://{settings.AZURE_STORAGE_CONTAINER_UPLOADS}/{key}" - # Get metadata (including checksum) metadata = await self.async_client.get_file_metadata(object_uri, log_exception=False) - - # Create FileInfo object filename = os.path.basename(key) checksum = metadata.get("sha256_checksum") if metadata else None + display_name = metadata.get("original_filename", filename) if metadata else filename - # Get SAS URL sas_urls = await self.async_client.create_sas_urls([object_uri]) if not sas_urls: continue - file_info = FileInfo( - url=sas_urls[0], - checksum=checksum, - filename=metadata.get("original_filename", filename) if metadata else filename, + file_infos.append( + FileInfo( + url=sas_urls[0], + checksum=checksum, + filename=display_name, + ) ) - file_infos.append(file_info) return file_infos diff --git a/skyvern/forge/sdk/artifact/storage/base.py b/skyvern/forge/sdk/artifact/storage/base.py index 72f484402..4bbded38a 100644 --- a/skyvern/forge/sdk/artifact/storage/base.py +++ b/skyvern/forge/sdk/artifact/storage/base.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from typing import BinaryIO +from skyvern.forge import app from skyvern.forge.sdk.artifact.models import Artifact, ArtifactType, LogEntityType from skyvern.forge.sdk.models import Step from skyvern.forge.sdk.schemas.ai_suggestions import AISuggestion @@ -8,6 +9,33 @@ from skyvern.forge.sdk.schemas.files import FileInfo from skyvern.forge.sdk.schemas.task_v2 import TaskV2, Thought from skyvern.forge.sdk.schemas.workflow_runs import WorkflowRunBlock + +def _file_infos_from_download_artifacts(artifacts: list[Artifact]) -> list[FileInfo]: + """Build the API-shaped ``FileInfo`` list from DOWNLOAD artifact rows. + + Filename is the URI basename (the save site writes ``{base_uri}/{file}``); + checksum and modified_at come straight from the row, so retrieval needs + zero S3 round-trips. + """ + infos: list[FileInfo] = [] + for artifact in artifacts: + filename = artifact.uri.rsplit("/", 1)[-1] if artifact.uri else "" + url = app.ARTIFACT_MANAGER.build_signed_content_url( + artifact_id=artifact.artifact_id, + artifact_name=filename, + artifact_type=ArtifactType.DOWNLOAD.value, + ) + infos.append( + FileInfo( + url=url, + checksum=artifact.checksum, + filename=filename, + modified_at=artifact.created_at, + ) + ) + return infos + + # TODO: This should be a part of the ArtifactType model FILE_EXTENTSION_MAP: dict[ArtifactType, str] = { ArtifactType.RECORDING: "webm", diff --git a/skyvern/forge/sdk/artifact/storage/s3.py b/skyvern/forge/sdk/artifact/storage/s3.py index 51b934ea5..a0f5f28e7 100644 --- a/skyvern/forge/sdk/artifact/storage/s3.py +++ b/skyvern/forge/sdk/artifact/storage/s3.py @@ -11,6 +11,7 @@ import zstandard as zstd from skyvern.config import settings from skyvern.constants import BROWSER_DOWNLOADING_SUFFIX, DOWNLOAD_FILE_PREFIX +from skyvern.forge import app from skyvern.forge.sdk.api.aws import AsyncAWSClient, S3StorageClass, S3Uri from skyvern.forge.sdk.api.files import ( calculate_sha256_for_file, @@ -24,6 +25,7 @@ from skyvern.forge.sdk.artifact.models import Artifact, ArtifactType, LogEntityT from skyvern.forge.sdk.artifact.storage.base import ( FILE_EXTENTSION_MAP, BaseStorage, + _file_infos_from_download_artifacts, ) from skyvern.forge.sdk.models import Step from skyvern.forge.sdk.schemas.ai_suggestions import AISuggestion @@ -432,15 +434,91 @@ class S3Storage(BaseStorage): organization_id=organization_id, storage_class=storage_class, ) - # Upload file with checksum metadata - await self.async_client.upload_file_from_path( - uri=uri, - file_path=fpath, - metadata={"sha256_checksum": checksum, "original_filename": file}, - storage_class=storage_class, - ) + # S3 object metadata only allows ASCII; non-ASCII filenames (CJK, + # emoji) would otherwise raise ParamValidationError at upload time. + # The full filename is still preserved in the S3 key and on the + # Artifact row's URI. + metadata: dict[str, str] = {"sha256_checksum": checksum} + if file.isascii(): + metadata["original_filename"] = file + # Upload with raise_exception=True so a partial failure aborts + # this iteration and we never create an Artifact row for bytes + # that didn't actually land in S3. + try: + await self.async_client.upload_file_from_path( + uri=uri, + file_path=fpath, + metadata=metadata, + storage_class=storage_class, + raise_exception=True, + ) + except Exception: + LOG.warning( + "Skipping downloaded file — S3 upload failed", + file=file, + organization_id=organization_id, + run_id=run_id, + exc_info=True, + ) + continue + + # Register the file as an Artifact so GET run output can serve it via + # the signed /v1/artifacts/{id}/content endpoint (SKY-8861). Persist + # the SHA-256 we already computed so retrieval doesn't need an + # extra S3 HEAD per file. + if run_id is not None: + try: + await app.ARTIFACT_MANAGER.create_download_artifact( + organization_id=organization_id, + run_id=run_id, + uri=uri, + filename=file, + checksum=checksum, + ) + except Exception: + LOG.warning( + "Failed to register downloaded file as artifact; falling back to S3 listing for retrieval", + file=file, + organization_id=organization_id, + run_id=run_id, + exc_info=True, + ) async def get_downloaded_files(self, organization_id: str, run_id: str | None) -> list[FileInfo]: + # Artifact-first: when a run has DOWNLOAD artifact rows, return them as + # the source of truth — the row carries enough to build a short signed + # /v1/artifacts/{id}/content URL plus the SHA-256 we persisted at save + # time, so we skip the S3 LIST and per-file HEAD entirely (SKY-8861). + # + # If HMAC signing isn't configured (self-hosted OSS default), the signed + # endpoint requires an API key webhook consumers don't have, so we stay + # on the legacy S3-list+presign path even when rows exist. + if run_id is not None and settings.ARTIFACT_CONTENT_HMAC_KEYRING: + artifacts = await self._list_download_artifacts_safe(organization_id=organization_id, run_id=run_id) + if artifacts: + return _file_infos_from_download_artifacts(artifacts) + + # Legacy fallback — runs predating SKY-8861 (no artifact rows) and + # OSS-default deployments without HMAC signing both arrive here. + return await self._get_downloaded_files_via_s3_listing(organization_id=organization_id, run_id=run_id) + + async def _list_download_artifacts_safe(self, *, organization_id: str, run_id: str) -> list[Artifact]: + try: + return await app.DATABASE.artifacts.list_artifacts_for_run_by_type( + run_id=run_id, + organization_id=organization_id, + artifact_type=ArtifactType.DOWNLOAD, + ) + except Exception: + LOG.warning( + "Failed to look up download artifacts; falling back to presigned S3 URLs", + organization_id=organization_id, + run_id=run_id, + exc_info=True, + ) + return [] + + async def _get_downloaded_files_via_s3_listing(self, *, organization_id: str, run_id: str | None) -> list[FileInfo]: bucket = settings.AWS_S3_BUCKET_UPLOADS uri = f"s3://{bucket}/{DOWNLOAD_FILE_PREFIX}/{settings.ENV}/{organization_id}/{run_id}" object_keys = await self.async_client.list_files(uri=uri) @@ -451,25 +529,22 @@ class S3Storage(BaseStorage): for key in object_keys: object_uri = f"s3://{bucket}/{key}" - # Get metadata (including checksum) metadata = await self.async_client.get_file_metadata(object_uri, log_exception=False) - - # Create FileInfo object filename = os.path.basename(key) checksum = metadata.get("sha256_checksum") if metadata else None + display_name = metadata.get("original_filename", filename) if metadata else filename - # Get presigned URL presigned_urls = await self.async_client.create_presigned_urls([object_uri]) if not presigned_urls: continue - file_info = FileInfo( - url=presigned_urls[0], - checksum=checksum, - filename=metadata.get("original_filename", filename) if metadata else filename, + file_infos.append( + FileInfo( + url=presigned_urls[0], + checksum=checksum, + filename=display_name, + ) ) - file_infos.append(file_info) - return file_infos async def save_legacy_file( diff --git a/skyvern/forge/sdk/cache/extraction_cache.py b/skyvern/forge/sdk/cache/extraction_cache.py index 5333e5342..39c39cc36 100644 --- a/skyvern/forge/sdk/cache/extraction_cache.py +++ b/skyvern/forge/sdk/cache/extraction_cache.py @@ -40,6 +40,10 @@ Key derivation (shared with the cross-run tier): correctness if an intra-task second-step extraction happens. - llm_key — the caller's model override. Prevents stale hits when a user changes models to retune quality. + - workflow_system_prompt — the workflow's workflow_system_prompt (or None). + The prompt is sent to the LLM as the `system` message; changing it + changes the output even if all user-prompt inputs are identical, so two + calls that differ only in workflow_system_prompt must not collide. - Date is intentionally NOT in the key. Two calls on byte-identical page content are semantically the same extraction regardless of wall-clock date; relying on the content hash keeps hit rate up for scheduled @@ -353,6 +357,7 @@ def compute_cache_key( error_code_mapping: Any = None, previous_extracted_information: Any = None, llm_key: str | None = None, + workflow_system_prompt: str | None = None, ) -> str: """Return a stable sha256 hex digest for the inputs that affect extraction output. @@ -391,6 +396,7 @@ def compute_cache_key( _normalize(error_code_mapping), _normalize(previous_extracted_information), _s(llm_key), + _s(workflow_system_prompt), ] joined = "\x1f".join(parts).encode("utf-8", errors="replace") return hashlib.sha256(joined).hexdigest() diff --git a/skyvern/forge/sdk/db/models.py b/skyvern/forge/sdk/db/models.py index 5b4dbd1c8..87df33a87 100644 --- a/skyvern/forge/sdk/db/models.py +++ b/skyvern/forge/sdk/db/models.py @@ -103,6 +103,7 @@ class TaskModel(Base): order = Column(Integer, nullable=True) retry = Column(Integer, nullable=True) error_code_mapping = Column(JSON, nullable=True) + workflow_system_prompt = Column(UnicodeText, nullable=True) errors = Column(JSON, default=[], nullable=False) max_steps_per_run = Column(Integer, nullable=True) application = Column(String, nullable=True) @@ -248,6 +249,7 @@ class ArtifactModel(Base): uri = Column(String) bundle_key = Column(String, nullable=True) run_id = Column(String, nullable=True) + checksum = Column(String, nullable=True) created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) modified_at = Column( DateTime, @@ -426,6 +428,14 @@ class WorkflowRunModel(Base): verification_code_identifier = Column(String, nullable=True) verification_code_polling_started_at = Column(DateTime, nullable=True) failure_category = Column(JSON, nullable=True) + # When True, this run was spawned by a WorkflowTriggerBlock whose + # ignore_workflow_system_prompt flag was set, and the child must not + # inherit the parent chain's workflow_system_prompt. Set at spawn time so + # async (Temporal-dispatched) child runs can honor the flag even though + # they start in a separate worker without in-process context. + ignore_inherited_workflow_system_prompt = Column( + Boolean, nullable=False, default=False, server_default=sqlalchemy.false() + ) queued_at = Column(DateTime, nullable=True) started_at = Column(DateTime, nullable=True) @@ -864,6 +874,7 @@ class TaskV2Model(Base): proxy_location = Column(String, nullable=True) extracted_information_schema = Column(JSON, nullable=True) error_code_mapping = Column(JSON, nullable=True) + workflow_system_prompt = Column(UnicodeText, nullable=True) max_steps = Column(Integer, nullable=True) max_screenshot_scrolling_times = Column(Integer, nullable=True) extra_http_headers = Column(JSON, nullable=True) diff --git a/skyvern/forge/sdk/db/repositories/artifacts.py b/skyvern/forge/sdk/db/repositories/artifacts.py index b2b0b1083..d01f118f3 100644 --- a/skyvern/forge/sdk/db/repositories/artifacts.py +++ b/skyvern/forge/sdk/db/repositories/artifacts.py @@ -47,6 +47,7 @@ class ArtifactsRepository(BaseRepository): run_id: str | None = None, thought_id: str | None = None, ai_suggestion_id: str | None = None, + checksum: str | None = None, ) -> Artifact: async with self.Session() as session: new_artifact = ArtifactModel( @@ -62,6 +63,7 @@ class ArtifactsRepository(BaseRepository): run_id=run_id, ai_suggestion_id=ai_suggestion_id, organization_id=organization_id, + checksum=checksum, ) session.add(new_artifact) await session.commit() @@ -357,6 +359,60 @@ class ArtifactsRepository(BaseRepository): return convert_to_artifact(artifact, self.debug_enabled) return None + @db_operation("find_download_artifact") + async def find_download_artifact( + self, + organization_id: str, + run_id: str, + uri: str, + ) -> Artifact | None: + """Return the existing DOWNLOAD artifact for ``(run_id, uri)`` if any. + + Used by :meth:`ArtifactManager.create_download_artifact` to stay + idempotent: repeated saves of the same file in the same run (e.g. + within a loop block iteration) must reuse the existing artifact_id + so downstream URL-based dedup keeps seeing a stable URL. + """ + async with self.Session() as session: + artifact = ( + await session.scalars( + select(ArtifactModel) + .filter(ArtifactModel.run_id == run_id) + .filter(ArtifactModel.artifact_type == ArtifactType.DOWNLOAD) + .filter(ArtifactModel.organization_id == organization_id) + .filter(ArtifactModel.uri == uri) + .order_by(ArtifactModel.created_at.desc()) + ) + ).first() + if artifact: + return convert_to_artifact(artifact, self.debug_enabled) + return None + + @db_operation("list_artifacts_for_run_by_type") + async def list_artifacts_for_run_by_type( + self, + run_id: str, + organization_id: str, + artifact_type: ArtifactType, + ) -> list[Artifact]: + """List all artifacts for a run filtered by type, using the dedicated ``run_id`` column. + + Unlike :meth:`get_artifacts_for_run` this does not consult a ``RunReader`` — + it filters directly on the partial index ``ix_artifacts_run_id_partial`` and + returns the rows ordered by creation time. + """ + async with self.Session() as session: + artifacts = ( + await session.scalars( + select(ArtifactModel) + .filter(ArtifactModel.run_id == run_id) + .filter(ArtifactModel.artifact_type == artifact_type) + .filter(ArtifactModel.organization_id == organization_id) + .order_by(ArtifactModel.created_at) + ) + ).all() + return [convert_to_artifact(a, self.debug_enabled) for a in artifacts] + @db_operation("get_artifact_for_run") async def get_artifact_for_run( self, diff --git a/skyvern/forge/sdk/db/repositories/observer.py b/skyvern/forge/sdk/db/repositories/observer.py index d0cbc86e5..a90028d8f 100644 --- a/skyvern/forge/sdk/db/repositories/observer.py +++ b/skyvern/forge/sdk/db/repositories/observer.py @@ -28,6 +28,7 @@ from skyvern.forge.sdk.db.utils import ( ) from skyvern.forge.sdk.schemas.task_v2 import TaskV2, TaskV2Status, Thought, ThoughtType from skyvern.forge.sdk.schemas.workflow_runs import WorkflowRunBlock +from skyvern.forge.sdk.utils.sanitization import sanitize_postgres_text from skyvern.schemas.runs import ProxyLocationInput, RunEngine, ScriptRunResponse from skyvern.schemas.workflows import BlockStatus, BlockType @@ -196,12 +197,15 @@ class ObserverRepository(BaseRepository): webhook_callback_url: str | None = None, extracted_information_schema: dict | list | str | None = None, error_code_mapping: dict | None = None, + workflow_system_prompt: str | None = None, model: dict[str, Any] | None = None, max_screenshot_scrolling_times: int | None = None, extra_http_headers: dict[str, str] | None = None, browser_address: str | None = None, run_with: str | None = None, ) -> TaskV2: + if isinstance(workflow_system_prompt, str): + workflow_system_prompt = sanitize_postgres_text(workflow_system_prompt) async with self.Session() as session: new_task_v2 = TaskV2Model( workflow_run_id=workflow_run_id, @@ -215,6 +219,7 @@ class ObserverRepository(BaseRepository): webhook_callback_url=webhook_callback_url, extracted_information_schema=extracted_information_schema, error_code_mapping=error_code_mapping, + workflow_system_prompt=workflow_system_prompt, organization_id=organization_id, model=model, max_screenshot_scrolling_times=max_screenshot_scrolling_times, diff --git a/skyvern/forge/sdk/db/repositories/tasks.py b/skyvern/forge/sdk/db/repositories/tasks.py index eac12d56e..fc21c91c4 100644 --- a/skyvern/forge/sdk/db/repositories/tasks.py +++ b/skyvern/forge/sdk/db/repositories/tasks.py @@ -57,6 +57,7 @@ class TasksRepository(BaseRepository): retry: int | None = None, max_steps_per_run: int | None = None, error_code_mapping: dict[str, str] | None = None, + workflow_system_prompt: str | None = None, task_type: str = TaskType.general, application: str | None = None, include_action_history_in_verification: bool | None = None, @@ -79,6 +80,7 @@ class TasksRepository(BaseRepository): url = sanitize_postgres_text(url) complete_criterion = _sanitize(complete_criterion) terminate_criterion = _sanitize(terminate_criterion) + workflow_system_prompt = _sanitize(workflow_system_prompt) async with self.Session() as session: new_task = TaskModel( @@ -102,6 +104,7 @@ class TasksRepository(BaseRepository): retry=retry, max_steps_per_run=max_steps_per_run, error_code_mapping=error_code_mapping, + workflow_system_prompt=workflow_system_prompt, application=application, include_action_history_in_verification=include_action_history_in_verification, model=model, diff --git a/skyvern/forge/sdk/db/repositories/workflow_runs.py b/skyvern/forge/sdk/db/repositories/workflow_runs.py index 480b4b88c..1864b27c8 100644 --- a/skyvern/forge/sdk/db/repositories/workflow_runs.py +++ b/skyvern/forge/sdk/db/repositories/workflow_runs.py @@ -163,6 +163,7 @@ class WorkflowRunsRepository(BaseRepository): workflow_run_id: str | None = None, trigger_type: WorkflowRunTriggerType | None = None, workflow_schedule_id: str | None = None, + ignore_inherited_workflow_system_prompt: bool = False, ) -> WorkflowRun: async with self.Session() as session: kwargs: dict[str, Any] = {} @@ -190,6 +191,7 @@ class WorkflowRunsRepository(BaseRepository): code_gen=code_gen, trigger_type=trigger_type.value if trigger_type else None, workflow_schedule_id=workflow_schedule_id, + ignore_inherited_workflow_system_prompt=ignore_inherited_workflow_system_prompt, **kwargs, ) session.add(workflow_run) diff --git a/skyvern/forge/sdk/db/utils.py b/skyvern/forge/sdk/db/utils.py index eac4ed7ed..ae164c876 100644 --- a/skyvern/forge/sdk/db/utils.py +++ b/skyvern/forge/sdk/db/utils.py @@ -245,6 +245,7 @@ def convert_to_task(task_obj: TaskModel, debug_enabled: bool = False, workflow_p retry=task_obj.retry, max_steps_per_run=task_obj.max_steps_per_run, error_code_mapping=task_obj.error_code_mapping, + workflow_system_prompt=task_obj.workflow_system_prompt, errors=task_obj.errors, application=task_obj.application, model=task_obj.model, @@ -372,6 +373,7 @@ def convert_to_artifact(artifact_model: ArtifactModel, debug_enabled: bool = Fal artifact_type=ArtifactType[artifact_model.artifact_type.upper()], uri=artifact_model.uri, bundle_key=artifact_model.bundle_key, + checksum=artifact_model.checksum, task_id=artifact_model.task_id, step_id=artifact_model.step_id, workflow_run_id=artifact_model.workflow_run_id, @@ -488,6 +490,7 @@ def convert_to_workflow_run( trigger_type=_safe_trigger_type(workflow_run_model.trigger_type), workflow_schedule_id=workflow_run_model.workflow_schedule_id, failure_category=workflow_run_model.failure_category, + ignore_inherited_workflow_system_prompt=workflow_run_model.ignore_inherited_workflow_system_prompt, ) diff --git a/skyvern/forge/sdk/routes/agent_protocol.py b/skyvern/forge/sdk/routes/agent_protocol.py index f5098a0dd..46c516491 100644 --- a/skyvern/forge/sdk/routes/agent_protocol.py +++ b/skyvern/forge/sdk/routes/agent_protocol.py @@ -1,7 +1,9 @@ import asyncio import json +import unicodedata from enum import Enum from typing import Annotated, Any +from urllib.parse import quote import structlog import yaml @@ -1459,10 +1461,106 @@ _ARTIFACT_CONTENT_TYPES: dict[ArtifactType, str] = { ArtifactType.SCREENSHOT_ACTION: "image/png", ArtifactType.SCREENSHOT_FINAL: "image/png", ArtifactType.RECORDING: "video/webm", + ArtifactType.DOWNLOAD: "application/octet-stream", } _ARTIFACT_CONTENT_TYPE_DEFAULT = "application/json" +def _sanitize_header_filename(name: str) -> str: + """Strip characters that would break or inject into a Content-Disposition header. + + The artifact URI basename is derived from a user-controlled S3 key. Rejects: + + - C0 (<0x20), DEL (0x7F), C1 (0x80-0x9F) — RFC 7230 violations. + - ``"`` and ``\\`` — would terminate or escape the quoted value. + - Unicode *format* (Cf) and *separator-line/paragraph* (Zl/Zp) chars — + includes bidi overrides (U+202E), ZWSP (U+200B), ZWNBSP (U+FEFF); + these enable filename spoofing in the browser download UI. + """ + cleaned = [] + for ch in name: + code = ord(ch) + if code < 0x20 or code == 0x7F or 0x80 <= code <= 0x9F: + continue + if ch in ('"', "\\"): + continue + if unicodedata.category(ch) in {"Cf", "Zl", "Zp"}: + continue + cleaned.append(ch) + return "".join(cleaned) or "download" + + +def _ascii_fallback_filename(name: str) -> str: + """Best-effort ASCII form of ``name`` for the ``filename=`` parameter. + + NFKD-normalizes and drops combining marks first so accented Latin + characters survive as their base letters (``fïlè.pdf`` → ``file.pdf``) + instead of being stripped entirely. The RFC 5987 ``filename*=UTF-8''...`` + form still carries the full name for modern clients. + + If the ASCII stem ends up empty (e.g. pure CJK or emoji names), + prepend ``download`` so legacy clients don't save a bare ``.pdf`` + hidden file. + """ + normalized = unicodedata.normalize("NFKD", name) + ascii_only = normalized.encode("ascii", "ignore").decode("ascii") + sanitized = _sanitize_header_filename(ascii_only) + stem, dot, ext = sanitized.rpartition(".") + if dot and not stem: + return f"download.{ext}" + return sanitized + + +def _build_attachment_disposition(filename: str) -> str: + """Build a Content-Disposition header that survives non-ASCII filenames. + + Emits both ``filename=""`` (for legacy clients) and + ``filename*=UTF-8''`` (RFC 5987, for everything modern). + Ensures the header value is Latin-1 encodable so Starlette doesn't 500. + """ + safe = _sanitize_header_filename(filename) + ascii_part = _ascii_fallback_filename(safe) + encoded = quote(safe, safe="") + return f"attachment; filename=\"{ascii_part}\"; filename*=UTF-8''{encoded}" + + +def _artifact_filename_from_uri(uri: str | None) -> str: + """Extract the basename from an ``s3://``/``azure://`` URI without using + ``urlparse`` — that would split on ``?``/``#`` characters, which are legal + in S3 keys.""" + if not uri: + return "" + return uri.rsplit("/", 1)[-1] + + +def _artifact_response_config(artifact: Artifact) -> tuple[str, str]: + """Return (media_type, Content-Disposition) for the artifact content response. + + DOWNLOAD artifacts use ``attachment`` disposition with the sanitized filename + so browsers never render user-supplied content inline (SKY-8862). All other + types keep the historical ``inline`` behaviour. + """ + media_type = _ARTIFACT_CONTENT_TYPES.get(artifact.artifact_type, _ARTIFACT_CONTENT_TYPE_DEFAULT) + if artifact.artifact_type == ArtifactType.DOWNLOAD: + raw_name = _artifact_filename_from_uri(artifact.uri) + return media_type, _build_attachment_disposition(raw_name) + return media_type, "inline" + + +def _artifact_content_response_headers(*, disposition: str, is_signed: bool) -> dict[str, str]: + """Response headers for the artifact content endpoint. + + Includes ``X-Content-Type-Options: nosniff`` as defence-in-depth for + SKY-8862: even if something upstream strips the attachment disposition, + the browser will not sniff the octet-stream body back into HTML/PDF. + """ + return { + "Content-Disposition": disposition, + "Cache-Control": f"private, max-age={ARTIFACT_URL_EXPIRY_SECONDS}" if is_signed else "private, no-cache", + "X-Content-Type-Options": "nosniff", + } + + @base_router.get( "/artifacts/{artifact_id}/content", tags=["Artifacts"], @@ -1529,16 +1627,15 @@ async def get_artifact_content( status_code=http_status.HTTP_404_NOT_FOUND, detail="Artifact content not available", ) - media_type = _ARTIFACT_CONTENT_TYPES.get(artifact.artifact_type, _ARTIFACT_CONTENT_TYPE_DEFAULT) + media_type, content_disposition = _artifact_response_config(artifact) is_signed = sig is not None and expiry is not None and kid is not None - cache_control = f"private, max-age={ARTIFACT_URL_EXPIRY_SECONDS}" if is_signed else "private, no-cache" return Response( content=content, media_type=media_type, - headers={ - "Content-Disposition": "inline", - "Cache-Control": cache_control, - }, + headers=_artifact_content_response_headers( + disposition=content_disposition, + is_signed=is_signed, + ), ) diff --git a/skyvern/forge/sdk/schemas/task_v2.py b/skyvern/forge/sdk/schemas/task_v2.py index 9e4de0449..c839c752b 100644 --- a/skyvern/forge/sdk/schemas/task_v2.py +++ b/skyvern/forge/sdk/schemas/task_v2.py @@ -46,6 +46,7 @@ class TaskV2(BaseModel): webhook_failure_reason: str | None = None extracted_information_schema: dict | list | str | None = None error_code_mapping: dict | None = None + workflow_system_prompt: str | None = None model: dict[str, Any] | None = None queued_at: datetime | None = None started_at: datetime | None = None @@ -203,6 +204,7 @@ class TaskV2Request(BaseModel): publish_workflow: bool = False extracted_information_schema: dict | list | str | None = None error_code_mapping: dict[str, str] | None = None + workflow_system_prompt: str | None = None max_screenshot_scrolls: int | None = None extra_http_headers: dict[str, str] | None = None browser_address: str | None = None diff --git a/skyvern/forge/sdk/schemas/tasks.py b/skyvern/forge/sdk/schemas/tasks.py index 68670bd31..a3009bc95 100644 --- a/skyvern/forge/sdk/schemas/tasks.py +++ b/skyvern/forge/sdk/schemas/tasks.py @@ -69,6 +69,11 @@ class TaskBase(BaseModel): } ], ) + workflow_system_prompt: str | None = Field( + default=None, + description="System prompt applied to every LLM call for this task. Inherited from the workflow-level workflow_system_prompt when unset.", + examples=["Never guess at an answer. If unsure, respond with UNKNOWN."], + ) proxy_location: ProxyLocationInput = Field( default=None, description=PROXY_LOCATION_DOC_STRING, diff --git a/skyvern/forge/sdk/workflow/context_manager.py b/skyvern/forge/sdk/workflow/context_manager.py index b1d277379..e7d735944 100644 --- a/skyvern/forge/sdk/workflow/context_manager.py +++ b/skyvern/forge/sdk/workflow/context_manager.py @@ -1,4 +1,5 @@ import copy +from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Awaitable, Callable, Self import structlog @@ -26,7 +27,7 @@ from skyvern.forge.sdk.schemas.organizations import Organization from skyvern.forge.sdk.schemas.tasks import TaskStatus from skyvern.forge.sdk.services.bitwarden import BitwardenConstants, BitwardenService from skyvern.forge.sdk.services.credentials import AzureVaultConstants, OnePasswordConstants, parse_totp_secret -from skyvern.forge.sdk.workflow.exceptions import OutputParameterKeyCollisionError +from skyvern.forge.sdk.workflow.exceptions import MissingJinjaVariables, OutputParameterKeyCollisionError from skyvern.forge.sdk.workflow.models.parameter import ( PARAMETER_TYPE, AWSSecretParameter, @@ -45,6 +46,7 @@ from skyvern.forge.sdk.workflow.models.parameter import ( WorkflowParameterType, ) from skyvern.utils.strings import generate_random_string +from skyvern.utils.templating import get_missing_variables if TYPE_CHECKING: from skyvern.forge.sdk.workflow.models.workflow import Workflow, WorkflowRunParameter @@ -83,6 +85,7 @@ class WorkflowRunContext: ], block_outputs: dict[str, Any] | None = None, workflow: "Workflow | None" = None, + inherited_workflow_system_prompt: str | None = None, ) -> Self: # key is label name workflow_run_context = cls( @@ -92,6 +95,7 @@ class WorkflowRunContext: workflow_run_id=workflow_run_id, aws_client=aws_client, workflow=workflow, + inherited_workflow_system_prompt=inherited_workflow_system_prompt, ) workflow_run_context.organization_id = organization.organization_id @@ -170,12 +174,34 @@ class WorkflowRunContext: workflow_run_id: str, aws_client: AsyncAWSClient, workflow: "Workflow | None" = None, + inherited_workflow_system_prompt: str | None = None, ) -> None: self.workflow_title = workflow_title self.workflow_id = workflow_id self.workflow_permanent_id = workflow_permanent_id self.workflow_run_id = workflow_run_id self.workflow = workflow + # Joined raw workflow_system_prompt(s) from ancestor workflows (outermost + # first) collected by walking workflow_run.parent_workflow_run_id at + # execute_workflow time. Jinja-rendered on demand and concatenated with + # this workflow's own workflow_system_prompt inside + # resolve_effective_workflow_system_prompt so parent-workflow rules flow + # into every child block and agent (SKY-9147). + self.inherited_workflow_system_prompt = inherited_workflow_system_prompt + # Sentinel for the lazy-resolved effective workflow_system_prompt cache. + # Using a sentinel (not None) so "resolved to None" is distinguishable + # from "not yet resolved". Invalidated by set_workflow() because late + # hydration can change the workflow's own workflow_system_prompt. + self._effective_workflow_system_prompt_cache: str | None = None + self._effective_workflow_system_prompt_resolved: bool = False + # Per-block record of the effective workflow_system_prompt once a block + # has run through ``Block._apply_workflow_system_prompt``. Keyed by + # block label. ``None`` is a valid recorded value (block opted out). + # Read by the script path (``RealSkyvernPageAi.ai_extract``) so a + # cached-script extraction uses the same string the agent path would + # — single source of truth, no re-resolving the opt-out from the + # workflow definition in two places (SKY-9147). + self._block_workflow_system_prompts: dict[str, str | None] = {} self.blocks_metadata: dict[str, BlockMetadata] = {} self.parameters: dict[str, PARAMETER_TYPE] = {} self.values: dict[str, Any] = {} @@ -193,6 +219,12 @@ class WorkflowRunContext: This is used when the workflow is fetched from the database as a fallback. """ self.workflow = workflow + # Late-hydrated workflow may carry a different workflow_system_prompt than + # what was visible at construction time. Drop the cache so the next + # resolve_effective_workflow_system_prompt() re-renders against the new + # definition. + self._effective_workflow_system_prompt_resolved = False + self._effective_workflow_system_prompt_cache = None def get_parameter(self, key: str) -> Parameter: return self.parameters[key] @@ -225,6 +257,108 @@ class WorkflowRunContext: label = "" return self.blocks_metadata.get(label, BlockMetadata()) + def record_block_workflow_system_prompt(self, label: str, value: str | None) -> None: + """Record the effective ``workflow_system_prompt`` a block resolved to. + + Called by ``Block._apply_workflow_system_prompt`` (agent path) and by + the script-path dispatch before handing execution to cached code. Both + paths use the same recorded value in ``ai_extract`` so agent and + script extractions for the same block hash to the same cache key and + the same LLM input. + """ + if label: + self._block_workflow_system_prompts[label] = value + + def get_block_workflow_system_prompt(self, label: str | None) -> tuple[bool, str | None]: + """Return ``(recorded, value)`` for a block label. + + ``recorded`` is True only when the block has actually run through + ``_apply_workflow_system_prompt`` — a recorded ``None`` (opt-out) is + distinguished from "never recorded" so callers can fall back safely + for non-block invocations (e.g. standalone scripts). + """ + if label and label in self._block_workflow_system_prompts: + return True, self._block_workflow_system_prompts[label] + return False, None + + def resolve_effective_workflow_system_prompt(self) -> str | None: + """Return the effective workflow-level system prompt for this run. + + Concatenates any prompt inherited from ancestor workflows (propagated via + ``WorkflowTriggerBlock`` — outermost first) with this workflow's own + ``workflow_system_prompt``. Jinja substitutions are rendered against this + run's values for both portions so ancestor templates can still reference + common variables like ``workflow_title``; placeholders that only exist in + the parent's context render empty under non-strict mode. Parts join with + a blank line so distinct rule sets stay readable to the LLM. Returns + ``None`` when nothing is configured at any level so callers can short- + circuit on a simple falsy check. + + The resolved string is cached on first call and reused for the life of + the run so every block sees the same effective prompt — and the LLM + cache keys that derive from it stay stable across blocks. The cache is + invalidated in ``set_workflow`` for the late-hydration path. + """ + if self._effective_workflow_system_prompt_resolved: + return self._effective_workflow_system_prompt_cache + own_raw: str | None = None + if self.workflow is not None and self.workflow.workflow_definition is not None: + candidate = self.workflow.workflow_definition.workflow_system_prompt + # ``isinstance`` guard: a malformed workflow definition (or a test + # MagicMock whose attribute access returns another mock) could + # hand us a non-string here. Jinja's ``from_string`` would then + # raise ``Can't compile non template nodes`` deep inside the + # render path. Narrowing to ``str`` keeps the fallback silent. + if isinstance(candidate, str): + own_raw = candidate + inherited = ( + self.inherited_workflow_system_prompt if isinstance(self.inherited_workflow_system_prompt, str) else None + ) + inherited_resolved = self.render_workflow_level_template(inherited) if inherited else None + own_resolved = self.render_workflow_level_template(own_raw) if own_raw else None + parts = [p for p in (inherited_resolved, own_resolved) if p] + resolved = "\n\n".join(parts) if parts else None + self._effective_workflow_system_prompt_cache = resolved + self._effective_workflow_system_prompt_resolved = True + return resolved + + def render_workflow_level_template(self, raw_template: str) -> str: + """Render a Jinja template against workflow-scoped variables only. + + Shared by every path that resolves the workflow-level workflow_system_prompt + (block execution, script-path ai_extract) so both produce the same string — + same cache key, same LLM output. Deliberately omits block-scoped context: + a workflow-wide prompt has no single "current block" to bind against. + """ + if not raw_template: + return raw_template + + template_data: dict[str, Any] = self.values.copy() + template_data.setdefault("workflow_title", self.workflow_title) + template_data.setdefault("workflow_id", self.workflow_id) + template_data.setdefault("workflow_permanent_id", self.workflow_permanent_id) + template_data.setdefault("workflow_run_id", self.workflow_run_id) + template_data.setdefault("browser_session_id", self.browser_session_id or "") + template_data.setdefault("current_date", datetime.now(timezone.utc).strftime("%Y-%m-%d")) + template_data["workflow_run_outputs"] = self.workflow_run_outputs + template_data["workflow_run_summary"] = self.build_workflow_run_summary() + + if missing_variables := get_missing_variables(raw_template, template_data): + if settings.WORKFLOW_TEMPLATING_STRICTNESS == "strict": + raise MissingJinjaVariables(template=raw_template, variables=missing_variables) + # Non-strict mode silently renders undefined variables as empty strings, + # which makes typos like {{ persona }} invisible to the user. Emit a + # warning so the operator has a breadcrumb when a workflow_system_prompt + # isn't picking up the value they expected. + LOG.warning( + "Undefined Jinja variables in workflow-level template; rendering them as empty strings", + missing_variables=missing_variables, + workflow_run_id=self.workflow_run_id, + workflow_permanent_id=self.workflow_permanent_id, + ) + + return jinja_sandbox_env.from_string(raw_template).render(template_data) + async def _should_include_secrets_in_templates(self) -> bool: """ Check if secrets should be included in template formatting based on experimentation provider. @@ -1266,6 +1400,7 @@ class WorkflowContextManager: ], block_outputs: dict[str, Any] | None = None, workflow: "Workflow | None" = None, + inherited_workflow_system_prompt: str | None = None, ) -> WorkflowRunContext: workflow_run_context = await WorkflowRunContext.init( self.aws_client, @@ -1280,6 +1415,7 @@ class WorkflowContextManager: secret_parameters, block_outputs, workflow, + inherited_workflow_system_prompt=inherited_workflow_system_prompt, ) self.workflow_run_contexts[workflow_run_id] = workflow_run_context return workflow_run_context diff --git a/skyvern/forge/sdk/workflow/models/block.py b/skyvern/forge/sdk/workflow/models/block.py index 9a1eb9282..9687c9e90 100644 --- a/skyvern/forge/sdk/workflow/models/block.py +++ b/skyvern/forge/sdk/workflow/models/block.py @@ -261,6 +261,19 @@ class Block(BaseModel, abc.ABC): continue_on_failure: bool = False model: dict[str, Any] | None = None disable_cache: bool = False + # Opt-out from workflow-level workflow_system_prompt inheritance (and, on a + # WorkflowTriggerBlock, from propagating the parent chain's prompt into the + # spawned child run). A no-op for deterministic blocks that don't call an LLM. + ignore_workflow_system_prompt: bool = False + # Runtime cache populated by ``Block._apply_workflow_system_prompt`` — not + # user-settable. Excluded from serialization (``model_dump`` / JSON / API + # responses) so the resolved prompt doesn't leak into logs, workflow + # definition round-trips, or responses that weren't meant to carry it. + # Deliberately absent from the BlockYAML schema so it can never be set + # through YAML or the API. The user-facing opt-out is + # ``ignore_workflow_system_prompt``. Only consumed by block types that call + # an LLM; deterministic blocks ignore it. + workflow_system_prompt: str | None = Field(default=None, exclude=True) # Only valid for blocks inside a for loop block # Whether to continue to the next iteration when the block fails @@ -517,6 +530,42 @@ class Block(BaseModel, abc.ABC): return template.render(template_data) + def _apply_workflow_system_prompt( + self, + workflow_run_context: WorkflowRunContext, + ) -> None: + """Resolve the workflow-level ``workflow_system_prompt`` for this block and + materialize it onto ``self.workflow_system_prompt``. + + Concatenates any prompt inherited from ancestor workflows (propagated through + ``WorkflowTriggerBlock``) with this workflow's own ``workflow_system_prompt``. + Jinja substitutions on this workflow's own prompt are resolved against + ``workflow_run_context``; the inherited portion is already resolved at the + trigger boundary. + + Shared by every block type that needs to inherit the workflow system prompt + into its own ``workflow_system_prompt`` runtime cache before dispatching an + LLM call. Callers invoke this inside ``format_potential_template_parameters`` + so the value is available at execute time. ``workflow_system_prompt`` on each + block is a runtime cache — it's deliberately absent from the BlockYAML schema + and not user-settable. + + When a block opts out via ``ignore_workflow_system_prompt``, this leaves + the block's own ``workflow_system_prompt`` untouched (falling back to the + system default if none is set). The opt-out covers both this workflow's + prompt and any inherited prompt from parent workflows. + """ + if self.ignore_workflow_system_prompt: + # Record the opt-out so the script path (``ai_extract``) reads the + # same decision instead of re-resolving the flag from the + # definition. See ``WorkflowRunContext.record_block_workflow_system_prompt``. + workflow_run_context.record_block_workflow_system_prompt(self.label, None) + return + resolved = workflow_run_context.resolve_effective_workflow_system_prompt() + if resolved is not None: + self.workflow_system_prompt = resolved + workflow_run_context.record_block_workflow_system_prompt(self.label, resolved) + @classmethod def get_subclasses(cls) -> tuple[type[Block], ...]: return tuple(cls.__subclasses__()) @@ -809,6 +858,10 @@ class BaseTaskBlock(Block): for error_code, error_description in merged_mapping.items() } + # Materialize the workflow-level workflow_system_prompt onto this block so + # ForgeAgent.create_task can hand it off to the Task row verbatim. + self._apply_workflow_system_prompt(workflow_run_context) + @staticmethod async def get_task_order(workflow_run_id: str, current_retry: int) -> tuple[int, int]: """ @@ -2787,6 +2840,8 @@ class TextPromptBlock(Block): if self.json_schema: self.json_schema = self._render_schema_templates(self.json_schema, workflow_run_context) + self._apply_workflow_system_prompt(workflow_run_context) + async def send_prompt( self, prompt: str, @@ -2839,6 +2894,7 @@ class TextPromptBlock(Block): response = await llm_api_handler( prompt=prompt, prompt_name="text-prompt", + system_prompt=self.workflow_system_prompt, workflow_run_block_id=workflow_run_block_id, organization_id=organization_id, ) @@ -3786,6 +3842,8 @@ class FileParserBlock(Block): self.file_url, workflow_run_context ) + self._apply_workflow_system_prompt(workflow_run_context) + def _detect_file_type_from_url(self, file_url: str, file_path: str | None = None) -> FileType: """Detect file type based on file extension in the URL, with magic-byte fallback.""" url_parsed = urlparse(file_url) @@ -4024,6 +4082,8 @@ class FileParserBlock(Block): llm_api_handler = LLMAPIHandlerFactory.get_override_llm_api_handler( self.override_llm_key, default=app.LLM_API_HANDLER ) + # OCR transcription intentionally skips system_prompt + # It still applies to the downstream extract-information-from-file-text call. llm_response = await llm_api_handler( prompt=llm_prompt, prompt_name="extract-text-from-image", @@ -4055,6 +4115,8 @@ class FileParserBlock(Block): llm_api_handler = LLMAPIHandlerFactory.get_override_llm_api_handler( self.override_llm_key, default=app.LLM_API_HANDLER ) + # OCR transcription intentionally skips system_prompt — see + # _parse_pdf_file_with_vision_ocr for rationale. llm_response = await llm_api_handler( prompt=llm_prompt, prompt_name="extract-text-from-image", @@ -4175,6 +4237,7 @@ class FileParserBlock(Block): prompt=llm_prompt, prompt_name="extract-information-from-file-text", force_dict=False, + system_prompt=self.workflow_system_prompt, workflow_run_block_id=workflow_run_block_id, organization_id=organization_id, ) @@ -4348,6 +4411,8 @@ class PDFParserBlock(Block): self.file_url, workflow_run_context ) + self._apply_workflow_system_prompt(workflow_run_context) + async def execute( self, workflow_run_id: str, @@ -4416,6 +4481,7 @@ class PDFParserBlock(Block): prompt=llm_prompt, prompt_name="extract-information-from-file-text", force_dict=False, + system_prompt=self.workflow_system_prompt, workflow_run_block_id=workflow_run_block_id, organization_id=organization_id, ) @@ -4837,6 +4903,10 @@ class TaskV2Block(Block): ) self.totp_verification_url = prepend_scheme_and_validate_url(self.totp_verification_url) + # Materialize the workflow-level workflow_system_prompt onto this block so + # execute() can hand it off to the TaskV2 row verbatim. + self._apply_workflow_system_prompt(workflow_run_context) + async def execute( self, workflow_run_id: str, @@ -4912,6 +4982,7 @@ class TaskV2Block(Block): totp_identifier=resolved_totp_identifier, totp_verification_url=resolved_totp_verification_url, max_screenshot_scrolling_times=workflow_run.max_screenshot_scrolls, + workflow_system_prompt=self.workflow_system_prompt, ) await app.DATABASE.observer.update_task_v2( task_v2.observer_cruise_id, status=TaskV2Status.queued, organization_id=organization_id @@ -7091,6 +7162,7 @@ class WorkflowTriggerBlock(Block): workflow_permanent_id=resolved_workflow_permanent_id, organization=organization, parent_workflow_run_id=workflow_run_id, + ignore_inherited_workflow_system_prompt=self.ignore_workflow_system_prompt, ) except Exception as e: error_msg = get_user_facing_exception_message(e) @@ -7106,6 +7178,9 @@ class WorkflowTriggerBlock(Block): ) try: + # The opt-out flag is persisted on the child's workflow_run row at + # spawn time (setup_workflow_run above), so execute_workflow reads + # it from the DB. This works identically for sync and async triggers. final_run = await app.WORKFLOW_SERVICE.execute_workflow( workflow_run_id=triggered_run_id, api_key=None, @@ -7173,6 +7248,12 @@ class WorkflowTriggerBlock(Block): browser_session_id=resolved_browser_session_id, ) try: + # ``run_workflow`` persists this flag to the child's + # workflow_run row via its internal setup_workflow_run call, + # then dispatches to Temporal without passing the flag + # separately; the worker reads it back from the DB inside + # ``execute_workflow``. Symmetric with the sync branch above + # — the flag is written once, at spawn time, for both paths. triggered_workflow_run = await run_workflow( workflow_id=resolved_workflow_permanent_id, organization=organization, @@ -7180,6 +7261,7 @@ class WorkflowTriggerBlock(Block): request=None, background_tasks=None, parent_workflow_run_id=workflow_run_id, + ignore_inherited_workflow_system_prompt=self.ignore_workflow_system_prompt, ) except Exception as e: error_msg = get_user_facing_exception_message(e) diff --git a/skyvern/forge/sdk/workflow/models/workflow.py b/skyvern/forge/sdk/workflow/models/workflow.py index 3d80d677b..361e59a0b 100644 --- a/skyvern/forge/sdk/workflow/models/workflow.py +++ b/skyvern/forge/sdk/workflow/models/workflow.py @@ -56,6 +56,7 @@ class WorkflowDefinition(BaseModel): blocks: List[BlockTypeVar] finally_block_label: str | None = None error_code_mapping: dict[str, str] | None = None + workflow_system_prompt: str | None = None def validate(self) -> None: all_labels: set[str] = set() @@ -199,6 +200,7 @@ class WorkflowRun(BaseModel): code_gen: bool | None = None trigger_type: WorkflowRunTriggerType | None = None workflow_schedule_id: str | None = None + ignore_inherited_workflow_system_prompt: bool = False @field_validator("run_with", mode="before") @classmethod diff --git a/skyvern/forge/sdk/workflow/service.py b/skyvern/forge/sdk/workflow/service.py index ec55caff7..439563597 100644 --- a/skyvern/forge/sdk/workflow/service.py +++ b/skyvern/forge/sdk/workflow/service.py @@ -80,6 +80,7 @@ from skyvern.forge.sdk.workflow.models.block import ( ForLoopBlock, NavigationBlock, TaskV2Block, + WorkflowTriggerBlock, compute_conditional_scopes, get_all_blocks, ) @@ -649,6 +650,7 @@ class WorkflowService: workflow_run_id: str | None = None, trigger_type: WorkflowRunTriggerType | None = None, workflow_schedule_id: str | None = None, + ignore_inherited_workflow_system_prompt: bool = False, ) -> WorkflowRun: """ Create a workflow run and its parameters. Validate the workflow and the organization. If there are missing @@ -708,6 +710,7 @@ class WorkflowService: workflow_run_id=workflow_run_id, trigger_type=trigger_type, workflow_schedule_id=workflow_schedule_id, + ignore_inherited_workflow_system_prompt=ignore_inherited_workflow_system_prompt, ) LOG.info( f"Created workflow run {workflow_run.workflow_run_id} for workflow {workflow.workflow_id}", @@ -940,6 +943,79 @@ class WorkflowService: return None + async def _collect_inherited_workflow_system_prompt( + self, + parent_workflow_run_id: str | None, + ) -> str | None: + """Walk up the parent workflow-run chain and join each ancestor's raw + ``workflow_system_prompt`` (outermost first). Returns None when no ancestor + has one set. A depth cap matches ``WorkflowTriggerBlock.MAX_TRIGGER_DEPTH`` + to keep the traversal bounded against malformed chains. + + This reads raw prompt strings from each ancestor's ``workflow_definition`` + without Jinja rendering — the child's context will render them later via + ``WorkflowRunContext.resolve_effective_workflow_system_prompt``. Using raw + strings here avoids depending on the parent's live ``WorkflowRunContext``, + which isn't available for async/fire-and-forget child runs. + + Chain-break on opt-out: when an ancestor has ``ignore_inherited_workflow_system_prompt`` + set, its own prompt is still included (it ran without its own ancestors' + prompts, but its own rules remain its statement to descendants), but the + traversal stops there. A workflow explicitly opting out of its parents' + rules creates a clean boundary for itself and everything it triggers — + otherwise descendants would silently reintroduce prompts the opted-out + workflow rejected. + """ + # Two-phase walk to keep DB round trips bounded. Phase 1 is an + # inherently sequential chain walk (each ``parent_workflow_run_id`` is + # only known after fetching the previous run), capped at + # ``MAX_TRIGGER_DEPTH``. Phase 2 batches the independent workflow- + # definition fetches with ``asyncio.gather`` so all N definition + # lookups happen in one concurrent burst instead of N sequential + # awaits — brings the worst case from 2N round trips down to + # N + 1 (depth-bounded at 10). A deeper optimization (single + # recursive CTE across workflow_runs + workflows) is possible if + # trigger chains ever get deep enough to matter. + chain: list[tuple[str, bool]] = [] # [(workflow_id, ignore_inherited), ...] outermost child first + current_parent_id: str | None = parent_workflow_run_id + visited: set[str] = set() + depth = 0 + while current_parent_id and depth < WorkflowTriggerBlock.MAX_TRIGGER_DEPTH: + if current_parent_id in visited: + break + visited.add(current_parent_id) + parent_run = await app.DATABASE.workflow_runs.get_workflow_run(current_parent_id) + if parent_run is None: + break + chain.append((parent_run.workflow_id, parent_run.ignore_inherited_workflow_system_prompt)) + if parent_run.ignore_inherited_workflow_system_prompt: + break + current_parent_id = parent_run.parent_workflow_run_id + depth += 1 + + if not chain: + return None + + # Fetch all ancestor workflow definitions concurrently. + ancestor_workflows = await asyncio.gather( + *(self.get_workflow(workflow_id=workflow_id) for workflow_id, _ in chain), + return_exceptions=False, + ) + + prompts: list[str] = [] + for workflow in ancestor_workflows: + if workflow is None or workflow.workflow_definition is None: + continue + raw = workflow.workflow_definition.workflow_system_prompt + if raw: + prompts.append(raw) + + if not prompts: + return None + # Outermost ancestor first so child-local rules appear after broader rules. + prompts.reverse() + return "\n\n".join(prompts) + @traced(name="skyvern.workflow.execute", role="wrapper") async def execute_workflow( self, @@ -951,7 +1027,15 @@ class WorkflowService: browser_session_id: str | None = None, need_call_webhook: bool = True, ) -> WorkflowRun: - """Execute a workflow.""" + """Execute a workflow. + + When the workflow_run row has ``ignore_inherited_workflow_system_prompt`` + set (populated at spawn time by a ``WorkflowTriggerBlock`` whose + ``ignore_workflow_system_prompt`` flag is True), the child workflow + starts with a clean slate — no inherited prompt from the ancestor + chain. Persisting the intent on the row means the flag is honored for + both sync and async (Temporal-dispatched) trigger modes. + """ organization_id = organization.organization_id LOG.info( @@ -1008,6 +1092,19 @@ class WorkflowService: # Get all tuples wp_wps_tuples = await self.get_workflow_run_parameter_tuples(workflow_run_id=workflow_run_id) workflow_output_parameters = await self.get_workflow_output_parameters(workflow_id=workflow.workflow_id) + # Collect resolved workflow_system_prompt from every ancestor workflow so child + # blocks inherit them (SKY-9147). We read each parent's workflow_definition from + # the DB because the parent's in-memory WorkflowRunContext may be gone by the + # time a fire-and-forget child runs on its own worker. Jinja placeholders in + # ancestor prompts are rendered against this run's values; parent-only + # parameters will simply render empty in non-strict mode. + inherited_workflow_system_prompt = ( + None + if workflow_run.ignore_inherited_workflow_system_prompt + else await self._collect_inherited_workflow_system_prompt( + parent_workflow_run_id=workflow_run.parent_workflow_run_id, + ) + ) try: await app.WORKFLOW_CONTEXT_MANAGER.initialize_workflow_run_context( organization, @@ -1021,6 +1118,7 @@ class WorkflowService: secret_parameters, block_outputs, workflow, + inherited_workflow_system_prompt=inherited_workflow_system_prompt, ) except Exception as e: LOG.exception( @@ -2121,6 +2219,22 @@ class WorkflowService: block_label=block.label, run_signature=script_block.run_signature, ) + # Script path skips the block's own execute() (which is where + # format_potential_template_parameters runs in the agent path), + # so we apply the workflow_system_prompt here to thread the + # block-resolved value into the ``WorkflowRunContext`` cache. + # ``ai_extract`` reads from that cache so the script-generated + # extraction honors ``ignore_workflow_system_prompt`` the same + # way the agent path does — same string, same cache key. + try: + workflow_run_context = app.WORKFLOW_CONTEXT_MANAGER.get_workflow_run_context(workflow_run_id) + block._apply_workflow_system_prompt(workflow_run_context) + except Exception: + LOG.warning( + "Failed to apply workflow_system_prompt for script-path block; continuing", + block_label=block.label, + exc_info=True, + ) block_exec_start = time.monotonic() try: vars_dict = vars(loaded_script_module) if loaded_script_module else {} @@ -3750,6 +3864,7 @@ class WorkflowService: workflow_run_id: str | None = None, trigger_type: WorkflowRunTriggerType | None = None, workflow_schedule_id: str | None = None, + ignore_inherited_workflow_system_prompt: bool = False, ) -> WorkflowRun: # validate the browser session or profile id browser_profile_id = workflow_request.browser_profile_id @@ -3840,6 +3955,7 @@ class WorkflowService: workflow_run_id=workflow_run_id, trigger_type=trigger_type, workflow_schedule_id=workflow_schedule_id, + ignore_inherited_workflow_system_prompt=ignore_inherited_workflow_system_prompt, ) async def _update_workflow_run_status( diff --git a/skyvern/forge/sdk/workflow/workflow_definition_converter.py b/skyvern/forge/sdk/workflow/workflow_definition_converter.py index 63718c7fe..1df8c0a5b 100644 --- a/skyvern/forge/sdk/workflow/workflow_definition_converter.py +++ b/skyvern/forge/sdk/workflow/workflow_definition_converter.py @@ -327,6 +327,7 @@ def convert_workflow_definition( version=dag_version, finally_block_label=workflow_definition_yaml.finally_block_label, error_code_mapping=workflow_definition_yaml.error_code_mapping, + workflow_system_prompt=workflow_definition_yaml.workflow_system_prompt, ) LOG.info( @@ -383,6 +384,7 @@ def _build_block_kwargs( "continue_on_failure": block_yaml.continue_on_failure, "next_loop_on_failure": block_yaml.next_loop_on_failure, "model": block_yaml.model, + "ignore_workflow_system_prompt": block_yaml.ignore_workflow_system_prompt, } diff --git a/skyvern/library/skyvern_browser_page_ai.py b/skyvern/library/skyvern_browser_page_ai.py index f9693df12..ac26533e1 100644 --- a/skyvern/library/skyvern_browser_page_ai.py +++ b/skyvern/library/skyvern_browser_page_ai.py @@ -15,7 +15,7 @@ from skyvern.client import ( RunSdkActionRequestAction_Validate, ) from skyvern.config import settings -from skyvern.core.script_generations.skyvern_page_ai import SkyvernPageAi +from skyvern.core.script_generations.skyvern_page_ai import SYSTEM_PROMPT_UNSET, SkyvernPageAi if TYPE_CHECKING: from skyvern.library.skyvern_browser import SkyvernBrowser @@ -168,13 +168,15 @@ class SdkSkyvernPageAi(SkyvernPageAi): data: str | dict[str, Any] | None = None, skip_refresh: bool = False, include_extracted_text: bool = True, + system_prompt: str | None | Any = SYSTEM_PROMPT_UNSET, ) -> dict[str, Any] | list | str | None: """Extract information from the page using AI via API call. - Note: skip_refresh and include_extracted_text are accepted for Protocol - compatibility but not forwarded to the API. The server-side controls - both via the Task record on the SDK HTTP path. The optimizations only - take effect on the direct RealSkyvernPageAI path (MCP local browser). + Note: skip_refresh, include_extracted_text, and system_prompt are + accepted for Protocol compatibility but not forwarded to the API. The + server-side controls them via the Task record on the SDK HTTP path. + The optimizations only take effect on the direct RealSkyvernPageAI + path (MCP local browser). """ LOG.info("AI extract", prompt=prompt, workflow_run_id=self._browser.workflow_run_id) diff --git a/skyvern/schemas/workflows.py b/skyvern/schemas/workflows.py index 9d903071f..47cd945ce 100644 --- a/skyvern/schemas/workflows.py +++ b/skyvern/schemas/workflows.py @@ -321,6 +321,16 @@ def sanitize_workflow_yaml_with_references(workflow_yaml: dict[str, Any]) -> dic workflow_definition["parameters"], old_output_key, new_output_key ) + # workflow_system_prompt is rendered through Jinja at execution time, so + # references inside it need the same rename treatment as block fields. + if isinstance(workflow_definition.get("workflow_system_prompt"), str): + workflow_definition["workflow_system_prompt"] = _replace_references_in_value( + workflow_definition["workflow_system_prompt"], old_output_key, new_output_key + ) + workflow_definition["workflow_system_prompt"] = _replace_references_in_value( + workflow_definition["workflow_system_prompt"], old_label, new_label + ) + # Step 4: Update all parameter key references for old_key, new_key in param_key_mapping.items(): # Update Jinja references in blocks (e.g., {{ old_key }}) @@ -339,6 +349,11 @@ def sanitize_workflow_yaml_with_references(workflow_yaml: dict[str, Any]) -> dic workflow_definition["parameters"], old_key, new_key ) + if isinstance(workflow_definition.get("workflow_system_prompt"), str): + workflow_definition["workflow_system_prompt"] = _replace_references_in_value( + workflow_definition["workflow_system_prompt"], old_key, new_key + ) + # Rewrite workflow-level error_code_mapping atomically so substitutions don't chain # (e.g. foo-bar -> foo_bar and foo_bar -> foo_bar_2 must not combine into foo_bar_2). if "error_code_mapping" in workflow_definition: @@ -614,6 +629,10 @@ class BlockYAML(BaseModel, abc.ABC): ) continue_on_failure: bool = False model: dict[str, Any] | None = None + # Opt-out from workflow-level workflow_system_prompt inheritance (and, on a + # WorkflowTriggerBlock, from propagating the parent chain's prompt into the + # spawned child run). A no-op for deterministic blocks that don't call an LLM. + ignore_workflow_system_prompt: bool = False # Only valid for blocks inside a for loop block # Whether to continue to the next iteration when the block fails next_loop_on_failure: bool = False @@ -1105,6 +1124,7 @@ class WorkflowDefinitionYAML(BaseModel): blocks: list[BLOCK_YAML_TYPES] finally_block_label: str | None = None error_code_mapping: dict[str, str] | None = None + workflow_system_prompt: str | None = None @model_validator(mode="after") def validate_unique_block_labels(self) -> "WorkflowDefinitionYAML": diff --git a/skyvern/services/task_v2_service.py b/skyvern/services/task_v2_service.py index 01d0c3120..f1aa5a8ed 100644 --- a/skyvern/services/task_v2_service.py +++ b/skyvern/services/task_v2_service.py @@ -145,6 +145,7 @@ async def _summarize_max_steps_failure_reason( screenshots=screenshots, prompt_name="task_v2_summarize-max-steps-reason", thought=thought, + system_prompt=task_v2.workflow_system_prompt, ) return json_response.get("reasoning", ""), json_response.get("failure_categories") except Exception: @@ -252,6 +253,7 @@ async def initialize_task_v2( parent_workflow_run_id: str | None = None, extracted_information_schema: dict | list | str | None = None, error_code_mapping: dict | None = None, + workflow_system_prompt: str | None = None, create_task_run: bool = False, model: dict[str, Any] | None = None, max_screenshot_scrolling_times: int | None = None, @@ -270,6 +272,7 @@ async def initialize_task_v2( proxy_location=proxy_location, extracted_information_schema=extracted_information_schema, error_code_mapping=error_code_mapping, + workflow_system_prompt=workflow_system_prompt, model=model, max_screenshot_scrolling_times=max_screenshot_scrolling_times, extra_http_headers=extra_http_headers, @@ -381,6 +384,7 @@ async def initialize_task_v2_metadata( prompt=metadata_prompt, thought=thought, prompt_name="task_v2_generate_metadata", + system_prompt=task_v2.workflow_system_prompt, ) # validate @@ -829,6 +833,7 @@ async def run_task_v2_helper( screenshots=scraped_page.screenshots, thought=thought, prompt_name="task_v2", + system_prompt=task_v2.workflow_system_prompt, ) LOG.info( "Task v2 response", @@ -1079,6 +1084,7 @@ async def run_task_v2_helper( screenshots=completion_screenshots, thought=thought, prompt_name="task_v2_check_completion", + system_prompt=task_v2.workflow_system_prompt, ) LOG.info( "Task v2 completion check response", @@ -1465,6 +1471,7 @@ async def _generate_loop_task( screenshots=scraped_page.screenshots, thought=thought_task_in_loop, prompt_name="task_v2_generate_task_block", + system_prompt=task_v2.workflow_system_prompt, ) LOG.info("Task in loop metadata response", task_in_loop_metadata_response=task_in_loop_metadata_response) navigation_goal = task_in_loop_metadata_response.get("navigation_goal") @@ -1571,6 +1578,7 @@ async def _generate_extraction_task( task_v2=task_v2, prompt_name="task_v2_generate_extraction_task", organization_id=task_v2.organization_id, + system_prompt=task_v2.workflow_system_prompt, ) break except (InvalidLLMResponseFormat, EmptyLLMResponseError) as e: @@ -1971,6 +1979,7 @@ async def _summarize_task_v2( screenshots=screenshots, thought=thought, prompt_name="task_v2_summary", + system_prompt=task_v2.workflow_system_prompt, ) LOG.info("Task v2 summary response", task_v2_summary_resp=task_v2_summary_resp) diff --git a/skyvern/services/workflow_service.py b/skyvern/services/workflow_service.py index 033b11d53..6c1e793df 100644 --- a/skyvern/services/workflow_service.py +++ b/skyvern/services/workflow_service.py @@ -27,6 +27,7 @@ async def prepare_workflow( code_gen: bool | None = None, parent_workflow_run_id: str | None = None, trigger_type: WorkflowRunTriggerType | None = None, + ignore_inherited_workflow_system_prompt: bool = False, ) -> WorkflowRun: """ Prepare a workflow to be run. @@ -47,6 +48,7 @@ async def prepare_workflow( code_gen=code_gen, parent_workflow_run_id=parent_workflow_run_id, trigger_type=trigger_type, + ignore_inherited_workflow_system_prompt=ignore_inherited_workflow_system_prompt, ) workflow = await app.WORKFLOW_SERVICE.get_workflow_by_permanent_id( @@ -87,6 +89,7 @@ async def run_workflow( block_outputs: dict[str, t.Any] | None = None, parent_workflow_run_id: str | None = None, trigger_type: WorkflowRunTriggerType | None = None, + ignore_inherited_workflow_system_prompt: bool = False, ) -> WorkflowRun: workflow_run = await prepare_workflow( workflow_id=workflow_id, @@ -98,6 +101,7 @@ async def run_workflow( request_id=request_id, parent_workflow_run_id=parent_workflow_run_id, trigger_type=trigger_type, + ignore_inherited_workflow_system_prompt=ignore_inherited_workflow_system_prompt, ) await AsyncExecutorFactory.get_executor().execute_workflow( diff --git a/skyvern/webeye/actions/handler.py b/skyvern/webeye/actions/handler.py index 1ba7ed098..9dd9314a3 100644 --- a/skyvern/webeye/actions/handler.py +++ b/skyvern/webeye/actions/handler.py @@ -4463,6 +4463,7 @@ async def extract_information_for_navigation_goal( error_code_mapping=error_code_mapping_str, previous_extracted_information=post_ceiling_kwargs["previous_extracted_information"], llm_key=llm_key_override, + workflow_system_prompt=task.workflow_system_prompt, ) if is_retry_step: # Proactively evict the in-run entry. The cross-run tier will be @@ -4548,6 +4549,7 @@ async def extract_information_for_navigation_goal( # reasons unrelated to cache correctness. prompt_name="extract-information", force_dict=False, + system_prompt=task.workflow_system_prompt, ) # Apply the same post-processing the miss path applies so the # comparison is apples-to-apples against the cached value. @@ -4696,6 +4698,7 @@ async def extract_information_for_navigation_goal( screenshots=scraped_page.screenshots, prompt_name="extract-information", force_dict=False, + system_prompt=task.workflow_system_prompt, ) # Validate and fill missing fields based on schema diff --git a/tests/unit/test_ai_extract_system_prompt_optout.py b/tests/unit/test_ai_extract_system_prompt_optout.py new file mode 100644 index 000000000..70ec83973 --- /dev/null +++ b/tests/unit/test_ai_extract_system_prompt_optout.py @@ -0,0 +1,263 @@ +"""Regression tests: script-path ``ai_extract`` must honor the current block's +``ignore_workflow_system_prompt`` opt-out, matching agent-path behavior. + +Without this, a cached script-path extraction still injects the workflow prompt +even when the block has opted out — the two execution modes diverge for the +same block (SKY-9147). +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from skyvern.core.script_generations import real_skyvern_page_ai as module +from skyvern.core.script_generations.skyvern_page_ai import SYSTEM_PROMPT_UNSET +from skyvern.forge.sdk.workflow.models.block import ExtractionBlock +from skyvern.forge.sdk.workflow.models.parameter import OutputParameter, ParameterType +from skyvern.forge.sdk.workflow.models.workflow import Workflow, WorkflowDefinition + + +def _make_output_parameter() -> OutputParameter: + now = datetime.now(timezone.utc) + return OutputParameter( + parameter_type=ParameterType.OUTPUT, + key="extract1_output", + description="test output", + output_parameter_id="op_extract1", + workflow_id="w_test", + created_at=now, + modified_at=now, + ) + + +def _make_workflow_with_block(*, ignore_workflow_system_prompt: bool, workflow_system_prompt: str | None) -> Workflow: + block = ExtractionBlock( + label="extract1", + output_parameter=_make_output_parameter(), + data_extraction_goal="Extract things", + ignore_workflow_system_prompt=ignore_workflow_system_prompt, + ) + now = datetime.now(timezone.utc) + return Workflow( + workflow_id="w_test", + organization_id="o_test", + title="test", + workflow_permanent_id="wpid_test", + version=1, + is_saved_task=False, + workflow_definition=WorkflowDefinition( + parameters=[], + blocks=[block], + workflow_system_prompt=workflow_system_prompt, + ), + created_at=now, + modified_at=now, + ) + + +def _run_ai_extract( + monkeypatch: pytest.MonkeyPatch, + *, + workflow: Workflow | None, + current_label: str | None, + extra_kwargs: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Drive ``RealSkyvernPageAi.ai_extract`` with mocks that capture both the + cache-key ``system_prompt`` and the LLM-handler ``system_prompt``. Returns + a dict with keys ``cache_system_prompt`` and ``llm_system_prompt``.""" + + captured: dict[str, Any] = {} + + def fake_compute_cache_key(**kwargs: Any) -> str: + captured["cache_system_prompt"] = kwargs.get("workflow_system_prompt") + return "fake-cache-key" + + def fake_lookup(*_args: Any, **_kwargs: Any) -> None: + # Cache miss — let the flow fall through to the handler. + return None + + def fake_load_prompt(**_kwargs: Any) -> tuple[str, dict[str, Any]]: + return "rendered-prompt", { + "extracted_text": None, + "extracted_information_schema": None, + } + + async def fake_handler(*, system_prompt: Any = None, **_ignored: Any) -> dict[str, Any]: + captured["llm_system_prompt"] = system_prompt + return {} + + # Build a minimal fake ``WorkflowRunContext``. The single-block workflows + # this helper builds go through ``_apply_workflow_system_prompt`` in the + # real script-path dispatch, so simulate that by pre-recording the block's + # effective value — ``None`` when the block opts out, the rendered prompt + # otherwise. ``ai_extract`` reads this recorded value verbatim, which is + # exactly the contract Andrew's fix enforces. + if workflow is not None: + block = workflow.workflow_definition.blocks[0] if workflow.workflow_definition.blocks else None + workflow_prompt = workflow.workflow_definition.workflow_system_prompt + recorded_value: str | None = ( + None if (block is not None and getattr(block, "ignore_workflow_system_prompt", False)) else workflow_prompt + ) + has_block_record = block is not None and current_label == block.label + ctx = MagicMock() + ctx.workflow = workflow + ctx.resolve_effective_workflow_system_prompt = MagicMock(return_value=workflow_prompt) + ctx.get_block_workflow_system_prompt = MagicMock( + return_value=(has_block_record, recorded_value), + ) + + def get_run_ctx(_run_id: str) -> Any: + return ctx + + monkeypatch.setattr( + module.app.WORKFLOW_CONTEXT_MANAGER, + "get_workflow_run_context", + get_run_ctx, + ) + + skyvern_ctx = MagicMock() + skyvern_ctx.workflow_run_id = "wr_test" + skyvern_ctx.tz_info = None + skyvern_ctx.organization_id = None + skyvern_ctx.task_id = None + skyvern_ctx.step_id = None + skyvern_ctx.script_mode = False + + monkeypatch.setattr(module.skyvern_context, "current", lambda: skyvern_ctx) + monkeypatch.setattr(module, "load_prompt_with_elements_tracked", fake_load_prompt) + monkeypatch.setattr(module.extraction_cache, "compute_cache_key", fake_compute_cache_key) + monkeypatch.setattr(module.extraction_cache, "lookup", fake_lookup) + monkeypatch.setattr(module.app, "EXTRACTION_LLM_API_HANDLER", fake_handler) + + scraped_page = MagicMock() + scraped_page.url = "https://example.test" + scraped_page.extracted_text = "page text" + scraped_page.screenshots = [] + scraped_page.build_element_tree = MagicMock(return_value="link") + scraped_page.support_economy_elements_tree = MagicMock(return_value=False) + scraped_page.last_used_element_tree_html = None + + page = module.RealSkyvernPageAi.__new__(module.RealSkyvernPageAi) + page.scraped_page = scraped_page + page.current_label = current_label + + async def fake_refresh(*_args: Any, **_kwargs: Any) -> None: + return None + + monkeypatch.setattr(page, "_refresh_scraped_page", fake_refresh) + + asyncio.run( + page.ai_extract( + prompt="Extract things", + schema={"type": "object"}, + **(extra_kwargs or {}), + ) + ) + + return captured + + +# --------------------------------------------------------------------------- +# The bug Andrew flagged: block opts out, but script-path still injects prompt. +# --------------------------------------------------------------------------- + + +def test_opted_out_block_sends_no_system_prompt_to_llm(monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _make_workflow_with_block( + ignore_workflow_system_prompt=True, + workflow_system_prompt="WORKFLOW RULES.", + ) + captured = _run_ai_extract(monkeypatch, workflow=workflow, current_label="extract1") + + assert captured["llm_system_prompt"] is None + + +def test_opted_out_block_cache_key_omits_system_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + """Cache-key parity with agent path: an opted-out block's extractions must + hash the same way whether the workflow has a prompt set or not. Otherwise a + user toggling ``workflow_system_prompt`` at the workflow level silently + invalidates caches for blocks that explicitly opted out.""" + workflow = _make_workflow_with_block( + ignore_workflow_system_prompt=True, + workflow_system_prompt="WORKFLOW RULES.", + ) + captured = _run_ai_extract(monkeypatch, workflow=workflow, current_label="extract1") + + assert captured["cache_system_prompt"] is None + + +# --------------------------------------------------------------------------- +# Non-opted-out block still inherits the workflow prompt (no regression of the +# base feature). +# --------------------------------------------------------------------------- + + +def test_non_opted_out_block_receives_workflow_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _make_workflow_with_block( + ignore_workflow_system_prompt=False, + workflow_system_prompt="WORKFLOW RULES.", + ) + captured = _run_ai_extract(monkeypatch, workflow=workflow, current_label="extract1") + + assert captured["llm_system_prompt"] == "WORKFLOW RULES." + assert captured["cache_system_prompt"] == "WORKFLOW RULES." + + +# --------------------------------------------------------------------------- +# Explicit parameter overrides: caller can pass ``system_prompt`` directly, +# bypassing the block-flag lookup. Includes the "explicit None" escape hatch. +# --------------------------------------------------------------------------- + + +def test_explicit_system_prompt_overrides_workflow(monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _make_workflow_with_block( + ignore_workflow_system_prompt=False, + workflow_system_prompt="WORKFLOW RULES.", + ) + captured = _run_ai_extract( + monkeypatch, + workflow=workflow, + current_label="extract1", + extra_kwargs={"system_prompt": "EXPLICIT RULES."}, + ) + + assert captured["llm_system_prompt"] == "EXPLICIT RULES." + assert captured["cache_system_prompt"] == "EXPLICIT RULES." + + +def test_explicit_none_opts_out_even_when_workflow_has_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + """Explicit ``None`` is the escape hatch: caller says "send no system prompt" + regardless of the workflow or block state.""" + workflow = _make_workflow_with_block( + ignore_workflow_system_prompt=False, + workflow_system_prompt="WORKFLOW RULES.", + ) + captured = _run_ai_extract( + monkeypatch, + workflow=workflow, + current_label="extract1", + extra_kwargs={"system_prompt": None}, + ) + + assert captured["llm_system_prompt"] is None + assert captured["cache_system_prompt"] is None + + +def test_sentinel_default_is_not_leaked_to_handler(monkeypatch: pytest.MonkeyPatch) -> None: + """The sentinel must never reach the LLM handler — if the fallback path + silently forwarded it, the handler would see a non-None, non-string object + and fail or pass a bogus ``system_prompt`` to the model.""" + workflow = _make_workflow_with_block( + ignore_workflow_system_prompt=False, + workflow_system_prompt=None, # no prompt at all + ) + captured = _run_ai_extract(monkeypatch, workflow=workflow, current_label="extract1") + + assert captured["llm_system_prompt"] is None + assert captured["llm_system_prompt"] is not SYSTEM_PROMPT_UNSET + assert captured["cache_system_prompt"] is None diff --git a/tests/unit/test_block_workflow_system_prompt_inheritance.py b/tests/unit/test_block_workflow_system_prompt_inheritance.py new file mode 100644 index 000000000..ae6e12302 --- /dev/null +++ b/tests/unit/test_block_workflow_system_prompt_inheritance.py @@ -0,0 +1,489 @@ +"""Tests for workflow-level workflow_system_prompt inheritance into blocks at execution time.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +from skyvern.forge.sdk.workflow.context_manager import WorkflowRunContext +from skyvern.forge.sdk.workflow.models.block import ( + FileParserBlock, + PDFParserBlock, + TaskBlock, + TaskV2Block, + TextPromptBlock, +) +from skyvern.forge.sdk.workflow.models.parameter import OutputParameter, ParameterType +from skyvern.forge.sdk.workflow.models.workflow import Workflow, WorkflowDefinition +from skyvern.schemas.workflows import FileType + + +def _make_output_parameter() -> OutputParameter: + now = datetime.now(timezone.utc) + return OutputParameter( + parameter_type=ParameterType.OUTPUT, + key="task1_output", + description="test output", + output_parameter_id="op_task1", + workflow_id="w_test", + created_at=now, + modified_at=now, + ) + + +def _make_task_block(workflow_system_prompt: str | None = None) -> TaskBlock: + return TaskBlock( + label="task1", + output_parameter=_make_output_parameter(), + title="task title", + workflow_system_prompt=workflow_system_prompt, + ) + + +def _make_task_v2_block(workflow_system_prompt: str | None = None) -> TaskV2Block: + return TaskV2Block( + label="task1", + output_parameter=_make_output_parameter(), + prompt="user goal", + workflow_system_prompt=workflow_system_prompt, + ) + + +def _make_workflow(workflow_system_prompt: str | None) -> Workflow: + workflow_definition = WorkflowDefinition( + parameters=[], + blocks=[], + workflow_system_prompt=workflow_system_prompt, + ) + now = datetime.now(timezone.utc) + return Workflow( + workflow_id="w_test", + organization_id="o_test", + title="test", + workflow_permanent_id="wpid_test", + version=1, + is_saved_task=False, + workflow_definition=workflow_definition, + created_at=now, + modified_at=now, + ) + + +def _make_workflow_run_context( + workflow_system_prompt: str | None, + inherited_workflow_system_prompt: str | None = None, +) -> WorkflowRunContext: + ctx = WorkflowRunContext( + workflow_title="test", + workflow_id="w_test", + workflow_permanent_id="wpid_test", + workflow_run_id="wr_test", + aws_client=MagicMock(), + workflow=_make_workflow(workflow_system_prompt), + inherited_workflow_system_prompt=inherited_workflow_system_prompt, + ) + return ctx + + +class TestTaskBlockSystemPromptInheritance: + def test_block_inherits_workflow_prompt_when_none(self) -> None: + block = _make_task_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context("Never guess. If unsure, say UNKNOWN.") + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt == "Never guess. If unsure, say UNKNOWN." + + def test_both_none_stays_none(self) -> None: + block = _make_task_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context(workflow_system_prompt=None) + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt is None + + def test_jinja_substitution_resolves_workflow_parameters(self) -> None: + """Global system prompt should support Jinja substitution against workflow parameters.""" + block = _make_task_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context("Respond in the style of {{ style }}.") + ctx.values["style"] = "a formal English butler" + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt == "Respond in the style of a formal English butler." + + +class TestTaskV2BlockSystemPromptInheritance: + def test_block_inherits_workflow_prompt_when_none(self) -> None: + block = _make_task_v2_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context("Never guess.") + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt == "Never guess." + + def test_both_none_stays_none(self) -> None: + block = _make_task_v2_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context(workflow_system_prompt=None) + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt is None + + +def _make_text_prompt_block(workflow_system_prompt: str | None = None) -> TextPromptBlock: + return TextPromptBlock( + label="prompt1", + output_parameter=_make_output_parameter(), + prompt="what is 2 + 2?", + workflow_system_prompt=workflow_system_prompt, + ) + + +def _make_file_parser_block(workflow_system_prompt: str | None = None) -> FileParserBlock: + return FileParserBlock( + label="fileparser1", + output_parameter=_make_output_parameter(), + file_url="https://example.com/file.csv", + file_type=FileType.CSV, + workflow_system_prompt=workflow_system_prompt, + ) + + +def _make_pdf_parser_block(workflow_system_prompt: str | None = None) -> PDFParserBlock: + return PDFParserBlock( + label="pdfparser1", + output_parameter=_make_output_parameter(), + file_url="https://example.com/file.pdf", + workflow_system_prompt=workflow_system_prompt, + ) + + +class TestTextPromptBlockSystemPromptInheritance: + def test_block_inherits_workflow_prompt_when_none(self) -> None: + block = _make_text_prompt_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context("Answer only in Spanish.") + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt == "Answer only in Spanish." + + def test_both_none_stays_none(self) -> None: + block = _make_text_prompt_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context(workflow_system_prompt=None) + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt is None + + def test_jinja_substitution_resolves_workflow_parameters(self) -> None: + block = _make_text_prompt_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context("Respond in the style of {{ style }}.") + ctx.values["style"] = "a pirate" + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt == "Respond in the style of a pirate." + + +class TestFileParserBlockSystemPromptInheritance: + def test_block_inherits_workflow_prompt_when_none(self) -> None: + block = _make_file_parser_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context("Only respond with structured data.") + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt == "Only respond with structured data." + + def test_both_none_stays_none(self) -> None: + block = _make_file_parser_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context(workflow_system_prompt=None) + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt is None + + +class TestPDFParserBlockSystemPromptInheritance: + def test_block_inherits_workflow_prompt_when_none(self) -> None: + block = _make_pdf_parser_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context("Summarize in English.") + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt == "Summarize in English." + + def test_both_none_stays_none(self) -> None: + block = _make_pdf_parser_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context(workflow_system_prompt=None) + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt is None + + +class TestChildWorkflowInheritsParentWorkflowSystemPrompt: + """SKY-9147: parent workflow_trigger workflow_system_prompt must flow into child blocks.""" + + def test_child_inherits_parent_when_child_unset(self) -> None: + block = _make_task_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context( + workflow_system_prompt=None, + inherited_workflow_system_prompt="Omit the word 'not'.", + ) + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt == "Omit the word 'not'." + + def test_child_concatenates_parent_and_own_prompt(self) -> None: + block = _make_task_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context( + workflow_system_prompt="Respond in French.", + inherited_workflow_system_prompt="Omit the word 'not'.", + ) + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt == "Omit the word 'not'.\n\nRespond in French." + + def test_ignore_flag_drops_both_inherited_and_own(self) -> None: + block = _make_task_block(workflow_system_prompt=None) + block.ignore_workflow_system_prompt = True + ctx = _make_workflow_run_context( + workflow_system_prompt="Respond in French.", + inherited_workflow_system_prompt="Omit the word 'not'.", + ) + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt is None + + def test_inherited_only_with_no_workflow_attached(self) -> None: + """Child context may carry inherited rules even when workflow is not hydrated.""" + block = _make_text_prompt_block(workflow_system_prompt=None) + ctx = WorkflowRunContext( + workflow_title="child", + workflow_id="w_child", + workflow_permanent_id="wpid_child", + workflow_run_id="wr_child", + aws_client=MagicMock(), + workflow=None, + inherited_workflow_system_prompt="Be concise.", + ) + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt == "Be concise." + + +class TestIgnoreWorkflowSystemPromptPerBlock: + """SKY-9147: per-block opt-out short-circuits inheritance across every LLM-consuming block.""" + + def test_task_block_opt_out_skips_workflow_prompt(self) -> None: + block = _make_task_block(workflow_system_prompt=None) + block.ignore_workflow_system_prompt = True + ctx = _make_workflow_run_context("Be concise.") + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt is None + + def test_task_v2_block_opt_out_skips_workflow_prompt(self) -> None: + block = _make_task_v2_block(workflow_system_prompt=None) + block.ignore_workflow_system_prompt = True + ctx = _make_workflow_run_context("Be concise.") + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt is None + + def test_text_prompt_block_opt_out_skips_workflow_prompt(self) -> None: + block = _make_text_prompt_block(workflow_system_prompt=None) + block.ignore_workflow_system_prompt = True + ctx = _make_workflow_run_context("Answer only in Spanish.") + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt is None + + def test_file_parser_block_opt_out_skips_workflow_prompt(self) -> None: + block = _make_file_parser_block(workflow_system_prompt=None) + block.ignore_workflow_system_prompt = True + ctx = _make_workflow_run_context("Only respond with structured data.") + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt is None + + def test_pdf_parser_block_opt_out_skips_workflow_prompt(self) -> None: + block = _make_pdf_parser_block(workflow_system_prompt=None) + block.ignore_workflow_system_prompt = True + ctx = _make_workflow_run_context("Summarize in English.") + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt is None + + def test_opt_out_default_false_preserves_inheritance(self) -> None: + """Regression guard: omitting the field leaves default inheritance behavior intact.""" + block = _make_task_block(workflow_system_prompt=None) + assert block.ignore_workflow_system_prompt is False + ctx = _make_workflow_run_context("Be concise.") + + block.format_potential_template_parameters(ctx) + + assert block.workflow_system_prompt == "Be concise." + + +class TestWorkflowTriggerPersistsOptOutOnChildRun: + """SKY-9147: the trigger-block flag is persisted on the spawned child's + workflow_run row so both sync and async (Temporal-dispatched) child + executions honor it uniformly when they read their own row. + """ + + def test_workflow_run_has_skip_inherited_field(self) -> None: + """WorkflowRun Pydantic model carries the persisted flag.""" + now = datetime.now(timezone.utc) + from skyvern.forge.sdk.workflow.models.workflow import WorkflowRun, WorkflowRunStatus + + run = WorkflowRun( + workflow_run_id="wr_child", + workflow_id="w_child", + workflow_permanent_id="wpid_child", + organization_id="o_test", + status=WorkflowRunStatus.created, + created_at=now, + modified_at=now, + ignore_inherited_workflow_system_prompt=True, + ) + + assert run.ignore_inherited_workflow_system_prompt is True + + def test_workflow_run_defaults_false(self) -> None: + """Existing code paths that omit the field continue to inherit.""" + now = datetime.now(timezone.utc) + from skyvern.forge.sdk.workflow.models.workflow import WorkflowRun, WorkflowRunStatus + + run = WorkflowRun( + workflow_run_id="wr_child", + workflow_id="w_child", + workflow_permanent_id="wpid_child", + organization_id="o_test", + status=WorkflowRunStatus.created, + created_at=now, + modified_at=now, + ) + + assert run.ignore_inherited_workflow_system_prompt is False + + +class TestWorkflowDefinitionYAMLRoundTrip: + def test_workflow_system_prompt_survives_yaml_roundtrip(self) -> None: + """Regression guard: workflow_system_prompt must roundtrip through WorkflowCreateYAMLRequest.""" + from skyvern.schemas.workflows import WorkflowCreateYAMLRequest + + payload = { + "title": "test", + "workflow_definition": { + "version": 1, + "parameters": [], + "blocks": [], + "workflow_system_prompt": "Never guess.", + }, + } + request = WorkflowCreateYAMLRequest.model_validate(payload) + assert request.workflow_definition.workflow_system_prompt == "Never guess." + + +class TestBlockWorkflowSystemPromptNotSerialized: + """The runtime cache must not leak through ``model_dump`` or JSON + serialization — it's a per-run transient, not part of the block's + authored shape.""" + + def test_unset_field_absent_from_model_dump(self) -> None: + block = _make_task_block(workflow_system_prompt=None) + assert "workflow_system_prompt" not in block.model_dump() + + def test_resolved_runtime_value_absent_from_model_dump(self) -> None: + block = _make_task_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context("Never guess.") + block.format_potential_template_parameters(ctx) + + # Invariant confirmed at runtime: inheritance populated the cache… + assert block.workflow_system_prompt == "Never guess." + # …but it still doesn't escape via serialization. + dumped = block.model_dump() + assert "workflow_system_prompt" not in dumped + assert "Never guess." not in block.model_dump_json() + + +class TestBlockWorkflowSystemPromptRecordedOnContext: + """``Block._apply_workflow_system_prompt`` records its decision on the + ``WorkflowRunContext`` so both the agent path (which uses the block's + own ``workflow_system_prompt`` field) and the script path (which reads + from the context cache via ``ai_extract``) see the same value — single + source of truth for the opt-out (SKY-9147).""" + + def test_non_opted_out_block_records_resolved_value(self) -> None: + block = _make_task_block(workflow_system_prompt=None) + ctx = _make_workflow_run_context("Answer only in Spanish.") + + block.format_potential_template_parameters(ctx) + + recorded, value = ctx.get_block_workflow_system_prompt(block.label) + assert recorded is True + assert value == "Answer only in Spanish." + + def test_opted_out_block_records_none(self) -> None: + block = TaskBlock( + label="task1", + output_parameter=_make_output_parameter(), + title="task title", + ignore_workflow_system_prompt=True, + ) + ctx = _make_workflow_run_context("WORKFLOW RULES.") + + block.format_potential_template_parameters(ctx) + + # Recorded explicitly so ``ai_extract`` reads ``None`` (opt-out) rather + # than falling through to ``resolve_effective_workflow_system_prompt``. + recorded, value = ctx.get_block_workflow_system_prompt(block.label) + assert recorded is True + assert value is None + + def test_unknown_label_returns_not_recorded(self) -> None: + ctx = _make_workflow_run_context("whatever") + recorded, value = ctx.get_block_workflow_system_prompt("never-seen") + assert recorded is False + assert value is None + + +class TestResolveEffectiveWorkflowSystemPromptRejectsNonString: + """``resolve_effective_workflow_system_prompt`` must treat a non-string + ``workflow_system_prompt`` as absent rather than passing it to Jinja. + + Regression: a malformed workflow definition (or a test fixture whose + attribute access returns a ``MagicMock``) previously flowed a non-str + into ``env.from_string`` and exploded with ``Can't compile non template + nodes`` from deep inside the template compiler.""" + + def test_magicmock_workflow_definition_yields_none(self) -> None: + ctx = _make_workflow_run_context(workflow_system_prompt=None) + mock_workflow = MagicMock() + # Attribute access on a MagicMock returns another truthy MagicMock, + # mirroring what ``get_workflow_by_permanent_id`` returns in tests + # that don't set up a real Workflow. + ctx.set_workflow(mock_workflow) + + assert ctx.resolve_effective_workflow_system_prompt() is None + + def test_non_string_inherited_prompt_yields_none(self) -> None: + ctx = WorkflowRunContext( + workflow_title="test", + workflow_id="w_test", + workflow_permanent_id="wpid_test", + workflow_run_id="wr_test", + aws_client=MagicMock(), + inherited_workflow_system_prompt=MagicMock(), # non-str + ) + + assert ctx.resolve_effective_workflow_system_prompt() is None diff --git a/tests/unit/test_collect_inherited_workflow_system_prompt.py b/tests/unit/test_collect_inherited_workflow_system_prompt.py new file mode 100644 index 000000000..7d02a60ba --- /dev/null +++ b/tests/unit/test_collect_inherited_workflow_system_prompt.py @@ -0,0 +1,270 @@ +"""Unit tests for ``WorkflowService._collect_inherited_workflow_system_prompt``. + +The helper walks the parent workflow-run chain via +``app.DATABASE.workflow_runs.get_workflow_run`` + ``WorkflowService.get_workflow`` +and joins each ancestor's raw ``workflow_system_prompt`` outermost-first. These +tests pin the chain-break-on-opt-out, cycle detection, outermost-first ordering, +and depth-cap semantics described in the helper's docstring (SKY-9147). +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock + +import pytest + +from skyvern.forge import app +from skyvern.forge.sdk.workflow.models.block import WorkflowTriggerBlock +from skyvern.forge.sdk.workflow.models.workflow import ( + Workflow, + WorkflowDefinition, + WorkflowRun, + WorkflowRunStatus, +) +from skyvern.forge.sdk.workflow.service import WorkflowService + + +def _make_workflow(workflow_id: str, workflow_system_prompt: str | None) -> Workflow: + now = datetime.now(timezone.utc) + return Workflow( + workflow_id=workflow_id, + organization_id="o_test", + title=f"workflow {workflow_id}", + workflow_permanent_id=f"wpid_{workflow_id}", + version=1, + is_saved_task=False, + workflow_definition=WorkflowDefinition( + parameters=[], + blocks=[], + workflow_system_prompt=workflow_system_prompt, + ), + created_at=now, + modified_at=now, + ) + + +def _make_run( + *, + run_id: str, + workflow_id: str, + parent_run_id: str | None, + skip_inherited: bool = False, +) -> WorkflowRun: + now = datetime.now(timezone.utc) + return WorkflowRun( + workflow_run_id=run_id, + workflow_id=workflow_id, + workflow_permanent_id=f"wpid_{workflow_id}", + organization_id="o_test", + status=WorkflowRunStatus.running, + parent_workflow_run_id=parent_run_id, + ignore_inherited_workflow_system_prompt=skip_inherited, + created_at=now, + modified_at=now, + ) + + +def _install_chain( + monkeypatch: pytest.MonkeyPatch, + runs: dict[str, WorkflowRun], + workflows: dict[str, Workflow], +) -> tuple[AsyncMock, AsyncMock]: + """Wire mocked ``get_workflow_run`` + ``get_workflow`` against the provided + dicts. Returns both mocks so tests can assert call counts.""" + + get_run = AsyncMock(side_effect=lambda run_id: runs.get(run_id)) + monkeypatch.setattr(app.DATABASE.workflow_runs, "get_workflow_run", get_run) + + get_workflow = AsyncMock(side_effect=lambda workflow_id: workflows.get(workflow_id)) + monkeypatch.setattr(WorkflowService, "get_workflow", get_workflow) + + return get_run, get_workflow + + +@pytest.mark.asyncio +async def test_returns_none_when_parent_run_id_is_none(monkeypatch: pytest.MonkeyPatch) -> None: + _install_chain(monkeypatch, runs={}, workflows={}) + + result = await WorkflowService()._collect_inherited_workflow_system_prompt(parent_workflow_run_id=None) + + assert result is None + + +@pytest.mark.asyncio +async def test_returns_none_when_no_ancestor_has_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + """Chain exists but every ancestor's ``workflow_system_prompt`` is None/empty.""" + runs = { + "wr_parent": _make_run(run_id="wr_parent", workflow_id="w_parent", parent_run_id="wr_grandparent"), + "wr_grandparent": _make_run(run_id="wr_grandparent", workflow_id="w_grandparent", parent_run_id=None), + } + workflows = { + "w_parent": _make_workflow("w_parent", workflow_system_prompt=None), + "w_grandparent": _make_workflow("w_grandparent", workflow_system_prompt=""), + } + _install_chain(monkeypatch, runs, workflows) + + result = await WorkflowService()._collect_inherited_workflow_system_prompt(parent_workflow_run_id="wr_parent") + + assert result is None + + +@pytest.mark.asyncio +async def test_single_ancestor_with_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + runs = {"wr_parent": _make_run(run_id="wr_parent", workflow_id="w_parent", parent_run_id=None)} + workflows = {"w_parent": _make_workflow("w_parent", workflow_system_prompt="Respond in English.")} + _install_chain(monkeypatch, runs, workflows) + + result = await WorkflowService()._collect_inherited_workflow_system_prompt(parent_workflow_run_id="wr_parent") + + assert result == "Respond in English." + + +@pytest.mark.asyncio +async def test_outermost_first_ordering(monkeypatch: pytest.MonkeyPatch) -> None: + """Chain: great-grandparent -> grandparent -> parent -> (child). The helper + walks up bottom-up starting from the parent; the returned string must join + outermost-first (great-grandparent before grandparent before parent).""" + runs = { + "wr_parent": _make_run(run_id="wr_parent", workflow_id="w_parent", parent_run_id="wr_grand"), + "wr_grand": _make_run(run_id="wr_grand", workflow_id="w_grand", parent_run_id="wr_great"), + "wr_great": _make_run(run_id="wr_great", workflow_id="w_great", parent_run_id=None), + } + workflows = { + "w_parent": _make_workflow("w_parent", workflow_system_prompt="PARENT rule."), + "w_grand": _make_workflow("w_grand", workflow_system_prompt="GRAND rule."), + "w_great": _make_workflow("w_great", workflow_system_prompt="GREAT rule."), + } + _install_chain(monkeypatch, runs, workflows) + + result = await WorkflowService()._collect_inherited_workflow_system_prompt(parent_workflow_run_id="wr_parent") + + assert result == "GREAT rule.\n\nGRAND rule.\n\nPARENT rule." + + +@pytest.mark.asyncio +async def test_chain_break_includes_opted_out_ancestors_own_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + """When an ancestor has ``ignore_inherited_workflow_system_prompt=True``, its own + prompt is still collected but traversal stops — the grandparent's rules must + NOT appear in the result (SKY-9147 design: an opted-out workflow rejects its + parents' rules but still propagates its own to descendants).""" + runs = { + "wr_parent": _make_run( + run_id="wr_parent", + workflow_id="w_parent", + parent_run_id="wr_grand", + skip_inherited=True, + ), + "wr_grand": _make_run(run_id="wr_grand", workflow_id="w_grand", parent_run_id=None), + } + workflows = { + "w_parent": _make_workflow("w_parent", workflow_system_prompt="PARENT rule."), + "w_grand": _make_workflow("w_grand", workflow_system_prompt="GRAND rule."), + } + _install_chain(monkeypatch, runs, workflows) + + result = await WorkflowService()._collect_inherited_workflow_system_prompt(parent_workflow_run_id="wr_parent") + + assert result == "PARENT rule." + + +@pytest.mark.asyncio +async def test_chain_break_opted_out_ancestor_with_no_own_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + """When the opted-out ancestor has no prompt of its own and its ancestors do, + the result is None — traversal still stops at the opted-out boundary.""" + runs = { + "wr_parent": _make_run( + run_id="wr_parent", + workflow_id="w_parent", + parent_run_id="wr_grand", + skip_inherited=True, + ), + "wr_grand": _make_run(run_id="wr_grand", workflow_id="w_grand", parent_run_id=None), + } + workflows = { + "w_parent": _make_workflow("w_parent", workflow_system_prompt=None), + "w_grand": _make_workflow("w_grand", workflow_system_prompt="GRAND rule."), + } + _install_chain(monkeypatch, runs, workflows) + + result = await WorkflowService()._collect_inherited_workflow_system_prompt(parent_workflow_run_id="wr_parent") + + assert result is None + + +@pytest.mark.asyncio +async def test_cycle_detection_breaks_loop(monkeypatch: pytest.MonkeyPatch) -> None: + """A parent chain that points back to an already-visited run must not infinite- + loop. The helper should collect each unique ancestor's prompt exactly once.""" + runs = { + "wr_a": _make_run(run_id="wr_a", workflow_id="w_a", parent_run_id="wr_b"), + "wr_b": _make_run(run_id="wr_b", workflow_id="w_b", parent_run_id="wr_a"), + } + workflows = { + "w_a": _make_workflow("w_a", workflow_system_prompt="A rule."), + "w_b": _make_workflow("w_b", workflow_system_prompt="B rule."), + } + get_run, _ = _install_chain(monkeypatch, runs, workflows) + + result = await WorkflowService()._collect_inherited_workflow_system_prompt(parent_workflow_run_id="wr_a") + + assert result == "B rule.\n\nA rule." + # Each unique run fetched exactly once — no infinite loop. + assert get_run.await_count == 2 + + +@pytest.mark.asyncio +async def test_missing_parent_run_breaks_chain(monkeypatch: pytest.MonkeyPatch) -> None: + """If ``get_workflow_run`` returns None mid-walk (soft-deleted / race), + traversal stops cleanly and returns whatever was collected so far.""" + runs = { + "wr_parent": _make_run(run_id="wr_parent", workflow_id="w_parent", parent_run_id="wr_missing"), + } + workflows = {"w_parent": _make_workflow("w_parent", workflow_system_prompt="PARENT rule.")} + _install_chain(monkeypatch, runs, workflows) + + result = await WorkflowService()._collect_inherited_workflow_system_prompt(parent_workflow_run_id="wr_parent") + + assert result == "PARENT rule." + + +@pytest.mark.asyncio +async def test_missing_parent_workflow_skips_that_ancestor(monkeypatch: pytest.MonkeyPatch) -> None: + """If ``get_workflow`` returns None (deleted definition) the traversal still + continues up the chain, silently skipping that level rather than aborting.""" + runs = { + "wr_parent": _make_run(run_id="wr_parent", workflow_id="w_parent_missing", parent_run_id="wr_grand"), + "wr_grand": _make_run(run_id="wr_grand", workflow_id="w_grand", parent_run_id=None), + } + workflows = {"w_grand": _make_workflow("w_grand", workflow_system_prompt="GRAND rule.")} + _install_chain(monkeypatch, runs, workflows) + + result = await WorkflowService()._collect_inherited_workflow_system_prompt(parent_workflow_run_id="wr_parent") + + assert result == "GRAND rule." + + +@pytest.mark.asyncio +async def test_depth_cap_bounds_traversal(monkeypatch: pytest.MonkeyPatch) -> None: + """A malformed deep chain must stop at ``MAX_TRIGGER_DEPTH`` — the helper + collects ancestors up to the cap and returns those without hanging.""" + depth = WorkflowTriggerBlock.MAX_TRIGGER_DEPTH + 5 + runs: dict[str, WorkflowRun] = {} + workflows: dict[str, Workflow] = {} + for i in range(depth): + run_id = f"wr_{i}" + workflow_id = f"w_{i}" + parent_id = f"wr_{i + 1}" if i + 1 < depth else None + runs[run_id] = _make_run(run_id=run_id, workflow_id=workflow_id, parent_run_id=parent_id) + workflows[workflow_id] = _make_workflow(workflow_id, workflow_system_prompt=f"rule {i}.") + + get_run, _ = _install_chain(monkeypatch, runs, workflows) + + result = await WorkflowService()._collect_inherited_workflow_system_prompt(parent_workflow_run_id="wr_0") + + # Exactly MAX_TRIGGER_DEPTH ancestors fetched; deeper ones dropped. + assert get_run.await_count == WorkflowTriggerBlock.MAX_TRIGGER_DEPTH + assert result is not None + # Parts are "rule 0." … "rule (cap-1)." joined outermost-first. + expected_parts = [f"rule {i}." for i in reversed(range(WorkflowTriggerBlock.MAX_TRIGGER_DEPTH))] + assert result == "\n\n".join(expected_parts) diff --git a/tests/unit/test_data_extraction_summary_schema_cap.py b/tests/unit/test_data_extraction_summary_schema_cap.py index 8da4cba8c..312d721d0 100644 --- a/tests/unit/test_data_extraction_summary_schema_cap.py +++ b/tests/unit/test_data_extraction_summary_schema_cap.py @@ -18,7 +18,7 @@ def _run_create_extract_action(monkeypatch, extracted_information_schema): captured.update(kwargs) return original_load_prompt(template_name, **kwargs) - async def fake_handler(*, prompt, step, prompt_name): + async def fake_handler(*, prompt, step, prompt_name, **_ignored): captured["prompt"] = prompt return {"summary": "ok"} diff --git a/tests/unit/test_downloaded_files_artifact_urls.py b/tests/unit/test_downloaded_files_artifact_urls.py new file mode 100644 index 000000000..b169d63db --- /dev/null +++ b/tests/unit/test_downloaded_files_artifact_urls.py @@ -0,0 +1,507 @@ +"""Tests for downloaded_files migration to short artifact URLs (SKY-8861).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import urlparse + +import pytest + +from skyvern.forge.sdk.artifact.manager import ArtifactManager +from skyvern.forge.sdk.artifact.models import Artifact, ArtifactType +from skyvern.forge.sdk.artifact.storage.s3 import S3Storage + + +def _is_amazonaws_s3_url(url: str) -> bool: + """Strict check that ``url`` is a real ``*.s3.amazonaws.com`` URL. + + Avoids the substring trap CodeQL flags as ``py/incomplete-url-substring-sanitization`` + — ``"s3.amazonaws.com" in url`` matches ``http://evil.com/?x=s3.amazonaws.com`` + and similar bypasses. Parse the URL and check the hostname suffix instead. + """ + host = urlparse(url).hostname + if host is None: + return False + return host == "s3.amazonaws.com" or host.endswith(".s3.amazonaws.com") + + +@pytest.mark.asyncio +async def test_create_download_artifact_is_idempotent_per_run_and_uri(): + """A repeat save (e.g. inside a loop) must return the existing artifact_id so + downstream URL-based dedup (``loop_download_filter``) keeps seeing a stable URL. + """ + manager = ArtifactManager() + + existing = Artifact( + artifact_id="a_existing", + artifact_type=ArtifactType.DOWNLOAD, + uri="s3://skyvern-uploads/downloads/local/o_1/wr_1/file.pdf", + organization_id="o_1", + run_id="wr_1", + workflow_run_id="wr_1", + created_at="2026-04-23T00:00:00Z", + modified_at="2026-04-23T00:00:00Z", + ) + find_existing = AsyncMock(return_value=existing) + mock_db_create = AsyncMock() + + with ( + patch( + "skyvern.forge.sdk.artifact.manager.app.DATABASE.artifacts.find_download_artifact", + find_existing, + ), + patch( + "skyvern.forge.sdk.artifact.manager.app.DATABASE.artifacts.create_artifact", + mock_db_create, + ), + ): + artifact_id = await manager.create_download_artifact( + organization_id="o_1", + run_id="wr_1", + workflow_run_id="wr_1", + uri=existing.uri, + filename="file.pdf", + ) + + assert artifact_id == "a_existing" + mock_db_create.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_download_artifact_inserts_row_without_uploading(): + """create_download_artifact only writes a DB row; bytes are already in S3.""" + manager = ArtifactManager() + + mock_db_create = AsyncMock( + return_value=Artifact( + artifact_id="a_abc123", + artifact_type=ArtifactType.DOWNLOAD, + uri="s3://skyvern-uploads/download/prod/o_1/wr_1/file.pdf", + organization_id="o_1", + run_id="wr_1", + workflow_run_id="wr_1", + created_at="2026-04-23T00:00:00Z", + modified_at="2026-04-23T00:00:00Z", + ) + ) + mock_store = AsyncMock() + find_existing = AsyncMock(return_value=None) + + with ( + patch( + "skyvern.forge.sdk.artifact.manager.app.DATABASE.artifacts.find_download_artifact", + find_existing, + ), + patch("skyvern.forge.sdk.artifact.manager.app.DATABASE.artifacts.create_artifact", mock_db_create), + patch("skyvern.forge.sdk.artifact.manager.app.STORAGE.store_artifact", mock_store), + patch("skyvern.forge.sdk.artifact.manager.app.STORAGE.store_artifact_from_path", mock_store), + ): + artifact_id = await manager.create_download_artifact( + organization_id="o_1", + run_id="wr_1", + workflow_run_id="wr_1", + uri="s3://skyvern-uploads/download/prod/o_1/wr_1/file.pdf", + filename="file.pdf", + ) + + assert artifact_id.startswith("a_") + mock_db_create.assert_awaited_once() + _, kwargs = mock_db_create.call_args + assert kwargs["artifact_type"] == ArtifactType.DOWNLOAD + assert kwargs["uri"] == "s3://skyvern-uploads/download/prod/o_1/wr_1/file.pdf" + assert kwargs["organization_id"] == "o_1" + assert kwargs["run_id"] == "wr_1" + assert kwargs["workflow_run_id"] == "wr_1" + mock_store.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_save_downloaded_files_registers_artifact_per_file(tmp_path): + """After uploading each file to S3, save_downloaded_files should create an + Artifact row so later retrieval can build short /v1/artifacts URLs.""" + download_dir = tmp_path / "downloads" + download_dir.mkdir() + (download_dir / "invoice.pdf").write_bytes(b"%PDF-1.4 ...") + (download_dir / "report.csv").write_bytes(b"a,b,c\n1,2,3\n") + + storage = S3Storage() + storage.async_client = MagicMock() + storage.async_client.upload_file_from_path = AsyncMock() + + mock_create_download = AsyncMock(return_value="a_new") + mock_artifact_manager = MagicMock() + mock_artifact_manager.create_download_artifact = mock_create_download + + with ( + patch("skyvern.forge.sdk.artifact.storage.s3.get_download_dir", return_value=str(download_dir)), + patch.object(storage, "_get_storage_class_for_org", new=AsyncMock(return_value=MagicMock())), + patch("skyvern.forge.sdk.artifact.storage.s3.calculate_sha256_for_file", return_value="sha-xyz"), + patch("skyvern.forge.sdk.artifact.storage.s3.app") as app_module, + ): + app_module.ARTIFACT_MANAGER = mock_artifact_manager + await storage.save_downloaded_files(organization_id="o_1", run_id="wr_1") + + assert mock_create_download.await_count == 2 + uris = {call.kwargs["uri"] for call in mock_create_download.await_args_list} + filenames = {call.kwargs["filename"] for call in mock_create_download.await_args_list} + assert filenames == {"invoice.pdf", "report.csv"} + assert all(u.startswith("s3://") and "/downloads/" in u and "/o_1/wr_1/" in u for u in uris) + for call in mock_create_download.await_args_list: + assert call.kwargs["organization_id"] == "o_1" + assert call.kwargs["run_id"] == "wr_1" + + +def _make_artifact( + artifact_id: str, + uri: str, + run_id: str = "wr_1", + *, + checksum: str | None = None, + created_at: str = "2026-04-23T00:00:00Z", +) -> Artifact: + return Artifact( + artifact_id=artifact_id, + artifact_type=ArtifactType.DOWNLOAD, + uri=uri, + organization_id="o_1", + run_id=run_id, + workflow_run_id=run_id if run_id.startswith("wr_") else None, + checksum=checksum, + created_at=created_at, + modified_at=created_at, + ) + + +_DUMMY_KEYRING_JSON = '{"current_kid": "k1", "keys": {"k1": {"secret": "0000000000000000000000000000000000000000000000000000000000000000"}}}' + + +@pytest.fixture +def keyring_configured(): + """Simulate cloud-style config: HMAC keyring is set so the artifact URL branch is active. + Unit tests default to no keyring to match the OSS default, so tests that exercise the + short-URL path must opt in.""" + from skyvern.config import settings + + with patch.object(settings, "ARTIFACT_CONTENT_HMAC_KEYRING", _DUMMY_KEYRING_JSON): + yield + + +@pytest.mark.asyncio +async def test_get_downloaded_files_uses_artifact_urls_when_rows_exist(keyring_configured): + """When DOWNLOAD artifact rows exist, retrieval skips S3 entirely: + URL, checksum, filename, modified_at all come straight from the row.""" + storage = S3Storage() + storage.async_client = MagicMock() + storage.async_client.list_files = AsyncMock() # must NOT be called + storage.async_client.get_file_metadata = AsyncMock() # must NOT be called + storage.async_client.create_presigned_urls = AsyncMock() # must NOT be called + + artifact = _make_artifact( + "a_42", + "s3://skyvern-uploads/downloads/local/o_1/wr_1/invoice.pdf", + checksum="sha-from-db", + ) + mock_list = AsyncMock(return_value=[artifact]) + build_url = MagicMock(return_value="https://api.skyvern.com/v1/artifacts/a_42/content?expiry=x&kid=y&sig=z") + + with patch("skyvern.forge.sdk.artifact.storage.base.app") as base_app: + with patch("skyvern.forge.sdk.artifact.storage.s3.app") as s3_app: + s3_app.DATABASE.artifacts.list_artifacts_for_run_by_type = mock_list + base_app.ARTIFACT_MANAGER.build_signed_content_url = build_url + result = await storage.get_downloaded_files(organization_id="o_1", run_id="wr_1") + + assert len(result) == 1 + assert result[0].url.startswith("https://api.skyvern.com/v1/artifacts/a_42/content") + assert result[0].filename == "invoice.pdf" + assert result[0].checksum == "sha-from-db" + assert result[0].modified_at is not None + storage.async_client.list_files.assert_not_awaited() + storage.async_client.get_file_metadata.assert_not_awaited() + storage.async_client.create_presigned_urls.assert_not_awaited() + mock_list.assert_awaited_once_with(run_id="wr_1", organization_id="o_1", artifact_type=ArtifactType.DOWNLOAD) + + +@pytest.mark.asyncio +async def test_get_downloaded_files_preserves_artifact_row_order(keyring_configured): + """Artifact rows are returned ASC by created_at; FileInfo list must follow the + same order (matches save order, drives loop_download_filter signatures).""" + storage = S3Storage() + storage.async_client = MagicMock() + + first = _make_artifact( + "a_1", + "s3://skyvern-uploads/downloads/local/o_1/wr_1/first.pdf", + created_at="2026-04-23T00:00:00Z", + ) + second = _make_artifact( + "a_2", + "s3://skyvern-uploads/downloads/local/o_1/wr_1/second.pdf", + created_at="2026-04-23T00:01:00Z", + ) + mock_list = AsyncMock(return_value=[first, second]) + build_url = MagicMock( + side_effect=lambda artifact_id, **_: f"https://api.skyvern.com/v1/artifacts/{artifact_id}/content" + ) + + with patch("skyvern.forge.sdk.artifact.storage.base.app") as base_app: + with patch("skyvern.forge.sdk.artifact.storage.s3.app") as s3_app: + s3_app.DATABASE.artifacts.list_artifacts_for_run_by_type = mock_list + base_app.ARTIFACT_MANAGER.build_signed_content_url = build_url + result = await storage.get_downloaded_files(organization_id="o_1", run_id="wr_1") + + assert [fi.filename for fi in result] == ["first.pdf", "second.pdf"] + + +@pytest.mark.asyncio +async def test_get_downloaded_files_falls_back_to_presigned_for_legacy_runs(keyring_configured): + """Production-cloud legacy run: keyring IS configured, but the run pre-dates SKY-8861 + so no artifact rows exist. Files in S3 must still surface as presigned URLs — the + whole point of keeping the fallback path.""" + storage = S3Storage() + storage.async_client = MagicMock() + s3_key = "downloads/local/o_1/wr_old/legacy.pdf" + storage.async_client.list_files = AsyncMock(return_value=[s3_key]) + storage.async_client.get_file_metadata = AsyncMock( + return_value={"sha256_checksum": "sha-old", "original_filename": "legacy.pdf"} + ) + storage.async_client.create_presigned_urls = AsyncMock( + return_value=["https://skyvern-uploads.s3.amazonaws.com/...?sig=old"] + ) + + mock_list = AsyncMock(return_value=[]) # no artifact rows for this legacy run + build_url = MagicMock() # must NOT be called + + with patch("skyvern.forge.sdk.artifact.storage.base.app") as base_app: + with patch("skyvern.forge.sdk.artifact.storage.s3.app") as s3_app: + s3_app.DATABASE.artifacts.list_artifacts_for_run_by_type = mock_list + base_app.ARTIFACT_MANAGER.build_signed_content_url = build_url + result = await storage.get_downloaded_files(organization_id="o_1", run_id="wr_old") + + assert len(result) == 1 + assert result[0].filename == "legacy.pdf" + assert result[0].checksum == "sha-old" + assert _is_amazonaws_s3_url(result[0].url) + build_url.assert_not_called() + storage.async_client.list_files.assert_awaited_once() + storage.async_client.create_presigned_urls.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_get_downloaded_files_falls_back_to_presigned_when_keyring_unset(tmp_path): + """Self-hosted OSS deployments without ARTIFACT_CONTENT_HMAC_KEYRING must keep + serving presigned S3 URLs — the short Skyvern URL would be unsigned and the + content endpoint would 401 without an API key.""" + from skyvern.config import settings + + storage = S3Storage() + storage.async_client = MagicMock() + s3_key = "downloads/local/o_1/wr_1/invoice.pdf" + storage.async_client.list_files = AsyncMock(return_value=[s3_key]) + storage.async_client.get_file_metadata = AsyncMock( + return_value={"sha256_checksum": "sha-abc", "original_filename": "invoice.pdf"} + ) + storage.async_client.create_presigned_urls = AsyncMock( + return_value=["https://skyvern-uploads.s3.amazonaws.com/...?sig=fallback"] + ) + + artifact = _make_artifact("a_42", f"s3://skyvern-uploads/{s3_key}") + mock_list = AsyncMock(return_value=[artifact]) + build_url = MagicMock() + + with ( + patch("skyvern.forge.sdk.artifact.storage.s3.app") as app_module, + patch.object(settings, "ARTIFACT_CONTENT_HMAC_KEYRING", None), + ): + app_module.DATABASE.artifacts.list_artifacts_for_run_by_type = mock_list + app_module.ARTIFACT_MANAGER.build_signed_content_url = build_url + result = await storage.get_downloaded_files(organization_id="o_1", run_id="wr_1") + + assert len(result) == 1 + assert _is_amazonaws_s3_url(result[0].url) + build_url.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_downloaded_files_artifact_lookup_failure_falls_back_to_listing(keyring_configured): + """If the DB lookup raises (transient outage), retrieval must not 500 the + run-output API — fall through to the legacy S3-listing path so files + still surface as presigned URLs.""" + storage = S3Storage() + storage.async_client = MagicMock() + s3_key = "downloads/local/o_1/wr_1/recoverable.pdf" + storage.async_client.list_files = AsyncMock(return_value=[s3_key]) + storage.async_client.get_file_metadata = AsyncMock( + return_value={"sha256_checksum": "sha-recover", "original_filename": "recoverable.pdf"} + ) + storage.async_client.create_presigned_urls = AsyncMock( + return_value=["https://skyvern-uploads.s3.amazonaws.com/...?sig=fallback"] + ) + + mock_list = AsyncMock(side_effect=RuntimeError("DB unreachable")) + + with patch("skyvern.forge.sdk.artifact.storage.s3.app") as app_module: + app_module.DATABASE.artifacts.list_artifacts_for_run_by_type = mock_list + result = await storage.get_downloaded_files(organization_id="o_1", run_id="wr_1") + + assert len(result) == 1 + assert _is_amazonaws_s3_url(result[0].url) + storage.async_client.list_files.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_content_endpoint_download_returns_attachment_with_filename(): + """DOWNLOAD artifacts must serve with attachment disposition so browsers don't render + PDFs inline (defeats the SKY-8862 XSS-via-PDF mitigation).""" + from skyvern.forge.sdk.routes.agent_protocol import _artifact_response_config + + artifact = _make_artifact("a_dl", "s3://skyvern-uploads/downloads/local/o_1/wr_1/invoice.pdf") + media_type, disposition = _artifact_response_config(artifact) + assert media_type == "application/octet-stream" + assert disposition.startswith("attachment;") + assert 'filename="invoice.pdf"' in disposition + + +def test_content_endpoint_non_download_stays_inline(): + """Existing artifact types keep the inline disposition we had before.""" + from skyvern.forge.sdk.routes.agent_protocol import _artifact_response_config + + screenshot = Artifact( + artifact_id="a_ss", + artifact_type=ArtifactType.SCREENSHOT_FINAL, + uri="s3://skyvern-artifacts/.../final.png", + organization_id="o_1", + created_at="2026-04-23T00:00:00Z", + modified_at="2026-04-23T00:00:00Z", + ) + media_type, disposition = _artifact_response_config(screenshot) + assert media_type == "image/png" + assert disposition == "inline" + + +def test_content_endpoint_download_non_ascii_filename_does_not_crash_header_encoding(): + """Starlette encodes response headers as Latin-1. Unicode filenames must use RFC 5987 + (filename*=UTF-8''...) with an ASCII fallback so the endpoint does not 500.""" + from starlette.responses import Response + + from skyvern.forge.sdk.routes.agent_protocol import _artifact_response_config + + artifact = _make_artifact("a_unicode", "s3://skyvern-uploads/downloads/local/o_1/wr_1/文档.pdf") + media_type, disposition = _artifact_response_config(artifact) + # Must not raise — Starlette's header encoding rejects non-Latin-1 bytes. + Response(content=b"x", media_type=media_type, headers={"Content-Disposition": disposition}) + assert "filename*=UTF-8''" in disposition + assert "%E6%96%87%E6%A1%A3.pdf" in disposition or "%e6%96%87%e6%a1%a3.pdf" in disposition + + +def test_sanitize_header_filename_strips_crlf_and_quotes_directly(): + """Direct unit test on _sanitize_header_filename so we know the function works + even when urlparse isn't part of the chain (defense in depth).""" + from skyvern.forge.sdk.routes.agent_protocol import _sanitize_header_filename + + assert _sanitize_header_filename('evil"pdf') == "evilpdf" + assert _sanitize_header_filename("hello\r\nworld.pdf") == "helloworld.pdf" + assert _sanitize_header_filename("back\\slash.pdf") == "backslash.pdf" + assert _sanitize_header_filename("") == "download" + + +def test_content_endpoint_download_filename_preserves_question_and_hash(): + """S3 keys may legitimately contain '?' or '#'; urlparse would otherwise strip them.""" + from skyvern.forge.sdk.routes.agent_protocol import _artifact_response_config + + artifact = _make_artifact("a_q", "s3://skyvern-uploads/downloads/local/o_1/wr_1/report?v=2#a.pdf") + _, disposition = _artifact_response_config(artifact) + assert "report%3Fv%3D2%23a.pdf" in disposition or "report?v=2#a.pdf" in disposition + + +def test_sanitize_header_filename_strips_bidi_and_format_characters(): + """Unicode bidi overrides and format chars (ZWSP, RLO, ZWNBSP) enable filename + spoofing in the browser's download UI (``invoice\\u202efdp.exe`` -> ``invoice.exe.pdf``).""" + from skyvern.forge.sdk.routes.agent_protocol import _sanitize_header_filename + + assert "\u202e" not in _sanitize_header_filename("invoice\u202efdp.exe") + assert "\u200b" not in _sanitize_header_filename("stealth\u200b.pdf") + assert "\ufeff" not in _sanitize_header_filename("bom\ufeff.pdf") + + +def test_ascii_fallback_filename_preserves_stem_for_pure_unicode_names(): + """Pure non-ASCII names (e.g. CJK, emoji) must not reduce to a bare ``.pdf`` hidden + dotfile after the NFKD strip; fall back to a ``download`` stem instead.""" + from skyvern.forge.sdk.routes.agent_protocol import _ascii_fallback_filename + + assert _ascii_fallback_filename("文档.pdf") == "download.pdf" + assert _ascii_fallback_filename("🎉.pdf") == "download.pdf" + # Accented Latin still transliterates to keep the stem. + assert _ascii_fallback_filename("fïlè.pdf") == "file.pdf" + + +def test_sanitize_header_filename_strips_control_characters(): + """NUL/DEL/C1 control chars are valid Latin-1 bytes but violate RFC 7230 header syntax.""" + from skyvern.forge.sdk.routes.agent_protocol import _sanitize_header_filename + + assert "\x00" not in _sanitize_header_filename("evil\x00.pdf") + assert "\x7f" not in _sanitize_header_filename("evil\x7f.pdf") + assert "\x1b" not in _sanitize_header_filename("evil\x1b.pdf") + assert "\x80" not in _sanitize_header_filename("evil\x80.pdf") + + +@pytest.mark.asyncio +async def test_content_endpoint_sets_nosniff_header_end_to_end(): + """Hit the real route through a FastAPI TestClient and verify the response + actually carries X-Content-Type-Options: nosniff on the DOWNLOAD path. + + Defence-in-depth for SKY-8862: prevents a refactor from silently dropping + the header without this suite noticing.""" + import json + + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from skyvern.config import settings + from skyvern.forge.sdk.artifact.signing import sign_artifact_url + from skyvern.forge.sdk.routes.routers import base_router + + artifact = _make_artifact("a_e2e", "s3://skyvern-uploads/downloads/local/o_1/wr_1/report.pdf") + keyring_json = json.dumps({"current_kid": "k1", "keys": {"k1": {"secret": "0" * 64, "created_at": "2026-04-23"}}}) + + with ( + patch.object(settings, "ARTIFACT_CONTENT_HMAC_KEYRING", keyring_json), + patch.object(settings, "SKYVERN_BASE_URL", "http://testserver"), + patch("skyvern.forge.sdk.routes.agent_protocol.app") as app_module, + ): + app_module.DATABASE.artifacts.get_artifact_by_id_no_org = AsyncMock(return_value=artifact) + app_module.ARTIFACT_MANAGER.retrieve_artifact = AsyncMock(return_value=b"%PDF-1.4 fake body") + + from skyvern.forge.sdk.artifact.signing import parse_keyring + + signed_url = sign_artifact_url( + base_url="http://testserver", + artifact_id=artifact.artifact_id, + keyring=parse_keyring(keyring_json), + artifact_name="report.pdf", + artifact_type="download", + ) + + test_app = FastAPI() + test_app.include_router(base_router, prefix="/v1") + client = TestClient(test_app) + resp = client.get(signed_url.replace("http://testserver", "")) + + assert resp.status_code == 200, resp.text + assert resp.headers.get("X-Content-Type-Options") == "nosniff" + assert resp.headers.get("Content-Disposition", "").startswith("attachment;") + assert resp.content == b"%PDF-1.4 fake body" + + +def test_content_endpoint_download_filename_strips_header_injection(): + """URI-derived filenames go straight into a Content-Disposition header; + CR/LF and raw quotes must be stripped to prevent header injection.""" + from skyvern.forge.sdk.routes.agent_protocol import _artifact_response_config + + artifact = _make_artifact( + "a_bad", + 's3://skyvern-uploads/downloads/local/o_1/wr_1/evil"\r\nSet-Cookie: x=y.pdf', + ) + _, disposition = _artifact_response_config(artifact) + assert "\r" not in disposition + assert "\n" not in disposition + assert disposition.count('"') == 2 # only the pair around filename diff --git a/tests/unit/test_extract_information_text_optout.py b/tests/unit/test_extract_information_text_optout.py index de5abf2b6..a4f49fb78 100644 --- a/tests/unit/test_extract_information_text_optout.py +++ b/tests/unit/test_extract_information_text_optout.py @@ -196,7 +196,7 @@ def _capture_ai_extract_kwargs(monkeypatch, include_extracted_text: bool): async def fake_refresh(*_args, **_kwargs): return None - async def fake_handler(*, prompt, step, screenshots, prompt_name, force_dict): + async def fake_handler(*, prompt, step, screenshots, prompt_name, force_dict, **_ignored): return {} monkeypatch.setattr(module, "load_prompt_with_elements_tracked", fake_load_prompt_with_elements_tracked) @@ -258,7 +258,7 @@ def _capture_ai_extract_kwargs_with_schema(monkeypatch, schema): async def fake_refresh(*_args, **_kwargs): return None - async def fake_handler(*, prompt, step, screenshots, prompt_name, force_dict): + async def fake_handler(*, prompt, step, screenshots, prompt_name, force_dict, **_ignored): return {} monkeypatch.setattr(module, "load_prompt_with_elements_tracked", fake_load_prompt_with_elements_tracked) diff --git a/tests/unit/test_workflow_parameter_validation.py b/tests/unit/test_workflow_parameter_validation.py index 1a68b4eb8..ce0df96d2 100644 --- a/tests/unit/test_workflow_parameter_validation.py +++ b/tests/unit/test_workflow_parameter_validation.py @@ -625,3 +625,40 @@ class TestSanitizeWorkflowYamlWithReferences: goal = result["workflow_definition"]["blocks"][0]["navigation_goal"] assert "{{ block_1 }}" in goal assert "{{ block_1_output }}" in goal + + def test_sanitize_updates_output_references_in_workflow_system_prompt(self) -> None: + """Output references inside the workflow-level workflow_system_prompt must + be rewritten when the referenced block label is sanitized. The global + prompt is resolved through Jinja at execution time, so its references + need the same renaming treatment as block-level fields.""" + workflow_yaml = { + "title": "Test Workflow", + "workflow_definition": { + "parameters": [], + "blocks": [{"label": "my-block", "block_type": "task"}], + "workflow_system_prompt": "Honor {{ my-block_output }} for every downstream block.", + }, + } + result = sanitize_workflow_yaml_with_references(workflow_yaml) + assert result["workflow_definition"]["blocks"][0]["label"] == "my_block" + assert "{{ my_block_output }}" in result["workflow_definition"]["workflow_system_prompt"] + + def test_sanitize_parameter_key_updates_jinja_references_in_workflow_system_prompt(self) -> None: + """Parameter-key references inside the workflow-level workflow_system_prompt + must be rewritten when the parameter key is sanitized.""" + workflow_yaml = { + "title": "Test Workflow", + "workflow_definition": { + "parameters": [ + { + "key": "user-input", + "parameter_type": "workflow", + } + ], + "blocks": [], + "workflow_system_prompt": "Always respond in the style of {{ user-input }}.", + }, + } + result = sanitize_workflow_yaml_with_references(workflow_yaml) + assert result["workflow_definition"]["parameters"][0]["key"] == "user_input" + assert "{{ user_input }}" in result["workflow_definition"]["workflow_system_prompt"] diff --git a/tests/unit/test_workflow_system_prompt_llm_forwarding.py b/tests/unit/test_workflow_system_prompt_llm_forwarding.py new file mode 100644 index 000000000..08e9bdb3a --- /dev/null +++ b/tests/unit/test_workflow_system_prompt_llm_forwarding.py @@ -0,0 +1,387 @@ +"""Regression tests: workflow-level workflow_system_prompt must reach the LLM handler. + +Complements ``test_block_workflow_system_prompt_inheritance.py`` (which asserts +that the workflow prompt flows onto the block model). These tests assert the +next hop — that each block/function that makes an LLM call actually forwards +``system_prompt`` as a kwarg to the handler. Without this, inheritance passes +quietly but the prompt has no effect on output (the bug the user hit on the +extraction path). +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +from skyvern.forge.sdk.cache import extraction_cache +from skyvern.forge.sdk.workflow.models.block import FileParserBlock, PDFParserBlock, TextPromptBlock +from skyvern.forge.sdk.workflow.models.parameter import OutputParameter, ParameterType +from skyvern.schemas.workflows import FileType + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _make_output_parameter() -> OutputParameter: + now = datetime.now(timezone.utc) + return OutputParameter( + parameter_type=ParameterType.OUTPUT, + key="task1_output", + description="test output", + output_parameter_id="op_task1", + workflow_id="w_test", + created_at=now, + modified_at=now, + ) + + +def _make_scraped_page(): + refreshed = MagicMock() + refreshed.extracted_text = "page text" + refreshed.url = "https://example.test" + refreshed.screenshots = [] + refreshed.build_element_tree = MagicMock(return_value="link") + refreshed.support_economy_elements_tree = MagicMock(return_value=False) + refreshed.last_used_element_tree_html = None + + scraped_page = MagicMock() + scraped_page.refresh = AsyncMock(return_value=refreshed) + scraped_page.screenshots = [] + return scraped_page + + +def _make_task(*, system_prompt: str | None) -> MagicMock: + task = MagicMock() + task.navigation_goal = None + task.navigation_payload = None + task.extracted_information = None + task.extracted_information_schema = {"type": "object"} + task.data_extraction_goal = "Extract documents" + task.error_code_mapping = None + task.llm_key = None + task.workflow_run_id = "wfr_sysprompt" + task.task_id = "tsk_sysprompt" + task.workflow_permanent_id = "wpid_sysprompt" + task.organization_id = "o_sysprompt" + task.include_extracted_text = True + task.workflow_system_prompt = system_prompt + return task + + +# --------------------------------------------------------------------------- +# extract-information handler (the OP's exact failure path) +# --------------------------------------------------------------------------- + + +def test_extract_information_forwards_system_prompt(monkeypatch) -> None: + """Regression for the OP's bug: an ExtractionBlock whose Task carries a + ``system_prompt`` (inherited from workflow.workflow_system_prompt) must + forward it to the extract-information LLM call.""" + from skyvern.webeye.actions import handler + + extraction_cache._reset_for_tests() + + captured: dict = {} + + async def fake_llm(**kwargs): + captured.update(kwargs) + return {"quotes": "SHOUTED QUOTE"} + + monkeypatch.setattr( + handler, + "load_prompt_with_elements_tracked", + lambda **kwargs: ("rendered-prompt", dict(kwargs)), + ) + monkeypatch.setattr(handler, "ensure_context", lambda: MagicMock(tz_info=None)) + monkeypatch.setattr(handler.service_utils, "is_cua_task", AsyncMock(return_value=False)) + monkeypatch.setattr( + handler.LLMAPIHandlerFactory, + "get_override_llm_api_handler", + lambda llm_key, default: fake_llm, + ) + monkeypatch.setattr( + handler.app.AGENT_FUNCTION, + "should_shadow_extraction_cache_hit", + AsyncMock(return_value=False), + ) + monkeypatch.setattr( + handler.app.AGENT_FUNCTION, + "lookup_cross_run_extraction_cache", + AsyncMock(return_value=None), + ) + monkeypatch.setattr( + handler.app.AGENT_FUNCTION, + "store_cross_run_extraction_cache", + AsyncMock(return_value=None), + ) + + task = _make_task(system_prompt="Respond only in uppercase.") + step = MagicMock(step_id="stp_sp", retry_index=0) + scraped_page = _make_scraped_page() + + asyncio.run(handler.extract_information_for_navigation_goal(task=task, step=step, scraped_page=scraped_page)) + + assert captured.get("system_prompt") == "Respond only in uppercase." + extraction_cache._reset_for_tests() + + +def test_extract_information_passes_none_system_prompt_when_task_has_none(monkeypatch) -> None: + """No workflow_system_prompt → task.workflow_system_prompt is None → handler receives None. + Locks in that we don't accidentally invent a default or drop the kwarg.""" + from skyvern.webeye.actions import handler + + extraction_cache._reset_for_tests() + + captured: dict = {} + + async def fake_llm(**kwargs): + captured.update(kwargs) + return {"quotes": "x"} + + monkeypatch.setattr( + handler, + "load_prompt_with_elements_tracked", + lambda **kwargs: ("rendered-prompt", dict(kwargs)), + ) + monkeypatch.setattr(handler, "ensure_context", lambda: MagicMock(tz_info=None)) + monkeypatch.setattr(handler.service_utils, "is_cua_task", AsyncMock(return_value=False)) + monkeypatch.setattr( + handler.LLMAPIHandlerFactory, + "get_override_llm_api_handler", + lambda llm_key, default: fake_llm, + ) + monkeypatch.setattr( + handler.app.AGENT_FUNCTION, + "should_shadow_extraction_cache_hit", + AsyncMock(return_value=False), + ) + monkeypatch.setattr( + handler.app.AGENT_FUNCTION, + "lookup_cross_run_extraction_cache", + AsyncMock(return_value=None), + ) + monkeypatch.setattr( + handler.app.AGENT_FUNCTION, + "store_cross_run_extraction_cache", + AsyncMock(return_value=None), + ) + + task = _make_task(system_prompt=None) + step = MagicMock(step_id="stp_none", retry_index=0) + scraped_page = _make_scraped_page() + + asyncio.run(handler.extract_information_for_navigation_goal(task=task, step=step, scraped_page=scraped_page)) + + assert "system_prompt" in captured + assert captured["system_prompt"] is None + extraction_cache._reset_for_tests() + + +# --------------------------------------------------------------------------- +# data-extraction-summary (agent.py path) +# --------------------------------------------------------------------------- + + +def test_data_extraction_summary_forwards_system_prompt(monkeypatch) -> None: + from skyvern.forge import agent as agent_module + + captured: dict = {} + + async def fake_handler(**kwargs): + captured.update(kwargs) + return {"summary": "ok"} + + monkeypatch.setattr(agent_module.app, "EXTRACTION_LLM_API_HANDLER", fake_handler) + monkeypatch.setattr( + agent_module.skyvern_context, + "ensure_context", + lambda: MagicMock(tz_info=None, workflow_run_id="wr_sp"), + ) + monkeypatch.setattr(agent_module.extraction_cache, "compute_cache_key", lambda **_: None) + monkeypatch.setattr(agent_module.extraction_cache, "lookup", lambda *a, **k: None) + + task = _make_task(system_prompt="Answer in French.") + step = MagicMock(step_id="stp_sp", order=0) + scraped_page = MagicMock(url="https://example.test") + + asyncio.run(agent_module.ForgeAgent.create_extract_action(task=task, step=step, scraped_page=scraped_page)) + + assert captured.get("system_prompt") == "Answer in French." + + +# --------------------------------------------------------------------------- +# Extraction cache key — system_prompt must be part of the key +# --------------------------------------------------------------------------- + + +def test_extraction_cache_key_changes_with_system_prompt() -> None: + """Two calls that differ only in system_prompt must produce different + digests — otherwise a user switching workflow_system_prompt mid-run would + get stale output from a prior key's cached value.""" + base_kwargs: dict = dict( + call_path="handler", + element_tree="link", + extracted_text="text", + current_url="https://example.test", + data_extraction_goal="Extract docs", + extracted_information_schema={"type": "object"}, + navigation_payload=None, + error_code_mapping=None, + previous_extracted_information=None, + llm_key=None, + ) + + key_no_sp = extraction_cache.compute_cache_key(**base_kwargs, workflow_system_prompt=None) + key_a = extraction_cache.compute_cache_key(**base_kwargs, workflow_system_prompt="Answer in Spanish.") + key_b = extraction_cache.compute_cache_key(**base_kwargs, workflow_system_prompt="Answer in French.") + + assert key_no_sp != key_a + assert key_a != key_b + # Same prompt → same key (determinism). + key_a2 = extraction_cache.compute_cache_key(**base_kwargs, workflow_system_prompt="Answer in Spanish.") + assert key_a == key_a2 + + +# --------------------------------------------------------------------------- +# TextPromptBlock.send_prompt +# --------------------------------------------------------------------------- + + +def test_text_prompt_block_forwards_system_prompt(monkeypatch) -> None: + from skyvern.forge.sdk.workflow.models import block as block_module + + captured: dict = {} + + async def fake_llm(**kwargs): + captured.update(kwargs) + return {"llm_response": "hi"} + + async def fake_default_handler(*args, **kwargs): + return None + + async def fake_resolve(self, workflow_run_id, organization_id): + return fake_default_handler + + monkeypatch.setattr(TextPromptBlock, "_resolve_default_llm_handler", fake_resolve) + monkeypatch.setattr( + block_module.LLMAPIHandlerFactory, + "get_override_llm_api_handler", + lambda llm_key, default: fake_llm, + ) + + block = TextPromptBlock( + label="p", + output_parameter=_make_output_parameter(), + prompt="What is the meaning of life?", + workflow_system_prompt="Respond as Shakespeare.", + ) + + asyncio.run( + block.send_prompt( + prompt="What is the meaning of life?", + parameter_values={}, + workflow_run_id="wfr_sp", + organization_id="o_sp", + workflow_run_block_id=None, + ) + ) + + assert captured.get("system_prompt") == "Respond as Shakespeare." + + +# --------------------------------------------------------------------------- +# FileParserBlock._extract_with_ai +# --------------------------------------------------------------------------- + + +def test_file_parser_block_forwards_system_prompt(monkeypatch) -> None: + from skyvern.forge.sdk.workflow.models import block as block_module + + captured: dict = {} + + async def fake_llm(**kwargs): + captured.update(kwargs) + return {"output": {}} + + monkeypatch.setattr( + block_module.LLMAPIHandlerFactory, + "get_override_llm_api_handler", + lambda llm_key, default: fake_llm, + ) + + block = FileParserBlock( + label="fp", + output_parameter=_make_output_parameter(), + file_url="https://example.com/file.csv", + file_type=FileType.CSV, + workflow_system_prompt="Parse as JSON only.", + ) + + asyncio.run(block._extract_with_ai("hello,world\n1,2", workflow_run_context=MagicMock())) + + assert captured.get("system_prompt") == "Parse as JSON only." + + +# --------------------------------------------------------------------------- +# PDFParserBlock.execute — system_prompt forwarded to LLM call +# --------------------------------------------------------------------------- + + +def test_pdf_parser_block_forwards_system_prompt(monkeypatch) -> None: + from skyvern.forge.sdk.workflow.models import block as block_module + + captured: dict = {} + + async def fake_llm(**kwargs): + captured.update(kwargs) + return {"output": {}} + + # Patch the app-level handler directly since PDFParserBlock uses app.LLM_API_HANDLER. + monkeypatch.setattr(block_module.app, "LLM_API_HANDLER", fake_llm) + monkeypatch.setattr( + block_module, + "download_file", + AsyncMock(return_value="/tmp/file.pdf"), + ) + monkeypatch.setattr(block_module, "extract_pdf_file", lambda *a, **k: "extracted text") + + workflow_run_context = MagicMock() + workflow_run_context.has_parameter = MagicMock(return_value=False) + workflow_run_context.has_value = MagicMock(return_value=False) + workflow_run_context.workflow = None + workflow_run_context.resolve_effective_workflow_system_prompt = MagicMock(return_value=None) + + monkeypatch.setattr( + PDFParserBlock, + "get_workflow_run_context", + staticmethod(lambda workflow_run_id: workflow_run_context), + ) + monkeypatch.setattr( + PDFParserBlock, + "record_output_parameter_value", + AsyncMock(return_value=None), + ) + monkeypatch.setattr( + PDFParserBlock, + "build_block_result", + AsyncMock(return_value=MagicMock()), + ) + + block = PDFParserBlock( + label="pdf", + output_parameter=_make_output_parameter(), + file_url="https://example.com/file.pdf", + workflow_system_prompt="Summarize in one sentence.", + ) + + asyncio.run( + block.execute( + workflow_run_id="wfr_sp_pdf", + workflow_run_block_id="wrb_sp_pdf", + organization_id="o_sp_pdf", + ) + ) + + assert captured.get("system_prompt") == "Summarize in one sentence."