已合并
support multi-workflow adapter #321
xiongxing创建于 6月10日
support multi-workflow adapter #321
已合并
共 27 个文件变更+1006-932
| @@ -89,9 +89,6 @@ DPA_AGENT_ID=edp_agent | |||
| 89 | DPA_AGENT_NAME=EDP Agent | 89 | DPA_AGENT_NAME=EDP Agent |
| 90 | DPA_MAX_ITERATIONS=30 | 90 | DPA_MAX_ITERATIONS=30 |
| 91 | 91 | ||
| 92 | -# ── Versatile 工作流最终结果节点名(a2a_service/config.py 读取)─────────── | ||
| 93 | -va_workflow_result_node= | ||
| 94 | - | ||
| 95 | # ── 沙箱配置 ── | 92 | # ── 沙箱配置 ── |
| 96 | # 沙箱服务地址 | 93 | # 沙箱服务地址 |
| 97 | SANDBOX_URL= | 94 | SANDBOX_URL= |
| @@ -28,6 +28,7 @@ from a2a.server.context import ServerCallContext | |||
| 28 | from a2a.server.events import EventQueue | 28 | from a2a.server.events import EventQueue |
| 29 | from a2a.types.a2a_pb2 import ( | 29 | from a2a.types.a2a_pb2 import ( |
| 30 | Message, | 30 | Message, |
| 31 | + Artifact, | ||
| 31 | Part, | 32 | Part, |
| 32 | SendMessageRequest, | 33 | SendMessageRequest, |
| 33 | Task, | 34 | Task, |
| @@ -41,7 +42,7 @@ from a2a.types.a2a_pb2 import ( | |||
| 41 | TASK_STATE_INPUT_REQUIRED, | 42 | TASK_STATE_INPUT_REQUIRED, |
| 42 | TASK_STATE_WORKING, | 43 | TASK_STATE_WORKING, |
| 43 | ) | 44 | ) |
| 44 | -from google.protobuf.json_format import MessageToDict | 45 | +from google.protobuf.json_format import MessageToDict, ParseDict |
| 45 | from google.protobuf.struct_pb2 import Struct, Value | 46 | from google.protobuf.struct_pb2 import Struct, Value |
| 46 | from loguru import logger | 47 | from loguru import logger |
| 47 | 48 | ||
| @@ -390,43 +391,12 @@ class Executor(AgentExecutor): | |||
| 390 | return SendMessageRequest(message=msg) | 391 | return SendMessageRequest(message=msg) |
| 391 | 392 | ||
| 392 | def _parse_stream_event(self, stream_resp): | 393 | def _parse_stream_event(self, stream_resp): |
| 393 | - which = ( | 394 | + if stream_resp.HasField("artifact_update"): |
| 394 | - stream_resp.WhichOneof("payload") | ||
| 395 | - if hasattr(stream_resp, "WhichOneof") | ||
| 396 | - else None | ||
| 397 | - ) | ||
| 398 | - if which == "artifact_update": | ||
| 399 | return stream_resp.artifact_update | 395 | return stream_resp.artifact_update |
| 400 | - if which == "status_update": | 396 | + if stream_resp.HasField("status_update"): |
| 401 | return stream_resp.status_update | 397 | return stream_resp.status_update |
| 402 | return None | 398 | return None |
| 403 | 399 | ||
| 404 | - def _extract_node_data( | ||
| 405 | - self, event: TaskArtifactUpdateEvent | ||
| 406 | - ) -> Optional[dict]: | ||
| 407 | - """从 VersatileAdapter 解包后的 artifact 取出节点数据。 | ||
| 408 | - | ||
| 409 | - data part 形状:``{"event": "<kind>", "data": <node_data>}`` | ||
| 410 | - —— 只对 ``event == "message"`` 的帧返回 node_data,其他(如 "end")返回 None。 | ||
| 411 | - """ | ||
| 412 | - for part in event.artifact.parts: | ||
| 413 | - if part.WhichOneof("content") == "data": | ||
| 414 | - frame = MessageToDict(part.data) | ||
| 415 | - if not isinstance(frame, dict): | ||
| 416 | - continue | ||
| 417 | - if frame.get("event") != "message": | ||
| 418 | - continue | ||
| 419 | - inner = frame.get("data") | ||
| 420 | - if isinstance(inner, dict): | ||
| 421 | - return inner | ||
| 422 | - return None | ||
| 423 | - | ||
| 424 | - def _extract_end_node(self, event: TaskArtifactUpdateEvent) -> Optional[dict]: | ||
| 425 | - node = self._extract_node_data(event) | ||
| 426 | - if node is not None and node.get("node_type") == "End": | ||
| 427 | - return node | ||
| 428 | - return None | ||
| 429 | - | ||
| 430 | def _extract_upstream_error(self, event: TaskArtifactUpdateEvent) -> Optional[dict]: | 400 | def _extract_upstream_error(self, event: TaskArtifactUpdateEvent) -> Optional[dict]: |
| 431 | """识别 VA 上游错误终态帧。 | 401 | """识别 VA 上游错误终态帧。 |
| 432 | 402 | ||
| @@ -507,25 +477,57 @@ class Executor(AgentExecutor): | |||
| 507 | f"msg={(upstream_error.get('message') or '')!r:.80}" | 477 | f"msg={(upstream_error.get('message') or '')!r:.80}" |
| 508 | ) | 478 | ) |
| 509 | 479 | ||
| 510 | - def _is_suppressed_node(self, event: TaskArtifactUpdateEvent) -> bool: | 480 | + def _extract_workflow_result(self, event: TaskStatusUpdateEvent) -> Optional[str]: |
| 511 | - """判断该 artifact 是否为配置中需要屏蔽的节点(不推送给用户)。""" | 481 | + """从 VA 的 COMPLETED 状态事件 message 中提取 workflow_result。 |
| 512 | - target = get_settings().va_workflow_result_node | ||
| 513 | - if not target: | ||
| 514 | - return False | ||
| 515 | - node = self._extract_node_data(event) | ||
| 516 | - return node is not None and node.get("node_name") == target | ||
| 517 | 482 | ||
| 518 | - def _extract_qa_node(self, event: TaskArtifactUpdateEvent) -> Optional[str]: | 483 | + VA 侧在 updater.complete(message) 时通过 Part 的 metadata {"vatype": "workflow_result"} 标识, |
| 519 | - target_node = get_settings().va_workflow_result_node | 484 | + DataPart 为纯文本 string_value。 |
| 520 | - if not target_node: | 485 | + """ |
| 486 | + if not event.status or not event.status.message: | ||
| 521 | return None | 487 | return None |
| 522 | - node = self._extract_node_data(event) | 488 | + for part in event.status.message.parts: |
| 523 | - if node is None: | 489 | + if not part.HasField("text") or not part.HasField("metadata"): |
| 524 | - return None | 490 | + continue |
| 525 | - if node.get("node_type") == "QA" and node.get("node_name") == target_node: | 491 | + meta = MessageToDict(part.metadata) |
| 526 | - return node.get("text", "") or None | 492 | + if meta.get("vatype") == "workflow_result": |
| 493 | + return part.text | ||
| 527 | return None | 494 | return None |
| 528 | 495 | ||
| 496 | + async def _forward_artifact( | ||
| 497 | + self, turn_ctx: _TurnContext, event: TaskArtifactUpdateEvent, event_queue: EventQueue, | ||
| 498 | + ) -> None: | ||
| 499 | + """转发 VA artifact 事件:将 text Part 转换为 data Part 后入队。""" | ||
| 500 | + parts = [] | ||
| 501 | + for part in event.artifact.parts: | ||
| 502 | + if not part.HasField("text") or not part.HasField("metadata"): | ||
| 503 | + continue | ||
| 504 | + meta = MessageToDict(part.metadata) | ||
| 505 | + if meta.get("vatype") != "data_proxy": | ||
| 506 | + continue | ||
| 507 | + try: | ||
| 508 | + parsed = json.loads(part.text) | ||
| 509 | + except ValueError: | ||
| 510 | + logger.error("[Executor] [VersatileProxy] 待转发内容不是结构化信息,跳过不处理") | ||
| 511 | + continue | ||
| 512 | + new_part = Part(data=ParseDict(parsed, Value()), media_type=part.media_type) | ||
| 513 | + parts.append(new_part) | ||
| 514 | + | ||
| 515 | + await event_queue.enqueue_event( | ||
| 516 | + TaskArtifactUpdateEvent( | ||
| 517 | + task_id=turn_ctx.task_id, | ||
| 518 | + context_id=turn_ctx.conv_id, | ||
| 519 | + artifact=Artifact( | ||
| 520 | + artifact_id=event.artifact.artifact_id, | ||
| 521 | + name=event.artifact.name, | ||
| 522 | + parts=parts, | ||
| 523 | + metadata=event.artifact.metadata, | ||
| 524 | + extensions=event.artifact.extensions, | ||
| 525 | + ), | ||
| 526 | + append=event.append, | ||
| 527 | + last_chunk=event.last_chunk, | ||
| 528 | + ) | ||
| 529 | + ) | ||
| 530 | + | ||
| 529 | async def _call_versatile_adapter( | 531 | async def _call_versatile_adapter( |
| 530 | self, | 532 | self, |
| 531 | turn_ctx: _TurnContext, | 533 | turn_ctx: _TurnContext, |
| @@ -584,7 +586,7 @@ class Executor(AgentExecutor): | |||
| 584 | 586 | ||
| 585 | va_real_task_id: Optional[str] = None | 587 | va_real_task_id: Optional[str] = None |
| 586 | continuation_task_id = "" | 588 | continuation_task_id = "" |
| 587 | - | 589 | + |
| 588 | # 从 delegate.target_agent 获取 agent_id | 590 | # 从 delegate.target_agent 获取 agent_id |
| 589 | agent_id = delegate.target_agent or "" | 591 | agent_id = delegate.target_agent or "" |
| 590 | 592 | ||
| @@ -602,12 +604,10 @@ class Executor(AgentExecutor): | |||
| 602 | ) | 604 | ) |
| 603 | 605 | ||
| 604 | has_end_node = False | 606 | has_end_node = False |
| 605 | - final_result: dict | None = None | ||
| 606 | qa_result: Optional[str] = None | 607 | qa_result: Optional[str] = None |
| 607 | upstream_error: Optional[dict] = None | 608 | upstream_error: Optional[dict] = None |
| 608 | stream_resp_count = 0 | 609 | stream_resp_count = 0 |
| 609 | forwarded_count = 0 | 610 | forwarded_count = 0 |
| 610 | - suppressed_count = 0 | ||
| 611 | logger.info( | 611 | logger.info( |
| 612 | f"[Executor] [VersatileProxy] 开始调用 VA: conv={conv_id}, " | 612 | f"[Executor] [VersatileProxy] 开始调用 VA: conv={conv_id}, " |
| 613 | f"intent={delegate.intent}, task_desc={delegate.task_description!r:.60}" | 613 | f"intent={delegate.intent}, task_desc={delegate.task_description!r:.60}" |
| @@ -654,43 +654,26 @@ class Executor(AgentExecutor): | |||
| 654 | ) | 654 | ) |
| 655 | 655 | ||
| 656 | if isinstance(event, TaskArtifactUpdateEvent): | 656 | if isinstance(event, TaskArtifactUpdateEvent): |
| 657 | - if self._is_suppressed_node(event): | 657 | + # data_proxy → 转换 text Part 后转发前端 |
| 658 | - suppressed_count += 1 | 658 | + await self._forward_artifact(turn_ctx, event, event_queue) |
| 659 | - logger.debug( | 659 | + forwarded_count += 1 |
| 660 | - f"[Executor] [VersatileProxy] chunk #{stream_resp_count} " | ||
| 661 | - f"命中 va_workflow_result_node,抑制不推送" | ||
| 662 | - ) | ||
| 663 | - else: | ||
| 664 | - await event_queue.enqueue_event(event) | ||
| 665 | - forwarded_count += 1 | ||
| 666 | - logger.debug( | ||
| 667 | - f"[Executor] [VersatileProxy] chunk #{stream_resp_count} " | ||
| 668 | - f"已转发到 event_queue" | ||
| 669 | - ) | ||
| 670 | 660 | ||
| 671 | - qa = self._extract_qa_node(event) | 661 | + elif isinstance(event, TaskStatusUpdateEvent): |
| 672 | - if qa is not None: | 662 | + if event.status.state == TASK_STATE_COMPLETED: |
| 673 | - qa_result = qa | ||
| 674 | - logger.debug( | ||
| 675 | - f"[Executor] [VersatileProxy] 提取到 QA 节点 text: " | ||
| 676 | - f"{qa!r:.60}" | ||
| 677 | - ) | ||
| 678 | - | ||
| 679 | - result = self._extract_end_node(event) | ||
| 680 | - if result is not None: | ||
| 681 | has_end_node = True | 663 | has_end_node = True |
| 682 | - final_result = result | 664 | + # 从 COMPLETED 状态事件的 message 中提取 workflow_result |
| 683 | - logger.debug( | 665 | + wr = self._extract_workflow_result(event) |
| 684 | - "[Executor] [VersatileProxy] 检测到 End node,将进入 cascade 路径" | 666 | + if wr is not None: |
| 685 | - ) | 667 | + qa_result = wr |
| 686 | - | ||
| 687 | - if upstream_error is None: | ||
| 688 | - err = self._extract_upstream_error(event) | ||
| 689 | - if err is not None: | ||
| 690 | - upstream_error = err | ||
| 691 | logger.debug( | 668 | logger.debug( |
| 692 | - "[Executor] [VersatileProxy] 检测到 VA 上游错误终态帧" | 669 | + f"[Executor] [VersatileProxy] chunk #{stream_resp_count} " |
| 670 | + f"status COMPLETED, workflow_result={wr!r:.60}" | ||
| 693 | ) | 671 | ) |
| 672 | + logger.debug("[Executor] VA TaskStatusUpdateEvent(COMPLETED)") | ||
| 673 | + elif event.status.state == TASK_STATE_FAILED: | ||
| 674 | + if upstream_error is None: | ||
| 675 | + upstream_error = {"message": "VA 任务异常终止"} | ||
| 676 | + logger.debug("[Executor] VA TaskStatusUpdateEvent(FAILED)") | ||
| 694 | 677 | ||
| 695 | except Exception as e: | 678 | except Exception as e: |
| 696 | status_message = 1 | 679 | status_message = 1 |
| @@ -722,7 +705,7 @@ class Executor(AgentExecutor): | |||
| 722 | 705 | ||
| 723 | if has_end_node: | 706 | if has_end_node: |
| 724 | cascade = ( | 707 | cascade = ( |
| 725 | - {"workflow_result": qa_result} if qa_result is not None else final_result | 708 | + {"workflow_result": qa_result} if qa_result is not None else {} |
| 726 | ) | 709 | ) |
| 727 | logger.info( | 710 | logger.info( |
| 728 | f"[Executor] VA end node: conv={conv_id}, qa_result={qa_result!r:.60}" | 711 | f"[Executor] VA end node: conv={conv_id}, qa_result={qa_result!r:.60}" |
| @@ -768,7 +751,7 @@ class Executor(AgentExecutor): | |||
| 768 | call_started_ms = int(time.time() * 1000) | 751 | call_started_ms = int(time.time() * 1000) |
| 769 | status_message = 0 | 752 | status_message = 0 |
| 770 | error_message: Optional[str] = None | 753 | error_message: Optional[str] = None |
| 771 | - | 754 | + |
| 772 | request = self._build_va_message( | 755 | request = self._build_va_message( |
| 773 | _VaRequestPayload( | 756 | _VaRequestPayload( |
| 774 | query=user_input, | 757 | query=user_input, |
| @@ -783,7 +766,6 @@ class Executor(AgentExecutor): | |||
| 783 | ) | 766 | ) |
| 784 | 767 | ||
| 785 | has_end_node = False | 768 | has_end_node = False |
| 786 | - final_result: dict | None = None | ||
| 787 | qa_result: Optional[str] = None | 769 | qa_result: Optional[str] = None |
| 788 | upstream_error: Optional[dict] = None | 770 | upstream_error: Optional[dict] = None |
| 789 | stream_resp_count = 0 | 771 | stream_resp_count = 0 |
| @@ -820,22 +802,21 @@ class Executor(AgentExecutor): | |||
| 820 | _log_va_chunk_debug(stream_resp_count, event) | 802 | _log_va_chunk_debug(stream_resp_count, event) |
| 821 | 803 | ||
| 822 | if isinstance(event, TaskArtifactUpdateEvent): | 804 | if isinstance(event, TaskArtifactUpdateEvent): |
| 823 | - if not self._is_suppressed_node(event): | 805 | + # data_proxy → 转换 text Part 后转发前端 |
| 824 | - await event_queue.enqueue_event(event) | 806 | + await self._forward_artifact(turn_ctx, event, event_queue) |
| 825 | 807 | ||
| 826 | - qa = self._extract_qa_node(event) | 808 | + elif isinstance(event, TaskStatusUpdateEvent): |
| 827 | - if qa is not None: | 809 | + if event.status.state == TASK_STATE_COMPLETED: |
| 828 | - qa_result = qa | ||
| 829 | - | ||
| 830 | - result = self._extract_end_node(event) | ||
| 831 | - if result is not None: | ||
| 832 | has_end_node = True | 810 | has_end_node = True |
| 833 | - final_result = result | 811 | + # 从 COMPLETED 状态事件的 message 中提取 workflow_result |
| 834 | - | 812 | + wr = self._extract_workflow_result(event) |
| 835 | - if upstream_error is None: | 813 | + if wr is not None: |
| 836 | - err = self._extract_upstream_error(event) | 814 | + qa_result = wr |
| 837 | - if err is not None: | 815 | + logger.debug("[Executor] VA 续轮 TaskStatusUpdateEvent(COMPLETED)") |
| 838 | - upstream_error = err | 816 | + elif event.status.state == TASK_STATE_FAILED: |
| 817 | + if upstream_error is None: | ||
| 818 | + upstream_error = {"message": "VA 任务异常终止"} | ||
| 819 | + logger.debug("[Executor] VA 续轮 TaskStatusUpdateEvent(FAILED)") | ||
| 839 | 820 | ||
| 840 | except Exception as e: | 821 | except Exception as e: |
| 841 | status_message = 1 | 822 | status_message = 1 |
| @@ -866,7 +847,7 @@ class Executor(AgentExecutor): | |||
| 866 | 847 | ||
| 867 | if has_end_node: | 848 | if has_end_node: |
| 868 | cascade = ( | 849 | cascade = ( |
| 869 | - {"workflow_result": qa_result} if qa_result is not None else final_result | 850 | + {"workflow_result": qa_result} if qa_result is not None else {} |
| 870 | ) | 851 | ) |
| 871 | logger.info( | 852 | logger.info( |
| 872 | f"[Executor] VA 续轮 end node: conv={conv_id}, qa_result={qa_result!r:.60}" | 853 | f"[Executor] VA 续轮 end node: conv={conv_id}, qa_result={qa_result!r:.60}" |
| @@ -14,12 +14,21 @@ VERSATILE_TIMEOUT=600 | |||
| 14 | # 会报 ValidationError 导致 VA 启动失败。要么保留注释、要么提供合法 JSON。 | 14 | # 会报 ValidationError 导致 VA 启动失败。要么保留注释、要么提供合法 JSON。 |
| 15 | # VERSATILE_HEADERS_TEMPLATE={"Cookie":"AGENT_SID=<token>","Accept":"application/json, text/event-stream","stream":"true"} | 15 | # VERSATILE_HEADERS_TEMPLATE={"Cookie":"AGENT_SID=<token>","Accept":"application/json, text/event-stream","stream":"true"} |
| 16 | 16 | ||
| 17 | +# 后端类型:controller(一级控制器)或 workflow(低码工作流) | ||
| 18 | +# VERSATILE_BACKEND_TYPE=controller | ||
| 19 | + | ||
| 20 | +# 需要屏蔽的工作流结果节点名(node_type=="QA" 且 node_name==此值的帧被提取为 workflow_result) | ||
| 21 | +# VERSATILE_WORKFLOW_RESULT_NODE=GXZQAResponseNode | ||
| 22 | + | ||
| 17 | # ── FastAPI ─────────────────────────────────────────────────────────────────── | 23 | # ── FastAPI ─────────────────────────────────────────────────────────────────── |
| 18 | ADAPTER_FASTAPI_HOST=0.0.0.0 | 24 | ADAPTER_FASTAPI_HOST=0.0.0.0 |
| 19 | ADAPTER_FASTAPI_PORT=8091 | 25 | ADAPTER_FASTAPI_PORT=8091 |
| 20 | ADAPTER_FASTAPI_DEBUG=False | 26 | ADAPTER_FASTAPI_DEBUG=False |
| 21 | ADAPTER_FASTAPI_WORKERS=1 | 27 | ADAPTER_FASTAPI_WORKERS=1 |
| 22 | 28 | ||
| 29 | +# ── Versatile 工作流最终结果节点名 ─────────── | ||
| 30 | +va_workflow_result_node= | ||
| 31 | + | ||
| 23 | # ── Log ─────────────────────────────────────────────────────────────────────── | 32 | # ── Log ─────────────────────────────────────────────────────────────────────── |
| 24 | ADAPTER_LOG_LEVEL=DEBUG | 33 | ADAPTER_LOG_LEVEL=DEBUG |
| 25 | ADAPTER_LOG_FILE=logs/versatile_adapter.log | 34 | ADAPTER_LOG_FILE=logs/versatile_adapter.log |
Rapplications/versatile_adapter/adapter/__init__.py→applications/versatile_adapter/a2a_facade/__init__.py+0-0
文件重命名但无更改。
Rapplications/versatile_adapter/adapter/agent_card.py→applications/versatile_adapter/a2a_facade/agent_card.py+0-0
文件重命名但无更改。
| @@ -0,0 +1,162 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +""" | ||
| 5 | +A2aVersatileExecutor — A2A 薄壳适配层。 | ||
| 6 | + | ||
| 7 | +纯 A2A 协议适配:不包含业务逻辑。 | ||
| 8 | +- 从 A2A RequestContext 解析 input_data(target/body/headers/params) | ||
| 9 | +- 调用 VersatileAdapterRunner.run_async() 获取 AdapterEvent | ||
| 10 | +- 基于 AdapterEvent 类型做 A2A 协议映射 | ||
| 11 | +""" | ||
| 12 | +from __future__ import annotations | ||
| 13 | + | ||
| 14 | +import uuid | ||
| 15 | + | ||
| 16 | +from google.protobuf.json_format import MessageToDict, ParseDict | ||
| 17 | +from google.protobuf import struct_pb2 | ||
| 18 | +from typing_extensions import override | ||
| 19 | + | ||
| 20 | +from a2a.server.agent_execution import AgentExecutor, RequestContext | ||
| 21 | +from a2a.server.events import EventQueue | ||
| 22 | +from a2a.server.tasks import TaskUpdater | ||
| 23 | +from a2a.helpers import ( | ||
| 24 | + new_message | ||
| 25 | +) | ||
| 26 | +from a2a.types.a2a_pb2 import ( | ||
| 27 | + Message, | ||
| 28 | + Part, | ||
| 29 | + Task, | ||
| 30 | + TaskState, | ||
| 31 | + TaskStatus, | ||
| 32 | + TaskStatusUpdateEvent, | ||
| 33 | + TASK_STATE_COMPLETED, | ||
| 34 | + TASK_STATE_FAILED, | ||
| 35 | + TASK_STATE_INPUT_REQUIRED, | ||
| 36 | + ROLE_AGENT, | ||
| 37 | +) | ||
| 38 | +from loguru import logger | ||
| 39 | + | ||
| 40 | +from dispatcher.runner import VersatileAdapterRunner | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +class A2aVersatileExecutor(AgentExecutor): | ||
| 44 | + """A2A 协议薄壳:AdapterEvent → A2A Artifact/Status 映射。""" | ||
| 45 | + | ||
| 46 | + def __init__(self, runner: VersatileAdapterRunner) -> None: | ||
| 47 | + self._runner = runner | ||
| 48 | + | ||
| 49 | + async def _setup_task( | ||
| 50 | + self, context: RequestContext, event_queue: EventQueue | ||
| 51 | + ) -> TaskUpdater: | ||
| 52 | + if context.current_task is None: | ||
| 53 | + task = Task(id=context.task_id, context_id=context.context_id, | ||
| 54 | + status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED), history=[context.message]) | ||
| 55 | + await event_queue.enqueue_event(task) | ||
| 56 | + else: | ||
| 57 | + task = context.current_task | ||
| 58 | + updater = TaskUpdater(event_queue, task.id, task.context_id) | ||
| 59 | + await updater.start_work() | ||
| 60 | + return updater | ||
| 61 | + | ||
| 62 | + | ||
| 63 | + async def execute( | ||
| 64 | + self, context: RequestContext, event_queue: EventQueue | ||
| 65 | + ) -> None: | ||
| 66 | + if not context.message or not context.context_id or not context.task_id: | ||
| 67 | + return | ||
| 68 | + | ||
| 69 | + input_data = self._build_first_input(context.message) | ||
| 70 | + log_kw = self._extract_logging_context(input_data, context.context_id) | ||
| 71 | + runner_kw = self._extract_runner_kwargs(input_data, context.context_id) | ||
| 72 | + | ||
| 73 | + with logger.contextualize(**log_kw): | ||
| 74 | + logger.info(f"[A2aVA] execute: conv_id={context.context_id}, task_id={context.task_id}") | ||
| 75 | + | ||
| 76 | + updater = await self._setup_task(context, event_queue) | ||
| 77 | + message = None | ||
| 78 | + is_failed = False | ||
| 79 | + | ||
| 80 | + try: | ||
| 81 | + async for event in self._runner.run_async(**runner_kw): | ||
| 82 | + if event.data_proxy is not None: | ||
| 83 | + part = self._make_text_part(event.data_proxy.raw_data, "data_proxy") | ||
| 84 | + await updater.add_artifact(parts=[part]) | ||
| 85 | + | ||
| 86 | + elif event.execution_input_required is not None: | ||
| 87 | + await updater.requires_input() | ||
| 88 | + return | ||
| 89 | + | ||
| 90 | + elif event.execution_completed is not None: | ||
| 91 | + is_failed = event.execution_completed.is_failed | ||
| 92 | + if event.execution_completed.result: | ||
| 93 | + part = self._make_text_part(event.execution_completed.result, "workflow_result") | ||
| 94 | + message = new_message(parts=[part]) | ||
| 95 | + | ||
| 96 | + if is_failed: | ||
| 97 | + await updater.failed() | ||
| 98 | + logger.info(f"[A2aVA] 流异常结束(failed): conv_id={context.context_id}, task_id={context.task_id}") | ||
| 99 | + else: | ||
| 100 | + await updater.complete(message) | ||
| 101 | + logger.info(f"[A2aVA] 流结束: conv_id={context.context_id}, task_id={context.task_id}") | ||
| 102 | + | ||
| 103 | + except Exception: | ||
| 104 | + logger.exception(f"[A2aVA] 流异常: conv_id={context.context_id}, task_id={context.task_id}") | ||
| 105 | + await updater.failed() | ||
| 106 | + return | ||
| 107 | + | ||
| 108 | + | ||
| 109 | + async def cancel( | ||
| 110 | + self, context: RequestContext, event_queue: EventQueue | ||
| 111 | + ) -> None: | ||
| 112 | + conv_id = context.context_id | ||
| 113 | + task_id = context.task_id | ||
| 114 | + input_data = self._build_first_input(context.message) | ||
| 115 | + agent_id = input_data.get("agent_id", "") | ||
| 116 | + | ||
| 117 | + updater = TaskUpdater(event_queue, task_id, conv_id) | ||
| 118 | + await updater.cancel() | ||
| 119 | + logger.info(f"[A2aVA] 任务已取消: conv_id={conv_id}, task_id={task_id}, agent_id={agent_id}") | ||
| 120 | + | ||
| 121 | + def _make_text_part( | ||
| 122 | + self, text: str, vatype: str | None = None, media_type: str | None = None, | ||
| 123 | + ) -> Part: | ||
| 124 | + """构造 text Part,可选附带 vatype metadata。""" | ||
| 125 | + metadata = {"vatype": vatype} if vatype else None | ||
| 126 | + part = Part(text=text, media_type=media_type or '', metadata=metadata) | ||
| 127 | + return part | ||
| 128 | + | ||
| 129 | + def _build_first_input(self, message) -> dict: | ||
| 130 | + for part in message.parts: | ||
| 131 | + if part.WhichOneof("content") == "data": | ||
| 132 | + data = MessageToDict(part.data) | ||
| 133 | + if isinstance(data, dict) and data: | ||
| 134 | + return data | ||
| 135 | + text = "" | ||
| 136 | + for part in message.parts: | ||
| 137 | + if part.WhichOneof("content") == "text" and part.text: | ||
| 138 | + text = part.text | ||
| 139 | + break | ||
| 140 | + if not text: | ||
| 141 | + logger.warning("[A2aVA] message 中未提取到 data/text part,使用空查询兜底") | ||
| 142 | + return {"body": {"input": {"query": text}}, "headers": {}, "params": {}} | ||
| 143 | + | ||
| 144 | + | ||
| 145 | + def _extract_logging_context(input_data: dict, conv_id: str) -> dict: | ||
| 146 | + return { | ||
| 147 | + "trace_id": input_data.get("trace_id", ""), | ||
| 148 | + "agent_id": input_data.get("agent_id", ""), | ||
| 149 | + "conv_id": conv_id, | ||
| 150 | + } | ||
| 151 | + | ||
| 152 | + | ||
| 153 | + def _extract_runner_kwargs(input_data: dict, conv_id: str) -> dict: | ||
| 154 | + target = input_data.get("target", {}) | ||
| 155 | + if "conversation_id" not in target and conv_id: | ||
| 156 | + target = {**target, "conversation_id": conv_id} | ||
| 157 | + return { | ||
| 158 | + "target": target, | ||
| 159 | + "body": input_data.get("body", {}), | ||
| 160 | + "headers": input_data.get("headers", {}), | ||
| 161 | + "params": input_data.get("params", {}), | ||
| 162 | + } | ||
| @@ -1,195 +0,0 @@ | |||
| 1 | -# coding: utf-8 | ||
| 2 | -# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | - | ||
| 4 | -""" | ||
| 5 | -VersatileAdapterExecutor — 无 LLM 的 A2A 执行器(a2a-sdk 1.0.0-alpha.1)。 | ||
| 6 | - | ||
| 7 | -实现 A2A SDK 的 AgentExecutor 接口: | ||
| 8 | - - execute(context, event_queue): 接收任务 → 调用 VersatileProxy → 推送流式事件 | ||
| 9 | - - cancel(context, event_queue): 取消任务 | ||
| 10 | - | ||
| 11 | -纯透传层:不做 end 节点判断,不做状态管理,不存任何上下文。 | ||
| 12 | -""" | ||
| 13 | -from __future__ import annotations | ||
| 14 | - | ||
| 15 | -import asyncio | ||
| 16 | -import uuid | ||
| 17 | - | ||
| 18 | -from google.protobuf.json_format import MessageToDict, ParseDict | ||
| 19 | -from google.protobuf.struct_pb2 import Value as ProtoValue | ||
| 20 | -from typing_extensions import override | ||
| 21 | - | ||
| 22 | -from a2a.server.agent_execution import AgentExecutor, RequestContext | ||
| 23 | -from a2a.server.events import EventQueue | ||
| 24 | -from a2a.server.tasks import TaskStore, TaskUpdater | ||
| 25 | -from a2a.types.a2a_pb2 import Part, Role | ||
| 26 | - | ||
| 27 | -from loguru import logger | ||
| 28 | - | ||
| 29 | -from adapter.versatile_proxy import VersatileProxy | ||
| 30 | - | ||
| 31 | -_CLEANUP_DELAY_SECONDS = 60.0 | ||
| 32 | - | ||
| 33 | - | ||
| 34 | -async def _delayed_delete_task( | ||
| 35 | - task_store: TaskStore, task_id: str, call_context, | ||
| 36 | -) -> None: | ||
| 37 | - await asyncio.sleep(_CLEANUP_DELAY_SECONDS) | ||
| 38 | - try: | ||
| 39 | - await task_store.delete(task_id, call_context) | ||
| 40 | - logger.info(f"[VersatileAdapter] Task 已从 TaskStore 删除:task_id={task_id}") | ||
| 41 | - except Exception: | ||
| 42 | - logger.exception(f"[VersatileAdapter] Task 删除失败:task_id={task_id}") | ||
| 43 | - | ||
| 44 | - | ||
| 45 | -class VersatileAdapterExecutor(AgentExecutor): # noqa: F821 | ||
| 46 | - """VersatileAdapter A2A 执行器。""" | ||
| 47 | - | ||
| 48 | - def __init__( | ||
| 49 | - self, | ||
| 50 | - versatile_proxy: VersatileProxy, | ||
| 51 | - task_store: TaskStore, | ||
| 52 | - ) -> None: | ||
| 53 | - self._proxy = versatile_proxy | ||
| 54 | - self._task_store = task_store | ||
| 55 | - | ||
| 56 | - | ||
| 57 | - async def execute( | ||
| 58 | - self, context: RequestContext, event_queue: EventQueue | ||
| 59 | - ) -> None: | ||
| 60 | - task_id = context.task_id | ||
| 61 | - conv_id = context.context_id | ||
| 62 | - | ||
| 63 | - # ── 直接使用传过来的请求头和请求体 ──────────────────────────────── | ||
| 64 | - input_data = self._build_first_input(context.message) | ||
| 65 | - versatile_input = input_data.get("body", {}) | ||
| 66 | - headers = input_data.get("headers", {}) | ||
| 67 | - params = input_data.get("params", {}) | ||
| 68 | - trace_id = input_data.get("trace_id", "") | ||
| 69 | - agent_id = input_data.get("agent_id", "") | ||
| 70 | - | ||
| 71 | - # 将 trace_id、agent_id 和 conv_id 打在日志中 | ||
| 72 | - with logger.contextualize(trace_id=trace_id, agent_id=agent_id, conv_id=conv_id): | ||
| 73 | - | ||
| 74 | - logger.info( | ||
| 75 | - f"[VersatileAdapter] execute:conv_id={conv_id}, task_id={task_id}, agent_id={agent_id}" | ||
| 76 | - ) | ||
| 77 | - | ||
| 78 | - # 先发送 Task 事件(a2a-sdk 1.0.0 要求) | ||
| 79 | - # 注:wyt 在 backup_enhancement 分支删除了这段,原因未说明; | ||
| 80 | - # 已记录在 docs/issue-wyt-merge-decisions.md V-1 待核实 a2a-sdk 版本与协议要求。 | ||
| 81 | - from a2a.types.a2a_pb2 import Task, TaskStatus, TaskState | ||
| 82 | - user_message = context.message | ||
| 83 | - if task_id and conv_id and user_message: | ||
| 84 | - await event_queue.enqueue_event( | ||
| 85 | - Task( | ||
| 86 | - id=task_id, | ||
| 87 | - context_id=conv_id, | ||
| 88 | - status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED), | ||
| 89 | - history=[user_message], | ||
| 90 | - ) | ||
| 91 | - ) | ||
| 92 | - | ||
| 93 | - updater = TaskUpdater(event_queue, task_id, conv_id) | ||
| 94 | - | ||
| 95 | - | ||
| 96 | - logger.info(f"[VersatileAdapter] 接收请求:conv_id={conv_id}, task_id={task_id}, agent_id={agent_id}") | ||
| 97 | - logger.debug(f"[VersatileAdapter] 请求头:{headers}") | ||
| 98 | - logger.debug(f"[VersatileAdapter] 请求体:{versatile_input}") | ||
| 99 | - logger.debug(f"[VersatileAdapter] 请求参数:{params}") | ||
| 100 | - | ||
| 101 | - await updater.start_work() | ||
| 102 | - | ||
| 103 | - # ── 纯透传调用 VersatileProxy,流式推送 ───────────────────────────── | ||
| 104 | - # 用"前一个 chunk"模式:延迟一次,确保最后一个 chunk 以 last_chunk=True 发送且不重复 | ||
| 105 | - prev_part: Part | None = None | ||
| 106 | - | ||
| 107 | - try: | ||
| 108 | - async for chunk in self._proxy.dispatch_stream( | ||
| 109 | - versatile_input, conv_id, headers, params | ||
| 110 | - ): | ||
| 111 | - if prev_part is not None: | ||
| 112 | - await updater.add_artifact( | ||
| 113 | - parts=[prev_part], | ||
| 114 | - artifact_id=str(uuid.uuid4()), | ||
| 115 | - last_chunk=False, | ||
| 116 | - ) | ||
| 117 | - | ||
| 118 | - data_part = Part() | ||
| 119 | - proto_value = ProtoValue() | ||
| 120 | - ParseDict(chunk, proto_value.struct_value) | ||
| 121 | - data_part.data.CopyFrom(proto_value) | ||
| 122 | - prev_part = data_part | ||
| 123 | - | ||
| 124 | - # ── 流结束:将最后一个 chunk 以 last_chunk=True 发出 ───────────────── | ||
| 125 | - if prev_part is not None: | ||
| 126 | - await updater.add_artifact( | ||
| 127 | - parts=[prev_part], | ||
| 128 | - artifact_id=str(uuid.uuid4()), | ||
| 129 | - last_chunk=True, | ||
| 130 | - ) | ||
| 131 | - else: | ||
| 132 | - text_part = Part() | ||
| 133 | - text_part.text = "流结束" | ||
| 134 | - await updater.add_artifact( | ||
| 135 | - parts=[text_part], | ||
| 136 | - artifact_id=str(uuid.uuid4()), | ||
| 137 | - last_chunk=True, | ||
| 138 | - ) | ||
| 139 | - | ||
| 140 | - await updater.complete() | ||
| 141 | - logger.info( | ||
| 142 | - f"[VersatileAdapter] 流结束:conv_id={conv_id}, task_id={task_id}, agent_id={agent_id}" | ||
| 143 | - ) | ||
| 144 | - except Exception as e: | ||
| 145 | - logger.exception( | ||
| 146 | - f"[VersatileAdapter] proxy 流异常:conv_id={conv_id}, task_id={task_id}, agent_id={agent_id}" | ||
| 147 | - ) | ||
| 148 | - await updater.failed(message=str(e)) | ||
| 149 | - finally: | ||
| 150 | - if task_id and context.call_context: | ||
| 151 | - asyncio.create_task( | ||
| 152 | - _delayed_delete_task( | ||
| 153 | - self._task_store, task_id, context.call_context | ||
| 154 | - ) | ||
| 155 | - ) | ||
| 156 | - | ||
| 157 | - | ||
| 158 | - async def cancel( | ||
| 159 | - self, context: RequestContext, event_queue: EventQueue | ||
| 160 | - ) -> None: | ||
| 161 | - conv_id = context.context_id | ||
| 162 | - task_id = context.task_id | ||
| 163 | - | ||
| 164 | - # 从 input_data 中获取 agent_id | ||
| 165 | - input_data = self._build_first_input(context.message) | ||
| 166 | - agent_id = input_data.get("agent_id", "") | ||
| 167 | - | ||
| 168 | - updater = TaskUpdater(event_queue, task_id, conv_id) | ||
| 169 | - await updater.cancel() | ||
| 170 | - logger.info( | ||
| 171 | - f"[VersatileAdapter] 任务已取消:conv_id={conv_id}, task_id={task_id}, agent_id={agent_id}" | ||
| 172 | - ) | ||
| 173 | - if task_id and context.call_context: | ||
| 174 | - asyncio.create_task( | ||
| 175 | - _delayed_delete_task( | ||
| 176 | - self._task_store, task_id, context.call_context | ||
| 177 | - ) | ||
| 178 | - ) | ||
| 179 | - | ||
| 180 | - def _build_first_input(self, message) -> dict: | ||
| 181 | - for part in message.parts: | ||
| 182 | - if part.WhichOneof("content") == "data": | ||
| 183 | - data = MessageToDict(part.data) | ||
| 184 | - if isinstance(data, dict) and data: | ||
| 185 | - return data | ||
| 186 | - text = "" | ||
| 187 | - for part in message.parts: | ||
| 188 | - if part.WhichOneof("content") == "text" and part.text: | ||
| 189 | - text = part.text | ||
| 190 | - break | ||
| 191 | - if not text: | ||
| 192 | - logger.warning( | ||
| 193 | - "[VersatileAdapter] message 中未提取到 data/text part,使用空查询兜底" | ||
| 194 | - ) | ||
| 195 | - return {"body": {"input": {"query": text}}, "headers": {}, "params": {}} | ||
| @@ -1,181 +0,0 @@ | |||
| 1 | -# coding: utf-8 | ||
| 2 | -# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | - | ||
| 4 | -""" | ||
| 5 | -VersatileProxy — 通过 HTTP 流式调用 Versatile 低代码平台(NDJSON 协议)。 | ||
| 6 | -""" | ||
| 7 | -from __future__ import annotations | ||
| 8 | - | ||
| 9 | -import json as _json | ||
| 10 | -from typing import AsyncGenerator, Optional | ||
| 11 | - | ||
| 12 | -import httpx | ||
| 13 | -from loguru import logger | ||
| 14 | - | ||
| 15 | - | ||
| 16 | -_FORWARD_HEADER_WHITELIST = { | ||
| 17 | - "x-user-id", "x-project-id", "cust-token", "cust-userid", | ||
| 18 | - # 允许上游动态覆盖模板里的 Cookie(用户级 AGENT_SID 等) | ||
| 19 | - "cookie", | ||
| 20 | - # 用于端到端的分布式追踪 | ||
| 21 | - "x-trace-id", | ||
| 22 | -} | ||
| 23 | - | ||
| 24 | - | ||
| 25 | -def _unwrap_upstream_frame(outer: dict) -> dict: | ||
| 26 | - """从上游 Versatile NDJSON 行提取工作流事件载荷。 | ||
| 27 | - | ||
| 28 | - 兼容两种上游形状: | ||
| 29 | - 1. 带 envelope:``{..., "custom_rsp_data": {"event": "<kind>", "data": {...}}}`` | ||
| 30 | - (真实 Versatile 网关常见) | ||
| 31 | - 2. 已解包直发:``{"event": "<kind>", "data": {...}}`` | ||
| 32 | - (简化网关 / 内部 mock) | ||
| 33 | - | ||
| 34 | - 返回:``{"event": "<kind>", "data": {...}}``,供下游 wrap_workflow_event 使用。 | ||
| 35 | - | ||
| 36 | - 上游异常帧回退为 ``{"event": "message", "data": <raw or {}>}``, | ||
| 37 | - 把原始载荷交给下游兜底(不会二次套壳)。 | ||
| 38 | - """ | ||
| 39 | - if not isinstance(outer, dict): | ||
| 40 | - return {"event": "message", "data": {}} | ||
| 41 | - | ||
| 42 | - # 形态 1:带 custom_rsp_data envelope | ||
| 43 | - inner = outer.get("custom_rsp_data") | ||
| 44 | - if isinstance(inner, dict) and "event" in inner: | ||
| 45 | - return {"event": inner["event"], "data": inner.get("data") or {}} | ||
| 46 | - | ||
| 47 | - # 形态 2:上游已直发 {event, data} | ||
| 48 | - if "event" in outer: | ||
| 49 | - data = outer.get("data") | ||
| 50 | - return { | ||
| 51 | - "event": outer["event"], | ||
| 52 | - "data": data if isinstance(data, dict) else {}, | ||
| 53 | - } | ||
| 54 | - | ||
| 55 | - # 无法识别:整体当 message 帧的 data 兜底 | ||
| 56 | - return {"event": "message", "data": outer} | ||
| 57 | - | ||
| 58 | - | ||
| 59 | -class VersatileProxy: | ||
| 60 | - def __init__( | ||
| 61 | - self, | ||
| 62 | - url_template: str, | ||
| 63 | - timeout: int = 600, | ||
| 64 | - headers_template: Optional[dict] = None, | ||
| 65 | - ) -> None: | ||
| 66 | - self._url_template = url_template | ||
| 67 | - self._timeout = timeout | ||
| 68 | - self._headers_template = dict(headers_template) if headers_template else {} | ||
| 69 | - | ||
| 70 | - def _build_url(self, conv_id: str) -> str: | ||
| 71 | - return self._url_template.format(conversation_id=conv_id) | ||
| 72 | - | ||
| 73 | - | ||
| 74 | - def _generate_curl_command(request: httpx.Request, body: bytes) -> str: | ||
| 75 | - """生成 curl 命令用于调试。""" | ||
| 76 | - cmd = f"curl -X {request.method} '{request.url}'" | ||
| 77 | - for key, value in request.headers.items(): | ||
| 78 | - cmd += f" -H '{key}: {value}'" | ||
| 79 | - if body: | ||
| 80 | - try: | ||
| 81 | - json_body = _json.loads(body.decode('utf-8')) | ||
| 82 | - cmd += f" -d '{_json.dumps(json_body, ensure_ascii=False)}'" | ||
| 83 | - except Exception: | ||
| 84 | - logger.debug("[VersatileProxy] body 非 JSON,curl 命令使用 raw 字节回退") | ||
| 85 | - cmd += f" -d '{body.decode('utf-8', errors='replace')}'" | ||
| 86 | - return cmd | ||
| 87 | - | ||
| 88 | - async def _log_request(self, request: httpx.Request) -> None: | ||
| 89 | - """记录请求日志(生成 curl 命令)。""" | ||
| 90 | - body = await request.aread() | ||
| 91 | - curl = self._generate_curl_command(request, body) | ||
| 92 | - banner_start = f"{'='*20} Proxy Request (Stream) Start {'='*20}" | ||
| 93 | - banner_end = f"{'='*20} Proxy Request (Stream) End {'='*20}" | ||
| 94 | - logger.info("[VersatileProxy] {}", banner_start) | ||
| 95 | - logger.info("[VersatileProxy] {}", curl) | ||
| 96 | - logger.info("[VersatileProxy] {}", banner_end) | ||
| 97 | - | ||
| 98 | - async def dispatch_stream( | ||
| 99 | - self, | ||
| 100 | - body: dict, | ||
| 101 | - conv_id: str, | ||
| 102 | - extra_headers: Optional[dict] = None, | ||
| 103 | - params: Optional[dict] = None, | ||
| 104 | - ) -> AsyncGenerator[dict, None]: | ||
| 105 | - url = self._build_url(conv_id) | ||
| 106 | - headers = dict(self._headers_template) | ||
| 107 | - headers.setdefault("Content-Type", "application/json") | ||
| 108 | - if extra_headers: | ||
| 109 | - headers.update( | ||
| 110 | - {k: v for k, v in extra_headers.items() if k.lower() in _FORWARD_HEADER_WHITELIST} | ||
| 111 | - ) | ||
| 112 | - | ||
| 113 | - logger.info(f"[VersatileProxy] 发送请求:POST {url}") | ||
| 114 | - logger.debug(f"[VersatileProxy] 请求头:{headers}") | ||
| 115 | - logger.debug(f"[VersatileProxy] 请求体:{body.get('custom_data', {})})") | ||
| 116 | - logger.debug(f"[VersatileProxy] 请求参数:{params})") | ||
| 117 | - | ||
| 118 | - try: | ||
| 119 | - async with httpx.AsyncClient( | ||
| 120 | - verify=False, | ||
| 121 | - limits=httpx.Limits(max_keepalive_connections=5, max_connections=10), | ||
| 122 | - timeout=httpx.Timeout(self._timeout, read=None), | ||
| 123 | - ) as client: | ||
| 124 | - # 构建请求对象用于日志 | ||
| 125 | - custom_body = body.get("custom_data", {}) | ||
| 126 | - request = client.build_request( | ||
| 127 | - "POST", | ||
| 128 | - url, | ||
| 129 | - json=custom_body, | ||
| 130 | - headers=headers, | ||
| 131 | - params=params, | ||
| 132 | - ) | ||
| 133 | - await self._log_request(request) | ||
| 134 | - | ||
| 135 | - async with client.stream( | ||
| 136 | - "POST", | ||
| 137 | - url, | ||
| 138 | - json=custom_body, | ||
| 139 | - headers=headers, | ||
| 140 | - params=params, | ||
| 141 | - ) as response: | ||
| 142 | - logger.info(f"[VersatileProxy] --- Proxy Response (Stream): {response.status_code} ---") | ||
| 143 | - logger.debug(f"[VersatileProxy] Response Headers: {dict(response.headers)}") | ||
| 144 | - if response.is_error: | ||
| 145 | - # 在 stream 上下文内先读出响应体,便于在 raise 前打印错误正文, | ||
| 146 | - # 避免上下文退出后 e.response.text 不可读。 | ||
| 147 | - body_bytes = await response.aread() | ||
| 148 | - body_text = body_bytes.decode("utf-8", errors="replace") | ||
| 149 | - logger.error( | ||
| 150 | - f"[VersatileProxy] HTTP {response.status_code} url={url} " | ||
| 151 | - f"body={body_text!r:.500}" | ||
| 152 | - ) | ||
| 153 | - response.raise_for_status() | ||
| 154 | - async for line in response.aiter_lines(): | ||
| 155 | - logger.debug(f"[VersatileProxy] proxy received line: {line}]") | ||
| 156 | - line = line.strip() | ||
| 157 | - if not line: | ||
| 158 | - continue | ||
| 159 | - # SSE 格式:去掉 "data:" 前缀 | ||
| 160 | - if line.startswith("data:"): | ||
| 161 | - line = line[5:].strip() | ||
| 162 | - if not line: | ||
| 163 | - continue | ||
| 164 | - try: | ||
| 165 | - outer = _json.loads(line) | ||
| 166 | - except Exception: | ||
| 167 | - logger.warning(f"[VersatileProxy] 无法解析行:{line!r:.80}") | ||
| 168 | - continue | ||
| 169 | - | ||
| 170 | - yield _unwrap_upstream_frame(outer) | ||
| 171 | - | ||
| 172 | - except httpx.HTTPStatusError as e: | ||
| 173 | - # 详细错误正文已在 stream 上下文内记录,这里只补一条状态摘要 | ||
| 174 | - logger.error( | ||
| 175 | - f"[VersatileProxy] HTTPStatusError 已记录:" | ||
| 176 | - f"{e.response.status_code} {url}" | ||
| 177 | - ) | ||
| 178 | - raise | ||
| 179 | - except httpx.RequestError as e: | ||
| 180 | - logger.error(f"[VersatileProxy] 请求错误:{e}") | ||
| 181 | - raise | ||
Rapplications/versatile_adapter/tests/__init__.py→applications/versatile_adapter/adapters/__init__.py+0-0
文件重命名但无更改。
| @@ -0,0 +1,37 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +""" | ||
| 5 | +BaseAdapter — 后端协议适配器抽象基类。 | ||
| 6 | + | ||
| 7 | +每种后端类型实现此接口,封装 HTTP 交互 + SSE 解析 + 报文转换 + 节点处理。 | ||
| 8 | +直接 yield AdapterEvent,不依赖 A2A SDK。 | ||
| 9 | +""" | ||
| 10 | +from __future__ import annotations | ||
| 11 | + | ||
| 12 | +from abc import ABC, abstractmethod | ||
| 13 | +from typing import AsyncGenerator, Optional | ||
| 14 | + | ||
| 15 | +from event.events import AdapterEvent | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +class BaseAdapter(ABC): | ||
| 19 | + """后端协议适配器抽象基类。""" | ||
| 20 | + | ||
| 21 | + | ||
| 22 | + async def dispatch_stream( | ||
| 23 | + self, | ||
| 24 | + conv_id: str, | ||
| 25 | + headers: Optional[dict] = None, | ||
| 26 | + params: Optional[dict] = None, | ||
| 27 | + body: Optional[dict] = None, | ||
| 28 | + ) -> AsyncGenerator[AdapterEvent, None]: | ||
| 29 | + """向后端发起流式请求,yield 类型化的 AdapterEvent。 | ||
| 30 | + | ||
| 31 | + Args: | ||
| 32 | + conv_id: 会话 ID,供 _build_url 格式化。 | ||
| 33 | + headers: 每个 HTTP 请求的输入头。 | ||
| 34 | + params: URL 查询参数。 | ||
| 35 | + body: 业务负载。 | ||
| 36 | + """ | ||
| 37 | + ... | ||
| @@ -0,0 +1,86 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +""" | ||
| 5 | +VersatileController — 一级控制器协议适配器。 | ||
| 6 | + | ||
| 7 | +差异定制:custom_data 请求体提取、 | ||
| 8 | +End/exception 终态检测、GXZQAResponseNode 过滤。 | ||
| 9 | +""" | ||
| 10 | +from __future__ import annotations | ||
| 11 | + | ||
| 12 | +import json as _json | ||
| 13 | +from typing import Optional | ||
| 14 | + | ||
| 15 | +from loguru import logger | ||
| 16 | + | ||
| 17 | +from adapters.versatile_proxy import VersatileProxy, VersatileStreamCtx | ||
| 18 | +from event.events import ( | ||
| 19 | + AdapterEvent, | ||
| 20 | + DataProxyContent, | ||
| 21 | + ExecutionCompletedContent, | ||
| 22 | + ExecutionInputRequiredContent, | ||
| 23 | +) | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +_FORWARD_HEADER_WHITELIST = ( | ||
| 27 | + "x-user-id", "x-project-id", "cust-token", "cust-userid", | ||
| 28 | + # 允许上游动态覆盖模板里的 Cookie(用户级 AGENT_SID 等) | ||
| 29 | + "cookie", | ||
| 30 | +) | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +class VersatileController(VersatileProxy): | ||
| 34 | + """一级控制器协议适配器。""" | ||
| 35 | + | ||
| 36 | + def __init__( | ||
| 37 | + self, | ||
| 38 | + url_template: str, | ||
| 39 | + timeout: int = 600, | ||
| 40 | + headers_template: Optional[dict] = None, | ||
| 41 | + forward_header_whitelist: Optional[set[str]] = None, | ||
| 42 | + workflow_result_node: Optional[str] = None, | ||
| 43 | + ) -> None: | ||
| 44 | + header_whitelist = forward_header_whitelist if forward_header_whitelist else _FORWARD_HEADER_WHITELIST | ||
| 45 | + super().__init__(url_template, timeout, headers_template, header_whitelist) | ||
| 46 | + self._workflow_result_node = workflow_result_node | ||
| 47 | + | ||
| 48 | + def _process_chunk(self, chunk: str, ctx: VersatileStreamCtx) -> list[AdapterEvent]: | ||
| 49 | + if '"node_type":"End"' in chunk: | ||
| 50 | + logger.debug(f"[VersatileController] End 节点,yield data_proxy") | ||
| 51 | + ctx.completed = True | ||
| 52 | + return [AdapterEvent(data_proxy=DataProxyContent(raw_data=chunk))] | ||
| 53 | + | ||
| 54 | + if '"event":"exception"' in chunk: | ||
| 55 | + logger.debug(f"[VersatileController] exception 帧,yield data_proxy") | ||
| 56 | + ctx.completed = True | ||
| 57 | + ctx.is_failed = True | ||
| 58 | + return [AdapterEvent(data_proxy=DataProxyContent(raw_data=chunk))] | ||
| 59 | + | ||
| 60 | + if self._workflow_result_node and f'"node_name":"{self._workflow_result_node}"' in chunk: | ||
| 61 | + try: | ||
| 62 | + parsed = _json.loads(chunk) | ||
| 63 | + except Exception: | ||
| 64 | + logger.warning(f"[VersatileController] 无法解析 workflow_result 行: {chunk!r:.80}") | ||
| 65 | + return [AdapterEvent(data_proxy=DataProxyContent(raw_data=chunk))] | ||
| 66 | + data = (parsed.get("custom_rsp_data") or parsed).get("data") or {} | ||
| 67 | + if isinstance(data, dict) and data.get("node_type") == "QA": | ||
| 68 | + text = data.get("text", "") or "" | ||
| 69 | + if not text: | ||
| 70 | + return [] | ||
| 71 | + logger.debug(f"[VersatileController] workflow_result: {text!r:.60}") | ||
| 72 | + ctx.execution_result = text | ||
| 73 | + return [] | ||
| 74 | + | ||
| 75 | + return [AdapterEvent(data_proxy=DataProxyContent(raw_data=chunk))] | ||
| 76 | + | ||
| 77 | + def _on_stream_end(self, ctx: VersatileStreamCtx) -> list[AdapterEvent]: | ||
| 78 | + if not ctx.completed: | ||
| 79 | + return [AdapterEvent(execution_input_required=ExecutionInputRequiredContent())] | ||
| 80 | + | ||
| 81 | + if ctx.execution_result: | ||
| 82 | + return [AdapterEvent(execution_completed=ExecutionCompletedContent( | ||
| 83 | + is_failed=ctx.is_failed, result=ctx.execution_result | ||
| 84 | + ))] | ||
| 85 | + | ||
| 86 | + return [] | ||
| @@ -0,0 +1,166 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +""" | ||
| 5 | +VersatileProxy — HTTP+SSE 流式调用基类。 | ||
| 6 | + | ||
| 7 | +封装通用的 HTTP 流式请求 + SSE 行解析 + 错误处理。 | ||
| 8 | +子类通过钩子方法定制 URL 构建、请求头过滤、请求体提取、行处理等。 | ||
| 9 | +""" | ||
| 10 | +from __future__ import annotations | ||
| 11 | + | ||
| 12 | +import json as _json | ||
| 13 | +from typing import AsyncGenerator, Optional | ||
| 14 | + | ||
| 15 | +from abc import abstractmethod | ||
| 16 | + | ||
| 17 | +import httpx | ||
| 18 | +from loguru import logger | ||
| 19 | + | ||
| 20 | +from event.events import ( | ||
| 21 | + AdapterEvent, | ||
| 22 | +) | ||
| 23 | +from adapters.base_adapter import BaseAdapter | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +class VersatileStreamCtx: | ||
| 27 | + """SSE 行循环中累积的可变状态,贯穿 _process_line → _process_chunk → _on_stream_end。""" | ||
| 28 | + | ||
| 29 | + __slots__ = ("completed", "is_failed", "execution_result") | ||
| 30 | + | ||
| 31 | + def __init__(self) -> None: | ||
| 32 | + self.completed: bool = False | ||
| 33 | + self.is_failed: bool = False | ||
| 34 | + self.execution_result: str | None = None | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +class VersatileProxy(BaseAdapter): | ||
| 38 | + """HTTP+SSE 流式调用基类。 | ||
| 39 | + | ||
| 40 | + dispatch_stream 实现完整的 HTTP 流读取 + SSE 解析循环, | ||
| 41 | + 子类通过以下钩子方法定制差异行为: | ||
| 42 | + - _build_url: URL 模板格式化 | ||
| 43 | + - _build_headers: 请求头构建与过滤 | ||
| 44 | + - _build_request_body: 请求体提取 | ||
| 45 | + - _process_chunk: data 行内容解析,通过 ctx 累积状态,返回事件列表 | ||
| 46 | + - _on_stream_end: 流结束后基于 ctx 的累积状态生成后置事件 | ||
| 47 | + """ | ||
| 48 | + | ||
| 49 | + def __init__( | ||
| 50 | + self, | ||
| 51 | + url_template: str, | ||
| 52 | + timeout: int = 600, | ||
| 53 | + headers_template: Optional[dict] = None, | ||
| 54 | + forward_header_whitelist: Optional[set[str]] = None, | ||
| 55 | + ) -> None: | ||
| 56 | + self._url_template = url_template | ||
| 57 | + self._timeout = timeout | ||
| 58 | + self._headers_template = dict(headers_template) if headers_template else {} | ||
| 59 | + self._forward_header_whitelist = forward_header_whitelist | ||
| 60 | + | ||
| 61 | + # ── 子类可覆盖的钩子 ───────────────────────────────────── | ||
| 62 | + | ||
| 63 | + def _build_url(self, conv_id: str) -> str: | ||
| 64 | + return self._url_template.format(conversation_id=conv_id) | ||
| 65 | + | ||
| 66 | + def _build_headers(self, headers: Optional[dict] = None) -> dict: | ||
| 67 | + merged = dict(self._headers_template) | ||
| 68 | + merged.setdefault("Content-Type", "application/json") | ||
| 69 | + if headers: | ||
| 70 | + # 配置中指定仅转发部分HEADER时则仅转发指定的HEADER,否则转发全部HEADER | ||
| 71 | + if self._forward_header_whitelist: | ||
| 72 | + merged.update({k: v for k, v in headers.items() if k.lower() in self._forward_header_whitelist}) | ||
| 73 | + else: | ||
| 74 | + merged.update(headers) | ||
| 75 | + return merged | ||
| 76 | + | ||
| 77 | + def _build_request_body(self, body: dict) -> dict: | ||
| 78 | + return body.get("custom_data", {}) | ||
| 79 | + | ||
| 80 | + | ||
| 81 | + def _process_chunk(self, chunk: str, ctx: VersatileStreamCtx) -> list[AdapterEvent]: | ||
| 82 | + """子类实现:解析 data 行内容,通过 ctx 累积状态,返回事件列表。""" | ||
| 83 | + | ||
| 84 | + def _process_line(self, line: str, ctx: VersatileStreamCtx) -> list[AdapterEvent]: | ||
| 85 | + """SSE 行过滤(跳过 id/event/空行),仅 data 行交由 _process_chunk 处理。""" | ||
| 86 | + line = line.strip() | ||
| 87 | + if not line: | ||
| 88 | + return [] | ||
| 89 | + if line.startswith("data:"): | ||
| 90 | + line = line[5:].strip() | ||
| 91 | + if not line: | ||
| 92 | + return [] | ||
| 93 | + return self._process_chunk(line, ctx) | ||
| 94 | + | ||
| 95 | + | ||
| 96 | + def _on_stream_end(self, ctx: VersatileStreamCtx) -> list[AdapterEvent]: | ||
| 97 | + """子类实现:基于 ctx 累积状态生成后置事件。""" | ||
| 98 | + | ||
| 99 | + # ── 主流程 ──────────────────────────────────────────────── | ||
| 100 | + | ||
| 101 | + async def dispatch_stream( | ||
| 102 | + self, | ||
| 103 | + conv_id: str, | ||
| 104 | + headers: Optional[dict] = None, | ||
| 105 | + params: Optional[dict] = None, | ||
| 106 | + body: Optional[dict] = None, | ||
| 107 | + ) -> AsyncGenerator[AdapterEvent, None]: | ||
| 108 | + url = self._build_url(conv_id) | ||
| 109 | + req_headers = self._build_headers(headers) | ||
| 110 | + request_body = self._build_request_body(body or {}) | ||
| 111 | + | ||
| 112 | + logger.info(f"[VersatileProxy] 发送请求:POST {url}") | ||
| 113 | + logger.debug(f"[VersatileProxy] 请求头:{req_headers}") | ||
| 114 | + logger.debug(f"[VersatileProxy] 请求体:{request_body}") | ||
| 115 | + | ||
| 116 | + ctx = VersatileStreamCtx() | ||
| 117 | + | ||
| 118 | + try: | ||
| 119 | + limits = httpx.Limits(max_keepalive_connections=5, max_connections=10) | ||
| 120 | + timeout = httpx.Timeout(self._timeout, read=None) | ||
| 121 | + async with httpx.AsyncClient(verify=False, limits=limits, timeout=timeout) as client: | ||
| 122 | + request = client.build_request("POST", url, json=request_body, headers=req_headers, params=params) | ||
| 123 | + await self._log_request(request) | ||
| 124 | + | ||
| 125 | + async with client.stream("POST", url, json=request_body, headers=req_headers, | ||
| 126 | + params=params) as response: | ||
| 127 | + logger.info(f"[VersatileProxy] Response: {response.status_code}") | ||
| 128 | + if response.is_error: | ||
| 129 | + body_bytes = await response.aread() | ||
| 130 | + body_text = body_bytes.decode("utf-8", errors="replace") | ||
| 131 | + logger.error(f"[VersatileProxy] HTTP {response.status_code} url={url} body={body_text!r:.500}") | ||
| 132 | + response.raise_for_status() | ||
| 133 | + | ||
| 134 | + async for line in response.aiter_lines(): | ||
| 135 | + logger.debug(f"[VersatileProxy] received line: {line}") | ||
| 136 | + for event in self._process_line(line, ctx): | ||
| 137 | + yield event | ||
| 138 | + | ||
| 139 | + except httpx.HTTPStatusError as e: | ||
| 140 | + logger.error(f"[VersatileProxy] HTTPStatusError:{e.response.status_code} {url}") | ||
| 141 | + raise | ||
| 142 | + except httpx.RequestError as e: | ||
| 143 | + logger.error(f"[VersatileProxy] 请求错误:{e}") | ||
| 144 | + raise | ||
| 145 | + | ||
| 146 | + for event in self._on_stream_end(ctx): | ||
| 147 | + yield event | ||
| 148 | + | ||
| 149 | + | ||
| 150 | + async def _log_request(request: httpx.Request) -> None: | ||
| 151 | + """记录请求日志(生成 curl 命令)。""" | ||
| 152 | + body = await request.aread() | ||
| 153 | + cmd = f"curl -X {request.method} '{request.url}'" | ||
| 154 | + for key, value in request.headers.items(): | ||
| 155 | + cmd += f" -H '{key}: {value}'" | ||
| 156 | + if body: | ||
| 157 | + try: | ||
| 158 | + json_body = _json.loads(body.decode("utf-8")) | ||
| 159 | + cmd += f" -d '{_json.dumps(json_body, ensure_ascii=False)}'" | ||
| 160 | + except Exception: | ||
| 161 | + cmd += f" -d '{body.decode('utf-8', errors='replace')}'" | ||
| 162 | + banner_start = f"{'='*20} Proxy Request (Stream) Start {'='*20}" | ||
| 163 | + banner_end = f"{'='*20} Proxy Request (Stream) End {'='*20}" | ||
| 164 | + logger.info("[VersatileProxy] {}", banner_start) | ||
| 165 | + logger.info("[VersatileProxy] {}", cmd) | ||
| 166 | + logger.info("[VersatileProxy] {}", banner_end) | ||
| @@ -0,0 +1,48 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +""" | ||
| 5 | +VersatileWorkflow — 低码工作流协议适配器。 | ||
| 6 | + | ||
| 7 | +差异定制:URL 格式化含 params、请求体直传、 | ||
| 8 | +帧类型过滤(finish/runCompleted/dialogId 跳过)。 | ||
| 9 | +HTTP 流断流由调用方(executor)通过流结束触发 complete()。 | ||
| 10 | +""" | ||
| 11 | +from __future__ import annotations | ||
| 12 | + | ||
| 13 | +from typing import Optional | ||
| 14 | + | ||
| 15 | +from adapters.versatile_proxy import VersatileProxy, VersatileStreamCtx | ||
| 16 | +from event.events import ( | ||
| 17 | + AdapterEvent, | ||
| 18 | + DataProxyContent, | ||
| 19 | +) | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +class VersatileWorkflow(VersatileProxy): | ||
| 23 | + """低码工作流协议适配器。""" | ||
| 24 | + | ||
| 25 | + _SKIP_TYPES = frozenset({"finish", "runCompleted", "dialogId"}) | ||
| 26 | + | ||
| 27 | + def __init__( | ||
| 28 | + self, | ||
| 29 | + url_template: str, | ||
| 30 | + workflow_id: str, | ||
| 31 | + timeout: int = 600, | ||
| 32 | + headers_template: Optional[dict] = None, | ||
| 33 | + forward_header_whitelist: Optional[set[str]] = None, | ||
| 34 | + ) -> None: | ||
| 35 | + super().__init__(url_template, timeout, headers_template, forward_header_whitelist) | ||
| 36 | + self._workflow_id = workflow_id | ||
| 37 | + | ||
| 38 | + def _build_url(self, conv_id: str) -> str: | ||
| 39 | + return self._url_template.format(conversation_id=conv_id, workflow_id=self._workflow_id) | ||
| 40 | + | ||
| 41 | + def _process_chunk(self, chunk: str, ctx: VersatileStreamCtx) -> list[AdapterEvent]: | ||
| 42 | + for t in self._SKIP_TYPES: | ||
| 43 | + if f'"type":"{t}"' in chunk: | ||
| 44 | + return [] | ||
| 45 | + return [AdapterEvent(data_proxy=DataProxyContent(raw_data=chunk))] | ||
| 46 | + | ||
| 47 | + def _on_stream_end(self, ctx: VersatileStreamCtx) -> list[AdapterEvent]: | ||
| 48 | + return [] | ||
| @@ -23,15 +23,17 @@ from contextlib import asynccontextmanager | |||
| 23 | 23 | ||
| 24 | from a2a.server.request_handlers import DefaultRequestHandler | 24 | from a2a.server.request_handlers import DefaultRequestHandler |
| 25 | from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes | 25 | from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes |
| 26 | -from a2a.server.tasks import InMemoryTaskStore | ||
| 27 | from fastapi import FastAPI | 26 | from fastapi import FastAPI |
| 28 | from loguru import logger | 27 | from loguru import logger |
| 29 | from starlette.applications import Starlette | 28 | from starlette.applications import Starlette |
| 30 | 29 | ||
| 30 | +from a2a.server.tasks import InMemoryTaskStore, TaskStore | ||
| 31 | +from persistence.redis_client import RedisClient | ||
| 32 | +from persistence.redis_task_store import RedisTaskStore | ||
| 31 | from config import get_settings | 33 | from config import get_settings |
| 32 | -from adapter.agent_card import VERSATILE_ADAPTER_CARD | 34 | +from a2a_facade.agent_card import VERSATILE_ADAPTER_CARD |
| 33 | -from adapter.executor import VersatileAdapterExecutor | 35 | +from dispatcher.runner import VersatileAdapterRunner |
| 34 | -from adapter.versatile_proxy import VersatileProxy | 36 | +from a2a_facade.executor import A2aVersatileExecutor |
| 35 | 37 | ||
| 36 | 38 | ||
| 37 | os.environ['NO_PROXY'] = 'localhost,127.0.0.1' | 39 | os.environ['NO_PROXY'] = 'localhost,127.0.0.1' |
| @@ -78,10 +80,7 @@ def setup_logging() -> None: | |||
| 78 | os.makedirs(log_dir, exist_ok=True) | 80 | os.makedirs(log_dir, exist_ok=True) |
| 79 | log_file_path = settings.adapter_log_file | 81 | log_file_path = settings.adapter_log_file |
| 80 | base, ext = os.path.splitext(log_file_path) | 82 | base, ext = os.path.splitext(log_file_path) |
| 81 | - log_suffix = "" | 83 | + log_file_with_pid = f"{base}_{os.getpid()}{ext}" |
| 82 | - if settings.adapter_fastapi_workers and settings.adapter_fastapi_workers > 1: | ||
| 83 | - log_suffix = f"_{os.getpid()}" | ||
| 84 | - log_file_with_pid = f"{base}{log_suffix}{ext}" | ||
| 85 | logger.add( | 84 | logger.add( |
| 86 | log_file_with_pid, | 85 | log_file_with_pid, |
| 87 | level=settings.adapter_log_level.upper() if settings.adapter_log_level else "INFO", | 86 | level=settings.adapter_log_level.upper() if settings.adapter_log_level else "INFO", |
| @@ -103,27 +102,37 @@ def setup_logging() -> None: | |||
| 103 | setup_logging() | 102 | setup_logging() |
| 104 | 103 | ||
| 105 | 104 | ||
| 105 | +_TTL = 1800 | ||
| 106 | + | ||
| 107 | + | ||
| 108 | +async def _create_task_store(settings) -> tuple[TaskStore, RedisClient | None]: | ||
| 109 | + """根据配置创建 TaskStore:Redis 有效时使用 RedisTaskStore,否则回退 InMemoryTaskStore。 | ||
| 110 | + | ||
| 111 | + Returns: | ||
| 112 | + (task_store, redis_client) — redis_client 在 Redis 模式下非 None,调用方需在关闭时 disconnect。 | ||
| 113 | + """ | ||
| 114 | + if settings.redis_host: | ||
| 115 | + redis = RedisClient() | ||
| 116 | + await redis.connect(settings.redis_url) | ||
| 117 | + task_store = RedisTaskStore(redis, ttl=settings.redis_session_ttl or _TTL) | ||
| 118 | + logger.info(f"[VersatileAdapter] TaskStore=RedisTaskStore, host={settings.redis_host}") | ||
| 119 | + return task_store, redis | ||
| 120 | + logger.info("[VersatileAdapter] TaskStore=InMemoryTaskStore(未配置 Redis)") | ||
| 121 | + return InMemoryTaskStore(), None | ||
| 122 | + | ||
| 123 | + | ||
| 106 | 124 | ||
| 107 | async def lifespan(fastapi_app: FastAPI): | 125 | async def lifespan(fastapi_app: FastAPI): |
| 108 | settings = get_settings() | 126 | settings = get_settings() |
| 109 | 127 | ||
| 110 | - versatile_proxy = VersatileProxy( | 128 | + # 1. 创建 TaskStore(按配置选择 Redis 或 InMemory) |
| 111 | - url_template=settings.versatile_url_template, | 129 | + task_store, redis = await _create_task_store(settings) |
| 112 | - timeout=settings.versatile_timeout, | ||
| 113 | - headers_template=settings.versatile_headers_template, | ||
| 114 | - ) | ||
| 115 | 130 | ||
| 116 | - logger.info( | 131 | + # 2. 从 YAML 配置创建 Runner(动态路由) |
| 117 | - f"[VersatileAdapter] Versatile headers template keys: " | 132 | + runner = VersatileAdapterRunner() |
| 118 | - f"{sorted(settings.versatile_headers_template.keys())}" | ||
| 119 | - ) | ||
| 120 | 133 | ||
| 121 | - task_store = InMemoryTaskStore() | 134 | + # 3. 创建 A2A 薄壳 |
| 122 | - | 135 | + executor = A2aVersatileExecutor(runner=runner) |
| 123 | - executor = VersatileAdapterExecutor( | ||
| 124 | - versatile_proxy=versatile_proxy, | ||
| 125 | - task_store=task_store, | ||
| 126 | - ) | ||
| 127 | 136 | ||
| 128 | request_handler = DefaultRequestHandler( | 137 | request_handler = DefaultRequestHandler( |
| 129 | agent_executor=executor, | 138 | agent_executor=executor, |
| @@ -136,14 +145,13 @@ async def lifespan(fastapi_app: FastAPI): | |||
| 136 | ) | 145 | ) |
| 137 | fastapi_app.mount("/", Starlette(routes=a2a_routes)) | 146 | fastapi_app.mount("/", Starlette(routes=a2a_routes)) |
| 138 | 147 | ||
| 139 | - logger.info( | 148 | + logger.info("[VersatileAdapter] 启动完成") |
| 140 | - f"[VersatileAdapter] 启动完成," | ||
| 141 | - f"Versatile URL template: {settings.versatile_url_template}" | ||
| 142 | - ) | ||
| 143 | 149 | ||
| 144 | try: | 150 | try: |
| 145 | yield | 151 | yield |
| 146 | finally: | 152 | finally: |
| 153 | + if redis: | ||
| 154 | + await redis.disconnect() | ||
| 147 | logger.info("[VersatileAdapter] 关闭完成") | 155 | logger.info("[VersatileAdapter] 关闭完成") |
| 148 | 156 | ||
| 149 | 157 | ||
| @@ -7,7 +7,7 @@ from functools import lru_cache | |||
| 7 | from pathlib import Path | 7 | from pathlib import Path |
| 8 | from typing import Any, Dict, Optional | 8 | from typing import Any, Dict, Optional |
| 9 | 9 | ||
| 10 | -from pydantic import Json | 10 | +from pydantic import Field, Json |
| 11 | from pydantic_settings import BaseSettings, SettingsConfigDict | 11 | from pydantic_settings import BaseSettings, SettingsConfigDict |
| 12 | 12 | ||
| 13 | 13 | ||
| @@ -34,6 +34,8 @@ class Settings(BaseSettings): | |||
| 34 | env_file=Path(__file__).parent / ".env", | 34 | env_file=Path(__file__).parent / ".env", |
| 35 | env_file_encoding="utf-8", | 35 | env_file_encoding="utf-8", |
| 36 | extra="ignore", | 36 | extra="ignore", |
| 37 | + validate_by_name=True, | ||
| 38 | + validate_by_alias=True, | ||
| 37 | ) | 39 | ) |
| 38 | 40 | ||
| 39 | # ── App ───────────────────────────────────────────────────────────────── | 41 | # ── App ───────────────────────────────────────────────────────────────── |
| @@ -43,6 +45,10 @@ class Settings(BaseSettings): | |||
| 43 | versatile_url_template: Optional[str] = None | 45 | versatile_url_template: Optional[str] = None |
| 44 | versatile_timeout: Optional[int] = None | 46 | versatile_timeout: Optional[int] = None |
| 45 | versatile_headers_template: Json[Dict[str, Any]] = _DEFAULT_VERSATILE_HEADERS_TEMPLATE | 47 | versatile_headers_template: Json[Dict[str, Any]] = _DEFAULT_VERSATILE_HEADERS_TEMPLATE |
| 48 | + versatile_adapter_type: str = "controller" # "controller" 或 "workflow" | ||
| 49 | + versatile_workflow_result_node: Optional[str] = Field( | ||
| 50 | + default=None, alias="va_workflow_result_node", | ||
| 51 | + ) # 环境变量 VA_WORKFLOW_RESULT_NODE,代码中用 versatile_ 前缀访问 | ||
| 46 | 52 | ||
| 47 | # ── FastAPI ───────────────────────────────────────────────────────────── | 53 | # ── FastAPI ───────────────────────────────────────────────────────────── |
| 48 | adapter_fastapi_host: Optional[str] = None | 54 | adapter_fastapi_host: Optional[str] = None |
| @@ -54,6 +60,21 @@ class Settings(BaseSettings): | |||
| 54 | adapter_log_level: Optional[str] = None | 60 | adapter_log_level: Optional[str] = None |
| 55 | adapter_log_file: Optional[str] = None | 61 | adapter_log_file: Optional[str] = None |
| 56 | 62 | ||
| 63 | + # ── Redis ────────────────────────────────────────────────────────────── | ||
| 64 | + redis_host: Optional[str] = None | ||
| 65 | + redis_port: Optional[int] = None | ||
| 66 | + redis_db: Optional[int] = None | ||
| 67 | + redis_password: Optional[str] = None | ||
| 68 | + redis_session_ttl: Optional[int] = None | ||
| 69 | + | ||
| 70 | + | ||
| 71 | + def redis_url(self) -> str: | ||
| 72 | + from urllib.parse import quote_plus | ||
| 73 | + if self.redis_password: | ||
| 74 | + pwd = quote_plus(self.redis_password) | ||
| 75 | + return f"redis://:{pwd}@{self.redis_host}:{self.redis_port}/{self.redis_db}" | ||
| 76 | + return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}" | ||
| 77 | + | ||
| 57 | 78 | ||
| 58 | 79 | ||
| 59 | def get_settings() -> Settings: | 80 | def get_settings() -> Settings: |
| @@ -0,0 +1,2 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| @@ -0,0 +1,143 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +""" | ||
| 5 | +VersatileAdapterRunner — 配置驱动的动态路由层。 | ||
| 6 | + | ||
| 7 | +从 YAML 配置文件加载 adapter 定义,在 run_async 时根据 target 动态匹配并创建适配器: | ||
| 8 | + - target 含 workflow_id 且匹配到 workflow 配置 → VersatileWorkflow | ||
| 9 | + - target 含 intent 且匹配到 workflow 配置的 intent → VersatileWorkflow | ||
| 10 | + - 否则 → VersatileController(使用第一个 type=controller 的配置) | ||
| 11 | +""" | ||
| 12 | +from __future__ import annotations | ||
| 13 | + | ||
| 14 | +from pathlib import Path | ||
| 15 | +from typing import AsyncGenerator, Optional | ||
| 16 | + | ||
| 17 | +import yaml | ||
| 18 | +from loguru import logger | ||
| 19 | + | ||
| 20 | +from adapters.versatile_controller import VersatileController | ||
| 21 | +from adapters.versatile_workflow import VersatileWorkflow | ||
| 22 | +from config import get_settings | ||
| 23 | +from event.events import AdapterEvent | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +_DEFAULT_CONFIG_PATH = Path("/etc/edpagent/config/versatile_proxy.yaml") | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +class _VersatileAdapterConfig: | ||
| 30 | + """单个 adapter 的配置。""" | ||
| 31 | + | ||
| 32 | + __slots__ = ( | ||
| 33 | + "name", "type", "url_template", "timeout", | ||
| 34 | + "headers_template", "forward_header_whitelist", | ||
| 35 | + "workflow_result_node", "workflow_id", "intent", | ||
| 36 | + ) | ||
| 37 | + | ||
| 38 | + def __init__(self, raw: dict) -> None: | ||
| 39 | + self.name = raw["name"] | ||
| 40 | + self.type = raw["type"] | ||
| 41 | + self.url_template = raw["url_template"] | ||
| 42 | + self.timeout = raw.get("timeout", 600) | ||
| 43 | + self.headers_template = raw.get("headers_template", {}) | ||
| 44 | + self.forward_header_whitelist = ( | ||
| 45 | + set(h.lower() for h in raw["forward_header_whitelist"]) | ||
| 46 | + if "forward_header_whitelist" in raw else None | ||
| 47 | + ) | ||
| 48 | + self.workflow_result_node = raw.get("workflow_result_node") | ||
| 49 | + self.workflow_id = raw.get("workflow_id") | ||
| 50 | + self.intent = raw.get("intent") | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +class VersatileAdapterRunner: | ||
| 54 | + """配置驱动的动态路由 Runner。""" | ||
| 55 | + | ||
| 56 | + def __init__(self, config_path: Optional[Path] = None) -> None: | ||
| 57 | + path = config_path or _DEFAULT_CONFIG_PATH | ||
| 58 | + self._adapters = self._load_config(path) | ||
| 59 | + if not self._adapters: | ||
| 60 | + self._adapters = self._build_from_settings() | ||
| 61 | + self._controller_cfg = self._find_controller_cfg() | ||
| 62 | + logger.info( | ||
| 63 | + f"[VersatileAdapterRunner] 加载 {len(self._adapters)} 个 adapter 配置" | ||
| 64 | + f"(controller={self._controller_cfg.name if self._controller_cfg else '(none)'})" | ||
| 65 | + ) | ||
| 66 | + | ||
| 67 | + | ||
| 68 | + def _load_config(path: Path) -> list[_VersatileAdapterConfig]: | ||
| 69 | + if not path.exists(): | ||
| 70 | + logger.warning(f"[VersatileAdapterRunner] 配置文件不存在: {path},将从 Settings 生成") | ||
| 71 | + return [] | ||
| 72 | + with open(path, encoding="utf-8") as f: | ||
| 73 | + raw = yaml.safe_load(f) | ||
| 74 | + adapters_raw = raw.get("adapters", []) | ||
| 75 | + return [_VersatileAdapterConfig(b) for b in adapters_raw] | ||
| 76 | + | ||
| 77 | + | ||
| 78 | + def _build_from_settings() -> list[_VersatileAdapterConfig]: | ||
| 79 | + """YAML 配置文件不存在时,从 Settings 自动生成唯一的 controller 配置。""" | ||
| 80 | + settings = get_settings() | ||
| 81 | + raw = { | ||
| 82 | + "name": "default_controller", | ||
| 83 | + "type": "controller", | ||
| 84 | + "url_template": settings.versatile_url_template or "", | ||
| 85 | + "timeout": settings.versatile_timeout or 600, | ||
| 86 | + "headers_template": dict(settings.versatile_headers_template) | ||
| 87 | + if settings.versatile_headers_template else {}, | ||
| 88 | + "workflow_result_node": settings.versatile_workflow_result_node, | ||
| 89 | + } | ||
| 90 | + logger.info(f"[VersatileAdapterRunner] 从 Settings 自动生成 default_controller 配置: {raw}") | ||
| 91 | + return [_VersatileAdapterConfig(raw)] | ||
| 92 | + | ||
| 93 | + def _find_controller_cfg(self) -> Optional[_VersatileAdapterConfig]: | ||
| 94 | + for b in self._adapters: | ||
| 95 | + if b.type == "controller": | ||
| 96 | + return b | ||
| 97 | + return None | ||
| 98 | + | ||
| 99 | + def _match_workflow(self, target: dict) -> Optional[_VersatileAdapterConfig]: | ||
| 100 | + """根据 target 中的 workflow_id 或 intent 匹配 workflow 配置。""" | ||
| 101 | + for b in self._adapters: | ||
| 102 | + if b.type != "workflow": | ||
| 103 | + continue | ||
| 104 | + if b.workflow_id and target.get("workflow_id") == b.workflow_id: | ||
| 105 | + return b | ||
| 106 | + if b.intent and target.get("intent") == b.intent: | ||
| 107 | + return b | ||
| 108 | + return None | ||
| 109 | + | ||
| 110 | + | ||
| 111 | + def _create_adapter(cfg: _VersatileAdapterConfig): | ||
| 112 | + """根据配置创建对应的适配器实例。""" | ||
| 113 | + whitelist = cfg.forward_header_whitelist | ||
| 114 | + if cfg.type == "workflow": | ||
| 115 | + return VersatileWorkflow( | ||
| 116 | + url_template=cfg.url_template, | ||
| 117 | + workflow_id=cfg.workflow_id or "", | ||
| 118 | + timeout=cfg.timeout, | ||
| 119 | + headers_template=cfg.headers_template, | ||
| 120 | + forward_header_whitelist=whitelist, | ||
| 121 | + ) | ||
| 122 | + return VersatileController( | ||
| 123 | + url_template=cfg.url_template, | ||
| 124 | + timeout=cfg.timeout, | ||
| 125 | + headers_template=cfg.headers_template, | ||
| 126 | + forward_header_whitelist=whitelist, | ||
| 127 | + workflow_result_node=cfg.workflow_result_node, | ||
| 128 | + ) | ||
| 129 | + | ||
| 130 | + async def run_async(self, target: dict, headers: dict, params: dict, | ||
| 131 | + body: dict) -> AsyncGenerator[AdapterEvent, None]: | ||
| 132 | + """根据 target 动态匹配配置并创建适配器,驱动流。""" | ||
| 133 | + conv_id = target.get("conversation_id", "") | ||
| 134 | + wf_cfg = self._match_workflow(target) | ||
| 135 | + cfg = wf_cfg or self._controller_cfg | ||
| 136 | + if cfg is None: | ||
| 137 | + raise ValueError(f"无法匹配 adapter 配置: target={target}") | ||
| 138 | + | ||
| 139 | + logger.debug(f"[VersatileAdapterRunner] target 匹配 adapter={cfg.name} ({cfg.type})") | ||
| 140 | + adapter = self._create_adapter(cfg) | ||
| 141 | + | ||
| 142 | + async for event in adapter.dispatch_stream(conv_id, headers=headers, params=params, body=body): | ||
| 143 | + yield event | ||
| @@ -0,0 +1,2 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| @@ -0,0 +1,49 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +""" | ||
| 5 | +AdapterEvent — VersatileAdapterRunner 输出的标准化事件类型(discriminated union)。 | ||
| 6 | + | ||
| 7 | +设计原则: | ||
| 8 | + - 不为远端 SSE 流的每种格式定义事件类型 | ||
| 9 | + - 绝大部分通过 DataProxyContent 直接透传 | ||
| 10 | + - 仅对需特殊处理的节点定义专属类型 | ||
| 11 | + - 同一时刻仅一个内容字段非 None,通过 event_type() 判别 | ||
| 12 | +""" | ||
| 13 | +from __future__ import annotations | ||
| 14 | + | ||
| 15 | +from typing import Optional | ||
| 16 | + | ||
| 17 | +from pydantic import BaseModel, Field | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +class DataProxyContent(BaseModel, frozen=True): | ||
| 21 | + """原始后端帧数据原样透传。""" | ||
| 22 | + raw_data: str | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +class ExecutionInputRequiredContent(BaseModel, frozen=True): | ||
| 26 | + """非终态信号:需要前端继续输入以推进下一轮。""" | ||
| 27 | + pass | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +class ExecutionCompletedContent(BaseModel, frozen=True): | ||
| 31 | + """终态信号:任务完成并携带工作流结果。""" | ||
| 32 | + is_failed: bool = False | ||
| 33 | + result: str | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +class AdapterEvent(BaseModel): | ||
| 37 | + """Runner 输出的标准化事件(discriminated union)。 | ||
| 38 | + | ||
| 39 | + 同一时刻仅一个内容字段非 None。 | ||
| 40 | + """ | ||
| 41 | + data_proxy: Optional[DataProxyContent] = Field( | ||
| 42 | + default=None, description="原始后端帧数据原样透传。" | ||
| 43 | + ) | ||
| 44 | + execution_input_required: Optional[ExecutionInputRequiredContent] = Field( | ||
| 45 | + default=None, description="需要前端继续输入以推进下一轮。" | ||
| 46 | + ) | ||
| 47 | + execution_completed: Optional[ExecutionCompletedContent] = Field( | ||
| 48 | + default=None, description="任务完成并携带工作流结果。" | ||
| 49 | + ) | ||
| @@ -0,0 +1,2 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| @@ -0,0 +1,57 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +""" | ||
| 5 | +异步 Redis 客户端(redis-py asyncio)。 | ||
| 6 | +""" | ||
| 7 | +from __future__ import annotations | ||
| 8 | + | ||
| 9 | +from typing import Optional | ||
| 10 | +from urllib.parse import urlsplit, urlunsplit | ||
| 11 | + | ||
| 12 | +from loguru import logger | ||
| 13 | +from redis.asyncio import Redis | ||
| 14 | + | ||
| 15 | + | ||
| 16 | +class RedisClient: | ||
| 17 | + """轻量 Redis 封装,仅暴露项目用到的操作。""" | ||
| 18 | + | ||
| 19 | + def __init__(self) -> None: | ||
| 20 | + self._client: Optional[Redis] = None | ||
| 21 | + | ||
| 22 | + async def connect(self, url: str) -> None: | ||
| 23 | + self._client = Redis.from_url(url, decode_responses=True, protocol=2) | ||
| 24 | + await self._client.ping() | ||
| 25 | + safe_url = url | ||
| 26 | + parsed = urlsplit(url) | ||
| 27 | + if parsed.password is not None: | ||
| 28 | + username = parsed.username or "" | ||
| 29 | + host = parsed.hostname or "" | ||
| 30 | + if host and ":" in host and not host.startswith("["): | ||
| 31 | + host = f"[{host}]" | ||
| 32 | + port = f":{parsed.port}" if parsed.port is not None else "" | ||
| 33 | + masked_netloc = f"{username}:@{host}{port}" | ||
| 34 | + safe_url = urlunsplit((parsed.scheme, masked_netloc, parsed.path, parsed.query, parsed.fragment)) | ||
| 35 | + logger.info(f"[Redis] 已连接:{safe_url}") | ||
| 36 | + | ||
| 37 | + async def disconnect(self) -> None: | ||
| 38 | + if self._client: | ||
| 39 | + await self._client.aclose() | ||
| 40 | + self._client = None | ||
| 41 | + logger.info("[Redis] 连接已关闭") | ||
| 42 | + | ||
| 43 | + | ||
| 44 | + def client(self) -> Redis: | ||
| 45 | + if self._client is None: | ||
| 46 | + raise RuntimeError("RedisClient 未连接,请先调用 connect()") | ||
| 47 | + return self._client | ||
| 48 | + | ||
| 49 | + async def get(self, key: str) -> Optional[str]: | ||
| 50 | + return await self.client.get(key) | ||
| 51 | + | ||
| 52 | + async def set(self, key: str, value: str, ex: Optional[int] = None) -> None: | ||
| 53 | + await self.client.set(key, value, ex=ex) | ||
| 54 | + | ||
| 55 | + async def delete(self, *keys: str) -> None: | ||
| 56 | + if keys: | ||
| 57 | + await self.client.delete(*keys) | ||
| @@ -0,0 +1,53 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +""" | ||
| 5 | +Redis-backed A2A TaskStore。 | ||
| 6 | + | ||
| 7 | +Task protobuf 序列化为 base64 二进制存入 Redis,key 格式:a2a:task:{task_id}。 | ||
| 8 | +TTL 复用 redis_session_ttl 配置(秒),默认 1800 s。 | ||
| 9 | +""" | ||
| 10 | +from __future__ import annotations | ||
| 11 | + | ||
| 12 | +import base64 | ||
| 13 | +from typing import Optional | ||
| 14 | + | ||
| 15 | +from a2a.server.context import ServerCallContext | ||
| 16 | +from a2a.server.tasks.task_store import TaskStore | ||
| 17 | +from a2a.types.a2a_pb2 import ListTasksRequest, ListTasksResponse, Task | ||
| 18 | +from loguru import logger | ||
| 19 | + | ||
| 20 | +from persistence.redis_client import RedisClient | ||
| 21 | + | ||
| 22 | +_KEY_PREFIX = "vafacade:task:" | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +class RedisTaskStore(TaskStore): | ||
| 26 | + def __init__(self, redis: RedisClient, ttl: int = 1800) -> None: | ||
| 27 | + self._redis = redis | ||
| 28 | + self._ttl = ttl | ||
| 29 | + | ||
| 30 | + async def save(self, task: Task, context: ServerCallContext) -> None: | ||
| 31 | + key = _KEY_PREFIX + task.id | ||
| 32 | + data = base64.b64encode(task.SerializeToString()).decode("ascii") | ||
| 33 | + await self._redis.set(key, data, ex=self._ttl) | ||
| 34 | + logger.debug(f"[TaskStore] save task={task.id} state={task.status.state}") | ||
| 35 | + | ||
| 36 | + async def get(self, task_id: str, context: ServerCallContext) -> Optional[Task]: | ||
| 37 | + if not task_id: | ||
| 38 | + return None | ||
| 39 | + raw = await self._redis.get(_KEY_PREFIX + task_id) | ||
| 40 | + if raw is None: | ||
| 41 | + return None | ||
| 42 | + task = Task() | ||
| 43 | + task.ParseFromString(base64.b64decode(raw)) | ||
| 44 | + return task | ||
| 45 | + | ||
| 46 | + async def delete(self, task_id: str, context: ServerCallContext) -> None: | ||
| 47 | + await self._redis.delete(_KEY_PREFIX + task_id) | ||
| 48 | + logger.debug(f"[TaskStore] delete task={task_id}") | ||
| 49 | + | ||
| 50 | + async def list( | ||
| 51 | + self, params: ListTasksRequest, context: ServerCallContext | ||
| 52 | + ) -> ListTasksResponse: | ||
| 53 | + return ListTasksResponse() | ||
| @@ -36,7 +36,7 @@ requires = ["hatchling"] | |||
| 36 | build-backend = "hatchling.build" | 36 | build-backend = "hatchling.build" |
| 37 | 37 | ||
| 38 | [tool.hatch.build.targets.wheel] | 38 | [tool.hatch.build.targets.wheel] |
| 39 | -packages = ["common", "adapter"] | 39 | +packages = ["a2a_facade", "adapters", "dispatcher"] |
| 40 | 40 | ||
| 41 | [tool.pytest.ini_options] | 41 | [tool.pytest.ini_options] |
| 42 | asyncio_mode = "auto" | 42 | asyncio_mode = "auto" |
| @@ -1,95 +0,0 @@ | |||
| 1 | -# coding: utf-8 | ||
| 2 | -# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | - | ||
| 4 | -"""VA 配置层单元测试。 | ||
| 5 | - | ||
| 6 | -锁定 ``versatile_headers_template`` 字段的两条契约: | ||
| 7 | - | ||
| 8 | - 1. 默认值不含 ``Cookie``:避免把环境特定的 Session token(如 ``AGENT_SID=testUser|0``) | ||
| 9 | - 钉死在代码里。换测试环境时 Cookie 不需要的工作流不会被错误注入;需要的 | ||
| 10 | - 环境通过 ``VERSATILE_HEADERS_TEMPLATE`` 环境变量显式注入。 | ||
| 11 | - 2. 环境变量 ``VERSATILE_HEADERS_TEMPLATE`` 能覆盖默认值,注入自定义 Cookie。 | ||
| 12 | - | ||
| 13 | -历史(c1a46ad)曾把 ``AGENT_SID=testUser|0`` 硬编码进默认值,跨环境共用同一份代码 | ||
| 14 | -时会触发 VA "URL.project_id ↔ token.project_id mismatch" 的 403。改回纯通用头 | ||
| 15 | -(Accept/stream),把环境特定值收回到 .env / 部署配置。 | ||
| 16 | -""" | ||
| 17 | -from __future__ import annotations | ||
| 18 | - | ||
| 19 | -import importlib | ||
| 20 | - | ||
| 21 | -import pytest | ||
| 22 | - | ||
| 23 | - | ||
| 24 | -def _load_fresh_settings_class(): | ||
| 25 | - """重新 import config 模块,绕开 ``get_settings`` 的 ``@lru_cache``。 | ||
| 26 | - | ||
| 27 | - Settings 在 import 时把 env_file 路径绑死;在 monkeypatch 设置环境变量后再 | ||
| 28 | - 重 import 才会让新的环境变量被读到。 | ||
| 29 | - """ | ||
| 30 | - import config as _cfg | ||
| 31 | - return importlib.reload(_cfg) | ||
| 32 | - | ||
| 33 | - | ||
| 34 | -def test_default_headers_template_has_no_cookie(monkeypatch): | ||
| 35 | - """默认值不含 Cookie,避免把环境特定 Session token 钉死在代码里。""" | ||
| 36 | - # 显式清掉可能由 shell 环境引入的覆盖 | ||
| 37 | - monkeypatch.delenv("VERSATILE_HEADERS_TEMPLATE", raising=False) | ||
| 38 | - | ||
| 39 | - cfg = _load_fresh_settings_class() | ||
| 40 | - settings = cfg.Settings() | ||
| 41 | - | ||
| 42 | - headers = settings.versatile_headers_template | ||
| 43 | - assert isinstance(headers, dict) | ||
| 44 | - assert "Cookie" not in headers, ( | ||
| 45 | - f"默认 headers 不应注入 Cookie,但拿到 {headers!r};" | ||
| 46 | - f"环境特定 token 必须由 VERSATILE_HEADERS_TEMPLATE env 显式提供" | ||
| 47 | - ) | ||
| 48 | - # 通用的非环境特定头仍然保留 | ||
| 49 | - assert headers.get("Accept") == "application/json, text/event-stream" | ||
| 50 | - assert headers.get("stream") == "true" | ||
| 51 | - | ||
| 52 | - | ||
| 53 | -def test_env_override_can_inject_cookie(monkeypatch): | ||
| 54 | - """env 设置 VERSATILE_HEADERS_TEMPLATE 时能注入 Cookie,覆盖默认值。""" | ||
| 55 | - monkeypatch.setenv( | ||
| 56 | - "VERSATILE_HEADERS_TEMPLATE", | ||
| 57 | - '{"Cookie":"AGENT_SID=realUser|7",' | ||
| 58 | - '"Accept":"application/json, text/event-stream",' | ||
| 59 | - '"stream":"true"}', | ||
| 60 | - ) | ||
| 61 | - | ||
| 62 | - cfg = _load_fresh_settings_class() | ||
| 63 | - settings = cfg.Settings() | ||
| 64 | - | ||
| 65 | - headers = settings.versatile_headers_template | ||
| 66 | - assert headers.get("Cookie") == "AGENT_SID=realUser|7" | ||
| 67 | - assert headers.get("Accept") == "application/json, text/event-stream" | ||
| 68 | - assert headers.get("stream") == "true" | ||
| 69 | - | ||
| 70 | - | ||
| 71 | -def test_env_example_does_not_have_empty_assignment(): | ||
| 72 | - """回归测试:``.env.example`` 不能含 ``VERSATILE_HEADERS_TEMPLATE=`` 空赋值。 | ||
| 73 | - | ||
| 74 | - pydantic Json 字段 parse 空字符串会抛 ValidationError 导致 VA 启动失败。 | ||
| 75 | - 若要在 .env.example 里展示该字段,必须以 ``# `` 注释开头(示范用途), | ||
| 76 | - 或者完全不出现这一行(未设 env → 走代码默认)。 | ||
| 77 | - | ||
| 78 | - 历史教训:4891ac0 提交曾留下 ``VERSATILE_HEADERS_TEMPLATE=`` 空赋值,复制 | ||
| 79 | - .env.example → .env 就会让 VA 启动失败;本测试防止再次踩坑。 | ||
| 80 | - """ | ||
| 81 | - from pathlib import Path | ||
| 82 | - | ||
| 83 | - env_example = Path(__file__).parent.parent / ".env.example" | ||
| 84 | - assert env_example.exists(), f".env.example 缺失:{env_example}" | ||
| 85 | - for lineno, raw in enumerate(env_example.read_text(encoding="utf-8").splitlines(), 1): | ||
| 86 | - line = raw.lstrip() | ||
| 87 | - # 注释行允许出现 | ||
| 88 | - if line.startswith("#"): | ||
| 89 | - continue | ||
| 90 | - if line.startswith("VERSATILE_HEADERS_TEMPLATE="): | ||
| 91 | - value = line.split("=", 1)[1].strip() | ||
| 92 | - assert value, ( | ||
| 93 | - f".env.example 第 {lineno} 行存在空赋值 ``VERSATILE_HEADERS_TEMPLATE=``," | ||
| 94 | - f"会让 pydantic Json 字段 parse 失败、VA 启动失败。请删除该行或改为注释(# 开头)" | ||
| 95 | - ) | ||
| @@ -1,121 +0,0 @@ | |||
| 1 | -# coding: utf-8 | ||
| 2 | -# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | - | ||
| 4 | -"""Unit tests for adapter.versatile_proxy. | ||
| 5 | - | ||
| 6 | -Drives Solution A: proxy must peel the upstream Versatile envelope so | ||
| 7 | -downstream receives flat {event, data} frames instead of a double-nested | ||
| 8 | -custom_rsp_data. | ||
| 9 | -""" | ||
| 10 | -from __future__ import annotations | ||
| 11 | - | ||
| 12 | -import pytest | ||
| 13 | - | ||
| 14 | -from adapter.versatile_proxy import VersatileProxy, _unwrap_upstream_frame | ||
| 15 | - | ||
| 16 | - | ||
| 17 | -def test_unwrap_upstream_frame_message_yields_event_and_data(): | ||
| 18 | - """A Versatile 'message' frame is peeled to {event: 'message', data: {...}}.""" | ||
| 19 | - upstream = { | ||
| 20 | - "success": True, | ||
| 21 | - "agent_id": "a-1", | ||
| 22 | - "conversation_id": "c-1", | ||
| 23 | - "custom_rsp_data": { | ||
| 24 | - "event": "message", | ||
| 25 | - "data": { | ||
| 26 | - "node_type": "QA", | ||
| 27 | - "node_name": "xxx", | ||
| 28 | - "text": "hi", | ||
| 29 | - }, | ||
| 30 | - }, | ||
| 31 | - } | ||
| 32 | - assert _unwrap_upstream_frame(upstream) == { | ||
| 33 | - "event": "message", | ||
| 34 | - "data": { | ||
| 35 | - "node_type": "QA", | ||
| 36 | - "node_name": "xxx", | ||
| 37 | - "text": "hi", | ||
| 38 | - }, | ||
| 39 | - } | ||
| 40 | - | ||
| 41 | - | ||
| 42 | -def test_unwrap_upstream_frame_end_yields_empty_data(): | ||
| 43 | - """A Versatile 'end' frame has no data — peel to {event: 'end', data: {}}.""" | ||
| 44 | - upstream = { | ||
| 45 | - "success": True, | ||
| 46 | - "agent_id": "a-1", | ||
| 47 | - "custom_rsp_data": {"event": "end"}, | ||
| 48 | - } | ||
| 49 | - assert _unwrap_upstream_frame(upstream) == {"event": "end", "data": {}} | ||
| 50 | - | ||
| 51 | - | ||
| 52 | -def test_unwrap_upstream_frame_missing_custom_rsp_data_returns_safe_default(): | ||
| 53 | - """Malformed upstream without custom_rsp_data falls back to a 'message' frame. | ||
| 54 | - | ||
| 55 | - Frame carries the raw payload, so downstream can still reason about it. | ||
| 56 | - """ | ||
| 57 | - upstream = {"success": True, "garbled": "payload"} | ||
| 58 | - result = _unwrap_upstream_frame(upstream) | ||
| 59 | - assert result["event"] == "message" | ||
| 60 | - assert result["data"] == upstream | ||
| 61 | - | ||
| 62 | - | ||
| 63 | -def test_unwrap_upstream_frame_non_dict_returns_safe_default(): | ||
| 64 | - """Defensive: if upstream line isn't even a dict, return a safe empty frame.""" | ||
| 65 | - assert _unwrap_upstream_frame(None) == {"event": "message", "data": {}} # type: ignore[arg-type] | ||
| 66 | - assert _unwrap_upstream_frame([1, 2, 3]) == {"event": "message", "data": {}} # type: ignore[arg-type] | ||
| 67 | - | ||
| 68 | - | ||
| 69 | -def test_unwrap_upstream_frame_already_unwrapped_message_passes_through(): | ||
| 70 | - """Some upstream gateways send {event, data} directly without the envelope. | ||
| 71 | - | ||
| 72 | - Peel should pass through unchanged (don't double-wrap). | ||
| 73 | - """ | ||
| 74 | - already_unwrapped = { | ||
| 75 | - "event": "message", | ||
| 76 | - "data": {"node_type": "QA", "node_name": "xxx", "text": "hi"}, | ||
| 77 | - } | ||
| 78 | - assert _unwrap_upstream_frame(already_unwrapped) == already_unwrapped | ||
| 79 | - | ||
| 80 | - | ||
| 81 | -def test_unwrap_upstream_frame_already_unwrapped_end_passes_through(): | ||
| 82 | - """Already-unwrapped end frame.""" | ||
| 83 | - already_unwrapped_end = {"event": "end", "data": {"node_type": "End"}} | ||
| 84 | - assert _unwrap_upstream_frame(already_unwrapped_end) == already_unwrapped_end | ||
| 85 | - | ||
| 86 | - | ||
| 87 | -def test_unwrap_upstream_frame_already_unwrapped_end_without_data(): | ||
| 88 | - """Already-unwrapped end frame with no data key → data defaults to {}.""" | ||
| 89 | - assert _unwrap_upstream_frame({"event": "end"}) == {"event": "end", "data": {}} | ||
| 90 | - | ||
| 91 | - | ||
| 92 | -# ════════════════════════════════════════════════════════════════════ | ||
| 93 | -# dispatch_stream —— 端到端形状 | ||
| 94 | -# ════════════════════════════════════════════════════════════════════ | ||
| 95 | - | ||
| 96 | - | ||
| 97 | - | ||
| 98 | -async def test_dispatch_stream_yields_unwrapped_frames_only(httpx_mock): | ||
| 99 | - """从 httpx SSE 原始行到 yield 出的 chunk:只能是 {event, data},无外层 envelope。""" | ||
| 100 | - sse_body = ( | ||
| 101 | - 'data: {"success":true,"agent_id":"a","conversation_id":"c",' | ||
| 102 | - '"custom_rsp_data":{"event":"message","data":{"node_type":"QA","text":"hi"}}}\n\n' | ||
| 103 | - 'data: {"success":true,"custom_rsp_data":{"event":"end"}}\n\n' | ||
| 104 | - ) | ||
| 105 | - httpx_mock.add_response( | ||
| 106 | - url="https://va.test/v1/conv-1", method="POST", | ||
| 107 | - content=sse_body.encode("utf-8"), | ||
| 108 | - headers={"Content-Type": "text/event-stream"}, | ||
| 109 | - ) | ||
| 110 | - | ||
| 111 | - proxy = VersatileProxy(url_template="https://va.test/v1/{conversation_id}") | ||
| 112 | - stream = proxy.dispatch_stream(body={"custom_data": {}}, conv_id="conv-1") | ||
| 113 | - chunks = [chunk async for chunk in stream] | ||
| 114 | - | ||
| 115 | - assert chunks == [ | ||
| 116 | - {"event": "message", "data": {"node_type": "QA", "text": "hi"}}, | ||
| 117 | - {"event": "end", "data": {}}, | ||
| 118 | - ] | ||
| 119 | - # 明确不允许出现任何外层 envelope 字段 | ||
| 120 | - for chunk in chunks: | ||
| 121 | - assert set(chunk.keys()) == {"event", "data"} | ||
| @@ -1,205 +0,0 @@ | |||
| 1 | -# coding: utf-8 | ||
| 2 | -# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | - | ||
| 4 | -"""VersatileProxy 静态 headers 模板 + Cookie 白名单 + 异常正文记录单元测试。 | ||
| 5 | - | ||
| 6 | -背景: | ||
| 7 | - - issue 2026-04-28:versatile_adapter 调用 Versatile 时丢失认证头(Cookie/AGENT_SID), | ||
| 8 | - 导致依赖 AGENT_SID Session 的工作流(如 FUND_BETA)必然失败。 | ||
| 9 | - - 修复方向(issue 第五章 5.1/5.2/5.4): | ||
| 10 | - 5.1 引入静态 headers 模板,构造 VersatileProxy 时注入;调用时作为兜底,无论 | ||
| 11 | - 上游是否传 Cookie 都能保证发到 Versatile。 | ||
| 12 | - 5.2 把 cookie 加进白名单,让上游动态 Cookie 能覆盖模板。 | ||
| 13 | - 5.4 HTTP 错误分支记录响应正文,便于定位 Versatile 业务错误。 | ||
| 14 | - | ||
| 15 | -本测试锁定上述三条契约。 | ||
| 16 | -""" | ||
| 17 | -from __future__ import annotations | ||
| 18 | - | ||
| 19 | -import logging | ||
| 20 | - | ||
| 21 | -import pytest | ||
| 22 | -from loguru import logger | ||
| 23 | - | ||
| 24 | -from adapter.versatile_proxy import VersatileProxy | ||
| 25 | - | ||
| 26 | - | ||
| 27 | - | ||
| 28 | -def caplog_loguru(caplog): | ||
| 29 | - """让 loguru 的日志走 stdlib logging,从而能被 caplog 捕获。""" | ||
| 30 | - handler_id = logger.add( | ||
| 31 | - caplog.handler, | ||
| 32 | - format="{message}", | ||
| 33 | - level="DEBUG", | ||
| 34 | - filter=lambda r: True, | ||
| 35 | - ) | ||
| 36 | - caplog.set_level(logging.DEBUG) | ||
| 37 | - yield caplog | ||
| 38 | - logger.remove(handler_id) | ||
| 39 | - | ||
| 40 | - | ||
| 41 | -# ════════════════════════════════════════════════════════════════════ | ||
| 42 | -# 5.1 静态模板兜底 | ||
| 43 | -# ════════════════════════════════════════════════════════════════════ | ||
| 44 | - | ||
| 45 | - | ||
| 46 | - | ||
| 47 | -async def test_dispatch_stream_injects_headers_from_template(httpx_mock): | ||
| 48 | - """构造 VersatileProxy 时传入 headers_template,每次调用都会带上模板里的头。""" | ||
| 49 | - httpx_mock.add_response( | ||
| 50 | - url="https://va.test/v1/conv-1", | ||
| 51 | - method="POST", | ||
| 52 | - content=b'data: {"event":"end"}\n\n', | ||
| 53 | - headers={"Content-Type": "text/event-stream"}, | ||
| 54 | - ) | ||
| 55 | - | ||
| 56 | - proxy = VersatileProxy( | ||
| 57 | - url_template="https://va.test/v1/{conversation_id}", | ||
| 58 | - headers_template={ | ||
| 59 | - "Cookie": "AGENT_SID=testUser|0", | ||
| 60 | - "Accept": "application/json, text/event-stream", | ||
| 61 | - }, | ||
| 62 | - ) | ||
| 63 | - stream = proxy.dispatch_stream(body={"custom_data": {}}, conv_id="conv-1") | ||
| 64 | - [_ async for _ in stream] | ||
| 65 | - | ||
| 66 | - sent = httpx_mock.get_requests()[0] | ||
| 67 | - assert sent.headers["Cookie"] == "AGENT_SID=testUser|0" | ||
| 68 | - # 模板里的非白名单头也应该带上(对齐 AgentEngine 行为) | ||
| 69 | - assert sent.headers["Accept"] == "application/json, text/event-stream" | ||
| 70 | - | ||
| 71 | - | ||
| 72 | - | ||
| 73 | -async def test_dispatch_stream_template_applies_when_extra_headers_empty(httpx_mock): | ||
| 74 | - """没有 extra_headers 时,仍然要带模板里的头;Content-Type 必须保留。""" | ||
| 75 | - httpx_mock.add_response( | ||
| 76 | - url="https://va.test/v1/conv-2", | ||
| 77 | - method="POST", | ||
| 78 | - content=b'data: {"event":"end"}\n\n', | ||
| 79 | - headers={"Content-Type": "text/event-stream"}, | ||
| 80 | - ) | ||
| 81 | - | ||
| 82 | - proxy = VersatileProxy( | ||
| 83 | - url_template="https://va.test/v1/{conversation_id}", | ||
| 84 | - headers_template={"Cookie": "AGENT_SID=testUser|0"}, | ||
| 85 | - ) | ||
| 86 | - stream = proxy.dispatch_stream(body={"custom_data": {}}, conv_id="conv-2") | ||
| 87 | - [_ async for _ in stream] | ||
| 88 | - | ||
| 89 | - sent = httpx_mock.get_requests()[0] | ||
| 90 | - assert sent.headers["Cookie"] == "AGENT_SID=testUser|0" | ||
| 91 | - assert sent.headers["Content-Type"] == "application/json" | ||
| 92 | - | ||
| 93 | - | ||
| 94 | - | ||
| 95 | -async def test_dispatch_stream_no_template_keeps_default_content_type(httpx_mock): | ||
| 96 | - """不传 headers_template 时维持原行为:仅保留 Content-Type,不主动注入 Cookie。""" | ||
| 97 | - httpx_mock.add_response( | ||
| 98 | - url="https://va.test/v1/conv-3", | ||
| 99 | - method="POST", | ||
| 100 | - content=b'data: {"event":"end"}\n\n', | ||
| 101 | - headers={"Content-Type": "text/event-stream"}, | ||
| 102 | - ) | ||
| 103 | - | ||
| 104 | - proxy = VersatileProxy(url_template="https://va.test/v1/{conversation_id}") | ||
| 105 | - stream = proxy.dispatch_stream(body={"custom_data": {}}, conv_id="conv-3") | ||
| 106 | - [_ async for _ in stream] | ||
| 107 | - | ||
| 108 | - sent = httpx_mock.get_requests()[0] | ||
| 109 | - assert sent.headers["Content-Type"] == "application/json" | ||
| 110 | - assert "Cookie" not in sent.headers | ||
| 111 | - | ||
| 112 | - | ||
| 113 | -# ════════════════════════════════════════════════════════════════════ | ||
| 114 | -# 5.2 cookie 加入白名单 + 动态 Cookie 覆盖模板 | ||
| 115 | -# ════════════════════════════════════════════════════════════════════ | ||
| 116 | - | ||
| 117 | - | ||
| 118 | - | ||
| 119 | -async def test_dispatch_stream_dynamic_cookie_overrides_template(httpx_mock): | ||
| 120 | - """上游 extra_headers 里的 Cookie 必须能覆盖模板(白名单允许 cookie)。""" | ||
| 121 | - httpx_mock.add_response( | ||
| 122 | - url="https://va.test/v1/conv-4", | ||
| 123 | - method="POST", | ||
| 124 | - content=b'data: {"event":"end"}\n\n', | ||
| 125 | - headers={"Content-Type": "text/event-stream"}, | ||
| 126 | - ) | ||
| 127 | - | ||
| 128 | - proxy = VersatileProxy( | ||
| 129 | - url_template="https://va.test/v1/{conversation_id}", | ||
| 130 | - headers_template={"Cookie": "AGENT_SID=fallback|0"}, | ||
| 131 | - ) | ||
| 132 | - stream = proxy.dispatch_stream( | ||
| 133 | - body={"custom_data": {}}, | ||
| 134 | - conv_id="conv-4", | ||
| 135 | - extra_headers={"Cookie": "AGENT_SID=realUser|7"}, | ||
| 136 | - ) | ||
| 137 | - [_ async for _ in stream] | ||
| 138 | - | ||
| 139 | - sent = httpx_mock.get_requests()[0] | ||
| 140 | - assert sent.headers["Cookie"] == "AGENT_SID=realUser|7" | ||
| 141 | - | ||
| 142 | - | ||
| 143 | - | ||
| 144 | -async def test_dispatch_stream_existing_whitelist_headers_still_pass_through(httpx_mock): | ||
| 145 | - """现有白名单(x-user-id 等)必须维持透传,避免回归。""" | ||
| 146 | - httpx_mock.add_response( | ||
| 147 | - url="https://va.test/v1/conv-5", | ||
| 148 | - method="POST", | ||
| 149 | - content=b'data: {"event":"end"}\n\n', | ||
| 150 | - headers={"Content-Type": "text/event-stream"}, | ||
| 151 | - ) | ||
| 152 | - | ||
| 153 | - proxy = VersatileProxy( | ||
| 154 | - url_template="https://va.test/v1/{conversation_id}", | ||
| 155 | - headers_template={"Cookie": "AGENT_SID=testUser|0"}, | ||
| 156 | - ) | ||
| 157 | - stream = proxy.dispatch_stream( | ||
| 158 | - body={"custom_data": {}}, | ||
| 159 | - conv_id="conv-5", | ||
| 160 | - extra_headers={ | ||
| 161 | - "x-user-id": "u-1", | ||
| 162 | - "x-project-id": "p-1", | ||
| 163 | - "cust-token": "tok-xyz", | ||
| 164 | - "X-Should-Be-Filtered": "leak", | ||
| 165 | - }, | ||
| 166 | - ) | ||
| 167 | - [_ async for _ in stream] | ||
| 168 | - | ||
| 169 | - sent = httpx_mock.get_requests()[0] | ||
| 170 | - assert sent.headers["x-user-id"] == "u-1" | ||
| 171 | - assert sent.headers["x-project-id"] == "p-1" | ||
| 172 | - assert sent.headers["cust-token"] == "tok-xyz" | ||
| 173 | - # 非白名单头仍应被丢弃 | ||
| 174 | - assert "x-should-be-filtered" not in {k.lower() for k in sent.headers.keys()} | ||
| 175 | - | ||
| 176 | - | ||
| 177 | -# ════════════════════════════════════════════════════════════════════ | ||
| 178 | -# 5.4 异常分支记录响应正文 | ||
| 179 | -# ════════════════════════════════════════════════════════════════════ | ||
| 180 | - | ||
| 181 | - | ||
| 182 | - | ||
| 183 | -async def test_dispatch_stream_logs_response_body_on_http_error( | ||
| 184 | - httpx_mock, caplog_loguru | ||
| 185 | -): | ||
| 186 | - """上游返回 5xx 时,错误日志要包含响应正文(用于诊断 Versatile 业务错误)。""" | ||
| 187 | - error_body = ( | ||
| 188 | - '{"event":"error","data":{"code":"103104",' | ||
| 189 | - '"message":"NoneType has no attribute content"}}' | ||
| 190 | - ) | ||
| 191 | - httpx_mock.add_response( | ||
| 192 | - url="https://va.test/v1/conv-err", | ||
| 193 | - method="POST", | ||
| 194 | - status_code=500, | ||
| 195 | - content=error_body.encode("utf-8"), | ||
| 196 | - ) | ||
| 197 | - | ||
| 198 | - proxy = VersatileProxy(url_template="https://va.test/v1/{conversation_id}") | ||
| 199 | - stream = proxy.dispatch_stream(body={"custom_data": {}}, conv_id="conv-err") | ||
| 200 | - [_ async for _ in stream] | ||
| 201 | - | ||
| 202 | - combined = " ".join(rec.getMessage() for rec in caplog_loguru.records) | ||
| 203 | - assert "103104" in combined, ( | ||
| 204 | - f"HTTPStatusError 日志应包含响应正文(含 103104),实际:{combined!r:.300}" | ||
| 205 | - ) | ||
| @@ -0,0 +1,48 @@ | |||
| 1 | +# versatile_proxy.yaml — VersatileAdapterRunner 动态路由配置示例 | ||
| 2 | +# | ||
| 3 | +# 部署路径(默认):/etc/edpagent/config/versatile_proxy.yaml | ||
| 4 | +# 若该文件不存在,Runner 会自动从 Settings(.env / 环境变量)生成唯一的 controller 配置。 | ||
| 5 | +# | ||
| 6 | +# adapters 列表可包含多个 adapter,运行时按 route 动态匹配: | ||
| 7 | +# - route 含 workflow_id / intent 且匹配到 workflow 配置 → VersatileWorkflow | ||
| 8 | +# - 否则 → VersatileController(使用第一个 type=controller 的配置) | ||
| 9 | +# | ||
| 10 | +# 各字段说明: | ||
| 11 | +# name — 适配器名称(日志标识) | ||
| 12 | +# type — controller(一级控制器)或 workflow(低码工作流) | ||
| 13 | +# url_template — URL 模板,支持占位符 {conversation_id}、{workflow_id} | ||
| 14 | +# timeout — HTTP 流式请求超时(秒),默认 600 | ||
| 15 | +# headers_template — 调用 Versatile 时注入的默认请求头 | ||
| 16 | +# forward_header_whitelist — 仅转发白名单中的外部请求头(小写);不设置则转发全部 | ||
| 17 | +# workflow_result_node — (仅 controller)SSE 流中匹配 node_type=="QA" 且 | ||
| 18 | +# node_name==此值的帧,提取 text 作为 workflow_result | ||
| 19 | +# workflow_id — (仅 workflow)route 中 workflow_id 精确匹配时命中 | ||
| 20 | +# intent — (仅 workflow)route 中 intent 匹配时命中 | ||
| 21 | + | ||
| 22 | +adapters: | ||
| 23 | + - name: default_controller | ||
| 24 | + type: controller | ||
| 25 | + url_template: "https://versatile.example.com/api/conversations/{conversation_id}" | ||
| 26 | + timeout: 600 | ||
| 27 | + headers_template: | ||
| 28 | + Accept: "application/json, text/event-stream" | ||
| 29 | + stream: "true" | ||
| 30 | + forward_header_whitelist: | ||
| 31 | + - x-user-id | ||
| 32 | + - x-project-id | ||
| 33 | + - cust-token | ||
| 34 | + - cust-userid | ||
| 35 | + - cookie | ||
| 36 | + workflow_result_node: "" | ||
| 37 | + | ||
| 38 | + - name: versatile_workflow_1 | ||
| 39 | + type: workflow | ||
| 40 | + intent: "knowledge_qa" | ||
| 41 | + url_template: "https://versatile.example.com/workflows/{workflow_id}/conversations/{conversation_id}" | ||
| 42 | + timeout: 600 | ||
| 43 | + headers_template: | ||
| 44 | + Accept: "application/json, text/event-stream" | ||
| 45 | + stream: "true" | ||
| 46 | + forward_header_whitelist: | ||
| 47 | + - x-trace-id | ||
| 48 | + workflow_id: "wf_abc123" | ||