From 5c42b85f76326eb6fdb211ebff38bb828c926a76 Mon Sep 17 00:00:00 2001 From: cristhianzl Date: Tue, 16 Jun 2026 15:51:20 -0300 Subject: [PATCH] Code reviewer --- src/backend/base/langflow/api/build.py | 41 +---- src/backend/base/langflow/api/v2/hitl.py | 59 ++++++- src/backend/base/langflow/api/v2/workflow.py | 15 +- .../base/langflow/schema/content_block.py | 2 +- .../tests/unit/api/v2/test_resume_route.py | 70 +++++++- .../schema/test_human_input_content_block.py | 39 +++++ .../core/chatComponents/HumanInputCard.tsx | 4 +- .../__tests__/HumanInputCard.test.tsx | 19 +++ .../chat-messages/components/bot-message.tsx | 17 +- .../controllers/API/agui/human-input-card.ts | 154 ++++++++++++++++++ .../controllers/API/agui/run-flow-bridge.ts | 138 +--------------- src/lfx/src/lfx/graph/checkpoint/schema.py | 9 +- src/lfx/src/lfx/schema/content_block.py | 2 +- 13 files changed, 372 insertions(+), 197 deletions(-) create mode 100644 src/backend/tests/unit/schema/test_human_input_content_block.py create mode 100644 src/frontend/src/controllers/API/agui/human-input-card.ts diff --git a/src/backend/base/langflow/api/build.py b/src/backend/base/langflow/api/build.py index 920577e810..0d1a525c25 100644 --- a/src/backend/base/langflow/api/build.py +++ b/src/backend/base/langflow/api/build.py @@ -350,43 +350,6 @@ async def create_flow_response( ) -async def _persist_human_input_card(data: dict, flow_id: uuid.UUID, session_id: str, job_id) -> None: - """Persist the pause as a chat message so the interactive card survives reload. - - The card carries request_id + job_id, so a reloaded session can resume the run. - """ - from lfx.memory import astore_message - from lfx.schema.content_block import ContentBlock - from lfx.schema.content_types import HumanInputContent - from lfx.schema.message import Message - - content = HumanInputContent( - request_id=data.get("request_id", ""), - job_id=str(job_id) if job_id else None, - kind=data.get("kind", "node_input"), - prompt=data.get("prompt"), - options=data.get("options") or [], - fields=data.get("schema") or [], - allowed_decisions=data.get("allowed_decisions") or [], - ) - block = ContentBlock(title="Human input required", contents=[content]) - message = Message( - text="", - sender="Machine", - sender_name="AI", - session_id=session_id, - flow_id=flow_id, - content_blocks=[block], - ) - try: - stored = await astore_message(message, flow_id=flow_id, run_id=str(job_id) if job_id else None) - # Record the card's message id so resume can mark it answered (see the resume route). - if stored and job_id is not None: - await get_job_service().update_job_metadata(uuid.UUID(str(job_id)), {"card_message_id": str(stored[0].id)}) - except Exception: # noqa: BLE001 - await logger.awarning("Failed to persist human-input card for flow %s", flow_id, exc_info=True) - - async def generate_flow_events( *, flow_id: uuid.UUID, @@ -827,7 +790,9 @@ async def generate_flow_events( await _run_vertex_build() except GraphPausedException as exc: # Non-terminal: persist the card to history, emit the pause event, end without on_end. - await _persist_human_input_card(exc.data or {}, flow_id, graph.session_id or str(flow_id), job_id) + from langflow.api.v2.hitl import persist_human_input_card + + await persist_human_input_card(exc.data or {}, flow_id, graph.session_id or str(flow_id), job_id) event_manager.send_event(event_type="human_input_required", data=exc.data or {}) await event_manager.queue.put((None, None, time.time())) return diff --git a/src/backend/base/langflow/api/v2/hitl.py b/src/backend/base/langflow/api/v2/hitl.py index dcf350e72d..a690d7cb16 100644 --- a/src/backend/base/langflow/api/v2/hitl.py +++ b/src/backend/base/langflow/api/v2/hitl.py @@ -1,4 +1,4 @@ -"""Human-in-the-loop persistence helpers for the v2 workflows API. +"""Human-in-the-loop persistence + decision validation for the v2 workflows API. The pause is stored as a chat message so the interactive card survives reload; on resume the same message is updated with the chosen action so a reloaded @@ -7,12 +7,64 @@ session renders it as resolved instead of re-offering the decision. from __future__ import annotations +import uuid from uuid import UUID +from lfx.log import logger from sqlalchemy.orm.attributes import flag_modified from langflow.services.deps import get_job_service -from lfx.log import logger + + +async def is_decision_allowed(job_id: UUID, decision: dict) -> bool: + """Whether ``decision.action_id`` is one of the pause's allowed decisions. + + Returns True when there is no pending request constraining the choice (nothing + to enforce); otherwise the chosen action must be in ``allowed_decisions``. + """ + pending = await get_job_service().get_pending_human_request(job_id) + allowed = (pending or {}).get("allowed_decisions") or [] + if not allowed: + return True + return decision.get("action_id") in allowed + + +async def persist_human_input_card(data: dict, flow_id: uuid.UUID, session_id: str, job_id) -> None: + """Persist the pause as a chat message so the interactive card survives reload. + + The card carries request_id + job_id, so a reloaded session can resume the run. + Records the card's message id in job metadata so resume can mark it answered. + """ + from lfx.schema.content_block import ContentBlock + from lfx.schema.content_types import HumanInputContent + from lfx.schema.message import Message + + from langflow.memory import astore_message + + content = HumanInputContent( + request_id=data.get("request_id", ""), + job_id=str(job_id) if job_id else None, + kind=data.get("kind", "node_input"), + prompt=data.get("prompt"), + options=data.get("options") or [], + fields=data.get("schema") or [], + allowed_decisions=data.get("allowed_decisions") or [], + ) + block = ContentBlock(title="Human input required", contents=[content]) + message = Message( + text="", + sender="Machine", + sender_name="AI", + session_id=session_id, + flow_id=flow_id, + content_blocks=[block], + ) + try: + stored = await astore_message(message, flow_id=flow_id, run_id=str(job_id) if job_id else None) + if stored and job_id is not None: + await get_job_service().update_job_metadata(uuid.UUID(str(job_id)), {"card_message_id": str(stored[0].id)}) + except Exception: # noqa: BLE001 + await logger.awarning("Failed to persist human-input card for flow %s", flow_id, exc_info=True) def _set_submitted_action(content_blocks: list, action_id: str | None) -> bool: @@ -38,9 +90,10 @@ async def mark_card_answered(job_id: UUID, request_id: str, decision: dict) -> N Patches the existing card in place so the prompt/options it was stored with are preserved — rebuilding from the suspend event would drop them. """ - from langflow.services.database.models.message.model import MessageTable from lfx.services.deps import session_scope + from langflow.services.database.models.message.model import MessageTable + job = await get_job_service().get_job_by_job_id(job_id) card_message_id = (job.job_metadata or {}).get("card_message_id") if job else None if not card_message_id: diff --git a/src/backend/base/langflow/api/v2/workflow.py b/src/backend/base/langflow/api/v2/workflow.py index 4e718e822c..5b5033033b 100644 --- a/src/backend/base/langflow/api/v2/workflow.py +++ b/src/backend/base/langflow/api/v2/workflow.py @@ -1191,6 +1191,19 @@ async def resume_workflow( if job is None or job.type != JobType.WORKFLOW or not (is_owner or current_user.is_superuser): raise _not_found() + from langflow.api.v2.hitl import is_decision_allowed, mark_card_answered + + if not await is_decision_allowed(parsed_job_id, request.decision or {}): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "error": "Invalid decision", + "code": "INVALID_DECISION", + "message": "decision.action_id is not one of the pending request's allowed_decisions.", + "job_id": job_id, + }, + ) + service = get_background_execution_service() if service._frame_source_factory is None: # noqa: SLF001 service._frame_source_factory = _default_frame_source_factory # noqa: SLF001 @@ -1210,8 +1223,6 @@ async def resume_workflow( "job_id": job_id, }, ) - from langflow.api.v2.hitl import mark_card_answered - await mark_card_answered(parsed_job_id, request.request_id, request.decision or {}) return WorkflowResumeResponse(job_id=job_id, status="resuming", message="Resume accepted") diff --git a/src/backend/base/langflow/schema/content_block.py b/src/backend/base/langflow/schema/content_block.py index bbe266cc41..94a3f0e41d 100644 --- a/src/backend/base/langflow/schema/content_block.py +++ b/src/backend/base/langflow/schema/content_block.py @@ -20,7 +20,7 @@ def _get_type(d: dict | BaseModel) -> str | None: return getattr(d, "type", None) -# Create a union type of all content types +# Mirror of the lfx ContentType union — keep both in sync (a missing tag breaks MessageRead validation). ContentType = Annotated[ Annotated[ToolContent, Tag("tool_use")] | Annotated[ErrorContent, Tag("error")] diff --git a/src/backend/tests/unit/api/v2/test_resume_route.py b/src/backend/tests/unit/api/v2/test_resume_route.py index 8fc6dc0ddc..24b6b86848 100644 --- a/src/backend/tests/unit/api/v2/test_resume_route.py +++ b/src/backend/tests/unit/api/v2/test_resume_route.py @@ -11,7 +11,7 @@ from uuid import uuid4 import pytest from langflow.services.database.models.flow.model import Flow -from langflow.services.database.models.jobs.model import Job, JobStatus, JobType +from langflow.services.database.models.jobs.model import Job, JobEvent, JobStatus, JobType from lfx.services.deps import session_scope pytestmark = pytest.mark.usefixtures("client") @@ -75,7 +75,9 @@ async def test_resume_non_suspended_job_409(client, created_api_key): flow_id, job_id = uuid4(), uuid4() async with session_scope() as session: session.add(Flow(id=flow_id, name=f"f-{flow_id}", data={"nodes": [], "edges": []}, user_id=user_id)) - session.add(Job(job_id=job_id, flow_id=flow_id, user_id=user_id, type=JobType.WORKFLOW, status=JobStatus.QUEUED)) + session.add( + Job(job_id=job_id, flow_id=flow_id, user_id=user_id, type=JobType.WORKFLOW, status=JobStatus.QUEUED) + ) await session.flush() try: body = {"request_id": "req-1", "decision": {}} @@ -93,3 +95,67 @@ async def test_resume_invalid_job_id_404(client, created_api_key): body = {"request_id": "req-1", "decision": {}} resp = await client.post("api/v2/workflows/not-a-uuid/resume", json=body, headers=_headers(created_api_key)) assert resp.status_code == 404 + + +@pytest.fixture +async def suspended_job_with_decisions(created_api_key): + """A SUSPENDED job whose pending request constrains the choice to approve/reject.""" + user_id = created_api_key.user_id + flow_id, job_id = uuid4(), uuid4() + request = {"flow_id": str(flow_id), "mode": "background", "stream_protocol": "langflow", "input_value": "hi"} + async with session_scope() as session: + session.add(Flow(id=flow_id, name=f"f-{flow_id}", data={"nodes": [], "edges": []}, user_id=user_id)) + session.add( + Job( + job_id=job_id, + flow_id=flow_id, + user_id=user_id, + type=JobType.WORKFLOW, + status=JobStatus.SUSPENDED, + job_metadata={"pending_request_id": "req-1", "request": request}, + ) + ) + session.add( + JobEvent( + job_id=job_id, + seq=1, + event_type="human_input_required", + payload={"request_id": "req-1", "allowed_decisions": ["approve", "reject"]}, + ) + ) + await session.flush() + yield job_id + async with session_scope() as session: + job = await session.get(Job, job_id) + if job: + await session.delete(job) + flow = await session.get(Flow, flow_id) + if flow: + await session.delete(flow) + + +async def test_resume_action_id_not_allowed_422(client, created_api_key, suspended_job_with_decisions): + """B1: a decision whose action_id is not in allowed_decisions is rejected with 422.""" + body = {"request_id": "req-1", "decision": {"action_id": "delete_everything"}} + resp = await client.post( + f"api/v2/workflows/{suspended_job_with_decisions}/resume", json=body, headers=_headers(created_api_key) + ) + assert resp.status_code == 422, resp.text + assert resp.json()["detail"]["code"] == "INVALID_DECISION" + + +async def test_resume_allowed_action_id_accepted_200(client, created_api_key, suspended_job_with_decisions): + """B1: a decision whose action_id is in allowed_decisions passes validation.""" + body = {"request_id": "req-1", "decision": {"action_id": "approve"}} + resp = await client.post( + f"api/v2/workflows/{suspended_job_with_decisions}/resume", json=body, headers=_headers(created_api_key) + ) + assert resp.status_code == 200, resp.text + + +async def test_mark_card_answered_is_noop_without_card_message(client): # noqa: ARG001 + """mark_card_answered degrades gracefully when no card message id is recorded.""" + from langflow.api.v2.hitl import mark_card_answered + + # Unknown job → no job_metadata.card_message_id → returns without raising. + await mark_card_answered(uuid4(), "req-1", {"action_id": "approve"}) diff --git a/src/backend/tests/unit/schema/test_human_input_content_block.py b/src/backend/tests/unit/schema/test_human_input_content_block.py new file mode 100644 index 0000000000..c0a50104d2 --- /dev/null +++ b/src/backend/tests/unit/schema/test_human_input_content_block.py @@ -0,0 +1,39 @@ +"""Contract: the `human_input` content type must validate in BOTH ContentType unions. + +langflow-base and lfx each define a mirrored `ContentType` union. MessageRead uses +one of them; the durable card uses the other. If a tag is added to one union but not +the other, persisting the card fails at read-back with `union_tag_invalid`. This test +guards that drift for the HITL card. +""" + +from __future__ import annotations + +import pytest +from langflow.schema.content_block import ContentBlock as LangflowContentBlock +from lfx.schema.content_block import ContentBlock as LfxContentBlock + +_RAW = { + "type": "human_input", + "request_id": "HumanInput-x:job-1", + "job_id": "job-1", + "prompt": "Approve refund?", + "options": [{"action_id": "approve", "label": "Approve"}], + "allowed_decisions": ["approve"], +} + + +@pytest.mark.parametrize("content_block_cls", [LangflowContentBlock, LfxContentBlock]) +def test_human_input_content_validates_in_both_unions(content_block_cls): + block = content_block_cls(title="Human input required", contents=[dict(_RAW)]) + content = block.contents[0] + assert content.type == "human_input" + assert content.request_id == "HumanInput-x:job-1" + assert content.options[0]["action_id"] == "approve" + + +@pytest.mark.parametrize("content_block_cls", [LangflowContentBlock, LfxContentBlock]) +def test_human_input_content_round_trips_through_json(content_block_cls): + block = content_block_cls(title="Human input required", contents=[dict(_RAW)]) + restored = content_block_cls.model_validate(block.model_dump()) + assert restored.contents[0].type == "human_input" + assert restored.contents[0].submitted_action is None diff --git a/src/frontend/src/components/core/chatComponents/HumanInputCard.tsx b/src/frontend/src/components/core/chatComponents/HumanInputCard.tsx index ce11072cce..63a044f0ea 100644 --- a/src/frontend/src/components/core/chatComponents/HumanInputCard.tsx +++ b/src/frontend/src/components/core/chatComponents/HumanInputCard.tsx @@ -3,10 +3,10 @@ import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { queryClient } from "@/contexts"; import { - consumeBackgroundEvents, getResumeContext, markHumanInputSubmitted, -} from "@/controllers/API/agui/run-flow-bridge"; +} from "@/controllers/API/agui/human-input-card"; +import { consumeBackgroundEvents } from "@/controllers/API/agui/run-flow-bridge"; import { useResumeWorkflow } from "@/controllers/API/queries/workflows/use-resume-workflow"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/src/frontend/src/components/core/chatComponents/__tests__/HumanInputCard.test.tsx b/src/frontend/src/components/core/chatComponents/__tests__/HumanInputCard.test.tsx index a58fb9a7b5..0d2720dd9a 100644 --- a/src/frontend/src/components/core/chatComponents/__tests__/HumanInputCard.test.tsx +++ b/src/frontend/src/components/core/chatComponents/__tests__/HumanInputCard.test.tsx @@ -16,6 +16,8 @@ jest.mock("@/controllers/API/queries/workflows/use-resume-workflow", () => ({ })); jest.mock("@/controllers/API/agui/run-flow-bridge", () => ({ consumeBackgroundEvents: (...args: unknown[]) => mockConsume(...args), +})); +jest.mock("@/controllers/API/agui/human-input-card", () => ({ getResumeContext: () => ({ jobId: "job-1", opts: { flowId: "f1" } }), markHumanInputSubmitted: jest.fn(), })); @@ -142,6 +144,23 @@ describe("HumanInputCard", () => { expect(screen.getByTestId("human-input-decision-approve")).toBeDisabled(); }); + it("renders resolved on reload when content carries submitted_action", () => { + const content: InteractiveContent = { + ..._approval, + job_id: "job-1", + submitted_action: "approve", + }; + render(); + // Only the chosen option is shown; the others are gone; no resume is fired. + expect( + screen.getByTestId("human-input-decision-approve"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("human-input-decision-reject"), + ).not.toBeInTheDocument(); + expect(mockResume).not.toHaveBeenCalled(); + }); + it("keeps only the chosen option and removes the others after selecting", () => { const content: InteractiveContent = { ..._approval, job_id: "job-1" }; render(); diff --git a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-messages/components/bot-message.tsx b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-messages/components/bot-message.tsx index ca175b9e2b..c22152d94c 100644 --- a/src/frontend/src/components/core/playgroundComponent/chat-view/chat-messages/components/bot-message.tsx +++ b/src/frontend/src/components/core/playgroundComponent/chat-view/chat-messages/components/bot-message.tsx @@ -7,8 +7,8 @@ import IconComponent, { import MessageMetadata from "@/components/common/messageMetadataComponent"; import { ContentBlockDisplay } from "@/components/core/chatComponents/ContentBlockDisplay"; import HumanInputCard from "@/components/core/chatComponents/HumanInputCard"; +import { findHumanInputContent } from "@/controllers/API/agui/human-input-card"; import { useUpdateMessage } from "@/controllers/API/queries/messages"; -import type { InteractiveContent } from "@/types/chat"; import { CustomMarkdownField } from "@/customization/components/custom-markdown-field"; import useAlertStore from "@/stores/alertStore"; import useFlowStore from "@/stores/flowStore"; @@ -47,11 +47,11 @@ export const BotMessage = memo( const isEmpty = decodedMessage?.trim() === ""; const chatMessage = chat.message ? chat.message.toString() : ""; // ContentBlockDisplay renders only tool_use content, so the HITL card is rendered directly below. - const humanInputContent = chat.content_blocks - ?.flatMap((block) => block.contents ?? []) - .find((content) => content?.type === "human_input") as - | InteractiveContent - | undefined; + const humanInputContent = findHumanInputContent(chat.content_blocks); + const showThinkingDots = + (chatMessage === "" || (isEmpty && !isStreaming)) && + isBuilding && + lastMessage; const { mutate: updateMessageMutation } = useUpdateMessage(); const handleEditMessage = (message: string) => { @@ -228,10 +228,7 @@ export const BotMessage = memo( data-testid={`chat-message-${chat.sender_name}-${chatMessage}`} className="flex w-full flex-col" > - {humanInputContent ? null : (chatMessage === "" || - (isEmpty && !isStreaming)) && - isBuilding && - lastMessage ? ( + {humanInputContent ? null : showThinkingDots ? ( block.contents ?? []) + .find((content) => content?.type === "human_input") as + | InteractiveContent + | undefined; +} + +function toInteractiveContent( + payload: Record, + jobId: string, +): InteractiveContent { + return { + type: "human_input", + kind: (payload.kind as InteractiveContent["kind"]) ?? "node_input", + request_id: String(payload.request_id ?? ""), + prompt: payload.prompt as string | undefined, + options: (payload.options as InteractiveContent["options"]) ?? [], + schema: payload.schema as InteractiveContent["schema"], + allowed_decisions: (payload.allowed_decisions as string[]) ?? [], + job_id: jobId, + }; +} + +/** + * Reattach context per pause, keyed by request id. The interactive card resumes the + * run itself (no prop-drilling through the chat render chain), so it needs the exact + * run opts to stream the continued run back into the right session. + */ +const resumeRegistry = new Map< + string, + { jobId: string; opts: WorkflowRunOptions } +>(); + +export function getResumeContext( + requestId: string, +): { jobId: string; opts: WorkflowRunOptions } | undefined { + return resumeRegistry.get(requestId); +} + +function forEachMessageCache( + fn: (key: unknown[], messages: Message[]) => void, +): void { + for (const query of queryClient.getQueryCache().getAll()) { + const key = query.queryKey; + if (!Array.isArray(key) || key[0] !== MESSAGES_QUERY_KEY) continue; + const messages = query.state.data as Message[] | undefined; + if (Array.isArray(messages)) fn(key, messages); + } +} + +function cardAlreadyAnswered(messageId: string): boolean { + let answered = false; + forEachMessageCache((_key, messages) => { + const msg = messages.find((m) => m.id === messageId); + const content = findHumanInputContent(msg?.content_blocks); + if (content?.submitted_action) answered = true; + }); + return answered; +} + +/** + * Stamp the chosen action onto the card message in the react-query cache so the + * selection is derived from a stable source — local React state is lost when the + * resume reattach replays the stream and re-renders the message list. + */ +export function markHumanInputSubmitted( + requestId: string, + actionId: string, +): void { + const messageId = `human-input-${requestId}`; + forEachMessageCache((key, messages) => { + if (!messages.some((m) => m.id === messageId)) return; + queryClient.setQueryData(key, (old: Message[] = []) => + old.map((m) => + m.id === messageId + ? { + ...m, + content_blocks: (m.content_blocks ?? []).map((block) => ({ + ...block, + contents: (block.contents ?? []).map((c) => + c?.type === "human_input" + ? { ...c, submitted_action: actionId } + : c, + ), + })), + } + : m, + ), + ); + }); +} + +/** Render the pause as an interactive card in the chat and flag awaiting-input. */ +export function injectHumanInputCard( + payload: Record, + jobId: string, + opts: WorkflowRunOptions, +): void { + const content = toInteractiveContent(payload, jobId); + resumeRegistry.set(content.request_id, { jobId, opts }); + const messageId = `human-input-${content.request_id}`; + // A resume reattach replays the pause event; if the user already answered (the + // card carries submitted_action), re-injecting would clobber that choice. Skip. + if (cardAlreadyAnswered(messageId)) { + useFlowStore.getState().setAwaitingInput(true); + return; + } + const block: ContentBlock = { + title: "Human input required", + contents: [content], + allow_markdown: true, + component: "HumanInput", + }; + const message: Message = { + flow_id: opts.flowId, + text: "", + sender: "Machine", + sender_name: "AI", + session_id: opts.threadId ?? opts.flowId, + timestamp: new Date().toISOString(), + files: [], + id: messageId, + edit: false, + background_color: "", + text_color: "", + content_blocks: [block], + }; + // The new playground reads the react-query messages cache; the legacy IOModal + // playground reads useMessagesStore. Write both so the card renders either way. + updateMessage(message); + useMessagesStore.getState().addMessage(message); + useFlowStore.getState().setAwaitingInput(true); +} diff --git a/src/frontend/src/controllers/API/agui/run-flow-bridge.ts b/src/frontend/src/controllers/API/agui/run-flow-bridge.ts index 03b84c21d2..6fa8777cd4 100644 --- a/src/frontend/src/controllers/API/agui/run-flow-bridge.ts +++ b/src/frontend/src/controllers/API/agui/run-flow-bridge.ts @@ -10,21 +10,17 @@ import { type BaseEvent, EventType } from "@ag-ui/client"; import { handleMessageEvent } from "@/components/core/playgroundComponent/chat-view/utils/message-event-handler"; -import { updateMessage } from "@/components/core/playgroundComponent/chat-view/utils/message-utils"; -import { queryClient } from "@/contexts"; import { BuildStatus } from "@/constants/enums"; import useAlertStore from "@/stores/alertStore"; import useFlowStore from "@/stores/flowStore"; -import { useMessagesStore } from "@/stores/messagesStore"; import type { ChatInputType, ChatOutputType, VertexBuildTypeAPI, VertexDataTypeAPI, } from "@/types/api"; -import type { ContentBlock, InteractiveContent } from "@/types/chat"; -import type { Message } from "@/types/messages"; import { api } from "../api"; +import { injectHumanInputCard } from "./human-input-card"; import { buildWorkflowRunRequest, createWorkflowAgent, @@ -355,138 +351,6 @@ function buildBackgroundRunRequest(opts: WorkflowRunOptions) { return body; } -function toInteractiveContent( - payload: Record, - jobId: string, -): InteractiveContent { - const allowed = (payload.allowed_decisions as string[]) ?? []; - return { - type: "human_input", - kind: (payload.kind as InteractiveContent["kind"]) ?? "node_input", - request_id: String(payload.request_id ?? ""), - prompt: payload.prompt as string | undefined, - options: (payload.options as InteractiveContent["options"]) ?? [], - schema: payload.schema as InteractiveContent["schema"], - allowed_decisions: allowed, - job_id: jobId, - }; -} - -/** - * Reattach context per pause, keyed by request id. The interactive card resumes the - * run itself (no prop-drilling through the chat render chain), so it needs the exact - * run opts to stream the continued run back into the right session. - */ -const resumeRegistry = new Map< - string, - { jobId: string; opts: WorkflowRunOptions } ->(); - -export function getResumeContext( - requestId: string, -): { jobId: string; opts: WorkflowRunOptions } | undefined { - return resumeRegistry.get(requestId); -} - -function _cardSubmittedAction(messageId: string): string | undefined { - for (const query of queryClient.getQueryCache().getAll()) { - const key = query.queryKey; - if (!Array.isArray(key) || key[0] !== "useGetMessagesQuery") continue; - const messages = query.state.data as Message[] | undefined; - const msg = Array.isArray(messages) - ? messages.find((m) => m.id === messageId) - : undefined; - const content = msg?.content_blocks?.[0]?.contents?.find( - (c) => c?.type === "human_input", - ) as { submitted_action?: string } | undefined; - if (content?.submitted_action) return content.submitted_action; - } - return undefined; -} - -function cardAlreadyAnswered(messageId: string): boolean { - return _cardSubmittedAction(messageId) !== undefined; -} - -/** - * Stamp the chosen action onto the card message in the react-query cache so the - * selection is derived from a stable source — local React state is lost when the - * resume reattach replays the stream and re-renders the message list. - */ -export function markHumanInputSubmitted( - requestId: string, - actionId: string, -): void { - const messageId = `human-input-${requestId}`; - for (const query of queryClient.getQueryCache().getAll()) { - const key = query.queryKey; - if (!Array.isArray(key) || key[0] !== "useGetMessagesQuery") continue; - const messages = query.state.data as Message[] | undefined; - if (!Array.isArray(messages) || !messages.some((m) => m.id === messageId)) { - continue; - } - queryClient.setQueryData(key, (old: Message[] = []) => - old.map((m) => - m.id === messageId - ? { - ...m, - content_blocks: (m.content_blocks ?? []).map((block) => ({ - ...block, - contents: (block.contents ?? []).map((c) => - c?.type === "human_input" - ? { ...c, submitted_action: actionId } - : c, - ), - })), - } - : m, - ), - ); - } -} - -/** Render the pause as an interactive card in the chat and flag awaiting-input. */ -function injectHumanInputCard( - payload: Record, - jobId: string, - opts: WorkflowRunOptions, -): void { - const content = toInteractiveContent(payload, jobId); - resumeRegistry.set(content.request_id, { jobId, opts }); - const messageId = `human-input-${content.request_id}`; - // A resume reattach replays the pause event; if the user already answered (the - // card carries submitted_action), re-injecting would clobber that choice. Skip. - if (cardAlreadyAnswered(messageId)) { - useFlowStore.getState().setAwaitingInput(true); - return; - } - const block: ContentBlock = { - title: "Human input required", - contents: [content], - allow_markdown: true, - component: "HumanInput", - }; - const message: Message = { - flow_id: opts.flowId, - text: "", - sender: "Machine", - sender_name: "AI", - session_id: opts.threadId ?? opts.flowId, - timestamp: new Date().toISOString(), - files: [], - id: messageId, - edit: false, - background_color: "", - text_color: "", - content_blocks: [block], - }; - // The new playground reads the react-query messages cache; the legacy IOModal - // playground reads useMessagesStore. Write both so the card renders either way. - updateMessage(message); - useMessagesStore.getState().addMessage(message); - useFlowStore.getState().setAwaitingInput(true); -} - /** * Consume a background run's durable event stream (`GET /{job_id}/events`) and fold * events into the flow store. Resolves when the run ends, suspends for human input, diff --git a/src/lfx/src/lfx/graph/checkpoint/schema.py b/src/lfx/src/lfx/graph/checkpoint/schema.py index 007368ed68..d6c6b2bfdd 100644 --- a/src/lfx/src/lfx/graph/checkpoint/schema.py +++ b/src/lfx/src/lfx/graph/checkpoint/schema.py @@ -13,6 +13,8 @@ from uuid import uuid4 from pydantic import BaseModel, Field +from lfx.log.logger import logger + _WIRE_KIND = "__lfx_ser__" @@ -55,6 +57,10 @@ def serialize_value(value: Any) -> dict[str, Any] | None: Opaque objects (no faithful JSON form) degrade to None rather than raising: a checkpoint must still be writable when one vertex holds e.g. a client handle, and resume re-derives such objects from the rebuilt component. + + Limitation: paused vertex state that is not JSON-serializable is NOT restored + on resume (it returns as None). A flow that depends on such state downstream of + the pause must re-derive it from the rebuilt node, not from the checkpoint. """ if value is None or isinstance(value, (str, int, float, bool)): return {_WIRE_KIND: "raw", "value": value} @@ -62,7 +68,8 @@ def serialize_value(value: Any) -> dict[str, Any] | None: try: dumped = value.model_dump(mode="json") except Exception: # noqa: BLE001 - # A model with an opaque field (LLM client, model class) can't round-trip; degrade to None so the checkpoint stays writable. + # Opaque field (LLM client / model class) can't round-trip; degrade to None. + logger.debug("checkpoint: dropping non-serializable model %s", type(value).__qualname__) return None return { _WIRE_KIND: "model", diff --git a/src/lfx/src/lfx/schema/content_block.py b/src/lfx/src/lfx/schema/content_block.py index bbe266cc41..a101c05add 100644 --- a/src/lfx/src/lfx/schema/content_block.py +++ b/src/lfx/src/lfx/schema/content_block.py @@ -20,7 +20,7 @@ def _get_type(d: dict | BaseModel) -> str | None: return getattr(d, "type", None) -# Create a union type of all content types +# Mirror of the langflow-base ContentType union — keep both in sync (a missing tag breaks MessageRead validation). ContentType = Annotated[ Annotated[ToolContent, Tag("tool_use")] | Annotated[ErrorContent, Tag("error")]