From 6b9d7b0edad772eeab9b3202104775df8d90cf57 Mon Sep 17 00:00:00 2001 From: Andrew Neilson Date: Sun, 24 May 2026 11:23:15 -0700 Subject: [PATCH] SKY-10130 (1/3): add nullable TurnOutcome JSON column + schema plumbing (#6147) --- ...olumn_to_workflow_copilot_chat_messages.py | 45 +++++++++++++++++++ skyvern/forge/sdk/db/models.py | 1 + .../db/repositories/workflow_parameters.py | 3 ++ skyvern/forge/sdk/db/utils.py | 24 +++++++++- skyvern/forge/sdk/routes/workflow_copilot.py | 1 + .../forge/sdk/schemas/copilot_turn_outcome.py | 35 +++++++++++++++ skyvern/forge/sdk/schemas/workflow_copilot.py | 3 ++ 7 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/2026_05_24_1812-8a7754a48701_add_turn_outcome_column_to_workflow_copilot_chat_messages.py create mode 100644 skyvern/forge/sdk/schemas/copilot_turn_outcome.py diff --git a/alembic/versions/2026_05_24_1812-8a7754a48701_add_turn_outcome_column_to_workflow_copilot_chat_messages.py b/alembic/versions/2026_05_24_1812-8a7754a48701_add_turn_outcome_column_to_workflow_copilot_chat_messages.py new file mode 100644 index 000000000..69676d7b1 --- /dev/null +++ b/alembic/versions/2026_05_24_1812-8a7754a48701_add_turn_outcome_column_to_workflow_copilot_chat_messages.py @@ -0,0 +1,45 @@ +"""add turn_outcome column to workflow_copilot_chat_messages + +Revision ID: 8a7754a48701 +Revises: 729b4078a2e9 +Create Date: 2026-05-24T18:12:12.662694+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "8a7754a48701" +down_revision: Union[str, None] = "729b4078a2e9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + + columns = {col["name"] for col in inspector.get_columns("workflow_copilot_chat_messages")} + if "turn_outcome" in columns: + return + + if conn.dialect.name == "postgresql": + op.execute(sa.text("SET lock_timeout = '2s'")) + + op.add_column( + "workflow_copilot_chat_messages", + sa.Column("turn_outcome", sa.JSON(), nullable=True), + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = {col["name"] for col in inspector.get_columns("workflow_copilot_chat_messages")} + if "turn_outcome" not in columns: + return + op.drop_column("workflow_copilot_chat_messages", "turn_outcome") diff --git a/skyvern/forge/sdk/db/models.py b/skyvern/forge/sdk/db/models.py index 16e0c353e..c06011acb 100644 --- a/skyvern/forge/sdk/db/models.py +++ b/skyvern/forge/sdk/db/models.py @@ -1426,6 +1426,7 @@ class WorkflowCopilotChatMessageModel(Base): sender = Column(String, nullable=False) content = Column(UnicodeText, nullable=False) global_llm_context = Column(UnicodeText, nullable=True) + turn_outcome = Column(JSON, nullable=True) created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) modified_at = Column( diff --git a/skyvern/forge/sdk/db/repositories/workflow_parameters.py b/skyvern/forge/sdk/db/repositories/workflow_parameters.py index 3280deddf..c894212e0 100644 --- a/skyvern/forge/sdk/db/repositories/workflow_parameters.py +++ b/skyvern/forge/sdk/db/repositories/workflow_parameters.py @@ -38,6 +38,7 @@ from skyvern.forge.sdk.db.utils import ( hydrate_action, ) from skyvern.forge.sdk.schemas.ai_suggestions import AISuggestion +from skyvern.forge.sdk.schemas.copilot_turn_outcome import TurnOutcome from skyvern.forge.sdk.schemas.task_generations import TaskGeneration from skyvern.forge.sdk.schemas.tasks import Task, TaskStatus from skyvern.forge.sdk.schemas.workflow_copilot import ( @@ -519,6 +520,7 @@ class WorkflowParametersRepository(BaseRepository): sender: WorkflowCopilotChatSender, content: str, global_llm_context: str | None = None, + turn_outcome: TurnOutcome | None = None, ) -> WorkflowCopilotChatMessage: async with self.Session() as session: new_message = WorkflowCopilotChatMessageModel( @@ -527,6 +529,7 @@ class WorkflowParametersRepository(BaseRepository): sender=sender, content=content, global_llm_context=global_llm_context, + turn_outcome=turn_outcome.model_dump(mode="json") if turn_outcome is not None else None, ) session.add(new_message) await session.commit() diff --git a/skyvern/forge/sdk/db/utils.py b/skyvern/forge/sdk/db/utils.py index 74ebb3c60..cb196b963 100644 --- a/skyvern/forge/sdk/db/utils.py +++ b/skyvern/forge/sdk/db/utils.py @@ -35,6 +35,7 @@ from skyvern.forge.sdk.db.models import ( from skyvern.forge.sdk.encrypt import encryptor from skyvern.forge.sdk.encrypt.base import EncryptMethod from skyvern.forge.sdk.models import Step, StepStatus +from skyvern.forge.sdk.schemas.copilot_turn_outcome import TurnOutcome from skyvern.forge.sdk.schemas.organizations import ( AzureClientSecretCredential, AzureOrganizationAuthToken, @@ -356,7 +357,28 @@ def convert_to_workflow_copilot_chat_message( "Converting WorkflowCopilotChatMessage to WorkflowCopilotChatMessageSchema", workflow_copilot_chat_message_id=message_model.workflow_copilot_chat_message_id, ) - return WorkflowCopilotChatMessageSchema.model_validate(message_model) + raw_outcome = message_model.turn_outcome + parsed_outcome: TurnOutcome | None = None + if isinstance(raw_outcome, dict): + try: + parsed_outcome = TurnOutcome.model_validate(raw_outcome) + except Exception as exc: + LOG.warning( + "Failed to parse TurnOutcome from chat row, treating as None", + workflow_copilot_chat_message_id=message_model.workflow_copilot_chat_message_id, + exc_info=exc, + ) + parsed_outcome = None + return WorkflowCopilotChatMessageSchema( + workflow_copilot_chat_message_id=message_model.workflow_copilot_chat_message_id, + workflow_copilot_chat_id=message_model.workflow_copilot_chat_id, + sender=message_model.sender, + content=message_model.content, + global_llm_context=message_model.global_llm_context, + turn_outcome=parsed_outcome, + created_at=message_model.created_at, + modified_at=message_model.modified_at, + ) def convert_to_step(step_model: StepModel, debug_enabled: bool = False) -> Step: diff --git a/skyvern/forge/sdk/routes/workflow_copilot.py b/skyvern/forge/sdk/routes/workflow_copilot.py index 19b829661..bcfe9b571 100644 --- a/skyvern/forge/sdk/routes/workflow_copilot.py +++ b/skyvern/forge/sdk/routes/workflow_copilot.py @@ -2052,6 +2052,7 @@ def convert_to_history_messages( WorkflowCopilotChatHistoryMessage( sender=message.sender, content=message.content, + turn_outcome=message.turn_outcome, created_at=message.created_at, ) for message in messages diff --git a/skyvern/forge/sdk/schemas/copilot_turn_outcome.py b/skyvern/forge/sdk/schemas/copilot_turn_outcome.py new file mode 100644 index 000000000..8a6bc4871 --- /dev/null +++ b/skyvern/forge/sdk/schemas/copilot_turn_outcome.py @@ -0,0 +1,35 @@ +"""Persisted, typed record of one workflow-copilot turn. + +Lives under ``schemas/`` so chat-history schemas can embed it without pulling +in any ``copilot/`` business logic — derivation lives in +``skyvern/forge/sdk/copilot/turn_outcome.py``. +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class ResponseKind(StrEnum): + BUILD = "build" + CLARIFY = "clarify" + DIAGNOSE = "diagnose" + REFUSE = "refuse" + RECOVER = "recover" + + +class TurnOutcome(BaseModel): + # extra="ignore" so a rolling deploy that adds a new TurnOutcome field + # does not make older readers silently treat freshly-written rows as None. + model_config = ConfigDict(extra="ignore", frozen=True) + + turn_intent_summary: dict[str, Any] = Field(default_factory=dict) + response_kind: ResponseKind + reason_code: str = "" + normalized_reply_signature: str = "" + tool_calls: list[str] = Field(default_factory=list) + terminal_reason: str | None = None + blocked_signatures: list[str] = Field(default_factory=list) diff --git a/skyvern/forge/sdk/schemas/workflow_copilot.py b/skyvern/forge/sdk/schemas/workflow_copilot.py index 3e05711ee..2d9e897c1 100644 --- a/skyvern/forge/sdk/schemas/workflow_copilot.py +++ b/skyvern/forge/sdk/schemas/workflow_copilot.py @@ -5,6 +5,7 @@ from typing import Any from pydantic import BaseModel, ConfigDict, Field from skyvern.forge.sdk.copilot.context import ProposalDisposition, ResponseType +from skyvern.forge.sdk.schemas.copilot_turn_outcome import TurnOutcome class WorkflowCopilotChat(BaseModel): @@ -32,6 +33,7 @@ class WorkflowCopilotChatMessage(BaseModel): sender: WorkflowCopilotChatSender = Field(..., description="Message sender") content: str = Field(..., description="Message content") global_llm_context: str | None = Field(None, description="Optional global LLM context for the message") + turn_outcome: TurnOutcome | None = Field(None, description="Typed turn outcome (assistant rows)") created_at: datetime = Field(..., description="When the message was created") modified_at: datetime = Field(..., description="When the message was last modified") @@ -76,6 +78,7 @@ class WorkflowCopilotApplyProposedWorkflowRequest(BaseModel): class WorkflowCopilotChatHistoryMessage(BaseModel): sender: WorkflowCopilotChatSender = Field(..., description="Message sender") content: str = Field(..., description="Message content") + turn_outcome: TurnOutcome | None = Field(None, description="Typed turn outcome (assistant rows only)") created_at: datetime = Field(..., description="When the message was created")