From df68b1b372a4282a0a4176cab094c67aed46ecba Mon Sep 17 00:00:00 2001 From: Jordan Frazier Date: Fri, 27 Mar 2026 21:05:58 -0400 Subject: [PATCH] =?UTF-8?q?feat(a2a):=20PR=20prep=20=E2=80=94=20a2a-sdk=20?= =?UTF-8?q?dependency,=20Agent=20tool=20injection=20hook,=20ruff=20formatt?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepare the A2A implementation for PR readiness: - Add a2a-sdk>=0.3.0 to langflow-base dependencies in pyproject.toml - Add _inject_a2a_tools() hook to LCToolsAgentComponent in lfx: checks graph.context for A2A execution context, creates and injects request_input LangChain StructuredTool into self.tools. Called from both build_agent() and run_agent(). No-op when not running under A2A. - Run ruff format on all A2A source and test files (17 reformatted) - Alembic migration deferred: repo has pre-existing multiple heads that must be resolved first. SQLite auto-creates from SQLModel in tests. Migration will be created at PR merge time. 141 tests, all passing. --- .../base/langflow/api/a2a/agent_card.py | 6 +- src/backend/base/langflow/api/a2a/config.py | 42 +++++++------ .../base/langflow/api/a2a/flow_adapter.py | 40 +++++++----- src/backend/base/langflow/api/a2a/router.py | 11 +--- src/backend/base/pyproject.toml | 1 + .../tests/unit/api/a2a/test_discovery.py | 24 ++----- .../tests/unit/api/a2a/test_flow_adapter.py | 26 ++++---- .../tests/unit/api/a2a/test_input_required.py | 15 +++-- .../unit/api/a2a/test_input_required_e2e.py | 34 ++++++---- .../tests/unit/api/a2a/test_message_send.py | 40 +++--------- .../tests/unit/api/a2a/test_message_stream.py | 32 +++------- .../tests/unit/api/a2a/test_multi_turn.py | 32 +++------- .../tests/unit/api/a2a/test_request_input.py | 18 +++--- src/backend/tests/unit/api/a2a/test_smoke.py | 8 +-- .../tests/unit/api/a2a/test_streaming.py | 32 +++------- .../tests/unit/api/a2a/test_task_cleanup.py | 24 ++----- .../tests/unit/api/a2a/test_task_manager.py | 20 ++---- src/lfx/src/lfx/base/agents/agent.py | 63 +++++++++++++++++++ uv.lock | 21 +++++++ 19 files changed, 237 insertions(+), 252 deletions(-) diff --git a/src/backend/base/langflow/api/a2a/agent_card.py b/src/backend/base/langflow/api/a2a/agent_card.py index 009e340646..a00256de26 100644 --- a/src/backend/base/langflow/api/a2a/agent_card.py +++ b/src/backend/base/langflow/api/a2a/agent_card.py @@ -42,11 +42,7 @@ def generate_agent_card(flow: Flow, base_url: str) -> dict: agent_name = getattr(flow, "a2a_name", None) or flow.name # Resolve description: a2a_description > flow.description > default - agent_description = ( - getattr(flow, "a2a_description", None) - or flow.description - or _DEFAULT_DESCRIPTION - ) + agent_description = getattr(flow, "a2a_description", None) or flow.description or _DEFAULT_DESCRIPTION # Resolve tags tags = getattr(flow, "tags", None) or [] diff --git a/src/backend/base/langflow/api/a2a/config.py b/src/backend/base/langflow/api/a2a/config.py index fed9589c8c..6ca19b65bc 100644 --- a/src/backend/base/langflow/api/a2a/config.py +++ b/src/backend/base/langflow/api/a2a/config.py @@ -10,26 +10,28 @@ import re # Component types that indicate conversational / agentic capability. # A flow must contain at least one of these to be eligible for A2A exposure. -_ELIGIBLE_COMPONENT_TYPES = frozenset({ - # Agent components (primary use case) - "Agent", - "AgentComponent", - # LLM / Chat model components - "OpenAIModel", - "ChatOpenAI", - "AnthropicModel", - "ChatAnthropic", - "GoogleGenerativeAIModel", - "AzureChatOpenAI", - "OllamaModel", - "ChatOllama", - "GroqModel", - "HuggingFaceModel", - # Generic markers - "LLMModel", - "ChatModel", - "LanguageModel", -}) +_ELIGIBLE_COMPONENT_TYPES = frozenset( + { + # Agent components (primary use case) + "Agent", + "AgentComponent", + # LLM / Chat model components + "OpenAIModel", + "ChatOpenAI", + "AnthropicModel", + "ChatAnthropic", + "GoogleGenerativeAIModel", + "AzureChatOpenAI", + "OllamaModel", + "ChatOllama", + "GroqModel", + "HuggingFaceModel", + # Generic markers + "LLMModel", + "ChatModel", + "LanguageModel", + } +) # Slug format: lowercase alphanumeric + hyphens, 3-64 chars, # must start/end with alphanumeric, no consecutive hyphens. diff --git a/src/backend/base/langflow/api/a2a/flow_adapter.py b/src/backend/base/langflow/api/a2a/flow_adapter.py index e37788b951..f8fb6b0cd5 100644 --- a/src/backend/base/langflow/api/a2a/flow_adapter.py +++ b/src/backend/base/langflow/api/a2a/flow_adapter.py @@ -129,30 +129,38 @@ def _result_to_artifact(result, index: int = 0) -> dict | None: if isinstance(results, dict): message = results.get("message") if isinstance(message, dict) and "text" in message: - parts.append({ - "kind": "text", - "text": message["text"], - }) + parts.append( + { + "kind": "text", + "text": message["text"], + } + ) elif isinstance(message, str): - parts.append({ - "kind": "text", - "text": message, - }) + parts.append( + { + "kind": "text", + "text": message, + } + ) # Check for structured data output data = results.get("data") if isinstance(data, dict): - parts.append({ - "kind": "data", - "data": data, - }) + parts.append( + { + "kind": "data", + "data": data, + } + ) if not parts: # Fallback: serialize the entire result as data - parts.append({ - "kind": "data", - "data": results if isinstance(results, dict) else {"result": str(results)}, - }) + parts.append( + { + "kind": "data", + "data": results if isinstance(results, dict) else {"result": str(results)}, + } + ) return { "artifactId": f"artifact-{index}", diff --git a/src/backend/base/langflow/api/a2a/router.py b/src/backend/base/langflow/api/a2a/router.py index 29c33fe51c..abde3d81c9 100644 --- a/src/backend/base/langflow/api/a2a/router.py +++ b/src/backend/base/langflow/api/a2a/router.py @@ -177,9 +177,7 @@ async def message_send( if requested_task_id and _task_manager.has_pending_input(requested_task_id): # Extract text from the follow-up message parts = message.get("parts", []) - follow_up_text = " ".join( - p.get("text", "") for p in parts if p.get("kind") == "text" - ) + follow_up_text = " ".join(p.get("text", "") for p in parts if p.get("kind") == "text") await _task_manager.resolve_input(requested_task_id, follow_up_text) task = await _task_manager.get_task(requested_task_id) return task @@ -347,9 +345,7 @@ async def message_stream( "task_manager": _task_manager, "stream_bridge": bridge, } - result = await _execute_flow( - flow, flow_inputs, session, a2a_context=a2a_context - ) + result = await _execute_flow(flow, flow_inputs, session, a2a_context=a2a_context) # Check if the task entered INPUT_REQUIRED during execution # (this would be set by the request_input tool handler) @@ -539,8 +535,7 @@ async def update_a2a_config( if not validate_flow_eligible_for_a2a(flow.data): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="This flow is not eligible for A2A exposure. " - "It must contain an Agent or LLM component.", + detail="This flow is not eligible for A2A exposure. It must contain an Agent or LLM component.", ) # Apply updates diff --git a/src/backend/base/pyproject.toml b/src/backend/base/pyproject.toml index 4203314e68..21de261cb6 100644 --- a/src/backend/base/pyproject.toml +++ b/src/backend/base/pyproject.toml @@ -83,6 +83,7 @@ dependencies = [ "networkx>=3.4.2,<4.0.0", "json-repair>=0.30.3,<1.0.0", "mcp>=1.17.0,<2.0.0", + "a2a-sdk>=0.3.0,<1.0.0", "aiosqlite>=0.20.0,<1.0.0", "greenlet>=3.1.1,<4.0.0", "jsonquerylang>=1.1.1,<2.0.0", diff --git a/src/backend/tests/unit/api/a2a/test_discovery.py b/src/backend/tests/unit/api/a2a/test_discovery.py index 1950fdc8d0..520cc2fea5 100644 --- a/src/backend/tests/unit/api/a2a/test_discovery.py +++ b/src/backend/tests/unit/api/a2a/test_discovery.py @@ -200,9 +200,7 @@ class TestA2AConfigManagement: """ flow = await _create_flow_via_api(client, logged_in_headers) - response = await _enable_a2a_on_flow( - client, logged_in_headers, flow["id"], slug="config-test" - ) + response = await _enable_a2a_on_flow(client, logged_in_headers, flow["id"], slug="config-test") assert response.status_code == 200 # Verify the config was persisted @@ -263,23 +261,15 @@ class TestA2AConfigManagement: The slug is used in the public URL, so collisions would cause routing ambiguity. """ - flow1 = await _create_flow_via_api( - client, logged_in_headers, name="Flow One" - ) - flow2 = await _create_flow_via_api( - client, logged_in_headers, name="Flow Two" - ) + flow1 = await _create_flow_via_api(client, logged_in_headers, name="Flow One") + flow2 = await _create_flow_via_api(client, logged_in_headers, name="Flow Two") # Enable first flow with slug - response1 = await _enable_a2a_on_flow( - client, logged_in_headers, flow1["id"], slug="unique-slug" - ) + response1 = await _enable_a2a_on_flow(client, logged_in_headers, flow1["id"], slug="unique-slug") assert response1.status_code == 200 # Try same slug on second flow → should fail - response2 = await _enable_a2a_on_flow( - client, logged_in_headers, flow2["id"], slug="unique-slug" - ) + response2 = await _enable_a2a_on_flow(client, logged_in_headers, flow2["id"], slug="unique-slug") assert response2.status_code == 409 # Conflict async def test_invalid_slug_rejected(self, client: AsyncClient, logged_in_headers): @@ -306,9 +296,7 @@ class TestA2AConfigManagement: data=_make_non_agent_flow_data(), ) - response = await _enable_a2a_on_flow( - client, logged_in_headers, flow["id"], slug="pipeline" - ) + response = await _enable_a2a_on_flow(client, logged_in_headers, flow["id"], slug="pipeline") assert response.status_code == 422 # Not eligible diff --git a/src/backend/tests/unit/api/a2a/test_flow_adapter.py b/src/backend/tests/unit/api/a2a/test_flow_adapter.py index cb1ec69eb7..cbe545e998 100644 --- a/src/backend/tests/unit/api/a2a/test_flow_adapter.py +++ b/src/backend/tests/unit/api/a2a/test_flow_adapter.py @@ -181,9 +181,7 @@ class TestTranslateOutbound: This is the most common case — the agent produces a text response. """ - run_outputs = [ - _make_run_output(message_text="Here is your analysis of the codebase.") - ] + run_outputs = [_make_run_output(message_text="Here is your analysis of the codebase.")] artifacts = await translate_outbound(run_outputs) assert len(artifacts) == 1 @@ -192,9 +190,7 @@ class TestTranslateOutbound: async def test_structured_result_becomes_data_artifact(self): """When the flow returns structured data (dict), it becomes a data Part.""" - run_outputs = [ - _make_run_output(result_data={"score": 0.95, "labels": ["positive"]}) - ] + run_outputs = [_make_run_output(result_data={"score": 0.95, "labels": ["positive"]})] artifacts = await translate_outbound(run_outputs) assert len(artifacts) == 1 @@ -242,14 +238,18 @@ def _make_run_output( """ outputs = [] if message_text is not None: - outputs.append({ - "results": {"message": {"text": message_text}}, - "messages": [{"message": message_text}], - }) + outputs.append( + { + "results": {"message": {"text": message_text}}, + "messages": [{"message": message_text}], + } + ) if result_data is not None: - outputs.append({ - "results": {"data": result_data}, - }) + outputs.append( + { + "results": {"data": result_data}, + } + ) return { "inputs": {}, "outputs": outputs, diff --git a/src/backend/tests/unit/api/a2a/test_input_required.py b/src/backend/tests/unit/api/a2a/test_input_required.py index eeb9c3d6ce..a370776b0a 100644 --- a/src/backend/tests/unit/api/a2a/test_input_required.py +++ b/src/backend/tests/unit/api/a2a/test_input_required.py @@ -72,6 +72,7 @@ def _make_a2a_message(text: str, context_id: str | None = None, task_id: str | N def _mock_run_response(text: str = "Final answer"): from unittest.mock import MagicMock + run_output = MagicMock() run_output.inputs = {} result_data = MagicMock() @@ -101,9 +102,7 @@ class TestInputRequiredFollowUp: """ @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_follow_up_to_input_required_task( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_follow_up_to_input_required_task(self, mock_run, client: AsyncClient, logged_in_headers): """Send initial message, simulate INPUT_REQUIRED, send follow-up, verify the task completes. @@ -154,9 +153,7 @@ class TestInputRequiredFollowUp: assert body["id"] == task_id @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_get_input_required_task_shows_question( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_get_input_required_task_shows_question(self, mock_run, client: AsyncClient, logged_in_headers): """Polling an INPUT_REQUIRED task shows the agent's question. The client uses GET /tasks/{id} to see what the agent is asking. @@ -211,7 +208,8 @@ class TestRequestInputTaskManagerIntegration: await tm.update_state(task["id"], "working") tool_info = create_request_input_tool( - task_id=task["id"], task_manager=tm, + task_id=task["id"], + task_manager=tm, ) # Start the handler (will suspend and register pending input) @@ -235,7 +233,8 @@ class TestRequestInputTaskManagerIntegration: await tm.update_state(task["id"], "working") tool_info = create_request_input_tool( - task_id=task["id"], task_manager=tm, + task_id=task["id"], + task_manager=tm, ) handler = tool_info["handler"] future = asyncio.create_task(handler(question="Pick one")) diff --git a/src/backend/tests/unit/api/a2a/test_input_required_e2e.py b/src/backend/tests/unit/api/a2a/test_input_required_e2e.py index 426d31fb0c..e786141e27 100644 --- a/src/backend/tests/unit/api/a2a/test_input_required_e2e.py +++ b/src/backend/tests/unit/api/a2a/test_input_required_e2e.py @@ -95,9 +95,7 @@ class TestInputRequiredE2E: 3. Verifies the final response includes the resolved answer """ - async def test_input_required_resolved_via_follow_up( - self, client: AsyncClient, logged_in_headers - ): + async def test_input_required_resolved_via_follow_up(self, client: AsyncClient, logged_in_headers): """Full round-trip: send → INPUT_REQUIRED → follow-up → COMPLETED. This tests: @@ -113,9 +111,16 @@ class TestInputRequiredE2E: tool_event = asyncio.Event() tool_response_holder: dict = {} - async def mock_simple_run_flow(*, flow=None, input_request=None, stream=False, - api_key_user=None, event_manager=None, - context=None, run_id=None): + async def mock_simple_run_flow( + *, + flow=None, + input_request=None, + stream=False, + api_key_user=None, + event_manager=None, + context=None, + run_id=None, + ): """Simulate an agent that calls request_input mid-execution. 1. Signals INPUT_REQUIRED via task_manager @@ -208,9 +213,7 @@ class TestInputRequiredE2E: artifact_text = body["artifacts"][0]["parts"][0]["text"] assert "prod-us" in artifact_text - async def test_poll_shows_input_required_with_question( - self, client: AsyncClient, logged_in_headers - ): + async def test_poll_shows_input_required_with_question(self, client: AsyncClient, logged_in_headers): """GET /tasks/{id} shows INPUT_REQUIRED state with the agent's question. This is how a non-streaming client discovers that the agent @@ -223,9 +226,16 @@ class TestInputRequiredE2E: tool_event = asyncio.Event() tool_response_holder: dict = {} - async def mock_simple_run_flow(*, flow=None, input_request=None, stream=False, - api_key_user=None, event_manager=None, - context=None, run_id=None): + async def mock_simple_run_flow( + *, + flow=None, + input_request=None, + stream=False, + api_key_user=None, + event_manager=None, + context=None, + run_id=None, + ): all_tasks = await _task_manager.list_tasks() current_task = all_tasks[-1] await _task_manager.request_input( diff --git a/src/backend/tests/unit/api/a2a/test_message_send.py b/src/backend/tests/unit/api/a2a/test_message_send.py index 95820b14a0..e985594128 100644 --- a/src/backend/tests/unit/api/a2a/test_message_send.py +++ b/src/backend/tests/unit/api/a2a/test_message_send.py @@ -111,9 +111,7 @@ class TestMessageSendHappyPath: """ @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_send_text_message_returns_completed_task( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_send_text_message_returns_completed_task(self, mock_run, client: AsyncClient, logged_in_headers): """Sending a text message executes the flow and returns a COMPLETED task. This is the core happy path: @@ -145,9 +143,7 @@ class TestMessageSendHappyPath: assert "Hello from the agent!" in first_artifact["parts"][0]["text"] @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_task_has_context_id( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_task_has_context_id(self, mock_run, client: AsyncClient, logged_in_headers): """The returned task preserves the contextId from the request. This is essential for multi-turn conversations — the client @@ -168,9 +164,7 @@ class TestMessageSendHappyPath: assert body["contextId"] == "ctx-my-conversation" @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_idempotent_retry_returns_cached_result( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_idempotent_retry_returns_cached_result(self, mock_run, client: AsyncClient, logged_in_headers): """Sending the same taskId twice returns the cached result. The flow should NOT execute a second time. This prevents @@ -211,9 +205,7 @@ class TestMessageSendHappyPath: class TestMessageSendAuth: """Tests for authentication on the message:send endpoint.""" - async def test_unauthenticated_request_rejected( - self, client: AsyncClient, logged_in_headers - ): + async def test_unauthenticated_request_rejected(self, client: AsyncClient, logged_in_headers): """message:send requires authentication. Unlike the public AgentCard, message:send handles actual @@ -238,9 +230,7 @@ class TestMessageSendAuth: class TestMessageSendErrors: """Tests for error handling in message:send.""" - async def test_disabled_flow_returns_404( - self, client: AsyncClient, logged_in_headers - ): + async def test_disabled_flow_returns_404(self, client: AsyncClient, logged_in_headers): """Sending to a slug that exists but has A2A disabled returns 404.""" # Create flow but don't enable A2A flow = FlowCreate(name="Disabled", data=_make_agent_flow_data()) @@ -255,9 +245,7 @@ class TestMessageSendErrors: assert response.status_code == 404 @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_flow_execution_error_returns_failed_task( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_flow_execution_error_returns_failed_task(self, mock_run, client: AsyncClient, logged_in_headers): """When the flow execution raises an exception, the task becomes FAILED. The error message is included in the task status so the caller @@ -287,9 +275,7 @@ class TestTaskEndpoints: """Tests for task polling and cancellation endpoints.""" @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_get_task_returns_current_state( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_get_task_returns_current_state(self, mock_run, client: AsyncClient, logged_in_headers): """GET /tasks/{task_id} returns the current task state. This is the polling endpoint for clients that lost their @@ -316,9 +302,7 @@ class TestTaskEndpoints: assert get_response.json()["id"] == task_id assert get_response.json()["status"]["state"] == "completed" - async def test_get_nonexistent_task_returns_404( - self, client: AsyncClient, logged_in_headers - ): + async def test_get_nonexistent_task_returns_404(self, client: AsyncClient, logged_in_headers): """Polling a task that doesn't exist returns 404.""" await _create_a2a_enabled_flow(client, logged_in_headers, slug="poll-agent-2") @@ -329,9 +313,7 @@ class TestTaskEndpoints: assert response.status_code == 404 @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_cancel_task( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_cancel_task(self, mock_run, client: AsyncClient, logged_in_headers): """POST /tasks/{task_id}:cancel sets the task to CANCELED. Cancellation is best-effort — the flow may have already completed. @@ -357,9 +339,7 @@ class TestTaskEndpoints: assert cancel_response.json()["status"]["state"] == "canceled" @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_list_tasks_by_context( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_list_tasks_by_context(self, mock_run, client: AsyncClient, logged_in_headers): """GET /tasks?contextId=... returns tasks for that conversation.""" mock_run.return_value = _mock_run_response("Response") await _create_a2a_enabled_flow(client, logged_in_headers, slug="list-agent") diff --git a/src/backend/tests/unit/api/a2a/test_message_stream.py b/src/backend/tests/unit/api/a2a/test_message_stream.py index 9bfb3688ea..e5b421d641 100644 --- a/src/backend/tests/unit/api/a2a/test_message_stream.py +++ b/src/backend/tests/unit/api/a2a/test_message_stream.py @@ -41,9 +41,7 @@ def _make_agent_flow_data() -> dict: } -async def _create_a2a_enabled_flow( - client: AsyncClient, headers: dict, slug: str -) -> dict: +async def _create_a2a_enabled_flow(client: AsyncClient, headers: dict, slug: str) -> dict: flow = FlowCreate(name=f"Flow {slug}", description="Test", data=_make_agent_flow_data()) response = await client.post("api/v1/flows/", json=flow.model_dump(), headers=headers) assert response.status_code == 201 @@ -112,9 +110,7 @@ class TestMessageStreamHappyPath: """Tests for successful streaming flow execution.""" @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_stream_returns_sse_response( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_stream_returns_sse_response(self, mock_run, client: AsyncClient, logged_in_headers): """message:stream returns a streaming response with SSE media type. The response should be text/event-stream, not application/json. @@ -132,9 +128,7 @@ class TestMessageStreamHappyPath: assert "text/event-stream" in response.headers.get("content-type", "") @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_stream_contains_completed_event( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_stream_contains_completed_event(self, mock_run, client: AsyncClient, logged_in_headers): """The stream must end with a COMPLETED status event. This tells the client the task is done and no more events @@ -155,9 +149,7 @@ class TestMessageStreamHappyPath: assert any(e["status"]["state"] == "completed" for e in status_events) @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_stream_contains_working_event( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_stream_contains_working_event(self, mock_run, client: AsyncClient, logged_in_headers): """The stream should include a WORKING status event at the start. This tells the client the flow has started executing. @@ -176,9 +168,7 @@ class TestMessageStreamHappyPath: assert any(e["status"]["state"] == "working" for e in status_events) @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_stream_preserves_task_and_context_ids( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_stream_preserves_task_and_context_ids(self, mock_run, client: AsyncClient, logged_in_headers): """Every SSE event carries the taskId and contextId. This lets clients correlate events with the right task @@ -208,9 +198,7 @@ class TestMessageStreamErrors: """Tests for error handling during streaming.""" @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_stream_error_produces_failed_event( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_stream_error_produces_failed_event(self, mock_run, client: AsyncClient, logged_in_headers): """When flow execution fails, the stream includes a FAILED event. The client should see the error without the connection just dying. @@ -229,9 +217,7 @@ class TestMessageStreamErrors: status_events = [e for e in events if e.get("kind") == "status-update"] assert any(e["status"]["state"] == "failed" for e in status_events) - async def test_stream_disabled_flow_returns_404( - self, client: AsyncClient, logged_in_headers - ): + async def test_stream_disabled_flow_returns_404(self, client: AsyncClient, logged_in_headers): """Streaming to a non-existent slug returns 404.""" response = await client.post( "/a2a/nonexistent/v1/message:stream", @@ -240,9 +226,7 @@ class TestMessageStreamErrors: ) assert response.status_code == 404 - async def test_stream_requires_auth( - self, client: AsyncClient, logged_in_headers - ): + async def test_stream_requires_auth(self, client: AsyncClient, logged_in_headers): """message:stream requires authentication.""" await _create_a2a_enabled_flow(client, logged_in_headers, slug="auth-stream") diff --git a/src/backend/tests/unit/api/a2a/test_multi_turn.py b/src/backend/tests/unit/api/a2a/test_multi_turn.py index c4a1c2ffb5..b8d825836b 100644 --- a/src/backend/tests/unit/api/a2a/test_multi_turn.py +++ b/src/backend/tests/unit/api/a2a/test_multi_turn.py @@ -44,9 +44,7 @@ def _make_agent_flow_data() -> dict: } -async def _create_a2a_enabled_flow( - client: AsyncClient, headers: dict, slug: str -) -> dict: +async def _create_a2a_enabled_flow(client: AsyncClient, headers: dict, slug: str) -> dict: flow = FlowCreate(name=f"Flow {slug}", description="Test", data=_make_agent_flow_data()) response = await client.post("api/v1/flows/", json=flow.model_dump(), headers=headers) assert response.status_code == 201 @@ -103,9 +101,7 @@ class TestSessionContinuity: """ @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_same_context_id_uses_same_session( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_same_context_id_uses_same_session(self, mock_run, client: AsyncClient, logged_in_headers): """Two messages with the same contextId pass the same session_id to simple_run_flow. @@ -143,9 +139,7 @@ class TestSessionContinuity: assert session1.session_id.startswith("a2a-") @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_different_context_ids_use_different_sessions( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_different_context_ids_use_different_sessions(self, mock_run, client: AsyncClient, logged_in_headers): """Two messages with different contextIds use different session_ids. This ensures conversation isolation — one caller's context @@ -193,9 +187,7 @@ class TestTaskLinkage: """ @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_each_turn_creates_separate_task( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_each_turn_creates_separate_task(self, mock_run, client: AsyncClient, logged_in_headers): """Each message in a conversation produces a new Task. Tasks are the unit of work in A2A — each has its own lifecycle @@ -219,9 +211,7 @@ class TestTaskLinkage: assert len(set(task_ids)) == 3 @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_list_tasks_returns_all_turns( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_list_tasks_returns_all_turns(self, mock_run, client: AsyncClient, logged_in_headers): """Listing tasks by contextId returns all turns in the conversation. This is how a client retrieves the full conversation history @@ -256,9 +246,7 @@ class TestTaskLinkage: assert len(tasks_b) == 1 @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_all_tasks_share_context_id( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_all_tasks_share_context_id(self, mock_run, client: AsyncClient, logged_in_headers): """Every task in a conversation carries the same contextId. The contextId is the conversation thread identifier — it must @@ -280,9 +268,7 @@ class TestTaskLinkage: assert task["contextId"] == "ctx-preserved" @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_all_turns_complete_independently( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_all_turns_complete_independently(self, mock_run, client: AsyncClient, logged_in_headers): """Each turn completes independently — Turn 2 doesn't wait for Turn 1. In v1, turns are independent flow executions. The only continuity @@ -314,9 +300,7 @@ class TestSingleTurn: """ @patch("langflow.api.v1.endpoints.simple_run_flow", new_callable=AsyncMock) - async def test_no_context_id_generates_unique_sessions( - self, mock_run, client: AsyncClient, logged_in_headers - ): + async def test_no_context_id_generates_unique_sessions(self, mock_run, client: AsyncClient, logged_in_headers): """Two messages without contextId get different session_ids. Without an explicit contextId, each message is a standalone diff --git a/src/backend/tests/unit/api/a2a/test_request_input.py b/src/backend/tests/unit/api/a2a/test_request_input.py index 953dad628c..471c00416e 100644 --- a/src/backend/tests/unit/api/a2a/test_request_input.py +++ b/src/backend/tests/unit/api/a2a/test_request_input.py @@ -41,9 +41,7 @@ def task_manager(): @pytest.fixture async def task_with_tool(task_manager): """Create a task and its associated request_input tool.""" - task = await task_manager.create_task( - flow_id="flow-1", context_id="ctx-1", task_id="task-input-1" - ) + task = await task_manager.create_task(flow_id="flow-1", context_id="ctx-1", task_id="task-input-1") tool_info = create_request_input_tool( task_id=task["id"], task_manager=task_manager, @@ -183,9 +181,7 @@ class TestTimeout: The Agent can then decide to proceed without the info or fail. """ - task = await task_manager.create_task( - flow_id="f", context_id="c", task_id="timeout-task" - ) + task = await task_manager.create_task(flow_id="f", context_id="c", task_id="timeout-task") tool_info = create_request_input_tool( task_id=task["id"], task_manager=task_manager, @@ -216,13 +212,12 @@ class TestMultipleRounds: Round 1: "Which env?" → "staging" Round 2: "Which region?" → "us-west-2" """ - task = await task_manager.create_task( - flow_id="f", context_id="c", task_id="multi-q" - ) + task = await task_manager.create_task(flow_id="f", context_id="c", task_id="multi-q") # Round 1 tool_info_1 = create_request_input_tool( - task_id=task["id"], task_manager=task_manager, + task_id=task["id"], + task_manager=task_manager, ) handler_1 = tool_info_1["handler"] future_1 = asyncio.create_task(handler_1(question="Which env?")) @@ -234,7 +229,8 @@ class TestMultipleRounds: # Round 2 — new tool instance (agent creates a new call) tool_info_2 = create_request_input_tool( - task_id=task["id"], task_manager=task_manager, + task_id=task["id"], + task_manager=task_manager, ) handler_2 = tool_info_2["handler"] future_2 = asyncio.create_task(handler_2(question="Which region?")) diff --git a/src/backend/tests/unit/api/a2a/test_smoke.py b/src/backend/tests/unit/api/a2a/test_smoke.py index 003fae7aa1..d912f36e3a 100644 --- a/src/backend/tests/unit/api/a2a/test_smoke.py +++ b/src/backend/tests/unit/api/a2a/test_smoke.py @@ -79,9 +79,7 @@ class TestA2ASmoke: description="An agent for the A2A smoke test", data=_make_agent_flow_data(), ) - create_resp = await client.post( - "api/v1/flows/", json=flow.model_dump(), headers=logged_in_headers - ) + create_resp = await client.post("api/v1/flows/", json=flow.model_dump(), headers=logged_in_headers) assert create_resp.status_code == 201 flow_id = create_resp.json()["id"] @@ -104,9 +102,7 @@ class TestA2ASmoke: assert config["a2a_agent_slug"] == "smoke-agent" # Verify config readback - config_get = await client.get( - f"api/v1/flows/{flow_id}/a2a-config", headers=logged_in_headers - ) + config_get = await client.get(f"api/v1/flows/{flow_id}/a2a-config", headers=logged_in_headers) assert config_get.status_code == 200 assert config_get.json()["a2a_name"] == "Smoke Agent" diff --git a/src/backend/tests/unit/api/a2a/test_streaming.py b/src/backend/tests/unit/api/a2a/test_streaming.py index 077dd32422..dd2184350c 100644 --- a/src/backend/tests/unit/api/a2a/test_streaming.py +++ b/src/backend/tests/unit/api/a2a/test_streaming.py @@ -87,9 +87,7 @@ class TestTokenStreaming: bridge = A2AStreamBridge(task_id="task-1", context_id="ctx-1") for token in ["Hello", " world", "!"]: - await bridge.process_langflow_event( - _make_langflow_event("token", {"chunk": token}) - ) + await bridge.process_langflow_event(_make_langflow_event("token", {"chunk": token})) events = await _collect_a2a_events(bridge, 3) assert len(events) == 3 @@ -113,9 +111,7 @@ class TestStatusTransitions: bridge = A2AStreamBridge(task_id="task-1", context_id="ctx-1") end_data = {"result": {"outputs": [{"inputs": {}, "outputs": []}]}} - await bridge.process_langflow_event( - _make_langflow_event("end", end_data) - ) + await bridge.process_langflow_event(_make_langflow_event("end", end_data)) events = await _collect_a2a_events(bridge, 1) assert len(events) >= 1 @@ -132,9 +128,7 @@ class TestStatusTransitions: """ bridge = A2AStreamBridge(task_id="task-1", context_id="ctx-1") - await bridge.process_langflow_event( - _make_langflow_event("error", {"error": "LLM rate limit exceeded"}) - ) + await bridge.process_langflow_event(_make_langflow_event("error", {"error": "LLM rate limit exceeded"})) events = await _collect_a2a_events(bridge, 1) assert len(events) == 1 @@ -149,9 +143,7 @@ class TestStatusTransitions: """ bridge = A2AStreamBridge(task_id="task-1", context_id="ctx-1") - await bridge.process_langflow_event( - _make_langflow_event("end_vertex", {"build_data": "vertex-abc"}) - ) + await bridge.process_langflow_event(_make_langflow_event("end_vertex", {"build_data": "vertex-abc"})) events = await _collect_a2a_events(bridge, 1) assert len(events) == 1 @@ -184,17 +176,11 @@ class TestStreamLifecycle: bridge = A2AStreamBridge(task_id="task-1", context_id="ctx-1") # Tokens - await bridge.process_langflow_event( - _make_langflow_event("token", {"chunk": "Hello"}) - ) - await bridge.process_langflow_event( - _make_langflow_event("token", {"chunk": " world"}) - ) + await bridge.process_langflow_event(_make_langflow_event("token", {"chunk": "Hello"})) + await bridge.process_langflow_event(_make_langflow_event("token", {"chunk": " world"})) # End - await bridge.process_langflow_event( - _make_langflow_event("end", {"result": {}}) - ) + await bridge.process_langflow_event(_make_langflow_event("end", {"result": {}})) events = await _collect_a2a_events(bridge, 10) @@ -213,9 +199,7 @@ class TestStreamLifecycle: """ bridge = A2AStreamBridge(task_id="task-1", context_id="ctx-1") - await bridge.process_langflow_event( - _make_langflow_event("build_start", {"vertex_id": "v1"}) - ) + await bridge.process_langflow_event(_make_langflow_event("build_start", {"vertex_id": "v1"})) # Should produce no output events events = await _collect_a2a_events(bridge, 1, timeout=0.1) diff --git a/src/backend/tests/unit/api/a2a/test_task_cleanup.py b/src/backend/tests/unit/api/a2a/test_task_cleanup.py index c55531f496..7abcb75c11 100644 --- a/src/backend/tests/unit/api/a2a/test_task_cleanup.py +++ b/src/backend/tests/unit/api/a2a/test_task_cleanup.py @@ -37,9 +37,7 @@ class TestTaskCleanup: await task_manager.update_state(task["id"], "completed") # Backdate the task - task_manager._tasks["old-done"]["_updated_at"] = ( - datetime.now(timezone.utc) - timedelta(hours=25) - ).isoformat() + task_manager._tasks["old-done"]["_updated_at"] = (datetime.now(timezone.utc) - timedelta(hours=25)).isoformat() pruned = await task_manager.cleanup_expired_tasks(ttl_seconds=86400) # 24h @@ -52,9 +50,7 @@ class TestTaskCleanup: await task_manager.update_state(task["id"], "working") await task_manager.update_state(task["id"], "failed", error="boom") - task_manager._tasks["old-fail"]["_updated_at"] = ( - datetime.now(timezone.utc) - timedelta(hours=25) - ).isoformat() + task_manager._tasks["old-fail"]["_updated_at"] = (datetime.now(timezone.utc) - timedelta(hours=25)).isoformat() pruned = await task_manager.cleanup_expired_tasks(ttl_seconds=86400) assert pruned == 1 @@ -97,9 +93,7 @@ class TestTaskCleanup: task = await task_manager.create_task(flow_id="f", context_id="c", task_id="old-ir") await task_manager.update_state(task["id"], "input-required") - task_manager._tasks["old-ir"]["_updated_at"] = ( - datetime.now(timezone.utc) - timedelta(hours=48) - ).isoformat() + task_manager._tasks["old-ir"]["_updated_at"] = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat() pruned = await task_manager.cleanup_expired_tasks(ttl_seconds=86400) assert pruned == 0 @@ -113,9 +107,7 @@ class TestTaskCleanup: # Old completed → prune t1 = await task_manager.create_task(flow_id="f", context_id="c", task_id="t1") await task_manager.update_state(t1["id"], "completed") - task_manager._tasks["t1"]["_updated_at"] = ( - datetime.now(timezone.utc) - timedelta(hours=25) - ).isoformat() + task_manager._tasks["t1"]["_updated_at"] = (datetime.now(timezone.utc) - timedelta(hours=25)).isoformat() # New completed → keep t2 = await task_manager.create_task(flow_id="f", context_id="c", task_id="t2") @@ -124,16 +116,12 @@ class TestTaskCleanup: # Old working → keep (active) t3 = await task_manager.create_task(flow_id="f", context_id="c", task_id="t3") await task_manager.update_state(t3["id"], "working") - task_manager._tasks["t3"]["_updated_at"] = ( - datetime.now(timezone.utc) - timedelta(hours=25) - ).isoformat() + task_manager._tasks["t3"]["_updated_at"] = (datetime.now(timezone.utc) - timedelta(hours=25)).isoformat() # Old canceled → prune t4 = await task_manager.create_task(flow_id="f", context_id="c", task_id="t4") await task_manager.update_state(t4["id"], "canceled") - task_manager._tasks["t4"]["_updated_at"] = ( - datetime.now(timezone.utc) - timedelta(hours=25) - ).isoformat() + task_manager._tasks["t4"]["_updated_at"] = (datetime.now(timezone.utc) - timedelta(hours=25)).isoformat() pruned = await task_manager.cleanup_expired_tasks(ttl_seconds=86400) assert pruned == 2 # t1 and t4 diff --git a/src/backend/tests/unit/api/a2a/test_task_manager.py b/src/backend/tests/unit/api/a2a/test_task_manager.py index 0d1fdd661c..19a2dc8ade 100644 --- a/src/backend/tests/unit/api/a2a/test_task_manager.py +++ b/src/backend/tests/unit/api/a2a/test_task_manager.py @@ -98,9 +98,7 @@ class TestStateTransitions: await task_manager.update_state(task["id"], "working") artifacts = [{"parts": [{"kind": "text", "text": "Result"}]}] - updated = await task_manager.update_state( - task["id"], "completed", artifacts=artifacts - ) + updated = await task_manager.update_state(task["id"], "completed", artifacts=artifacts) assert updated["status"]["state"] == "completed" assert updated["artifacts"] == artifacts @@ -110,9 +108,7 @@ class TestStateTransitions: task = await task_manager.create_task(flow_id="f", context_id="c") await task_manager.update_state(task["id"], "working") - updated = await task_manager.update_state( - task["id"], "failed", error="Flow execution failed: division by zero" - ) + updated = await task_manager.update_state(task["id"], "failed", error="Flow execution failed: division by zero") assert updated["status"]["state"] == "failed" assert "division by zero" in updated["status"]["message"]["parts"][0]["text"] @@ -192,9 +188,7 @@ class TestIdempotentRetry: This prevents duplicate execution when a client retries after a network timeout (the original request may have succeeded). """ - task = await task_manager.create_task( - flow_id="f", context_id="c", task_id="retry-me" - ) + task = await task_manager.create_task(flow_id="f", context_id="c", task_id="retry-me") await task_manager.update_state(task["id"], "working") artifacts = [{"parts": [{"kind": "text", "text": "Original result"}]}] await task_manager.update_state(task["id"], "completed", artifacts=artifacts) @@ -211,9 +205,7 @@ class TestIdempotentRetry: Failed tasks should be retried — the failure may have been transient (network error, rate limit, etc.). """ - task = await task_manager.create_task( - flow_id="f", context_id="c", task_id="retry-failed" - ) + task = await task_manager.create_task(flow_id="f", context_id="c", task_id="retry-failed") await task_manager.update_state(task["id"], "working") await task_manager.update_state(task["id"], "failed", error="Timeout") @@ -226,9 +218,7 @@ class TestIdempotentRetry: The client should see that the task is still in progress rather than starting a duplicate execution. """ - task = await task_manager.create_task( - flow_id="f", context_id="c", task_id="in-progress" - ) + task = await task_manager.create_task(flow_id="f", context_id="c", task_id="in-progress") await task_manager.update_state(task["id"], "working") existing = await task_manager.handle_retry("in-progress") diff --git a/src/lfx/src/lfx/base/agents/agent.py b/src/lfx/src/lfx/base/agents/agent.py index c7b86520a7..29c9db026e 100644 --- a/src/lfx/src/lfx/base/agents/agent.py +++ b/src/lfx/src/lfx/base/agents/agent.py @@ -143,10 +143,72 @@ class LCAgentComponent(Component): return messages + def _inject_a2a_tools(self) -> None: + """Inject the request_input tool when running under A2A execution. + + Checks graph.context for an "a2a" key set by the A2A router. + If present, creates and appends a request_input tool that allows + the LLM to ask the calling agent for clarification mid-execution. + + This method is called from build_agent() and run_agent() before + the AgentExecutor is created. It's a no-op when not running + under A2A (normal playground execution). + """ + # Check if we have a graph context with A2A execution info + if not hasattr(self, "graph") or self.graph is None: + return + + a2a_ctx = self.graph.context.get("a2a") if hasattr(self.graph, "context") else None + if not a2a_ctx: + return + + task_id = a2a_ctx.get("task_id") + task_manager = a2a_ctx.get("task_manager") + if not task_id or not task_manager: + return + + try: + from langflow.api.a2a.request_input_tool import create_request_input_tool + + tool_info = create_request_input_tool( + task_id=task_id, + task_manager=task_manager, + ) + + # Store the tool info on the context so the router can access + # the event and response_holder for follow-up resolution + a2a_ctx["request_input_tool_info"] = tool_info + + # Create a LangChain StructuredTool from our tool info + from langchain_core.tools import StructuredTool + from pydantic import BaseModel, Field + + class RequestInputSchema(BaseModel): + question: str = Field(description="The question to ask the calling agent") + + lc_tool = StructuredTool( + name=tool_info["name"], + description=tool_info["description"], + coroutine=tool_info["handler"], + args_schema=RequestInputSchema, + ) + + if self.tools is None: + self.tools = [] + self.tools.append(lc_tool) + + logger.info(f"A2A: Injected request_input tool for task {task_id}") + + except ImportError: + logger.debug("A2A request_input_tool not available — skipping injection") + except Exception: + logger.exception("Failed to inject A2A request_input tool") + async def run_agent( self, agent: Runnable | BaseSingleActionAgent | BaseMultiActionAgent | AgentExecutor, ) -> Message: + self._inject_a2a_tools() if isinstance(agent, AgentExecutor): runnable = agent else: @@ -322,6 +384,7 @@ class LCToolsAgentComponent(LCAgentComponent): def build_agent(self) -> AgentExecutor: self.validate_tool_names() + self._inject_a2a_tools() agent = self.create_agent_runnable() return AgentExecutor.from_agent_and_tools( agent=RunnableAgent(runnable=agent, input_keys_arg=["input"], return_keys_arg=["output"]), diff --git a/uv.lock b/uv.lock index b4f3957e8e..02037ce693 100644 --- a/uv.lock +++ b/uv.lock @@ -30,6 +30,22 @@ overrides = [ { name = "z3-solver", specifier = "<4.15.7" }, ] +[[package]] +name = "a2a-sdk" +version = "0.3.25" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "protobuf" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/83/3c99b276d09656cce039464509f05bf385e5600d6dc046a131bbcf686930/a2a_sdk-0.3.25.tar.gz", hash = "sha256:afda85bab8d6af0c5d15e82f326c94190f6be8a901ce562d045a338b7127242f", size = 270638, upload-time = "2026-03-10T13:08:46.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/f9/6a62520b7ecb945188a6e1192275f4732ff9341cd4629bc975a6c146aeab/a2a_sdk-0.3.25-py3-none-any.whl", hash = "sha256:2fce38faea82eb0b6f9f9c2bcf761b0d78612c80ef0e599b50d566db1b2654b5", size = 149609, upload-time = "2026-03-10T13:08:44.7Z" }, +] + [[package]] name = "accelerate" version = "1.13.0" @@ -3948,6 +3964,7 @@ dependencies = [ { name = "griffecli" }, { name = "griffelib" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/04/56/28a0accac339c164b52a92c6cfc45a903acc0c174caa5c1713803467b533/griffe-2.0.0.tar.gz", hash = "sha256:c68979cd8395422083a51ea7cf02f9c119d889646d99b7b656ee43725de1b80f", size = 293906, upload-time = "2026-03-23T21:06:53.402Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214, upload-time = "2026-02-09T19:09:44.105Z" }, ] @@ -3960,6 +3977,7 @@ dependencies = [ { name = "colorama" }, { name = "griffelib" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/a4/f8/2e129fd4a86e52e58eefe664de05e7d502decf766e7316cc9e70fdec3e18/griffecli-2.0.0.tar.gz", hash = "sha256:312fa5ebb4ce6afc786356e2d0ce85b06c1c20d45abc42d74f0cda65e159f6ef", size = 56213, upload-time = "2026-03-23T21:06:54.8Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345, upload-time = "2026-02-09T19:09:42.554Z" }, ] @@ -3968,6 +3986,7 @@ wheels = [ name = "griffelib" version = "2.0.0" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] @@ -5994,6 +6013,7 @@ name = "langflow-base" version = "0.9.0" source = { editable = "src/backend/base" } dependencies = [ + { name = "a2a-sdk" }, { name = "aiofile" }, { name = "aiofiles" }, { name = "aiosqlite" }, @@ -6686,6 +6706,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "a2a-sdk", specifier = ">=0.3.0,<1.0.0" }, { name = "ag2", marker = "extra == 'ag2'", specifier = ">=0.1.0" }, { name = "agent-lifecycle-toolkit", marker = "extra == 'altk'", specifier = "~=0.4.4" }, { name = "aioboto3", marker = "extra == 'aioboto3'", specifier = ">=15.2.0,<16.0.0" },