> = {
function ActionTypePill({ actionType }: Props) {
return (
-
- {icons[actionType] ?? null}
- {ReadableActionTypes[actionType]}
-
+
+ {ReadableActionTypes[actionType]}
+
);
}
diff --git a/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.tsx b/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.tsx
index ea80808b0..500488be2 100644
--- a/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.tsx
+++ b/skyvern-frontend/src/routes/tasks/detail/ScrollableActionList.tsx
@@ -1,5 +1,6 @@
import { getClient } from "@/api/AxiosClient";
import { Action, ActionTypes } from "@/api/types";
+import { StatusPill } from "@/components/ui/status-pill";
import {
Tooltip,
TooltipContent,
@@ -91,9 +92,11 @@ function ScrollableActionList({
-
-
-
+
+ }
+ />
Code Execution
@@ -102,13 +105,15 @@ function ScrollableActionList({
)}
{action.success ? (
-
-
-
+ }
+ />
) : (
-
-
-
+
+ }
+ />
)}
diff --git a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx
index 0624e71f6..031fb8074 100644
--- a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx
+++ b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx
@@ -217,12 +217,7 @@ function WorkflowRun() {
finallyBlockInTimeline;
const workflowFailureReason = workflowRun?.failure_reason ? (
-
+
{failureReasonTitle}
{workflowRun.failure_reason}
{matchedTips}
diff --git a/skyvern-frontend/src/routes/workflows/debugger/DebuggerRun.tsx b/skyvern-frontend/src/routes/workflows/debugger/DebuggerRun.tsx
index 2551d59e7..e600d52ec 100644
--- a/skyvern-frontend/src/routes/workflows/debugger/DebuggerRun.tsx
+++ b/skyvern-frontend/src/routes/workflows/debugger/DebuggerRun.tsx
@@ -5,13 +5,7 @@ function DebuggerRun() {
const { data: workflowRun } = useWorkflowRunQuery();
const workflowFailureReason = workflowRun?.failure_reason ? (
-
+
Run Failure Reason
{workflowRun.failure_reason}
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx
index d67317056..32c6837d6 100644
--- a/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx
+++ b/skyvern-frontend/src/routes/workflows/workflowRun/ActionCard.tsx
@@ -1,4 +1,5 @@
import { ActionsApiResponse, ActionTypes, Status } from "@/api/types";
+import { StatusPill } from "@/components/ui/status-pill";
import {
Tooltip,
TooltipContent,
@@ -65,9 +66,11 @@ function ActionCard({ action, onClick, active, index }: Props) {
-
-
-
+
+ }
+ />
Code Execution
@@ -76,13 +79,13 @@ function ActionCard({ action, onClick, active, index }: Props) {
)}
{success ? (
-
-
-
+
}
+ />
) : (
-
-
-
+
}
+ />
)}
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx
index 1291494b7..2460db963 100644
--- a/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx
+++ b/skyvern-frontend/src/routes/workflows/workflowRun/ThoughtCard.tsx
@@ -1,3 +1,4 @@
+import { StatusPill } from "@/components/ui/status-pill";
import { QuestionMarkIcon } from "@radix-ui/react-icons";
import { ObserverThought } from "../types/workflowRunTypes";
import { cn } from "@/util/utils";
@@ -41,10 +42,9 @@ function ThoughtCard({ thought, onClick, active }: Props) {
{(thought.answer || thought.thought) &&
Thought}
{!thought.answer && !thought.thought &&
Thinking}
-
-
- Decision
-
+ }>
+ Decision
+
{(thought.answer || thought.thought) && (
diff --git a/skyvern/cli/core/result.py b/skyvern/cli/core/result.py
index 94757b5bc..cd62059fb 100644
--- a/skyvern/cli/core/result.py
+++ b/skyvern/cli/core/result.py
@@ -7,6 +7,35 @@ from typing import Any
from skyvern import analytics
+# Module-level flag: when True, make_result() strips fields that waste AI context
+# tokens (echoed inputs, sdk_equivalent, browser_context, timing, empty collections).
+# Set once at MCP server startup; CLI paths leave it False.
+_concise_responses: bool = False
+
+# Fields inside data{} that are debug/scripting aids, not decision-relevant for AI.
+_DATA_STRIP_KEYS = frozenset(
+ {
+ "sdk_equivalent",
+ "ai_mode",
+ "selector",
+ "intent",
+ }
+)
+
+# Keys whose None value is meaningful (e.g. JS eval returning null).
+# These survive the concise filter even when None.
+_DATA_KEEP_NONE_KEYS = frozenset(
+ {
+ "result",
+ "extracted",
+ }
+)
+
+
+def set_concise_responses(enabled: bool) -> None:
+ global _concise_responses # noqa: PLW0603
+ _concise_responses = enabled
+
class ErrorCode:
NO_ACTIVE_BROWSER = "NO_ACTIVE_BROWSER"
@@ -78,6 +107,25 @@ def make_result(
"session_id": browser_context.session_id if browser_context else None,
},
)
+
+ if _concise_responses:
+ result: dict[str, Any] = {"ok": ok}
+ if error:
+ result["error"] = error
+ if warnings:
+ result["warnings"] = warnings
+ if data:
+ concise_data = {
+ k: v
+ for k, v in data.items()
+ if k not in _DATA_STRIP_KEYS and (v is not None or k in _DATA_KEEP_NONE_KEYS)
+ }
+ if concise_data:
+ result["data"] = concise_data
+ if artifacts:
+ result["artifacts"] = [a.to_dict() for a in artifacts]
+ return result
+
return {
"ok": ok,
"action": action,
diff --git a/skyvern/cli/mcp_tools/__init__.py b/skyvern/cli/mcp_tools/__init__.py
index 967bc81b4..8f1fefe05 100644
--- a/skyvern/cli/mcp_tools/__init__.py
+++ b/skyvern/cli/mcp_tools/__init__.py
@@ -230,7 +230,7 @@ Once you've confirmed each step works, compose them into a workflow with skyvern
## Writing Scripts (ONLY when user explicitly asks)
Use the Skyvern Python SDK: `from skyvern import Skyvern`
NEVER import from skyvern.cli.mcp_tools — those are internal server modules.
-Every tool response includes an `sdk_equivalent` field for script conversion.
+In verbose mode (`--verbose`), every tool response includes an `sdk_equivalent` field for script conversion.
**Hybrid xpath+prompt pattern** — the recommended approach for production scripts:
await page.click("xpath=//button[@id='submit']", prompt="the Submit button")
diff --git a/skyvern/cli/run_commands.py b/skyvern/cli/run_commands.py
index f9b31826d..8c9e2f4ef 100644
--- a/skyvern/cli/run_commands.py
+++ b/skyvern/cli/run_commands.py
@@ -18,6 +18,7 @@ from starlette.middleware import Middleware
from skyvern.cli.console import console
from skyvern.cli.core.client import close_skyvern
from skyvern.cli.core.mcp_http_auth import MCPAPIKeyMiddleware, close_auth_db
+from skyvern.cli.core.result import set_concise_responses
from skyvern.cli.core.session_manager import close_current_session, set_stateless_http_mode
from skyvern.cli.mcp_tools import mcp # Uses standalone fastmcp (v2.x)
from skyvern.cli.utils import start_services
@@ -283,6 +284,13 @@ def run_mcp(
help="Use stateless HTTP semantics for HTTP transports (ignored for stdio).",
),
] = True,
+ verbose: Annotated[
+ bool,
+ typer.Option(
+ "--verbose/--no-verbose",
+ help="Return full tool responses including sdk_equivalent, browser_context, and timing.",
+ ),
+ ] = False,
) -> None:
"""Run the MCP server with configurable transport for local or remote hosting."""
path = _normalize_mcp_path(path)
@@ -292,6 +300,7 @@ def run_mcp(
# atexit doesn't fire on normal return and finally doesn't fire on signals.
atexit.register(_cleanup_mcp_resources_sync)
set_stateless_http_mode(stateless_http_enabled)
+ set_concise_responses(not verbose)
try:
if transport == "stdio":
mcp.run(transport="stdio")
@@ -308,6 +317,7 @@ def run_mcp(
)
finally:
set_stateless_http_mode(False)
+ set_concise_responses(False)
_cleanup_mcp_resources_blocking()
diff --git a/skyvern/forge/sdk/workflow/models/block.py b/skyvern/forge/sdk/workflow/models/block.py
index 326a16e3a..92e2ab970 100644
--- a/skyvern/forge/sdk/workflow/models/block.py
+++ b/skyvern/forge/sdk/workflow/models/block.py
@@ -993,7 +993,14 @@ class BaseTaskBlock(Block):
await self.record_output_parameter_value(workflow_run_context, workflow_run_id, output_parameter_value)
return await self.build_block_result(
success=success,
- failure_reason=updated_task.failure_reason,
+ failure_reason=(
+ updated_task.failure_reason
+ if success
+ else (
+ updated_task.failure_reason
+ or f"Task {updated_task.task_id} finished with status {updated_task.status}"
+ )
+ ),
output_parameter_value=output_parameter_value,
status=block_status_mapping[updated_task.status],
workflow_run_block_id=workflow_run_block_id,
@@ -1010,7 +1017,7 @@ class BaseTaskBlock(Block):
)
return await self.build_block_result(
success=False,
- failure_reason=updated_task.failure_reason,
+ failure_reason=updated_task.failure_reason or f"Task {updated_task.task_id} was canceled",
output_parameter_value=None,
status=block_status_mapping[updated_task.status],
workflow_run_block_id=workflow_run_block_id,
@@ -1027,7 +1034,7 @@ class BaseTaskBlock(Block):
)
return await self.build_block_result(
success=False,
- failure_reason=updated_task.failure_reason,
+ failure_reason=updated_task.failure_reason or f"Task {updated_task.task_id} timed out",
output_parameter_value=None,
status=block_status_mapping[updated_task.status],
workflow_run_block_id=workflow_run_block_id,
@@ -1083,7 +1090,10 @@ class BaseTaskBlock(Block):
)
return await self.build_block_result(
success=False,
- failure_reason=updated_task.failure_reason,
+ failure_reason=(
+ updated_task.failure_reason
+ or f"Task {updated_task.task_id} failed with status {updated_task.status}"
+ ),
output_parameter_value=output_parameter_value,
status=block_status_mapping[updated_task.status],
workflow_run_block_id=workflow_run_block_id,
@@ -1094,7 +1104,11 @@ class BaseTaskBlock(Block):
return await self.build_block_result(
success=False,
status=BlockStatus.failed,
- failure_reason=current_running_task.failure_reason if current_running_task else None,
+ failure_reason=(
+ (current_running_task.failure_reason or f"Task {current_running_task.task_id} failed")
+ if current_running_task
+ else "Task failed (no task reference available)"
+ ),
workflow_run_block_id=workflow_run_block_id,
organization_id=organization_id,
)
@@ -1311,7 +1325,10 @@ class ForLoopBlock(Block):
if not extraction_result.success:
LOG.error("Extraction block failed", failure_reason=extraction_result.failure_reason)
- raise ValueError(f"Extraction block failed: {extraction_result.failure_reason}")
+ raise ValueError(
+ f"Extraction block failed: "
+ f"{extraction_result.failure_reason or 'Unknown error (no failure reason provided)'}"
+ )
LOG.debug("Extraction block succeeded", output=extraction_result.output_parameter_value)
@@ -5755,7 +5772,10 @@ class ConditionalBlock(Block):
block_label=self.label,
failure_reason=extraction_result.failure_reason,
)
- raise ValueError(f"Branch evaluation failed: {extraction_result.failure_reason}")
+ raise ValueError(
+ f"Branch evaluation failed: "
+ f"{extraction_result.failure_reason or 'Unknown error (no failure reason provided)'}"
+ )
if workflow_run_context:
try:
diff --git a/tests/unit/test_mcp_concise_responses.py b/tests/unit/test_mcp_concise_responses.py
new file mode 100644
index 000000000..4522cf07a
--- /dev/null
+++ b/tests/unit/test_mcp_concise_responses.py
@@ -0,0 +1,185 @@
+"""Tests for the concise MCP response mode in make_result()."""
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+
+from skyvern.cli.core.result import Artifact, BrowserContext, make_result, set_concise_responses
+
+
+@pytest.fixture(autouse=True)
+def _enable_concise() -> Iterator[None]:
+ """Enable concise mode for every test; restore after."""
+ set_concise_responses(True)
+ yield
+ set_concise_responses(False)
+
+
+# -- Helpers ------------------------------------------------------------------
+
+_CTX = BrowserContext(mode="cdp", session_id="pbs_1", cdp_url="wss://example.com/devtools")
+
+_CLICK_DATA = {
+ "selector": "#btn",
+ "intent": "the Submit button",
+ "ai_mode": "proactive",
+ "resolved_selector": "xpath=/*[name()='html'][1]/*[name()='body'][1]/*[name()='button'][1]",
+ "sdk_equivalent": 'await page.click("xpath=...", prompt="the Submit button")',
+}
+
+
+# -- Stripped fields ----------------------------------------------------------
+
+
+def test_concise_strips_action_and_browser_context() -> None:
+ result = make_result("skyvern_click", browser_context=_CTX, data=_CLICK_DATA)
+ assert "action" not in result
+ assert "browser_context" not in result
+
+
+def test_concise_strips_timing() -> None:
+ result = make_result("skyvern_click", data=_CLICK_DATA, timing_ms={"sdk": 500, "total": 500})
+ assert "timing_ms" not in result
+
+
+@pytest.mark.parametrize("key", ["sdk_equivalent", "ai_mode", "selector", "intent"])
+def test_concise_strips_debug_data_keys(key: str) -> None:
+ data = {key: "some_value", "url": "https://example.com"}
+ result = make_result("skyvern_navigate", data=data)
+ assert key not in result.get("data", {})
+
+
+def test_concise_strips_none_values_from_data() -> None:
+ data = {"url": "https://example.com", "title": None}
+ result = make_result("skyvern_navigate", data=data)
+ assert "title" not in result.get("data", {})
+
+
+def test_concise_omits_data_when_all_keys_stripped() -> None:
+ """When every key in data is strippable, the data key should be omitted entirely."""
+ data = {"sdk_equivalent": "await page.click(...)", "ai_mode": "proactive", "selector": "#x", "intent": "foo"}
+ result = make_result("skyvern_click", data=data)
+ assert "data" not in result
+
+
+# -- Minimal response --------------------------------------------------------
+
+
+def test_concise_minimal_response() -> None:
+ """No data, no error, no artifacts — should return just {"ok": True}."""
+ result = make_result("skyvern_click")
+ assert result == {"ok": True}
+
+
+# -- Omitted empty collections -----------------------------------------------
+
+
+def test_concise_omits_empty_artifacts() -> None:
+ result = make_result("skyvern_click", data=_CLICK_DATA, artifacts=[])
+ assert "artifacts" not in result
+
+
+def test_concise_omits_empty_warnings() -> None:
+ result = make_result("skyvern_click", data=_CLICK_DATA, warnings=[])
+ assert "warnings" not in result
+
+
+def test_concise_omits_null_error() -> None:
+ result = make_result("skyvern_click", data=_CLICK_DATA, error=None)
+ assert "error" not in result
+
+
+# -- Preserved fields ---------------------------------------------------------
+
+
+def test_concise_click_preserves_resolved_selector() -> None:
+ """resolved_selector is actionable feedback — shows what the AI resolver matched."""
+ result = make_result("skyvern_click", data=_CLICK_DATA)
+ assert result["data"]["resolved_selector"] == _CLICK_DATA["resolved_selector"]
+
+
+def test_concise_click_strips_other_echoed_fields() -> None:
+ result = make_result("skyvern_click", data=_CLICK_DATA)
+ data = result.get("data", {})
+ assert "selector" not in data
+ assert "intent" not in data
+ assert "ai_mode" not in data
+ assert "sdk_equivalent" not in data
+
+
+def test_concise_preserves_meaningful_data() -> None:
+ data = {"extracted": {"price": 42.0}, "sdk_equivalent": "await page.extract(...)"}
+ result = make_result("skyvern_extract", data=data)
+ assert result["data"] == {"extracted": {"price": 42.0}}
+
+
+def test_concise_preserves_error() -> None:
+ err = {"code": "SELECTOR_NOT_FOUND", "message": "Not found", "hint": "Try another selector"}
+ result = make_result("skyvern_click", ok=False, error=err)
+ assert result["ok"] is False
+ assert result["error"] == err
+
+
+def test_concise_preserves_nonempty_warnings() -> None:
+ result = make_result("skyvern_click", ok=False, warnings=["Element hidden"])
+ assert result["warnings"] == ["Element hidden"]
+
+
+def test_concise_preserves_nonempty_artifacts() -> None:
+ artifact = Artifact(kind="screenshot", path="/tmp/shot.png", mime="image/png", bytes=1024)
+ result = make_result("skyvern_screenshot", artifacts=[artifact])
+ assert len(result["artifacts"]) == 1
+ assert result["artifacts"][0]["path"] == "/tmp/shot.png"
+
+
+# -- Partial failure with data ------------------------------------------------
+
+
+def test_concise_preserves_data_on_failure() -> None:
+ err = {"code": "TIMEOUT", "message": "Timed out", "hint": "Increase timeout"}
+ data = {"partial_result": {"items": 3}, "sdk_equivalent": "await page.extract(...)"}
+ result = make_result("skyvern_extract", ok=False, error=err, data=data)
+ assert result["ok"] is False
+ assert result["error"] == err
+ assert result["data"] == {"partial_result": {"items": 3}}
+
+
+# -- None-preserving keys (result, extracted) ---------------------------------
+
+
+def test_concise_preserves_none_result_for_evaluate() -> None:
+ """JS returning null is a meaningful answer — must not be stripped."""
+ data = {"result": None, "sdk_equivalent": "await page.evaluate(...)"}
+ result = make_result("skyvern_evaluate", data=data)
+ assert "data" in result
+ assert result["data"]["result"] is None
+
+
+def test_concise_preserves_none_extracted() -> None:
+ """Extraction returning None means 'found nothing' — must not be stripped."""
+ data = {"extracted": None, "sdk_equivalent": "await page.extract(...)"}
+ result = make_result("skyvern_extract", data=data)
+ assert "data" in result
+ assert result["data"]["extracted"] is None
+
+
+# -- Verbose mode (flag off) --------------------------------------------------
+
+
+def test_verbose_returns_all_fields() -> None:
+ set_concise_responses(False)
+ result = make_result(
+ "skyvern_click",
+ browser_context=_CTX,
+ data=_CLICK_DATA,
+ timing_ms={"sdk": 500, "total": 500},
+ )
+ assert result["action"] == "skyvern_click"
+ assert "browser_context" in result
+ assert result["timing_ms"] == {"sdk": 500, "total": 500}
+ assert result["data"]["sdk_equivalent"] is not None
+ assert result["data"]["resolved_selector"] is not None
+ assert result["artifacts"] == []
+ assert result["warnings"] == []
diff --git a/tests/unit/workflow/test_conditional_branch_evaluation.py b/tests/unit/workflow/test_conditional_branch_evaluation.py
index 592c30ec7..4a89582e1 100644
--- a/tests/unit/workflow/test_conditional_branch_evaluation.py
+++ b/tests/unit/workflow/test_conditional_branch_evaluation.py
@@ -397,3 +397,83 @@ async def test_empty_param_produces_explicit_marker_in_prompt_evaluation() -> No
assert rendered_expressions == ["if (empty value) is not empty"]
# The prompt should be loaded with the patched expression
assert mock_prompt.call_args.kwargs["conditions"] == ["if (empty value) is not empty"]
+
+
+# ---------------------------------------------------------------------------
+# Tests for None failure_reason guard in _evaluate_prompt_branches (SKY-8026)
+# ---------------------------------------------------------------------------
+
+
+def _failed_extraction_result(output_parameter: OutputParameter, failure_reason: str | None = None) -> BlockResult:
+ return BlockResult(
+ success=False,
+ output_parameter=output_parameter,
+ output_parameter_value=None,
+ failure_reason=failure_reason,
+ )
+
+
+@pytest.mark.asyncio
+async def test_extraction_failure_with_none_reason_produces_informative_error() -> None:
+ """When ExtractionBlock fails with failure_reason=None, the raised ValueError
+ should NOT contain the literal string 'None' (SKY-8026)."""
+ block = _conditional_block()
+ branch = BranchCondition(
+ criteria=PromptBranchCriteria(expression="user selected premium plan"),
+ next_block_label="premium",
+ )
+
+ evaluation_context = BranchEvaluationContext(workflow_run_context=None, template_renderer=lambda expr: expr)
+ evaluation_context.build_llm_safe_context_snapshot = MagicMock(return_value={}) # type: ignore[method-assign]
+
+ with (
+ patch("skyvern.forge.sdk.workflow.models.block.prompt_engine.load_prompt", return_value="goal"),
+ patch("skyvern.forge.sdk.workflow.models.block.ExtractionBlock") as mock_extraction_cls,
+ ):
+ mock_extraction = MagicMock()
+ mock_extraction.execute = AsyncMock(
+ return_value=_failed_extraction_result(block.output_parameter, failure_reason=None)
+ )
+ mock_extraction_cls.return_value = mock_extraction
+
+ with pytest.raises(ValueError, match="Unknown error"):
+ await block._evaluate_prompt_branches(
+ branches=[branch],
+ evaluation_context=evaluation_context,
+ workflow_run_id="wr_test",
+ workflow_run_block_id="wrb_test",
+ organization_id="org_test",
+ )
+
+
+@pytest.mark.asyncio
+async def test_extraction_failure_with_reason_preserves_original_message() -> None:
+ """When ExtractionBlock fails with a real failure_reason, that reason should
+ appear verbatim in the raised ValueError."""
+ block = _conditional_block()
+ branch = BranchCondition(
+ criteria=PromptBranchCriteria(expression="user selected premium plan"),
+ next_block_label="premium",
+ )
+
+ evaluation_context = BranchEvaluationContext(workflow_run_context=None, template_renderer=lambda expr: expr)
+ evaluation_context.build_llm_safe_context_snapshot = MagicMock(return_value={}) # type: ignore[method-assign]
+
+ with (
+ patch("skyvern.forge.sdk.workflow.models.block.prompt_engine.load_prompt", return_value="goal"),
+ patch("skyvern.forge.sdk.workflow.models.block.ExtractionBlock") as mock_extraction_cls,
+ ):
+ mock_extraction = MagicMock()
+ mock_extraction.execute = AsyncMock(
+ return_value=_failed_extraction_result(block.output_parameter, failure_reason="LLM rate limited")
+ )
+ mock_extraction_cls.return_value = mock_extraction
+
+ with pytest.raises(ValueError, match="LLM rate limited"):
+ await block._evaluate_prompt_branches(
+ branches=[branch],
+ evaluation_context=evaluation_context,
+ workflow_run_id="wr_test",
+ workflow_run_block_id="wrb_test",
+ organization_id="org_test",
+ )