From 784169cee7adb6ea20c062f5ac2dc735cdd26527 Mon Sep 17 00:00:00 2001 From: Jordan Frazier <122494242+jordanrfrazier@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:22:24 -0400 Subject: [PATCH] fix: fallback to sync call in lfx run when stream=true (#12906) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(lfx): unblock streaming flows in lfx run LCModelComponent._handle_stream tried to persist a partial Message via send_message whenever the LM was wired to ChatOutput, but the message- store path requires session_id and the chunk-consumption path requires an EventManager. lfx run had neither, so flows with stream=True crashed in astore_message ("session_id, sender, sender_name must be provided"). - run_flow now auto-generates a session_id when none is supplied so the message-store validator passes; an explicit value still wins. - lfx run gains a --session-id flag for memory continuity across runs (Memory / MessageHistory components keyed on session_id). - _handle_stream now also requires an EventManager before taking the streaming branch — without one, the chunk iterator would be stored but never drained, surfacing as an empty result downstream. Falls back to ainvoke and returns the full text instead. Tests cover the autogen, caller-precedence, and uniqueness cases on run_flow plus the four _handle_stream branches (no session_id, no event_manager, both present, not connected to chat output). * fix(lfx): autogen session_id in CUGA agent when graph has none Matches the pattern already used by base/agents/agent.py and base/agents/altk_base_agent.py. Without this, calling the CUGA agent outside run_flow (which now autogens a session_id) would still fail astore_message validation. * [autofix.ci] apply automated fixes * fix(lfx): plumb session_id through serve /run and /stream StreamRequest already declared a session_id field but it was never applied to the graph; RunRequest didn't have the field at all. Both endpoints called execute_graph_with_capture, which executed against an empty graph.session_id, so message-store paths skipped storage silently and Memory components could not maintain continuity. - Add session_id to RunRequest. - Have execute_graph_with_capture accept session_id, autogen if empty (matches run_flow), and apply it to graph.session_id before execution. - Forward session_id from both /run and /stream handlers. * fix(lfx): propagate session_id and user_id so memory works on lfx run The lfx run path uses graph.async_start instead of graph.arun, bypassing the has_session_id_vertices propagation loop in Graph._run that the playground hits via build_graph_from_data. Memory/MessageHistory components reading session_id from their input field would see "" even when --session-id was passed. Replicate the loop in run_flow and execute_graph_with_capture, with the same precedence as the playground: hardcoded values on the component win. AgentComponent's variable lookup precheck blocks any flow that resolves variables (e.g. api_key) when graph.user_id is empty. Auto-generate a ceremonial UUID; lfx's env-fallback variable service ignores user_id, so this only satisfies the precheck — env vars remain process-global with no per-user scoping. Make VariableService.get_variable async to match the langflow call site (`await variable_service.get_variable(...)` in custom_component.get_variable). The signature accepts and ignores user_id/field/session kwargs so flows behave identically under either backend. Tests cover propagation precedence (empty input filled, hardcoded value preserved, missing vertex skipped), user_id auto-gen and caller-takes- precedence, and the async signature with kwarg absorption. * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * [autofix.ci] apply automated fixes (attempt 3/3) * fix(lfx): plumb fallback_to_env_vars so DatabaseVariableService works in lfx run The lfx run path uses graph.async_start, which never propagated fallback_to_env_vars to vertex builds — the flag defaulted to False all the way down to update_params_with_load_from_db_fields. Result: if a user swapped lfx's env-fallback VariableService for langflow's DatabaseVariableService (via lfx.toml), every load_from_db variable (e.g. api_key=OPENAI_API_KEY) would raise "variable not found" because the random ceremonial user_id has no DB rows, with no env fallback. Add fallback_to_env_vars kwarg to async_start and astep (default False, non-breaking for existing callers). run_flow and execute_graph_with_capture read settings.fallback_to_env_var (defaults True, settable via LANGFLOW_FALLBACK_TO_ENV_VAR=false) and pass it through. Mirrors what processing.process.run_graph_internal does for the langflow API path. Make memory.stubs.astore_message tolerant of non-UUID flow_ids: the stub is the no-op fallback when no real database is registered, so it should not crash on synthetic identifiers (e.g. test fixtures, lfx callers passing string flow ids). UUID parsing only normalizes format; an invalid string is preserved verbatim. Tests: - TestRunFlowFallbackToEnvVars: confirms run_flow forwards fallback_to_env_vars from settings (default and disabled). - TestGraphExecution: same for execute_graph_with_capture. - Existing mock_async_start signatures updated to accept **kwargs. - Test fixtures using flow_id="test-flow-id" replaced with a real UUID so the now-active ChatInput storage path doesn't trip stubs.py's UUID parse — aligning fixtures with production semantics. * test(lfx): pass session_id=None when invoking the typer run() directly The lfx test suite calls ``run(...)`` (the typer command) without going through typer's CLI parser. Any parameter with a ``typer.Option(...)`` default in the signature (e.g. ``session_id``) evaluates to a ``typer.models.OptionInfo`` sentinel under that invocation pattern, not None. The session_id propagation loop then writes that sentinel into ``vertex.raw_params["session_id"]``, which fails ``MessageTextInput`` validation with ``Invalid value type ``. Fix at the call site: pass ``session_id=None`` explicitly. Mirrors how typer would resolve the option after parsing CLI args. Two tests affected; test_run_command.py now reflects the constraint that calls bypassing typer must pass all option-shaped args. * fix(lfx): harden session_id/user_id handling on lfx run path Addresses review findings on the streaming-fix PR: - Reject empty/whitespace --session-id and --user-id up-front so a shell quirk or empty env var surfaces a clear error instead of silently auto-generating a fresh session and breaking Memory continuity. - Extract a shared helper (lfx/run/_defaults.py) for session_id/user_id auto-gen, vertex propagation, and fallback_to_env_vars resolution; both run_flow and execute_graph_with_capture now delegate to it. - Warn when settings_service is None (silently flipped fallback_to_env_vars to False before). - CUGA agent: wrap uuid.uuid4() with str() to match the rest of the file and the codebase's expectation that Message.session_id renders as a hex string. Add focused tests with module-level skip when the cuga import side-effect (MODELS_METADATA["OpenAI"]) isn't satisfiable. - Streaming-fallback warning now names the component (display_name + _id) so users can identify which model fell back to ainvoke. - memory/stubs.py: replace contextlib.suppress(ValueError) with explicit try/except + warning so malformed flow_ids leave a breadcrumb. - Improve --session-id help text to explain WHEN to set it. - Tune session_id auto-gen log level from warning to info (less noisy on default CLI runs; visible at -v). - Add tests: --session-id CLI plumbing, multi-call continuity, empty/ whitespace rejection, and isinstance(str) on the streaming fallback path. * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../starter_projects/Document Q&A.json | 2 +- .../starter_projects/Hybrid Search RAG.json | 2 +- .../Instagram Copywriter.json | 2 +- .../starter_projects/Invoice Summarizer.json | 2 +- .../starter_projects/Knowledge Retrieval.json | 6 +- .../starter_projects/Market Research.json | 2 +- .../starter_projects/News Aggregator.json | 2 +- .../starter_projects/Nvidia Remix.json | 4 +- .../starter_projects/Pokédex Agent.json | 2 +- .../Portfolio Website Code Generator.json | 2 +- .../starter_projects/Price Deal Finder.json | 2 +- .../starter_projects/Research Agent.json | 2 +- .../starter_projects/SaaS Pricing.json | 2 +- .../starter_projects/Search agent.json | 2 +- .../Sequential Tasks Agents.json | 8 +- .../starter_projects/Simple Agent.json | 2 +- .../starter_projects/Social Media Agent.json | 6 +- .../Text Sentiment Analysis.json | 2 +- .../Travel Planning Agents.json | 6 +- .../starter_projects/Vector Store RAG.json | 10 +- .../starter_projects/Youtube Analysis.json | 2 +- src/lfx/src/lfx/_assets/component_index.json | 152 +++--- src/lfx/src/lfx/base/models/model.py | 45 +- src/lfx/src/lfx/cli/_running_commands.py | 8 + src/lfx/src/lfx/cli/common.py | 16 +- src/lfx/src/lfx/cli/run.py | 9 + src/lfx/src/lfx/cli/serve_app.py | 9 +- src/lfx/src/lfx/components/cuga/cuga_agent.py | 2 +- src/lfx/src/lfx/graph/graph/base.py | 8 +- src/lfx/src/lfx/memory/stubs.py | 14 +- src/lfx/src/lfx/run/_defaults.py | 132 ++++++ src/lfx/src/lfx/run/base.py | 41 +- src/lfx/src/lfx/services/variable/service.py | 13 +- .../unit/base/models/test_handle_stream.py | 120 +++++ src/lfx/tests/unit/cli/test_common.py | 160 ++++++- src/lfx/tests/unit/cli/test_run_command.py | 64 ++- src/lfx/tests/unit/cli/test_serve_app.py | 137 ++++-- .../tests/unit/cli/test_serve_components.py | 2 +- .../tests/unit/components/cuga/__init__.py | 0 .../cuga/test_cuga_session_id_fallback.py | 97 ++++ src/lfx/tests/unit/run/test_base.py | 431 ++++++++++++++++++ .../tests/unit/services/test_integration.py | 8 +- .../unit/services/test_minimal_services.py | 55 ++- .../unit/services/test_service_manager.py | 4 +- 44 files changed, 1392 insertions(+), 205 deletions(-) create mode 100644 src/lfx/src/lfx/run/_defaults.py create mode 100644 src/lfx/tests/unit/base/models/test_handle_stream.py create mode 100644 src/lfx/tests/unit/components/cuga/__init__.py create mode 100644 src/lfx/tests/unit/components/cuga/test_cuga_session_id_fallback.py diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Document Q&A.json b/src/backend/base/langflow/initial_setup/starter_projects/Document Q&A.json index 355eb99e44..347f54eb47 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Document Q&A.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Document Q&A.json @@ -1319,7 +1319,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Hybrid Search RAG.json b/src/backend/base/langflow/initial_setup/starter_projects/Hybrid Search RAG.json index 138b4e04ef..af539282b2 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Hybrid Search RAG.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Hybrid Search RAG.json @@ -1521,7 +1521,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Instagram Copywriter.json b/src/backend/base/langflow/initial_setup/starter_projects/Instagram Copywriter.json index b1887db7fa..3d98625f5b 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Instagram Copywriter.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Instagram Copywriter.json @@ -2084,7 +2084,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Invoice Summarizer.json b/src/backend/base/langflow/initial_setup/starter_projects/Invoice Summarizer.json index 267b5b3905..27258112df 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Invoice Summarizer.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Invoice Summarizer.json @@ -1192,7 +1192,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Knowledge Retrieval.json b/src/backend/base/langflow/initial_setup/starter_projects/Knowledge Retrieval.json index 4fefa4b52a..c0b9806e42 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Knowledge Retrieval.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Knowledge Retrieval.json @@ -545,7 +545,7 @@ }, { "name": "cryptography", - "version": "46.0.7" + "version": "47.0.0" }, { "name": "langchain_chroma", @@ -565,7 +565,7 @@ }, { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "langchain_huggingface", @@ -585,7 +585,7 @@ }, { "name": "langchain_ibm", - "version": "1.0.6" + "version": "1.0.7" } ], "total_dependencies": 12 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Market Research.json b/src/backend/base/langflow/initial_setup/starter_projects/Market Research.json index 9f994436a5..8a203ffb24 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Market Research.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Market Research.json @@ -1204,7 +1204,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/News Aggregator.json b/src/backend/base/langflow/initial_setup/starter_projects/News Aggregator.json index 451071cccd..34bcff1da8 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/News Aggregator.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/News Aggregator.json @@ -1186,7 +1186,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json b/src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json index 022046beaf..948b42e1e1 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json @@ -813,7 +813,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 @@ -2105,7 +2105,7 @@ "dependencies": [ { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json index 1926a20af3..a591bc20f3 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Pokédex Agent.json @@ -1251,7 +1251,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Portfolio Website Code Generator.json b/src/backend/base/langflow/initial_setup/starter_projects/Portfolio Website Code Generator.json index 859b99ee59..f66cee1ec5 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Portfolio Website Code Generator.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Portfolio Website Code Generator.json @@ -941,7 +941,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Price Deal Finder.json b/src/backend/base/langflow/initial_setup/starter_projects/Price Deal Finder.json index a66552640d..b4fdd81237 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Price Deal Finder.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Price Deal Finder.json @@ -1620,7 +1620,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Research Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Research Agent.json index 2f844458fe..54fd28e161 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Research Agent.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Research Agent.json @@ -2823,7 +2823,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/SaaS Pricing.json b/src/backend/base/langflow/initial_setup/starter_projects/SaaS Pricing.json index 6719f62cae..153e7f4088 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/SaaS Pricing.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/SaaS Pricing.json @@ -905,7 +905,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Search agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Search agent.json index bad7080ed0..dd45e9e19b 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Search agent.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Search agent.json @@ -952,7 +952,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json b/src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json index 31c4dd41d7..72bbddcb34 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Sequential Tasks Agents.json @@ -370,7 +370,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 @@ -958,7 +958,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 @@ -2403,7 +2403,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 @@ -2976,7 +2976,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json index 8d4f41685c..268efe45e7 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Simple Agent.json @@ -951,7 +951,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Social Media Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Social Media Agent.json index 600ed33b36..3e89aff1d2 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Social Media Agent.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Social Media Agent.json @@ -160,7 +160,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -392,7 +392,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -1301,7 +1301,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json b/src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json index a6958ef1eb..e6abf4c19f 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Text Sentiment Analysis.json @@ -2633,7 +2633,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Travel Planning Agents.json b/src/backend/base/langflow/initial_setup/starter_projects/Travel Planning Agents.json index 5083843f2c..22575d16e7 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Travel Planning Agents.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Travel Planning Agents.json @@ -1717,7 +1717,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 @@ -2300,7 +2300,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 @@ -2883,7 +2883,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json b/src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json index 8c65095fda..13ee4dee83 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json @@ -1635,7 +1635,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -2687,7 +2687,7 @@ }, { "name": "cryptography", - "version": "46.0.7" + "version": "47.0.0" }, { "name": "langchain_chroma", @@ -3057,7 +3057,7 @@ }, { "name": "cryptography", - "version": "46.0.7" + "version": "47.0.0" }, { "name": "langchain_chroma", @@ -3077,7 +3077,7 @@ }, { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "langchain_huggingface", @@ -3097,7 +3097,7 @@ }, { "name": "langchain_ibm", - "version": "1.0.6" + "version": "1.0.7" } ], "total_dependencies": 12 diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json b/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json index 170ec2a4fb..c373dc7eb9 100644 --- a/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json +++ b/src/backend/base/langflow/initial_setup/starter_projects/Youtube Analysis.json @@ -513,7 +513,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 diff --git a/src/lfx/src/lfx/_assets/component_index.json b/src/lfx/src/lfx/_assets/component_index.json index 290de90c6e..d85ba87cd6 100644 --- a/src/lfx/src/lfx/_assets/component_index.json +++ b/src/lfx/src/lfx/_assets/component_index.json @@ -313,7 +313,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "markdown", @@ -486,7 +486,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -627,7 +627,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -796,7 +796,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -937,7 +937,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -1107,7 +1107,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -1278,7 +1278,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -1471,7 +1471,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -3237,7 +3237,7 @@ "dependencies": [ { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "pydantic", @@ -3589,7 +3589,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 2 @@ -5729,7 +5729,7 @@ }, { "name": "anthropic", - "version": "0.96.0" + "version": "0.97.0" } ], "total_dependencies": 5 @@ -6063,7 +6063,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -7621,7 +7621,7 @@ "dependencies": [ { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "lfx", @@ -7860,7 +7860,7 @@ "dependencies": [ { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "lfx", @@ -12795,7 +12795,7 @@ }, { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "pydantic", @@ -13167,7 +13167,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -56097,12 +56097,12 @@ "icon": "bot", "legacy": false, "metadata": { - "code_hash": "e8176c866e13", + "code_hash": "8a418f0708c2", "dependencies": { "dependencies": [ { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -56110,7 +56110,7 @@ }, { "name": "cuga", - "version": "0.2.22" + "version": "0.2.25" } ], "total_dependencies": 3 @@ -56279,7 +56279,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import asyncio\nimport json\nimport traceback\nimport uuid\nfrom collections.abc import AsyncIterator\nfrom typing import TYPE_CHECKING, Any, cast\n\nfrom langchain_core.agents import AgentFinish\nfrom langchain_core.messages import AIMessage, HumanMessage\nfrom langchain_core.tools import StructuredTool\n\nfrom lfx.base.agents.agent import LCToolsAgentComponent\nfrom lfx.base.models.model_input_constants import (\n ALL_PROVIDER_FIELDS,\n MODEL_DYNAMIC_UPDATE_FIELDS,\n MODEL_PROVIDERS,\n MODEL_PROVIDERS_DICT,\n MODELS_METADATA,\n)\nfrom lfx.base.models.model_utils import get_model_name\nfrom lfx.components.helpers import CurrentDateComponent\nfrom lfx.components.langchain_utilities.tool_calling import ToolCallingAgentComponent\nfrom lfx.components.models_and_agents.memory import MemoryComponent\nfrom lfx.custom.custom_component.component import _get_component_toolkit\nfrom lfx.custom.utils import update_component_build_config\nfrom lfx.field_typing import Tool\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MultilineInput, Output\nfrom lfx.log.logger import logger\nfrom lfx.schema.dotdict import dotdict\nfrom lfx.schema.message import Message\n\nif TYPE_CHECKING:\n from lfx.schema.log import SendMessageFunctionType\n\n\ndef set_advanced_true(component_input):\n \"\"\"Set the advanced flag to True for a component input.\n\n Args:\n component_input: The component input to modify\n\n Returns:\n The modified component input with advanced=True\n \"\"\"\n component_input.advanced = True\n return component_input\n\n\nMODEL_PROVIDERS_LIST = [\"OpenAI\"]\n\n\nclass CugaComponent(ToolCallingAgentComponent):\n \"\"\"Cuga Agent Component for advanced AI task execution.\n\n The Cuga component is an advanced AI agent that can execute complex tasks using\n various tools and browser automation. It supports custom instructions, web applications,\n and API interactions.\n\n Attributes:\n display_name: Human-readable name for the component\n description: Brief description of the component's purpose\n documentation: URL to component documentation\n icon: Icon identifier for the UI\n name: Internal component name\n \"\"\"\n\n display_name: str = \"Cuga\"\n description: str = \"Define the Cuga agent's instructions, then assign it a task.\"\n documentation: str = \"https://docs.langflow.org/bundles-cuga\"\n icon = \"bot\"\n name = \"Cuga\"\n\n memory_inputs = [set_advanced_true(component_input) for component_input in MemoryComponent().inputs]\n\n inputs = [\n DropdownInput(\n name=\"agent_llm\",\n display_name=\"Model Provider\",\n info=\"The provider of the language model that the agent will use to generate responses.\",\n options=[*MODEL_PROVIDERS_LIST, \"Custom\"],\n value=\"OpenAI\",\n real_time_refresh=True,\n input_types=[],\n options_metadata=[MODELS_METADATA[key] for key in MODEL_PROVIDERS_LIST] + [{\"icon\": \"brain\"}],\n ),\n *MODEL_PROVIDERS_DICT[\"OpenAI\"][\"inputs\"],\n MultilineInput(\n name=\"instructions\",\n display_name=\"Instructions\",\n info=(\n \"Custom instructions for the agent to adhere to during its operation.\\n\"\n \"Example:\\n\"\n \"## Plan\\n\"\n \"< planning instructions e.g. which tools and when to use>\\n\"\n \"## Answer\\n\"\n \"< final answer instructions how to answer>\"\n ),\n value=\"\",\n advanced=False,\n ),\n IntInput(\n name=\"n_messages\",\n display_name=\"Number of Chat History Messages\",\n value=100,\n info=\"Number of chat history messages to retrieve.\",\n advanced=True,\n show=True,\n ),\n *LCToolsAgentComponent.get_base_inputs(),\n BoolInput(\n name=\"add_current_date_tool\",\n display_name=\"Current Date\",\n advanced=True,\n info=\"If true, will add a tool to the agent that returns the current date.\",\n value=True,\n ),\n BoolInput(\n name=\"lite_mode\",\n display_name=\"Enable CugaLite\",\n info=\"Faster reasoning for simple tasks. Enable CugaLite for simple API tasks.\",\n value=True,\n advanced=True,\n ),\n IntInput(\n name=\"lite_mode_tool_threshold\",\n display_name=\"CugaLite Tool Threshold\",\n info=\"Route to CugaLite if app has fewer than this many tools.\",\n value=25,\n advanced=True,\n ),\n DropdownInput(\n name=\"decomposition_strategy\",\n display_name=\"Decomposition Strategy\",\n info=\"Strategy for task decomposition: 'flexible' allows multiple subtasks per app,\\n\"\n \" 'exact' enforces one subtask per app.\",\n options=[\"flexible\", \"exact\"],\n value=\"flexible\",\n advanced=True,\n ),\n BoolInput(\n name=\"browser_enabled\",\n display_name=\"Enable Browser\",\n info=\"Toggle to enable a built-in browser tool for web scraping and searching.\",\n value=False,\n advanced=True,\n ),\n MultilineInput(\n name=\"web_apps\",\n display_name=\"Web applications\",\n info=(\n \"Cuga will automatically start this web application when Enable Browser is true. \"\n \"Currently only supports one web application. Example: https://example.com\"\n ),\n value=\"\",\n advanced=True,\n ),\n ]\n outputs = [\n Output(name=\"response\", display_name=\"Response\", method=\"message_response\"),\n ]\n\n async def call_agent(\n self, current_input: str, tools: list[Tool], history_messages: list[Message], llm\n ) -> AsyncIterator[dict[str, Any]]:\n \"\"\"Execute the Cuga agent with the given input and tools.\n\n This method initializes and runs the Cuga agent, processing the input through\n the agent's workflow and yielding events for real-time monitoring.\n\n Args:\n current_input: The user input to process\n tools: List of available tools for the agent\n history_messages: Previous conversation history\n llm: The language model instance to use\n\n Yields:\n dict: Agent events including tool usage, thinking, and final results\n\n Raises:\n ValueError: If there's an error in agent initialization\n TypeError: If there's a type error in processing\n RuntimeError: If there's a runtime error during execution\n ConnectionError: If there's a connection issue\n \"\"\"\n yield {\n \"event\": \"on_chain_start\",\n \"run_id\": str(uuid.uuid4()),\n \"name\": \"CUGA_initializing\",\n \"data\": {\"input\": {\"input\": current_input, \"chat_history\": []}},\n }\n logger.debug(f\"[CUGA] LLM MODEL TYPE: {type(llm)}\")\n if current_input:\n # Import settings first\n from cuga.config import settings\n\n # Use Dynaconf's set() method to update settings dynamically\n # This properly updates the settings object without corruption\n logger.debug(\"[CUGA] Updating CUGA settings via Dynaconf set() method\")\n\n settings.advanced_features.registry = False\n settings.advanced_features.lite_mode = self.lite_mode\n settings.advanced_features.lite_mode_tool_threshold = self.lite_mode_tool_threshold\n settings.advanced_features.decomposition_strategy = self.decomposition_strategy\n\n if self.browser_enabled:\n logger.debug(\"[CUGA] browser_enabled is true, setting mode to hybrid\")\n settings.advanced_features.mode = \"hybrid\"\n settings.advanced_features.use_vision = False\n else:\n logger.debug(\"[CUGA] browser_enabled is false, setting mode to api\")\n settings.advanced_features.mode = \"api\"\n\n from cuga.backend.activity_tracker.tracker import ActivityTracker\n from cuga.backend.cuga_graph.utils.agent_loop import StreamEvent\n from cuga.backend.cuga_graph.utils.controller import (\n AgentRunner as CugaAgent,\n )\n from cuga.backend.cuga_graph.utils.controller import (\n ExperimentResult as AgentResult,\n )\n from cuga.backend.llm.models import LLMManager\n from cuga.configurations.instructions_manager import InstructionsManager\n\n # Reset var_manager if this is the first message in history\n logger.debug(f\"[CUGA] Checking history_messages: count={len(history_messages) if history_messages else 0}\")\n if not history_messages or len(history_messages) == 0:\n logger.debug(\"[CUGA] First message in history detected, resetting var_manager\")\n else:\n logger.debug(f\"[CUGA] Continuing conversation with {len(history_messages)} previous messages\")\n\n llm_manager = LLMManager()\n llm_manager.set_llm(llm)\n instructions_manager = InstructionsManager()\n\n instructions_to_use = self.instructions or \"\"\n logger.debug(f\"[CUGA] instructions are: {instructions_to_use}\")\n instructions_manager.set_instructions_from_one_file(instructions_to_use)\n tracker = ActivityTracker()\n tracker.set_tools(tools)\n thread_id = self.graph.session_id\n logger.debug(f\"[CUGA] Using thread_id (session_id): {thread_id}\")\n cuga_agent = CugaAgent(browser_enabled=self.browser_enabled, thread_id=thread_id)\n if self.browser_enabled:\n await cuga_agent.initialize_freemode_env(start_url=self.web_apps.strip(), interface_mode=\"browser_only\")\n else:\n await cuga_agent.initialize_appworld_env()\n\n yield {\n \"event\": \"on_chain_start\",\n \"run_id\": str(uuid.uuid4()),\n \"name\": \"CUGA_thinking...\",\n \"data\": {\"input\": {\"input\": current_input, \"chat_history\": []}},\n }\n logger.debug(f\"[CUGA] current web apps are {self.web_apps}\")\n logger.debug(f\"[CUGA] Processing input: {current_input}\")\n try:\n # Convert history to LangChain format for the event\n logger.debug(f\"[CUGA] Converting {len(history_messages)} history messages to LangChain format\")\n lc_messages = []\n for i, msg in enumerate(history_messages):\n msg_text = getattr(msg, \"text\", \"N/A\")[:50] if hasattr(msg, \"text\") else \"N/A\"\n logger.debug(\n f\"[CUGA] Message {i}: type={type(msg)}, sender={getattr(msg, 'sender', 'N/A')}, \"\n f\"text={msg_text}...\"\n )\n if hasattr(msg, \"sender\") and msg.sender == \"Human\":\n lc_messages.append(HumanMessage(content=msg.text))\n else:\n lc_messages.append(AIMessage(content=msg.text))\n\n logger.debug(f\"[CUGA] Converted to {len(lc_messages)} LangChain messages\")\n await asyncio.sleep(0.5)\n\n # 2. Build final response\n response_parts = []\n\n response_parts.append(f\"Processed input: '{current_input}'\")\n response_parts.append(f\"Available tools: {len(tools)}\")\n last_event: StreamEvent | None = None\n tool_run_id: str | None = None\n # 3. Chain end event with AgentFinish\n async for event in cuga_agent.run_task_generic_yield(\n eval_mode=False, goal=current_input, chat_messages=lc_messages\n ):\n logger.debug(f\"[CUGA] recieved event {event}\")\n if last_event is not None and tool_run_id is not None:\n logger.debug(f\"[CUGA] last event {last_event}\")\n try:\n # TODO: Extract data\n data_dict = json.loads(last_event.data)\n except json.JSONDecodeError:\n data_dict = last_event.data\n if last_event.name == \"CodeAgent\" and \"code\" in data_dict:\n data_dict = data_dict[\"code\"]\n yield {\n \"event\": \"on_tool_end\",\n \"run_id\": tool_run_id,\n \"name\": last_event.name,\n \"data\": {\"output\": data_dict},\n }\n if isinstance(event, StreamEvent):\n tool_run_id = str(uuid.uuid4())\n last_event = StreamEvent(name=event.name, data=event.data)\n tool_event = {\n \"event\": \"on_tool_start\",\n \"run_id\": tool_run_id,\n \"name\": event.name,\n \"data\": {\"input\": {}},\n }\n logger.debug(f\"[CUGA] Yielding tool_start event: {event.name}\")\n yield tool_event\n\n if isinstance(event, AgentResult):\n task_result = event\n end_event = {\n \"event\": \"on_chain_end\",\n \"run_id\": str(uuid.uuid4()),\n \"name\": \"CugaAgent\",\n \"data\": {\"output\": AgentFinish(return_values={\"output\": task_result.answer}, log=\"\")},\n }\n answer_preview = task_result.answer[:100] if task_result.answer else \"None\"\n logger.info(f\"[CUGA] Yielding chain_end event with answer: {answer_preview}...\")\n yield end_event\n\n except (ValueError, TypeError, RuntimeError, ConnectionError) as e:\n logger.error(f\"[CUGA] An error occurred: {e!s}\")\n logger.error(f\"[CUGA] Traceback: {traceback.format_exc()}\")\n error_msg = f\"CUGA Agent error: {e!s}\"\n logger.error(f\"[CUGA] Error occurred: {error_msg}\")\n\n # Emit error event\n yield {\n \"event\": \"on_chain_error\",\n \"run_id\": str(uuid.uuid4()),\n \"name\": \"CugaAgent\",\n \"data\": {\"error\": error_msg},\n }\n\n async def message_response(self) -> Message:\n \"\"\"Generate a message response using the Cuga agent.\n\n This method processes the input through the Cuga agent and returns a structured\n message response. It handles agent initialization, tool setup, and event processing.\n\n Returns:\n Message: The agent's response message\n\n Raises:\n Exception: If there's an error during agent execution\n \"\"\"\n logger.debug(\"[CUGA] Starting Cuga agent run for message_response.\")\n logger.debug(f\"[CUGA] Agent input value: {self.input_value}\")\n\n # Validate input is not empty\n if not self.input_value or not str(self.input_value).strip():\n msg = \"Message cannot be empty. Please provide a valid message.\"\n raise ValueError(msg)\n\n try:\n from lfx.schema.content_block import ContentBlock\n from lfx.schema.message import MESSAGE_SENDER_AI\n\n llm_model, self.chat_history, self.tools = await self.get_agent_requirements()\n\n # Create agent message for event processing\n agent_message = Message(\n sender=MESSAGE_SENDER_AI,\n sender_name=\"Cuga\",\n properties={\"icon\": \"Bot\", \"state\": \"partial\"},\n content_blocks=[ContentBlock(title=\"Agent Steps\", contents=[])],\n session_id=self.graph.session_id,\n )\n\n # Pre-assign an ID for event processing, following the base agent pattern\n # This ensures streaming works even when not connected to ChatOutput\n if not self.is_connected_to_chat_output():\n # When not connected to ChatOutput, assign ID upfront for streaming support\n agent_message.data[\"id\"] = uuid.uuid4()\n\n # Get input text\n input_text = self.input_value.text if hasattr(self.input_value, \"text\") else str(self.input_value)\n\n # Create event iterator from call_agent\n event_iterator = self.call_agent(\n current_input=input_text, tools=self.tools or [], history_messages=self.chat_history, llm=llm_model\n )\n\n # Process events using the existing event processing system\n from lfx.base.agents.events import process_agent_events\n\n # Create a wrapper that forces DB updates for event handlers\n # This ensures the UI can see loading steps in real-time via polling\n async def force_db_update_send_message(message, id_=None, *, skip_db_update=False): # noqa: ARG001\n # Always persist to DB so polling-based UI shows loading steps in real-time\n content_blocks_len = len(message.content_blocks[0].contents) if message.content_blocks else 0\n logger.debug(\n f\"[CUGA] Sending message update - state: {message.properties.state}, \"\n f\"content_blocks: {content_blocks_len}\"\n )\n\n result = await self.send_message(message, id_=id_, skip_db_update=False)\n\n logger.debug(f\"[CUGA] Message processed with ID: {result.id}\")\n return result\n\n result = await process_agent_events(\n event_iterator, agent_message, cast(\"SendMessageFunctionType\", force_db_update_send_message)\n )\n\n logger.debug(\"[CUGA] Agent run finished successfully.\")\n logger.debug(f\"[CUGA] Agent output: {result}\")\n\n except Exception as e:\n logger.error(f\"[CUGA] Error in message_response: {e}\")\n logger.error(f\"[CUGA] An error occurred: {e!s}\")\n logger.error(f\"[CUGA] Traceback: {traceback.format_exc()}\")\n\n # Check if error is related to Playwright installation\n error_str = str(e).lower()\n if \"playwright install\" in error_str:\n msg = (\n \"Playwright is not installed. Please install Playwright Chromium using: \"\n \"uv run -m playwright install chromium\"\n )\n raise ValueError(msg) from e\n\n raise\n else:\n return result\n\n async def get_agent_requirements(self):\n \"\"\"Get the agent requirements for the Cuga agent.\n\n This method retrieves and configures all necessary components for the agent\n including the language model, chat history, and tools.\n\n Returns:\n tuple: A tuple containing (llm_model, chat_history, tools)\n\n Raises:\n ValueError: If no language model is selected or if there's an error\n in model initialization\n \"\"\"\n llm_model, display_name = await self.get_llm()\n if llm_model is None:\n msg = \"No language model selected. Please choose a model to proceed.\"\n raise ValueError(msg)\n self.model_name = get_model_name(llm_model, display_name=display_name)\n\n # Get memory data\n self.chat_history = await self.get_memory_data()\n if isinstance(self.chat_history, Message):\n self.chat_history = [self.chat_history]\n\n # Add current date tool if enabled\n if self.add_current_date_tool:\n if not isinstance(self.tools, list):\n self.tools = []\n current_date_tool = (await CurrentDateComponent(**self.get_base_args()).to_toolkit()).pop(0)\n if not isinstance(current_date_tool, StructuredTool):\n msg = \"CurrentDateComponent must be converted to a StructuredTool\"\n raise TypeError(msg)\n self.tools.append(current_date_tool)\n\n # --- ADDED LOGGING START ---\n logger.debug(\"[CUGA] Retrieved agent requirements: LLM, chat history, and tools.\")\n logger.debug(f\"[CUGA] LLM model: {self.model_name}\")\n logger.debug(f\"[CUGA] Number of chat history messages: {len(self.chat_history)}\")\n logger.debug(f\"[CUGA] Tools available: {[tool.name for tool in self.tools]}\")\n logger.debug(f\"[CUGA] metadata: {[tool.metadata for tool in self.tools]}\")\n # --- ADDED LOGGING END ---\n\n return llm_model, self.chat_history, self.tools\n\n async def get_memory_data(self):\n \"\"\"Retrieve chat history messages.\n\n This method fetches the conversation history from memory, excluding the current\n input message to avoid duplication.\n\n Returns:\n list: List of Message objects representing the chat history\n \"\"\"\n logger.debug(\"[CUGA] Retrieving chat history messages.\")\n logger.debug(f\"[CUGA] Session ID: {self.graph.session_id}\")\n logger.debug(f\"[CUGA] n_messages: {self.n_messages}\")\n logger.debug(f\"[CUGA] input_value: {self.input_value}\")\n logger.debug(f\"[CUGA] input_value type: {type(self.input_value)}\")\n logger.debug(f\"[CUGA] input_value id: {getattr(self.input_value, 'id', None)}\")\n\n messages = (\n await MemoryComponent(**self.get_base_args())\n .set(session_id=str(self.graph.session_id), order=\"Ascending\", n_messages=self.n_messages)\n .retrieve_messages()\n )\n logger.debug(f\"[CUGA] Retrieved {len(messages)} messages from memory\")\n return [\n message for message in messages if getattr(message, \"id\", None) != getattr(self.input_value, \"id\", None)\n ]\n\n async def get_llm(self):\n \"\"\"Get language model for the Cuga agent.\n\n This method initializes and configures the language model based on the\n selected provider and parameters.\n\n Returns:\n tuple: A tuple containing (llm_model, display_name)\n\n Raises:\n ValueError: If the model provider is invalid or model initialization fails\n \"\"\"\n logger.debug(\"[CUGA] Getting language model for the agent.\")\n logger.debug(f\"[CUGA] Requested LLM provider: {self.agent_llm}\")\n\n if not isinstance(self.agent_llm, str):\n logger.debug(\"[CUGA] Agent LLM is already a model instance.\")\n return self.agent_llm, None\n\n try:\n provider_info = MODEL_PROVIDERS_DICT.get(self.agent_llm)\n if not provider_info:\n msg = f\"Invalid model provider: {self.agent_llm}\"\n raise ValueError(msg)\n\n component_class = provider_info.get(\"component_class\")\n display_name = component_class.display_name\n inputs = provider_info.get(\"inputs\")\n prefix = provider_info.get(\"prefix\", \"\")\n logger.debug(f\"[CUGA] Successfully built LLM model from provider: {self.agent_llm}\")\n return self._build_llm_model(component_class, inputs, prefix), display_name\n\n except (AttributeError, ValueError, TypeError, RuntimeError) as e:\n await logger.aerror(f\"[CUGA] Error building {self.agent_llm} language model: {e!s}\")\n msg = f\"Failed to initialize language model: {e!s}\"\n raise ValueError(msg) from e\n\n def _build_llm_model(self, component, inputs, prefix=\"\"):\n \"\"\"Build LLM model with parameters.\n\n This method constructs a language model instance using the provided component\n class and input parameters.\n\n Args:\n component: The LLM component class to instantiate\n inputs: List of input field definitions\n prefix: Optional prefix for parameter names\n\n Returns:\n The configured LLM model instance\n \"\"\"\n model_kwargs = {}\n for input_ in inputs:\n if hasattr(self, f\"{prefix}{input_.name}\"):\n model_kwargs[input_.name] = getattr(self, f\"{prefix}{input_.name}\")\n return component.set(**model_kwargs).build_model()\n\n def set_component_params(self, component):\n \"\"\"Set component parameters based on provider.\n\n This method configures component parameters according to the selected\n model provider's requirements.\n\n Args:\n component: The component to configure\n\n Returns:\n The configured component\n \"\"\"\n provider_info = MODEL_PROVIDERS_DICT.get(self.agent_llm)\n if provider_info:\n inputs = provider_info.get(\"inputs\")\n prefix = provider_info.get(\"prefix\")\n model_kwargs = {}\n for input_ in inputs:\n if hasattr(self, f\"{prefix}{input_.name}\"):\n model_kwargs[input_.name] = getattr(self, f\"{prefix}{input_.name}\")\n return component.set(**model_kwargs)\n return component\n\n def delete_fields(self, build_config: dotdict, fields: dict | list[str]) -> None:\n \"\"\"Delete specified fields from build_config.\n\n This method removes unwanted fields from the build configuration.\n\n Args:\n build_config: The build configuration dictionary\n fields: Fields to remove (can be dict or list of strings)\n \"\"\"\n for field in fields:\n build_config.pop(field, None)\n\n def update_input_types(self, build_config: dotdict) -> dotdict:\n \"\"\"Update input types for all fields in build_config.\n\n This method ensures all fields in the build configuration have proper\n input types defined.\n\n Args:\n build_config: The build configuration to update\n\n Returns:\n dotdict: Updated build configuration with input types\n \"\"\"\n for key, value in build_config.items():\n if isinstance(value, dict):\n if value.get(\"input_types\") is None:\n build_config[key][\"input_types\"] = []\n elif hasattr(value, \"input_types\") and value.input_types is None:\n value.input_types = []\n return build_config\n\n async def update_build_config(\n self, build_config: dotdict, field_value: str, field_name: str | None = None\n ) -> dotdict:\n \"\"\"Update build configuration based on field changes.\n\n This method dynamically updates the component's build configuration when\n certain fields change, particularly the model provider selection.\n\n Args:\n build_config: The current build configuration\n field_value: The new value for the field\n field_name: The name of the field being changed\n\n Returns:\n dotdict: Updated build configuration\n\n Raises:\n ValueError: If required keys are missing from the configuration\n \"\"\"\n if field_name in (\"agent_llm\",):\n build_config[\"agent_llm\"][\"value\"] = field_value\n provider_info = MODEL_PROVIDERS_DICT.get(field_value)\n if provider_info:\n component_class = provider_info.get(\"component_class\")\n if component_class and hasattr(component_class, \"update_build_config\"):\n build_config = await update_component_build_config(\n component_class, build_config, field_value, \"model_name\"\n )\n\n provider_configs: dict[str, tuple[dict, list[dict]]] = {\n provider: (\n MODEL_PROVIDERS_DICT[provider][\"fields\"],\n [\n MODEL_PROVIDERS_DICT[other_provider][\"fields\"]\n for other_provider in MODEL_PROVIDERS_DICT\n if other_provider != provider\n ],\n )\n for provider in MODEL_PROVIDERS_DICT\n }\n if field_value in provider_configs:\n fields_to_add, fields_to_delete = provider_configs[field_value]\n\n # Delete fields from other providers\n for fields in fields_to_delete:\n self.delete_fields(build_config, fields)\n\n # Add provider-specific fields\n if field_value == \"OpenAI\" and not any(field in build_config for field in fields_to_add):\n build_config.update(fields_to_add)\n else:\n build_config.update(fields_to_add)\n build_config[\"agent_llm\"][\"input_types\"] = []\n elif field_value == \"Custom\":\n # Delete all provider fields\n self.delete_fields(build_config, ALL_PROVIDER_FIELDS)\n # Update with custom component\n custom_component = DropdownInput(\n name=\"agent_llm\",\n display_name=\"Language Model\",\n options=[*sorted(MODEL_PROVIDERS), \"Custom\"],\n value=\"Custom\",\n real_time_refresh=True,\n input_types=[\"LanguageModel\"],\n options_metadata=[MODELS_METADATA[key] for key in sorted(MODELS_METADATA.keys())]\n + [{\"icon\": \"brain\"}],\n )\n build_config.update({\"agent_llm\": custom_component.to_dict()})\n\n # Update input types for all fields\n build_config = self.update_input_types(build_config)\n\n # Validate required keys\n default_keys = [\n \"code\",\n \"_type\",\n \"agent_llm\",\n \"tools\",\n \"input_value\",\n \"add_current_date_tool\",\n \"instructions\",\n \"agent_description\",\n \"max_iterations\",\n \"handle_parsing_errors\",\n \"verbose\",\n ]\n missing_keys = [key for key in default_keys if key not in build_config]\n if missing_keys:\n msg = f\"Missing required keys in build_config: {missing_keys}\"\n raise ValueError(msg)\n\n if (\n isinstance(self.agent_llm, str)\n and self.agent_llm in MODEL_PROVIDERS_DICT\n and field_name in MODEL_DYNAMIC_UPDATE_FIELDS\n ):\n provider_info = MODEL_PROVIDERS_DICT.get(self.agent_llm)\n if provider_info:\n component_class = provider_info.get(\"component_class\")\n component_class = self.set_component_params(component_class)\n prefix = provider_info.get(\"prefix\")\n if component_class and hasattr(component_class, \"update_build_config\"):\n if isinstance(field_name, str) and isinstance(prefix, str):\n field_name = field_name.replace(prefix, \"\")\n build_config = await update_component_build_config(\n component_class, build_config, field_value, \"model_name\"\n )\n return dotdict({k: v.to_dict() if hasattr(v, \"to_dict\") else v for k, v in build_config.items()})\n\n async def _get_tools(self) -> list[Tool]:\n \"\"\"Build agent tools.\n\n This method constructs the list of tools available to the Cuga agent,\n including component tools and any additional configured tools.\n\n Returns:\n list[Tool]: List of available tools for the agent\n \"\"\"\n logger.debug(\"[CUGA] Building agent tools.\")\n component_toolkit = _get_component_toolkit()\n tools_names = self._build_tools_names()\n agent_description = self.get_tool_description()\n description = f\"{agent_description}{tools_names}\"\n tools = component_toolkit(component=self).get_tools(\n tool_name=\"Call_CugaAgent\", tool_description=description, callbacks=self.get_langchain_callbacks()\n )\n if hasattr(self, \"tools_metadata\"):\n tools = component_toolkit(component=self, metadata=self.tools_metadata).update_tools_metadata(tools=tools)\n logger.debug(f\"[CUGA] Tools built: {[tool.name for tool in tools]}\")\n return tools\n" + "value": "import asyncio\nimport json\nimport traceback\nimport uuid\nfrom collections.abc import AsyncIterator\nfrom typing import TYPE_CHECKING, Any, cast\n\nfrom langchain_core.agents import AgentFinish\nfrom langchain_core.messages import AIMessage, HumanMessage\nfrom langchain_core.tools import StructuredTool\n\nfrom lfx.base.agents.agent import LCToolsAgentComponent\nfrom lfx.base.models.model_input_constants import (\n ALL_PROVIDER_FIELDS,\n MODEL_DYNAMIC_UPDATE_FIELDS,\n MODEL_PROVIDERS,\n MODEL_PROVIDERS_DICT,\n MODELS_METADATA,\n)\nfrom lfx.base.models.model_utils import get_model_name\nfrom lfx.components.helpers import CurrentDateComponent\nfrom lfx.components.langchain_utilities.tool_calling import ToolCallingAgentComponent\nfrom lfx.components.models_and_agents.memory import MemoryComponent\nfrom lfx.custom.custom_component.component import _get_component_toolkit\nfrom lfx.custom.utils import update_component_build_config\nfrom lfx.field_typing import Tool\nfrom lfx.io import BoolInput, DropdownInput, IntInput, MultilineInput, Output\nfrom lfx.log.logger import logger\nfrom lfx.schema.dotdict import dotdict\nfrom lfx.schema.message import Message\n\nif TYPE_CHECKING:\n from lfx.schema.log import SendMessageFunctionType\n\n\ndef set_advanced_true(component_input):\n \"\"\"Set the advanced flag to True for a component input.\n\n Args:\n component_input: The component input to modify\n\n Returns:\n The modified component input with advanced=True\n \"\"\"\n component_input.advanced = True\n return component_input\n\n\nMODEL_PROVIDERS_LIST = [\"OpenAI\"]\n\n\nclass CugaComponent(ToolCallingAgentComponent):\n \"\"\"Cuga Agent Component for advanced AI task execution.\n\n The Cuga component is an advanced AI agent that can execute complex tasks using\n various tools and browser automation. It supports custom instructions, web applications,\n and API interactions.\n\n Attributes:\n display_name: Human-readable name for the component\n description: Brief description of the component's purpose\n documentation: URL to component documentation\n icon: Icon identifier for the UI\n name: Internal component name\n \"\"\"\n\n display_name: str = \"Cuga\"\n description: str = \"Define the Cuga agent's instructions, then assign it a task.\"\n documentation: str = \"https://docs.langflow.org/bundles-cuga\"\n icon = \"bot\"\n name = \"Cuga\"\n\n memory_inputs = [set_advanced_true(component_input) for component_input in MemoryComponent().inputs]\n\n inputs = [\n DropdownInput(\n name=\"agent_llm\",\n display_name=\"Model Provider\",\n info=\"The provider of the language model that the agent will use to generate responses.\",\n options=[*MODEL_PROVIDERS_LIST, \"Custom\"],\n value=\"OpenAI\",\n real_time_refresh=True,\n input_types=[],\n options_metadata=[MODELS_METADATA[key] for key in MODEL_PROVIDERS_LIST] + [{\"icon\": \"brain\"}],\n ),\n *MODEL_PROVIDERS_DICT[\"OpenAI\"][\"inputs\"],\n MultilineInput(\n name=\"instructions\",\n display_name=\"Instructions\",\n info=(\n \"Custom instructions for the agent to adhere to during its operation.\\n\"\n \"Example:\\n\"\n \"## Plan\\n\"\n \"< planning instructions e.g. which tools and when to use>\\n\"\n \"## Answer\\n\"\n \"< final answer instructions how to answer>\"\n ),\n value=\"\",\n advanced=False,\n ),\n IntInput(\n name=\"n_messages\",\n display_name=\"Number of Chat History Messages\",\n value=100,\n info=\"Number of chat history messages to retrieve.\",\n advanced=True,\n show=True,\n ),\n *LCToolsAgentComponent.get_base_inputs(),\n BoolInput(\n name=\"add_current_date_tool\",\n display_name=\"Current Date\",\n advanced=True,\n info=\"If true, will add a tool to the agent that returns the current date.\",\n value=True,\n ),\n BoolInput(\n name=\"lite_mode\",\n display_name=\"Enable CugaLite\",\n info=\"Faster reasoning for simple tasks. Enable CugaLite for simple API tasks.\",\n value=True,\n advanced=True,\n ),\n IntInput(\n name=\"lite_mode_tool_threshold\",\n display_name=\"CugaLite Tool Threshold\",\n info=\"Route to CugaLite if app has fewer than this many tools.\",\n value=25,\n advanced=True,\n ),\n DropdownInput(\n name=\"decomposition_strategy\",\n display_name=\"Decomposition Strategy\",\n info=\"Strategy for task decomposition: 'flexible' allows multiple subtasks per app,\\n\"\n \" 'exact' enforces one subtask per app.\",\n options=[\"flexible\", \"exact\"],\n value=\"flexible\",\n advanced=True,\n ),\n BoolInput(\n name=\"browser_enabled\",\n display_name=\"Enable Browser\",\n info=\"Toggle to enable a built-in browser tool for web scraping and searching.\",\n value=False,\n advanced=True,\n ),\n MultilineInput(\n name=\"web_apps\",\n display_name=\"Web applications\",\n info=(\n \"Cuga will automatically start this web application when Enable Browser is true. \"\n \"Currently only supports one web application. Example: https://example.com\"\n ),\n value=\"\",\n advanced=True,\n ),\n ]\n outputs = [\n Output(name=\"response\", display_name=\"Response\", method=\"message_response\"),\n ]\n\n async def call_agent(\n self, current_input: str, tools: list[Tool], history_messages: list[Message], llm\n ) -> AsyncIterator[dict[str, Any]]:\n \"\"\"Execute the Cuga agent with the given input and tools.\n\n This method initializes and runs the Cuga agent, processing the input through\n the agent's workflow and yielding events for real-time monitoring.\n\n Args:\n current_input: The user input to process\n tools: List of available tools for the agent\n history_messages: Previous conversation history\n llm: The language model instance to use\n\n Yields:\n dict: Agent events including tool usage, thinking, and final results\n\n Raises:\n ValueError: If there's an error in agent initialization\n TypeError: If there's a type error in processing\n RuntimeError: If there's a runtime error during execution\n ConnectionError: If there's a connection issue\n \"\"\"\n yield {\n \"event\": \"on_chain_start\",\n \"run_id\": str(uuid.uuid4()),\n \"name\": \"CUGA_initializing\",\n \"data\": {\"input\": {\"input\": current_input, \"chat_history\": []}},\n }\n logger.debug(f\"[CUGA] LLM MODEL TYPE: {type(llm)}\")\n if current_input:\n # Import settings first\n from cuga.config import settings\n\n # Use Dynaconf's set() method to update settings dynamically\n # This properly updates the settings object without corruption\n logger.debug(\"[CUGA] Updating CUGA settings via Dynaconf set() method\")\n\n settings.advanced_features.registry = False\n settings.advanced_features.lite_mode = self.lite_mode\n settings.advanced_features.lite_mode_tool_threshold = self.lite_mode_tool_threshold\n settings.advanced_features.decomposition_strategy = self.decomposition_strategy\n\n if self.browser_enabled:\n logger.debug(\"[CUGA] browser_enabled is true, setting mode to hybrid\")\n settings.advanced_features.mode = \"hybrid\"\n settings.advanced_features.use_vision = False\n else:\n logger.debug(\"[CUGA] browser_enabled is false, setting mode to api\")\n settings.advanced_features.mode = \"api\"\n\n from cuga.backend.activity_tracker.tracker import ActivityTracker\n from cuga.backend.cuga_graph.utils.agent_loop import StreamEvent\n from cuga.backend.cuga_graph.utils.controller import (\n AgentRunner as CugaAgent,\n )\n from cuga.backend.cuga_graph.utils.controller import (\n ExperimentResult as AgentResult,\n )\n from cuga.backend.llm.models import LLMManager\n from cuga.configurations.instructions_manager import InstructionsManager\n\n # Reset var_manager if this is the first message in history\n logger.debug(f\"[CUGA] Checking history_messages: count={len(history_messages) if history_messages else 0}\")\n if not history_messages or len(history_messages) == 0:\n logger.debug(\"[CUGA] First message in history detected, resetting var_manager\")\n else:\n logger.debug(f\"[CUGA] Continuing conversation with {len(history_messages)} previous messages\")\n\n llm_manager = LLMManager()\n llm_manager.set_llm(llm)\n instructions_manager = InstructionsManager()\n\n instructions_to_use = self.instructions or \"\"\n logger.debug(f\"[CUGA] instructions are: {instructions_to_use}\")\n instructions_manager.set_instructions_from_one_file(instructions_to_use)\n tracker = ActivityTracker()\n tracker.set_tools(tools)\n thread_id = self.graph.session_id\n logger.debug(f\"[CUGA] Using thread_id (session_id): {thread_id}\")\n cuga_agent = CugaAgent(browser_enabled=self.browser_enabled, thread_id=thread_id)\n if self.browser_enabled:\n await cuga_agent.initialize_freemode_env(start_url=self.web_apps.strip(), interface_mode=\"browser_only\")\n else:\n await cuga_agent.initialize_appworld_env()\n\n yield {\n \"event\": \"on_chain_start\",\n \"run_id\": str(uuid.uuid4()),\n \"name\": \"CUGA_thinking...\",\n \"data\": {\"input\": {\"input\": current_input, \"chat_history\": []}},\n }\n logger.debug(f\"[CUGA] current web apps are {self.web_apps}\")\n logger.debug(f\"[CUGA] Processing input: {current_input}\")\n try:\n # Convert history to LangChain format for the event\n logger.debug(f\"[CUGA] Converting {len(history_messages)} history messages to LangChain format\")\n lc_messages = []\n for i, msg in enumerate(history_messages):\n msg_text = getattr(msg, \"text\", \"N/A\")[:50] if hasattr(msg, \"text\") else \"N/A\"\n logger.debug(\n f\"[CUGA] Message {i}: type={type(msg)}, sender={getattr(msg, 'sender', 'N/A')}, \"\n f\"text={msg_text}...\"\n )\n if hasattr(msg, \"sender\") and msg.sender == \"Human\":\n lc_messages.append(HumanMessage(content=msg.text))\n else:\n lc_messages.append(AIMessage(content=msg.text))\n\n logger.debug(f\"[CUGA] Converted to {len(lc_messages)} LangChain messages\")\n await asyncio.sleep(0.5)\n\n # 2. Build final response\n response_parts = []\n\n response_parts.append(f\"Processed input: '{current_input}'\")\n response_parts.append(f\"Available tools: {len(tools)}\")\n last_event: StreamEvent | None = None\n tool_run_id: str | None = None\n # 3. Chain end event with AgentFinish\n async for event in cuga_agent.run_task_generic_yield(\n eval_mode=False, goal=current_input, chat_messages=lc_messages\n ):\n logger.debug(f\"[CUGA] recieved event {event}\")\n if last_event is not None and tool_run_id is not None:\n logger.debug(f\"[CUGA] last event {last_event}\")\n try:\n # TODO: Extract data\n data_dict = json.loads(last_event.data)\n except json.JSONDecodeError:\n data_dict = last_event.data\n if last_event.name == \"CodeAgent\" and \"code\" in data_dict:\n data_dict = data_dict[\"code\"]\n yield {\n \"event\": \"on_tool_end\",\n \"run_id\": tool_run_id,\n \"name\": last_event.name,\n \"data\": {\"output\": data_dict},\n }\n if isinstance(event, StreamEvent):\n tool_run_id = str(uuid.uuid4())\n last_event = StreamEvent(name=event.name, data=event.data)\n tool_event = {\n \"event\": \"on_tool_start\",\n \"run_id\": tool_run_id,\n \"name\": event.name,\n \"data\": {\"input\": {}},\n }\n logger.debug(f\"[CUGA] Yielding tool_start event: {event.name}\")\n yield tool_event\n\n if isinstance(event, AgentResult):\n task_result = event\n end_event = {\n \"event\": \"on_chain_end\",\n \"run_id\": str(uuid.uuid4()),\n \"name\": \"CugaAgent\",\n \"data\": {\"output\": AgentFinish(return_values={\"output\": task_result.answer}, log=\"\")},\n }\n answer_preview = task_result.answer[:100] if task_result.answer else \"None\"\n logger.info(f\"[CUGA] Yielding chain_end event with answer: {answer_preview}...\")\n yield end_event\n\n except (ValueError, TypeError, RuntimeError, ConnectionError) as e:\n logger.error(f\"[CUGA] An error occurred: {e!s}\")\n logger.error(f\"[CUGA] Traceback: {traceback.format_exc()}\")\n error_msg = f\"CUGA Agent error: {e!s}\"\n logger.error(f\"[CUGA] Error occurred: {error_msg}\")\n\n # Emit error event\n yield {\n \"event\": \"on_chain_error\",\n \"run_id\": str(uuid.uuid4()),\n \"name\": \"CugaAgent\",\n \"data\": {\"error\": error_msg},\n }\n\n async def message_response(self) -> Message:\n \"\"\"Generate a message response using the Cuga agent.\n\n This method processes the input through the Cuga agent and returns a structured\n message response. It handles agent initialization, tool setup, and event processing.\n\n Returns:\n Message: The agent's response message\n\n Raises:\n Exception: If there's an error during agent execution\n \"\"\"\n logger.debug(\"[CUGA] Starting Cuga agent run for message_response.\")\n logger.debug(f\"[CUGA] Agent input value: {self.input_value}\")\n\n # Validate input is not empty\n if not self.input_value or not str(self.input_value).strip():\n msg = \"Message cannot be empty. Please provide a valid message.\"\n raise ValueError(msg)\n\n try:\n from lfx.schema.content_block import ContentBlock\n from lfx.schema.message import MESSAGE_SENDER_AI\n\n llm_model, self.chat_history, self.tools = await self.get_agent_requirements()\n\n # Create agent message for event processing\n agent_message = Message(\n sender=MESSAGE_SENDER_AI,\n sender_name=\"Cuga\",\n properties={\"icon\": \"Bot\", \"state\": \"partial\"},\n content_blocks=[ContentBlock(title=\"Agent Steps\", contents=[])],\n session_id=self.graph.session_id or str(uuid.uuid4()),\n )\n\n # Pre-assign an ID for event processing, following the base agent pattern\n # This ensures streaming works even when not connected to ChatOutput\n if not self.is_connected_to_chat_output():\n # When not connected to ChatOutput, assign ID upfront for streaming support\n agent_message.data[\"id\"] = uuid.uuid4()\n\n # Get input text\n input_text = self.input_value.text if hasattr(self.input_value, \"text\") else str(self.input_value)\n\n # Create event iterator from call_agent\n event_iterator = self.call_agent(\n current_input=input_text, tools=self.tools or [], history_messages=self.chat_history, llm=llm_model\n )\n\n # Process events using the existing event processing system\n from lfx.base.agents.events import process_agent_events\n\n # Create a wrapper that forces DB updates for event handlers\n # This ensures the UI can see loading steps in real-time via polling\n async def force_db_update_send_message(message, id_=None, *, skip_db_update=False): # noqa: ARG001\n # Always persist to DB so polling-based UI shows loading steps in real-time\n content_blocks_len = len(message.content_blocks[0].contents) if message.content_blocks else 0\n logger.debug(\n f\"[CUGA] Sending message update - state: {message.properties.state}, \"\n f\"content_blocks: {content_blocks_len}\"\n )\n\n result = await self.send_message(message, id_=id_, skip_db_update=False)\n\n logger.debug(f\"[CUGA] Message processed with ID: {result.id}\")\n return result\n\n result = await process_agent_events(\n event_iterator, agent_message, cast(\"SendMessageFunctionType\", force_db_update_send_message)\n )\n\n logger.debug(\"[CUGA] Agent run finished successfully.\")\n logger.debug(f\"[CUGA] Agent output: {result}\")\n\n except Exception as e:\n logger.error(f\"[CUGA] Error in message_response: {e}\")\n logger.error(f\"[CUGA] An error occurred: {e!s}\")\n logger.error(f\"[CUGA] Traceback: {traceback.format_exc()}\")\n\n # Check if error is related to Playwright installation\n error_str = str(e).lower()\n if \"playwright install\" in error_str:\n msg = (\n \"Playwright is not installed. Please install Playwright Chromium using: \"\n \"uv run -m playwright install chromium\"\n )\n raise ValueError(msg) from e\n\n raise\n else:\n return result\n\n async def get_agent_requirements(self):\n \"\"\"Get the agent requirements for the Cuga agent.\n\n This method retrieves and configures all necessary components for the agent\n including the language model, chat history, and tools.\n\n Returns:\n tuple: A tuple containing (llm_model, chat_history, tools)\n\n Raises:\n ValueError: If no language model is selected or if there's an error\n in model initialization\n \"\"\"\n llm_model, display_name = await self.get_llm()\n if llm_model is None:\n msg = \"No language model selected. Please choose a model to proceed.\"\n raise ValueError(msg)\n self.model_name = get_model_name(llm_model, display_name=display_name)\n\n # Get memory data\n self.chat_history = await self.get_memory_data()\n if isinstance(self.chat_history, Message):\n self.chat_history = [self.chat_history]\n\n # Add current date tool if enabled\n if self.add_current_date_tool:\n if not isinstance(self.tools, list):\n self.tools = []\n current_date_tool = (await CurrentDateComponent(**self.get_base_args()).to_toolkit()).pop(0)\n if not isinstance(current_date_tool, StructuredTool):\n msg = \"CurrentDateComponent must be converted to a StructuredTool\"\n raise TypeError(msg)\n self.tools.append(current_date_tool)\n\n # --- ADDED LOGGING START ---\n logger.debug(\"[CUGA] Retrieved agent requirements: LLM, chat history, and tools.\")\n logger.debug(f\"[CUGA] LLM model: {self.model_name}\")\n logger.debug(f\"[CUGA] Number of chat history messages: {len(self.chat_history)}\")\n logger.debug(f\"[CUGA] Tools available: {[tool.name for tool in self.tools]}\")\n logger.debug(f\"[CUGA] metadata: {[tool.metadata for tool in self.tools]}\")\n # --- ADDED LOGGING END ---\n\n return llm_model, self.chat_history, self.tools\n\n async def get_memory_data(self):\n \"\"\"Retrieve chat history messages.\n\n This method fetches the conversation history from memory, excluding the current\n input message to avoid duplication.\n\n Returns:\n list: List of Message objects representing the chat history\n \"\"\"\n logger.debug(\"[CUGA] Retrieving chat history messages.\")\n logger.debug(f\"[CUGA] Session ID: {self.graph.session_id}\")\n logger.debug(f\"[CUGA] n_messages: {self.n_messages}\")\n logger.debug(f\"[CUGA] input_value: {self.input_value}\")\n logger.debug(f\"[CUGA] input_value type: {type(self.input_value)}\")\n logger.debug(f\"[CUGA] input_value id: {getattr(self.input_value, 'id', None)}\")\n\n messages = (\n await MemoryComponent(**self.get_base_args())\n .set(session_id=str(self.graph.session_id), order=\"Ascending\", n_messages=self.n_messages)\n .retrieve_messages()\n )\n logger.debug(f\"[CUGA] Retrieved {len(messages)} messages from memory\")\n return [\n message for message in messages if getattr(message, \"id\", None) != getattr(self.input_value, \"id\", None)\n ]\n\n async def get_llm(self):\n \"\"\"Get language model for the Cuga agent.\n\n This method initializes and configures the language model based on the\n selected provider and parameters.\n\n Returns:\n tuple: A tuple containing (llm_model, display_name)\n\n Raises:\n ValueError: If the model provider is invalid or model initialization fails\n \"\"\"\n logger.debug(\"[CUGA] Getting language model for the agent.\")\n logger.debug(f\"[CUGA] Requested LLM provider: {self.agent_llm}\")\n\n if not isinstance(self.agent_llm, str):\n logger.debug(\"[CUGA] Agent LLM is already a model instance.\")\n return self.agent_llm, None\n\n try:\n provider_info = MODEL_PROVIDERS_DICT.get(self.agent_llm)\n if not provider_info:\n msg = f\"Invalid model provider: {self.agent_llm}\"\n raise ValueError(msg)\n\n component_class = provider_info.get(\"component_class\")\n display_name = component_class.display_name\n inputs = provider_info.get(\"inputs\")\n prefix = provider_info.get(\"prefix\", \"\")\n logger.debug(f\"[CUGA] Successfully built LLM model from provider: {self.agent_llm}\")\n return self._build_llm_model(component_class, inputs, prefix), display_name\n\n except (AttributeError, ValueError, TypeError, RuntimeError) as e:\n await logger.aerror(f\"[CUGA] Error building {self.agent_llm} language model: {e!s}\")\n msg = f\"Failed to initialize language model: {e!s}\"\n raise ValueError(msg) from e\n\n def _build_llm_model(self, component, inputs, prefix=\"\"):\n \"\"\"Build LLM model with parameters.\n\n This method constructs a language model instance using the provided component\n class and input parameters.\n\n Args:\n component: The LLM component class to instantiate\n inputs: List of input field definitions\n prefix: Optional prefix for parameter names\n\n Returns:\n The configured LLM model instance\n \"\"\"\n model_kwargs = {}\n for input_ in inputs:\n if hasattr(self, f\"{prefix}{input_.name}\"):\n model_kwargs[input_.name] = getattr(self, f\"{prefix}{input_.name}\")\n return component.set(**model_kwargs).build_model()\n\n def set_component_params(self, component):\n \"\"\"Set component parameters based on provider.\n\n This method configures component parameters according to the selected\n model provider's requirements.\n\n Args:\n component: The component to configure\n\n Returns:\n The configured component\n \"\"\"\n provider_info = MODEL_PROVIDERS_DICT.get(self.agent_llm)\n if provider_info:\n inputs = provider_info.get(\"inputs\")\n prefix = provider_info.get(\"prefix\")\n model_kwargs = {}\n for input_ in inputs:\n if hasattr(self, f\"{prefix}{input_.name}\"):\n model_kwargs[input_.name] = getattr(self, f\"{prefix}{input_.name}\")\n return component.set(**model_kwargs)\n return component\n\n def delete_fields(self, build_config: dotdict, fields: dict | list[str]) -> None:\n \"\"\"Delete specified fields from build_config.\n\n This method removes unwanted fields from the build configuration.\n\n Args:\n build_config: The build configuration dictionary\n fields: Fields to remove (can be dict or list of strings)\n \"\"\"\n for field in fields:\n build_config.pop(field, None)\n\n def update_input_types(self, build_config: dotdict) -> dotdict:\n \"\"\"Update input types for all fields in build_config.\n\n This method ensures all fields in the build configuration have proper\n input types defined.\n\n Args:\n build_config: The build configuration to update\n\n Returns:\n dotdict: Updated build configuration with input types\n \"\"\"\n for key, value in build_config.items():\n if isinstance(value, dict):\n if value.get(\"input_types\") is None:\n build_config[key][\"input_types\"] = []\n elif hasattr(value, \"input_types\") and value.input_types is None:\n value.input_types = []\n return build_config\n\n async def update_build_config(\n self, build_config: dotdict, field_value: str, field_name: str | None = None\n ) -> dotdict:\n \"\"\"Update build configuration based on field changes.\n\n This method dynamically updates the component's build configuration when\n certain fields change, particularly the model provider selection.\n\n Args:\n build_config: The current build configuration\n field_value: The new value for the field\n field_name: The name of the field being changed\n\n Returns:\n dotdict: Updated build configuration\n\n Raises:\n ValueError: If required keys are missing from the configuration\n \"\"\"\n if field_name in (\"agent_llm\",):\n build_config[\"agent_llm\"][\"value\"] = field_value\n provider_info = MODEL_PROVIDERS_DICT.get(field_value)\n if provider_info:\n component_class = provider_info.get(\"component_class\")\n if component_class and hasattr(component_class, \"update_build_config\"):\n build_config = await update_component_build_config(\n component_class, build_config, field_value, \"model_name\"\n )\n\n provider_configs: dict[str, tuple[dict, list[dict]]] = {\n provider: (\n MODEL_PROVIDERS_DICT[provider][\"fields\"],\n [\n MODEL_PROVIDERS_DICT[other_provider][\"fields\"]\n for other_provider in MODEL_PROVIDERS_DICT\n if other_provider != provider\n ],\n )\n for provider in MODEL_PROVIDERS_DICT\n }\n if field_value in provider_configs:\n fields_to_add, fields_to_delete = provider_configs[field_value]\n\n # Delete fields from other providers\n for fields in fields_to_delete:\n self.delete_fields(build_config, fields)\n\n # Add provider-specific fields\n if field_value == \"OpenAI\" and not any(field in build_config for field in fields_to_add):\n build_config.update(fields_to_add)\n else:\n build_config.update(fields_to_add)\n build_config[\"agent_llm\"][\"input_types\"] = []\n elif field_value == \"Custom\":\n # Delete all provider fields\n self.delete_fields(build_config, ALL_PROVIDER_FIELDS)\n # Update with custom component\n custom_component = DropdownInput(\n name=\"agent_llm\",\n display_name=\"Language Model\",\n options=[*sorted(MODEL_PROVIDERS), \"Custom\"],\n value=\"Custom\",\n real_time_refresh=True,\n input_types=[\"LanguageModel\"],\n options_metadata=[MODELS_METADATA[key] for key in sorted(MODELS_METADATA.keys())]\n + [{\"icon\": \"brain\"}],\n )\n build_config.update({\"agent_llm\": custom_component.to_dict()})\n\n # Update input types for all fields\n build_config = self.update_input_types(build_config)\n\n # Validate required keys\n default_keys = [\n \"code\",\n \"_type\",\n \"agent_llm\",\n \"tools\",\n \"input_value\",\n \"add_current_date_tool\",\n \"instructions\",\n \"agent_description\",\n \"max_iterations\",\n \"handle_parsing_errors\",\n \"verbose\",\n ]\n missing_keys = [key for key in default_keys if key not in build_config]\n if missing_keys:\n msg = f\"Missing required keys in build_config: {missing_keys}\"\n raise ValueError(msg)\n\n if (\n isinstance(self.agent_llm, str)\n and self.agent_llm in MODEL_PROVIDERS_DICT\n and field_name in MODEL_DYNAMIC_UPDATE_FIELDS\n ):\n provider_info = MODEL_PROVIDERS_DICT.get(self.agent_llm)\n if provider_info:\n component_class = provider_info.get(\"component_class\")\n component_class = self.set_component_params(component_class)\n prefix = provider_info.get(\"prefix\")\n if component_class and hasattr(component_class, \"update_build_config\"):\n if isinstance(field_name, str) and isinstance(prefix, str):\n field_name = field_name.replace(prefix, \"\")\n build_config = await update_component_build_config(\n component_class, build_config, field_value, \"model_name\"\n )\n return dotdict({k: v.to_dict() if hasattr(v, \"to_dict\") else v for k, v in build_config.items()})\n\n async def _get_tools(self) -> list[Tool]:\n \"\"\"Build agent tools.\n\n This method constructs the list of tools available to the Cuga agent,\n including component tools and any additional configured tools.\n\n Returns:\n list[Tool]: List of available tools for the agent\n \"\"\"\n logger.debug(\"[CUGA] Building agent tools.\")\n component_toolkit = _get_component_toolkit()\n tools_names = self._build_tools_names()\n agent_description = self.get_tool_description()\n description = f\"{agent_description}{tools_names}\"\n tools = component_toolkit(component=self).get_tools(\n tool_name=\"Call_CugaAgent\", tool_description=description, callbacks=self.get_langchain_callbacks()\n )\n if hasattr(self, \"tools_metadata\"):\n tools = component_toolkit(component=self, metadata=self.tools_metadata).update_tools_metadata(tools=tools)\n logger.debug(f\"[CUGA] Tools built: {[tool.name for tool in tools]}\")\n return tools\n" }, "decomposition_strategy": { "_input_type": "DropdownInput", @@ -58988,7 +58988,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -59868,7 +59868,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -61731,7 +61731,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -63834,7 +63834,7 @@ }, { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "openai", @@ -65694,7 +65694,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "langchain_elasticsearch", @@ -67745,7 +67745,7 @@ "dependencies": [ { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "metaphor_python", @@ -68206,7 +68206,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -68894,7 +68894,7 @@ }, { "name": "cryptography", - "version": "46.0.7" + "version": "47.0.0" }, { "name": "langchain_chroma", @@ -68914,7 +68914,7 @@ }, { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "langchain_huggingface", @@ -68934,7 +68934,7 @@ }, { "name": "langchain_ibm", - "version": "1.0.6" + "version": "1.0.7" } ], "total_dependencies": 12 @@ -69145,7 +69145,7 @@ }, { "name": "cryptography", - "version": "46.0.7" + "version": "47.0.0" }, { "name": "langchain_chroma", @@ -72558,7 +72558,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -72911,7 +72911,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "langchain_google_community", @@ -73064,7 +73064,7 @@ "dependencies": [ { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "langchain_google_genai", @@ -74782,7 +74782,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -74965,7 +74965,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -75791,7 +75791,7 @@ "dependencies": [ { "name": "langchain_ibm", - "version": "1.0.6" + "version": "1.0.7" }, { "name": "pydantic", @@ -76348,7 +76348,7 @@ }, { "name": "langchain_ibm", - "version": "1.0.6" + "version": "1.0.7" }, { "name": "pydantic", @@ -80604,7 +80604,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -81271,7 +81271,7 @@ "dependencies": [ { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -81899,7 +81899,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -83778,7 +83778,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -84176,7 +84176,7 @@ "dependencies": [ { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "langchain_experimental", @@ -84744,7 +84744,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -85555,7 +85555,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -86245,7 +86245,7 @@ }, { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "pydantic", @@ -88519,7 +88519,7 @@ }, { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "lfx", @@ -90575,7 +90575,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" } ], "total_dependencies": 3 @@ -91885,7 +91885,7 @@ "dependencies": [ { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -93556,7 +93556,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -93833,7 +93833,7 @@ }, { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "pydantic", @@ -96979,7 +96979,7 @@ "dependencies": [ { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "lfx", @@ -97535,7 +97535,7 @@ "dependencies": [ { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "pydantic", @@ -97974,7 +97974,7 @@ }, { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "pydantic", @@ -98886,7 +98886,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -101882,7 +101882,7 @@ "dependencies": [ { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -104452,7 +104452,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -106400,7 +106400,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -107472,7 +107472,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -107598,7 +107598,7 @@ "dependencies": [ { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -107788,7 +107788,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -108011,7 +108011,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -108302,7 +108302,7 @@ "dependencies": [ { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "langchain_experimental", @@ -108476,7 +108476,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -108659,7 +108659,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -108898,7 +108898,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -109120,7 +109120,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -109518,7 +109518,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -109859,7 +109859,7 @@ "dependencies": [ { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -114236,7 +114236,7 @@ "dependencies": [ { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "lfx", @@ -114574,7 +114574,7 @@ "dependencies": [ { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "pydantic", @@ -115546,7 +115546,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "lfx", @@ -115995,7 +115995,7 @@ }, { "name": "langchain_openai", - "version": "1.2.0" + "version": "1.2.1" }, { "name": "pydantic", @@ -116375,7 +116375,7 @@ }, { "name": "langchain_core", - "version": "1.3.1" + "version": "1.3.2" }, { "name": "pydantic", @@ -118101,6 +118101,6 @@ "num_components": 355, "num_modules": 97 }, - "sha256": "4d09ac8a221221ef4692449f898ab2dcd364837f01530fae8cefbab6dd7c4594", - "version": "0.4.1" + "sha256": "30e1190dc06f5782586a9628ddb7a5705cd1aa49d4d47e90503a5ddaf85290af", + "version": "0.4.2" } diff --git a/src/lfx/src/lfx/base/models/model.py b/src/lfx/src/lfx/base/models/model.py index 058137c3e7..a00f28cc10 100644 --- a/src/lfx/src/lfx/base/models/model.py +++ b/src/lfx/src/lfx/base/models/model.py @@ -13,6 +13,7 @@ from lfx.base.constants import STREAM_INFO_TEXT from lfx.custom.custom_component.component import Component from lfx.field_typing import LanguageModel from lfx.inputs.inputs import BoolInput, InputTypes, MessageInput, MultilineInput +from lfx.log.logger import logger from lfx.schema.message import Message from lfx.schema.properties import Usage from lfx.schema.token_usage import extract_usage_from_message @@ -340,16 +341,40 @@ class LCModelComponent(Component): session_id = self._session_id else: session_id = None - model_message = Message( - text=runnable.astream(inputs), - sender=MESSAGE_SENDER_AI, - sender_name="AI", - properties={"icon": self.icon, "state": "partial"}, - session_id=session_id, - ) - model_message.properties.source = self._build_source(self._id, self.display_name, self) - lf_message = await self.send_message(model_message) - result = lf_message.text or "" + # Streaming requires both a session_id and an event_manager: + # - session_id is required so astore_message validation passes when send_message + # persists the placeholder Message. + # - event_manager is required so the chunk iterator that backs Message.text gets + # drained; without one, no consumer iterates astream(), the iterator is stored + # verbatim, and downstream readers see empty text. + # If either is missing, fall back to a non-streaming ainvoke. + event_manager = getattr(self, "_event_manager", None) + if session_id and event_manager: + model_message = Message( + text=runnable.astream(inputs), + sender=MESSAGE_SENDER_AI, + sender_name="AI", + properties={"icon": self.icon, "state": "partial"}, + session_id=session_id, + ) + model_message.properties.source = self._build_source(self._id, self.display_name, self) + lf_message = await self.send_message(model_message) + result = lf_message.text or "" + else: + missing = [] + if not session_id: + missing.append("session_id") + if not event_manager: + missing.append("event_manager") + component_label = getattr(self, "display_name", None) or getattr(self, "_id", "") + logger.warning( + f"Streaming fallback to ainvoke for component '{component_label}' " + f"(id={getattr(self, '_id', '')}): missing {', '.join(missing)}. " + "UI will not see token-by-token streaming for this run." + ) + ai_message = await runnable.ainvoke(inputs) + result = ai_message.content if hasattr(ai_message, "content") else ai_message + result = _normalize_message_content(result) else: ai_message = await runnable.ainvoke(inputs) result = ai_message.content if hasattr(ai_message, "content") else ai_message diff --git a/src/lfx/src/lfx/cli/_running_commands.py b/src/lfx/src/lfx/cli/_running_commands.py index a332f37dda..e13fe6cad0 100644 --- a/src/lfx/src/lfx/cli/_running_commands.py +++ b/src/lfx/src/lfx/cli/_running_commands.py @@ -60,6 +60,13 @@ def register(app: typer.Typer) -> None: show_default=True, help="Include detailed timing information in output", ), + session_id: str | None = typer.Option( + None, + "--session-id", + help=( + "Session ID to attach to the run. Agent and Memory Components will use this to track conversation history." + ), + ), ) -> None: """Run a flow directly (lazy-loaded).""" from pathlib import Path @@ -81,6 +88,7 @@ def register(app: typer.Typer) -> None: verbose_detailed=verbose_detailed, verbose_full=verbose_full, timing=timing, + session_id=session_id, ) @app.command(name="serve", help="Serve a flow as an API", no_args_is_help=True, rich_help_panel="Running") diff --git a/src/lfx/src/lfx/cli/common.py b/src/lfx/src/lfx/cli/common.py index ef1c3777b2..25ee5ab84c 100644 --- a/src/lfx/src/lfx/cli/common.py +++ b/src/lfx/src/lfx/cli/common.py @@ -29,6 +29,7 @@ from lfx.cli.script_loader import ( load_graph_from_script, ) from lfx.load import load_flow_from_json +from lfx.run._defaults import apply_run_defaults, resolve_fallback_to_env_vars from lfx.schema.schema import InputValueRequest if TYPE_CHECKING: @@ -299,12 +300,16 @@ def prepare_graph(graph, verbose_print): raise typer.Exit(1) from e -async def execute_graph_with_capture(graph, input_value: str | None): +async def execute_graph_with_capture(graph, input_value: str | None, session_id: str | None = None): """Execute a graph and capture output. Args: graph: Graph object to execute input_value: Input value to pass to the graph + session_id: Optional session ID. ``None`` auto-generates one so that + message-store paths (which validate session_id) succeed; an empty or + whitespace-only string is rejected with ``ValueError`` to surface + shell/env-var typos (see ``lfx.run._defaults.validate_provided_id``). Returns: Tuple of (results, captured_logs) @@ -312,6 +317,11 @@ async def execute_graph_with_capture(graph, input_value: str | None): Raises: Exception: Re-raises any exception that occurs during graph execution """ + # Apply session_id, user_id, and Memory-vertex propagation defaults via the + # shared helper (same logic as run_flow). user_id is not exposed in this + # entry point, so any pre-existing graph.user_id is preserved. + apply_run_defaults(graph, session_id=session_id, user_id=None, overwrite_user_id=False) + # Create input request inputs = InputValueRequest(input_value=input_value) if input_value else None @@ -323,10 +333,12 @@ async def execute_graph_with_capture(graph, input_value: str | None): original_stdout = sys.stdout original_stderr = sys.stderr + fallback_to_env_vars = resolve_fallback_to_env_vars() + try: sys.stdout = captured_stdout sys.stderr = captured_stderr - results = [result async for result in graph.async_start(inputs)] + results = [result async for result in graph.async_start(inputs, fallback_to_env_vars=fallback_to_env_vars)] except Exception as exc: # Capture any error output that was written to stderr error_output = captured_stderr.getvalue() diff --git a/src/lfx/src/lfx/cli/run.py b/src/lfx/src/lfx/cli/run.py index 20ffcc8a63..0098b54c3f 100644 --- a/src/lfx/src/lfx/cli/run.py +++ b/src/lfx/src/lfx/cli/run.py @@ -102,6 +102,13 @@ async def run( show_default=True, help="Include detailed timing information in output", ), + session_id: str | None = typer.Option( + None, + "--session-id", + help=( + "Session ID to attach to the run. Agent and Memory Components will use this to track conversation history." + ), + ), ) -> None: """Execute a Langflow graph script or JSON flow and return the result. @@ -121,6 +128,7 @@ async def run( stdin: Read JSON flow content from stdin check_variables: Check global variables for environment compatibility timing: Include detailed timing information in output + session_id: Optional session ID; auto-generated if not supplied """ # Determine verbosity for output formatting verbosity = 3 if verbose_full else (2 if verbose_detailed else (1 if verbose else 0)) @@ -139,6 +147,7 @@ async def run( verbose_full=verbose_full, timing=timing, global_variables=None, + session_id=session_id, ) # Output based on format diff --git a/src/lfx/src/lfx/cli/serve_app.py b/src/lfx/src/lfx/cli/serve_app.py index 9c57589436..6e2851e89f 100644 --- a/src/lfx/src/lfx/cli/serve_app.py +++ b/src/lfx/src/lfx/cli/serve_app.py @@ -226,6 +226,7 @@ class RunRequest(BaseModel): """Request model for executing a LFX flow.""" input_value: str = Field(..., description="Input value passed to the flow") + session_id: str | None = Field(default=None, description="Session ID for maintaining conversation state") class StreamRequest(BaseModel): @@ -329,7 +330,9 @@ async def run_flow_generator_for_serve( # For the serve app, we'll use execute_graph_with_capture with streaming # Note: This is a simplified version. In a full implementation, you might want # to integrate with the full LFX streaming pipeline from endpoints.py - results, logs = await execute_graph_with_capture(graph, input_request.input_value) + results, logs = await execute_graph_with_capture( + graph, input_request.input_value, session_id=input_request.session_id + ) result_data = extract_result_data(results, logs) # Send the final result @@ -422,7 +425,9 @@ def create_multi_serve_app( try: validate_flow_for_current_settings(graph) graph_copy = deepcopy(graph) - results, logs = await execute_graph_with_capture(graph_copy, request.input_value) + results, logs = await execute_graph_with_capture( + graph_copy, request.input_value, session_id=request.session_id + ) result_data = extract_result_data(results, logs) # Debug logging diff --git a/src/lfx/src/lfx/components/cuga/cuga_agent.py b/src/lfx/src/lfx/components/cuga/cuga_agent.py index 2dbe486107..41a11bf53b 100644 --- a/src/lfx/src/lfx/components/cuga/cuga_agent.py +++ b/src/lfx/src/lfx/components/cuga/cuga_agent.py @@ -368,7 +368,7 @@ class CugaComponent(ToolCallingAgentComponent): sender_name="Cuga", properties={"icon": "Bot", "state": "partial"}, content_blocks=[ContentBlock(title="Agent Steps", contents=[])], - session_id=self.graph.session_id, + session_id=self.graph.session_id or str(uuid.uuid4()), ) # Pre-assign an ID for event processing, following the base agent pattern diff --git a/src/lfx/src/lfx/graph/graph/base.py b/src/lfx/src/lfx/graph/graph/base.py index bf4c15a54a..cf8983a5d1 100644 --- a/src/lfx/src/lfx/graph/graph/base.py +++ b/src/lfx/src/lfx/graph/graph/base.py @@ -361,6 +361,7 @@ class Graph: event_manager: EventManager | None = None, *, reset_output_values: bool = True, + fallback_to_env_vars: bool = False, ): # Preserve start_component_id from constructor if available start_component_id = self._start.get_id() if self._start else None @@ -379,7 +380,9 @@ class Graph: yielded_counts: dict[str, int] = defaultdict(int) while should_continue(yielded_counts, max_iterations): - result = await self.astep(event_manager=event_manager, inputs=inputs) + result = await self.astep( + event_manager=event_manager, inputs=inputs, fallback_to_env_vars=fallback_to_env_vars + ) yield result if isinstance(result, Finish): return @@ -1441,6 +1444,8 @@ class Graph: files: list[str] | None = None, user_id: str | None = None, event_manager: EventManager | None = None, + *, + fallback_to_env_vars: bool = False, ): if not self._prepared: msg = "Graph not prepared. Call prepare() first." @@ -1479,6 +1484,7 @@ class Graph: get_cache=get_cache_func, set_cache=set_cache_func, event_manager=event_manager, + fallback_to_env_vars=fallback_to_env_vars, ) next_runnable_vertices = await self.get_next_runnable_vertices( diff --git a/src/lfx/src/lfx/memory/stubs.py b/src/lfx/src/lfx/memory/stubs.py index 6f4b03d994..31b2c08cc5 100644 --- a/src/lfx/src/lfx/memory/stubs.py +++ b/src/lfx/src/lfx/memory/stubs.py @@ -41,10 +41,20 @@ async def astore_message( ) raise ValueError(msg) - # Set flow_id if provided + # Set flow_id if provided. The stub is the fallback when no real database is + # registered, so be tolerant of non-UUID flow_ids (e.g. synthetic IDs from tests + # or callers that pass a string identifier). UUID parsing here only normalizes + # format; an invalid string is preserved verbatim, but logged so downstream + # UUID-expecting code paths have a breadcrumb if they later fail. if flow_id: if isinstance(flow_id, str): - flow_id = UUID(flow_id) + try: + flow_id = UUID(flow_id) + except ValueError: + logger.warning( + f"flow_id {flow_id!r} is not a valid UUID; preserving verbatim. " + "Downstream code that expects a UUID may surface a confusing error." + ) message.flow_id = str(flow_id) # In lfx, we use the service architecture - this is a simplified implementation diff --git a/src/lfx/src/lfx/run/_defaults.py b/src/lfx/src/lfx/run/_defaults.py new file mode 100644 index 0000000000..06bf730e46 --- /dev/null +++ b/src/lfx/src/lfx/run/_defaults.py @@ -0,0 +1,132 @@ +"""Shared helpers for applying run-time defaults to a Graph. + +The CLI's ``lfx run`` (``run_flow``) and ``lfx serve``/``execute_graph_with_capture`` +both need to: + +1. Reject empty/whitespace-only ``session_id`` / ``user_id`` (so a typo or + empty env var doesn't silently produce a random session and break Memory + continuity). +2. Auto-generate a UUID when neither is supplied (so component prechecks and + ``astore_message`` validation don't fail). +3. Propagate ``session_id`` to ``Memory``/``MessageHistory`` vertices the way + ``langflow.api.utils.flow_utils.build_graph_from_data`` does — preserving + any value already pinned in the flow JSON. +4. Resolve ``fallback_to_env_vars`` from settings (mirrors the langflow API + path in ``processing.process.run_graph_internal``). + +Both call sites used to inline these steps; consolidating them here prevents +behavior drift between ``lfx run`` and ``lfx serve``. +""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from lfx.log.logger import logger +from lfx.services.deps import get_settings_service + +if TYPE_CHECKING: + from lfx.graph.graph.base import Graph + + +def validate_provided_id(name: str, value: str | None) -> None: + """Reject empty/whitespace strings while allowing None (which means "auto-generate"). + + Empty strings reach this code via shell quirks (``--session-id ""``), env-var + expansion that resolved to empty, or callers that defaulted a missing arg to + ``""`` instead of ``None``. Treating them the same as ``None`` would silently + fall through to UUID generation — and the user would only learn their session + didn't carry over by noticing Memory looks empty on the next run. + """ + if value is None: + return + if not isinstance(value, str) or not value.strip(): + msg = ( + f"{name} was provided but is empty or whitespace. " + f"Pass a non-empty value, or omit {name} to auto-generate one." + ) + raise ValueError(msg) + + +def apply_run_defaults( + graph: Graph, + *, + session_id: str | None, + user_id: str | None, + overwrite_user_id: bool = True, +) -> tuple[str, str]: + """Apply session_id, user_id, and vertex-propagation defaults to *graph*. + + Validates non-None values are non-empty (raises ``ValueError`` otherwise). + Auto-generates UUID hex strings for any value left as ``None``. Sets the + resolved values on the graph and propagates ``session_id`` to vertices in + ``graph.has_session_id_vertices`` that don't already have a hardcoded value. + + Args: + graph: The graph to mutate. + session_id: Caller-supplied session_id. ``None`` -> auto-generate. + user_id: Caller-supplied user_id. ``None`` -> auto-generate. + overwrite_user_id: When False, an existing ``graph.user_id`` is preserved + (matches the prior ``execute_graph_with_capture`` behavior). When True, + the resolved user_id always wins (matches the prior ``run_flow`` + behavior, which intentionally re-stamps the graph). + + Returns: + ``(session_id, user_id)`` — the values actually applied to the graph. + """ + validate_provided_id("session_id", session_id) + validate_provided_id("user_id", user_id) + + if not user_id: + user_id = uuid.uuid4().hex + logger.debug( + f"No user_id provided; auto-generated {user_id} to satisfy component prechecks. " + "lfx's variable service is env-backed, so user_id is not used for variable scoping." + ) + if overwrite_user_id or not getattr(graph, "user_id", None): + graph.user_id = user_id + else: + # Caller-supplied None plus an existing graph.user_id: preserve the existing. + user_id = graph.user_id + + if not session_id: + session_id = uuid.uuid4().hex + # info, not warning: this is the normal case for one-shot CLI runs and + # would otherwise spam stderr on every invocation. Visible at -v. + logger.info( + f"No session_id provided; auto-generated {session_id}. " + "Memory/MessageHistory components will not see prior conversation state across runs." + ) + graph.session_id = session_id + + # Mirror langflow's build_graph_from_data: only write when raw_params has no + # session_id (hardcoded values pinned in the flow JSON win). graph.async_start + # bypasses the equivalent loop in Graph._run, so without this Memory components + # would read "" from their input field even when --session-id is set. + for vertex_id in graph.has_session_id_vertices: + vertex = graph.get_vertex(vertex_id) + if vertex is None: + continue + if not vertex.raw_params.get("session_id"): + vertex.update_raw_params({"session_id": session_id}, overwrite=True) + + return session_id, user_id + + +def resolve_fallback_to_env_vars() -> bool: + """Read ``fallback_to_env_var`` from settings, defaulting to True if the service is missing. + + Mirrors the langflow API path (``processing.process.run_graph_internal``): + when a ``load_from_db`` variable misses (e.g. the ceremonial UUID has no + Variable row), fall through to ``os.environ`` instead of failing the build. + A missing settings_service is unusual — log a warning so the user can debug. + """ + settings_service = get_settings_service() + if settings_service is None: + logger.warning( + "settings_service is unavailable; defaulting fallback_to_env_vars=True. " + "If load_from_db variables are misbehaving, verify the settings service initialized." + ) + return True + return bool(settings_service.settings.fallback_to_env_var) diff --git a/src/lfx/src/lfx/run/base.py b/src/lfx/src/lfx/run/base.py index feb9f15c3e..13c5cb3b68 100644 --- a/src/lfx/src/lfx/run/base.py +++ b/src/lfx/src/lfx/run/base.py @@ -16,6 +16,7 @@ from lfx.cli.script_loader import ( ) from lfx.cli.validation import validate_global_variables_for_env from lfx.log.logger import logger +from lfx.run._defaults import apply_run_defaults, resolve_fallback_to_env_vars, validate_provided_id from lfx.schema.schema import InputValueRequest if TYPE_CHECKING: @@ -116,6 +117,18 @@ async def run_flow( configure(log_level="CRITICAL", output_file=sys.stderr) verbosity = 0 + # Validate caller-supplied IDs up-front so users get a clear error before any + # graph loading work happens. None means "auto-generate later"; "" / whitespace + # is rejected so a typo or empty env var doesn't silently produce a fresh + # session and break Memory continuity. + try: + validate_provided_id("session_id", session_id) + validate_provided_id("user_id", user_id) + except ValueError as e: + error_msg = str(e) + output_error(error_msg, verbose=verbose, exception=e) + raise RunError(error_msg, e) from e + start_time = time.time() if timing else None # Use either positional input_value or --input-value option @@ -221,17 +234,14 @@ async def run_flow( error_msg = "No input source provided" raise ValueError(error_msg) - # Set user_id on graph if provided (required for some components like AgentComponent) - if user_id: - graph.user_id = user_id - if verbosity > 0: - logger.info(f"Set graph user_id: {user_id}") - - # Set session_id on graph if provided (isolates memory between requests) - if session_id: - graph.session_id = session_id - if verbosity > 0: - logger.info(f"Set graph session_id: {session_id}") + # Apply session_id, user_id, and Memory-vertex propagation defaults. Both + # are auto-generated when not supplied so component prechecks (custom_component + # .get_variable for user_id) and astore_message validation (for session_id) + # don't fail. See lfx.run._defaults.apply_run_defaults for the full rationale. + session_id, user_id = apply_run_defaults(graph, session_id=session_id, user_id=user_id) + if verbosity > 0: + logger.info(f"Set graph user_id: {user_id}") + logger.info(f"Set graph session_id: {session_id}") # Inject global variables into graph context if global_variables: @@ -348,7 +358,14 @@ async def run_flow( logger.info("Starting graph execution...", level="DEBUG") - async for result in graph.async_start(inputs, event_manager=event_manager): + # See lfx.run._defaults.resolve_fallback_to_env_vars for why this flag is + # plumbed through (mirrors langflow's API path so load_from_db variables + # fall through to os.environ on miss instead of erroring the build). + fallback_to_env_vars = resolve_fallback_to_env_vars() + + async for result in graph.async_start( + inputs, event_manager=event_manager, fallback_to_env_vars=fallback_to_env_vars + ): result_count += 1 if verbosity > 0: logger.debug(f"Processing result #{result_count}") diff --git a/src/lfx/src/lfx/services/variable/service.py b/src/lfx/src/lfx/services/variable/service.py index d0e1aef8d1..5b3761b6e3 100644 --- a/src/lfx/src/lfx/services/variable/service.py +++ b/src/lfx/src/lfx/services/variable/service.py @@ -22,14 +22,23 @@ class VariableService(Service): self.set_ready() logger.debug("Variable service initialized (env vars only)") - def get_variable(self, name: str, **kwargs) -> str | None: # noqa: ARG002 + async def get_variable(self, name: str, **kwargs) -> str | None: # noqa: ARG002 """Get a variable value. First checks in-memory cache, then environment variables. + Async to match the call signature in custom_component.get_variable + (`await variable_service.get_variable(...)`), which is the path used + by component variable resolution. The lookup itself is sync — no I/O — + but the coroutine wrapper is required so callers can `await` it + regardless of which variable service implementation is registered + (lfx env-fallback vs langflow DB-backed). + Args: name: Variable name - **kwargs: Additional arguments (ignored in minimal implementation) + **kwargs: Additional arguments (ignored; user_id/field/session + from langflow's call signature are absorbed and not used, + since this implementation has no per-user scope). Returns: Variable value or None if not found diff --git a/src/lfx/tests/unit/base/models/test_handle_stream.py b/src/lfx/tests/unit/base/models/test_handle_stream.py new file mode 100644 index 0000000000..153799d719 --- /dev/null +++ b/src/lfx/tests/unit/base/models/test_handle_stream.py @@ -0,0 +1,120 @@ +"""Tests for LCModelComponent._handle_stream session_id handling. + +When a streaming LLM is wired to ChatOutput, the streaming path tries to persist +a placeholder Message via send_message. With no session_id (e.g. ``lfx run`` with +NoopSession and no ``--session-id``), astore_message rejects the empty value. +The fallback in _handle_stream invokes the model non-streamingly so the run can +still complete. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from lfx.base.models.model import LCModelComponent + + +class _StreamableProbe(LCModelComponent): + """Minimal LCModelComponent subclass usable without going through Component init.""" + + display_name = "Probe" + description = "test" + + def build_model(self): # pragma: no cover - abstract stub + raise NotImplementedError + + +def _make_probe(*, connected: bool, session_id, event_manager=None): + probe = _StreamableProbe.__new__(_StreamableProbe) + probe.is_connected_to_chat_output = MagicMock(return_value=connected) + # ``graph`` is a read-only property on Component (-> self._vertex.graph), + # so wire the underlying _vertex instead of trying to assign graph directly. + probe._vertex = SimpleNamespace(graph=SimpleNamespace(session_id=session_id, flow_id=None)) + probe.icon = "brain" + probe._id = "probe-1" + probe._event_manager = event_manager + probe.send_message = AsyncMock() + probe._build_source = MagicMock(return_value=None) + return probe + + +@pytest.mark.asyncio +async def test_handle_stream_falls_back_to_invoke_when_no_session_id(): + """Empty graph.session_id must not call send_message; falls back to ainvoke.""" + probe = _make_probe(connected=True, session_id="", event_manager=MagicMock()) + runnable = SimpleNamespace( + astream=MagicMock(), + ainvoke=AsyncMock(return_value=SimpleNamespace(content="hi from invoke")), + ) + + lf_message, result, ai_message = await probe._handle_stream(runnable, "input") + + runnable.ainvoke.assert_awaited_once_with("input") + runnable.astream.assert_not_called() + probe.send_message.assert_not_awaited() + assert lf_message is None + assert result == "hi from invoke" + assert isinstance(result, str) + assert ai_message is not None + + +@pytest.mark.asyncio +async def test_handle_stream_falls_back_to_invoke_when_no_event_manager(): + """Without an event_manager (e.g. lfx run) the chunk iterator would never be drained. + + The fallback prevents send_message from storing a Message whose text is an unconsumed + AsyncIterator, which previously surfaced as an empty result downstream. + """ + probe = _make_probe(connected=True, session_id="sess-123", event_manager=None) + runnable = SimpleNamespace( + astream=MagicMock(), + ainvoke=AsyncMock(return_value=SimpleNamespace(content="batched")), + ) + + lf_message, result, ai_message = await probe._handle_stream(runnable, "input") + + runnable.ainvoke.assert_awaited_once_with("input") + runnable.astream.assert_not_called() + probe.send_message.assert_not_awaited() + assert lf_message is None + assert result == "batched" + # Lock in that the fallback returns plain text, not the unconsumed AsyncIterator + # that astream would have produced — the original bug surfaced as empty downstream + # text because the iterator was stored verbatim. + assert isinstance(result, str) + assert ai_message is not None + + +@pytest.mark.asyncio +async def test_handle_stream_uses_send_message_when_session_id_and_event_manager_present(): + """Both session_id and event_manager present -> original streaming + persistence path.""" + probe = _make_probe(connected=True, session_id="sess-123", event_manager=MagicMock()) + probe.send_message.return_value = SimpleNamespace(text="streamed text") + runnable = SimpleNamespace(astream=MagicMock(), ainvoke=AsyncMock()) + + lf_message, result, ai_message = await probe._handle_stream(runnable, "input") + + probe.send_message.assert_awaited_once() + runnable.ainvoke.assert_not_awaited() + assert lf_message is not None + assert result == "streamed text" + assert ai_message is None + + +@pytest.mark.asyncio +async def test_handle_stream_invokes_directly_when_not_connected_to_chat_output(): + """Pre-existing branch: not connected -> ainvoke regardless of session_id.""" + probe = _make_probe(connected=False, session_id="sess-123", event_manager=MagicMock()) + runnable = SimpleNamespace( + astream=MagicMock(), + ainvoke=AsyncMock(return_value=SimpleNamespace(content="direct")), + ) + + lf_message, result, _ = await probe._handle_stream(runnable, "input") + + runnable.ainvoke.assert_awaited_once_with("input") + probe.send_message.assert_not_awaited() + assert lf_message is None + assert result == "direct" diff --git a/src/lfx/tests/unit/cli/test_common.py b/src/lfx/tests/unit/cli/test_common.py index 43a0b9c484..abf516b14b 100644 --- a/src/lfx/tests/unit/cli/test_common.py +++ b/src/lfx/tests/unit/cli/test_common.py @@ -208,7 +208,7 @@ class TestGraphExecution: # Mock graph and async iterator mock_result = MagicMock(results={"text": "Test result"}) - async def mock_async_start(inputs): # noqa: ARG001 + async def mock_async_start(inputs, **kwargs): # noqa: ARG001 yield mock_result mock_graph = MagicMock() @@ -229,7 +229,7 @@ class TestGraphExecution: # Ensure results attribute doesn't exist delattr(mock_result, "results") - async def mock_async_start(inputs): # noqa: ARG001 + async def mock_async_start(inputs, **kwargs): # noqa: ARG001 yield mock_result mock_graph = MagicMock() @@ -244,7 +244,7 @@ class TestGraphExecution: async def test_execute_graph_with_capture_error(self): """Test graph execution with error.""" - async def mock_async_start_error(inputs): # noqa: ARG001 + async def mock_async_start_error(inputs, **kwargs): # noqa: ARG001 msg = "Execution failed" raise RuntimeError(msg) yield # This line never executes but makes it an async generator @@ -255,6 +255,160 @@ class TestGraphExecution: with pytest.raises(RuntimeError, match="Execution failed"): await execute_graph_with_capture(mock_graph, "test input") + @pytest.mark.asyncio + async def test_execute_graph_with_capture_autogenerates_session_id(self): + """Auto-generate a session_id when none is provided. + + Message-store validators reject empty session_id, so the helper assigns one + to keep streaming/persistence paths functional in lfx serve. + """ + + async def mock_async_start(inputs, **kwargs): # noqa: ARG001 + yield MagicMock(results={"text": "ok"}) + + mock_graph = MagicMock() + mock_graph.async_start = mock_async_start + + await execute_graph_with_capture(mock_graph, "test input") + + assert mock_graph.session_id, "session_id should be auto-generated" + assert isinstance(mock_graph.session_id, str) + + @pytest.mark.asyncio + async def test_execute_graph_with_capture_preserves_caller_session_id(self): + """An explicit session_id wins over auto-generation.""" + + async def mock_async_start(inputs, **kwargs): # noqa: ARG001 + yield MagicMock(results={"text": "ok"}) + + mock_graph = MagicMock() + mock_graph.async_start = mock_async_start + + await execute_graph_with_capture(mock_graph, "test input", session_id="fixed-session") + + assert mock_graph.session_id == "fixed-session" + + @pytest.mark.asyncio + async def test_execute_graph_propagates_session_id_to_vertices(self): + """Session_id must reach Memory/MessageHistory inputs on the lfx serve path. + + ``execute_graph_with_capture`` uses ``graph.async_start``, which bypasses + the propagation loop in ``Graph._run``. The helper has to replicate it + so served ``/run`` and ``/stream`` requests behave like the playground. + """ + + async def mock_async_start(inputs, **kwargs): # noqa: ARG001 + yield MagicMock(results={"text": "ok"}) + + mock_graph = MagicMock() + mock_graph.async_start = mock_async_start + memory_vertex = MagicMock() + memory_vertex.raw_params = {} + memory_vertex.update_raw_params = MagicMock() + mock_graph.has_session_id_vertices = ["memory-1"] + mock_graph.get_vertex = MagicMock(return_value=memory_vertex) + + await execute_graph_with_capture(mock_graph, "test input", session_id="my-conversation") + + memory_vertex.update_raw_params.assert_called_once_with({"session_id": "my-conversation"}, overwrite=True) + + @pytest.mark.asyncio + async def test_execute_graph_does_not_overwrite_hardcoded_session_id(self): + """Hardcoded session_id on a Memory component (set in flow JSON) wins over the request value. + + Mirrors Langflow's playground precedence in ``build_graph_from_data``. + """ + + async def mock_async_start(inputs, **kwargs): # noqa: ARG001 + yield MagicMock(results={"text": "ok"}) + + mock_graph = MagicMock() + mock_graph.async_start = mock_async_start + pinned_vertex = MagicMock() + pinned_vertex.raw_params = {"session_id": "hardcoded-in-flow"} + pinned_vertex.update_raw_params = MagicMock() + mock_graph.has_session_id_vertices = ["memory-pinned"] + mock_graph.get_vertex = MagicMock(return_value=pinned_vertex) + + await execute_graph_with_capture(mock_graph, "test input", session_id="from-request") + + pinned_vertex.update_raw_params.assert_not_called() + + @pytest.mark.asyncio + async def test_execute_graph_autogenerates_user_id_when_unset(self): + """When the graph arrives without a user_id (typical for lfx serve), assign a UUID. + + AgentComponent's variable lookup precheck requires a non-empty user_id; the + env-fallback variable service does not use it for scoping, so a random UUID is + ceremonial but necessary. + """ + + async def mock_async_start(inputs, **kwargs): # noqa: ARG001 + yield MagicMock(results={"text": "ok"}) + + mock_graph = MagicMock() + mock_graph.async_start = mock_async_start + mock_graph.user_id = None + + await execute_graph_with_capture(mock_graph, "test input") + + assert mock_graph.user_id, "user_id should be auto-assigned when graph has none" + assert isinstance(mock_graph.user_id, str) + + @pytest.mark.asyncio + async def test_execute_graph_preserves_existing_user_id(self): + """A user_id already set on the graph (e.g., by an upstream caller) is left alone.""" + + async def mock_async_start(inputs, **kwargs): # noqa: ARG001 + yield MagicMock(results={"text": "ok"}) + + mock_graph = MagicMock() + mock_graph.async_start = mock_async_start + mock_graph.user_id = "preset-user-uuid" + + await execute_graph_with_capture(mock_graph, "test input") + + assert mock_graph.user_id == "preset-user-uuid" + + @pytest.mark.asyncio + async def test_execute_graph_passes_fallback_from_settings_default(self): + """Default settings (fallback_to_env_var=True) reach async_start. + + Lets components fall through to os.environ when a load_from_db variable + has no DB row — matching langflow's API path behavior. + """ + captured: dict = {} + + async def mock_async_start(inputs, **kwargs): # noqa: ARG001 + captured.update(kwargs) + yield MagicMock(results={"text": "ok"}) + + mock_graph = MagicMock() + mock_graph.async_start = mock_async_start + + await execute_graph_with_capture(mock_graph, "test input") + + assert captured.get("fallback_to_env_vars") is True + + @pytest.mark.asyncio + async def test_execute_graph_respects_disabled_fallback_setting(self): + """When the user opts out of env fallback in settings, the flag is False.""" + captured: dict = {} + + async def mock_async_start(inputs, **kwargs): # noqa: ARG001 + captured.update(kwargs) + yield MagicMock(results={"text": "ok"}) + + mock_graph = MagicMock() + mock_graph.async_start = mock_async_start + mock_settings = MagicMock() + mock_settings.settings.fallback_to_env_var = False + + with patch("lfx.run._defaults.get_settings_service", return_value=mock_settings): + await execute_graph_with_capture(mock_graph, "test input") + + assert captured.get("fallback_to_env_vars") is False + class TestResultExtraction: """Test result data extraction.""" diff --git a/src/lfx/tests/unit/cli/test_run_command.py b/src/lfx/tests/unit/cli/test_run_command.py index 90b4a45a00..8f82719ed5 100644 --- a/src/lfx/tests/unit/cli/test_run_command.py +++ b/src/lfx/tests/unit/cli/test_run_command.py @@ -4,7 +4,7 @@ import contextlib import json import tempfile from pathlib import Path -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest import typer @@ -155,6 +155,10 @@ chat_input = ChatInput( def test_execute_python_script_success(self, simple_chat_script, capsys): """Test executing a valid Python script.""" # Test that Python script execution either succeeds or fails gracefully + # ``run`` is a typer command, so any parameter with a ``typer.Option(...)`` default + # (e.g. session_id) needs to be passed explicitly when invoking outside typer's + # parser — otherwise the default evaluates to a ``typer.models.OptionInfo`` + # sentinel and propagates downstream. with contextlib.suppress(typer.Exit): run( script_path=simple_chat_script, @@ -164,6 +168,7 @@ chat_input = ChatInput( output_format="json", flow_json=None, stdin=False, + session_id=None, ) # Test passes as long as no unhandled exceptions occur @@ -181,6 +186,7 @@ chat_input = ChatInput( def test_execute_python_script_verbose(self, simple_chat_script, capsys): """Test executing a Python script with verbose output.""" # Test that verbose mode execution either succeeds or fails gracefully + # See note in ``test_execute_python_script_success`` re: session_id=None. with contextlib.suppress(typer.Exit): run( script_path=simple_chat_script, @@ -190,6 +196,7 @@ chat_input = ChatInput( output_format="json", flow_json=None, stdin=False, + session_id=None, ) # Test passes as long as no unhandled exceptions occur @@ -320,7 +327,8 @@ chat_input = ChatInput( flow_json_str = json.dumps(simple_json_flow) mock_stdin.read.return_value = flow_json_str - # Test that stdin execution either succeeds or fails gracefully + # Test that stdin execution either succeeds or fails gracefully. + # See note in ``test_execute_python_script_success`` re: session_id=None. with pytest.raises(typer.Exit) as exc_info: run( script_path=None, @@ -330,6 +338,7 @@ chat_input = ChatInput( output_format="json", flow_json=None, stdin=True, + session_id=None, ) # Check that stdin was read and function exited cleanly @@ -436,6 +445,7 @@ chat_input = ChatInput( def test_execute_verbose_error_output(self, invalid_script, capsys): """Test that verbose mode shows error details.""" + # See note in ``test_execute_python_script_success`` re: session_id=None. with pytest.raises(typer.Exit) as exc_info: run( script_path=invalid_script, @@ -445,6 +455,7 @@ chat_input = ChatInput( output_format="json", flow_json=None, stdin=False, + session_id=None, ) assert exc_info.value.exit_code == 1 @@ -453,6 +464,55 @@ chat_input = ChatInput( error_output = captured.out + captured.err assert "graph" in error_output.lower() or "variable" in error_output.lower() + def test_session_id_cli_flag_plumbs_through_to_run_flow(self, simple_chat_script): + """--session-id is wired from typer through `run` into `run_flow(session_id=...)`. + + Locks in the typer Option name so a rename or wiring typo at lfx.cli.run.run + is caught here (the unit tests on run_flow itself only cover the function + contract, not this CLI layer). + """ + captured = {} + + async def fake_run_flow(**kwargs): + captured.update(kwargs) + return {"success": True, "result": "ok", "logs": ""} + + with patch("lfx.cli.run.run_flow", new=AsyncMock(side_effect=fake_run_flow)): + run( + script_path=simple_chat_script, + input_value="hi", + input_value_option=None, + verbose=False, + output_format="json", + flow_json=None, + stdin=False, + session_id="my-fixed-session", + ) + + assert captured.get("session_id") == "my-fixed-session" + + def test_session_id_cli_flag_omitted_passes_none(self, simple_chat_script): + """No --session-id => `run_flow` receives session_id=None (auto-gen happens downstream).""" + captured = {} + + async def fake_run_flow(**kwargs): + captured.update(kwargs) + return {"success": True, "result": "ok", "logs": ""} + + with patch("lfx.cli.run.run_flow", new=AsyncMock(side_effect=fake_run_flow)): + run( + script_path=simple_chat_script, + input_value="hi", + input_value_option=None, + verbose=False, + output_format="json", + flow_json=None, + stdin=False, + session_id=None, + ) + + assert captured.get("session_id") is None + def test_execute_without_input_value(self, simple_chat_script, capsys): """Test executing without providing input value.""" # Test that execution without input either succeeds or fails gracefully diff --git a/src/lfx/tests/unit/cli/test_serve_app.py b/src/lfx/tests/unit/cli/test_serve_app.py index 3c79f2afa8..6f5c354a47 100644 --- a/src/lfx/tests/unit/cli/test_serve_app.py +++ b/src/lfx/tests/unit/cli/test_serve_app.py @@ -118,13 +118,13 @@ class TestCreateServeApp: def real_graph(self, simple_chat_json): """Create a real graph using Graph.from_payload to match serve_app expectations.""" # Create graph using from_payload with real test data - return Graph.from_payload(simple_chat_json, flow_id="test-flow-id") + return Graph.from_payload(simple_chat_json, flow_id="00000000-0000-0000-0000-000000000001") @pytest.fixture def mock_meta(self): """Create mock flow metadata.""" return FlowMeta( - id="test-flow-id", + id="00000000-0000-0000-0000-000000000001", relative_path="test.json", title="Test Flow", description="A test flow", @@ -132,8 +132,8 @@ class TestCreateServeApp: def test_create_multi_serve_app_single_flow(self, real_graph, mock_meta): """Test creating app with single flow.""" - graphs = {"test-flow-id": real_graph} - metas = {"test-flow-id": mock_meta} + graphs = {"00000000-0000-0000-0000-000000000001": real_graph} + metas = {"00000000-0000-0000-0000-000000000001": mock_meta} verbose_print = Mock() app = create_multi_serve_app( @@ -150,7 +150,7 @@ class TestCreateServeApp: routes = [route.path for route in app.routes] assert "/health" in routes assert "/flows" in routes # Multi-flow always has this - assert "/flows/test-flow-id/run" in routes # Flow-specific endpoint + assert "/flows/00000000-0000-0000-0000-000000000001/run" in routes # Flow-specific endpoint def test_create_multi_serve_app_multiple_flows(self, real_graph, mock_meta, simple_chat_json): """Test creating app with multiple flows.""" @@ -164,8 +164,8 @@ class TestCreateServeApp: description="Second flow", ) - graphs = {"test-flow-id": real_graph, "flow-2": graph2} - metas = {"test-flow-id": mock_meta, "flow-2": meta2} + graphs = {"00000000-0000-0000-0000-000000000001": real_graph, "flow-2": graph2} + metas = {"00000000-0000-0000-0000-000000000001": mock_meta, "flow-2": meta2} verbose_print = Mock() app = create_multi_serve_app( @@ -182,14 +182,14 @@ class TestCreateServeApp: routes = [route.path for route in app.routes] assert "/health" in routes assert "/flows" in routes - assert "/flows/test-flow-id/run" in routes - assert "/flows/test-flow-id/info" in routes + assert "/flows/00000000-0000-0000-0000-000000000001/run" in routes + assert "/flows/00000000-0000-0000-0000-000000000001/info" in routes assert "/flows/flow-2/run" in routes assert "/flows/flow-2/info" in routes def test_create_multi_serve_app_mismatched_keys(self, real_graph, mock_meta): """Test error when graphs and metas have different keys.""" - graphs = {"test-flow-id": real_graph} + graphs = {"00000000-0000-0000-0000-000000000001": real_graph} metas = {"different-id": mock_meta} verbose_print = Mock() @@ -217,13 +217,13 @@ class TestServeAppEndpoints: def real_graph_with_async(self, simple_chat_json): """Create a real graph with async execution capability.""" # Create graph using from_payload with real test data - graph = Graph.from_payload(simple_chat_json, flow_id="test-flow-id") + graph = Graph.from_payload(simple_chat_json, flow_id="00000000-0000-0000-0000-000000000001") # Store original async_start to restore later if needed original_async_start = graph.async_start # Mock successful execution with real ResultData - async def mock_async_start(inputs): # noqa: ARG001 + async def mock_async_start(inputs, **kwargs): # noqa: ARG001 # Create real Message and ResultData objects message = Message(text="Hello from flow") result_data = ResultData( @@ -251,14 +251,14 @@ class TestServeAppEndpoints: from lfx.services.deps import get_settings_service meta = FlowMeta( - id="test-flow-id", + id="00000000-0000-0000-0000-000000000001", relative_path="test.json", title="Test Flow", description="A test flow", ) - graphs = {"test-flow-id": real_graph_with_async} - metas = {"test-flow-id": meta} + graphs = {"00000000-0000-0000-0000-000000000001": real_graph_with_async} + metas = {"00000000-0000-0000-0000-000000000001": meta} verbose_print = Mock() app = create_multi_serve_app( @@ -282,14 +282,14 @@ class TestServeAppEndpoints: # Create second real graph using the same JSON structure graph2 = Graph.from_payload(simple_chat_json, flow_id="flow-2") - async def mock_async_start2(inputs): # noqa: ARG001 + async def mock_async_start2(inputs, **kwargs): # noqa: ARG001 # Return empty results for this test yield MagicMock(outputs=[]) graph2.async_start = mock_async_start2 meta1 = FlowMeta( - id="test-flow-id", + id="00000000-0000-0000-0000-000000000001", relative_path="test.json", title="Test Flow", description="First flow", @@ -301,8 +301,8 @@ class TestServeAppEndpoints: description="Second flow", ) - graphs = {"test-flow-id": real_graph_with_async, "flow-2": graph2} - metas = {"test-flow-id": meta1, "flow-2": meta2} + graphs = {"00000000-0000-0000-0000-000000000001": real_graph_with_async, "flow-2": graph2} + metas = {"00000000-0000-0000-0000-000000000001": meta1, "flow-2": meta2} verbose_print = Mock() app = create_multi_serve_app( @@ -340,7 +340,9 @@ class TestServeAppEndpoints: "type": "message", "component": "TestComponent", } - response = app_client.post("/flows/test-flow-id/run", json=request_data, headers=headers) + response = app_client.post( + "/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers + ) assert response.status_code == 200 data = response.json() @@ -353,7 +355,7 @@ class TestServeAppEndpoints: request_data = {"input_value": "Test input"} with patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}): # pragma: allowlist secret - response = app_client.post("/flows/test-flow-id/run", json=request_data) + response = app_client.post("/flows/00000000-0000-0000-0000-000000000001/run", json=request_data) assert response.status_code == 401 assert response.json()["detail"] == "API key required" @@ -364,7 +366,9 @@ class TestServeAppEndpoints: headers = {"x-api-key": "wrong-key"} with patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}): # pragma: allowlist secret - response = app_client.post("/flows/test-flow-id/run", json=request_data, headers=headers) + response = app_client.post( + "/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers + ) assert response.status_code == 401 assert response.json()["detail"] == "Invalid API key" @@ -376,15 +380,15 @@ class TestServeAppEndpoints: """Test that /run fails closed before execution when custom components are blocked.""" real_graph_with_async.raw_graph_data = _blocked_raw_graph() meta = FlowMeta( - id="test-flow-id", + id="00000000-0000-0000-0000-000000000001", relative_path="test.json", title="Test Flow", description="A test flow", ) app = create_multi_serve_app( root_dir=Path("/test"), - graphs={"test-flow-id": real_graph_with_async}, - metas={"test-flow-id": meta}, + graphs={"00000000-0000-0000-0000-000000000001": real_graph_with_async}, + metas={"00000000-0000-0000-0000-000000000001": meta}, verbose_print=Mock(), ) headers = {"x-api-key": "test-api-key"} @@ -411,7 +415,7 @@ class TestServeAppEndpoints: ): client = TestClient(app) response = client.post( - "/flows/test-flow-id/run", + "/flows/00000000-0000-0000-0000-000000000001/run", json={"input_value": "Test input"}, headers=headers, ) @@ -435,7 +439,9 @@ class TestServeAppEndpoints: "type": "message", "component": "TestComponent", } - response = app_client.post("/flows/test-flow-id/run?x-api-key=test-api-key", json=request_data) + response = app_client.post( + "/flows/00000000-0000-0000-0000-000000000001/run?x-api-key=test-api-key", json=request_data + ) assert response.status_code == 200 assert response.json()["success"] is True @@ -446,7 +452,7 @@ class TestServeAppEndpoints: headers = {"x-api-key": "test-api-key"} # Mock execute_graph_with_capture to raise an error - async def mock_execute_error(graph, input_value): # noqa: ARG001 + async def mock_execute_error(graph, input_value, session_id=None): # noqa: ARG001 msg = "Flow execution failed" raise RuntimeError(msg) @@ -454,7 +460,9 @@ class TestServeAppEndpoints: patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}), # pragma: allowlist secret patch("lfx.cli.serve_app.execute_graph_with_capture", mock_execute_error), ): - response = app_client.post("/flows/test-flow-id/run", json=request_data, headers=headers) + response = app_client.post( + "/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers + ) assert response.status_code == 200 # Returns 200 with error in response body data = response.json() @@ -471,14 +479,16 @@ class TestServeAppEndpoints: headers = {"x-api-key": "test-api-key"} # Mock execute_graph_with_capture to return empty results - async def mock_execute_empty(graph, input_value): # noqa: ARG001 + async def mock_execute_empty(graph, input_value, session_id=None): # noqa: ARG001 return [], "" # Empty results and logs with ( patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}), # pragma: allowlist secret patch("lfx.cli.serve_app.execute_graph_with_capture", mock_execute_empty), ): - response = app_client.post("/flows/test-flow-id/run", json=request_data, headers=headers) + response = app_client.post( + "/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers + ) assert response.status_code == 200 data = response.json() @@ -486,6 +496,53 @@ class TestServeAppEndpoints: assert data["success"] is False assert data["type"] == "error" + def test_run_endpoint_forwards_session_id(self, app_client): + """The /run endpoint must forward session_id from RunRequest to the executor.""" + captured: dict = {} + + async def mock_execute_capture(graph, input_value, session_id=None): # noqa: ARG001 + captured["session_id"] = session_id + return [], "" + + request_data = {"input_value": "Test input", "session_id": "my-conversation"} + headers = {"x-api-key": "test-api-key"} + + with ( + patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}), # pragma: allowlist secret + patch("lfx.cli.serve_app.execute_graph_with_capture", mock_execute_capture), + ): + response = app_client.post( + "/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers + ) + + assert response.status_code == 200 + assert captured["session_id"] == "my-conversation" + + def test_stream_endpoint_forwards_session_id(self, app_client): + """The /stream endpoint must forward session_id from StreamRequest to the executor.""" + captured: dict = {} + + async def mock_execute_capture(graph, input_value, session_id=None): # noqa: ARG001 + captured["session_id"] = session_id + return [], "" + + request_data = {"input_value": "Test input", "session_id": "my-stream-conversation"} + headers = {"x-api-key": "test-api-key"} + + with ( + patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}), # pragma: allowlist secret + patch("lfx.cli.serve_app.execute_graph_with_capture", mock_execute_capture), + # Drain the streaming response so the background task completes before we assert. + app_client.stream( + "POST", "/flows/00000000-0000-0000-0000-000000000001/stream", json=request_data, headers=headers + ) as response, + ): + assert response.status_code == 200 + for _ in response.iter_bytes(): + pass + + assert captured["session_id"] == "my-stream-conversation" + def test_list_flows_endpoint(self, multi_flow_client): """Test listing flows in multi-flow mode.""" response = multi_flow_client.get("/flows") @@ -493,7 +550,7 @@ class TestServeAppEndpoints: assert response.status_code == 200 flows = response.json() assert len(flows) == 2 - assert any(f["id"] == "test-flow-id" for f in flows) + assert any(f["id"] == "00000000-0000-0000-0000-000000000001" for f in flows) assert any(f["id"] == "flow-2" for f in flows) def test_flow_info_endpoint(self, multi_flow_client): @@ -501,11 +558,11 @@ class TestServeAppEndpoints: headers = {"x-api-key": "test-api-key"} with patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}): # pragma: allowlist secret - response = multi_flow_client.get("/flows/test-flow-id/info", headers=headers) + response = multi_flow_client.get("/flows/00000000-0000-0000-0000-000000000001/info", headers=headers) assert response.status_code == 200 info = response.json() - assert info["id"] == "test-flow-id" + assert info["id"] == "00000000-0000-0000-0000-000000000001" assert info["title"] == "Test Flow" assert info["description"] == "First flow" @@ -524,7 +581,9 @@ class TestServeAppEndpoints: "type": "message", "component": "TestComponent", } - response = multi_flow_client.post("/flows/test-flow-id/run", json=request_data, headers=headers) + response = multi_flow_client.post( + "/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers + ) assert response.status_code == 200 data = response.json() @@ -536,7 +595,7 @@ class TestServeAppEndpoints: headers = {"x-api-key": "test-api-key"} with patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}): # pragma: allowlist secret - response = app_client.post("/flows/test-flow-id/run", json={}, headers=headers) + response = app_client.post("/flows/00000000-0000-0000-0000-000000000001/run", json={}, headers=headers) assert response.status_code == 422 # Validation error @@ -544,7 +603,7 @@ class TestServeAppEndpoints: """Test flow execution with message-type output.""" # Create a real message output scenario - async def mock_async_start_message(inputs): # noqa: ARG001 + async def mock_async_start_message(inputs, **kwargs): # noqa: ARG001 # Create real Message and ResultData objects message = Message(text="Message output") result_data = ResultData( @@ -576,7 +635,9 @@ class TestServeAppEndpoints: "type": "message", "component": "Chat Output", } - response = app_client.post("/flows/test-flow-id/run", json=request_data, headers=headers) + response = app_client.post( + "/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers + ) assert response.status_code == 200 data = response.json() diff --git a/src/lfx/tests/unit/cli/test_serve_components.py b/src/lfx/tests/unit/cli/test_serve_components.py index c8b8aa814a..961d88375a 100644 --- a/src/lfx/tests/unit/cli/test_serve_components.py +++ b/src/lfx/tests/unit/cli/test_serve_components.py @@ -249,7 +249,7 @@ def create_real_graph(): """Helper function to create a real LFX graph with nodes/edges for serve_app.""" # Load real JSON data and create graph using from_payload json_data = simple_chat_json() - return Graph.from_payload(json_data, flow_id="test-flow-id") + return Graph.from_payload(json_data, flow_id="00000000-0000-0000-0000-000000000001") class TestFastAPIAppCreation: diff --git a/src/lfx/tests/unit/components/cuga/__init__.py b/src/lfx/tests/unit/components/cuga/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/lfx/tests/unit/components/cuga/test_cuga_session_id_fallback.py b/src/lfx/tests/unit/components/cuga/test_cuga_session_id_fallback.py new file mode 100644 index 0000000000..556c6e841d --- /dev/null +++ b/src/lfx/tests/unit/components/cuga/test_cuga_session_id_fallback.py @@ -0,0 +1,97 @@ +"""Regression tests for CugaAgent session_id fallback (cuga_agent.py). + +The agent's ``message_response`` builds an internal ``Message`` with +``session_id=self.graph.session_id or str(uuid.uuid4())``. The fallback exists +because some flows (notably ``lfx run`` without --session-id, before run_flow +auto-generates one) had a graph with an empty session_id, which crashed +``astore_message`` validation. The ``str(...)`` wrap matches the rest of the +file (every other ``uuid.uuid4()`` is wrapped) and the codebase's expectation +that Message.session_id renders as a hex string in logs / persistence. + +These tests halt the agent's execution at the Message construction site so we +can verify the constructed kwargs without mocking the rest of the agent. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# CugaComponent's class body looks up MODELS_METADATA["OpenAI"], so it can only be +# imported when an OpenAI provider is registered (depends on optional deps in the env). +# Skip the whole module if the import side-effect fails. +try: + from lfx.components.cuga import cuga_agent +except Exception as exc: + pytest.skip(f"cuga_agent module not importable in this env: {exc}", allow_module_level=True) + + +class _Halt(Exception): # noqa: N818 + """Sentinel raised by the patched Message constructor to stop further execution.""" + + +@pytest.fixture +def captured_message_kwargs(): + """Patch cuga_agent.Message to capture constructor kwargs and halt execution.""" + captured: dict = {} + + def fake_message(*_args, **kwargs): + captured.update(kwargs) + raise _Halt + + with patch.object(cuga_agent, "Message", side_effect=fake_message): + yield captured + + +def _make_agent(*, graph_session_id): + """Build a minimally-initialized CugaComponent that reaches Message construction.""" + agent = cuga_agent.CugaComponent.__new__(cuga_agent.CugaComponent) + agent.input_value = "hello" + # ``graph`` is a read-only property on Component (-> self._vertex.graph), + # so wire the underlying _vertex instead of trying to assign graph directly. + agent._vertex = SimpleNamespace(graph=SimpleNamespace(session_id=graph_session_id)) + agent.is_connected_to_chat_output = MagicMock(return_value=True) + agent.get_agent_requirements = AsyncMock(return_value=(MagicMock(), [], [])) + return agent + + +@pytest.mark.asyncio +async def test_session_id_falls_back_to_string_uuid_when_graph_session_id_empty(captured_message_kwargs): + """Empty graph.session_id must produce a non-empty string session_id (not a UUID object).""" + agent = _make_agent(graph_session_id="") + + with pytest.raises(_Halt): + await agent.message_response() + + assert "session_id" in captured_message_kwargs + session_id = captured_message_kwargs["session_id"] + assert isinstance(session_id, str), ( + f"Expected str (use `str(uuid.uuid4())` not bare `uuid.uuid4()`), got {type(session_id).__name__}" + ) + assert session_id, "Generated session_id should be non-empty" + + +@pytest.mark.asyncio +async def test_session_id_falls_back_when_graph_session_id_none(captured_message_kwargs): + """None graph.session_id is also covered by the `or` fallback.""" + agent = _make_agent(graph_session_id=None) + + with pytest.raises(_Halt): + await agent.message_response() + + session_id = captured_message_kwargs["session_id"] + assert isinstance(session_id, str) + assert session_id + + +@pytest.mark.asyncio +async def test_session_id_preserved_when_graph_session_id_set(captured_message_kwargs): + """Explicit graph.session_id wins over the auto-generated fallback.""" + agent = _make_agent(graph_session_id="caller-supplied") + + with pytest.raises(_Halt): + await agent.message_response() + + assert captured_message_kwargs["session_id"] == "caller-supplied" diff --git a/src/lfx/tests/unit/run/test_base.py b/src/lfx/tests/unit/run/test_base.py index d158c6a2b6..6b4997506e 100644 --- a/src/lfx/tests/unit/run/test_base.py +++ b/src/lfx/tests/unit/run/test_base.py @@ -297,6 +297,437 @@ chat_input = ChatInput() assert "No 'graph' variable found" in str(exc_info.value) +class TestRunFlowSessionId: + """Tests for run_flow session_id handling.""" + + @staticmethod + def _mock_graph(): + graph = MagicMock() + graph.context = {} + graph.vertices = [] + graph.edges = [] + graph.prepare = MagicMock() + + async def _async_start(_inputs, **_kwargs): + yield + + graph.async_start = _async_start + return graph + + @pytest.mark.asyncio + async def test_session_id_autogenerated_when_not_provided(self, tmp_path): + """run_flow must assign a session_id so memory-store paths don't fail validation.""" + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + mock_graph = self._mock_graph() + + with ( + patch("lfx.run.base.find_graph_variable") as mock_find, + patch("lfx.run.base.load_graph_from_script") as mock_load, + patch("lfx.run.base.validate_global_variables_for_env") as mock_validate, + patch("lfx.run.base.extract_structured_result") as mock_extract, + ): + mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"} + mock_load.return_value = mock_graph + mock_validate.return_value = [] + mock_extract.return_value = {"success": True, "result": "test"} + + await run_flow(script_path=script_path) + + assert mock_graph.session_id, "session_id should be auto-generated when not provided" + assert isinstance(mock_graph.session_id, str) + + @pytest.mark.asyncio + async def test_caller_session_id_is_preserved(self, tmp_path): + """Caller-supplied session_id wins over auto-generation.""" + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + mock_graph = self._mock_graph() + + with ( + patch("lfx.run.base.find_graph_variable") as mock_find, + patch("lfx.run.base.load_graph_from_script") as mock_load, + patch("lfx.run.base.validate_global_variables_for_env") as mock_validate, + patch("lfx.run.base.extract_structured_result") as mock_extract, + ): + mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"} + mock_load.return_value = mock_graph + mock_validate.return_value = [] + mock_extract.return_value = {"success": True, "result": "test"} + + await run_flow(script_path=script_path, session_id="my-fixed-session") + + assert mock_graph.session_id == "my-fixed-session" + + @pytest.mark.asyncio + async def test_autogenerated_session_ids_are_unique_across_runs(self, tmp_path): + """Each run without a session_id should produce a distinct value.""" + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + graph_a = self._mock_graph() + graph_b = self._mock_graph() + + with ( + patch("lfx.run.base.find_graph_variable") as mock_find, + patch("lfx.run.base.load_graph_from_script") as mock_load, + patch("lfx.run.base.validate_global_variables_for_env") as mock_validate, + patch("lfx.run.base.extract_structured_result") as mock_extract, + ): + mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"} + mock_validate.return_value = [] + mock_extract.return_value = {"success": True, "result": "test"} + + mock_load.return_value = graph_a + await run_flow(script_path=script_path) + mock_load.return_value = graph_b + await run_flow(script_path=script_path) + + assert graph_a.session_id != graph_b.session_id + + @pytest.mark.asyncio + async def test_explicit_session_id_carries_across_consecutive_runs(self, tmp_path): + """The same caller-supplied session_id reaches both graphs — Memory continuity surface. + + Counterpart to ``test_autogenerated_session_ids_are_unique_across_runs``: with an + explicit session_id, two consecutive run_flow invocations must both stamp the + graph with that exact value (otherwise --session-id would not actually achieve + conversational continuity). + """ + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + graph_a = self._mock_graph() + graph_b = self._mock_graph() + + with ( + patch("lfx.run.base.find_graph_variable") as mock_find, + patch("lfx.run.base.load_graph_from_script") as mock_load, + patch("lfx.run.base.validate_global_variables_for_env") as mock_validate, + patch("lfx.run.base.extract_structured_result") as mock_extract, + ): + mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"} + mock_validate.return_value = [] + mock_extract.return_value = {"success": True, "result": "test"} + + mock_load.return_value = graph_a + await run_flow(script_path=script_path, session_id="continuity-session") + mock_load.return_value = graph_b + await run_flow(script_path=script_path, session_id="continuity-session") + + assert graph_a.session_id == "continuity-session" + assert graph_b.session_id == "continuity-session" + + @pytest.mark.asyncio + @pytest.mark.parametrize("bad_value", ["", " ", "\t\n"]) + async def test_empty_or_whitespace_session_id_is_rejected(self, tmp_path, bad_value): + """Empty/whitespace session_id surfaces as RunError instead of silently auto-generating. + + The --session-id flag's purpose is Memory/MessageHistory continuity. If a shell + quirk or env-var typo collapsed the value to empty, silently auto-generating + a fresh UUID would mask the error: subsequent runs would not see prior state + and the user would only notice via missing memory. Validate up-front instead. + """ + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + + with pytest.raises(RunError) as exc_info: + await run_flow(script_path=script_path, session_id=bad_value) + assert "session_id" in str(exc_info.value) + + @pytest.mark.asyncio + @pytest.mark.parametrize("bad_value", ["", " "]) + async def test_empty_or_whitespace_user_id_is_rejected(self, tmp_path, bad_value): + """Same validation as session_id, applied to user_id (variable-scoping in DB-backed setups).""" + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + + with pytest.raises(RunError) as exc_info: + await run_flow(script_path=script_path, user_id=bad_value) + assert "user_id" in str(exc_info.value) + + +class TestRunFlowSessionIdPropagation: + """Session_id must reach Memory/MessageHistory components on the lfx run path. + + lfx run uses ``graph.async_start`` (not ``graph.arun``), so it bypasses the + ``has_session_id_vertices`` propagation loop in ``Graph._run``. ``run_flow`` + must replicate that loop after assigning ``graph.session_id`` so components + that read ``self.session_id`` from their input field (Memory.retrieve_messages + etc.) actually see the configured value. Mirrors what + ``langflow/api/utils/flow_utils.build_graph_from_data`` does for the playground. + """ + + @staticmethod + def _mock_graph_with_vertices(vertex_specs): + """Build a mock graph whose has_session_id_vertices loop drives ``vertex_specs``. + + Each spec is (vertex_id, raw_params_dict). Returns the graph plus the + list of vertex mocks so tests can assert on update_raw_params calls. + """ + graph = MagicMock() + graph.context = {} + graph.vertices = [] + graph.edges = [] + graph.prepare = MagicMock() + + async def _async_start(_inputs, **_kwargs): + yield + + graph.async_start = _async_start + + vertex_mocks = {} + for vertex_id, raw_params in vertex_specs: + vertex = MagicMock() + vertex.raw_params = dict(raw_params) + vertex.update_raw_params = MagicMock() + vertex_mocks[vertex_id] = vertex + + graph.has_session_id_vertices = list(vertex_mocks.keys()) + graph.get_vertex = MagicMock(side_effect=lambda vid: vertex_mocks.get(vid)) + return graph, vertex_mocks + + @staticmethod + def _patches(): + return ( + patch("lfx.run.base.find_graph_variable"), + patch("lfx.run.base.load_graph_from_script"), + patch("lfx.run.base.validate_global_variables_for_env"), + patch("lfx.run.base.extract_structured_result"), + ) + + @pytest.mark.asyncio + async def test_session_id_propagates_to_vertex_with_empty_input(self, tmp_path): + """A Memory vertex with no hardcoded session_id receives the run's session_id.""" + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + graph, vertices = self._mock_graph_with_vertices([("memory-1", {})]) + + find_p, load_p, validate_p, extract_p = self._patches() + with find_p as mock_find, load_p as mock_load, validate_p as mock_validate, extract_p as mock_extract: + mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"} + mock_load.return_value = graph + mock_validate.return_value = [] + mock_extract.return_value = {"success": True, "result": "test"} + + await run_flow(script_path=script_path, session_id="from-cli") + + vertices["memory-1"].update_raw_params.assert_called_once_with({"session_id": "from-cli"}, overwrite=True) + + @pytest.mark.asyncio + async def test_session_id_does_not_overwrite_hardcoded_vertex_value(self, tmp_path): + """If the flow JSON pinned session_id on the Memory component, the CLI must not clobber it. + + Matches Langflow's playground behavior: ``build_graph_from_data`` only writes + when ``raw_params.get("session_id")`` is falsy. + """ + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + graph, vertices = self._mock_graph_with_vertices([("memory-pinned", {"session_id": "hardcoded-in-flow"})]) + + find_p, load_p, validate_p, extract_p = self._patches() + with find_p as mock_find, load_p as mock_load, validate_p as mock_validate, extract_p as mock_extract: + mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"} + mock_load.return_value = graph + mock_validate.return_value = [] + mock_extract.return_value = {"success": True, "result": "test"} + + await run_flow(script_path=script_path, session_id="from-cli") + + vertices["memory-pinned"].update_raw_params.assert_not_called() + + @pytest.mark.asyncio + async def test_session_id_propagation_handles_missing_vertex(self, tmp_path): + """A stale vertex_id in has_session_id_vertices (get_vertex returns None) is skipped, not raised.""" + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + graph, vertices = self._mock_graph_with_vertices([("real-vertex", {})]) + graph.has_session_id_vertices = ["real-vertex", "ghost-vertex"] + + find_p, load_p, validate_p, extract_p = self._patches() + with find_p as mock_find, load_p as mock_load, validate_p as mock_validate, extract_p as mock_extract: + mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"} + mock_load.return_value = graph + mock_validate.return_value = [] + mock_extract.return_value = {"success": True, "result": "test"} + + await run_flow(script_path=script_path, session_id="from-cli") + + vertices["real-vertex"].update_raw_params.assert_called_once() + + +class TestRunFlowUserId: + """user_id auto-generation on the lfx run path. + + AgentComponent (and any component that resolves variables) hits a precheck + in ``custom_component.get_variable`` that requires a non-empty ``self.user_id``. + lfx run has no notion of authenticated users, but the precheck still has to + pass so the env-fallback variable service can answer. ``run_flow`` therefore + auto-generates a ceremonial UUID when none is supplied; the value is unused + for variable scoping in lfx (env vars are process-global) but exists to keep + component initialization unblocked. + """ + + @staticmethod + def _mock_graph(): + graph = MagicMock() + graph.context = {} + graph.vertices = [] + graph.edges = [] + graph.prepare = MagicMock() + + async def _async_start(_inputs, **_kwargs): + yield + + graph.async_start = _async_start + return graph + + @pytest.mark.asyncio + async def test_user_id_autogenerated_when_not_provided(self, tmp_path): + """run_flow assigns a UUID user_id so the component precheck passes.""" + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + mock_graph = self._mock_graph() + + with ( + patch("lfx.run.base.find_graph_variable") as mock_find, + patch("lfx.run.base.load_graph_from_script") as mock_load, + patch("lfx.run.base.validate_global_variables_for_env") as mock_validate, + patch("lfx.run.base.extract_structured_result") as mock_extract, + ): + mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"} + mock_load.return_value = mock_graph + mock_validate.return_value = [] + mock_extract.return_value = {"success": True, "result": "test"} + + await run_flow(script_path=script_path) + + assert mock_graph.user_id, "user_id should be auto-generated when not provided" + assert isinstance(mock_graph.user_id, str) + + @pytest.mark.asyncio + async def test_caller_user_id_is_preserved(self, tmp_path): + """An explicit user_id wins over auto-generation.""" + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + mock_graph = self._mock_graph() + + with ( + patch("lfx.run.base.find_graph_variable") as mock_find, + patch("lfx.run.base.load_graph_from_script") as mock_load, + patch("lfx.run.base.validate_global_variables_for_env") as mock_validate, + patch("lfx.run.base.extract_structured_result") as mock_extract, + ): + mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"} + mock_load.return_value = mock_graph + mock_validate.return_value = [] + mock_extract.return_value = {"success": True, "result": "test"} + + await run_flow(script_path=script_path, user_id="real-user-uuid") + + assert mock_graph.user_id == "real-user-uuid" + + @pytest.mark.asyncio + async def test_autogenerated_user_ids_are_unique_across_runs(self, tmp_path): + """Each run without a user_id should produce a distinct value (ceremonial UUIDs differ).""" + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + graph_a = self._mock_graph() + graph_b = self._mock_graph() + + with ( + patch("lfx.run.base.find_graph_variable") as mock_find, + patch("lfx.run.base.load_graph_from_script") as mock_load, + patch("lfx.run.base.validate_global_variables_for_env") as mock_validate, + patch("lfx.run.base.extract_structured_result") as mock_extract, + ): + mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"} + mock_validate.return_value = [] + mock_extract.return_value = {"success": True, "result": "test"} + + mock_load.return_value = graph_a + await run_flow(script_path=script_path) + mock_load.return_value = graph_b + await run_flow(script_path=script_path) + + assert graph_a.user_id != graph_b.user_id + + +class TestRunFlowFallbackToEnvVars: + """run_flow must plumb fallback_to_env_vars into ``graph.async_start``. + + Without this, a langflow ``DatabaseVariableService`` registered alongside + ``database_service`` would raise ``variable not found`` for any + ``load_from_db=True`` field whose user_id has no Variable row (e.g., the + ceremonial UUID lfx auto-generates). The flag tells + ``loading.update_params_with_load_from_db_fields`` to fall back to + ``os.environ`` when the DB lookup misses — same behavior as the langflow + API path in ``processing.process.run_graph_internal``. + """ + + @staticmethod + def _mock_graph_capturing_kwargs(captured: dict): + graph = MagicMock() + graph.context = {} + graph.vertices = [] + graph.edges = [] + graph.prepare = MagicMock() + + async def _async_start(_inputs, **kwargs): + captured.update(kwargs) + yield + + graph.async_start = _async_start + return graph + + @pytest.mark.asyncio + async def test_passes_fallback_from_settings_default(self, tmp_path): + """Setting defaults True; run_flow forwards True into async_start.""" + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + captured: dict = {} + mock_graph = self._mock_graph_capturing_kwargs(captured) + + with ( + patch("lfx.run.base.find_graph_variable") as mock_find, + patch("lfx.run.base.load_graph_from_script") as mock_load, + patch("lfx.run.base.validate_global_variables_for_env") as mock_validate, + patch("lfx.run.base.extract_structured_result") as mock_extract, + ): + mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"} + mock_load.return_value = mock_graph + mock_validate.return_value = [] + mock_extract.return_value = {"success": True, "result": "test"} + + await run_flow(script_path=script_path) + + assert captured.get("fallback_to_env_vars") is True + + @pytest.mark.asyncio + async def test_respects_settings_when_disabled(self, tmp_path): + """When LANGFLOW_FALLBACK_TO_ENV_VAR=false, the flag plumbs through as False.""" + script_path = tmp_path / "test.py" + script_path.write_text("graph = None") + captured: dict = {} + mock_graph = self._mock_graph_capturing_kwargs(captured) + mock_settings = MagicMock() + mock_settings.settings.fallback_to_env_var = False + + with ( + patch("lfx.run.base.find_graph_variable") as mock_find, + patch("lfx.run.base.load_graph_from_script") as mock_load, + patch("lfx.run.base.validate_global_variables_for_env") as mock_validate, + patch("lfx.run.base.extract_structured_result") as mock_extract, + patch("lfx.run._defaults.get_settings_service", return_value=mock_settings), + ): + mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"} + mock_load.return_value = mock_graph + mock_validate.return_value = [] + mock_extract.return_value = {"success": True, "result": "test"} + + await run_flow(script_path=script_path) + + assert captured.get("fallback_to_env_vars") is False + + class TestRunFlowGlobalVariables: """Tests for run_flow global variables injection.""" diff --git a/src/lfx/tests/unit/services/test_integration.py b/src/lfx/tests/unit/services/test_integration.py index c796e54f95..f8a3a4449a 100644 --- a/src/lfx/tests/unit/services/test_integration.py +++ b/src/lfx/tests/unit/services/test_integration.py @@ -482,7 +482,7 @@ class TestEnvironmentVariableIntegration: asyncio.run(manager.teardown()) - def test_variable_service_uses_env(self, clean_manager): + async def test_variable_service_uses_env(self, clean_manager): """Test that variable service reads from environment.""" from lfx.services.variable.service import VariableService @@ -491,12 +491,12 @@ class TestEnvironmentVariableIntegration: os.environ["TEST_API_KEY"] = "test_value_123" # pragma: allowlist secret try: variables = clean_manager.get(ServiceType.VARIABLE_SERVICE) - value = variables.get_variable("TEST_API_KEY") + value = await variables.get_variable("TEST_API_KEY") assert value == "test_value_123" finally: del os.environ["TEST_API_KEY"] - def test_variable_service_in_memory_overrides_env(self, clean_manager): + async def test_variable_service_in_memory_overrides_env(self, clean_manager): """Test that in-memory variables override environment.""" from lfx.services.variable.service import VariableService @@ -506,7 +506,7 @@ class TestEnvironmentVariableIntegration: try: variables = clean_manager.get(ServiceType.VARIABLE_SERVICE) variables.set_variable("TEST_VAR", "memory_value") - value = variables.get_variable("TEST_VAR") + value = await variables.get_variable("TEST_VAR") assert value == "memory_value" finally: del os.environ["TEST_VAR"] diff --git a/src/lfx/tests/unit/services/test_minimal_services.py b/src/lfx/tests/unit/services/test_minimal_services.py index 72a64b6ff2..01387df6e9 100644 --- a/src/lfx/tests/unit/services/test_minimal_services.py +++ b/src/lfx/tests/unit/services/test_minimal_services.py @@ -206,31 +206,31 @@ class TestVariableService: assert variables.ready is True assert variables.name == "variable_service" - def test_set_and_get_variable(self, variables): + async def test_set_and_get_variable(self, variables): """Test setting and getting a variable.""" variables.set_variable("test_key", "test_value") - value = variables.get_variable("test_key") + value = await variables.get_variable("test_key") assert value == "test_value" - def test_get_from_environment(self, variables): + async def test_get_from_environment(self, variables): """Test getting variable from environment.""" os.environ["TEST_ENV_VAR"] = "env_value" try: - value = variables.get_variable("TEST_ENV_VAR") + value = await variables.get_variable("TEST_ENV_VAR") assert value == "env_value" finally: del os.environ["TEST_ENV_VAR"] - def test_get_nonexistent_variable(self, variables): + async def test_get_nonexistent_variable(self, variables): """Test getting a variable that doesn't exist.""" - value = variables.get_variable("nonexistent_key") + value = await variables.get_variable("nonexistent_key") assert value is None - def test_delete_variable(self, variables): + async def test_delete_variable(self, variables): """Test deleting a variable.""" variables.set_variable("test_key", "test_value") variables.delete_variable("test_key") - value = variables.get_variable("test_key") + value = await variables.get_variable("test_key") assert value is None def test_list_variables(self, variables): @@ -242,24 +242,55 @@ class TestVariableService: assert "key1" in vars_list assert "key2" in vars_list - def test_in_memory_overrides_env(self, variables): + async def test_in_memory_overrides_env(self, variables): """Test that in-memory variables override environment.""" os.environ["TEST_VAR"] = "env_value" try: variables.set_variable("TEST_VAR", "memory_value") - value = variables.get_variable("TEST_VAR") + value = await variables.get_variable("TEST_VAR") assert value == "memory_value" finally: del os.environ["TEST_VAR"] - @pytest.mark.asyncio + async def test_get_variable_is_coroutine(self, variables): + """get_variable must be async to match the langflow call site. + + Callers use ``await variable_service.get_variable(...)`` (see + ``Component.get_variable`` in custom_component.py); awaiting a non-coroutine + TypeErrors. lfx's implementation does no I/O — the async wrapper exists + purely to match the interface langflow's DatabaseVariableService uses. + """ + import inspect + + assert inspect.iscoroutinefunction(variables.get_variable) + coro = variables.get_variable("anything") + assert inspect.iscoroutine(coro) + await coro # avoid 'coroutine never awaited' warning + + async def test_get_variable_absorbs_extra_kwargs(self, variables): + """Extra kwargs from the langflow call site must not crash the lfx implementation. + + Langflow calls ``await variable_service.get_variable(user_id=..., name=..., + field=..., session=...)``. lfx's implementation must accept and ignore the + extras so flows behave identically as long as the variable can be resolved + by name. + """ + os.environ["LFX_KWARG_TEST"] = "value-from-env" + try: + value = await variables.get_variable( + user_id="random-uuid", name="LFX_KWARG_TEST", field="value", session=None + ) + assert value == "value-from-env" + finally: + del os.environ["LFX_KWARG_TEST"] + async def test_teardown(self, variables): """Test service teardown clears variables.""" variables.set_variable("test_key", "test_value") await variables.teardown() # Variables should be cleared (verify via public API) assert variables.list_variables() == [] - assert variables.get_variable("test_key") is None + assert await variables.get_variable("test_key") is None class TestMinimalServicesIntegration: diff --git a/src/lfx/tests/unit/services/test_service_manager.py b/src/lfx/tests/unit/services/test_service_manager.py index 44b50ee327..c1b4e8b11b 100644 --- a/src/lfx/tests/unit/services/test_service_manager.py +++ b/src/lfx/tests/unit/services/test_service_manager.py @@ -427,7 +427,7 @@ class TestRealWorldScenarios: await service_manager.teardown() assert ServiceType.STORAGE_SERVICE not in service_manager.services - def test_multiple_services_working_together(self, service_manager): + async def test_multiple_services_working_together(self, service_manager): """Test multiple services can coexist and work together.""" # Register all minimal services service_manager.register_service_class(ServiceType.STORAGE_SERVICE, LocalStorageService) @@ -450,7 +450,7 @@ class TestRealWorldScenarios: # All should be usable tracing.add_log("test_trace", {"message": "test"}) variables.set_variable("TEST_KEY", "test_value") - assert variables.get_variable("TEST_KEY") == "test_value" + assert await variables.get_variable("TEST_KEY") == "test_value" def test_config_file_with_all_minimal_services(self, service_manager, temp_config_dir): """Test loading all minimal services from config file."""