已合并
support multi-workflow adapter #321
support multi-workflow adapter #321
已合并
xiongxing创建于 6月10日
27 个文件变更+1006-932
@@ -89,9 +89,6 @@ DPA_AGENT_ID=edp_agent
89DPA_AGENT_NAME=EDP Agent89DPA_AGENT_NAME=EDP Agent
90DPA_MAX_ITERATIONS=3090DPA_MAX_ITERATIONS=30
91 91 
92-# ── Versatile 工作流最终结果节点名(a2a_service/config.py 读取)───────────
93-va_workflow_result_node=
94- 
95# ── 沙箱配置 ──92# ── 沙箱配置 ──
96# 沙箱服务地址93# 沙箱服务地址
97SANDBOX_URL=94SANDBOX_URL=
@@ -28,6 +28,7 @@ from a2a.server.context import ServerCallContext
28from a2a.server.events import EventQueue28from a2a.server.events import EventQueue
29from a2a.types.a2a_pb2 import (29from 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 MessageToDict45+from google.protobuf.json_format import MessageToDict, ParseDict
45from google.protobuf.struct_pb2 import Struct, Value46from google.protobuf.struct_pb2 import Struct, Value
46from loguru import logger47from 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_update395 return stream_resp.artifact_update
400- if which == "status_update":396+ if stream_resp.HasField("status_update"):
401 return stream_resp.status_update397 return stream_resp.status_update
402 return None398 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_node484+ DataPart 为纯文本 string_value。
520- if not target_node:485+ """
486+ if not event.status or not event.status.message:
521 return None487 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 None490+ 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 None492+ if meta.get("vatype") == "workflow_result":
493+ return part.text
527 return None494 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] = None587 va_real_task_id: Optional[str] = None
586 continuation_task_id = ""588 continuation_task_id = ""
587- 589+ 
588 # 从 delegate.target_agent 获取 agent_id590 # 从 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 = False606 has_end_node = False
605- final_result: dict | None = None
606 qa_result: Optional[str] = None607 qa_result: Optional[str] = None
607 upstream_error: Optional[dict] = None608 upstream_error: Optional[dict] = None
608 stream_resp_count = 0609 stream_resp_count = 0
609 forwarded_count = 0610 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 += 1658+ 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 = True663 has_end_node = True
682- final_result = result664+ # 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 = 1679 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_result708+ {"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 = 0752 status_message = 0
770 error_message: Optional[str] = None753 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 = False768 has_end_node = False
786- final_result: dict | None = None
787 qa_result: Optional[str] = None769 qa_result: Optional[str] = None
788 upstream_error: Optional[dict] = None770 upstream_error: Optional[dict] = None
789 stream_resp_count = 0771 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 = True810 has_end_node = True
833- final_result = result811+ # 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 = err816+ 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 = 1822 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_result850+ {"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 ───────────────────────────────────────────────────────────────────
18ADAPTER_FASTAPI_HOST=0.0.0.024ADAPTER_FASTAPI_HOST=0.0.0.0
19ADAPTER_FASTAPI_PORT=809125ADAPTER_FASTAPI_PORT=8091
20ADAPTER_FASTAPI_DEBUG=False26ADAPTER_FASTAPI_DEBUG=False
21ADAPTER_FASTAPI_WORKERS=127ADAPTER_FASTAPI_WORKERS=1
22 28 
29+# ── Versatile 工作流最终结果节点名 ───────────
30+va_workflow_result_node=
31+ 
23# ── Log ───────────────────────────────────────────────────────────────────────32# ── Log ───────────────────────────────────────────────────────────────────────
24ADAPTER_LOG_LEVEL=DEBUG33ADAPTER_LOG_LEVEL=DEBUG
25ADAPTER_LOG_FILE=logs/versatile_adapter.log34ADAPTER_LOG_FILE=logs/versatile_adapter.log
Rapplications/versatile_adapter/adapter/__init__.pyapplications/versatile_adapter/a2a_facade/__init__.py+0-0
文件重命名但无更改。
Rapplications/versatile_adapter/adapter/agent_card.pyapplications/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+ @override
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+ @override
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+ @staticmethod
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+ @staticmethod
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- @override
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- @override
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- @staticmethod
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__.pyapplications/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+ @abstractmethod
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+ @abstractmethod
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+ @abstractmethod
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+ @staticmethod
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 
24from a2a.server.request_handlers import DefaultRequestHandler24from a2a.server.request_handlers import DefaultRequestHandler
25from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes25from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
26-from a2a.server.tasks import InMemoryTaskStore
27from fastapi import FastAPI26from fastapi import FastAPI
28from loguru import logger27from loguru import logger
29from starlette.applications import Starlette28from 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
31from config import get_settings33from config import get_settings
32-from adapter.agent_card import VERSATILE_ADAPTER_CARD34+from a2a_facade.agent_card import VERSATILE_ADAPTER_CARD
33-from adapter.executor import VersatileAdapterExecutor35+from dispatcher.runner import VersatileAdapterRunner
34-from adapter.versatile_proxy import VersatileProxy36+from a2a_facade.executor import A2aVersatileExecutor
35 37 
36 38 
37os.environ['NO_PROXY'] = 'localhost,127.0.0.1'39os.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_file81 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:
103setup_logging()102setup_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@asynccontextmanager124@asynccontextmanager
107async def lifespan(fastapi_app: FastAPI):125async 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 yield151 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
7from pathlib import Path7from pathlib import Path
8from typing import Any, Dict, Optional8from typing import Any, Dict, Optional
9 9 
10-from pydantic import Json10+from pydantic import Field, Json
11from pydantic_settings import BaseSettings, SettingsConfigDict11from 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] = None45 versatile_url_template: Optional[str] = None
44 versatile_timeout: Optional[int] = None46 versatile_timeout: Optional[int] = None
45 versatile_headers_template: Json[Dict[str, Any]] = _DEFAULT_VERSATILE_HEADERS_TEMPLATE47 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] = None54 adapter_fastapi_host: Optional[str] = None
@@ -54,6 +60,21 @@ class Settings(BaseSettings):
54 adapter_log_level: Optional[str] = None60 adapter_log_level: Optional[str] = None
55 adapter_log_file: Optional[str] = None61 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+ @property
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@lru_cache79@lru_cache
59def get_settings() -> Settings:80def 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+ @staticmethod
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+ @staticmethod
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+ @staticmethod
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+ @property
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"]
36build-backend = "hatchling.build"36build-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]
42asyncio_mode = "auto"42asyncio_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-@pytest.mark.asyncio
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-@pytest.fixture
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-@pytest.mark.asyncio
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-@pytest.mark.asyncio
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-@pytest.mark.asyncio
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-@pytest.mark.asyncio
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-@pytest.mark.asyncio
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-@pytest.mark.asyncio
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"