diff --git a/.env.example b/.env.example index 717dc1bb..7945f4e7 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ # A2A-T SDK Configuration # Copy this file to .env and fill in your values -# Full A2A/Order protocol messages are emitted by the dedicated PROTOCOL DEBUG logger. +# Full A2A protocol messages are emitted by the dedicated PROTOCOL DEBUG logger. # Keep sensitive values hidden by default; set true only during controlled local debugging. WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=false # Set false to omit request/response bodies; large bodies are truncated at this character count. @@ -41,7 +41,7 @@ A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_API_KEY=your-api-key-here A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_MAX_TOKENS=2000 +A2AT_LLM_MAX_TOKENS=4000 A2AT_LLM_TEMPERATURE=0 A2AT_LLM_TIMEOUT_SECONDS=60 A2AT_LLM_HISTORY_WINDOW=10 @@ -55,21 +55,8 @@ A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory # Generate with: openssl rand -hex 32 A2AT_CRED_KEY=your-32-byte-hex-key-here -# Workbench outbound transport: order (Eastcom SDK), mock, or direct -A2A_TRANSPORT_MODE=order -A2A_MOCK_GATEWAY_URL=http://127.0.0.1:26400 - -# Eastcom instruction platform. Configure either a default NE or per-agent NE routes. -# true starts the local RSocket-RPC protocol simulator; never enable it in production. -EASTCOM_ORDER_SIMULATOR_ENABLED=false -EASTCOM_ORDER_HOST=10.x.x.x -EASTCOM_ORDER_PORT=0 -EASTCOM_ORDER_USERNAME=your-username -EASTCOM_ORDER_PASSWORD=your-password -EASTCOM_ORDER_CLIENT_ID= -EASTCOM_ORDER_CLIENT_SECRET= -EASTCOM_ORDER_DEFAULT_NE= -EASTCOM_ORDER_CITY1_NE=city1-omc -EASTCOM_ORDER_CITY2_NE=city2-omc -EASTCOM_ORDER_LOGIN_TIMEOUT_SECONDS=15 -EASTCOM_ORDER_TIMEOUT_SECONDS=600 +# Keep true for production HTTPS. Set false only for local/self-signed integration testing. +A2A_SSL_VERIFY=false +# Comma-separated classpath:, file: URI, or absolute file locations. Leave empty for bundled cards. +# For a live OMC, point the city AgentCards at files exported by the registry center. +A2A_AGENT_CARD_LOCATIONS= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e19fdbbe..0f7cc79a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: build-and-test: runs-on: ubuntu-latest name: Build and Test (workflow-engine, spring-boot-starter) - + steps: - name: Checkout code uses: actions/checkout@v4 @@ -39,11 +39,11 @@ jobs: spring-boot-starter/target/surefire-reports/ retention-days: 7 - samples-compileilation-check: + samples-compilation-check: runs-on: ubuntu-latest name: Samples Compilation Check (optional) continue-on-error: true - + steps: - name: Checkout code uses: actions/checkout@v4 @@ -55,7 +55,5 @@ jobs: distribution: 'temurin' cache: 'maven' - - name: Check samples compilation - run: | - echo "::notice::Samples module depends on Eastcom SDK (not on Maven Central). Skipping compilation check in CI." - echo "::notice::Local verification: mvn -pl samples -am compile" + - name: Build and test samples + run: mvn -B -pl samples -am verify diff --git a/CHANGELOG.md b/CHANGELOG.md index b5fc2c4b..b68f9c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased — business callback boundary + +- Use published A2A-T 1.1.0 from Maven Central; remove SDK source checkout/install from CI; move content generation, validation, templates and SDK initialization to the host. +- onTask returns final MessageContent; onNegotiation returns Send/Stop. Remove engine profiles and content handlers. +- Preserve complete ReceivedMessage and metadata layers alongside deterministic convenience outputs; keep local nested multi-output values. +- Deduplicate negotiation rounds, separate resource budgets from protocol rounds, and suppress late sends after timeout/cancellation. +- Separate authorization/notification lifecycle from workflow outcomes; never synthesize a subscription ACK. +- Observe serialized HTTP/JSON-RPC, real gRPC metadata/protobuf and dev vendor SDK traffic with mandatory redaction and bounded SSE. +- Add local missing-port SpringSpnDemo negotiation tests for direct and dev Order; real LLM/platform/OMC validation remains separate. +- Refresh bilingual callback, architecture and integration contracts. No older SDK compatibility layer. + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres @@ -7,11 +18,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +> Entries below older release headings describe those releases and are not current API contracts. + ### Added - Client TLS options for custom CA, mTLS client certificate/private key, encrypted PKCS#8 keys, and CRL checking - Configurable bounded executors for client sends and Spring Boot server request handling - Configurable Notification-T acknowledgement timeout and protocol-log body controls +- Pinned, unreleased A2A-T SDK revision with real offline SDK end-to-end coverage +- Explicit `NotificationSubscription` lifecycle/heartbeat handle and stable SDK-event mapper +- Structured Authorization-T and Notification-T generation, server validation, and whitelist/recovery sample callbacks +- WAIMO Task-T parsing plus two-city parallel diagnosis and exact-once merged workbench result ### Changed @@ -24,6 +41,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Workflow execution validates graph targets and cycles up front and waits only for activated predecessors at joins - Protocol headers are redacted by default; sensitive values require an explicit diagnostic opt-in - Agent clients and transport executors are reused and closed through their owning runtime +- Task-T, Authorization-T, and Notification-T use independent transport/runtime/context instances +- Negotiation-T uses the latest stateless content APIs and canonical `negotiationContext`; removed SDK state-machine APIs are not used +- SDK `.env` paths remain instance-local instead of being copied into JVM-wide system properties +- Extension subscription and task-control capabilities are explicit interface contracts instead of + default methods that fail at runtime ### Fixed @@ -32,6 +54,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Extension URI matching now uses exact URI path segments instead of substring matching - Notification callback implementations can no longer silently discard the supplied callback - Header construction is split into isolated auth, credential, and extension contributors with conflict detection +- Protocol generation/validation failures now fail closed instead of sending raw text under an A2A-T URI +- Authorization-T rejection stops the independent authorization operation; add/modify/delete/query operations follow the SDK template contract +- Notification-T failed/canceled/rejected acknowledgements fail the subscription and callback failures cannot prevent cleanup +- Workflow joins merge all predecessor outputs deterministically and execute exactly once after active predecessors complete ## [1.0.0] - 2026-07-28 diff --git a/README.md b/README.md index 93b195d9..6026cd55 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,40 @@ A standalone SDK for executing multi-agent workflows over the [A2A protocol](https://a2aproject.github.io/a2a-java/) with [A2A-T](https://projects.tmforum.org/a2aproject/telecommunication/extensions/) telecom extensions. -The engine handles all protocol mechanics: A2A message transport, SSE streaming, Task-T prompt generation, Negotiation-T auto-loop, authentication, and TLS. You implement only business decisions. +The engine handles workflow scheduling, A2A envelopes, transport, task waiting, authentication and TLS. Host callbacks return final message content and own any A2A-T generation, schema, validation or LLM calls. ## Features -- **A2A-T Extension Support**: Task-T (structured task prompts), Negotiation-T (auto negotiation loop), Authorization-T (pre-positioned whitelist), Notification-T (long-lived SSE subscription) +- **A2A-T Extension Support**: Task-T (structured task prompts), Negotiation-T (stateless auto negotiation loop), Authorization-T (independent whitelist operation), Notification-T (independent long-lived SSE subscription) +- **Content-neutral callbacks**: final MessageContent, complete ReceivedMessage, local multi-output TaskResult and explicit NegotiationReply.Send/Stop +- **Minimal A2A-T dependency**: a2a-t-core only in the engine; content generation and template queries use the host's explicit a2a-t-client dependency - **DAG Workflow Execution**: Parallel dispatch, self-loop steps, conditional routing - **Multi-Protocol Transport**: REST, JSON-RPC, and gRPC auto-selected from AgentCard - **Authentication**: Bearer token login with TTL cache, AES-256-GCM encrypted credentials, custom `AuthProvider` - **HTTPS/TLS**: Configurable trust store, self-signed cert support for development -- **Protocol Logging**: Full request/response header and body dumps for debugging +- **Protocol Logging**: actual HTTP/JSON-RPC boundaries, gRPC metadata/protobuf views and dev Order SDK observations; bodies default on at DEBUG with mandatory secret-field redaction + +The SPN sample treats the protocol document as an input, not as executable truth. The pinned A2A-T +SDK templates, slot schemas, canonical URIs, and validation results are authoritative. Protocol +generation and validation fail closed; raw text is never sent under an A2A-T URI as a fallback. + +In `SpringSpnDemo`, WAIMO sends a Task-T complaint to the workbench. The workbench loads the PSOP, +dispatches two city-specific OMC diagnoses in parallel, joins both branches exactly once, and +returns the real merged result over direct HTTP+JSON transport. Task-T, Authorization-T, and +Notification-T each use an independent transport/runtime/context. Authorization and Notification +are independently triggered workbench operations rather than DAG nodes; Notification keeps an +explicit long-lived subscription until the recovery result, cancellation, or shutdown. ## Quick Start ### 1. Add Maven dependency +A2A-T SDK `1.1.0` is published to Maven Central. Maven resolves it automatically; +no SDK source checkout or local SDK build is required. The engine depends only on +`a2a-t-core`; hosts using content generation explicitly add `a2a-t-client:1.1.0` +(and `a2a-t-server:1.1.0` for receiving-side validation). +See the [A2A-T SDK dependency guide](docs/zh/A2AT-SDK-DEPENDENCY.md) for IDEA setup and upgrade checks. + ```xml dev.openan.workflow.sdk @@ -56,24 +75,17 @@ List agentCards = registry.fetchAgentCards(); A2ATransport transport = new A2ATransport(agentCards, null, WorkflowEngineClientConfig.builder() .sslVerify(true) - .a2atEnvPath(".env") .credentialsConfigPath("credentials.json") .build()); WorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); -// 4. Implement ControlPoint (business decisions only) -ControlPoint controlPoint = new DefaultControlPoint() { - @Override - public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - return engineClient - .sendMessage(request.getAgentName(), request.getMessage()) - .thenApply(r -> TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); - } -}; +// 4. Plain A2A example; use host A2A-T SDK generation for Task-T. +ControlPoint controlPoint = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .build(); +// Implement local, conditional-route and negotiation callbacks as needed. +// Complete contract: docs/en/BUSINESS_CALLBACKS.md // 5. Execute ExecutionResult result = ExecutePsop.builder() @@ -90,6 +102,8 @@ System.out.println("Success: " + result.isSuccess()); ``` +Final content callbacks, complete dependency inputs and negotiation Send/Stop: [English](docs/en/BUSINESS_CALLBACKS.md) / [中文](docs/zh/BUSINESS_CALLBACKS.md). + ## Architecture ```mermaid @@ -135,7 +149,7 @@ graph TD | `client` | `WorkflowEngineClient`, `DefaultWorkflowEngineClient`, `ExtensionSender`, `A2ATransport`, `AuthProvider`, `AgentAuthManager`, `WorkflowEngineClientConfig`, `CredentialCrypto`, `AgentCardJacksonModule` | A2A transport, auth, extensions (package-private internals) | | `control` | `ControlPoint`, `DefaultControlPoint`, `EventCallback`, `EventType`, `NegotiationStrategy` | User-facing decision interfaces | | `core` | `WorkflowExecutor`, `ContextBuilder` | DAG traversal, context assembly | -| `model` | `Workflow`, `WorkflowStep`, `Task`, `TaskRequest`, `TaskResponse`, `StepType`, `JumpCondition`, `RouteDecision`, `ExecutionResult`, `SendMessageResult`, `TaskStatus`, `WorkflowSearchResult` | Data models | +| `model` | `Workflow`, `WorkflowStep`, `Task`, `TaskRequest`, `MessageContent`, `TaskResult`, `NegotiationRequest`, `NegotiationReply`, `ExecutionResult` | Data models | | `registry` | `LoadPsop`, `RegistryClient` | PSOP loading and AgentCard registry | | `runner` | `ExecutePsop` | Entry point for workflow execution | @@ -149,7 +163,6 @@ graph TD - [API Reference](docs/en/API_REFERENCE.md) - Public interface and class documentation - [Design Document](docs/en/DESIGN.md) - Architecture, module structure, design decisions - [Developer Guide](docs/en/DEVELOPER_GUIDE.md) - Internal architecture, contribution, debugging -- [Notification-T Design Analysis](docs/en/notification-t-design-analysis.md) - Long-lived SSE design ### 中文 @@ -157,9 +170,6 @@ graph TD - [API 参考](docs/zh/API_REFERENCE.md) - 公共接口和类文档 - [架构设计](docs/zh/DESIGN.md) - 架构、模块结构、设计决策 - [开发者指南](docs/zh/DEVELOPER_GUIDE.md) - 内部架构、贡献、调试 -- [Notification-T 设计分析](docs/zh/notification-t-design-analysis.md) - 长连接 SSE 设计选型 -- [业务流](docs/zh/业务流.md) - SPN 跨城诊断业务流程 -- [调用过程](docs/zh/调用过程.md) - 端到端报文交互示例 - [工作台集成指南](docs/zh/工作台集成指南.md) - 工作台 Spring Boot 集成完整指南 ## Modules @@ -173,3 +183,23 @@ graph TD ## License [Apache License 2.0](LICENSE) + +### Inspect negotiation in the local Demo + +Running the local SpringSpnDemo without VM options now negotiates missing City1 input; City2 diagnoses directly. +This is a local sample scenario, not an engine default. To use complete inputs in both cities, add this IDEA **VM option**: + +```text +-Da2at.samples.negotiation=false +``` + +By default only City1 loses its Task-T task object. Add `-Da2at.samples.negotiation.city=city2` or `both` +to exercise City2 or both cities. The host retains city-scoped authoritative input and the complaint context is preserved. +Expect DEMO_NEGOTIATION → INPUT_REQUIRED/PROPOSE → onNegotiation → ACCEPT → both diagnoses → one aggregate. +External-OMC mode defaults to no injection and rejects an explicit true switch. +The Demo passes its setting into the current Spring application context without modifying JVM-wide properties. +Protocol observations are in the console and `logs/spn-demo.log` relative to the run directory. + +Run `SpringSpnDemoE2ETest` (direct) and, on dev, `SpringSpnDemoOrderE2ETest` sequentially. +Each tests the no-VM-option single-city default, explicit disable/enable and both-city negotiation with current SDK resources and an offline LLM provider. +This is local protocol E2E evidence, not real model/platform/OMC validation. diff --git a/docs/diagrams/workflow-engine-surrounding-systems.mmd b/docs/diagrams/workflow-engine-surrounding-systems.mmd new file mode 100644 index 00000000..d255c752 --- /dev/null +++ b/docs/diagrams/workflow-engine-surrounding-systems.mmd @@ -0,0 +1,56 @@ +%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 55, "rankSpacing": 75}}}%% +flowchart TB + classDef external fill:#EEF4FF,stroke:#2563EB,color:#172554,stroke-width:1.5px + classDef host fill:#FFF7ED,stroke:#EA580C,color:#431407,stroke-width:1.5px + classDef sdk fill:#ECFDF5,stroke:#059669,color:#052E16,stroke-width:1.5px + classDef local fill:#F8FAFC,stroke:#64748B,color:#0F172A,stroke-dasharray:5 3 + classDef agent fill:#F5F3FF,stroke:#7C3AED,color:#2E1065,stroke-width:1.5px + + subgraph CONTROL["周边控制面与上游系统"] + direction LR + WAIMO["WAIMO
上游任务发起方"]:::external + REG["注册中心 / Registry Center
AgentCard 发布与查询"]:::external + ORCH["编排中心 / Orchestration Center
工作流检索与 PSOP 加载"]:::external + end + + subgraph HOST["宿主智能体:传输工作台 / Workbench Agent"] + direction TB + ENTRY["A2A 服务端入口
接收 Task-T,解析诊断意图"]:::host + ADAPTER["宿主集成层
准备 AgentCard、Workflow、配置和业务上下文"]:::host + + subgraph ENGINE["嵌入式 Workflow Execution Engine SDK"] + direction TB + LOAD["发现辅助 API
RegistryClient / LoadPsop"]:::sdk + RUN["工作流协议调度
ExecutePsop → WorkflowExecutor
Task-T + Negotiation-T"]:::sdk + CALLBACK["宿主回调接口
ControlPoint / EventCallback / onFinish"]:::sdk + EXT["流程外协议操作
ExtensionSender
Authorization-T / Notification-T"]:::sdk + end + + BIZ["工作台业务实现
业务接管、路由、汇总、持久化、通知处理"]:::host + end + + subgraph SAMPLE["Demo 本地替代路径(非生产数据源)"] + direction LR + LOCALCARD["本地 AgentCard JSON
WorkbenchAgentCatalog"]:::local + LOCALPSOP["本地 PSOP fallback
仅用于离线演示"]:::local + end + + subgraph DOWNSTREAM["下游业务智能体"] + OMCS["地市 OMC 智能体群
地市 1 OMC | 地市 2 OMC"]:::agent + end + + WAIMO <-->|"Task-T 诊断任务 / 汇总 artifact"| ENTRY + ENTRY -->|"运行意图"| ADAPTER + REG -->|"生产:查询 AgentCard"| LOAD + ORCH -->|"search / load PSOP"| LOAD + LOCALCARD -.->|"Demo 替代注册中心"| ADAPTER + LOCALPSOP -.->|"Demo 检索失败时"| ADAPTER + LOAD -->|"发现结果交给宿主"| ADAPTER + ADAPTER -->|"Workflow + AgentCards"| RUN + RUN <-->|"业务决策与执行结果"| CALLBACK + CALLBACK <-->|"回调"| BIZ + ADAPTER -->|"独立业务时机"| EXT + + RUN <-->|"向两地市并行 Task-T
必要时 Negotiation-T / 返回诊断结果"| OMCS + EXT <-->|"Authorization-T 一次性授权
Notification-T 长连接订阅与结果 SSE"| OMCS + BIZ -->|"汇总结果"| ENTRY diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index f85a370e..d5ec5252 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -29,7 +29,6 @@ Entry point for executing a PSOP workflow. Uses the Builder pattern. | `engineClient(WorkflowEngineClient)` | optional | null | Pre-configured client (null = auto-create) | | `runtimeIntent(String)` | optional | `""` | Natural-language intent for context assembly | | `lang(String)` | optional | `"zh"` | Language hint (`"zh"` or `"en"`) | -| `a2atEnvPath(String)` | optional | null | Path to `.env` file for A2A-T SDK | | `credentialsConfigPath(String)` | optional | null | Path to credentials JSON file | | `sslVerify(boolean)` | optional | `true` | Whether to verify TLS certificates | | `caCertsPath(String)` | optional | null | Path to CA certificates PEM file | @@ -44,7 +43,6 @@ ExecutionResult result = ExecutePsop.builder() .agentCards(cards) .controlPoint(cp) .runtimeIntent("diagnose fault") - .a2atEnvPath(".env") .sslVerify(false) .execute() .get(10, TimeUnit.MINUTES); @@ -58,99 +56,44 @@ ExecutionResult result = ExecutePsop.builder() ### WorkflowEngineClient -Primary interface for sending A2A messages to agents. - ```java -public interface WorkflowEngineClient { - // Send a message with optional context ID and preset metadata - CompletableFuture sendMessage( - String agentName, String message, - String contextId, Map metadata); - - // Convenience: no context ID, no metadata - CompletableFuture sendMessage( - String agentName, String message); - - void setControlPoint(ControlPoint controlPoint); - - void setEventCallback(EventCallback callback); - - void close(); -} +CompletableFuture dispatch(TaskRequest request, MessageContent content, ControlPoint callbacks); +CompletableFuture sendMessage(String agentName, MessageContent content); +CompletableFuture getTask(String agentName, String taskId); +CompletableFuture cancelTask(String agentName, String taskId); +CompletableFuture subscribeToTask(String agentName, String taskId, Consumer> callback); +long callbackTimeoutSeconds(); +void setControlPoint(ControlPoint callbacks); +void setEventCallback(EventCallback callback); +void close(); ``` -> Pre-positioning lives on `ExtensionSender`, not here: Authorization-T is a one-shot request and Notification-T is a -> long-lived subscription. See the `ExtensionSender` section below. - -#### sendMessage - -| Parameter | Type | Description | -|-------------|-----------------------|-----------------------------------------------| -| `agentName` | `String` | Target agent name (must match AgentCard.name) | -| `message` | `String` | Full assembled message text | -| `contextId` | `String` | Optional context ID (null = auto-generated) | -| `metadata` | `Map` | Optional preset metadata | - -**Returns:** `CompletableFuture` containing response text, task, metadata, and task state. +The executor calls dispatch; onTask does not send. Content is final parts/metadata/extensions. The engine manages envelopes and interactions, never instantiates A2ATClient or generates content from AgentCard declarations. Template queries and generation belong to the host SDK. -The engine internally handles before sending: +Only a remote `INPUT_REQUIRED` carrying valid Negotiation-T Propose enters `onNegotiation`. +Terminal responses never restart negotiation; ordinary `INPUT_REQUIRED` fails explicitly. +The host validates/interprets the proposal and generates the final Accept/Reject/Abort with its own A2A-T client. +Use `A2atMessages.contextOf(request.received())` to obtain the received context; +reply with the same id, round and maxRounds. The last allowed round can still be answered. +Do not call nextRound for an ending reply or return a new Propose. -1. Task-T prompt generation (if AgentCard declares Task-T) -2. Negotiation-T metadata injection (for follow-up messages) -3. Auth header injection (from credentials or AuthProvider) -4. A2A-Extensions header (only extensions present in metadata) - -After receiving: - -1. Response text extraction from SSE events -2. Metadata extraction (task-level + artifact-level) -3. Negotiation-T auto-loop (if `INPUT_REQUIRED`) +Return `new NegotiationReply.Send(content)` to send that exact content. +Return `new NegotiationReply.Stop(code, reason)` to stop locally without a generated Abort. +Repeated task/session/round events do not repeat the callback or submission. Unchanged waiting state is observed with getTask. +`maxNegotiationExchanges` (default 3) bounds local interactions, independently of the SDK context's maxRounds. +Timeout, exhausted budget or a missing handler fails locally; no implicit Accept or synthesized Abort. +Accept/Reject ACKs in SUBMITTED/WORKING remain pending and are observed without resending the command. +A business-sent Abort is never diagnosis success, even if the remote acknowledges it with COMPLETED. ### ExtensionSender -Pre-positioning facade over the same `A2ATransport`. Sends one-shot Authorization-T requests, establishes long-lived -Notification-T subscriptions, and sends other one-shot extensions before a workflow starts. It bypasses Task-T prompt -generation and the Negotiation-T auto-loop; later Notification-T events are delivered through the subscription callback. - ```java -public interface ExtensionSender { - CompletableFuture sendExtensionMessage( - String agentName, String instruction, - String naturalLanguageInput, A2ATExtension extension); - - // Convenience: Authorization-T - CompletableFuture sendAuthorization( - String agentName, String instruction, String naturalLanguageInput); - - // Convenience: Notification-T (long-lived SSE) - CompletableFuture sendNotification( - String agentName, String instruction, String naturalLanguageInput); - - // Interface contract: Notification-T (long-lived SSE + event callback) - CompletableFuture sendNotification( - String agentName, String instruction, - String naturalLanguageInput, Consumer> eventCallback); -} +CompletableFuture sendAuthorization(String agentName, MessageContent content); +NotificationSubscription openNotification(String agentName, MessageContent content, + BiConsumer listener); ``` -| Parameter | Type | Description | -|------------------------|-----------------|-------------------------------------------------| -| `agentName` | `String` | Target agent name (must match `AgentCard.name`) | -| `instruction` | `String` | Short instruction text; becomes `parts[].text` in the A2A message body | -| `naturalLanguageInput` | `String` | Natural-language extension metadata. Task-T is structured by the A2A-T SDK; Authorization-T and Notification-T currently use this value directly | -| `extension` | `A2ATExtension` | Extension enum (never hardcode URIs) | -| `eventCallback` | `Consumer>` | Optional SSE event callback (`sendNotification` only). Recovery results pushed by the agent are received here in real time; the Map contains `agent`, `text`, `metadata`, `state`. Null drops subsequent events | - -**Wire format**: The resulting A2A message sent to the agent has `parts[].text = instruction` and `metadata = { "": "" }`. For example, Authorization-T produces: - -```json -{ - "parts": [{"text": "Authorize diagnosis operations"}], - "metadata": { - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1": "" - } -} -``` +Authorization and notification accept host-generated final content. Use separate transport/runtime/context instances; their outcomes do not gate the workflow. openNotification registers a handle before I/O, and passes it plus ReceivedMessage directly to the listener. acknowledgement() is the real ACK; timeout fails. close() requests closure; completion() observes actual stream termination. ### WorkflowEngineClientConfig @@ -171,10 +114,9 @@ Builder-based configuration for the workflow engine client. | `sendExecutorQueueCapacity` | `int` | `256` | Bounded send executor queue capacity | | `authProvider` | `AuthProvider` | null | Custom auth provider | | `credentialsConfigPath` | `String` | null | Path to credentials JSON; explicit missing or malformed files fail startup | +| `credentialEncryptionKey` | `String` | null | Host-supplied decryption key; never loaded from LLM .env | | `credentialsConfig` | `Map` | null | Inline credentials config; must match AgentCard security requirements | -| `a2atEnvPath` | `String` | null | Path to `.env` file | -| `maxNegotiationRounds` | `int` | `3` | Max negotiation auto-loop rounds | -| `customHandlers` | `List` | null | Custom extension handlers | +| `maxNegotiationExchanges` | `int` | `3` | Local interaction budget, independent of SDK maxRounds | ```java WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() @@ -183,9 +125,8 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .clientCertPath("client-cert.pem") .clientKeyPath("client-key.pem") .sendTimeoutSeconds(900) - .a2atEnvPath(".env") .credentialsConfigPath("creds.json") - .maxNegotiationRounds(5) + .maxNegotiationExchanges(5) .authProvider(myProvider) .build(); ``` @@ -203,28 +144,9 @@ public interface AuthProvider { Called for every message send. The `headers` map is mutable; add `Authorization`, custom headers, etc. `AuthProvider` can be the sole authentication source, including when `securityRequirements` is non-empty and credentials are not configured. When credentials are also configured, both sets are computed independently and merged; different values for the same header name throw `SecurityException` instead of silently overwriting either value. -### ExtensionHandler - -Extension handler for custom A2A-T extensions. - -```java -public interface ExtensionHandler { - String extensionKeyword(); - - CompletableFuture> beforeSend( - AgentCard agentCard, String messageText, - Map metadata, - A2ATClient a2atClient, ControlPoint controlPoint); - - CompletableFuture afterReceive( - AgentCard agentCard, SendMessageResult result, - A2ATClient a2atClient, ControlPoint controlPoint, - EventCallback eventCallback); -} -``` +### A2atMessages -Built-in: Task-T and Negotiation-T are handled automatically. You can register custom handlers via customHandlers in the -config. +Submit custom extensions via MessageContent(parts, metadata, extensions); no engine content-handler registration. A2atMessages.from(MetadataContent, List>) preserves SDK metadata and activates the corresponding URI; contextOf(ReceivedMessage) or contextOf(Map) reads/checks canonical negotiation context. This adapter uses a2a-t-core only, never content generation or semantic validation. ### A2AJavaClientRuntime @@ -299,46 +221,19 @@ Map normalized = AgentCardNormalizer.normalize(rawMap); ### ControlPoint -User-facing decision interface. Each method has a single responsibility. - ```java -public interface ControlPoint { - // Send a Task-T message to an agent. Just call sendMessage. - CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient); - - // Self-loop step: handled locally, no A2A-T message to self. - default CompletableFuture onSelfTask(TaskRequest request); - - // Conditional branch decision. Only decide which step to go to. - CompletableFuture onRoute( - String stepName, Map results, - List conditions); - - // Provide clarification for negotiation. Default: generic text. - default CompletableFuture onNegotiation( - String agentName, String negotiationText, - Map receiveResult); +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -| Method | When Called | Return | -|-------------------|--------------------------------------------|-----------------------------------| -| `onTask` | A step dispatches a task to another agent | `TaskResponse` (success + output) | -| `onSelfTask` | A `SELF_LOOP` step runs locally (no A2A-T) | `TaskResponse` (success + output) | -| `onRoute` | After step completes, before next step | `RouteDecision` (nextStep) | -| `onNegotiation` | When agent returns `INPUT_REQUIRED` | `String` clarification text | - -### DefaultControlPoint - -Default implementation with sensible defaults: - -- `onTask`: calls `sendMessage()`, returns success/output -- `onSelfTask`: echoes the task message back (override for local logic) -- `onRoute`: picks first non-terminal branch -- `onNegotiation`: returns generic clarification - -Extend this class and override only the methods you need. +onTask returns final parts/metadata/extensions; the engine sends them without generating or rewriting content. +onSelfTask returns local TaskResult, onRoute selects an allowed candidate, and onNegotiation returns Send or Stop. +Unimplemented callbacks fail explicitly. No echo-success, first-branch choice or automatic consent. +See [Business callback contract](BUSINESS_CALLBACKS.md) for fields and working examples. ### EventCallback @@ -436,7 +331,7 @@ Map registerAgentCard(Map agentCard) ### WorkflowExecutor -Mid-layer DAG traversal engine. Walks the workflow steps, assembles upstream context via `ContextBuilder`, dispatches subtasks in parallel, applies step success policies (`ALL_SUCCESS` / `ANY_SUCCESS` / `SELF_LOOP`), and routes to the next step. +Mid-layer DAG traversal engine. Walks workflow steps, selects typed upstream execution results through `ContextBuilder`, dispatches subtasks in parallel, applies step success policies (`ALL_SUCCESS` / `ANY_SUCCESS` / `SELF_LOOP`), and routes to the next step. Not typically instantiated directly by SDK users — `ExecutePsop` wraps it internally. Exposed for advanced integrations that need to run the traversal layer without the runner's lifecycle management. @@ -449,7 +344,7 @@ ExecutionResult result = executor.run().join(); ### ContextBuilder -Package-private helper that assembles the context message for each step by folding upstream step outputs according to `contextFrom` rules (`"*"` = all ancestors, specific names = selective inheritance). Not part of the public API surface. +Package-private helper that selects upstream step results and creates a `WorkflowInput` according to `contextFrom` (omitted = direct predecessors, `[]` = no upstream input, `"*"` = all ancestors, explicit names = selective inheritance). It does not render prompts and is not part of the public API surface. --- @@ -473,8 +368,8 @@ Static factory: `Workflow.fromMap(Map)` parses from orchestratio | `name` | `String` | - | Step name (unique within workflow) | | `subtasks` | `List` | `List.of()` | Subtasks dispatched in this step | | `next` | `List` | `List.of()` | Conditional next steps | -| `layer` | `int` | `0` | Context layer (0 = runtime intent only) | -| `contextFrom` | `List` | null | Steps to inherit context from (`"*"` = all predecessors) | +| `layer` | `int` | `0` | Design-time hint; dependency graph selects context | +| `contextFrom` | `List` | null | Aggregation source: omitted = direct predecessors, `[]` = none, `"*"` = all ancestors, or explicit ancestor names | | `stepType` | `StepType` | `ALL_SUCCESS` | Execution mode | ### StepType @@ -511,35 +406,73 @@ Task lifecycle status, used in `TASK_STATUS_CHANGED` events for cross-SDK consis | `step` | `String` | Next step name (`"end"` for terminal) | | `condition` | `String` | Condition expression (`"success"`, `"fail"`, etc.) | -### TaskRequest +### TaskRequest / BusinessInput + +TaskRequest uses getXxx() accessors: + +| Field | Meaning | +|---|---| +| executionId / taskId | Local execution/logical task identities, not remote protocol IDs | +| stepName / agentName / skill | Current workflow step, destination and skill | +| instruction / language | Current instruction only; no appended history | +| input | BusinessInput: exactly one of text or arbitrary JSON-serializable data; no schema | +| workflowInput | WorkflowInput(runtimeIntent, upstreamResults), separate from current input | + +BusinessInput.text(value) / BusinessInput.data(value) create input snapshots. +WorkflowInput, UpstreamStepResult, ReceivedMessage and NegotiationRequest use record field() accessors. + +| contextFrom | Upstream selection | +|---|---| +| absent / null | Available direct predecessors | +| [] | No upstream results; runtimeIntent remains | +| ["*"] | Available ancestors | +| explicit ancestor names | Available named results in declaration order | + +contextFrom selects evidence, not scheduling edges; define dependencies with next. +Unknown/non-ancestor names or mixing "*" with names is invalid. Unselected/inactive branches are not fabricated. +The engine never appends upstream results to instruction/parts or invokes an LLM to map them. +The host decides how to consume the window or map it to the next agent's input. + +The window is stepName → taskResults[] → outputs[] / receivedMessages[]. +TaskExecutionResult also retains agentName, skill, logical taskId, taskDescription, status, +error, errorCode and errorDetails. Multiple subtasks remain distinguishable. +Nested arrays remain one output value; there is no requirement that outputs originated from an LLM. + +### MessageContent / ReceivedMessage + +```java +record MessageContent(List> parts, Map metadata, Set extensions) {} +record ReceivedMessage(MessageContent message, Map taskMetadata, List artifacts) {} +``` + +MessageContent.text(text), MessageContent.parts(parts), or the canonical constructor create snapshots. +TextPart, DataPart and FilePart preserve order and part metadata. File references are not fetched. +MessageContent has no role, target, messageId, taskId, contextId or auth headers. +A business metadata key named contextId does not override the A2A envelope. + +ReceivedMessage preserves message metadata, task metadata and each artifact's identity/parts/metadata separately. +message can be null; metadata-only results remain accessible through the complete view. +For convenience, outputs projects TextPart values and DataPart data from artifacts in order; +if there are no artifacts, a successful final/standalone message can supply these values. +FilePart remains available only in the complete view. No text parsing, adjacent-part concatenation or nested-array flattening. +Failed task status messages are evidence, not outputs; valid partial artifacts are retained. +Streaming append/replace is assembled per artifact; terminal snapshots do not duplicate earlier chunks. + +TaskResult.success(List) represents local output, including an empty list. +TaskResult.failure(code, message) separates failure from output; its builder can retain valid partial outputs. +TaskResult includes receivedMessages for remote evidence; remote convenience outputs derive from that view. +A remote task succeeds only in COMPLETED, or a standalone A2A Message can complete the interaction. +Progress and negotiation are never a successful workflow task merely because they have text. + -| Field | Type | Description | -|----------------|----------|---------------------------| -| `agentName` | `String` | Target agent | -| `skill` | `String` | Agent skill | -| `message` | `String` | Full message text | -| `description` | `String` | Task description | -| `context` | `String` | Context message | -| `stepName` | `String` | Source step name | -| `subtaskIndex` | `int` | Subtask index within step | -### TaskResponse +### NegotiationRequest / NegotiationReply -| Field | Type | Description | -|------------|-----------|----------------------------| -| `success` | `boolean` | Whether the task succeeded | -| `output` | `String` | Response text | -| `error` | `String` | Error message (if failed) | -| `metadata` | `Map` | Response metadata | +See [BUSINESS_CALLBACKS](BUSINESS_CALLBACKS.md). ### SendMessageResult -| Field | Type | Description | -|-------------|----------|------------------------------------------------| -| `text` | `String` | Extracted response text | -| `task` | `Task` | SDK Task object | -| `metadata` | `Map` | Response metadata (merged task + artifact) | -| `taskState` | `String` | Final task state (e.g. `TASK_STATE_COMPLETED`) | +getReceivedMessages() preserves response levels; getOutputs() is a convenience projection. getTask()/getTaskState() retain actual remote state; failureCode/failureMessage describe independent local interaction failure. text and flattened metadata are transport diagnostics, not substitutes for the complete business response. ### ExecutionResult @@ -579,16 +512,12 @@ Task lifecycle status, used in `TASK_STATUS_CHANGED` events for cross-SDK consis | Extension | URI | |-----------------|-------------------------------------------------------------------------------------------| | Task-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1` | -| Negotiation-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T` | +| Negotiation-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1` | | Authorization-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1` | | Notification-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1` | -> **Note:** `DATA-NEGOTIATION-T/v1` is **not** an A2A-T extension. It is an -> SDK-internal metadata key used by the A2A-T SDK negotiation module to carry -> structured context (`negotiationType`, `round`, `negotiationId`, -> `status`) alongside the Negotiation-T text. It is not declared on -> AgentCard, not handled by ExtensionHandler, and not advertised in the -> `A2A-Extensions` header. +Canonical negotiation metadata is `templateUri` plus +`negotiationContext={id,round,maxRounds,performative}`. Legacy state-machine keys are not accepted. --- @@ -600,10 +529,11 @@ Task lifecycle status, used in `TASK_STATUS_CHANGED` events for cross-SDK consis ## Error Handling -- Agent call failures throw `RuntimeException` wrapping the cause. -- Negotiation failures fall through after `maxNegotiationRounds`. -- Auth failures (401) are logged as `ERROR` and auth headers are not set; the request proceeds without auth. -- SSE stream errors after terminal events are logged at `DEBUG` level (expected behavior). +- Missing/null/exceptional/timed-out callbacks fail explicitly. Host SDK errors may be mapped to BusinessFailure with safe code/details. +- Exhausted maxNegotiationExchanges or local Stop does not generate Abort. Business Send(Abort) is never diagnosis success. +- Remote state/errors stay separate from outputs; failed status text is evidence, while partial artifacts remain available. +- Missing required credentials reject the request; the engine never silently sends unauthenticated. +- Stream-exit/transport-error observations do not independently define workflow outcome; inspect ExecutionResult and remote task state. --- diff --git a/docs/en/BUSINESS_CALLBACKS.md b/docs/en/BUSINESS_CALLBACKS.md new file mode 100644 index 00000000..5011d43b --- /dev/null +++ b/docs/en/BUSINESS_CALLBACKS.md @@ -0,0 +1,276 @@ +# Business callback integration contract + +Initial-release API; A2A-T SDK `1.1.0`, A2A Java `1.2.0.Final`. +The engine owns DAG scheduling, standard A2A envelopes, auth, transport and task waiting. +The host owns content, schemas, templates, semantic validation and any LLM calls. +Direct OMC and dev's Eastcom forwarding use the same business contracts. + +## 1. Callbacks + +```java +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); +} +``` + +onTask returns final parts/metadata/extensions; the engine sends them without generating or rewriting content. +onSelfTask returns local TaskResult, onRoute selects an allowed candidate, and onNegotiation returns Send or Stop. +Unimplemented callbacks fail explicitly. No echo-success, first-branch choice or automatic consent. +See [Business callback contract](BUSINESS_CALLBACKS.md) for fields and working examples. + +```java +ControlPoint callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .onSelfTask(request -> CompletableFuture.completedFuture( + TaskResult.success(List.of(Map.of( + "sourceResults", request.getWorkflowInput().upstreamResults()))))) + .onRoute(request -> CompletableFuture.failedFuture( + new IllegalStateException("Supply a routing policy for " + request.stepName()))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Manual confirmation required"))) + .build(); +``` + +This example deliberately sends ordinary text, not Task-T. Replace the local projection and route/stop policies +with real business behavior. onTask must not send a second network request itself. +Import `control.ControlPoint` and `model.*` from `dev.openan.workflow.engine`, +plus `java.util.List/Map` and `java.util.concurrent.CompletableFuture`. + +## 2. TaskRequest and dependency input + +TaskRequest uses getXxx() accessors: + +| Field | Meaning | +|---|---| +| executionId / taskId | Local execution/logical task identities, not remote protocol IDs | +| stepName / agentName / skill | Current workflow step, destination and skill | +| instruction / language | Current instruction only; no appended history | +| input | BusinessInput: exactly one of text or arbitrary JSON-serializable data; no schema | +| workflowInput | WorkflowInput(runtimeIntent, upstreamResults), separate from current input | + +BusinessInput.text(value) / BusinessInput.data(value) create input snapshots. +WorkflowInput, UpstreamStepResult, ReceivedMessage and NegotiationRequest use record field() accessors. + +| contextFrom | Upstream selection | +|---|---| +| absent / null | Available direct predecessors | +| [] | No upstream results; runtimeIntent remains | +| ["*"] | Available ancestors | +| explicit ancestor names | Available named results in declaration order | + +contextFrom selects evidence, not scheduling edges; define dependencies with next. +Unknown/non-ancestor names or mixing "*" with names is invalid. Unselected/inactive branches are not fabricated. +The engine never appends upstream results to instruction/parts or invokes an LLM to map them. +The host decides how to consume the window or map it to the next agent's input. + +The window is stepName → taskResults[] → outputs[] / receivedMessages[]. +TaskExecutionResult also retains agentName, skill, logical taskId, taskDescription, status, +error, errorCode and errorDetails. Multiple subtasks remain distinguishable. +Nested arrays remain one output value; there is no requirement that outputs originated from an LLM. + +## 3. Final message and complete response + +```java +record MessageContent(List> parts, Map metadata, Set extensions) {} +record ReceivedMessage(MessageContent message, Map taskMetadata, List artifacts) {} +``` + +MessageContent.text(text), MessageContent.parts(parts), or the canonical constructor create snapshots. +TextPart, DataPart and FilePart preserve order and part metadata. File references are not fetched. +MessageContent has no role, target, messageId, taskId, contextId or auth headers. +A business metadata key named contextId does not override the A2A envelope. + +ReceivedMessage preserves message metadata, task metadata and each artifact's identity/parts/metadata separately. +message can be null; metadata-only results remain accessible through the complete view. +For convenience, outputs projects TextPart values and DataPart data from artifacts in order; +if there are no artifacts, a successful final/standalone message can supply these values. +FilePart remains available only in the complete view. No text parsing, adjacent-part concatenation or nested-array flattening. +Failed task status messages are evidence, not outputs; valid partial artifacts are retained. +Streaming append/replace is assembled per artifact; terminal snapshots do not duplicate earlier chunks. + +TaskResult.success(List) represents local output, including an empty list. +TaskResult.failure(code, message) separates failure from output; its builder can retain valid partial outputs. +TaskResult includes receivedMessages for remote evidence; remote convenience outputs derive from that view. +A remote task succeeds only in COMPLETED, or a standalone A2A Message can complete the interaction. +Progress and negotiation are never a successful workflow task merely because they have text. + +## 4. Host-owned A2A-T calls + +Pure engine consumers only need a2a-t-core; hosts generating content explicitly depend on a2a-t-client. +Create/configure A2ATClient in the host, not in WorkflowEngineClientConfig or ExecutePsop. + +```java +// sdk, data, schema and template selection are owned by host code. +MetadataContent generated = sdk.generateTaskPromptFromDataWithSchema( + data, schema, StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); +MessageContent outgoing = A2atMessages.from(generated, List.of(new TextPart("Diagnose this line"))); +``` + +Natural-language equivalent: `sdk.generateTaskPromptFromText(text, StandardTemplates.PRIVATE_LINE_COMPLAINT.uri())`, then use the same A2atMessages.from conversion. + +Natural-language generation/validation and all other A2A-T content APIs are called directly on the host's SDK. +A2atMessages.from copies generated metadata and activates exactly its extension; supplied parts stay unchanged. +It does not generate, validate or parse business content. Use StandardTemplates, not invented template URIs. +WorkbenchControlPoint and examples.negotiation.NegotiationStrategy provide runnable SPN implementations. +A2ATInitialization is a sample-host workaround for the pinned SDK's cached JarFile lifecycle, not an engine dependency. + +## 5. Negotiation + +NegotiationRequest(task, originalSubmission, received, previousExchanges, remainingWait): +task is the original TaskRequest; originalSubmission is the exact initial content; received is the complete +current response; previousExchanges contains only this session's completed Exchange(received, reply); +remainingWait is the remaining local interaction deadline. There are no engine-defined business proposal schemas. + +Only a remote `INPUT_REQUIRED` carrying valid Negotiation-T Propose enters `onNegotiation`. +Terminal responses never restart negotiation; ordinary `INPUT_REQUIRED` fails explicitly. +The host validates/interprets the proposal and generates the final Accept/Reject/Abort with its own A2A-T client. +Use `A2atMessages.contextOf(request.received())` to obtain the received context; +reply with the same id, round and maxRounds. The last allowed round can still be answered. +Do not call nextRound for an ending reply or return a new Propose. + +Return `new NegotiationReply.Send(content)` to send that exact content. +Return `new NegotiationReply.Stop(code, reason)` to stop locally without a generated Abort. +Repeated task/session/round events do not repeat the callback or submission. Unchanged waiting state is observed with getTask. +`maxNegotiationExchanges` (default 3) bounds local interactions, independently of the SDK context's maxRounds. +Timeout, exhausted budget or a missing handler fails locally; no implicit Accept or synthesized Abort. +Accept/Reject ACKs in SUBMITTED/WORKING remain pending and are observed without resending the command. +A business-sent Abort is never diagnosis success, even if the remote acknowledges it with COMPLETED. + +Host policy may call validateProposePromptAndDataFilling and typed fromData or natural-language SDK generation. +It must not invent missing customer facts. The sample supplies only requested fields from the current city's input. +Map SDK business exceptions to BusinessFailure(code, safeMessage, safeDetails) in host code. +The engine does not recognize SDK-specific content exception classes. + +## 6. Routing, concurrency and failures + +RouteRequest(executionId, stepName, workflowInput, currentResults, candidates). +Candidates are RouteOption(nextStep, condition); return RouteDecision.builder().nextStep(allowed).build(). +Unconditional edges fan out without calling onRoute; conditional selection must be an allowed destination. + +Callbacks may run concurrently; do not hold a shared mutable current task/city. +Each workflow task activation (preparation and dispatch combined) is bounded by the client timeout (default sendTimeoutSeconds=600). +Routing has a callback timeout; dispatch/negotiation additionally has its own total wait deadline. Cancellation/timeout prevents late sends; +it does not automatically stop external business/LLM work. Hosts own cancellation of their resources. +Synchronous callback entry points must return promptly; place blocking operations in an asynchronous executor. +Missing/null/exceptional callbacks fail; uncertain sends are not blindly retried. + +## 7. Independent extensions + +ExtensionSender.sendAuthorization(agentName, finalContent) returns CompletableFuture. +ExtensionSender.openNotification(agentName, finalContent, (handle, received) -> ...) returns the handle immediately. +Use host-generated Authorization-T or Notification-T content with the matching activated extension. +The handle is registered before I/O; early callbacks can call handle.close() without capturing an unassigned variable. +acknowledgement() reports actual ACK or failure; timeout is not a synthesized success. +close() requests shutdown; completion() completes after the stream actually exits. +Retain it until recovery, explicit cancellation or host shutdown, not just workflow completion. + +Task, authorization and notification use separate transports/runtimes/contexts. +Neither extension success nor failure is a prerequisite for workflow execution. +Whitelist success only controls the OMC's optional automatic recovery behavior. + +## 8. Verification and logs + +### Inspect negotiation in the local Demo + +Running the local SpringSpnDemo without VM options now negotiates missing City1 input; City2 diagnoses directly. +This is a local sample scenario, not an engine default. To use complete inputs in both cities, add this IDEA **VM option**: + +```text +-Da2at.samples.negotiation=false +``` + +By default only City1 loses its Task-T task object. Add `-Da2at.samples.negotiation.city=city2` or `both` +to exercise City2 or both cities. The host retains city-scoped authoritative input and the complaint context is preserved. +Expect DEMO_NEGOTIATION → INPUT_REQUIRED/PROPOSE → onNegotiation → ACCEPT → both diagnoses → one aggregate. +External-OMC mode defaults to no injection and rejects an explicit true switch. +The Demo passes its setting into the current Spring application context without modifying JVM-wide properties. +Protocol observations are in the console and `logs/spn-demo.log` relative to the run directory. + +Run `SpringSpnDemoE2ETest` (direct) and, on dev, `SpringSpnDemoOrderE2ETest` sequentially. +Each tests the no-VM-option single-city default, explicit disable/enable and both-city negotiation with current SDK resources and an offline LLM provider. +This is local protocol E2E evidence, not real model/platform/OMC validation. + +Actual startup tests use public simulator credentials and do not shadow SDK schemas/templates. +Run main/dev tests sequentially; fixed local E2E ports must be free. +After a local workflow, the demo may observe the first recovery notification for up to ten seconds; +absence of a recovery event does not change workflow success. +See [Integration guide](INTEGRATION_GUIDE.md) for logging and authentication configuration. + +## 9. Business-side A2A-T 1.1.0 reference + +Content generation, validation, filling and domain decisions belong to the sample host/OMC code, not the engine. +Each role calls the APIs it needs; do not invoke every generation alternative for the same message. + +| Business path | SDK generation and receiving validation | Consumed result | +|---|---|---| +| WAIMO → workbench | generateTaskPromptFromDataWithSchema → validateTaskPromptAndDataFilling | WorkbenchTaskInputParser retains filled.data for workflow selection | +| onTask → either OMC | generateTaskPromptFromDataWithSchema → validateTaskPromptAndDataFilling | SpnTaskInput built from filled.data, not raw protocol text | +| Either OMC → onNegotiation | generateNegotiationProposePromptFromData → validateProposePromptAndDataFilling | Requested items answered only from the current city's authoritative input | +| Host Accept → OMC | generateNegotiationAcceptPromptFromData → validateAcceptPromptAndDataFilling | Merge requested filled fields; validate domain requirements before diagnosis | +| Host Reject / Abort → OMC | generateNegotiationRejectPromptFromData / generateNegotiationAbortPromptFromData → corresponding validateRejectPromptAndDataFilling / validateAbortPromptAndDataFilling | Validate reason, end the task without diagnosis | +| Independent authorization | generateAuthPromptFromDataWithSchema → validateAuthPromptAndDataFilling | Apply AuthorizationPolicy from filled.data | +| Independent subscription | generateNotificationPromptFromDataWithSchema → validateNotificationPromptAndDataFilling | Create NotificationPolicy and keep a separate stream open | + +For natural-language inputs choose generateTaskPromptFromText(text, templateUri), +generateAuthPromptFromText(text, templateUri), or generateNotificationPromptFromText(text, templateUri). +Negotiation also offers phase-specific FromText and typed FromData alternatives. +Structured generation may still call the SDK's configured LLM. +Wrap the returned MetadataContent with A2atMessages.from(generated, List.of(new TextPart("business summary"))). +Preserve full metadata, including templateUri and negotiationContext; provide nonempty parts. +The parts summary is not the formal extension validation input. + +The complaint example negotiates the canonical business fields 任务对象 and 任务上下文. +The former identifies this city's access port; the latter retains classification, OSS incident ID, time and details. +Only unresolved fields are requested when trusted partial data exists. If the SDK rejects the whole input, +none of its partial fields are trusted. The current proposal uses AND: all requested fields are required. +Propose extraction uses items (nonempty field-name array) and nullable relationship; Accept extraction dynamically +requires the requested nonblank fields. These SPN domain checks are sample rules, not generic engine constraints. + +Replies must match A2A task/context IDs and negotiation id/round/maxRounds. Formal text comes from Negotiation-T metadata. +Validated replies are consumed once; malformed, incomplete, foreign-city or cross-negotiation replies never run diagnosis. +NegotiationStrategy.mayProvideField(task, fieldName) is the host disclosure-policy hook: false produces SDK Reject. +Missing authoritative information produces SDK Abort, not invented values. This policy is separate from Authorization-T recovery permissions. + +Do not reuse subscription-request generation/validation for recovery events: diagnosis results, authorization receipts +and recovery events are OMC business outputs. RecoveryNotification checks the SPN result fields in formal Notification-T +artifact metadata before closing the subscription: terminal state, task/incident IDs, port, plan and execution outcome. +Both successful and failed finished recoveries are terminal, without treating failure as success. +A name, summary, ACK or not-started plan alone never closes the stream. Invalid events remain observable by the callback. +City1 uses the actual validated port and OSS incident ID. A healthy City2 need not emit a recovery result. +Authorization/subscription outcomes remain independent of workflow execution. + +EmbeddedA2AServerTest covers normal/Accept filled data, invalid formal text, missing fields, cross-negotiation replies, +Reject and Abort. NegotiationStrategyTest covers city isolation and host Abort/Reject policy. +RecoveryNotificationTest covers complete success/failure and malformed/incomplete events. +SpringSpnDemoE2ETest and dev's SpringSpnDemoOrderE2ETest cover full input, City1 missing input and both cities missing input; +SpnCrossCityE2ETest checks the recovery lifecycle. Tests use actual SDK pipelines with a strict fixture-only offline LLM provider, +not a live-model semantic evaluation or real-OMC certification. + +### Authorization format and negotiation keys (SDK 1.1.0) + +ADD uses consecutive numbered lines, labeled values and full-width commas; multiple rules use +newlines, not semicolons. Example: +`1. 业务场景是业务投诉诊断,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-06-01~2030-06-18`. +Validity is an inclusive ISO-date range or `永久生效`. Parse every SDK-validated rule before replacing +the whitelist; malformed rules leave it unchanged. Authorization uses exact scenario/disposal/action +matches, never fuzzy matching. + +The sample stores one in-memory whitelist with a fixed demonstration ID. DELETE accepts only +`1. 策略标识是`. QUERY requires an omitted or empty policy list and returns all +active rules. MODIFY, filtered queries and batch/conditional deletion require an integrating policy +store; unsupported conditions are rejected, never silently ignored. These are sample business limits, +not limitations of the A2A-T SDK. + +After SDK Propose semantic validation, the sample preserves literal numbered item names from the +current Chinese typed information template. LLM paraphrases of descriptions must not rename an item +or broaden disclosure. Replies use only the current city's authoritative input; unknown/missing data +causes Abort and a host disclosure denial causes Reject. This handling belongs to sample business code, +not the workflow engine. + +Notification requests use the complete failure-reason field name `业务抢通方案执行失败原因`, required +when execution fails. SDK rejection logs include its code, slot errors and extracted parameter keys; +validation is not bypassed. Authorization and subscription failures still do not block the workflow. diff --git a/docs/en/DESIGN.md b/docs/en/DESIGN.md index d9ae5b29..f11451f4 100644 --- a/docs/en/DESIGN.md +++ b/docs/en/DESIGN.md @@ -9,25 +9,48 @@ ## 1. Overview -The A2A-T Engine SDK lets a host agent execute multi-step workflows over the A2A protocol with A2A-T telecom extensions. -A workflow is a directed acyclic graph (DAG) of steps; each step dispatches one or more tasks to remote agents and -routes to the next step. The SDK owns the protocol mechanics - message send, streaming, authentication, Task-T prompt -generation, the Negotiation-T auto-loop - and exposes a small set of decision interfaces the host implements for -business logic. - -The guiding principle is a clean separation between **mechanics the SDK owns** and **decisions the host owns**: - -| The SDK owns (protocol mechanics) | The host owns (business decisions) | -|---------------------------------------------------------------------------|-----------------------------------------------| -| A2A message send, streaming, SSE normalization | Whether and when to send a task | -| Agent authentication (Bearer, custom headers) | Credential configuration | -| A2A-T extensions (Task-T, Negotiation-T, Authorization-T, Notification-T) | Authorization approval, notification handling | -| DAG traversal, context assembly, state management | Branch routing decisions | -| Event emission | Event handling | +The SDK is an embedded workflow protocol scheduler, not a separately deployed business service. +The host supplies final content; the engine owns DAG execution, parallel scheduling, A2A envelopes, +authentication, transport, waiting and result association. Host callbacks choose whether/how to invoke +A2A-T natural-language or structured content APIs. Templates, schemas, LLMs and semantic validation belong to the host. +The engine uses a2a-t-core only for canonical negotiation context and metadata copying, not content generation. + +| Engine responsibility | Host responsibility | +|---|---| +| DAG, task/context association, concurrency and route validation | Final content, routing policy and local analysis | +| Select complete upstream results with contextFrom | Consume evidence and map downstream business input | +| A2A authentication and transport mechanics | Credential source, AuthProvider and deployment | +| Independent sends, subscription handles and lifecycle | Authorization policy, subscription content and closure timing | + +## 2. Host Integration and Surrounding Systems + +![Workflow engine and surrounding-system dependencies](../images/workflow-engine-surrounding-systems.png) + +The SDK in this diagram is a library embedded in the host-agent process, not a separately deployed +orchestration service. In `SpringSpnDemo`, the transport workbench is the host agent: + +1. WAIMO invokes the workbench's A2A server endpoint with a Task-T diagnosis request. +2. The workbench prepares execution inputs. In production it obtains downstream AgentCards from the + registry center and searches/loads a PSOP from the orchestration center. The SDK offers optional + `RegistryClient` and `LoadPsop` helpers, while discovery timing, caching, and failure policy remain + host responsibilities. +3. The workbench passes the `Workflow`, AgentCards, runtime intent, and business callbacks to + `ExecutePsop`. The engine walks the DAG, dispatches both city tasks in parallel, and handles + Negotiation-T when needed. `ControlPoint` returns control to the workbench for local aggregation, + routing, and clarification decisions. +4. The workbench returns the aggregate as a Task-T artifact and terminal status to WAIMO. +5. Authorization-T and Notification-T are invoked by the workbench at independent business times + through `ExtensionSender`. They are outside the PSOP DAG and do not share a transport, runtime, or + context with workflow tasks. + +For offline execution, the demo loads AgentCards from the classpath through `WorkbenchAgentCatalog` +and uses a local PSOP fallback only if orchestration-center search or load fails. These are sample +substitutes, not production discovery or disaster-recovery rules. The editable diagram source is +[`docs/diagrams/workflow-engine-surrounding-systems.mmd`](../diagrams/workflow-engine-surrounding-systems.mmd). --- -## 2. Layered Architecture +## 3. Layered Architecture The SDK is structured in four layers. Each layer builds on the one below; each has a single responsibility and a clear entry point. @@ -36,48 +59,31 @@ entry point. graph TD L2["Layer 2 - Orchestration
execute_psop / ExecutePsop
lifecycle, event stream, cancellation, onFinish persistence"] L1["Layer 1 - Traversal
WorkflowExecutor
DAG walk, parallel dispatch, context assembly, routing"] - L0["Layer 0 - Communication
A2ATransport + two facades
WorkflowEngineClient (workflow send) | ExtensionSender (one-shot)"] + L0["Layer 0 - Communication
A2ATransport + two facades
WorkflowEngineClient (workflow send) | ExtensionSender (independent operations)"] F["Foundation - Decision
ControlPoint
user-implemented business decisions"] L2 --> L1 --> L0 L0 -.-> F ``` -### 2.1 Layer 0 - Communication +### 3.1 Layer 0 - Communication -This layer is the heart of the transport-facade split. +A2ATransport uses A2A SDK REST, JSON-RPC and gRPC bindings for authentication, delivery and complete response assembly. +WorkflowEngineClient accepts MessageContent and manages task association and negotiation continuation. +ExtensionSender provides independent sendAuthorization and openNotification operations. +The implementations are reusable; task, authorization and notification do not share transport/runtime/context instances. +ProtocolResponses assembles artifact deltas by identity and ReceivedMessage preserves metadata at each level. -**`A2ATransport`** is the shared wire layer. It owns exactly one concern: -getting bytes to and from remote agents. That means the A2A SDK client runtime, -the auth manager and interceptors, the agent-card map, and the streaming-response consumer. It exposes two send -primitives - `send` (collect-and-return) and -`sendNotificationStream` (long-lived SSE) - plus static extractors that turn the raw SDK event stream into text, task -state, and metadata. +### 3.2 Layer 1 - Traversal -**Two facades sit on top of the transport, each with a single responsibility:** - -- **`WorkflowEngineClient`** - the workflow execution send path. Owns Task-T prompt generation (before send), the - Negotiation-T auto-loop (after receive), the global `EventCallback`, and the `ControlPoint` wiring. - This is the facade the executor calls during workflow execution. -- **`ExtensionSender`** - pre-positioning facade. Sends one-shot Authorization-T requests or establishes long-lived - Notification-T subscriptions *before* the workflow starts. It bypasses Task-T generation and the negotiation loop; - Notification-T events use the subscription callback rather than the workflow-global callback. - -#### Why reusable transport machinery with two facades? - -Both the workflow send path and the pre-positioning path need the same wire-level machinery: an HTTP client, -TLS configuration, auth interceptors, agent-card resolution, and SSE parsing. Putting that machinery on either facade -would either (a) force a caller that only wants to pre-position to hold the full workflow facade, or (b) duplicate the -wire code across two classes. The reusable transport / two-facade design avoids both: -the wire layer is written once on `A2ATransport`, and each facade delegates all wire work to it while keeping its own -orchestration concern isolated. The facades may use task-scoped or workbench-scoped transport lifetimes. - -### 2.2 Layer 1 - Traversal - -**`WorkflowExecutor`** walks the DAG. At each step it assembles upstream context (`ContextBuilder`), dispatches subtasks +**`WorkflowExecutor`** walks the DAG. At each step it selects typed upstream results according to `contextFrom` +(`ContextBuilder`), dispatches subtasks concurrently, applies the step's success policy, and determines the next step (s). It delegates every *decision* to `ControlPoint` and every *send* to `WorkflowEngineClient`. +TaskRequest separates current input from workflowInput. ContextBuilder selects complete upstream views +and convenience outputs; it never formats or generates downstream content. The host decides how to consume them. + Step dispatch rules: - Steps whose predecessors are all satisfied are collected and dispatched in parallel (`CompletableFuture`), so steps at the same layer run concurrently. @@ -86,7 +92,7 @@ Step dispatch rules: - `ANY_SUCCESS` - the first successful subtask wins; the rest are cancelled. - `SELF_LOOP` - the task is handled locally via `onSelfTask`, with no A2A-T message sent to the agent. -### 2.3 Layer 2 - Orchestration +### 3.3 Layer 2 - Orchestration **`ExecutePsop`** is the high-level runner. It wraps the executor with a lifecycle (start / complete / error / close), event serialization, client-disconnect cancellation, and an @@ -94,80 +100,46 @@ Step dispatch rules: --- -## 3. Decision Interfaces - -The SDK exposes two user-implemented interfaces, split by responsibility. - -### 3.1 ControlPoint - flow decisions - -Drives the workflow forward. Each method is called by the executor or the auto-negotiate loop and makes exactly one -decision: - -| Method | Called by | Decision | -|-----------------|------------------|----------------------------------------------| -| `onTask` | executor | Send a task to an agent (call `sendMessage`) | -| `onSelfTask` | executor | Handle a self-loop task locally (no A2A-T) | -| `onRoute` | executor | Choose a branch at a conditional step | -| `onNegotiation` | client auto-loop | Supply clarification on INPUT_REQUIRED | - -Authorization-T and Notification-T are pre-positioning concerns handled via `ExtensionSender` before the workflow starts, not in-workflow callbacks. +## 4. Decision Interfaces ---- - -## 4. A2A-T Extension Model - -Four A2A-T extensions are supported. They divide into two groups by lifecycle. - -### 4.1 In-workflow extensions - -Participate in every `sendMessage` lifecycle through the extension handler chain (`ExtensionRegistry` pre-registers -both): - -- **Task-T** - On send, calls the A2A-T SDK to generate a structured task prompt from the natural-language message and - injects it into the message metadata. Skipped for negotiation follow-ups and when the caller pre-sets the prompt. On - receive: pass-through. -- **Negotiation-T** - On receive, when the agent returns `INPUT_REQUIRED` - and declares the extension, extracts the negotiation context and message. This feeds the auto-loop: the engine calls - `ControlPoint.onNegotiation` - for a clarification, resends the follow-up, and repeats up to a configured round limit. - -### 4.2 Pre-positioning extensions - -Established through `ExtensionSender` before a workflow and decoupled from any single workflow transport: - -- **Authorization-T** - Sends a one-shot authorization request. The current implementation uses the caller-supplied natural-language input directly as the metadata value. -- **Notification-T** - Establishes a result subscription. Opens a long-lived SSE stream so later recovery results flow - back through the response stream. +```java +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); +} +``` -The subscription *result* (e.g. a recovery outcome pushed later) flows back through the `sendNotification` response -stream, not through -`onNotification`. That hook only fires when an agent voluntarily includes a Notification-T payload in a `sendMessage` -task response. +onTask returns final parts/metadata/extensions; the engine sends them without generating or rewriting content. +onSelfTask returns local TaskResult, onRoute selects an allowed candidate, and onNegotiation returns Send or Stop. +Unimplemented callbacks fail explicitly. No echo-success, first-branch choice or automatic consent. +See [Business callback contract](BUSINESS_CALLBACKS.md) for fields and working examples. -Task-T uses the SDK's `generateTaskPrompt`. Authorization-T and Notification-T currently use caller-supplied metadata -values because the upstream SDK does not expose corresponding prompt generators. +## 5. A2A-T Extension Model -### 4.3 Extension handler chain +Task-T: the host generates final content; the engine envelopes and sends it. AgentCard declarations do not trigger generation. -```mermaid -graph TD - SM["sendMessage(agent, message)"] - BS["before_send: Task-T generates prompt, injects into metadata"] - TS["transport.send (Task-T metadata on the wire)"] - AR["after_receive: Negotiation-T extracts context (feeds auto-loop)"] - AN["auto_negotiate loop (if INPUT_REQUIRED)"] +Negotiation-T: - SM --> BS --> TS --> AR --> AN -``` +Only a remote `INPUT_REQUIRED` carrying valid Negotiation-T Propose enters `onNegotiation`. +Terminal responses never restart negotiation; ordinary `INPUT_REQUIRED` fails explicitly. +The host validates/interprets the proposal and generates the final Accept/Reject/Abort with its own A2A-T client. +Use `A2atMessages.contextOf(request.received())` to obtain the received context; +reply with the same id, round and maxRounds. The last allowed round can still be answered. +Do not call nextRound for an ending reply or return a new Propose. -`ExtensionRegistry.getHandlersForExtensions` matches an agent's declared extension URIs against handler keywords -(case-insensitive) and returns the handler chain for that agent. Authorization-T / Notification-T handler classes are -retained for callers that need inline handling of agent-pushed data, but they are not auto-registered - that is a -pre-positioning concern. +Return `new NegotiationReply.Send(content)` to send that exact content. +Return `new NegotiationReply.Stop(code, reason)` to stop locally without a generated Abort. +Repeated task/session/round events do not repeat the callback or submission. Unchanged waiting state is observed with getTask. +`maxNegotiationExchanges` (default 3) bounds local interactions, independently of the SDK context's maxRounds. +Timeout, exhausted budget or a missing handler fails locally; no implicit Accept or synthesized Abort. +Accept/Reject ACKs in SUBMITTED/WORKING remain pending and are observed without resending the command. +A business-sent Abort is never diagnosis success, even if the remote acknowledges it with COMPLETED. ---- +Authorization-T and Notification-T are independent of the DAG. The host generates content and uses dedicated senders; failure does not affect the workflow. Whitelists only control optional OMC recovery. Subscriptions stay open until the host closes them. -## 5. Condition Routing +## 6. Condition Routing A step's `next` list holds `JumpCondition(step, condition)` entries. The routing rule is: @@ -181,7 +153,7 @@ This makes conditional branches an N-choose-1 selection and keeps unconditional --- -## 6. Event Model +## 7. Event Model Events are emitted to an optional `EventCallback` as stable string types (`EventType`). They are grouped by origin: @@ -191,98 +163,72 @@ Events are emitted to an optional `EventCallback` as stable string types (`Event `workflow_complete` - **Agent traffic** - `agent_request`, `agent_response`, `agent_status_update`, `agent_artifact_update`, `agent_message_event` -- **A2A-T extensions** - `negotiation_request`, `negotiation_resolved`, - `negotiation_failed`, `authorization_request`, `authorization_resolved`, - `notification` +- **In-workflow A2A-T extensions** - `negotiation_request`, `negotiation_resolved`, + `negotiation_failed` - **Failure** - `error`, emitted on step failure by the executor and on final failure by the runner ---- +`authorization_request`, `authorization_resolved`, and `notification` currently remain reserved +`EventType` constants; the workflow event stream does not emit them. Independent authorization +results are handled through the `ExtensionSender` result, and subscription events through the +`NotificationSubscription` callback. -## 7. Interaction Sequences +--- -### 7.1 Workflow execution with negotiation +## 8. Interaction Sequences ```mermaid sequenceDiagram - participant H as Host - participant E as Executor - participant C as EngineClient - participant A as Agent - - H->>E: run(workflow) - E->>C: onTask(req) - C->>A: before_send: Task-T - A-->>C: INPUT_REQUIRED (Negotiation-T) - C->>E: negotiation result - E->>H: onNegotiation (host supplies clarification) - H->>E: clarification - E->>C: follow-up send - C->>A: send follow-up - A-->>C: final result - C->>E: final result - E->>H: ExecutionResult + participant H as Host callbacks + A2A-T client + participant E as Workflow engine + participant A as Remote agent + E->>H: onTask(TaskRequest + upstream window) + H->>H: Generate/validate final content + H-->>E: MessageContent + E->>A: A2A envelope + unchanged content + opt INPUT_REQUIRED with valid Propose + A-->>E: Task status + Negotiation-T Propose + E->>H: onNegotiation(originalSubmission, received, history) + H->>H: Validate proposal; generate reply + H-->>E: Send(MessageContent) or local Stop + E->>A: Same task/context; final reply content + end + A-->>E: Task result / artifacts + E->>H: onSelfTask(selected complete upstream results) + H-->>E: TaskResult ``` -### 7.2 Pre-positioning authorization - ```mermaid sequenceDiagram - participant H as Host - participant ES as ExtensionSender - participant T as Transport - participant A as Agent - - H->>ES: sendAuthorization(agent) - ES->>ES: generate prompt (SDK) - ES->>T: send(instruction, auth) - T->>A: send - A-->>T: auth result - T-->>ES: auth result - ES-->>H: result + participant H as Host + A2A-T client + participant ES as Independent ExtensionSender + participant A as OMC + H->>H: Generate final authorization content + H->>ES: sendAuthorization(agent, content) + ES->>A: One-shot authorization + A-->>H: Independent result, never gates workflow + H->>H: Generate final subscription content + H->>ES: openNotification(agent, content, listener) + ES-->>H: Registered handle + ES->>A: Independent long-lived stream + A-->>H: ACK via acknowledgement() + A-->>H: listener(handle, ReceivedMessage) + H->>ES: handle.close() on recovery/cancel/shutdown + ES-->>H: completion() after stream exits ``` -### 7.3 Notification subscription - -```mermaid -sequenceDiagram - participant H as Host - participant ES as ExtensionSender - participant T as Transport - participant A as Agent - - H->>ES: sendNotification - ES->>T: sendNotificationStream - T->>A: open long-lived SSE - A-->>T: ack (working) - T-->>ES: first event -> future - ES-->>H: result - Note over T,A: later results stream back over the same connection -``` - ---- - -## 8. Dependencies - - -**Java SDK:** `org.a2aproject.sdk:a2a-java-sdk-client` (A2A protocol), -`net.openan.a2at.sdk:a2a-t-client` (A2A-T extensions), Jackson, SLF4J, Lombok. - -The SDK is standalone: it does not depend on the orchestration center. - ---- - -## 9. Design Decisions Summary - -1. **Reusable transport, two facades** - wire machinery written once on - `A2ATransport`; `WorkflowEngineClient` and `ExtensionSender` each own one orchestration concern and delegate wire - work, with task-scoped or workbench-scoped transport lifetimes. Avoids forced-facade coupling and wire-code duplication. +## 9. Dependencies -2. **In-workflow vs pre-positioning extensions** - Task-T and Negotiation-T are part of the `sendMessage` chain; - Authorization-T is a one-shot request sent before the workflow, while Notification-T is a long-lived subscription - established before the workflow. The registry auto-registers only the in-workflow pair. +workflow-engine uses A2A Java `1.2.0.Final` (REST/JSON-RPC/gRPC), a matching gRPC runtime, +`net.openan.a2a-t.sdk:a2a-t-core:1.1.0`, Jackson and SLF4J. +Pure engine consumers do not transitively receive A2A-T client/server, LLM, prompt or resources. +samples/hosts explicitly depend on a2a-t-client, and OMC receivers additionally use a2a-t-server. +Registry/orchestration discovery is host-owned; RegistryClient/LoadPsop are optional helpers. +Templates and slot schemas come from the pinned SDK jar, not sample resource overrides. -3. **Auto-negotiation loop** - the engine owns the resend loop so hosts only supply clarification text - (`onNegotiation`), never the protocol mechanics of resending. +## 10. Design Decisions Summary -4. **Condition routing semantics** - empty conditions mean fan-out (parallel), conditional branches mean N-choose-1 via - `onRoute`. Keeps the routing model predictable. +Final content is separate from protocol scheduling; hosts do not maintain A2A envelopes. +Local multiple outputs and complete remote evidence enter the selected upstream window without losing metadata or flattening arrays. +Business owns negotiation reply content; the engine owns association, deduplication and bounded waiting. Stop and Abort are distinct. +Independent authorization/notification never gate the workflow; direct and dev Order share callbacks. +Protocol logs observe actual boundaries with mandatory redaction; see [Integration guide](INTEGRATION_GUIDE.md). diff --git a/docs/en/DEVELOPER_GUIDE.md b/docs/en/DEVELOPER_GUIDE.md index e4a06371..03d31a9c 100644 --- a/docs/en/DEVELOPER_GUIDE.md +++ b/docs/en/DEVELOPER_GUIDE.md @@ -15,9 +15,7 @@ Add to your `pom.xml`: ``` -The engine transitively pulls in the A2A protocol SDK (`a2a-java-sdk-client` with REST, -JSON-RPC, and gRPC transports) and the A2A-T extension SDK (`a2a-t-client`). No additional -dependencies are needed. +The engine pulls in A2A protocol transports and a2a-t-core only. Hosts generating A2A-T content explicitly add a2a-t-client; receiving hosts may also add a2a-t-server. ## 2. Core Concepts @@ -29,34 +27,33 @@ dependencies are needed. ## 3. Implement ControlPoint -Only two methods are required: - ```java -public class MyControlPoint implements ControlPoint { - @Override - public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient client) { - return client.sendMessage(request.getAgentName(), request.getMessage()) - .thenApply(result -> TaskResponse.builder() - .success(true) - .output(result.getText()) - .build()); - } - - @Override - public CompletableFuture onRoute( - String stepName, Map results, - List conditions) { - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(conditions.get(0).getStep()) - .reason("picked first") - .build()); - } +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -`onNegotiation` has a default that returns a generic clarification. Authorization and notification are pre-positioning operations sent via `ExtensionSender` before the workflow starts, not on `ControlPoint`. +onTask returns final parts/metadata/extensions; the engine sends them without generating or rewriting content. +onSelfTask returns local TaskResult, onRoute selects an allowed candidate, and onNegotiation returns Send or Stop. +Unimplemented callbacks fail explicitly. No echo-success, first-branch choice or automatic consent. +See [Business callback contract](BUSINESS_CALLBACKS.md) for fields and working examples. + +```java +ControlPoint callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .onSelfTask(request -> CompletableFuture.completedFuture( + TaskResult.success(List.of(Map.of( + "sourceResults", request.getWorkflowInput().upstreamResults()))))) + .onRoute(request -> CompletableFuture.failedFuture( + new IllegalStateException("Supply a routing policy for " + request.stepName()))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Manual confirmation required"))) + .build(); +``` ## 4. Execute via Builder (recommended) @@ -68,7 +65,6 @@ ExecutionResult result = ExecutePsop.builder() .runtimeIntent("Diagnose SPN fault") .lang("zh") .sslVerify(false) - .a2atEnvPath(".env") .credentialsConfigPath("agent_credentials.json") .eventCallback(new EventCallback()) .onFinish((r, e) -> { @@ -93,18 +89,18 @@ Events come from three layers: the runner (lifecycle bracket), the executor (ste | `start` | runner | Workflow begins | `workflow`, `steps` | | `step_start` | executor | Step begins | `step` | | `task_request` | executor | A subtask is dispatched to `onTask`/`onSelfTask` | `step`, `agent`, `task` | -| `task_response` | executor | `onTask`/`onSelfTask` returned a `TaskResponse` | `step`, `agent`, `task`, `output` | +| `task_response` | executor | Remote task completed or onSelfTask returned TaskResult | `step`, `agent`, `task`, `outputs` | | `task_status_changed` | executor | Task status changed (pending → running → success/failed) | `step`, `agent`, `task`, `status` | | `route_decision` | executor | Branch chosen | `step`, `next`, `reason` | | `step_complete` | executor | Step finished | `step`, `results` | | `workflow_complete` | executor | All steps finished | `history`, `step_outputs` | -| `agent_request` | engine client | Message sent to agent | `agent`, `request`, `metadata` | -| `agent_response` | engine client | Response from agent | `agent`, `response` | +| `agent_request` | engine client | Dispatch intent, not a wire observation | `agent`, `content` | +| `agent_response` | engine client | Remote response assembled | `agent`, `response`, `receivedMessages` | | `agent_status_update` | engine client | Agent SSE status update | `agent`, `state`, `is_final` | | `agent_artifact_update` | engine client | Agent SSE artifact update | `agent`, `artifact_name`, `text` | -| `negotiation_request` | engine client | Agent needs clarification | `agent`, `round`, `concern` | -| `negotiation_resolved` | engine client | Clarification provided | `agent`, `round`, `clarification` | -| `negotiation_failed` | engine client | Negotiation failed | `agent`, `round`, `reason` | +| `negotiation_request` | engine client | Valid Propose enters host callback | `agent`, `request`, `exchange` | +| `negotiation_resolved` | engine client | Host Send passed association checks, not task success | `agent`, `reply`, `exchange` | +| `negotiation_failed` | engine client | Local negotiation interaction failed | `agent`, `exchange`, `errorType` | | `complete` | runner | Workflow succeeded | `history`, `step_outputs` | | `error` | runner or executor | Workflow failed | runner: `error`, `history`; executor: `step`, `results` | | `close` | runner | Cleanup done | (empty) | @@ -116,7 +112,6 @@ try(var client = new DefaultWorkflowEngineClient(agentCards, a2aRuntime, WorkflowEngineClientConfig.builder() .sslVerify(false) .credentialsConfigPath("etc/conf/agent_credentials.json") - .a2atEnvPath(".env") .build())){ WorkflowExecutor executor = new WorkflowExecutor( workflow, @@ -132,26 +127,20 @@ ExecutionResult result = executor.run().join(); ### 6.1 Negotiation Auto-Loop -The engine client's `sendMessage()` automatically handles negotiation: -when the agent returns `INPUT_REQUIRED`, the engine extracts the negotiation text from response metadata, calls -`ControlPoint.onNegotiation()` -for a clarification, and sends it back as a follow-up message. The loop repeats up to `maxNegotiationRounds` (default -3). - -Override `onNegotiation()` in your `ControlPoint` to provide business-specific clarifications: - -```java - -@Override -public CompletableFuture onNegotiation( - String agentName, String negotiationText, - Map receiveResult) { - return myLlm.generate("Agent " + agentName + " needs: " + negotiationText) - .thenApply(Response::text); -} -``` - -Return an empty/null string to fail the round (a `negotiation_failed` event is emitted and the loop retries). +Only a remote `INPUT_REQUIRED` carrying valid Negotiation-T Propose enters `onNegotiation`. +Terminal responses never restart negotiation; ordinary `INPUT_REQUIRED` fails explicitly. +The host validates/interprets the proposal and generates the final Accept/Reject/Abort with its own A2A-T client. +Use `A2atMessages.contextOf(request.received())` to obtain the received context; +reply with the same id, round and maxRounds. The last allowed round can still be answered. +Do not call nextRound for an ending reply or return a new Propose. + +Return `new NegotiationReply.Send(content)` to send that exact content. +Return `new NegotiationReply.Stop(code, reason)` to stop locally without a generated Abort. +Repeated task/session/round events do not repeat the callback or submission. Unchanged waiting state is observed with getTask. +`maxNegotiationExchanges` (default 3) bounds local interactions, independently of the SDK context's maxRounds. +Timeout, exhausted budget or a missing handler fails locally; no implicit Accept or synthesized Abort. +Accept/Reject ACKs in SUBMITTED/WORKING remain pending and are observed without resending the command. +A business-sent Abort is never diagnosis success, even if the remote acknowledges it with COMPLETED. ### 6.2 Workflow Model Fields @@ -160,8 +149,8 @@ Return an empty/null string to fail the round (a `negotiation_failed` event is e | `steps[].stepType` | `WorkflowStep` | `AllSuccess` (default): every subtask must succeed; `AnySuccess`: any subtask success suffices; `SelfLoop`: the workflow agent handles the step locally via `onSelfTask` (no A2A-T message to the named agent). | | `steps[].subtasks[]` | `Task` | Each has `agent`, `skill`, `description`. One `onTask` (or `onSelfTask` for SelfLoop) call per subtask. | | `steps[].next[]` | `List` | Branch targets. `step` = next step name; `condition` = rule text. | -| `steps[].layer` | `WorkflowStep` | `layer == 0` starts the DAG (context = runtime intent only). Higher layers get upstream results. | -| `steps[].contextFrom` | `WorkflowStep` | Optional step names whose outputs fold into context. `"*"` = all ancestors. | +| `steps[].layer` | `WorkflowStep` | Orchestration-level hint; actual readiness is derived from DAG predecessors. | +| `steps[].contextFrom` | `WorkflowStep` | Selects steps exposed through `workflowInput.upstreamResults`; omitted = direct predecessors, `[]` = none, `"*"` = all ancestors, or explicit ancestor names. | ### 6.3 AgentCard Type @@ -243,16 +232,13 @@ Set `sslVerify=false` only for dev with self-signed certs. ## 9. A2A-T Environment (.env) -```ini -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_API_KEY=sk-... -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LANGUAGE=zh-CN -A2AT_CRED_KEY=<32-byte hex> -``` - -When `a2atEnvPath` is null, Task-T prompt generation is skipped. +The engine does not read A2A-T .env files or create LLM clients. If host callbacks use A2A-T, +initialize A2ATClient/A2ATServer with a host-owned environment file containing provider/model/key/base URL +and A2AT_LANGUAGE. The sample's a2atEnvPath setting is only a host/demo setting, not an engine builder option. +Do not put OMC credential decryption ownership in LLM configuration: pass the secret explicitly through +WorkflowEngineClientConfig.builder().credentialEncryptionKey(key) when using encrypted built-in credentials, +then pass that configured engineClient to ExecutePsop. Custom AuthProvider owns its own token/configuration. +Tests use the current SDK SPI with an offline provider, not template overrides or production fallbacks. ## 10. Integration Patterns @@ -286,16 +272,32 @@ public Flux execute(@PathVariable String psopId) { ### Cancellation -`ExecutePsop.builder().execute()` returns a `CompletableFuture`. You can -`cancel(true)` it, but the internal executor does not actively interrupt a running A2A call. For SSE, drop the -subscriber and let the future complete. +ExecutePsop.builder().execute() returns a CompletableFuture. Cancellation prevents late callback results +from being sent and requests conversation cleanup. Hosts must separately cancel their own LLM/business work. +A remote task already submitted may require an explicit cancelTask operation; local cancellation is not protocol Abort. + ## 11. Checklist 1. Add Maven dependencies -2. Implement `ControlPoint` (at minimum `onTask` + `onRoute`; `onSelfTask` and `onNegotiation` have defaults) +2. Implement `ControlPoint` (implement final onTask content plus local tasks, conditional routes and negotiation when required) 3. Get AgentCards (from registry or JSON files) 4. Load Workflow (via `LoadPsop` or build your own) 5. Configure `.env` and credentials file 6. Call `ExecutePsop.builder().execute()` 7. Handle events + onFinish persistence + +### Pretty protocol log display + +Protocol logs default to pretty display: one header value per line and indented JSON bodies. +SSE keeps event controls (id/event/comments); JSON appears between `=== SSE data (JSON display; not wire text) ===` +and `=== End SSE data ===`, without repeating `data:` on every JSON line. Event boundaries remain separate. +Set the environment variable or JVM property `WORKFLOW_ENGINE_PROTOCOL_PRETTY=false` to retain redacted raw body text. +Formatting changes presentation only (JSON whitespace and SSE display labels), never transmitted bytes, metadata, number tokens or extension headers. +The raw observer content remains unchanged. +SSE pretty output is a display, not a replayable packet capture. Escaped newlines inside JSON strings stay escaped; +invalid/incomplete or non-JSON content stays raw. Redaction and capacity limits still apply. +The demo logs `NEGOTIATION_DEMO enabled=..., city=...` at startup. Locally City1 negotiates by default while City2 diagnoses directly; +`-Da2at.samples.negotiation=false` disables the missing-input scenario. +Look for `A2A-Extensions: .../Negotiation-T/v1`, the matching metadata key and `negotiationContext`; +there is no dedicated Negotiation-T HTTP header. diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index eb7869e1..713e3974 100644 --- a/docs/en/INTEGRATION_GUIDE.md +++ b/docs/en/INTEGRATION_GUIDE.md @@ -5,8 +5,8 @@ The A2A-T Workflow Execution Engine is a Java SDK for orchestrating multi-agent workflows using the A2A protocol with A2A-T telecom extensions. -The engine handles all protocol mechanics automatically (message transport, SSE streaming, Task-T prompt generation, -Negotiation-T auto-loop, authentication, TLS). You focus on business decisions only. +The engine schedules A2A tasks, envelopes final content, manages authentication/transport and waits for results. +Host callbacks own A2A-T generation, semantic validation, schemas and any LLM calls. ## 2. Prerequisites @@ -88,71 +88,33 @@ List> cards = registry.fetchAgentCards(); ### 4.3 Implement ControlPoint -Extend `DefaultControlPoint` and override the methods you need: - ```java -public class MyControlPoint extends DefaultControlPoint { - @Override - public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - return engineClient.sendMessage( - request.getAgentName(), request.getMessage()) - .thenApply(r -> { - String state = r.getTaskState(); - boolean success = state == null || state.isBlank() - ? r.getText() != null && !r.getText().isBlank() - : state.endsWith("COMPLETED"); - return TaskResponse.builder() - .success(success) - .output(r.getText()) - .build(); - }); - } - - @Override - public CompletableFuture onSelfTask(TaskRequest request) { - // SELF_LOOP step: handled locally, no engineClient, no A2A-T message. - // request.getMessage() already carries upstream step results as context. - String summary = summarizeLocally(request.getMessage()); - return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output(summary).build()); - } - - @Override - public CompletableFuture onRoute( - String stepName, Map results, - List conditions) { - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(conditions.get(0).getStep()) - .build()); - } - - @Override - public CompletableFuture onNegotiation( - String agentName, String negotiationText, - Map receiveResult) { - return CompletableFuture.completedFuture( - "Please proceed with available information."); - } +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -| Method | When Called | What You Do | -|-------------------|-------------------------------------------|--------------------------------------------------| -| `onTask` | A step dispatches a task to another agent | Call `engineClient.sendMessage()`, return result | -| `onSelfTask` | A `SELF_LOOP` step runs locally | Handle locally, return result (no A2A-T message) | -| `onRoute` | After step completes, before next step | Pick the next step from candidates | -| `onNegotiation` | Agent returns `INPUT_REQUIRED` | Return clarification text | +onTask returns final parts/metadata/extensions; the engine sends them without generating or rewriting content. +onSelfTask returns local TaskResult, onRoute selects an allowed candidate, and onNegotiation returns Send or Stop. +Unimplemented callbacks fail explicitly. No echo-success, first-branch choice or automatic consent. +See [Business callback contract](BUSINESS_CALLBACKS.md) for fields and working examples. -`onNegotiation` defaults to a generic clarification. Override only what you need. - -**Pre-positioning (Authorization-T / Notification-T)**: Both are established through `ExtensionSender` before a workflow starts, but they have different lifecycles. Authorization-T is a one-shot request that ends after its response. Notification-T is a long-lived subscription on a transport independent of any single workflow; its first event or acknowledgement is returned as `SendMessageResult`, and later events are delivered to the callback until that subscription transport is explicitly closed. - -**Self-loop steps (SelfLoop)**: When a step is the workflow-executing agent's own task (e.g. merging multiple agents' -diagnostic results), set `stepType` to `SELF_LOOP`. The engine calls `onSelfTask` locally instead of sending an A2A-T -message to the agent itself. `onSelfTask` takes no `engineClient` parameter — this enforces at the API level that -self-loop tasks never send A2A-T. Only steps targeting other agents go through `onTask` + A2A-T. +```java +ControlPoint callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .onSelfTask(request -> CompletableFuture.completedFuture( + TaskResult.success(List.of(Map.of( + "sourceResults", request.getWorkflowInput().upstreamResults()))))) + .onRoute(request -> CompletableFuture.failedFuture( + new IllegalStateException("Supply a routing policy for " + request.stepName()))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Manual confirmation required"))) + .build(); +``` ### 4.4 Execute @@ -163,7 +125,6 @@ ExecutionResult result = ExecutePsop.builder() .controlPoint(new MyControlPoint()) .runtimeIntent("SPN cross-city fault diagnosis") .lang("zh") - .a2atEnvPath(".env") .credentialsConfigPath("credentials.json") .sslVerify(true) .onFinish((r, history) -> { @@ -179,21 +140,13 @@ Required: `psop`, `controlPoint`. All other config items have defaults. ### 5.1 .env File -Configures the LLM and prompt runtime: - -```ini -A2AT_LANGUAGE=zh-CN -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-v4-flash -A2AT_LLM_API_KEY=sk-xxxxxxxxxxxxxxxx -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_MAX_TOKENS=2000 -A2AT_LLM_TEMPERATURE=0 -A2AT_LLM_TIMEOUT_SECONDS=60 -A2AT_CRED_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef -``` - -When `.env` is not configured, Task-T prompt generation is unavailable. All other features work normally. +The engine does not read A2A-T .env files or create LLM clients. If host callbacks use A2A-T, +initialize A2ATClient/A2ATServer with a host-owned environment file containing provider/model/key/base URL +and A2AT_LANGUAGE. The sample's a2atEnvPath setting is only a host/demo setting, not an engine builder option. +Do not put OMC credential decryption ownership in LLM configuration: pass the secret explicitly through +WorkflowEngineClientConfig.builder().credentialEncryptionKey(key) when using encrypted built-in credentials, +then pass that configured engineClient to ExecutePsop. Custom AuthProvider owns its own token/configuration. +Tests use the current SDK SPI with an offline provider, not template overrides or production fallbacks. ### 5.2 Credentials File @@ -304,7 +257,6 @@ public class SsoAuthProvider implements AuthProvider { WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .authProvider(new SsoAuthProvider(mySsoClient)) .sslVerify(true) - .a2atEnvPath(".env") .build(); ``` @@ -353,7 +305,7 @@ AgentCards declare extensions via `capabilities.extensions`: "required": false }, { - "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T", + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", "description": "Negotiation text exchange", "required": false }, @@ -398,68 +350,28 @@ Both `securitySchemes` and `securityRequirements` are optional. The former lists ## 7. A2A-T Extensions -The engine handles four A2A-T extensions automatically. You do not need to deal with protocol details: - -### Task-T (automatic) - -When sending a message to an agent, the engine generates a structured task prompt and places it in the message metadata. -In `onTask`, you just call -`sendMessage()` -- prompt generation is transparent. - -### Negotiation-T (automatic) - -When an agent returns `INPUT_REQUIRED`, the engine extracts the negotiation text, calls your `onNegotiation()` for a -clarification, and sends it back. Auto-loops up to `maxNegotiationRounds` (default 3). - -### Authorization-T (pre-positioning) - -Before the workflow starts, send a whitelist authorization strategy to SPN agents. Pre-positioning uses the -`ExtensionSender` facade over the same transport, not the workflow client: - -```java -ExtensionSender sender = new DefaultExtensionSender(transport); -sender.sendAuthorization( - "SPN Domain Agent", - "Authorization-T pre-positioning", - "Task type: new authorization, operation: service recovery, ..." -); -``` - -`A2ATExtension.AUTHORIZATION_T` is used internally; never hardcode the URI. The SPN agent stores the strategy and -compares subsequent operations against the whitelist. Operations within the whitelist are executed; others are rejected. - -### Notification-T (pre-positioning) - -Before the workflow starts, subscribe to recovery result notifications: +Only a remote `INPUT_REQUIRED` carrying valid Negotiation-T Propose enters `onNegotiation`. +Terminal responses never restart negotiation; ordinary `INPUT_REQUIRED` fails explicitly. +The host validates/interprets the proposal and generates the final Accept/Reject/Abort with its own A2A-T client. +Use `A2atMessages.contextOf(request.received())` to obtain the received context; +reply with the same id, round and maxRounds. The last allowed round can still be answered. +Do not call nextRound for an ending reply or return a new Propose. + +Return `new NegotiationReply.Send(content)` to send that exact content. +Return `new NegotiationReply.Stop(code, reason)` to stop locally without a generated Abort. +Repeated task/session/round events do not repeat the callback or submission. Unchanged waiting state is observed with getTask. +`maxNegotiationExchanges` (default 3) bounds local interactions, independently of the SDK context's maxRounds. +Timeout, exhausted budget or a missing handler fails locally; no implicit Accept or synthesized Abort. +Accept/Reject ACKs in SUBMITTED/WORKING remain pending and are observed without resending the command. +A business-sent Abort is never diagnosis success, even if the remote acknowledges it with COMPLETED. ```java -sender.sendNotification( - "SPN Domain Agent", - "Notification-T subscription", - "Topic: service-recovery-execution-result, ..." -); +CompletableFuture sendAuthorization(String agentName, MessageContent content); +NotificationSubscription openNotification(String agentName, MessageContent content, + BiConsumer listener); ``` -`A2ATExtension.NOTIFICATION_T` opens a long-lived SSE stream. To receive subsequent recovery results, pass a -`Consumer>` callback as the fourth parameter: - -```java -sender.sendNotification( - "SPN Domain Agent", - "Notification-T subscription", - "Topic: service-recovery-execution-result, ...", - event -> { - // event contains agent, text, metadata, state - Object text = event.get("text"); - if (text != null) { - System.out.println("Recovery result: " + text); - } - } -); -``` - -Without a callback (null), subsequent events are dropped. The SPN agent reports recovery results through the -notification channel. +The host generates final Authorization-T/Notification-T content and calls these methods on separate transport/runtime/context instances. The listener receives handle and complete ReceivedMessage and closes on the business recovery event. acknowledgement() and completion() separately represent ACK and actual stream exit, never workflow prerequisites. ## 8. HTTPS Configuration @@ -485,24 +397,55 @@ so mTLS and `crlPath` cannot be combined with that mode and fail fast instead of ## 9. Logging -The dedicated `PROTOCOL` logger emits protocol requests and responses. Bodies are enabled by default and truncated to a configurable size. Sensitive headers such as Authorization, cookies, API keys, tokens, and secrets are redacted by default. Configure the logger in `log4j2.properties`: - -```properties -logger.PROTOCOL.name=PROTOCOL -logger.PROTOCOL.level=info -logger.PROTOCOL.additivity=false -logger.PROTOCOL.appenderRef=console -``` +The `PROTOCOL` logger at DEBUG records observations at the actual transport boundary. +HTTP/JSON-RPC logs preserve the serialized body and application headers after A2A SDK processing, +including A2A-Version when actually present. gRPC records actual metadata and a protobuf JSON view; +that view is not an HTTP JSON body. The engine never adds a missing header just to make logs look uniform. +Automatic network headers, HTTP/2 frames, TLS records and server-side bytes are not captured. -Control the content with environment variables or same-named JVM system properties: +On dev, `ORDER_FORWARD_REQUEST` records the vendor SDK input and `ORDER_SDK_RESPONSE` records +the status, multi-value headers and text delivered by the SDK. `sdk-sse-text` assembles available SSE +framing from SDK string chunks; original byte encoding and the platform-to-OMC wire remain unobserved. +It is not OMC packet-capture evidence. `MODEL_PREVIEW` is optional, disabled by default, and never wire proof. ```properties +logger.protocol.name=PROTOCOL +logger.protocol.level=DEBUG +logger.protocol.additivity=true WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY=true WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS=100000 -WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=false ``` -Enable sensitive headers only for isolated, controlled local diagnostics; the engine emits a security warning when this opt-in is active. +Body observation defaults to enabled when DEBUG is enabled; disable it explicitly for sensitive deployments. +JVM properties take precedence over same-named environment variables. +Header credentials/cookies/tokens and recognized secret body fields are always redacted; this cannot be disabled. +This is field-based redaction, not a classifier for all personal/business-sensitive content. +Bodies are bounded (raw collectors use the configured numeric limit as bytes; emitted text uses characters). +Oversized SSE frames are dropped whole until the next delimiter and marked `dropped-capacity`; +disabled, truncated and interrupted observations are labeled. UTF-8 is decoded after assembling chunks. +Observers cannot fail delivery. File references are recorded as references and are never downloaded for logging. +requestId correlates each call; workflow calls additionally carry executionId/logicalTaskId/attempt, +agent/contextId/channel and remoteTaskId when known. These are local log fields, not wire metadata. + +### Inspect negotiation in the local Demo + +Running the local SpringSpnDemo without VM options now negotiates missing City1 input; City2 diagnoses directly. +This is a local sample scenario, not an engine default. To use complete inputs in both cities, add this IDEA **VM option**: + +```text +-Da2at.samples.negotiation=false +``` + +By default only City1 loses its Task-T task object. Add `-Da2at.samples.negotiation.city=city2` or `both` +to exercise City2 or both cities. The host retains city-scoped authoritative input and the complaint context is preserved. +Expect DEMO_NEGOTIATION → INPUT_REQUIRED/PROPOSE → onNegotiation → ACCEPT → both diagnoses → one aggregate. +External-OMC mode defaults to no injection and rejects an explicit true switch. +The Demo passes its setting into the current Spring application context without modifying JVM-wide properties. +Protocol observations are in the console and `logs/spn-demo.log` relative to the run directory. + +Run `SpringSpnDemoE2ETest` (direct) and, on dev, `SpringSpnDemoOrderE2ETest` sequentially. +Each tests the no-VM-option single-city default, explicit disable/enable and both-city negotiation with current SDK resources and an offline LLM provider. +This is local protocol E2E evidence, not real model/platform/OMC validation. ## 10. Event Callback @@ -546,41 +489,7 @@ Workflow workflow = LoadPsop.load( ## 12. Custom Extensions -To add a new A2A-T extension, implement `ExtensionHandler`: - -```java -public class MyExtensionHandler implements ExtensionHandler { - @Override - public String extensionKeyword() { - return "My-Extension"; - } - - @Override - public CompletableFuture> beforeSend( - AgentCard agentCard, String messageText, - Map metadata, - A2ATClient a2atClient, ControlPoint controlPoint) { - metadata.put("https://example.com/extensions/My-Extension/v1", "value"); - return CompletableFuture.completedFuture(metadata); - } - - @Override - public CompletableFuture afterReceive( - AgentCard agentCard, SendMessageResult result, - A2ATClient a2atClient, ControlPoint controlPoint, - EventCallback eventCallback) { - return CompletableFuture.completedFuture(result); - } -} -``` - -Register via config: - -```java -WorkflowEngineClientConfig.builder() - .customHandlers(List.of(new MyExtensionHandler())) - .build(); -``` +Construct final MessageContent(parts, metadata, extensions), with host-owned content generation/validation. No engine handler or SDK instance registration. A2atMessages.from copies A2A-T metadata; other extensions can directly supply metadata and activation URIs. AgentCard declarations never cause implicit generation. ## 13. Interface Reference @@ -589,13 +498,12 @@ WorkflowEngineClientConfig.builder() | `ExecutePsop.Builder` | Workflow execution entry point | | `ControlPoint` / `DefaultControlPoint` | Business decisions (onTask, onSelfTask, onRoute, onNegotiation, etc.) | | `WorkflowEngineClient` / `DefaultWorkflowEngineClient` | Workflow send (sendMessage, auth, extensions) | -| `ExtensionSender` / `DefaultExtensionSender` | One-shot pre-positioning (sendAuthorization, sendNotification) | -| `A2ATransport` | Shared wire layer (httpx runtime, auth, SSE consumer) | +| `ExtensionSender` / `DefaultExtensionSender` | Independent Authorization-T operations and Notification-T subscriptions | +| `A2ATransport` | Shared wire layer (A2A Java client runtime, auth, SSE consumer) | | `WorkflowEngineClientConfig` | Configuration (SSL, auth, A2A-T, negotiation rounds, custom handlers) | | `AuthProvider` | Custom authentication | -| `ExtensionHandler` | Custom extension handler | | `EventCallback` / `EventType` | Event callback | | `LoadPsop` / `RegistryClient` | Workflow loading / AgentCard fetching | | `Workflow` / `WorkflowStep` / `Task` / `JumpCondition` | Workflow definition | | `ExecutionResult` | Execution result | -| `SendMessageResult` / `TaskResponse` | Message/task response | +| `SendMessageResult` / `TaskResult` | Message/task response | diff --git a/docs/en/notification-t-design-analysis.md b/docs/en/notification-t-design-analysis.md deleted file mode 100644 index dad4d835..00000000 --- a/docs/en/notification-t-design-analysis.md +++ /dev/null @@ -1,322 +0,0 @@ -# Notification-T / Authorization-T Design Analysis - -> **Superseded.** This historical analysis has been folded into -> [DESIGN.md](DESIGN.md), sections 4 (A2A-T Extension Model) and 7 -> (Interaction Sequences). The pre-positioning flow described below now -> lives on `ExtensionSender` (see DESIGN.md, "Shared transport with two -> facades"), not on `WorkflowEngineClient`. Kept for reference only. - ---- - -# Notification-T / Authorization-T Design Analysis - -## Current Implementation (as of 2026-07-27) - -### Pre-positioning: Three Independent One-Shot A2A Calls - -The engine currently sends Authorization-T, Notification-T, and Task-T as three completely separate A2A message/send -calls. Each opens its own HTTP connection (SSE stream), gets a response, and closes. - -``` - -```mermaid -sequenceDiagram - participant W as Workbench - participant S as SPN Agent - - W->>S: 1. sendAuthorization (message/send) - Note right of S: detect Authorization-T, store policy - S-->>W: ack artifact + COMPLETED - W->>S: 2. sendNotification (message/send) - Note right of S: detect Notification-T, store subscription - S-->>W: ack artifact + COMPLETED - W->>S: 3. Task-T diagnosis (message/stream) - S-->>W: INPUT_REQUIRED (negotiation) - W->>S: negotiation reply - Note right of S: diagnosis -> selfTriggerRecovery() - Note right of S: check whitelist -> execute recovery - S-->>W: artifact (recovery result) - S-->>W: COMPLETED (stream closes) -``` - -### Key Code Locations - -- sendExtensionMessage: DefaultExtensionSender.java - - Generates metadata value via A2ATClient.generateTaskPrompt () (LLM + template) - - Falls back to raw natural-language input if SDK unavailable - - Calls doSendViaA2ARuntime () -> supplyAsync -> a2aClientRuntime.sendMessage () - - Bypasses Task-T prompt generation and Negotiation-T auto-loop - -- SPN Agent pre-positioning handler: NegotiationBaseAgentExecutor.java - - detectPrePositionedExtension (): scans message metadata for - "Authorization-T" or "Notification-T" keywords - - handlePrePositionedExtension (): stores payload text in volatile field, sends ack artifact + TASK_STATE_COMPLETED, - execute () returns immediately - - authorizationPolicy: volatile String, stored on first Authorization-T receipt - - notificationSubscription: volatile String, stored on first Notification-T receipt - -- SPN Agent recovery: SpnDomainAgentCity1Executor.java selfTriggerRecovery () - - getAuthorizationPolicy () to read stored whitelist policy - - Matches policy keywords ("business recovery", "optical module", "authorization") - - If matched: execute recovery, emit artifact with Notification-T URI in metadata - - If not matched: emit refusal artifact with Notification-T URI in metadata - -### Facts - -1. Authorization-T is a static store-and-read. Policy text stored in memory, read later during Task-T diagnosis via - getAuthorizationPolicy (). Works. - -2. Notification-T subscription text is stored but NEVER READ. getNotificationSubscription () exists but is not called - anywhere in samples code. The stored subscription text has no functional use. - -3. Recovery result comes back through Task-T SSE stream, NOT through a separate Notification-T channel. - selfTriggerRecovery () emits an artifact with Notification-T URI in artifact metadata, but this artifact is part of - the Task-T SSE response (executeBusiness has not returned yet, execute () - has not returned yet, the same SSE stream is still open). - -4. No long-lived connections. All three SSE streams follow open->receive->close pattern. Notification-T pre-positioning - stream is especially short-lived: - sends ack and closes immediately. - -5. All three calls are independent. They share state only through the SPN Agent's in-memory volatile fields - (authorizationPolicy, notificationSubscription). No persistent channel between them. - -## Problem Statement - -Notification-T should be a real subscription channel, not a static one-shot message. The recovery result should flow -back through the Notification-T channel (the SSE stream opened during subscription), not piggyback on the Task-T stream. - -Currently: - -- Notification-T subscription = send ack, close stream (no channel created) -- Recovery result = piggyback on Task-T stream artifact metadata - -Desired: - -- Notification-T subscription = keep SSE stream open (channel created) -- Recovery result = push through the open Notification-T SSE stream - -## Proposed Design: Long-Lived SSE for Notification-T - -### Concept - -The workbench (client) sends a Notification-T subscription via message/stream. The SPN Agent keeps the SSE response -stream open. When recovery completes, the SPN Agent pushes the result through this stream. The client cancels by -disconnecting. - -``` - -```mermaid -sequenceDiagram - participant W as Workbench - participant S as SPN Agent - - W->>S: Notification-T subscribe (message/stream, SSE) - S-->>W: "subscribed" status update - Note over S: execute() blocks, stream stays open - W->>S: Task-T diagnosis (separate connection) - Note right of S: diagnosis -> recovery -> signal queue - S-->>W: COMPLETED (Task-T stream closes) - S-->>W: SSE event: recovery result (via Notification-T stream) - W->>S: close connection (cancel subscription) -``` - -### Authentication - -Forward only. The workbench authenticates to the SPN Agent when opening the SSE stream (existing bearerAuth). The SPN -Agent writes events to the response stream it already has open. No reverse HTTP call, no reverse authentication. - -This eliminates the webhook reverse-call authentication problem entirely. - -### Implementation Requirements - -SPN Agent side (NegotiationBaseAgentExecutor): - -- handlePrePositionedExtension () Notification-T branch: do NOT call emitter.complete () or return. Instead: - 1. Send "subscribed" status update (TASK_STATE_WORKING) - 2. Register this emitter/stream as the notification target - 3. Block on a queue/CompletableFuture (keep SSE stream open) - 4. When recovery completes (signaled from Task-T execute () in another thread), push a MessageEvent or - TaskUpdateEvent with recovery result + Notification-T metadata - 5. Continue blocking until client disconnects or sends cancel - -- selfTriggerRecovery () in SpnDomainAgentCity1Executor: instead of (or in addition to) emitting artifact on Task-T - stream, signal the Notification-T stream to push the recovery result - -- Thread management: two concurrent execute () calls on the same executor instance (Notification-T subscription + Task-T - diagnosis). The A2A server must run them on separate threads. - -Workbench/client side (DefaultWorkflowEngineClient): - -- sendNotification () must NOT .join () and close. It must keep the SSE reader running in background, feeding events to - a queue/callback. -- The workflow merge step must be able to wait for Notification-T events from the queue (with timeout). - -Authorization-T: no change needed. Static store-and-read works fine. - -### Open Questions (to align with user before implementing) - -1. Does the A2A SDK (a2a-java-sdk) support keeping an SSE response stream open and pushing events to it later, after the - initial message is processed? The current EmbeddedA2AServer / AgentEmitter model assumes execute () returns to close - the stream. - -2. How does the SPN Agent correlate the Notification-T subscription stream with the Task-T diagnosis stream? Same - contextId? Same agent instance? A subscription registry? - -3. What happens if the Notification-T SSE connection drops (network issue)? Is the subscription lost? Does the workbench - need to reconnect? - -4. Should the workflow merge step block-wait for the Notification-T result, or should it proceed with diagnosis results - only and handle recovery results separately? - -5. Timeout: if recovery never completes, how long does the Notification-T stream stay open? - -6. Does the A2A-T SDK (a2a-t-sdk-java) have built-in support for long-lived Notification-T subscriptions, or do we need - to implement this ourselves? - -## Authorization-T: No Change Needed - -Authorization-T is a one-way static policy push: - -- Workbench sends policy text -> SPN Agent stores in memory -- During Task-T diagnosis, SPN Agent reads stored policy for whitelist check -- No response channel needed through Authorization-T - -This pattern is correct and requires no modification. - -## File Locations - -- Engine client: workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java -- SPN agent base: samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationBaseAgentExecutor.java -- SPN agent city1: samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java -- SPN agent city2: samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java -- Workbench control: samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchControlPoint.java -- Demo entry: samples/src/main/java/dev/openan/workflow/engine/examples/SpnCrossCityDiagnosisDemo.java -- Agent server: samples/src/main/java/dev/openan/workflow/engine/examples/embedded/StartAgentsServer.java -- E2E test: samples/src/test/java/dev/openan/workflow/engine/examples/SpnCrossCityE2ETest.java -- AgentCard city1: samples/src/main/resources/agentcard/spn_domain_agent_city1.json -- AgentCard city2: samples/src/main/resources/agentcard/spn_domain_agent_city2.json -- AgentCard workbench: samples/src/main/resources/agentcard/transport_workbench_agent.json -- Credentials: samples/src/main/resources/spn_agent_credentials.json -- Workflow JSON: orchestration-center/data/workflow_storage/psop/psop_spn_cross_city_diagnosis.json (separate repo) -- Protocol examples: docs/zh/调用过程.md - -## Business Flow Definition (from business-flow.md / yewuliu.md) - -### Four A2A-T Interfaces - -| # | Extension | Direction | Purpose | -|---|-----------------|--------------------------------------------------|----------------------------------------------------------| -| 1 | Task-T | Workbench -> SPN | Diagnosis request + diagnosis result return | -| 2 | Negotiation-T | SPN <-> Workbench (bidirectional, SPN initiates) | Parameter correction when OMC finds missing/wrong params | -| 3 | Notification-T | Workbench subscribes -> SPN pushes via SSE | Recovery result subscription + push reporting | -| 4 | Authorization-T | Workbench -> SPN | Whitelist authorization policy (static, one-way) | - -### Role Clarification (confirmed with user 2026-07-27) - -- Workbench IS an A2A-T Server (receives Task-T from upper layer) -- Upper layer -> Workbench: simple Task-T, NO negotiation -- Negotiation happens ONLY between Workbench and SPN Domain Agent -- Negotiation is CONDITIONAL: SPN initiates only when it finds missing/wrong parameters, not every time - -### Complete Business Flow - -``` - -```mermaid -sequenceDiagram - participant U as Upper layer - participant W as Workbench - participant S1 as SPN Agent City1 - participant S2 as SPN Agent City2 - - U->>W: Task-T diagnosis - par - W->>S1: Auth-T pre-position - W->>S1: Notif-T pre-position (SSE stays open) - and - W->>S2: Auth-T pre-position - W->>S2: Notif-T pre-position (SSE stays open) - end - par - W->>S1: Task-T diagnosis - and - W->>S2: Task-T diagnosis - end - alt params missing/wrong - S1-->>W: Negotiation-T (conditional) - W->>S1: negotiation reply - end - S1-->>W: diagnosis result - S2-->>W: diagnosis result - Note over S1: check whitelist -> auto recovery - S1-->>W: recovery result (via Notif-T SSE) - S2-->>W: recovery result (or no result) - Note over W: SelfLoop: merge - W-->>U: merged result -``` - -### Key Design Decisions (confirmed) - -1. Workbench = A2A Server (receives upper layer Task-T) -2. Upper layer -> Workbench: NO negotiation (just send task, get result) -3. Negotiation: Workbench <-> SPN, SPN initiates, CONDITIONAL (not every time) -4. Notification-T: long-lived SSE stream (workbench opens, SPN keeps open, pushes recovery result when done) -5. Authorization-T: static one-way push (no change from current) - -## Current Demo Complexity Issues - -### Problem 1: Unnecessary A2A indirection (Workbench server + workflow runner) - -Current: Demo sends Task-T to Workbench Agent (A2A server) -> Workbench Agent receives task -> Workbench internally runs -workflow -> workflow sends Task-T to SPN agents. - -The Workbench correctly plays both server (upper layer) and client (SPN agents) roles. But the -TransportWorkbenchAgentExecutor mixes "receive task" -and "run workflow" in one execute () call, making the flow hard to follow. - -Simplification: Keep the dual role but make it cleaner: - -- execute () receives the upper layer task -- Pre-positions Auth-T + Notif-T to SPN agents -- Runs the workflow (ExecutePsop) -- Waits for recovery results from Notif-T streams -- Merges and returns - -### Problem 2: Negotiation forced every time - -Current: NegotiationBaseAgentExecutor.handleNewTask () ALWAYS sends INPUT_REQUIRED, even when parameters are fine. - -Business flow says: negotiation is conditional - SPN initiates ONLY when it finds missing/wrong parameters. - -Simplification: SPN agent checks parameters first. If valid, proceed to diagnosis directly. If invalid/missing, send -INPUT_REQUIRED. - -### Problem 3: Recovery result piggybacks on Task-T stream - -Current: selfTriggerRecovery () emits artifact on the Task-T SSE stream. - -Business flow says: recovery result goes through the Notification-T SSE stream (the one opened during subscription). - -Simplification: Implement long-lived Notification-T SSE (see above). - -### Problem 4: Pre-positioning not in Workbench executor - -Current: Auth-T and Notif-T pre-positioning is done by the E2E test or demo code, not by the Workbench executor itself. - -Business flow says: Workbench pre-positions before diagnosis. - -Simplification: Workbench executor handles pre-positioning internally before starting the workflow. - -### Problem 5: Agent executor inheritance chain too deep - -Current: -AgentExecutor (SDK interface) --> BaseAgentExecutor (extractText, buildStatusMessage) --> NegotiationBaseAgentExecutor (negotiation, pre-positioning, business) --> SpnDomainAgentCity1Executor -> SpnDomainAgentCity2Executor -> TransportWorkbenchAgentExecutor - -Three levels of inheritance makes it hard to trace the flow. - -Simplification: Flatten where possible. Or at minimum, make the flow in each class readable without jumping through -three files. \ No newline at end of file diff --git a/docs/images/workflow-engine-surrounding-systems.png b/docs/images/workflow-engine-surrounding-systems.png new file mode 100644 index 00000000..6d501ab1 Binary files /dev/null and b/docs/images/workflow-engine-surrounding-systems.png differ diff --git a/docs/zh/A2AT-SDK-DEPENDENCY.md b/docs/zh/A2AT-SDK-DEPENDENCY.md new file mode 100644 index 00000000..51029242 --- /dev/null +++ b/docs/zh/A2AT-SDK-DEPENDENCY.md @@ -0,0 +1,180 @@ +# A2A-T SDK 正式版本依赖说明 + +执行引擎首发版本只支持下列 A2A-T SDK 正式版本基线,不提供旧 SDK 兼容层: + +- 仓库:`project-openan/a2a-t-sdk-java` +- 发布标签:`v1.1.0`,commit:`e42c83acce3e5ac2c245d36546ced0fa017b2b58` +- Maven Central 版本:`1.1.0`,groupId `net.openan.a2a-t.sdk` +- 引擎使用 `a2a-t-core`;samples/宿主显式使用 `a2a-t-client`,OMC 接收端使用 `a2a-t-server`。引擎不传递引入内容生成、LLM、prompt/resources。 + +## 已知上游生命周期缺口 + +该正式版本中的 `A2ATClient`、`A2ATServer` 和 `LLMClient` 尚未实现 +`AutoCloseable`,因此调用过 OpenAI 兼容 LLM 后,底层 OpenAI Java 客户端的 +`DefaultSleeper` 定时线程无法由工作流引擎通过公共 API 关闭。`SpringSpnDemo` 的 +离线协议测试不受此限制;真实模型调用后的生命周期仍需额外观察, +一次性关闭内嵌 Tomcat 时可能提示上游线程生命周期告警。 + +上游 SDK 应在新提交中补充幂等 `close()`,由 `A2ATClient/A2ATServer` 级联关闭其拥有的 +LLM provider;执行引擎届时应升级到该新提交对应的新 Maven 版本。禁止在不改变版本号的 +情况下覆盖 `1.1.0`,否则 IDEA 与 CI 将得到不可复现的同坐标异内容制品。 + +该 SDK 已发布到 [Maven Central](https://repo.maven.apache.org/maven2/net/openan/a2a-t/sdk/a2a-t-client/1.1.0/)。 +本机和 CI 直接解析正式制品,不再检出 SDK 源码或执行 SDK install,也不降级到其他版本。 + +## 当前模板发现的资源隔离 + +当前 SDK 的 PromptTemplateCatalog 会关闭 JarURLConnection 返回的 JarFile。 +默认缓存可能让并行门面共用该句柄,导致 zip file closed。 +samples 宿主 A2ATInitialization 在初始化线程临时包装资源 URL,仅该连接 useCaches=false, +完成后恢复类加载器;不更改全局 URL 缓存或 SDK jar。 +每次 SDK 升级需重新验证该隔离是否仍必要。 + +SDK 当前内容接口接受字符串模板 URI,宿主通过 StandardTemplates.*.uri() 指定。 +业务调用生成/校验 API,再经 A2atMessages.from 转为最终 MessageContent。 +引擎只检查协商上下文关联,不解释业务内容。 +完整契约见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 + +## Maven / IDEA 接入 + +根 POM 的 `a2a.t.sdk.version` 是唯一依赖版本来源,当前为 `1.1.0`。 +在执行引擎目录直接执行: + +```powershell +mvn -B -U clean verify +``` + +IDEA 使用该工程的 Maven 配置并执行 Reload All Maven Projects 即可;无需手工安装 A2A-T jar。 +如果企业或公共镜像尚未同步正式版本,应等待同步或按组织要求调整 Maven 镜像, +不要同坐标 install 本地构建物替代正式发布制品。本次通过本机镜像下载并核对 +SDK jar 的 SHA-1 与 Central 发布校验值一致。 + +宿主若调用 A2A-T 内容生成,需显式声明: + +```xml + + net.openan.a2a-t.sdk + a2a-t-client + 1.1.0 + +``` + +需要接收端校验时同样显式添加 `a2a-t-server:1.1.0`;samples 已声明二者。 + +## 升级 A2A-T SDK + +升级时更新根 `pom.xml` 的 `a2a.t.sdk.version`、本文发布标签和相关接入文档, +核对正式制品与源码/资源差异,并运行完整回归。CI 直接使用根 POM,不单独维护版本或编译 SDK。 + +禁止只覆盖本地同版本 jar。新版本至少要验证 Task-T 生成/校验、无状态 Negotiation-T +metadata、Authorization-T、Notification-T 长连接,以及现行传输路径: +验证直连 OMC 的 SpringSpnDemo 端到端流程。 + +“只支持最新 A2A-T SDK”仅表示不兼容旧 SDK 版本和旧协议格式。 + +## A2A Java SDK 基线 + +执行引擎使用 Maven Central 已正式发布的 `org.a2aproject.sdk` `1.2.0.Final`,不采用尚未 +发布制品的源码标签或 SNAPSHOT。该版本将 `DefaultRequestHandler` 构造迁移到 Builder、 +将任务校验收进请求处理器,并把 `Utils` 移到 `org.a2aproject.sdk.spec.util`。同时必须让 +`protobuf-java-util` 与其传递引入的 `protobuf-java:4.35.0` 对齐,避免 REST JSON 转换时 +出现 `NoSuchMethodError`。Spring starter 通过仅暴露 `RequestHandler` 接口的委托隔离 +`DefaultRequestHandler` 中 CDI 专用注入点;SSE 端点使用 `SseEmitter` 输出规范事件,不能把 +手工拼接的 `id:/data:` 文本再次作为普通响应体发送。升级 A2A Java SDK 后同样要跑直连和 +Order 两条 E2E。 + +## 单一资源基线 + +模板、slot schema、slot extraction 和 content validation 提示词全部来自锁定的 SDK jar。 +samples 不再用同名 classpath 资源覆盖 SDK 内容,也不保留旧 renderer/validator 的兼容提示词。 +测试会核对实际加载资源来自 `a2a-t-resources`,避免升级后仍静默运行旧规则。 + +Authorization-T 只接受当前 SDK 定义的规范数据形态:每条策略以从 1 连续递增的编号 `1.` 开头, +字段为“字段名是值”,字段间使用全角逗号 `,`,多条策略用换行分隔。 +例如:`1. 业务场景是业务投诉诊断,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-06-01~2030-06-18`。 +日期范围与“永久生效”均支持;裸值列表、分号分隔和旧 `/` 格式不接受。 +此前本节关于全角分号的描述不正确,已按 1.1.0 正式 jar 的 slot.json 修正。 + +## 1.1.0 升级核对 + +相对此前 `ec14175` 源码基线,`v1.1.0` 的 core/client/server/llm/prompt/resources/negotiation +模块源码与资源没有差异;无需修改业务回调或增加版本兼容层。上述 JarFile 初始化隔离仍有必要, +LLM close 公共接口缺口也未随发布解决。发布之后当前 upstream/main 的新增差异仅为开发指南版本说明。 + +2026-08-31 本地发布制品回归:main 188 项、dev 239 项测试通过,均无失败、错误或跳过。 +覆盖正常输入与缺参协商的直连 SpringSpnDemo,dev 另覆盖 Order SDK 平台模拟路线。 +SDK 模板和校验使用正式 jar,LLM 使用离线 provider,OMC/平台使用本地模拟器;未验证真实模型或现网。 +依赖树确认纯引擎只有 a2a-t-core,samples 的全部 A2A-T 组件均为 1.1.0。 +构建日志位于各工作树 `logs/a2at-1.1.0-*-verify.log`,逐项报告在各模块 `target/surefire-reports/`。 + +## 示例业务接口调用复核(2026-08-31) + +此前发现的正文来源、填充结果丢弃、City2 缺参路径、Reject/Abort 分发及离线宽松校验问题, +现已在 samples 业务层整改;通用引擎未重新引入内容生成或语义判断职责。 + +| 路径 | 发送端生成 | 接收端校验/实际使用 | +|---|---|---| +| WAIMO → 工作台 | generateTaskPromptFromDataWithSchema | WorkbenchTaskInputParser 使用 validateTaskPromptAndDataFilling 的 filled.data | +| 工作台 → 两地市任务 | WorkbenchControlPoint.onTask:generateTaskPromptFromDataWithSchema | 两 OMC 校验 Task-T,使用 filled.data 构造 SpnTaskInput | +| 两地市 OMC → 工作台协商 | generateNegotiationProposePromptFromData,按实际缺失字段生成 | validateProposePromptAndDataFilling,按 items 从当前城市输入提取答案 | +| 工作台 → OMC Accept | generateNegotiationAcceptPromptFromData | 从正式 metadata 取正文,validateAcceptPromptAndDataFilling 后合并请求字段并复核业务必填信息 | +| 工作台 → OMC Reject/Abort | 对应 generateNegotiationRejectPromptFromData/generateNegotiationAbortPromptFromData | 对应 validateRejectPromptAndDataFilling/validateAbortPromptAndDataFilling,结束任务且不执行诊断 | +| 独立授权 | generateAuthPromptFromDataWithSchema | validateAuthPromptAndDataFilling 后构造并应用 AuthorizationPolicy | +| 独立订阅 | generateNotificationPromptFromDataWithSchema | validateNotificationPromptAndDataFilling 后构造 NotificationPolicy | + +自然语言 FromText 和结构化 FromDataWithSchema 是替代入口,不是同一消息必须串行全部执行的步骤。 +本 Demo 主路径选择结构化数据;Task-T 自然语言另有测试,不宣称覆盖 SDK 全部公开接口。 +端口资源归属、投诉分类和 OSS 流水号是 SPN 业务层规则;正式集成应替换为宿主/OMC 的实际业务判断。 + +协商校验不再读取 parts 摘要;会核对 A2A taskId/contextId 和协商 id/round/maxRounds。 +SDK 拒绝原始任务时,不使用未经验证的局部数据,重新协商完整必要字段。 +接收 Accept 后只合并本轮请求字段;错误正文、缺参和跨协商回复均不能进入诊断。 + +1.1.0 的授权/通知生成入口面向授权操作及订阅请求,并不是任意业务结果生成接口。 +诊断结果、授权回执和抢通事件由 OMC 业务产生。RecoveryNotification 从正式 Notification-T artifact +metadata 检查 SPN 最终抢通结果,避免仅凭 artifact 名称/摘要就关闭订阅。 +这不是对任意外部通知的通用语义验证,也不在工作流引擎内实施。 + +完整角色映射、字段规则、FromText/FromData 选择、宿主拒绝策略和可重复验证范围见 +[业务回调指南第 9 节](BUSINESS_CALLBACKS.md#9-示例业务侧的-a2a-t-110-调用参考)。 +严格离线测试只处理明确样例输入,不从模板示例臆造参数;不能代替真实 LLM 和现网 OMC 联调。 + +业务调用整改阶段的验证记录(后续默认演示及日志展示变更见下节):两分支均执行 `mvn -q test`; +补充端口修正/原上下文保留用例后,又分别执行 +`mvn -q -pl samples -am "-Dtest=EmbeddedA2AServerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`。 +最终 Surefire 汇总为 main 202、dev 254 项,失败/错误/跳过均为 0。 +dev 的直连及 Order Spring E2E 各 3 项,接收端真实 SDK 往返用例共 13 项。 +主测试日志位于各运行目录 `logs/business-sdk-*-verified.log`;最后补充用例日志为 +`logs/business-sdk-*-input-preservation.log`,详细报告位于各模块 `target/surefire-reports/`。 + +## 默认演示与 SSE 展示更新(2026-08-31) + +本地 SpringSpnDemo 无需 VM 参数即演示 City1 缺参协商、City2 完整输入直接诊断; +`-Da2at.samples.negotiation=false` 关闭演示。设置通过当前 Spring 应用实例传递, +不修改 JVM 全局开关,普通宿主默认关闭;外部 OMC 模式默认关闭并拒绝显式注入。 + +SSE pretty 日志保留事件控制字段,将 JSON 单独放在标明非原始报文的数据区,不再逐行重复 `data:`。 +原始观测正文保持不变,`WORKFLOW_ENGINE_PROTOCOL_PRETTY=false` 可查看脱敏后的原始正文; +非 JSON/不完整正文保持原样,脱敏和容量限制仍生效。 + +两分支分别执行 `mvn -q test`:main 208 项、dev 261 项,失败/错误/跳过均为 0。 +直连及 Order Spring E2E 各 4 项,包括默认仅 City1 协商、显式开关及双城市额外回归; +另有外部 OMC 禁止注入、无全局设置污染、SSE 原文不变及事件边界测试。 +日志为各分支 `logs/demo-display-main-verified.log` 或 `logs/demo-display-dev-verified.log`。 +仍为真实 SDK 加离线 LLM/本地 OMC 和平台模拟器验证,不代表现网模型或设备联调。 + +## 授权格式与协商字段回归(2026-08-31 09:28) + +按 1.1.0 正式资源修正授权编号、字段标签、换行和删除选择器,加入直接读取 jar 内多条策略示例的测试。 +真实运行出现的“本地市实际接入端口名称”提取结果已纳入离线 fixture;协商回复保留经 SDK +语义校验的原始编号信息项名称。详情见 [业务回调指南](BUSINESS_CALLBACKS.md)。 + +最终版本全量验证:main 221 项、dev 274 项,失败/错误/跳过均为 0(统计包含 Spring starter 模块)。 +main 直连与 dev 直连、Order 平台模拟路线均通过;默认场景确认 City1 Propose/Accept、 +City2 无协商,两个 OMC 授权成功、订阅成功,工作流完成并释放连接。 +日志为 main 工作树 `logs/auth-canonical-main-verify.log` 和 dev 工作树 +`logs/auth-canonical-dev-verify.log`,JUnit 明细位于各模块 `target/surefire-reports/`。 + +这些结果基于正式 SDK jar、离线 LLM provider 和本地 OMC/Order 模拟器,不是现网或真实模型验收。 +Notification-T 同步补齐失败原因字段名和拒绝详情日志,但历史日志没有 slot 错误详情, +不能断言此前真实模型拒绝订阅的唯一原因已经确认;需真实模型重跑核验。 diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index e80f19bf..469be164 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -29,7 +29,6 @@ | `controlPoint(ControlPoint)` | 必填 | - | 用户决策实现 | | `runtimeIntent(String)` | 可选 | `""` | 自然语言意图,用于上下文组装 | | `lang(String)` | 可选 | `"zh"` | 语言提示(`"zh"` 或 `"en"`) | -| `a2atEnvPath(String)` | 可选 | null | `.env` 文件路径(A2A-T SDK 配置) | | `credentialsConfigPath(String)` | 可选 | null | 凭证 JSON 文件路径 | | `sslVerify(boolean)` | 可选 | `true` | 是否验证 TLS 证书 | | `caCertsPath(String)` | 可选 | null | CA 证书 PEM 文件路径 | @@ -44,7 +43,6 @@ ExecutionResult result = ExecutePsop.builder() .agentCards(cards) .controlPoint(cp) .runtimeIntent("诊断故障") - .a2atEnvPath(".env") .sslVerify(false) .execute() .get(10, TimeUnit.MINUTES); @@ -58,98 +56,43 @@ ExecutionResult result = ExecutePsop.builder() ### WorkflowEngineClient -发送 A2A 消息的核心接口。 - ```java -public interface WorkflowEngineClient { - // 发送消息(可选 contextId 和预设 metadata) - CompletableFuture sendMessage( - String agentName, String message, - String contextId, Map metadata); - - // 便捷方法:无 contextId,无 metadata - CompletableFuture sendMessage( - String agentName, String message); - - void setControlPoint(ControlPoint controlPoint); - - - void setEventCallback(EventCallback callback); - - void close(); -} +CompletableFuture dispatch(TaskRequest request, MessageContent content, ControlPoint callbacks); +CompletableFuture sendMessage(String agentName, MessageContent content); +CompletableFuture getTask(String agentName, String taskId); +CompletableFuture cancelTask(String agentName, String taskId); +CompletableFuture subscribeToTask(String agentName, String taskId, Consumer> callback); +long callbackTimeoutSeconds(); +void setControlPoint(ControlPoint callbacks); +void setEventCallback(EventCallback callback); +void close(); ``` -> 前置操作位于 `ExtensionSender`,不在本接口:Authorization-T 是一次性请求,Notification-T 是长连接订阅。见下方 `ExtensionSender` 章节。 - -#### sendMessage - -| 参数 | 类型 | 说明 | -|-------------|-----------------------|-----------------------------------------| -| `agentName` | `String` | 目标智能体名称(须匹配 AgentCard.name) | -| `message` | `String` | 完整组装后的消息文本 | -| `contextId` | `String` | 可选上下文 ID(null = 自动生成) | -| `metadata` | `Map` | 可选预设 metadata | - -**返回:** `CompletableFuture`,包含响应文本、Task 对象、metadata、任务状态。 +执行器内部调用 dispatch;onTask 不自行发送。内容是最终 parts/metadata/extensions,引擎只管理信封和交互,不创建 A2ATClient,不按 AgentCard 声明生成内容。模板查询和生成接口请直接使用宿主 SDK。 -引擎内部在发送前自动处理: +只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 +终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 +宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 +通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; +结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 -1. Task-T 提示词生成(如果 AgentCard 声明了 Task-T) -2. Negotiation-T metadata 注入(后续协商消息) -3. 认证头注入(来自凭证文件或 AuthProvider) -4. A2A-Extensions 头(仅 metadata 中实际存在的扩展) - -接收后自动处理: - -1. 从 SSE 事件提取响应文本 -2. 提取 metadata(task 级 + artifact 级合并) -3. Negotiation-T 自动循环(如果 `INPUT_REQUIRED`) +返回 `new NegotiationReply.Send(content)` 发送最终内容; +返回 `new NegotiationReply.Stop(code, reason)` 只在本地停止,不生成 Abort。 +同一任务/会话/轮次的重复等待事件不会重复回调、重复提交;未变化状态通过 getTask 观察。 +`maxNegotiationExchanges` 默认 3,是独立于 SDK context.maxRounds 的本地交互资源预算。 +超时、预算耗尽、回调缺失均明确失败,不默认 Accept,也不自动生成 Abort。 +Accept/Reject 的 SUBMITTED/WORKING ACK 仍需等待任务结果,不重发原命令。 +业务发送 Abort 后,即使远端用 COMPLETED 确认,也不能判为诊断成功。 ### ExtensionSender -基于同一 `A2ATransport` 的前置操作门面。在工作流开始前发送一次性 Authorization-T 请求、建立长连接 Notification-T 订阅,或发送其他一次性扩展。跳过 -Task-T 提示词生成和 Negotiation-T 自动循环;Notification-T 后续事件通过订阅回调交付。 - ```java -public interface ExtensionSender { - CompletableFuture sendExtensionMessage( - String agentName, String instruction, - String naturalLanguageInput, A2ATExtension extension); - - // 便捷方法:Authorization-T - CompletableFuture sendAuthorization( - String agentName, String instruction, String naturalLanguageInput); - - // 便捷方法:Notification-T(长连接 SSE) - CompletableFuture sendNotification( - String agentName, String instruction, String naturalLanguageInput); - - // 接口契约:Notification-T(长连接 SSE + 事件回调) - CompletableFuture sendNotification( - String agentName, String instruction, - String naturalLanguageInput, Consumer> eventCallback); -} +CompletableFuture sendAuthorization(String agentName, MessageContent content); +NotificationSubscription openNotification(String agentName, MessageContent content, + BiConsumer listener); ``` -| 参数 | 类型 | 说明 | -|------------------------|-----------------|-------------------------------------| -| `agentName` | `String` | 目标智能体名称(须匹配 `AgentCard.name`) | -| `instruction` | `String` | 简短指令文本;成为 A2A 消息体的 `parts[].text` | -| `naturalLanguageInput` | `String` | 扩展 metadata 的自然语言输入。Task-T 由 A2A-T SDK 生成结构化提示词;Authorization-T / Notification-T 当前直接使用此值 | -| `extension` | `A2ATExtension` | 扩展枚举(勿硬编码 URI) | -| `eventCallback` | `Consumer>` | 可选 SSE 事件回调(`sendNotification` 专用)。OMC 后续推送的抢通结果通过此回调实时接收,回调 Map 含 `agent`、`text`、`metadata`、`state`。不传(null)时后续事件被丢弃 | - -**报文格式**:发送给智能体的 A2A 消息结构为 `parts[].text = instruction`,`metadata = { "<扩展URI>": "<扩展值>" }`。以 Authorization-T 为例: - -```json -{ - "parts": [{"text": "授权诊断操作"}], - "metadata": { - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1": "<结构化授权策略>" - } -} -``` +授权和订阅接收宿主生成的最终内容。三类操作使用独立 transport/runtime/context,成功与否不阻断工作流。openNotification 先注册 handle 再开始 I/O,监听器直接收到 handle 和完整 ReceivedMessage。acknowledgement() 是实际 ACK;超时失败。close() 请求关闭,completion() 在流真正退出后完成。 ### WorkflowEngineClientConfig @@ -170,10 +113,9 @@ public interface ExtensionSender { | `sendExecutorQueueCapacity` | `int` | `256` | 发送线程池有界队列容量 | | `authProvider` | `AuthProvider` | null | 自定义认证提供器 | | `credentialsConfigPath` | `String` | null | 凭证 JSON 文件路径;显式配置缺失或损坏时启动失败 | +| `credentialEncryptionKey` | `String` | null | 宿主显式传入凭据解密密钥,不从 LLM .env 加载 | | `credentialsConfig` | `Map` | null | 内联凭证配置;AgentCard 声明安全要求时必须匹配 | -| `a2atEnvPath` | `String` | null | `.env` 文件路径 | -| `maxNegotiationRounds` | `int` | `3` | 协商自动循环最大轮数 | -| `customHandlers` | `List` | null | 自定义扩展处理器 | +| `maxNegotiationExchanges` | `int` | `3` | 本地交互预算,独立于 SDK maxRounds;耗尽仅本地失败 | ```java WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() @@ -182,9 +124,8 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .clientCertPath("client-cert.pem") .clientKeyPath("client-key.pem") .sendTimeoutSeconds(900) - .a2atEnvPath(".env") .credentialsConfigPath("creds.json") - .maxNegotiationRounds(5) + .maxNegotiationExchanges(5) .authProvider(myProvider) .build(); ``` @@ -202,27 +143,9 @@ public interface AuthProvider { 每次消息发送时调用。`headers` 是可变 Map;直接添加 `Authorization`、自定义头等。`AuthProvider` 可作为唯一认证来源,包括 AgentCard 的 `securityRequirements` 非空但未配置 credentials 的场景。若同时配置 credentials,两者分别计算后合并;若同名 Header 生成不同值,引擎会抛出 `SecurityException`,不会静默覆盖。 -### ExtensionHandler - -自定义 A2A-T 扩展的处理器接口。 +### A2atMessages -```java -public interface ExtensionHandler { - String extensionKeyword(); - - CompletableFuture> beforeSend( - AgentCard agentCard, String messageText, - Map metadata, - A2ATClient a2atClient, ControlPoint controlPoint); - - CompletableFuture afterReceive( - AgentCard agentCard, SendMessageResult result, - A2ATClient a2atClient, ControlPoint controlPoint, - EventCallback eventCallback); -} -``` - -内置:Task-T 和 Negotiation-T 自动处理。可通过配置的 customHandlers 注册自定义处理器。 +通过 MessageContent(parts, metadata, extensions) 提交自定义扩展内容,不注册引擎内容处理器。A2atMessages.from(MetadataContent, List>) 保留 SDK metadata 原位置并激活对应 URI;contextOf(ReceivedMessage) 或 contextOf(Map) 读取并检查规范协商上下文。只有 a2a-t-core 依赖,没有内容生成或语义校验。 ### A2AJavaClientRuntime @@ -297,46 +220,19 @@ Map normalized = AgentCardNormalizer.normalize(rawMap); ### ControlPoint -用户决策接口。每个方法单一职责。 - ```java -public interface ControlPoint { - // 向智能体发送 Task-T 消息。直接调用 sendMessage。 - CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient); - - // 自环节点:本地处理,不发 A2A-T 消息给自己。 - default CompletableFuture onSelfTask(TaskRequest request); - - // 条件分支决策。只决定下一步去哪个 step。 - CompletableFuture onRoute( - String stepName, Map results, - List conditions); - - // 提供协商补充信息。默认:返回通用文本。 - default CompletableFuture onNegotiation( - String agentName, String negotiationText, - Map receiveResult); +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -| 方法 | 触发时机 | 返回值 | -|-------------------|----------------------------------------|-------------------------------| -| `onTask` | 步骤向其他智能体分派任务时 | `TaskResponse`(成功 + 输出) | -| `onSelfTask` | `SELF_LOOP` 步骤本地执行(不走 A2A-T) | `TaskResponse`(成功 + 输出) | -| `onRoute` | 步骤完成后、下一步前 | `RouteDecision`(nextStep) | -| `onNegotiation` | 智能体返回 `INPUT_REQUIRED` 时 | `String` 补充信息文本 | - -### DefaultControlPoint - -默认实现,提供合理默认值: - -- `onTask`:调用 `sendMessage()`,返回 success/output -- `onSelfTask`:原样回传任务消息(本地逻辑请覆盖实现) -- `onRoute`:选第一个非终止分支 -- `onNegotiation`:返回通用补充信息 - -继承此类,只需覆盖需要自定义的方法。 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 +onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 +未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 ### EventCallback @@ -434,7 +330,7 @@ Map registerAgentCard(Map agentCard) ### WorkflowExecutor -中层 DAG 遍历引擎。遍历工作流步骤,通过 `ContextBuilder` 组装上游上下文,并行分派子任务,应用步骤成功策略(`ALL_SUCCESS` / `ANY_SUCCESS` / `SELF_LOOP`),并路由到下一步。 +中层 DAG 遍历引擎。遍历工作流步骤,通过 `ContextBuilder` 选择强类型上游执行结果,并行分派子任务,应用步骤成功策略(`ALL_SUCCESS` / `ANY_SUCCESS` / `SELF_LOOP`),并路由到下一步。 SDK 用户通常不直接实例化——`ExecutePsop` 内部封装了它。供需要在不含 runner 生命周期管理的情况下运行遍历层的高级集成使用。 @@ -447,7 +343,7 @@ ExecutionResult result = executor.run().join(); ### ContextBuilder -包级私有辅助类,按 `contextFrom` 规则折叠上游步骤输出,为每个步骤组装上下文消息(`"*"` = 所有前驱步骤,指定名称 = 选择性继承)。不属于公共 API 接口。 +包级私有辅助类,按 `contextFrom` 规则选择上游步骤结果并构造 `WorkflowInput`(省略 = 直接前驱,`[]` = 不聚合上游,`"*"` = 所有祖先,指定名称 = 选择性聚合)。它不生成提示词,也不属于公共 API 接口。 --- @@ -472,7 +368,7 @@ ExecutionResult result = executor.run().join(); | `subtasks` | `List` | `List.of()` | 此步骤分派的子任务 | | `next` | `List` | `List.of()` | 条件后续步骤 | | `layer` | `int` | `0` | 上下文层(0 = 仅运行时意图) | -| `contextFrom` | `List` | null | 继承上下文的步骤(`"*"` = 所有前驱步骤) | +| `contextFrom` | `List` | null | 聚合来源;省略 = 直接前驱,`[]` = 不聚合,`"*"` = 所有祖先,或指定祖先名称 | | `stepType` | `StepType` | `ALL_SUCCESS` | 执行模式 | ### StepType @@ -509,35 +405,72 @@ ExecutionResult result = executor.run().join(); | `step` | `String` | 下一步名称(`"end"` 表示终止) | | `condition` | `String` | 条件表达式(`"success"`、`"fail"` 等) | -### TaskRequest +### TaskRequest / BusinessInput + +TaskRequest 使用 getXxx() 访问器: + +| 字段 | 含义 | +|---|---| +| executionId / taskId | 本地执行/逻辑任务标识,不是远端协议 ID | +| stepName / agentName / skill | 当前步骤、目标智能体和技能 | +| instruction / language | 当前指令,不含引擎拼接的历史 | +| input | BusinessInput:文本或任意 JSON 可序列化数据,二选一,不含 schema | +| workflowInput | WorkflowInput(runtimeIntent, upstreamResults),与当前输入分离 | + +BusinessInput.text(value) / BusinessInput.data(value) 创建输入快照。 +WorkflowInput、UpstreamStepResult、ReceivedMessage、NegotiationRequest 等 record 使用 field() 访问器。 + +| contextFrom | 上游选择 | +|---|---| +| 未指定 / null | 已产生结果的直接前驱 | +| [] | 不聚合上游,runtimeIntent 仍保留 | +| ["*"] | 已产生结果的全部祖先 | +| 显式祖先名称 | 按声明顺序选取对应结果 | + +contextFrom 只选择证据,不建立执行依赖;依赖由 next 定义。 +未知或非祖先名称、通配符与名称混用均非法;未激活分支不虚构结果。 +引擎不把窗口附加到 instruction/parts,也不调用 LLM 映射上游;由宿主决定怎么消费或映射下游输入。 + +窗口结构:stepName → taskResults[] → outputs[] / receivedMessages[]。 +TaskExecutionResult 还保留 agentName、skill、逻辑 taskId、taskDescription、status、 +error、errorCode、errorDetails。多子任务不混合,嵌套数组仍作为一个输出项。 +输出不要求来自 LLM,也不要求符合投诉模板。 + +### MessageContent / ReceivedMessage + +```java +record MessageContent(List> parts, Map metadata, Set extensions) {} +record ReceivedMessage(MessageContent message, Map taskMetadata, List artifacts) {} +``` + +通过 MessageContent.text(text)、MessageContent.parts(parts) 或构造器创建快照。 +TextPart、DataPart、FilePart 保留顺序及各自 metadata,文件引用不会自动下载。 +MessageContent 不提供 role、目标、messageId、taskId、contextId 或认证头。 +业务 metadata 中即使存在 contextId 字段,也不能覆盖真实 A2A 信封。 + +ReceivedMessage 分别保留消息 metadata、任务 metadata、artifact 身份及 parts/metadata,不互相覆盖。 +message 可以为空;只有 metadata 的业务结果仍可从完整视图读取。 +便利 outputs 按 artifact 与 part 顺序提取 TextPart 文本和 DataPart 数据; +没有 artifact 时,可提取成功终态或独立 Message 的正文。 +FilePart 仅在完整视图提供。不解析文本、不拼接相邻文本、不拍平嵌套业务数组。 +失败状态消息只保留作证据,不进入 outputs;已返回的有效部分 artifact 仍保留。 +SSE append/replace 按 artifact 组装,最终快照不重复累加。 + +本地 onSelfTask 返回 TaskResult.success(List),允许空列表; +TaskResult.failure(code, message) 将错误与输出分开,builder 可保留有效部分输出。 +远端 TaskResult 的 receivedMessages 是完整证据,便利输出由它派生。 +远端 Task 只有 COMPLETED 才成功,独立 A2A Message 也可完成交互。 +进度/协商提示不会因为含文本就变成工作流成功。 + -| 字段 | 类型 | 说明 | -|----------------|----------|------------------| -| `agentName` | `String` | 目标智能体 | -| `skill` | `String` | 智能体技能 | -| `message` | `String` | 完整消息文本 | -| `description` | `String` | 任务描述 | -| `context` | `String` | 上下文消息 | -| `stepName` | `String` | 源步骤名 | -| `subtaskIndex` | `int` | 步骤内子任务索引 | -### TaskResponse +### NegotiationRequest / NegotiationReply -| 字段 | 类型 | 说明 | -|------------|-----------|--------------------| -| `success` | `boolean` | 任务是否成功 | -| `output` | `String` | 响应文本 | -| `error` | `String` | 错误信息(失败时) | -| `metadata` | `Map` | 响应 metadata | +参见 [BUSINESS_CALLBACKS](BUSINESS_CALLBACKS.md). ### SendMessageResult -| 字段 | 类型 | 说明 | -|-------------|----------|-------------------------------------------| -| `text` | `String` | 提取的响应文本 | -| `task` | `Task` | SDK Task 对象 | -| `metadata` | `Map` | 响应 metadata(task + artifact 合并) | -| `taskState` | `String` | 最终任务状态(如 `TASK_STATE_COMPLETED`) | +getReceivedMessages() 是保留层级的响应来源,getOutputs() 为便利投影;getTask()/getTaskState() 保留实际远端状态,failureCode/failureMessage 为独立本地交互失败。text 和扁平 metadata 仅用于传输诊断,不用它们代替完整业务响应。 ### ExecutionResult @@ -577,15 +510,17 @@ ExecutionResult result = executor.run().join(); | 扩展 | URI | |-----------------|-------------------------------------------------------------------------------------------| | Task-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1` | -| Negotiation-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T` | +| Negotiation-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1` | | Authorization-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1` | | Notification-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1` | -> **注意:** `DATA-NEGOTIATION-T/v1` **不是** A2A-T 扩展。它是 A2A-T SDK -> 协商模块内部使用的 metadata key,用于携带结构化协商上下文 -> (`negotiationType`、`round`、`negotiationId`、`status`)。 -> 不在 AgentCard 上声明,不被 ExtensionHandler 处理,也不出现在 -> `A2A-Extensions` 头中。 +> **协商相关 metadata key 约定:** +> +> - `negotiationContext`(SDK 契约):SDK 生成协商消息时写入,携带 `{id, round, maxRounds, performative}`。 +> validate API 依赖它区分协商消息与普通消息。 +> - `templateUri`(SDK 契约):SDK 生成消息时写入,标识渲染所用模板。 +> - `negotiation_message` / `negotiation_params`(引擎内部):接收校验后供自动循环和 +> 业务控制点读取,绝不作为线上的协议兼容键。 --- @@ -597,10 +532,11 @@ ExecutionResult result = executor.run().join(); ## 错误处理 -- 智能体调用失败抛出 `RuntimeException`(包装原始异常)。 -- 协商失败在 `maxNegotiationRounds` 轮后终止。 -- 认证失败(401)记为 `ERROR` 日志,认证头不设置,请求继续发出。 -- SSE 流在终态事件后的连接关闭日志为 `DEBUG` 级别(预期行为)。 +- 回调异常/空值/超时明确失败;SDK 内容错误由宿主转换为 BusinessFailure,必要时保留安全的 code/details。 +- maxNegotiationExchanges 耗尽或本地 Stop 只结束本地交互,不自动发送 Abort。业务 Send(Abort) 不算诊断成功。 +- 远端状态和错误与业务输出分开,失败状态消息不成为 outputs,已有部分 artifact 保留。 +- 缺失必需认证信息时拒绝发送,不静默匿名访问。 +- SDK 流退出/传输错误日志是传输观察,不等同于工作流最终状态;以 ExecutionResult 和远端任务状态判断结果。 --- diff --git a/docs/zh/BUSINESS_CALLBACKS.md b/docs/zh/BUSINESS_CALLBACKS.md new file mode 100644 index 00000000..49246a28 --- /dev/null +++ b/docs/zh/BUSINESS_CALLBACKS.md @@ -0,0 +1,312 @@ +# 业务回调集成契约 + +首发 API,A2A-T SDK `1.1.0`,A2A Java `1.2.0.Final`。 +引擎负责 DAG、标准 A2A 信封、认证、传输和任务等待;宿主负责消息内容、schema、模板、语义校验和 LLM。 +直连与 dev 东信转发使用相同业务契约。 + +## 1. 回调接口 + +```java +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); +} +``` + +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 +onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 +未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 + +```java +ControlPoint callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .onSelfTask(request -> CompletableFuture.completedFuture( + TaskResult.success(List.of(Map.of( + "sourceResults", request.getWorkflowInput().upstreamResults()))))) + .onRoute(request -> CompletableFuture.failedFuture( + new IllegalStateException("Supply a routing policy for " + request.stepName()))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Manual confirmation required"))) + .build(); +``` + +示例显式发送普通文本,不宣称为 Task-T;本地透传、路由和停止策略需替换为真实业务逻辑。 +onTask 只返回内容,不自行再次发起任务网络请求。 +导入 `dev.openan.workflow.engine.control.ControlPoint`、`model.*`, +以及 Java 的 List、Map、CompletableFuture。 + +## 2. TaskRequest 与上游窗口 + +TaskRequest 使用 getXxx() 访问器: + +| 字段 | 含义 | +|---|---| +| executionId / taskId | 本地执行/逻辑任务标识,不是远端协议 ID | +| stepName / agentName / skill | 当前步骤、目标智能体和技能 | +| instruction / language | 当前指令,不含引擎拼接的历史 | +| input | BusinessInput:文本或任意 JSON 可序列化数据,二选一,不含 schema | +| workflowInput | WorkflowInput(runtimeIntent, upstreamResults),与当前输入分离 | + +BusinessInput.text(value) / BusinessInput.data(value) 创建输入快照。 +WorkflowInput、UpstreamStepResult、ReceivedMessage、NegotiationRequest 等 record 使用 field() 访问器。 + +| contextFrom | 上游选择 | +|---|---| +| 未指定 / null | 已产生结果的直接前驱 | +| [] | 不聚合上游,runtimeIntent 仍保留 | +| ["*"] | 已产生结果的全部祖先 | +| 显式祖先名称 | 按声明顺序选取对应结果 | + +contextFrom 只选择证据,不建立执行依赖;依赖由 next 定义。 +未知或非祖先名称、通配符与名称混用均非法;未激活分支不虚构结果。 +引擎不把窗口附加到 instruction/parts,也不调用 LLM 映射上游;由宿主决定怎么消费或映射下游输入。 + +窗口结构:stepName → taskResults[] → outputs[] / receivedMessages[]。 +TaskExecutionResult 还保留 agentName、skill、逻辑 taskId、taskDescription、status、 +error、errorCode、errorDetails。多子任务不混合,嵌套数组仍作为一个输出项。 +输出不要求来自 LLM,也不要求符合投诉模板。 + +## 3. 最终内容与完整响应 + +```java +record MessageContent(List> parts, Map metadata, Set extensions) {} +record ReceivedMessage(MessageContent message, Map taskMetadata, List artifacts) {} +``` + +通过 MessageContent.text(text)、MessageContent.parts(parts) 或构造器创建快照。 +TextPart、DataPart、FilePart 保留顺序及各自 metadata,文件引用不会自动下载。 +MessageContent 不提供 role、目标、messageId、taskId、contextId 或认证头。 +业务 metadata 中即使存在 contextId 字段,也不能覆盖真实 A2A 信封。 + +ReceivedMessage 分别保留消息 metadata、任务 metadata、artifact 身份及 parts/metadata,不互相覆盖。 +message 可以为空;只有 metadata 的业务结果仍可从完整视图读取。 +便利 outputs 按 artifact 与 part 顺序提取 TextPart 文本和 DataPart 数据; +没有 artifact 时,可提取成功终态或独立 Message 的正文。 +FilePart 仅在完整视图提供。不解析文本、不拼接相邻文本、不拍平嵌套业务数组。 +失败状态消息只保留作证据,不进入 outputs;已返回的有效部分 artifact 仍保留。 +SSE append/replace 按 artifact 组装,最终快照不重复累加。 + +本地 onSelfTask 返回 TaskResult.success(List),允许空列表; +TaskResult.failure(code, message) 将错误与输出分开,builder 可保留有效部分输出。 +远端 TaskResult 的 receivedMessages 是完整证据,便利输出由它派生。 +远端 Task 只有 COMPLETED 才成功,独立 A2A Message 也可完成交互。 +进度/协商提示不会因为含文本就变成工作流成功。 + +## 4. 宿主调用 A2A-T + +纯引擎仅依赖 a2a-t-core;生成内容的宿主显式依赖 a2a-t-client。 +宿主创建、配置自己的 A2ATClient,不通过 WorkflowEngineClientConfig 或 ExecutePsop 承载 LLM 配置。 + +```java +// sdk、data、schema、模板选择均属于宿主。 +MetadataContent generated = sdk.generateTaskPromptFromDataWithSchema( + data, schema, StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); +MessageContent outgoing = A2atMessages.from(generated, List.of(new TextPart("诊断当前专线"))); +``` + +自然语言入口为 `sdk.generateTaskPromptFromText(text, StandardTemplates.PRIVATE_LINE_COMPLAINT.uri())`,随后同样调用 A2atMessages.from。 + +自然语言、结构化生成和校验等能力直接调用宿主 SDK。 +A2atMessages.from 只复制生成的 metadata 并激活对应扩展,parts 保持业务提供的内容; +不生成、不校验业务语义。模板使用 StandardTemplates,不自行猜 URI。 +可运行 SPN 示例见 WorkbenchControlPoint 和 examples.negotiation.NegotiationStrategy。 +A2ATInitialization 是 samples 宿主对锁定 SDK 的 JarFile 初始化隔离,不属于引擎。 + +## 5. 协商 + +NegotiationRequest(task, originalSubmission, received, previousExchanges, remainingWait): +task 是原始 TaskRequest,originalSubmission 是首次最终提交, +received 是当前完整响应,previousExchanges 只含该会话的 Exchange(received, reply), +remainingWait 是本次交互剩余时间。引擎不规定业务 proposal 分类或 schema。 + +只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 +终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 +宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 +通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; +结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 + +返回 `new NegotiationReply.Send(content)` 发送最终内容; +返回 `new NegotiationReply.Stop(code, reason)` 只在本地停止,不生成 Abort。 +同一任务/会话/轮次的重复等待事件不会重复回调、重复提交;未变化状态通过 getTask 观察。 +`maxNegotiationExchanges` 默认 3,是独立于 SDK context.maxRounds 的本地交互资源预算。 +超时、预算耗尽、回调缺失均明确失败,不默认 Accept,也不自动生成 Abort。 +Accept/Reject 的 SUBMITTED/WORKING ACK 仍需等待任务结果,不重发原命令。 +业务发送 Abort 后,即使远端用 COMPLETED 确认,也不能判为诊断成功。 + +业务可调用 validateProposePromptAndDataFilling,再选择 SDK 强类型 fromData 或自然语言生成接口。 +不得虚构缺失客户事实;样例只从本地当前地市任务输入提取实际请求的字段。 +SDK 内容异常由宿主映射成 BusinessFailure(code, safeMessage, safeDetails); +引擎不识别 SDK 专属内容异常类,也不自动转存可能含敏感数据的原异常。 + +## 6. 路由、并发与失败 + +RouteRequest(executionId, stepName, workflowInput, currentResults, candidates)。 +候选为 RouteOption(nextStep, condition),返回 RouteDecision.builder().nextStep(允许目标).build()。 +无条件边自动并行推进,条件分支只接受候选中的目标。 + +回调可能并发,不要共享可变“当前任务/城市”字段。 +每个工作流任务从内容准备到传输完成受客户端 timeout 总体限制,默认 sendTimeoutSeconds=600; +路由单独限制回调等待时间,dispatch/协商另有总等待截止时间。取消/超时后晚到结果不发送, +但不等于自动取消宿主正在运行的 LLM 或业务操作,宿主负责清理其资源。 +同步回调入口应迅速返回,阻塞任务应交给异步执行器。 +回调缺失、返回 null 或异常均明确失败;不确定发送失败不自动重发。 + +## 7. 独立授权与订阅 + +ExtensionSender.sendAuthorization(agentName, finalContent) 返回 CompletableFuture。 +ExtensionSender.openNotification(agentName, finalContent, (handle, received) -> ...) 立即返回 handle。 +传入宿主生成的最终授权/订阅内容并激活对应扩展。 +I/O 前注册 handle,早到事件可直接 handle.close(),不用捕获尚未赋值的外部变量。 +acknowledgement() 代表真实 ACK 或失败;超时不伪造成功。 +close() 请求关闭,completion() 在流实际退出后完成。 +订阅保持到抢通结果、显式取消或宿主退出,不随单次工作流结束自动关闭。 + +任务、授权、通知使用独立 transport/runtime/context。 +授权和订阅成功与否均不作为工作流执行前提; +有效白名单只决定 OMC 是否允许额外的自动抢通操作。 + +## 8. 验证和日志 + +### 查看真实协商与协议日志 + +直接运行本地 SpringSpnDemo,默认 City1 缺参并协商、City2 参数完整直接诊断,无需添加 VM 参数。 +Negotiation-T 扩展激活本身不强制协商;这是本地 Demo 专门设置的场景,不是引擎默认行为。 +要关闭本地缺参演示、让两城市都直接诊断,在 IDEA **VM options** 增加: + +```text +-Da2at.samples.negotiation=false +``` + +默认仅移除 City1 的 Task-T 任务对象;启用状态下增加 `-Da2at.samples.negotiation.city=city2` 或 `both` +可演示 City2 或两城市同时缺参。宿主仍保留各城市的正确输入,原投诉上下文不变。预期链路是 `DEMO_NEGOTIATION` → `INPUT_REQUIRED / PROPOSE` +→ 工作台 onNegotiation → `ACCEPT` → 两城市完成 → 一次汇总。 +非内嵌 OMC 模式默认不注入缺参,显式设置 `-Da2at.samples.negotiation=true` 也会被拒绝。 +Demo 将开关传入当前 Spring 应用实例,不设置或修改 JVM 全局开关,不影响其他宿主。 +协议日志在控制台及以运行目录为基准的 `logs/spn-demo.log`。 +main 支持直连;dev 默认 order,两种模式使用同一业务回调。 + +离线重复验证(两个命令顺序执行,避免端口冲突): + +```powershell +mvn -q -pl samples -am "-Dtest=SpringSpnDemoE2ETest" "-Dsurefire.failIfNoSpecifiedTests=false" test +# 仅 dev:真实供应商 jar + 本地平台/OMC 模拟器 +mvn -q -pl samples -am "-Dtest=SpringSpnDemoOrderE2ETest" "-Dsurefire.failIfNoSpecifiedTests=false" test +``` + +每个测试类覆盖无 VM 参数的默认单城市协商、显式关闭、显式开启及两城市同时缺参。测试使用离线 LLM provider,但实际运行当前 SDK 的 +模板、校验和 HTTP/SSE;不是现网 OMC、平台或真实模型语义的验证。 + +启动测试使用公开模拟凭据,不覆盖 SDK 模板或 schema。 +运行时固定测试端口需空闲;本地工作流完成后最多观察首次抢通通知十秒, +没有抢通通知不改变工作流结果。认证和日志配置详见 [集成指南](INTEGRATION_GUIDE.md)。 + +## 9. 示例业务侧的 A2A-T 1.1.0 调用参考 + +引擎只调度最终 A2A 消息;下列生成、校验、填充和业务判断都在 samples 中完成。 +不是每个智能体都调用全部 API,也不是同一消息依次调用 FromText 和 FromData。 + +| 业务位置 | 生成/校验入口 | 对结果的业务使用 | +|---|---|---| +| WAIMO → 工作台 | SpringSpnDemo:generateTaskPromptFromDataWithSchema;WorkbenchTaskInputParser:validateTaskPromptAndDataFilling | 将 filled.data 作为投诉参数,按任务检索工作流 | +| 工作台 onTask → 两地市 OMC | WorkbenchControlPoint:generateTaskPromptFromDataWithSchema;NegotiationBaseAgentExecutor:validateTaskPromptAndDataFilling | 用 filled.data 构造 SpnTaskInput,交给 executeBusiness;不把原始协议提示词传给诊断 | +| OMC 缺参 → 工作台 onNegotiation | 两地市共用 generateNegotiationProposePromptFromData;NegotiationStrategy:validateProposePromptAndDataFilling | 从 items 读取实际请求字段,仅从当前城市原始业务输入补充,不使用另一城市的数据 | +| 工作台补参 → OMC | generateNegotiationAcceptPromptFromData;validateAcceptPromptAndDataFilling | 合并所请求字段的 filled.data,复核端口、投诉分类及 OSS 流水号后执行诊断 | +| 工作台拒绝/终止 → OMC | generateNegotiationRejectPromptFromData/generateNegotiationAbortPromptFromData;对应 validateRejectPromptAndDataFilling/validateAbortPromptAndDataFilling | 校验原因后结束当前诊断任务,不进入诊断业务 | +| 独立白名单授权 | ExtensionPrePositioner:generateAuthPromptFromDataWithSchema;PrePositionedExtensionHandler:validateAuthPromptAndDataFilling | 用 filled.data 构建并应用 AuthorizationPolicy | +| 独立订阅 | ExtensionPrePositioner:generateNotificationPromptFromDataWithSchema;PrePositionedExtensionHandler:validateNotificationPromptAndDataFilling | 用 filled.data 构建 NotificationPolicy,保持独立通知连接 | + +### 输入选择与消息封装 + +有结构化业务参数时使用上述 FromDataWithSchema;只有自然语言时,业务可改用 +generateTaskPromptFromText(text, templateUri)、generateAuthPromptFromText(text, templateUri)、 +generateNotificationPromptFromText(text, templateUri)。协商同时提供各阶段 FromText 和强类型 FromData。 +这些是输入方式的选择,不是每条消息必须全部执行的流水线。FromDataWithSchema 也可能调用 SDK 配置的 LLM, +不能等同于完全不调用模型。 + +无论选择哪一种生成入口,都保留返回 MetadataContent 的完整 metadata,再通过 +A2atMessages.from(generated, List.of(new TextPart("业务摘要"))) 转成引擎 MessageContent。 +parts 必须有合法内容;摘要不是正式扩展正文,不能拿摘要替代校验输入。不要只复制 promptText 而丢失 +templateUri、negotiationContext 或扩展 URI。 + +### 信息协商的数据与失败边界 + +本场景按 SDK 投诉模板的业务字段协商:任务对象中包含本地市接入端口,任务上下文中包含投诉分类、 +OSS侧事件流水号及已有的时间和详情。已验证任务只有部分字段不足时仅请求这些字段; +SDK 拒绝整个原始输入时不信任其中的局部内容,重新请求完整业务字段。当前请求项采用 AND,所有请求字段必需。 + +接收 Propose 的参数 schema 中,items 是非空字段名数组,relationship 是可空逻辑关系字符串; +其 description 说明“实际请求字段”和 AND/OR 含义。Accept 校验 schema 则按本轮请求动态生成, +字段为非空字符串并列入 required。SpnTaskInput 的端口、流水号等规则仅是 SPN 示例业务规则, +不是通用执行引擎的输出格式约束;正式 OMC 应接入自身资源清单与领域校验。 + +回复必须匹配 A2A taskId/contextId 及协商 id/round/maxRounds,正式正文从 Negotiation-T metadata 读取。 +一次有效回复只消费一次。错误正文、漏填、外地市端口或跨协商回复不能进入诊断。 +Reject 表示业务拒绝提供;Abort 表示无法核实信息或终止协商,均不是诊断成功。 + +默认 NegotiationStrategy 允许提供已掌握的当前任务字段;无法核实时生成 Abort,不编造字段。 +宿主可通过 mayProvideField(task, fieldName) 注入披露策略,拒绝时由示例生成 Reject: +```java +var strategy = new dev.openan.workflow.engine.examples.negotiation.NegotiationStrategy(envPath) { + @Override + protected boolean mayProvideField( + dev.openan.workflow.engine.model.TaskRequest task, String fieldName) { + return allowedFields.contains(fieldName); // 宿主已计算的业务允许字段集合 + } +}; +var callbacks = new dev.openan.workflow.engine.examples.workbench.WorkbenchControlPoint(envPath, strategy); +``` +这里的字段披露策略与 Authorization-T 的“自动抢通白名单”是两件事。 + +### 结果和通知不要错用请求生成接口 + +诊断结果、授权回执、业务抢通事件由 OMC 业务生成。1.1.0 的上述授权/通知入口面向授权操作和订阅请求, +不能把抢通事件再次交给订阅请求生成/校验接口。示例 RecoveryNotification 按 SPN 业务事件格式检查正式 +Notification-T artifact metadata:包含任务流水号、OSS流水号、端口、方案及完成结果等必要字段且状态为已结束, +才确认收到最终结果并关闭该订阅;成功和失败都是“执行已结束”,不把失败结果冒充成功。 +单独 artifact 名称、parts 摘要、订阅 ACK、未启动方案均不能触发关闭。不完整事件仍交给通知回调观察。 + +City1 上报的端口和 OSS 流水号来自本次已校验 SpnTaskInput,不再固定写成样例常量。 +City2 没有故障可以不产生抢通结果;授权/订阅失败和是否收到抢通通知,均不决定工作流诊断是否启动或成功。 + +### 可重复验证范围 + +EmbeddedA2AServerTest 覆盖正常填充数据、Accept 填充数据保留、错误正文、漏填、跨协商回复、 +Reject 和 Abort;NegotiationStrategyTest 覆盖城市输入隔离、不可核实的 Abort 和业务拒绝的 Reject; +RecoveryNotificationTest 覆盖完整成功/失败结果、仅摘要、错误命名和不完整事件。 +SpringSpnDemoE2ETest 及 dev 的 SpringSpnDemoOrderE2ETest 均覆盖完整输入、City1 缺参、两城市同时缺参。 +完整业务抢通链路由 SpnCrossCityE2ETest 验证。 + +这些测试用真实 SDK 1.1.0 的生成和校验管线、真实 A2A HTTP/SSE;Order 测试使用供应商 jar 和本地平台模拟器。 +离线 LLM provider 仅解析明确的样例输入,拒绝错误摘要/缺参,不代表真实模型的语义质量或现网联调已通过。 + +### 授权策略格式与协商字段(SDK 1.1.0) + +新增授权使用 SDK 当前的编号、标签、换行格式,不能传四个无标签的裸值: + +```text +1. 业务场景是业务投诉诊断,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-06-01~2030-06-18 +2. 业务场景是视频保障,处置类型是故障切换,操作名称是频谱重耕,有效期是永久生效 +``` + +OMC 示例仅从 SDK validate-and-fill 的数据构造白名单,逐字段提取标签后的值, +按业务场景、处置类型、操作名称精确匹配,有效期包含起止日。全部规则解析成功才替换原策略; +任意一条非法均保持原策略,不降级成默认授权。 + +示例只保存一组内存白名单(固定示例 ID),不代表生产策略库: +删除请求使用 `1. 策略标识是<当前策略UUID>`;仅支持单个精确 ID,不支持批量或条件删除。 +查询省略策略列表或传空字符串,返回全部当前规则;不支持带条件查询,不会静默忽略条件。 +修改、条件查询、批量/条件删除需由集成方的策略库实现。该业务限制不代表 A2A-T SDK 不支持这些操作。 + +信息协商仍先调用 SDK `validateProposePromptAndDataFilling`。当前中文信息协商模板由 typed API +生成,示例从已通过语义校验的“所需信息项”中保留编号后的原始名称(如“任务对象”)用于回复, +不把模型提取出的说明文字(如“本地市实际接入端口名称”)当成新字段,也不模糊映射到其他数据。 +未知字段或本地数据缺失时 Abort,业务披露策略拒绝时 Reject;不能用其他地市的信息补齐。 +这些处理均在 samples 的业务层,不进入通用引擎。 + +Notification-T 的失败原因字段使用完整名称“业务抢通方案执行失败原因”,执行失败时必填。 +SDK 校验拒绝时记录错误码、slot 错误和已提取参数的键名,方便定位;不绕过校验。 +授权、订阅的成功与否仍不阻断独立工作流。 diff --git a/docs/zh/DESIGN.md b/docs/zh/DESIGN.md index 3de9fa6b..0a79f90b 100644 --- a/docs/zh/DESIGN.md +++ b/docs/zh/DESIGN.md @@ -1,31 +1,50 @@ # A2A-T 工作流执行引擎 - 架构设计 > A2A-T 工作流执行引擎的架构设计与设计原理。 -> 本文档描述 v1.0 发布版本。面向集成或扩展 SDK 的工程师, -> 不是某个 bug 修复的历史记录。 +> 本文档描述 v1.0 发布版本。面向集成或扩展 SDK 的工程师 --- ## 1. 概述 -A2A-T 工作流执行引擎让宿主智能体通过 A2A 协议和 A2A-T 电信扩展执行多步骤工作流。 -工作流是一个有向无环图(DAG),每个步骤向远程智能体下发一个或多个任务,并路由到下一步。 -SDK 负责协议机制(消息发送、流式传输、认证、Task-T 提示词生成、Negotiation-T 自动协商循环), -暴露少量决策接口由宿主实现业务逻辑。 - -核心设计原则:**SDK 负责协议机制,宿主负责业务决策**。 - -| SDK 负责(协议机制) | 宿主负责(业务决策) | -|----------------------------------------------------------|-------------------------------| -| A2A 消息发送、流式传输、SSE 规范化 | 是否发送任务、何时发送 | -| 智能体认证(Bearer、自定义 Header) | 凭证配置 | -| A2A-T 扩展(Task-T、Negotiation-T、Authorization-T、Notification-T) | 授权审批、通知处理 | -| DAG 遍历、上下文组装、状态管理 | 分支路由决策 | -| 事件发射 | 事件处理 | +SDK 是集成在宿主智能体进程中的工作流协议调度库,不是独立业务平台。 +宿主提供最终内容,引擎管理 DAG、并行调度、标准 A2A 信封、认证、传输、等待和结果关联。 +业务回调决定是否调用 A2A-T 的自然语言/结构化内容接口,模板、schema、LLM 与语义校验均归宿主。 +引擎只通过 a2a-t-core 读取规范协商上下文和复制扩展 metadata,不包含内容生成管线。 + +| 引擎职责 | 宿主职责 | +|---|---| +| DAG、任务/会话关联、并行与路由校验 | 内容生成、业务路由决策、本地汇总 | +| 按 contextFrom 选择完整上游结果 | 决定如何使用这些来源、映射下游输入 | +| A2A 认证与传输机制 | 凭据来源、AuthProvider、部署配置 | +| 独立授权发送、订阅 handle 和生命周期 | 授权策略、订阅内容、通知消费和关闭时机 | + +## 2. 周边系统依赖与宿主集成 + +![工作流执行引擎与周边系统依赖架构](../images/workflow-engine-surrounding-systems.png) + +上图中的 SDK 是**嵌入宿主智能体进程的库**,不是独立部署的编排服务。当前 +`SpringSpnDemo` 的宿主是传输工作台智能体,实际责任链如下: + +1. WAIMO 通过 A2A Task-T 调用工作台的服务端入口。 +2. 工作台准备执行输入:生产环境从注册中心获取下游 AgentCard,并根据任务意图从编排中心 + 搜索、加载 PSOP;SDK 提供可选的 `RegistryClient` 和 `LoadPsop` 辅助 API,但何时发现、如何缓存、 + 失败策略均由宿主决定。 +3. 工作台把 `Workflow`、AgentCard、运行意图和业务回调交给 `ExecutePsop`。执行引擎遍历 DAG, + 并行向两个地市 OMC 下发任务,并在必要时处理 Negotiation-T;工作台通过 `ControlPoint` 接管 + 本地汇总、路由和澄清等业务操作。 +4. 工作台把汇总结果作为 Task-T artifact 和完成状态返回 WAIMO。 +5. Authorization-T 与 Notification-T 由工作台在各自业务时机通过 `ExtensionSender` 单独触发, + 不属于 PSOP DAG,也不与工作流任务复用 transport/runtime/context。 + +Demo 为保证离线可运行,`WorkbenchAgentCatalog` 从 classpath 加载 AgentCard;仅在编排中心搜索或 +加载失败时使用本地 PSOP fallback。这两项是样例替代路径,不代表生产环境的数据来源或容灾策略。 +图的可编辑源文件见 +[`docs/diagrams/workflow-engine-surrounding-systems.mmd`](../diagrams/workflow-engine-surrounding-systems.mmd)。 --- -## 2. 分层架构 +## 3. 分层架构 SDK 分为四层,每层构建在下一层之上,单一职责,入口清晰。 @@ -33,120 +52,83 @@ SDK 分为四层,每层构建在下一层之上,单一职责,入口清晰 graph TD L2["Layer 2 - 编排层
ExecutePsop
生命周期、事件流、取消、onFinish 持久化"] L1["Layer 1 - 遍历层
WorkflowExecutor
DAG 遍历、并行下发、上下文组装、路由"] - L0["Layer 0 - 通信层
A2ATransport + 两个门面
WorkflowEngineClient(工作流发送)| ExtensionSender(一次性前置下发)"] + L0["Layer 0 - 通信层
A2ATransport + 两个门面
WorkflowEngineClient(工作流发送)| ExtensionSender(独立协议操作)"] F["基础层 - 决策
ControlPoint
用户实现的业务决策"] L2 --> L1 --> L0 L0 -.-> F ``` -### 2.1 Layer 0 - 通信层 - -**`A2ATransport`** 是共享通信层,只负责一件事:把字节发到远程智能体再收回来。 -它拥有 A2A SDK 客户端运行时、认证管理器和拦截器、智能体卡片映射、流式响应消费者。 -暴露两个发送原语:`send`(收集并返回)和 `sendNotificationStream`(长连接 SSE), -以及将原始 SDK 事件流转换为文本、任务状态和元数据的静态提取器。 - -**两个门面构建在 transport 之上,各司其职:** - -- **`WorkflowEngineClient`** — 工作流执行发送路径。拥有 Task-T 提示词生成(发送前)、 - Negotiation-T 自动循环(接收后)、全局 `EventCallback`、`ControlPoint` 装配。 - 这是执行器在工作流执行期间调用的门面。 -- **`ExtensionSender`** — 前置下发门面。在工作流启动前发送一次性 Authorization-T 请求或建立长连接 Notification-T 订阅。 - 绕过 Task-T 生成和协商循环,不通过全局回调发射事件 — 返回的结果就是回调。 - -#### 为什么复用 transport 实现 + 两个门面? +### 3.1 Layer 0 - 通信层 -工作流发送路径和前置下发路径都需要相同的通信层机制:HTTP 客户端、TLS 配置、 -认证拦截器、智能体卡片解析、SSE 解析。把这些机制放在任一门面上要么 (a) 强制只想做前置下发的 -调用方持有完整工作流门面,要么 (b) 在两个类中重复通信代码。复用 `A2ATransport` 实现 + 两个门面的设计 -避免了这两个问题。两个门面可以持有不同生命周期的 transport 实例:工作流发送使用任务级 transport, -Notification-T 订阅使用工作台级 transport。 +A2ATransport 使用 A2A SDK 的 REST、JSON-RPC、gRPC 绑定,负责认证头、实际传输和完整响应组装。 +WorkflowEngineClient 接收 MessageContent 并管理远端任务和必要的协商续发。 +ExtensionSender 负责流程外 sendAuthorization 与 openNotification。 +三种协议操作复用实现而不共用 transport/runtime/context 实例。 +ProtocolResponses 按 artifact 身份合并流式增量,ReceivedMessage 保留各层 metadata。 -### 2.2 Layer 1 - 遍历层 +### 3.2 Layer 1 - 遍历层 -**`WorkflowExecutor`** 遍历 DAG。在每个步骤组装上游上下文(`ContextBuilder`), +**`WorkflowExecutor`** 遍历 DAG。在每个步骤按 `contextFrom` 选择强类型上游结果(`ContextBuilder`), 并发下发子任务,应用步骤成功策略,确定下一步。所有决策委托给 `ControlPoint`, 所有发送委托给 `WorkflowEngineClient`。 -步骤下发规则: +TaskRequest 当前输入与 workflowInput 分离。上游完整视图和便利 outputs 由 contextFrom 选择, +不会自动拼接、生成或套用业务 schema;业务自行决定怎么消费。详见 [回调契约](BUSINESS_CALLBACKS.md)。 + + - 前驱步骤全部完成的步骤被收集并并行下发 - 同一层的步骤并发执行 - `ALL_SUCCESS` — 所有子任务必须成功 - `ANY_SUCCESS` — 第一个成功的子任务胜出,其余取消 - `SELF_LOOP` — 任务由 `onSelfTask` 本地处理,不发送 A2A-T 消息 -### 2.3 Layer 2 - 编排层 +### 3.3 Layer 2 - 编排层 **`ExecutePsop`** 是高层运行器。包装执行器,提供生命周期管理(启动/完成/错误/关闭)、 事件序列化、客户端断连取消、`onFinish` 持久化钩子。大多数集成使用这一层。 --- -## 3. 决策接口 - -SDK 暴露两个用户实现的接口,按职责拆分。 - -### 3.1 ControlPoint — 流程决策 - -驱动工作流前进。每个方法由执行器或自动协商循环调用,做恰好一个决策: - -| 方法 | 调用方 | 决策 | -|------------------|-------------|-------------------------------------------| -| `onTask` | 执行器 | 向智能体发送任务(调用 `sendMessage`) | -| `onSelfTask` | 执行器 | 本地处理自环任务(不走 A2A-T) | -| `onRoute` | 执行器 | 在条件步骤选择分支 | -| `onNegotiation` | 客户端自动循环 | 在 INPUT_REQUIRED 时提供澄清文本 | - -Authorization-T 和 Notification-T 是预置操作,通过 `ExtensionSender` 在工作流启动前发送,不在工作流执行中回调。 - ---- - -## 4. A2A-T 扩展模型 - -支持四个 A2A-T 扩展,按生命周期分为两组。 - -### 4.1 工作流内扩展 - -参与每次 `sendMessage` 生命周期,通过扩展处理器链(`ExtensionRegistry` 自动注册): - -- **Task-T** — 发送时,调用 A2A-T SDK 从自然语言消息生成结构化任务提示词,注入消息 metadata。 - 协商后续和调用方预设提示词时跳过。接收时:透传。 -- **Negotiation-T** — 接收时,当智能体返回 `INPUT_REQUIRED` 并声明该扩展, - 提取协商上下文和消息。这驱动自动循环:引擎调用 `ControlPoint.onNegotiation` 获取澄清, - 重发后续消息,重复直到达到配置的轮次上限。 - -### 4.2 前置下发扩展 +## 4. 决策接口 -在工作流启动前通过 `ExtensionSender` 建立,并与单次工作流 transport 解耦: +```java +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); +} +``` -- **Authorization-T** — 发送一次性授权前置请求。当前直接使用调用方提供的自然语言输入作为 metadata 值。 -- **Notification-T** — 建立结果订阅。打开长连接 SSE 流,后续抢通结果通过该流返回。 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 +onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 +未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 -订阅结果(如后续推送的抢通结果)通过 `sendNotification` 响应流返回, -不通过 `onNotification`。该钩子仅在智能体在 `sendMessage` 任务响应中主动包含 -Notification-T 载荷时触发。 +## 5. A2A-T 扩展模型 -### 4.3 扩展处理器链 +Task-T:宿主生成最终内容,引擎只封装并发送。AgentCard 声明扩展不触发生成。 -```mermaid -graph TD - SM["sendMessage(agent, message)"] - BS["before_send: Task-T 生成提示词,注入 metadata"] - TS["transport.send(Task-T metadata 在线上传输)"] - AR["after_receive: Negotiation-T 提取上下文(驱动自动循环)"] - AN["auto_negotiate 循环(如果 INPUT_REQUIRED)"] +Negotiation-T: - SM --> BS --> TS --> AR --> AN -``` +只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 +终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 +宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 +通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; +结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 -`ExtensionRegistry.getHandlersForExtensions` 将智能体声明的扩展 URI 与处理器关键字 -(不区分大小写)匹配,返回该智能体的处理器链。Authorization-T / Notification-T 处理器类 -保留给需要内联处理智能体推送数据的调用方,但不自动注册 — 这是前置下发的关注点。 +返回 `new NegotiationReply.Send(content)` 发送最终内容; +返回 `new NegotiationReply.Stop(code, reason)` 只在本地停止,不生成 Abort。 +同一任务/会话/轮次的重复等待事件不会重复回调、重复提交;未变化状态通过 getTask 观察。 +`maxNegotiationExchanges` 默认 3,是独立于 SDK context.maxRounds 的本地交互资源预算。 +超时、预算耗尽、回调缺失均明确失败,不默认 Accept,也不自动生成 Abort。 +Accept/Reject 的 SUBMITTED/WORKING ACK 仍需等待任务结果,不重发原命令。 +业务发送 Abort 后,即使远端用 COMPLETED 确认,也不能判为诊断成功。 ---- +Authorization-T 和 Notification-T 是独立业务操作,不属于 DAG。宿主生成内容并使用独立发送器;失败不影响工作流。白名单仅影响 OMC 自动抢通,订阅保持到业务主动关闭。 -## 5. 条件路由 +## 6. 条件路由 步骤的 `next` 列表持有 `JumpCondition(step, condition)` 条目。路由规则: @@ -159,7 +141,7 @@ graph TD --- -## 6. 事件模型 +## 7. 事件模型 事件通过可选的 `EventCallback` 以稳定字符串类型(`EventType`)发射,按来源分组: @@ -168,97 +150,70 @@ graph TD `task_status_changed`、`route_decision`、`workflow_complete` - **智能体流量** — `agent_request`、`agent_response`、`agent_status_update`、 `agent_artifact_update`、`agent_message_event` -- **A2A-T 扩展** — `negotiation_request`、`negotiation_resolved`、`negotiation_failed`、 - `authorization_request`、`authorization_resolved`、`notification` +- **工作流内 A2A-T 扩展** — `negotiation_request`、`negotiation_resolved`、`negotiation_failed` - **失败** — `error`,由执行器在步骤失败时和运行器在最终失败时发射 ---- +`authorization_request`、`authorization_resolved` 和 `notification` 目前只是 `EventType` 中的保留常量, +工作流事件流不会发射它们。独立授权结果由 `ExtensionSender` 的返回值处理,订阅事件由 +`NotificationSubscription` 回调处理。 -## 7. 交互序列 - -### 7.1 带协商的工作流执行 - -```mermaid -sequenceDiagram - participant H as 宿主 - participant E as 执行器 - participant C as EngineClient - participant A as 智能体 - - H->>E: run(workflow) - E->>C: onTask(req) - C->>A: before_send: Task-T - A-->>C: INPUT_REQUIRED (Negotiation-T) - C->>E: negotiation result - E->>H: onNegotiation (宿主提供澄清) - H->>E: clarification - E->>C: follow-up send - C->>A: send follow-up - A-->>C: final result - C->>E: final result - E->>H: ExecutionResult -``` +--- -### 7.2 前置下发授权 +## 8. 交互序列 ```mermaid sequenceDiagram - participant H as 宿主 - participant ES as ExtensionSender - participant T as Transport - participant A as 智能体 - - H->>ES: sendAuthorization - ES->>ES: generate prompt (SDK) - ES->>T: send(instruction, auth) - T->>A: send - A-->>T: auth result - T-->>ES: auth result - ES-->>H: result + participant H as Host callbacks + A2A-T client + participant E as Workflow engine + participant A as Remote agent + E->>H: onTask(TaskRequest + upstream window) + H->>H: Generate/validate final content + H-->>E: MessageContent + E->>A: A2A envelope + unchanged content + opt INPUT_REQUIRED with valid Propose + A-->>E: Task status + Negotiation-T Propose + E->>H: onNegotiation(originalSubmission, received, history) + H->>H: Validate proposal; generate reply + H-->>E: Send(MessageContent) or local Stop + E->>A: Same task/context; final reply content + end + A-->>E: Task result / artifacts + E->>H: onSelfTask(selected complete upstream results) + H-->>E: TaskResult ``` -### 7.3 Notification 订阅 - ```mermaid sequenceDiagram - participant H as 宿主 - participant ES as ExtensionSender - participant T as Transport - participant A as 智能体 - - H->>ES: sendNotification - ES->>T: sendNotificationStream - T->>A: open long-lived SSE - A-->>T: ack (working) - T-->>ES: first event -> future - ES-->>H: result - Note over T,A: 后续结果通过同一连接流回 + participant H as Host + A2A-T client + participant ES as Independent ExtensionSender + participant A as OMC + H->>H: Generate final authorization content + H->>ES: sendAuthorization(agent, content) + ES->>A: One-shot authorization + A-->>H: Independent result, never gates workflow + H->>H: Generate final subscription content + H->>ES: openNotification(agent, content, listener) + ES-->>H: Registered handle + ES->>A: Independent long-lived stream + A-->>H: ACK via acknowledgement() + A-->>H: listener(handle, ReceivedMessage) + H->>ES: handle.close() on recovery/cancel/shutdown + ES-->>H: completion() after stream exits ``` ---- - -## 8. 依赖 - -**本 SDK:** `org.a2aproject.sdk:a2a-java-sdk-client`(A2A 协议)、 -`net.openan.a2at.sdk:a2a-t-client`(A2A-T 扩展)、Jackson、SLF4J、Lombok。 - -SDK 是独立的:不依赖编排中心。 - ---- - -## 9. 设计决策总结 - -1. **复用 transport 实现,两个门面** — 通信层机制在 `A2ATransport` 上写一次; - `WorkflowEngineClient` 和 `ExtensionSender` 各自拥有一个编排职责,委托通信工作, - 并可使用任务级或工作台级 transport 生命周期。 - 避免强制门面耦合和通信代码重复。 +## 9. 依赖 -2. **工作流内扩展 vs 前置下发扩展** — Task-T 和 Negotiation-T 是 `sendMessage` 链的一部分; - Authorization-T 是工作流启动前的一次性请求,Notification-T 是工作流启动前建立的长连接订阅。 - 注册表只自动注册工作流内的一对。 +workflow-engine:A2A Java `1.2.0.Final`(REST/JSON-RPC/gRPC)、匹配的 gRPC runtime、 +`net.openan.a2a-t.sdk:a2a-t-core:1.1.0`、Jackson、SLF4J。 +纯引擎消费者不会传递引入 A2A-T client/server、LLM、prompt 或 resources。 +samples/宿主显式依赖 a2a-t-client,需要实现 OMC 接收端时另依赖 a2a-t-server。 +注册中心和编排中心由宿主调用,可选择 RegistryClient/LoadPsop 辅助接口或自己的实现。 +模板和 slot schema 来自锁定 SDK jar,样例不覆盖同名资源。 -3. **自动协商循环** — 引擎拥有重发循环,宿主只提供澄清文本(`onNegotiation`), - 不需要关心重发的协议机制。 +## 10. 设计决策总结 -4. **条件路由语义** — 空条件意味着扇出(并行),条件分支意味着通过 `onRoute` 做 N 选 1。 - 保持路由模型可预测。 +最终内容与协议调度分离,宿主不自行维护 A2A 信封。 +本地多输出和远端完整证据统一进入下游窗口,不丢失 metadata、不拍平数组。 +协商回复内容归业务,任务关联/去重/有界等待归引擎;本地 Stop 与协议 Abort 分离。 +独立授权和通知不成为工作流前提,直连与 dev 东信转发继续共享回调。 +协议日志在实际边界采集并强制脱敏,详情见 [集成指南](INTEGRATION_GUIDE.md)。 diff --git a/docs/zh/DEVELOPER_GUIDE.md b/docs/zh/DEVELOPER_GUIDE.md index 8c4d7917..a753eb03 100644 --- a/docs/zh/DEVELOPER_GUIDE.md +++ b/docs/zh/DEVELOPER_GUIDE.md @@ -15,7 +15,7 @@ ``` 引擎会传递性引入 A2A 协议 SDK(`a2a-java-sdk-client`,含 REST、JSON-RPC、gRPC 传输) -和 A2A-T 扩展 SDK(`a2a-t-client`)。无需额外依赖。 +和最小 A2A-T 核心(`a2a-t-core`)。生成内容的宿主显式依赖 a2a-t-client,OMC 接收端另引入 a2a-t-server。 ## 2. 核心概念 @@ -27,34 +27,33 @@ ## 3. 实现 ControlPoint -只需实现两个方法: - ```java -public class MyControlPoint implements ControlPoint { - @Override - public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient client) { - return client.sendMessage(request.getAgentName(), request.getMessage()) - .thenApply(result -> TaskResponse.builder() - .success(true) - .output(result.getText()) - .build()); - } - - @Override - public CompletableFuture onRoute( - String stepName, Map results, - List conditions) { - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(conditions.get(0).getStep()) - .reason("picked first") - .build()); - } +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -`onNegotiation` 有默认实现,返回通用澄清文本。授权和通知是预置操作,通过 `ExtensionSender` 在工作流启动前发送,不在 `ControlPoint` 上。 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 +onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 +未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 + +```java +ControlPoint callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .onSelfTask(request -> CompletableFuture.completedFuture( + TaskResult.success(List.of(Map.of( + "sourceResults", request.getWorkflowInput().upstreamResults()))))) + .onRoute(request -> CompletableFuture.failedFuture( + new IllegalStateException("Supply a routing policy for " + request.stepName()))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Manual confirmation required"))) + .build(); +``` ## 4. 通过 Builder 执行(推荐) @@ -66,7 +65,6 @@ ExecutionResult result = ExecutePsop.builder() .runtimeIntent("诊断SPN故障") .lang("zh") .sslVerify(false) - .a2atEnvPath(".env") .credentialsConfigPath("agent_credentials.json") .eventCallback(new EventCallback()) .onFinish((r, e) -> { @@ -90,18 +88,18 @@ ExecutionResult result = ExecutePsop.builder() | `start` | 运行器 | 工作流开始 | `workflow`、`steps` | | `step_start` | 执行器 | 步骤开始 | `step` | | `task_request` | 执行器 | 子任务下发到 `onTask`/`onSelfTask` | `step`、`agent`、`task` | -| `task_response` | 执行器 | `onTask`/`onSelfTask` 返回 `TaskResponse` | `step`、`agent`、`task`、`output` | +| `task_response` | 执行器 | 远端任务完成或 onSelfTask 返回 TaskResult | `step`、`agent`、`task`、`outputs` | | `task_status_changed` | 执行器 | 任务状态变更(pending → running → success/failed) | `step`、`agent`、`task`、`status` | | `route_decision` | 执行器 | 分支选择 | `step`、`next`、`reason` | | `step_complete` | 执行器 | 步骤完成 | `step`、`results` | | `workflow_complete` | 执行器 | 所有步骤完成 | `history`、`step_outputs` | -| `agent_request` | 引擎客户端 | 消息发送到智能体 | `agent`、`request`、`metadata` | -| `agent_response` | 引擎客户端 | 收到智能体响应 | `agent`、`response` | +| `agent_request` | engine client | 准备下发(不是 wire 日志) | `agent`, `content` | +| `agent_response` | engine client | 远端响应已组装 | `agent`, `response`, `receivedMessages` | | `agent_status_update` | 引擎客户端 | 智能体 SSE 状态更新 | `agent`、`state`、`is_final` | | `agent_artifact_update` | 引擎客户端 | 智能体 SSE 产物更新 | `agent`、`artifact_name`、`text` | -| `negotiation_request` | 引擎客户端 | 智能体需要澄清 | `agent`、`round`、`concern` | -| `negotiation_resolved` | 引擎客户端 | 澄清已提供 | `agent`、`round`、`clarification` | -| `negotiation_failed` | 引擎客户端 | 协商失败 | `agent`、`round`、`reason` | +| `negotiation_request` | engine client | 有效 Propose 进入业务接管 | `agent`, `request`, `exchange` | +| `negotiation_resolved` | engine client | 宿主 Send 通过关联检查;不等于任务成功 | `agent`, `reply`, `exchange` | +| `negotiation_failed` | engine client | 本地协商交互失败 | `agent`, `exchange`, `errorType` | | `complete` | 运行器 | 工作流成功 | `history`、`step_outputs` | | `error` | 运行器或执行器 | 工作流失败 | 运行器:`error`、`history`;执行器:`step`、`results` | | `close` | 运行器 | 清理完成 | (空) | @@ -113,7 +111,6 @@ try (var client = new DefaultWorkflowEngineClient(agentCards, a2aRuntime, WorkflowEngineClientConfig.builder() .sslVerify(false) .credentialsConfigPath("etc/conf/agent_credentials.json") - .a2atEnvPath(".env") .build())) { WorkflowExecutor executor = new WorkflowExecutor( workflow, @@ -129,23 +126,19 @@ try (var client = new DefaultWorkflowEngineClient(agentCards, a2aRuntime, ### 6.1 协商自动循环 -引擎客户端的 `sendMessage()` 自动处理协商:当智能体返回 `INPUT_REQUIRED` 时, -引擎从响应 metadata 中提取协商文本,调用 `ControlPoint.onNegotiation()` 获取澄清, -然后作为后续消息发回。循环重复直到 `maxNegotiationRounds`(默认 3)。 +只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 +终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 +宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 +通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; +结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 -覆盖 `onNegotiation()` 提供业务特定的澄清: - -```java -@Override -public CompletableFuture onNegotiation( - String agentName, String negotiationText, - Map receiveResult) { - return myLlm.generate("Agent " + agentName + " needs: " + negotiationText) - .thenApply(Response::text); -} -``` - -返回空/null 字符串会失败该轮(发射 `negotiation_failed` 事件,循环重试)。 +返回 `new NegotiationReply.Send(content)` 发送最终内容; +返回 `new NegotiationReply.Stop(code, reason)` 只在本地停止,不生成 Abort。 +同一任务/会话/轮次的重复等待事件不会重复回调、重复提交;未变化状态通过 getTask 观察。 +`maxNegotiationExchanges` 默认 3,是独立于 SDK context.maxRounds 的本地交互资源预算。 +超时、预算耗尽、回调缺失均明确失败,不默认 Accept,也不自动生成 Abort。 +Accept/Reject 的 SUBMITTED/WORKING ACK 仍需等待任务结果,不重发原命令。 +业务发送 Abort 后,即使远端用 COMPLETED 确认,也不能判为诊断成功。 ### 6.2 工作流模型字段 @@ -154,8 +147,8 @@ public CompletableFuture onNegotiation( | `steps[].stepType` | `WorkflowStep` | `AllSuccess`(默认):所有子任务必须成功;`AnySuccess`:任一成功即可;`SelfLoop`:工作流智能体通过 `onSelfTask` 本地处理(不向命名智能体发送 A2A-T 消息)。 | | `steps[].subtasks[]` | `Task` | 每个含 `agent`、`skill`、`description`。每个子任务触发一次 `onTask`(或 SelfLoop 的 `onSelfTask`)调用。 | | `steps[].next[]` | `List` | 分支目标。`step` = 下一步名称;`condition` = 规则文本。 | -| `steps[].layer` | `WorkflowStep` | `layer == 0` 开始 DAG(上下文 = 运行时意图)。更高层获取上游结果。 | -| `steps[].contextFrom` | `WorkflowStep` | 可选步骤名,其输出折叠到上下文中。`"*"` = 所有祖先。 | +| `steps[].layer` | `WorkflowStep` | 编排层级提示;实际就绪条件由 DAG 前驱关系决定。 | +| `steps[].contextFrom` | `WorkflowStep` | 选择传入 `workflowInput.upstreamResults` 的步骤;省略 = 直接前驱,`[]` = 不聚合,`"*"` = 所有祖先,或显式指定祖先名称。 | ### 6.3 AgentCard 类型 @@ -236,16 +229,13 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() ## 9. A2A-T 环境(.env) -```ini -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_API_KEY=sk-... -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LANGUAGE=zh-CN -A2AT_CRED_KEY=<32字节hex> -``` - -`a2atEnvPath` 为 null 时,Task-T 提示词生成跳过。 +引擎不读取 A2A-T .env,也不创建 LLM client。业务回调需要 A2A-T 时, +宿主用自己的环境文件初始化 A2ATClient/A2ATServer,配置 provider/model/key/base URL 和 A2AT_LANGUAGE。 +样例的 a2atEnvPath 是宿主/Demo 配置,不是引擎 builder 参数。 +OMC 凭据解密不与 LLM 配置耦合:内置凭据模式通过 +WorkflowEngineClientConfig.builder().credentialEncryptionKey(key) 显式提供密钥, +再将配置好的 engineClient 传给 ExecutePsop。自定义 AuthProvider 自行管理 token 和配置。 +测试使用当前 SDK SPI 的离线 provider,不覆盖模板,也不是生产失败兜底。 ## 10. 集成模式 @@ -284,9 +274,23 @@ public Flux execute(@PathVariable String psopId) { ## 11. 检查清单 1. 添加 Maven 依赖 -2. 实现 `ControlPoint`(至少 onTask + onRoute;onSelfTask 和 onNegotiation 有默认实现) +2. 实现 `ControlPoint`(onTask 必须返回最终内容;包含本地任务、条件路由或协商时也实现对应回调) 3. 获取 AgentCards(从注册中心或 JSON 文件) 4. 加载 Workflow(通过 `LoadPsop` 或自行构建) 5. 配置 `.env` 和凭证文件 6. 调用 `ExecutePsop.builder().execute()` 7. 处理事件 + onFinish 持久化 + +### 协议日志 pretty 展示 + +协议日志默认以 pretty 形式展示:逐行打印头字段,缩进 JSON 正文。SSE 保留 id/event 等事件信息, +JSON 在 `=== SSE data (JSON display; not wire text) ===` 与 `=== End SSE data ===` 之间单独展示, +不再为展开后的每行 JSON 添加 `data:` 前缀。多个事件各自保留边界。 +`WORKFLOW_ENGINE_PROTOCOL_PRETTY=false`(环境变量或同名 JVM 属性)可保留脱敏后的原始正文展示。 +pretty 只影响显示(JSON 缩进及 SSE 数据区标记),不改实际发送字节、metadata、数值或扩展头; +原始观测正文仍保留,SSE pretty 展示不是可直接重放的抓包。 +JSON 字符串中的 `\\n` 保留转义,避免把业务字符串误写成非法 JSON;非 JSON/不完整正文保持原样。 +日志仍执行脱敏和容量限制。Demo 启动记录 `NEGOTIATION_DEMO enabled=..., city=...`; +本地默认 City1 协商、City2 直接诊断,`-Da2at.samples.negotiation=false` 关闭缺参演示。 +协商激活位于 `A2A-Extensions: .../Negotiation-T/v1`,不存在专用的 Negotiation-T HTTP 头; +正文检查对应 URI 的 metadata 和 `negotiationContext`,不要只检索大写 `NEGOTIATION-T`。 diff --git a/docs/zh/INTEGRATION_GUIDE.md b/docs/zh/INTEGRATION_GUIDE.md index 535ebe54..3b194f88 100644 --- a/docs/zh/INTEGRATION_GUIDE.md +++ b/docs/zh/INTEGRATION_GUIDE.md @@ -4,7 +4,7 @@ A2A-T 工作流执行引擎是一个 Java SDK,用于基于 A2A 协议和 A2A-T 电信扩展编排多智能体工作流。 -引擎自动处理 A2A 协议层的全部机制(消息收发、SSE 流式传输、Task-T 提示词生成、Negotiation-T 协商循环、认证、TLS),你只需关注业务决策。 +引擎处理 A2A 信封、消息收发、流式响应、协商关联、认证和 TLS;宿主负责最终内容生成、语义校验和业务决策。 ## 2. 环境要求 @@ -86,69 +86,33 @@ List> cards = registry.fetchAgentCards(); ### 4.3 实现 ControlPoint -继承 `DefaultControlPoint`,按需覆盖以下方法: - ```java -public class MyControlPoint extends DefaultControlPoint { - @Override - public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - return engineClient.sendMessage( - request.getAgentName(), request.getMessage()) - .thenApply(r -> { - String state = r.getTaskState(); - boolean success = state == null || state.isBlank() - ? r.getText() != null && !r.getText().isBlank() - : state.endsWith("COMPLETED"); - return TaskResponse.builder() - .success(success) - .output(r.getText()) - .build(); - }); - } - - @Override - public CompletableFuture onSelfTask(TaskRequest request) { - // SELF_LOOP 步骤在这里本地处理,不需要 engineClient,不发 A2A-T 消息。 - // request.getMessage() 已包含上游步骤的执行结果上下文。 - String summary = summarizeLocally(request.getMessage()); - return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output(summary).build()); - } - - @Override - public CompletableFuture onRoute( - String stepName, Map results, - List conditions) { - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(conditions.get(0).getStep()) - .build()); - } - - @Override - public CompletableFuture onNegotiation( - String agentName, String negotiationText, - Map receiveResult) { - return CompletableFuture.completedFuture("请使用现有信息继续执行。"); - } +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -| 方法 | 何时调用 | 你需要做什么 | -|-------------------|--------------------------------|----------------------------------------------------------| -| `onTask` | 步骤向其他智能体分派任务时 | 调用 `engineClient.sendMessage()` 发送消息,返回执行结果 | -| `onSelfTask` | `SELF_LOOP` 步骤本地执行时 | 本地处理并返回结果(不发 A2A-T 消息) | -| `onRoute` | 步骤完成后、决定下一步前 | 从候选分支中选择下一步 | -| `onNegotiation` | 智能体返回 `INPUT_REQUIRED` 时 | 返回补充说明文本 | - -`onNegotiation` 默认返回通用文本。只需覆盖你关心的方法。 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 +onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 +未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 -**前置操作(Authorization-T / Notification-T)**:两者都在工作流开始前通过 `ExtensionSender` 建立,但生命周期不同。Authorization-T 是响应完成即结束的一次性请求;Notification-T 是独立于单次工作流 transport 的长连接订阅,首个事件或 ACK 通过返回的 `SendMessageResult` 获取,后续事件通过回调持续接收,直到显式关闭订阅 transport。 - -**自环节点(SelfLoop)**:当一个步骤是工作流执行智能体自身的任务(例如汇总多个智能体的诊断结果),把 `stepType` 设为 -`SELF_LOOP`。引擎会调用 `onSelfTask` 本地处理,而不是通过 A2A-T 协议给智能体自己发消息。`onSelfTask` 不接收 `engineClient` -参数——从契约上保证自环任务不会误发 A2A-T。只有发给其他智能体的步骤才走 `onTask` + A2A-T 协议。 +```java +ControlPoint callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .onSelfTask(request -> CompletableFuture.completedFuture( + TaskResult.success(List.of(Map.of( + "sourceResults", request.getWorkflowInput().upstreamResults()))))) + .onRoute(request -> CompletableFuture.failedFuture( + new IllegalStateException("Supply a routing policy for " + request.stepName()))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Manual confirmation required"))) + .build(); +``` ### 4.4 执行 @@ -159,7 +123,6 @@ ExecutionResult result = ExecutePsop.builder() .controlPoint(new MyControlPoint()) .runtimeIntent("SPN跨城专线故障诊断与抢通") .lang("zh") - .a2atEnvPath(".env") .credentialsConfigPath("credentials.json") .sslVerify(true) .onFinish((r, history) -> { @@ -175,21 +138,13 @@ ExecutionResult result = ExecutePsop.builder() ### 5.1 .env 文件 -配置 LLM 和提示词运行时: - -```ini -A2AT_LANGUAGE=zh-CN -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-v4-flash -A2AT_LLM_API_KEY=sk-xxxxxxxxxxxxxxxx -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_MAX_TOKENS=2000 -A2AT_LLM_TEMPERATURE=0 -A2AT_LLM_TIMEOUT_SECONDS=60 -A2AT_CRED_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef -``` - -不配置 `.env` 时,Task-T 提示词生成不可用,其余功能不受影响。 +引擎不读取 A2A-T .env,也不创建 LLM client。业务回调需要 A2A-T 时, +宿主用自己的环境文件初始化 A2ATClient/A2ATServer,配置 provider/model/key/base URL 和 A2AT_LANGUAGE。 +样例的 a2atEnvPath 是宿主/Demo 配置,不是引擎 builder 参数。 +OMC 凭据解密不与 LLM 配置耦合:内置凭据模式通过 +WorkflowEngineClientConfig.builder().credentialEncryptionKey(key) 显式提供密钥, +再将配置好的 engineClient 传给 ExecutePsop。自定义 AuthProvider 自行管理 token 和配置。 +测试使用当前 SDK SPI 的离线 provider,不覆盖模板,也不是生产失败兜底。 ### 5.2 凭证配置文件 @@ -234,7 +189,7 @@ openssl rand -hex 32 4f8a2b1c3d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b ``` -将生成的密钥写入 `.env` 文件: +生成密钥后由宿主安全保存,并显式传给 credentialEncryptionKey;下列环境变量也可由宿主读取: ``` A2AT_CRED_KEY=4f8a2b1c3d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b @@ -300,7 +255,6 @@ public class SsoAuthProvider implements AuthProvider { WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .authProvider(new SsoAuthProvider(mySsoClient)) .sslVerify(true) - .a2atEnvPath(".env") .build(); ``` @@ -349,7 +303,7 @@ AgentCard 通过 `capabilities.extensions` 声明扩展点: "required": false }, { - "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T", + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", "description": "协商文本交换", "required": false }, @@ -394,63 +348,27 @@ AgentCard 通过 `capabilities.extensions` 声明扩展点: ## 7. A2A-T 扩展能力 -引擎自动处理四个 A2A-T 扩展,你无需关心协议细节: - -### Task-T(自动) - -给智能体发消息时,引擎自动调用 A2A-T SDK 生成结构化任务提示词,放入消息 metadata。你在 `onTask` 里只需调用 `sendMessage()` -,提示词生成是透明的。 - -### Negotiation-T(自动) - -智能体返回 `INPUT_REQUIRED` 时,引擎自动提取协商文本,调用你的 `onNegotiation()` 获取补充信息,然后发回后续消息。自动循环最多 -`maxNegotiationRounds` 次(默认 3)。 - -### Authorization-T(前置下发) - -工作流开始前,向 SPN 智能体下发白名单授权策略。前置操作使用基于同一 transport 的 `ExtensionSender` 门面,而非工作流客户端: +只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 +终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 +宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 +通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; +结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 -```java -ExtensionSender sender = new DefaultExtensionSender(transport); -sender.sendAuthorization( - "SPN Domain Agent", - "Authorization-T pre-positioning", - "任务类型:新增授权,操作:业务抢通,操作类型:光模块更换,..." -); -``` - -内部使用 `A2ATExtension.AUTHORIZATION_T`,勿硬编码 URI。SPN 智能体收到后存储策略,后续操作与白名单比对,在策略内直接执行,不在则拒绝。 - -### Notification-T(前置订阅) - -工作流开始前,向 SPN 智能体订阅抢通结果通知: +返回 `new NegotiationReply.Send(content)` 发送最终内容; +返回 `new NegotiationReply.Stop(code, reason)` 只在本地停止,不生成 Abort。 +同一任务/会话/轮次的重复等待事件不会重复回调、重复提交;未变化状态通过 getTask 观察。 +`maxNegotiationExchanges` 默认 3,是独立于 SDK context.maxRounds 的本地交互资源预算。 +超时、预算耗尽、回调缺失均明确失败,不默认 Accept,也不自动生成 Abort。 +Accept/Reject 的 SUBMITTED/WORKING ACK 仍需等待任务结果,不重发原命令。 +业务发送 Abort 后,即使远端用 COMPLETED 确认,也不能判为诊断成功。 ```java -sender.sendNotification( - "SPN Domain Agent", - "Notification-T subscription", - "通知主题:service-recovery-execution-result,..." -); +CompletableFuture sendAuthorization(String agentName, MessageContent content); +NotificationSubscription openNotification(String agentName, MessageContent content, + BiConsumer listener); ``` -`A2ATExtension.NOTIFICATION_T` 打开长连接 SSE 流。如需接收后续抢通结果,传入第四个参数 `Consumer>` 回调: - -```java -sender.sendNotification( - "SPN Domain Agent", - "Notification-T subscription", - "通知主题:service-recovery-execution-result,...", - event -> { - // event 包含 agent, text, metadata, state - Object text = event.get("text"); - if (text != null) { - System.out.println("抢通结果: " + text); - } - } -); -``` - -不传回调(null)时后续事件被丢弃。SPN 智能体通过通知通道上报抢通结果。 +宿主生成最终 Authorization-T/Notification-T 内容后调用上述接口;使用三类独立 transport/runtime/context。订阅监听器收到 handle 与完整 ReceivedMessage,按业务收到抢通结果后关闭。handle.acknowledgement() 和 completion() 分别表示 ACK 和真实流退出,不用它们的成功作为工作流前提。 ## 8. HTTPS 配置 @@ -475,24 +393,62 @@ HTTP/JSON-RPC 的 TLS 策略只作用于当前客户端,不修改 JVM 全局 ## 9. 日志 -引擎设有专用 `PROTOCOL` 日志器输出协议层请求/响应。Body 默认输出并按长度截断;`Authorization`、Cookie、API Key、Token、Secret 等敏感 Header 默认脱敏。在 `log4j2.properties` 中配置: - -```properties -logger.PROTOCOL.name=PROTOCOL -logger.PROTOCOL.level=info -logger.PROTOCOL.additivity=false -logger.PROTOCOL.appenderRef=console -``` +将 `PROTOCOL` logger 设为 DEBUG,可查看实际传输边界的观测记录。 +HTTP/JSON-RPC 记录 A2A SDK 处理后的实际序列化正文和应用头; +A2A-Version 只在真实请求有该头时出现,不为日志展示补造 Header。 +gRPC 记录实际 metadata 和 protobuf 的 JSON 展示,不伪装成 HTTP JSON 报文。 +JDK 自动网络头、HTTP/2 帧、TLS 密文及服务端字节不在此观测范围。 -以下环境变量或同名 JVM system property 控制内容: +dev 中 `ORDER_FORWARD_REQUEST` 表示交给东信 SDK 的请求, +`ORDER_SDK_RESPONSE` 表示 SDK 实际交付的状态、多值响应头和文本。 +`sdk-sse-text` 从 SDK 字符串分片组装可见 SSE 帧;原始字节编码及平台到 OMC 的报文不可见, +不能称为 OMC 抓包。`MODEL_PREVIEW` 只是高层预览,默认关闭,不是协议证据。 ```properties +logger.protocol.name=PROTOCOL +logger.protocol.level=DEBUG +logger.protocol.additivity=true WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY=true WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS=100000 -WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=false ``` -只有在隔离、受控的本地联调中才可临时打开敏感 Header;打开时引擎会打印安全告警。 +DEBUG 开启时正文观测默认开启;敏感部署可显式禁用。JVM 同名属性优先于环境变量。 +认证头、Cookie、Token 及识别出的口令字段强制脱敏,不提供关闭脱敏的开关。 +这是字段级脱敏,不能自动识别所有个人信息和业务敏感内容,生产应另定日志策略。 +缓冲有界:原始收集器按该数值限制字节,输出文本按字符限制; +超限 SSE 帧整帧丢弃至下个分隔符并标记 dropped-capacity,禁用、截断、中断也有明确标记。 +UTF-8 分片先组装再解码,日志观察失败不改变报文投递;文件引用不会为打印日志而下载。 +requestId 关联单次调用;工作流调用还带 executionId/logicalTaskId/attempt、 +agent/contextId/channel,已知远端任务时附 remoteTaskId,均为本地日志字段,不污染协议 metadata。 + +### 查看真实协商与协议日志 + +直接运行本地 SpringSpnDemo,默认 City1 缺参并协商、City2 参数完整直接诊断,无需添加 VM 参数。 +Negotiation-T 扩展激活本身不强制协商;这是本地 Demo 专门设置的场景,不是引擎默认行为。 +要关闭本地缺参演示、让两城市都直接诊断,在 IDEA **VM options** 增加: + +```text +-Da2at.samples.negotiation=false +``` + +默认仅移除 City1 的 Task-T 任务对象;启用状态下增加 `-Da2at.samples.negotiation.city=city2` 或 `both` +可演示 City2 或两城市同时缺参。宿主仍保留各城市的正确输入,原投诉上下文不变。预期链路是 `DEMO_NEGOTIATION` → `INPUT_REQUIRED / PROPOSE` +→ 工作台 onNegotiation → `ACCEPT` → 两城市完成 → 一次汇总。 +非内嵌 OMC 模式默认不注入缺参,显式设置 `-Da2at.samples.negotiation=true` 也会被拒绝。 +Demo 将开关传入当前 Spring 应用实例,不设置或修改 JVM 全局开关,不影响其他宿主。 +协议日志在控制台及以运行目录为基准的 `logs/spn-demo.log`。 +main 支持直连;dev 默认 order,两种模式使用同一业务回调。 + +离线重复验证(两个命令顺序执行,避免端口冲突): + +```powershell +mvn -q -pl samples -am "-Dtest=SpringSpnDemoE2ETest" "-Dsurefire.failIfNoSpecifiedTests=false" test +# 仅 dev:真实供应商 jar + 本地平台/OMC 模拟器 +mvn -q -pl samples -am "-Dtest=SpringSpnDemoOrderE2ETest" "-Dsurefire.failIfNoSpecifiedTests=false" test +``` + +每个测试类覆盖无 VM 参数的默认单城市协商、显式关闭、显式开启及两城市同时缺参。测试使用离线 LLM provider,但实际运行当前 SDK 的 +模板、校验和 HTTP/SSE;不是现网 OMC、平台或真实模型语义的验证。 ## 10. 事件回调 @@ -535,41 +491,7 @@ Workflow workflow = LoadPsop.load( ## 12. 自定义扩展 -如需扩展新的 A2A-T 扩展点,实现 `ExtensionHandler` 接口: - -```java -public class MyExtensionHandler implements ExtensionHandler { - @Override - public String extensionKeyword() { - return "My-Extension"; - } - - @Override - public CompletableFuture> beforeSend( - AgentCard agentCard, String messageText, - Map metadata, - A2ATClient a2atClient, ControlPoint controlPoint) { - metadata.put("https://example.com/extensions/My-Extension/v1", "value"); - return CompletableFuture.completedFuture(metadata); - } - - @Override - public CompletableFuture afterReceive( - AgentCard agentCard, SendMessageResult result, - A2ATClient a2atClient, ControlPoint controlPoint, - EventCallback eventCallback) { - return CompletableFuture.completedFuture(result); - } -} -``` - -通过配置注册: - -```java -WorkflowEngineClientConfig.builder() - .customHandlers(List.of(new MyExtensionHandler())) - .build(); -``` +直接构造最终 MessageContent(parts, metadata, extensions),由宿主管理扩展内容的生成/校验。无需注册引擎 handler 或 SDK 实例。A2atMessages.from 是 A2A-T metadata 复制辅助;非 A2A-T 扩展也可直接提供 metadata 和激活 URI。引擎不会仅因 AgentCard 声明而自动生成内容。 ## 13. 你需要使用的接口一览 @@ -579,12 +501,11 @@ WorkflowEngineClientConfig.builder() | `ControlPoint` / `DefaultControlPoint` | 业务决策实现(onTask、onSelfTask、onRoute、onNegotiation 等) | | `WorkflowEngineClient` / `DefaultWorkflowEngineClient` | 工作流发送(sendMessage、认证、扩展) | | `ExtensionSender` / `DefaultExtensionSender` | 前置授权请求与长连接通知订阅 | -| `A2ATransport` | 共享通信层(httpx runtime、认证、SSE 消费) | +| `A2ATransport` | 共享通信层(A2A Java 客户端 runtime、认证、SSE 消费) | | `WorkflowEngineClientConfig` | 配置(SSL、认证、A2A-T、协商轮数、自定义 Handler) | | `AuthProvider` | 自定义认证 | -| `ExtensionHandler` | 自定义扩展 | | `EventCallback` / `EventType` | 事件回调 | | `LoadPsop` / `RegistryClient` | 工作流加载 / AgentCard 获取 | | `Workflow` / `WorkflowStep` / `Task` / `JumpCondition` | 工作流定义 | | `ExecutionResult` | 执行结果 | -| `SendMessageResult` / `TaskResponse` | 消息/任务响应 | +| `SendMessageResult` / `TaskResult` | 消息/任务响应 | diff --git a/docs/zh/notification-t-design-analysis.md b/docs/zh/notification-t-design-analysis.md deleted file mode 100644 index a384f839..00000000 --- a/docs/zh/notification-t-design-analysis.md +++ /dev/null @@ -1,143 +0,0 @@ -# Notification-T / Authorization-T 设计分析 - -> **已归档。** 本历史分析已整合到 [DESIGN.md](DESIGN.md) 的第 4 节 (A2A-T 扩展模型) 和第 7 节 (交互时序)。下方描述的前置预定位流程现在位于 `ExtensionSender`(见 DESIGN.md "共享传输层与双门面"),不再在 `WorkflowEngineClient` 上。仅保留供参考。 - ---- - -> 本文分析 Notification-T 和 Authorization-T 的设计选型,提出长连接 SSE 方案, -> 并记录已确认的关键设计决策。 - ---- - -## 1. 概述 - -Notification-T 和 Authorization-T 是 A2A-T 的前置扩展,在工作流启动前通过 -`ExtensionSender` 一次性下发。它们不参与工作流内的 `sendMessage` 链。 - -- **Authorization-T** — 下发授权白名单策略。智能体存储策略,后续操作与白名单比对, - 在策略内直接执行,不在则拒绝。 -- **Notification-T** — 建立结果订阅。打开长连接 SSE 流,后续结果通过该流返回。 - ---- - -## 2. 当前架构 - -### 2.1 ExtensionSender - -`ExtensionSender` 提供三个方法: - -| 方法 | 扩展 | 用途 | -|----------------------------|-------------------|---------------------| -| `sendExtensionMessage` | 通用 | 发送任意扩展消息 | -| `sendAuthorization` | Authorization-T | 授权前置下发 | -| `sendNotification` | Notification-T | 通知订阅前置下发 | - -### 2.2 A2ATransport - -`A2ATransport` 暴露两个发送原语: - -- `send` — 收集并返回(用于 Authorization-T) -- `sendNotificationStream` — 长连接 SSE(用于 Notification-T) - -`sendNotificationStream` 在独立守护线程上运行,保持 SSE 流打开直到客户端断开或智能体关闭。 - -### 2.3 服务端处理 - -服务端的 `NegotiationBaseAgentExecutor`(参考实现,在 samples 中)通过 -`PrePositionedExtensionHandler` 处理前置消息: - -1. 检测消息 metadata 中的扩展 URI(Authorization-T 或 Notification-T) -2. 存储策略文本(Authorization-T)或保持 SSE 流打开(Notification-T) -3. 发送确认产物,立即完成任务 - ---- - -## 3. 设计分析 - -### 3.1 Authorization-T - -Authorization-T 是一次性操作:下发策略 → 服务端确认 → 完成。不需要长连接。 - -流程: -```mermaid -graph LR - ES["ExtensionSender.sendAuthorization"] - TR["A2ATransport.send"] - SR["服务端存储策略"] - ACK["返回 ack"] - DONE["任务完成"] - - ES --> TR --> SR --> ACK --> DONE -``` - -### 3.2 Notification-T - -Notification-T 需要长连接:订阅 → 流保持打开 → 后续结果通过该流返回。 - -设计选择:**在独立守护线程上维持长连接 SSE 流**。 - -```mermaid -graph TD - ES["ExtensionSender.sendNotification"] - TR["A2ATransport.sendNotificationStream"] - DT["守护线程启动"] - ACK["服务端返回 ack"] - FUT["future 完成"] - OPEN["流保持打开"] - PUSH["后续结果通过同一 SSE 流推送"] - END["客户端断开或服务端关闭时流结束"] - - ES --> TR --> DT --> ACK --> FUT --> OPEN --> PUSH --> END -``` - ---- - -## 4. 提议设计:Notification-T 长连接 SSE - -### 4.1 客户端 - -`A2ATransport.sendNotificationStream` 方法: - -1. 创建守护线程(`"notif-t-" + agentName`) -2. 在线程内调用 `a2aClientRuntime.sendMessage()` — SDK 发起 SSE 请求 -3. 对每个 SSE 事件调用 `eventSink` 回调 -4. 第一个事件(订阅确认)完成后 future -5. 流保持打开,后续事件持续推送 - -### 4.2 服务端 - -`NegotiationBaseAgentExecutor.handleNotificationSubscription` 方法: - -1. 发送确认产物("已订阅") -2. 进入循环:`notificationQueue.poll(30, SECONDS)` -3. 有结果时,构建带 Notification-T URI 的 metadata,通过 `emitter.addArtifact` 推送 -4. 无结果时继续等待 -5. 线程中断时退出(智能体关闭) - -### 4.3 结果推送 - -业务代码通过 `pushNotificationResult(result)` 将结果推入队列, -`handleNotificationSubscription` 从队列取出并通过 SSE 流推送。 - ---- - -## 5. 关键设计决策(已确认) - -1. **Notification-T 用长连接 SSE,不用 webhook** — SSE 流由 SDK 管理, - 不需要额外的 webhook 端点或反向认证。流保持打开直到客户端断开。 - -2. **Authorization-T 是一次性操作** — 下发策略,确认即完成。不需要长连接。 - -3. **sendNotificationStream 在守护线程上运行** — 不阻塞调用线程, - future 在第一个事件后完成,流在后台持续。 - -4. **服务端用 BlockingQueue 桥接** — 业务代码通过 `pushNotificationResult()` 推入队列, - SSE 消费者从队列取出推送。解耦业务逻辑和协议传输。 - -5. **不修改 A2A SDK** — Notification-T 的长连接能力完全在引擎层实现, - 不依赖 A2A SDK 的修改。SDK 的 `sendMessage` 已经支持 SSE 流式响应, - 引擎只是不主动关闭流而已。 - -6. **前置下发不参与工作流** — Authorization-T 和 Notification-T 通过 - `ExtensionSender` 在工作流启动前完成,不在 `WorkflowEngineClient.sendMessage` 链中。 - `ExtensionRegistry` 只自动注册 Task-T 和 Negotiation-T。 diff --git "a/docs/zh/\344\270\232\345\212\241\346\265\201.md" "b/docs/zh/\344\270\232\345\212\241\346\265\201.md" index 98da6067..ec5f6fbc 100644 --- "a/docs/zh/\344\270\232\345\212\241\346\265\201.md" +++ "b/docs/zh/\344\270\232\345\212\241\346\265\201.md" @@ -1,4 +1,17 @@ ---- +# SPN 专线投诉诊断业务流 + +## 工作台业务边界(2026-08-27 校准) + +1. WAIMO 通过 Task-T 将一个投诉诊断任务下发给工作台。工作台使用 SDK 校验并提取 + 投诉参数,搜索/加载工作流。 +2. 工作流向两个不同地市 OMC 并行下发含地市诊断输入的 Task-T。两路结果都完成后, + 工作台 join 节点仅执行一次汇总分析,把真实汇总结果返回 WAIMO。 +3. 工作台到 OMC 通过直连通道下发 Task-T 诊断任务。 +4. Authorization-T 和 Notification-T 由工作台在各自业务时机独立触发,不是工作流 + DAG 节点。Task、Authorization、Notification 使用不同的 transport/runtime/context/通道。 +5. OMC 对诊断后的抢通方案做白名单精确匹配;命中后自动执行,并通过已建立的 + Notification-T 长连接上报结果。工作台收到抢通结果后关闭对应订阅;ACK 不是抢通结果。 + ### 表格一:专线业务投诉诊断接口(核心诊断) @@ -8,8 +21,8 @@ | **协议/模式** | A2A-T Task-T | | **调用方向** | 工作台智能体 -> SPN Domain Agent(OMC) | | **功能描述** | 工作台智能体下发诊断请求,OMC完成网络侧故障根因诊断并返回结果。 | -| **请求参数** | 1. 投诉发生时间
2. 业务投诉故障现象
3. 专线分段业务标识
4. 故障场景(专线业务质差 / 专线中断) | -| **响应内容** | **一期必须返回:**
1. 故障根因
2. 根因点位置
3. 根因描述
4. 修复建议

**二期新增返回:**
1. OMC是否支持业务自动抢通
2. 业务抢通的方案详情 | +| **请求参数** | **必填:**
1. 任务对象:业务名称、业务标识或接入端口三者至少一个
2. 投诉分类(专线质差 / 专线中断)
3. OSS 侧事件流水号

**可选:**
4. 问题发生时间
5. 投诉详情 | +| **响应内容** | Task-T 只返回本地诊断结论:
1. 诊断结果
2. 故障根因/根因位置与描述
3. 修复建议。抢通方案和执行结果不拼入 Task-T 响应,统一走独立 Notification-T 订阅通道。 | ### 表格二:专线业务投诉诊断协商接口(参数修正) @@ -19,8 +32,8 @@ | **协议/模式** | A2A-T Negotiation-T | | **调用方向** | SPN Domain Agent(OMC) <--> 工作台智能体(双向) | | **功能描述** | 当OMC发现任务参数有误或缺失时,反向发起协商,请求工作台智能体补全或修正信息。 | -| **协商请求(OMC→工作台)** | **触发条件:**
1. 必选参数缺失(如故障发生时间)
2. 参数取值错误(如故障现象不在枚举范围内)
3. 参数语义校验错误(如应为逻辑口却下发了物理口)

**请求内容:** 明确指出缺失/错误的参数及修正建议。 | -| **协商响应(工作台→OMC)** | 工作台智能体理解协商请求,补全或修正相关参数,并返回给OMC。 | +| **协商请求(OMC→工作台)** | **触发条件:**
1. 必选参数缺失(如任务对象、投诉分类或 OSS 侧事件流水号)
2. 参数取值错误(如投诉分类不在枚举范围内)
3. 参数语义校验错误(如应为逻辑口却下发了物理口)

**请求内容:** 明确指出缺失/错误的参数及修正建议。 | +| **协商响应(工作台→OMC)** | 工作台智能体理解协商请求,返回强类型 Accept / Reject / Abort。Information Reject 必须对每个无法提供的请求信息项逐项说明原因,不能只返回一个汇总拒绝原因。 | | **协商完成(OMC)** | OMC确认协商信息完成后,继续执行诊断任务,并最终返回诊断结果(同表格一响应内容)。 | ### 表格三:业务抢通消息订阅与上报接口(结果通知) @@ -31,8 +44,8 @@ | **协议/模式** | A2A-T Notification-T | | **功能描述** | 用于工作台订阅抢通结果,以及OMC通过SSE流主动上报抢通结果消息。 | | **订阅请求(工作台→OMC)** | **包含内容:**
1. 消息主题
2. 上报消息数据格式(需包括:投诉事件流水号、分段业务标识、抢通方案执行结果、抢通方案详情) | -| **订阅响应(OMC→工作台)** | **成功:** 返回订阅成功。
**失败:** 返回订阅失败及失败原因描述。 | -| **消息上报(OMC→工作台)** | 基于订阅时指定的数据格式,通过**SSE流**主动向工作台推送业务抢通结果消息。 | +| **订阅响应(OMC→工作台)** | **成功:** Notification-T metadata 返回“订阅结果:成功”,任务进入 `WORKING`。
**失败:** 返回 `FAILED` 及失败原因。 | +| **消息上报(OMC→工作台)** | 基于订阅时指定的数据格式,通过 **SSE** 先推送 `recovery-plan`(未启动),白名单命中并执行后再推送 `recovery-result`(已结束);结果、取消或宿主关闭才结束通道。 | ### 表格四:专线业务抢通授权接口(自动执行授权) @@ -40,7 +53,15 @@ |:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------| | **接口名称** | 专线业务抢通授权接口 | | **协议/模式** | A2A-T Authorization-T | -| **功能描述** | 工作台向OMC下发或取消抢通授权,OMC据此决定是否自动执行抢通。 | -| **授权请求(工作台→OMC)** | **包含内容:**
1. 操作类型:新增授权 / 取消授权
2. 操作名称:隧道调优(或其他业务抢通操作) | -| **授权响应(OMC→工作台)** | **成功:** 返回授权成功。
**失败:** 返回授权失败及详细的失败原因。 | +| **功能描述** | 工作台向 OMC 新增、修改、删除或查询抢通授权,OMC 据有效白名单决定是否自动执行抢通。 | +| **授权请求(工作台→OMC)** | **包含内容:**
1. 操作类型:新增/修改/删除/查询授权策略
2. SDK 模板所定义的策略列表或查询条件 | +| **授权响应(OMC→工作台)** | artifact 的 Authorization-T metadata 返回“授权操作执行结果”;新增/查询成功时还返回策略标识和策略列表,删除必须精确匹配策略标识。失败返回 `FAILED` 且保留原策略。 | | **后置操作(OMC内部)** | 授权成功后,当OMC在执行诊断后的业务抢通时,若匹配到已授权项,则**自动执行**业务抢通,并依据**表格三**的订阅通道上报抢通结果消息。 | + + +## 引擎回调与依赖结果 + +上述场景使用同一套业务回调:onTask 返回最终 MessageContent,引擎封装信封并发送;onSelfTask 返回 TaskResult。 +多地市、多任务的输出按步骤分组保留于 WorkflowInput,contextFrom 选择是否聚合,当前 instruction 不拼历史。 +宿主自行调用 A2A-T SDK 生成协商回复,onNegotiation 返回 Send(content) 或 Stop;引擎只管理会话关联和等待。 +[业务回调集成契约](BUSINESS_CALLBACKS.md) 给出字段、最终内容与上下文选择的完整规则。 diff --git "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" index 1458434a..09562b52 100644 --- "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" @@ -1,6 +1,15 @@ -# 编排中心工作流执行引擎 SDK 工作台集成指南 +# 工作流执行引擎 SDK 工作台集成指南 -> 面向传输工作台研发团队。本文档以 SPN 专线故障跨城协同诊断联创场景为例,从工作台视角说明接入Workflow执行引擎 SDK 的完整路径。 +> 交付基线:workflow engine `1.0.0`,A2A-T SDK +> `1.1.0`(commit `e42c83acce3e5ac2c245d36546ced0fa017b2b58`), +> A2A Java SDK `1.2.0.Final`。本执行引擎是首发版本, +> 只支持该当前 A2A-T API/资源基线,不提供旧 SDK 状态机接口或旧报文 fallback。 + +> 面向传输工作台研发团队。本文档以 SPN 专线故障跨城协同诊断联创场景为例,从工作台视角说明接入 Workflow 执行引擎 SDK 的完整路径。 +> +> 业务基线:上游为 WAIMO;工作台接收 Task-T 后加载工作流,并行调度两地市 OMC, +> 两路完成后汇总一次并返回 WAIMO。Authorization-T 和 Notification-T 是工作台在 +> 独立时机触发的协议操作,不是工作流 DAG 节点。 --- @@ -8,19 +17,17 @@ ### 1.1 业务拓扑 -```mermaid -graph TD - UPPER["上层故障系统
(上层业务系统)"] - WB["传输工作台智能体 (工作台)
接入 workflow-engine-java SDK
本文档的集成方"] - OMC1["SPN Domain Agent
(城市1 OMC)"] - OMC2["SPN Domain Agent
(城市2 OMC)"] - - UPPER -->|"A2A-T Task-T 下发原始诊断任务"| WB - WB -->|"A2A-T 下发诊断"| OMC1 - WB -->|"A2A-T 下发诊断"| OMC2 -``` +![工作流执行引擎与周边系统依赖架构](../images/workflow-engine-surrounding-systems.png) -工作台是编排方。它接收 上层故障系统通过 A2A-T 下发的原始诊断任务,编排下游多地市 SPN Domain Agent (OMC) 执行故障根因诊断,汇总结果并上报。 +工作台是运行时编排宿主,执行引擎 SDK 嵌入工作台进程,不是独立部署的服务。编排中心负责 +PSOP 的存储、检索和加载,注册中心负责 AgentCard 的发布和查询;工作台负责在适当时机获取这些 +输入并交给 SDK。它接收 WAIMO 通过 A2A-T 下发的原始诊断任务,并行编排下游多地市 +SPN Domain Agent(OMC)执行故障根因诊断,汇总结果后上报。 + +`SpringSpnDemo` 为便于离线运行,默认从 classpath 加载 AgentCard,并在编排中心不可用时使用本地 +PSOP fallback。真实 OMC 联调可用 `A2A_AGENT_CARD_LOCATIONS` 指定逗号或分号分隔的 +`classpath:`、`file:` URI 或绝对路径;显式文件缺失或内容无效时启动立即失败。这是样例特例, +生产集成应以注册中心和编排中心为数据源,并自行定义受控的失败策略。 ### 1.2 四类 A2A-T 交互 @@ -33,23 +40,20 @@ SDK 在该场景中承载四类 A2A-T 交互,对应业务流文档的四个接 | 3 | Authorization-T | 抢通授权 | 工作台 -> OMC | 工作流启动前,前置预定位 | | 4 | Notification-T | 抢通结果订阅/上报 | 工作台 -> OMC (订阅) / OMC -> 工作台 (SSE 上报) | 工作流启动前订阅,执行中上报 | -序号 1、2 在工作流执行过程中发生,由 SDK 的 `WorkflowEngineClient` 承载。序号 3、4 是前置动作,在工作流启动前完成,由 SDK 的 `ExtensionSender` 承载。这两条路径共享同一个底层传输层 `A2ATransport`,职责隔离。 +序号 1、2 在工作流执行过程中发生,由 SDK 的 `WorkflowEngineClient` 承载。序号 3、4 是流程外的独立操作,Demo 只是在工作流启动前尝试,由 SDK 的 `ExtensionSender` 承载;这个时间顺序不构成因果依赖,授权或订阅失败均不得阻止后续 Task-T 工作流。各路径使用同一类底层传输组件 `A2ATransport`,但 runtime、context 和生命周期互相隔离:工作流发送使用任务级 transport,Notification-T 订阅使用工作台级 transport,避免工作流结束时关闭长连接。 ### 1.3 SDK 自动处理 vs 工作台实现 -引擎的设计原则是: **协议机制由 SDK 承担,业务决策由工作台实现**。 +| 引擎承担 | 工作台承担 | +|---|---| +| DAG、并行、任务关联、信封与传输 | onTask 返回最终 MessageContent | +| 上游来源选择、完整响应组装 | 根据业务需要映射上下游输入 | +| 协商关联、去重、等待 | SDK 生成/校验回复,返回 Send 或 Stop | +| 认证机制与 Header 注入 | 凭据来源或自定义 AuthProvider | +| 独立授权发送与订阅生命周期 | SDK 生成授权/订阅内容、处理业务结果 | -| SDK 自动处理 | 工作台实现 | -|-------------|-----------| -| A2A 消息收发、SSE 流式传输、事件提取 | 是否发送任务、发送什么内容 | -| Agent 认证 (Bearer 令牌获取/刷新) | 凭证配置 (credentials.json) 或自定义 AuthProvider | -| Task-T 结构化提示词生成 (调用 A2A-T SDK) | 诊断任务的原始自然语言输入 | -| Negotiation-T 自动协商循环 (重发 follow-up) | 协商澄清内容 (onNegotiation 返回) | -| Authorization-T / Notification-T 报文构造 | 授权策略描述、订阅主题描述 | -| DAG 遍历、上下文组装、状态管理 | 分支路由决策 (onRoute 返回) | -| 事件全量产出 (EventType) | 事件处理 (EventCallback) | - ---- +A2A-T client/server、模板、schema、LLM 初始化属于宿主; +workflow-engine 仅依赖 a2a-t-core,不管理内容生成。 ## 2. 集成全景图 @@ -61,7 +65,7 @@ graph LR S2["步骤 2
准备配置文件
AgentCard + 认证 + .env"] S3["步骤 3
实现 AgentExecutor
A2A 服务端入口"] S4["步骤 4
实现 ControlPoint
工作流决策回调"] - S5["步骤 5
组装编排管线
加载卡片 -> 预定位 -> 执行"] + S5["步骤 5
组装运行管线
发现输入 -> 执行工作流 / 独立扩展"] S1 --> S2 --> S3 --> S4 --> S5 ``` @@ -129,8 +133,15 @@ Spring Boot starter 会自动注册 `AgentCard`、`RequestHandler`、`RestHandle {"uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1", "required": false} ] }, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], "skills": [ - {"id": "cross-city-fault-diagnosis", "name": "跨城故障协同诊断"} + { + "id": "cross-city-fault-diagnosis", + "name": "跨城故障协同诊断", + "description": "协同多地市 OMC 执行专线故障诊断并汇总结果", + "tags": ["SPN", "fault-diagnosis"] + } ], "supportedInterfaces": [ {"protocolBinding": "HTTP+JSON", "url": "https://工作台地址/a2a/json"} @@ -140,16 +151,38 @@ Spring Boot starter 会自动注册 `AgentCard`、`RequestHandler`、`RestHandle 下游 SPN Domain Agent 的 AgentCard 由厂商提供或从注册中心获取。工作台只需加载它们用于 SDK 通信。关键信息是 `name` (SDK 中用此名称寻址) 和 `supportedInterfaces.url` (通信地址)。 +下游 AgentCard 的安全字段语义: + +- `securitySchemes` 表示智能体支持的认证方式,不等于强制要求 +- `securityRequirements` 表示当前对接必须满足的认证方式;缺失、`null` 或 `[]` 都表示不启用内置凭证认证 +- 如果工作台通过 `AuthProvider` 自行获取 OMC Token,推荐配置 `"securityRequirements": []`,`securitySchemes` 可以保留为能力声明 +- 如果希望内置 credentials 认证生效,`securityRequirements` 必须非空,并引用 `securitySchemes` 中声明的方案名 + +AuthProvider-only 场景的下游 AgentCard 安全字段示例: + +```json +{ + "securitySchemes": { + "bearerAuth": { + "httpAuthSecurityScheme": { + "scheme": "Bearer" + } + } + }, + "securityRequirements": [] +} +``` + #### 2.2 认证配置 SDK 提供两层认证机制,可单独使用或组合使用: | 认证方式 | 适用场景 | 配置方式 | |---------|---------|---------| -| Credentials 认证 | Agent 使用标准 Bearer/API Key 认证,有登录接口获取令牌 | 提供 credentials.json 文件 | -| 自定义 AuthProvider | 企业 SSO、非标准认证协议、AgentCard 无 securitySchemes 但仍需认证 | 实现 `AuthProvider` 接口 | +| Credentials 认证 | Agent 提供登录接口并使用 Bearer 或自定义认证头 | 提供 credentials.json 文件 | +| 自定义 AuthProvider | 工作台自管 Token、企业 SSO、非标准认证头,或经自定义网关/协议转发获取 OMC Token | 实现 `AuthProvider` 接口 | -**执行顺序:** 自定义 AuthProvider 先执行,Credentials 认证后执行。两者可同时使用,各自向请求头注入认证信息。 +**合并规则:** 两种认证来源分别生成 Header 后合并。`AuthProvider` 可以作为唯一认证来源;如果两者对同名 Header 生成不同值,引擎会抛出 `SecurityException`。 ##### 方式一: Credentials 认证 (credentials.json) @@ -231,7 +264,7 @@ String encrypted = CredentialCrypto.encrypt("Admin@123"); } ``` -运行时 SDK 从 `A2AT_CRED_KEY` 环境变量读取密钥自动解密。密钥来源优先级: 操作系统环境变量 > Java 系统属性 (由 `.env` 文件加载器设置)。 +运行时 SDK 从 `A2AT_CRED_KEY` 环境变量读取密钥自动解密。密钥来源优先级:显式 credentialEncryptionKey > 操作系统 A2AT_CRED_KEY > 同名 JVM 属性。引擎不自动读取宿主 LLM .env。 凭证配置支持的完整字段: @@ -243,7 +276,7 @@ String encrypted = CredentialCrypto.encrypt("Admin@123"); | `request_fields` | Map | - | 请求体字段 (替代 legacy 的 username/password) | | `token_field` | String | `accessSession` | 从响应 JSON 提取令牌的路径 (支持点号嵌套,如 `data.access_token`) | | `token_ttl` | int | 3600 | 令牌缓存有效期 (秒),到期前 60 秒自动刷新 | -| `auth_header` | String | `Authorization` | 自定义认证头名称 (设置后使用 `CustomAuthInterceptor`) | +| `auth_header` | String | `Authorization` | 自定义认证头名称 (设置后引擎改用该 Header 注入凭证) | | `auth_header_prefix` | String | `Bearer ` | 自定义认证头前缀 (与 `auth_header` 配合使用) | ##### 方式二: 自定义 AuthProvider @@ -273,7 +306,6 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() headers.put("Authorization", "Bearer " + token); }) .sslVerify(sslVerify) - .a2atEnvPath(a2atEnvPath) .build(); ``` @@ -283,6 +315,9 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() - 认证机制非 A2A 标准类型 (Bearer、API Key、OAuth2) - 令牌来自企业统一身份认证平台 (如企业 SSO) - 需要注入非标准认证头 (如 `X-Custom-Token`、`X-Request-Signature`) +- 消息经自定义 `A2AJavaClientRuntime` 协议转发时,认证 Header 已由 `AuthProvider` 注入 `callContext`,转发实现原样复制即可 + +如果 Authorization-T / Notification-T 前置消息和工作流 Task-T 消息都需要认证,请把同一个 `AuthProvider` 配置到 `extensionTransport` 和 `taskTransport` 的 `WorkflowEngineClientConfig` 中。 ##### 两种方式组合使用 @@ -298,391 +333,118 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .build(); ``` -执行顺序: `AuthProvider.applyAuth()` 先执行 -> `AgentAuthManager` 后执行。两者写入同一个 headers Map,互不覆盖。 +`AuthProvider` 与 credentials 认证分别生成 Header 后再合并。`AuthProvider` 可以作为唯一认证来源,即使 AgentCard 的 `securityRequirements` 非空且未配置 credentials。不同名称的 Header 可同时使用;如果两者对同名 Header(例如 `Authorization`)给出不同值,引擎会立即抛出 `SecurityException`,避免认证信息被顺序覆盖。建议每个认证 Header 只配置一个来源。 #### 2.3 A2A-T SDK 环境 (.env) A2A-T SDK 的 LLM 配置 (用于 Task-T 提示词生成、场景识别等)。最小配置: ```ini -LLM_API_KEY=你的API密钥 -LLM_BASE_URL=https://api.example.com/v1 -LLM_MODEL=gpt-4o -LLM_LANGUAGE=zh-CN +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=deepseek-chat +A2AT_LLM_API_KEY=你的API密钥 +A2AT_LLM_BASE_URL=https://api.deepseek.com +A2AT_LANGUAGE=zh-CN ``` SDK 通过此配置调用 A2A-T SDK 生成 Task-T 结构化提示词。如果此文件未配置或生成失败,SDK 会降级为使用原始输入文本作为消息内容,不阻断流程。 ### 步骤 3: 实现 AgentExecutor -工作台作为 A2A 服务端,需要实现 `AgentExecutor` 接口。这是 上层故障系统调用工作台的入口。SDK 不提供此实现 -- 它是工作台的业务代码。 - -`AgentExecutor` 来自 A2A Java SDK (`org.a2aproject.sdk.server.agentexecution.AgentExecutor`)。工作台实现 `execute` 方法接收上层任务,实现 `cancel` 方法处理取消。 +工作台实现 A2A Java AgentExecutor.execute(RequestContext, AgentEmitter) 接收 WAIMO, +并在自己的入口用 A2ATServer.validateTaskPromptAndDataFilling 校验 Task-T。 +metadata 中的 Task-T 正文与 templateUri 是内容入口,不用 parts 长短决定读取哪个正文。 +然后搜索工作流、获取 AgentCards、调用执行引擎,并通过 A2A emitter 返回 artifact/终态。 -接口签名: - -```java -public interface AgentExecutor { - void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError; - void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError; -} -``` - -工作台实现示例: - -```java -public class TransportWorkbenchExecutor implements AgentExecutor { - - private final String orchUrl; // 编排中心地址 (PSOP 搜索/加载) - private final String credentialsPath; // 凭证配置路径 - private final boolean sslVerify; // 是否校验 TLS 证书 - private final String a2atEnvPath; // A2A-T .env 路径 - - public TransportWorkbenchExecutor(String orchUrl, String credentialsPath, - boolean sslVerify, String a2atEnvPath) { - this.orchUrl = orchUrl; - this.credentialsPath = credentialsPath; - this.sslVerify = sslVerify; - this.a2atEnvPath = a2atEnvPath; - } - - @Override - public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - String input = extractText(ctx.getMessage()); - - // 1. 向 上层系统 回报任务已接收 (A2A 状态消息) - emitter.submit(buildStatusMessage(contextId, taskId, "任务已接收")); - emitter.startWork(buildStatusMessage(contextId, taskId, "正在处理")); - - try { - // 2. 委托编排管线执行完整的诊断流程 - String result = new WorkbenchOrchestrator( - orchUrl, credentialsPath, sslVerify, a2atEnvPath - ).run(input); - - // 3. 返回诊断汇总结果 (A2A Artifact) - List> parts = List.of(new TextPart("跨城故障协同诊断汇总结果")); - emitter.addArtifact(parts, "result", "cross-city-diagnosis", null, false, true); - emitter.complete(buildStatusMessage(contextId, taskId, "完成")); - } catch (Exception e) { - emitter.fail(buildStatusMessage(contextId, taskId, "失败: " + e.getMessage())); - } - } - - @Override - public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { - emitter.cancel(); - } - - // --- 工具方法 --- - private static String extractText(Message message) { - StringBuilder sb = new StringBuilder(); - for (Part part : message.parts()) { - if (part instanceof TextPart tp) sb.append(tp.text()); - } - return sb.toString(); - } - - private static Message buildStatusMessage(String contextId, String taskId, String text) { - return Message.builder() - .messageId(UUID.randomUUID().toString()) - .contextId(contextId) - .taskId(taskId) - .role(Message.Role.ROLE_AGENT) - .parts(new TextPart(text)) - .build(); - } -} -``` - -`AgentEmitter` 的调用时序: `submit` (已接收) -> `startWork` (处理中) -> `addArtifact` (结果) -> `complete` (完成)。这一时序是 A2A 协议的标准消息交互,上层故障系统据此感知任务进度。 - -如果使用 Spring Boot starter,只需将此类标注 `@Component`,starter 自动注册到 A2A 服务: - -```java -@Component -public class TransportWorkbenchExecutor implements AgentExecutor { ... } -``` +可编译的完整实现见 samples 的 SpringWorkbenchExecutor、WorkbenchTaskInputParser、 +WorkbenchOrchestrator;Spring starter 只提供协议入口与生命周期,不替宿主决定业务或模板。 +入口取消应停止对应本地执行并清理宿主资源;远端已提交任务需按业务策略显式取消。 +不要把错误或空结果伪装成成功诊断。 ### 步骤 4: 实现 ControlPoint -`ControlPoint` 是工作台实现的核心决策接口。SDK 在工作流执行过程中调用这四个方法,工作台决定"做什么",SDK 负责"怎么发"。 - ```java -public interface ControlPoint { - CompletableFuture onTask(TaskRequest request, WorkflowEngineClient engineClient); - default CompletableFuture onSelfTask(TaskRequest request) { ... } - CompletableFuture onRoute(String stepName, Map results, List conditions); - default CompletableFuture onNegotiation(String agentName, String negotiationText, Map receiveResult) { ... } +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -可以继承 `DefaultControlPoint` (已实现 onSelfTask、onNegotiation 默认逻辑),只重写需要的方法。下面逐个说明。 - -#### 4.1 onTask -- 下发诊断任务 - -SDK 在工作流走到一个需要向下游 Agent 发送任务的步骤时调用此方法。工作台决定发送什么内容给哪个 Agent。 - -`request` 包含: `agentName` (目标 Agent 名称)、`stepName` (当前步骤名)、`message` (SDK 组装的完整消息,含上游上下文)。 - -关键设计: 给每个 OMC 发送的任务只包含该 OMC 相关的参数,不混入其他地市的信息。示例中 `buildTargetedTaskMessage` 依据步骤名构造针对性消息: - -```java -public class WorkbenchControlPoint extends DefaultControlPoint { - - @Override - public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - String step = request.getStepName(); - String agentName = request.getAgentName(); - - // 依据步骤构造针对性任务消息 (只含该 OMC 相关参数) - String targetedMessage = buildTargetedTaskMessage(step); - - return engineClient.sendMessage(agentName, targetedMessage) - .thenApply(r -> { - boolean success = r.getText() != null && !r.getText().isEmpty(); - return TaskResponse.builder() - .success(success) - .output(r.getText()) - .build(); - }) - .exceptionally(e -> TaskResponse.builder() - .success(false) - .error("Agent 调用失败: " + e.getMessage()) - .build()); - } - - private static String buildTargetedTaskMessage(String step) { - return switch (step) { - case "diagnosis_city1" -> """ - ## 任务 - SPN 专线故障诊断 - 城市1 OMC 侧 - - ## 故障参数 - - 端口状态: port-7 = DOWN - - 光功率: -28dBm (阈值 -20dBm) - - 所属单板: line-card-03 - - 端口号: port-7 - - 请针对城市1 OMC 侧端口故障进行诊断。"""; - case "diagnosis_city2" -> """ - ## 任务 - SPN 专线故障诊断 - 城市2 OMC 侧 - - ## 排查要求 - - 排查城市2 OMC 端口状态是否正常 - - 确认光功率是否在正常范围 - - 确认是否存在异常告警 - - 请针对城市2 OMC 侧进行排查。"""; - default -> "请执行 SPN 专线故障诊断任务。"; - }; - } -} -``` - -`engineClient.sendMessage(agentName, message)` 内部自动完成: Task-T 提示词生成 -> Bearer 认证 -> SSE 流式发送 -> 事件提取 -> Negotiation-T 协商循环。工作台只需调用一行。 - -#### 4.2 onSelfTask -- 本地汇总 (自环节点) - -当工作流步骤标记为 `SELF_LOOP` 时,SDK 调用此方法。这意味着该步骤由工作台本地处理,不向下游发送 A2A-T 消息。典型用途: 汇总多个 OMC 的诊断结果。 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 +onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 +未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 ```java -@Override -public CompletableFuture onSelfTask(TaskRequest request) { - String message = request.getMessage(); - // 工作台本地汇总逻辑 (可调用 LLM 做故障定位结论) - String fallback = "汇总分析完成。故障定位: 城市1 OMC,端口 Down,光功率 -28dBm 低于阈值。"; - String sys = "你是 SPN 跨城故障协同诊断汇总专家。根据两地市 OMC 诊断结果,输出故障定位结论。"; - String result = LlmHelper.text(a2atEnvPath, sys, message, fallback); - return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output(result).build()); -} +ControlPoint callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .onSelfTask(request -> CompletableFuture.completedFuture( + TaskResult.success(List.of(Map.of( + "sourceResults", request.getWorkflowInput().upstreamResults()))))) + .onRoute(request -> CompletableFuture.failedFuture( + new IllegalStateException("Supply a routing policy for " + request.stepName()))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Manual confirmation required"))) + .build(); ``` -`DefaultControlPoint` 的默认实现是将 `request.message` 原样返回。如果不需要 LLM 汇总,可以不重写此方法。 - -#### 4.3 onRoute -- 分支路由 - -当工作流步骤有条件分支时 (即 `next` 列表中 `JumpCondition` 带有非空 `condition`),SDK 调用此方法。工作台根据已有结果决定走哪个分支。 - -返回 `RouteDecision`,其 `nextStep` 必须是 `conditions` 中声明的某个步骤名: - -```java -@Override -public CompletableFuture onRoute( - String stepName, Map results, List conditions) { - // 简单场景: 取第一个非终止分支 - String nextStep = conditions.get(0).getStep(); - for (JumpCondition jc : conditions) { - if (!"end".equals(jc.getStep()) && !"retry".equals(jc.getStep())) { - nextStep = jc.getStep(); - break; - } - } - return CompletableFuture.completedFuture( - RouteDecision.builder().nextStep(nextStep).reason("默认路由").build()); -} -``` - -路由规则说明: -- 步骤没有 `next` -> 终止步骤,工作流结束。 -- `next` 中所有 `condition` 为空 -> 无条件扇出: 并行调度所有下游步骤。 -- `next` 中有 `condition` -> 条件分支: 调用 `onRoute`,返回单个目标步骤 (N 选 1)。 - -复杂场景可使用 LLM 决策路由 (参考编排中心 `DynamicWorkflowEngine._llm_route_decision` 的实现)。 - -#### 4.4 onNegotiation -- 协商澄清 - -当 OMC 返回 `INPUT_REQUIRED` (Negotiation-T) 时,SDK 自动进入协商循环。SDK 调用此方法获取澄清文本,然后自动重发给 OMC。工作台只需返回澄清内容,不需要自行发送消息。 - -触发条件: OMC 发现任务参数有误或缺失 (必选参数缺失、取值不在枚举范围、语义校验错误等)。 - -```java -@Override -public CompletableFuture onNegotiation( - String agentName, String negotiationText, Map receiveResult) { - // negotiationText: OMC 的协商请求内容 (明确指出缺失/错误的参数及修正建议) - // 返回: 工作台补全或修正后的澄清文本 - - String clarification = "城市1-城市2间 SPN 专线中断," - + "城市1 OMC 告警端口 Down,光功率 -28dBm。" - + "请根据此上下文修正参数后重新执行诊断。"; - return CompletableFuture.completedFuture(clarification); -} -``` - -SDK 自动将返回的澄清文本封装为 Negotiation-T follow-up 消息重发给 OMC,最多重试 3 轮 (可配置)。如果 OMC 在 3 轮内收敛 (返回正常结果),工作流继续。如果未收敛,`sendMessage` 返回的 `SendMessageResult` 中 `taskState` 仍为 `INPUT_REQUIRED`。 - -协商循环时序: - -```mermaid -sequenceDiagram - participant WB as 工作台 - participant SDK as SDK 引擎客户端 - participant OMC as OMC - - WB->>SDK: onTask -> sendMessage - SDK->>OMC: Task-T 下发诊断任务 - OMC-->>SDK: INPUT_REQUIRED (Negotiation-T) - SDK->>WB: onNegotiation() - WB-->>SDK: 返回澄清文本 - SDK->>OMC: follow-up 重发 (含澄清) - OMC-->>SDK: 诊断结果 (COMPLETED) - SDK-->>WB: TaskResponse -``` +生产 Task-T 生成及协商示例见 WorkbenchControlPoint / examples.negotiation.NegotiationStrategy;上述仅是普通 A2A 内容示例。 ### 步骤 5: 组装编排管线 -这一步将所有组件串联起来。工作台实现一个编排类,在 `AgentExecutor.execute` 被调用时运行完整的诊断流程。 - ```java -public class WorkbenchOrchestrator { - - private final String orchUrl; - private final String credentialsPath; - private final boolean sslVerify; - private final String a2atEnvPath; - - public String run(String messageText) throws Exception { - // 1. 加载 AgentCard (工作台自身 + 下游 OMC) - List agentCards = loadAgentCards(); - - // 2. 搜索 + 加载 PSOP 工作流 (从编排中心) - String psopId = searchPsop(messageText); - Workflow workflow = LoadPsop.load(orchUrl, psopId, null, sslVerify); - - // 3. 创建共享传输层 + 两个门面 - A2ATransport transport = new A2ATransport( - agentCards, - null, - WorkflowEngineClientConfig.builder() - .sslVerify(sslVerify) - .a2atEnvPath(a2atEnvPath) - .credentialsConfigPath(credentialsPath) - // 如需自定义认证 (企业 SSO 等),取消注释: - // .authProvider((agentName, card, headers) -> { - // headers.put("Authorization", "Bearer " + ssoClient.getToken(agentName)); - // }) - .build()); - WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport); - ExtensionSender extensionSender = new DefaultExtensionSender(transport); - - // 4. 前置预定位: 向每个 OMC 下发授权策略 + 订阅通知 - prePositionExtensions(extensionSender, agentCards); - - // 5. 执行工作流 - WorkbenchControlPoint controlPoint = - new WorkbenchControlPoint(a2atEnvPath, new NegotiationStrategy(a2atEnvPath)); - ExecutionResult result = ExecutePsop.builder() - .psop(workflow) - .agentCards(agentCards) - .controlPoint(controlPoint) - .engineClient(engineClient) - .runtimeIntent(messageText) - .lang("zh") - .sslVerify(sslVerify) - .credentialsConfigPath(credentialsPath) - .a2atEnvPath(a2atEnvPath) - .eventCallback(createLogCallback()) - .onFinish((r, events) -> { - // 持久化执行记录 (可选) - log.info("工作流完成: success={}, 事件数={}", r.isSuccess(), events.size()); - return CompletableFuture.completedFuture(null); - }) - .execute() - .join(); - - return buildResultText(result); - } +WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() + .sslVerify(true) + .credentialsConfigPath(credentialsPath) // 内置认证;自定义认证时改为 authProvider 并不配置它 + .credentialEncryptionKey(credentialKey) // 宿主密钥;明文配置可不提供 + .maxNegotiationExchanges(3) + .build(); +try (A2ATransport transport = new A2ATransport(cards, runtime, config)) { + WorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); + ExecutionResult result = ExecutePsop.builder() + .psop(workflow).agentCards(cards).controlPoint(callbacks) + .engineClient(client).runtimeIntent(intent).execute().join(); } ``` -#### 5.1 前置预定位详解 +workflow、cards、callbacks、intent 由宿主取得或构造。 +runtime 为 null 时使用默认直连实现;dev 的东信 runtime 只替换传输,不替换业务回调。 +上面 transport 仅属于这次工作流;授权和通知使用另外的实例。 +宿主自己的 A2ATClient 可在回调中复用,但要确保业务状态按 execution/task 隔离。 -步骤 4 的前置预定位是本场景的关键设计。授权和订阅是单次消息调用,与工作流执行分离: +#### 5.1 前置预定位详解 ```java -private void prePositionExtensions(ExtensionSender sender, List agentCards) { - for (AgentCard card : agentCards) { - String name = card.name(); - if (name.contains("Workbench")) continue; // 跳过工作台自身 - - // 授权预定位: 告知 OMC 允许自动执行的业务抢通操作 - String authInput = "任务类型新增授权,操作名称业务抢通," - + "操作类型光模块更换,操作对象 SPN 专线业务," - + "溢权策略 OMC 自动执行," - + "触发执行条件业务投诉诊断确认故障," - + "预期输出返回是否成功。"; - sender.sendAuthorization(name, "下发授权放行策略", authInput).join(); - - // 订阅预定位: 向 OMC 订阅业务抢通结果通知 - String notifInput = "通知主题为 service-recovery-execution-result," - + "订阅条件为业务抢通方案执行结果," - + "上报通知数据格式为 TextPart。"; - sender.sendNotification(name, "订阅业务抢通结果通知", notifInput, event -> { - // SSE 长连接回调: OMC 后续推送的抢通结果通过此通道接收 - Object text = event.get("text"); - Object state = event.get("state"); - log.info("[抢通结果] agent={}, state={}, text={} chars", - event.get("agent"), state, - text != null ? String.valueOf(text).length() : 0); - if (text != null) { - // 生产环境: 写入数据库 / 推送前端 / 触发后续流程 - log.info("[抢通结果] {}: {}", event.get("agent"), text); - } - }).join(); - } -} +// authorizationSender 与 notificationSender 各自使用独立 transport/runtime。 +MessageContent authContent = A2atMessages.from( + sdk.generateAuthPromptFromDataWithSchema(authData, authSchema, + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT.uri()), + List.of(new TextPart("新增动网操作授权"))); +authorizationSender.sendAuthorization(agentName, authContent) + .whenComplete((result, error) -> recordIndependentAuthorization(result, error)); + +MessageContent notificationContent = A2atMessages.from( + sdk.generateNotificationPromptFromDataWithSchema(notifData, notifSchema, + StandardTemplates.SERVICE_RECOVERY.uri()), + List.of(new TextPart("订阅业务抢通事件"))); +NotificationSubscription subscription = notificationSender.openNotification( + agentName, notificationContent, (handle, received) -> { + if (received.artifacts().stream().anyMatch(a -> "recovery-result".equals(a.name()))) { + handle.close(); + } + }); +// 将 handle 加入宿主订阅集合;ACK/失败都仅影响这个独立操作。 +// 监听器使用传入的 handle,不依赖此时外部 subscription 变量是否赋值。 +subscription.acknowledgement().whenComplete((ack, error) -> recordIndependentSubscription(ack, error)); ``` -- `sendAuthorization`: 向 OMC 下发授权白名单。OMC 在后续诊断中如果命中已授权操作 (如光模块更换),自动执行抢通。 -- `sendNotification`: 建立 SSE 订阅通道。第四个参数 `Consumer>` 是长连接 SSE 回调,OMC 后续推送的抢通结果 (recovery result) 通过此回调实时接收。回调收到的 Map 包含 `agent`、`text`、`metadata`、`state` 等字段。不传回调 (null) 时后续事件被丢弃。 - -这两个方法返回 `CompletableFuture`,`.join()` 阻塞等待首个事件 (订阅确认) 完成。SDK 内部自动处理 Authorization-T / Notification-T 报文构造和 Bearer 认证。对于 `sendNotification`,`.join()` 返回后 SSE 长连接仍然存活,后续事件持续推送到回调,直到工作台主动关闭 transport。 +sdk、业务数据、schema、recordIndependent* 均由宿主提供,不是引擎隐式服务。 +内容生成失败也只记录本次独立操作失败,不能阻止任务 DAG。 +close() 幂等请求关闭;completion() 等待真实流退出。工作流完成不能关闭独立订阅。 +实际生命周期与异常处理见 samples 的 ExtensionPrePositioner / WorkbenchExtensionLifecycle。 #### 5.2 PSOP 工作流搜索与加载 @@ -697,7 +459,9 @@ String psopId = results.get(0).getWorkflowId(); Workflow workflow = LoadPsop.load(orchUrl, psopId, null, sslVerify); ``` -如果搜索失败或编排中心不可用,应提供 fallback 工作流 ID 或本地加载。 +生产环境应明确编排中心不可用时的业务失败策略,不能默认执行可能过期的本地流程。 +`SpringSpnDemo` 为离线演示提供本地 PSOP fallback;接入方只有在完成版本一致性、审计和变更治理后, +才应自行设计受控的本地兜底。 #### 5.3 EventCallback -- 事件追踪 @@ -714,11 +478,11 @@ private EventCallback createLogCallback() { case EventType.AGENT_REQUEST -> log.info("[发送] -> {}", data.get("agent")); case EventType.AGENT_RESPONSE -> log.info("[接收] <- {}", data.get("agent")); case EventType.NEGOTIATION_REQUEST -> - log.info("[协商请求] agent={}, 第{}轮", data.get("agent"), data.get("round")); + log.info("[协商请求] agent={}, 第{}次交互", data.get("agent"), data.get("exchange")); case EventType.NEGOTIATION_RESOLVED -> log.info("[协商完成] agent={}", data.get("agent")); // 以下事件类型为预留,当前 SDK 版本不会触发 - // (Authorization-T / Notification-T ExtensionHandler 未注册) + // (独立操作不进入工作流事件流) case EventType.AUTHORIZATION_REQUEST -> log.info("[授权请求] agent={}", data.get("agent")); case EventType.NOTIFICATION -> @@ -737,133 +501,94 @@ private EventCallback createLogCallback() { 事件类型完整列表见 SDK 的 `EventType` 常量类。关键事件: `start` -> `step_start` -> `agent_request` / `agent_response` (含 `negotiation_*`) -> `step_complete` -> `complete` / `error` -> `close`。 -> **注意:** `authorization_request` 和 `notification` 事件类型在当前 SDK 版本中不会被触发 (Authorization-T / Notification-T 的 ExtensionHandler 未注册到 ExtensionRegistry)。这些事件类型为未来扩展预留。前置预定位阶段的授权/订阅响应通过 `ExtensionSender` 的返回值 (`SendMessageResult`) 获取,不经过事件流。 +> **注意:** `authorization_request` 和 `notification` 事件类型在当前 SDK 版本中不会被触发 (独立操作不进入工作流事件流)。这些事件类型为未来扩展预留。独立授权通过 SendMessageResult,订阅通过 handle 的 ACK 和监听器取得响应,不经过事件流。 --- ## 4. 完整调用时序 -以下是工作台收到 上层系统 下发诊断任务后的完整端到端时序。 - -### 4.1 关键设计: 预定位与工作流执行分离 - -SDK 提供两个独立的门面 (facade),职责隔离: - -| 门面 | 职责 | A2A-T 扩展 | 调用时机 | -|------|------|-----------|---------| -| `ExtensionSender` | 前置预定位 | Authorization-T, Notification-T | 工作流启动前,工作台手动调用 | -| `WorkflowEngineClient` | 工作流执行 | Task-T, Negotiation-T | 工作流执行中,由 `ExecutePsop` 内部调度 | - -**`ExecutePsop` 不做任何预定位。** 它只负责 DAG 遍历、调用 `ControlPoint` 决策回调、通过 `WorkflowEngineClient` 发送 Task-T 消息、处理 Negotiation-T 协商循环。预定位是工作台编排类的职责,必须在调用 `ExecutePsop` 之前完成。 - -两个门面共享同一个 `A2ATransport` (HTTP 连接池、认证管理器、AgentCard 注册表),避免重复初始化。 - -### 4.2 完整时序图 - ```mermaid sequenceDiagram - participant UPPER as 上层故障系统 - participant WB as 工作台 (WorkbenchOrchestrator) - participant ES as ExtensionSender (SDK 预定位门面) - participant EP as ExecutePsop (SDK 工作流运行器) - participant EC as WorkflowEngineClient (SDK 工作流发送门面) - participant OMC as OMC - - UPPER->>WB: A2A-T Task-T (原始诊断任务) - WB-->>UPPER: submit("任务已接收") - WB-->>UPPER: startWork("处理中") - - rect rgba(245, 245, 245, 1) - Note over WB: 工作台编排: 初始化 - Note over WB: 1. 加载 AgentCard (工作台 + OMC) - Note over WB: 2. 搜索/加载 PSOP (从编排中心) - Note over WB: 3. 创建 A2ATransport (共享) - Note over WB: 4. 创建 ExtensionSender + WorkflowEngineClient + participant H as Host callbacks + A2A-T client + participant E as Workflow engine + participant A as Remote agent + E->>H: onTask(TaskRequest + upstream window) + H->>H: Generate/validate final content + H-->>E: MessageContent + E->>A: A2A envelope + unchanged content + opt INPUT_REQUIRED with valid Propose + A-->>E: Task status + Negotiation-T Propose + E->>H: onNegotiation(originalSubmission, received, history) + H->>H: Validate proposal; generate reply + H-->>E: Send(MessageContent) or local Stop + E->>A: Same task/context; final reply content end + A-->>E: Task result / artifacts + E->>H: onSelfTask(selected complete upstream results) + H-->>E: TaskResult +``` - rect rgba(240, 248, 255, 1) - Note over WB,OMC: 工作台编排: 前置预定位 (手动调用 ExtensionSender) - WB->>ES: sendAuthorization(OMC, 授权策略) - ES->>OMC: Authorization-T (授权白名单) - OMC-->>ES: 授权确认 - ES-->>WB: SendMessageResult - - WB->>ES: sendNotification(OMC, 订阅主题, Consumer回调) - ES->>OMC: Notification-T (订阅请求) - OMC-->>ES: 订阅确认 - ES-->>WB: SendMessageResult (SSE 长连接已建立) - end +```mermaid +sequenceDiagram + participant H as Host + A2A-T client + participant ES as Independent ExtensionSender + participant A as OMC + H->>H: Generate final authorization content + H->>ES: sendAuthorization(agent, content) + ES->>A: One-shot authorization + A-->>H: Independent result, never gates workflow + H->>H: Generate final subscription content + H->>ES: openNotification(agent, content, listener) + ES-->>H: Registered handle + ES->>A: Independent long-lived stream + A-->>H: ACK via acknowledgement() + A-->>H: listener(handle, ReceivedMessage) + H->>ES: handle.close() on recovery/cancel/shutdown + ES-->>H: completion() after stream exits +``` - rect rgba(255, 248, 240, 1) - Note over WB,OMC: 工作台编排: 执行工作流 (调用 ExecutePsop) - WB->>EP: ExecutePsop.builder().execute() - - Note over EP: emit: start - Note over EP: WorkflowExecutor DAG 遍历 - - Note over EP: step_start: diagnosis_city1 - EP->>WB: ControlPoint.onTask(request, engineClient) - WB->>EC: engineClient.sendMessage(OMC, 诊断任务) - EC->>OMC: Task-T (诊断请求) - OMC-->>EC: 诊断结果 (或 INPUT_REQUIRED) - Note over EC,OMC: (若 INPUT_REQUIRED: 自动 Negotiation-T 协商循环) - EC-->>WB: SendMessageResult - WB-->>EP: TaskResponse - Note over EP: step_complete: diagnosis_city1 - - Note over EP: step_start: diagnosis_city2 (并行) - EP->>WB: ControlPoint.onTask(request, engineClient) - WB->>EC: engineClient.sendMessage(OMC, 诊断任务) - EC->>OMC: Task-T (诊断请求) - OMC-->>EC: 诊断结果 - EC-->>WB: SendMessageResult - WB-->>EP: TaskResponse - Note over EP: step_complete: diagnosis_city2 - - Note over EP: step_start: merge (SELF_LOOP) - EP->>WB: ControlPoint.onSelfTask(request) - Note over WB: 本地 LLM 汇总 (无 A2A-T 发送) - WB-->>EP: TaskResponse - Note over EP: step_complete: merge - - Note over EP: emit: complete - EP->>WB: onFinish(result, events) 持久化 - EP-->>WB: ExecutionResult - end +只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 +终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 +宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 +通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; +结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 - WB-->>UPPER: addArtifact(诊断汇总结果) - WB-->>UPPER: complete("完成") +返回 `new NegotiationReply.Send(content)` 发送最终内容; +返回 `new NegotiationReply.Stop(code, reason)` 只在本地停止,不生成 Abort。 +同一任务/会话/轮次的重复等待事件不会重复回调、重复提交;未变化状态通过 getTask 观察。 +`maxNegotiationExchanges` 默认 3,是独立于 SDK context.maxRounds 的本地交互资源预算。 +超时、预算耗尽、回调缺失均明确失败,不默认 Accept,也不自动生成 Abort。 +Accept/Reject 的 SUBMITTED/WORKING ACK 仍需等待任务结果,不重发原命令。 +业务发送 Abort 后,即使远端用 COMPLETED 确认,也不能判为诊断成功。 - rect rgba(240, 255, 240, 1) - Note over ES,OMC: 后续: OMC 自动抢通并通过已建立的 SSE 通道上报 - OMC-->>ES: Notification-T SSE (抢通结果推送) - ES-->>WB: Consumer 回调 (预定位时注册的回调) - end -``` +### 查看真实协商与协议日志 -### 4.3 时序说明 +直接运行本地 SpringSpnDemo,默认 City1 缺参并协商、City2 参数完整直接诊断,无需添加 VM 参数。 +Negotiation-T 扩展激活本身不强制协商;这是本地 Demo 专门设置的场景,不是引擎默认行为。 +要关闭本地缺参演示、让两城市都直接诊断,在 IDEA **VM options** 增加: -**初始化阶段 (工作台代码):** -- 步骤 1-4 是工作台 `WorkbenchOrchestrator.run()` 方法的代码,不是 SDK 自动完成的 -- SDK 只提供组件类 (`A2ATransport`, `ExtensionSender`, `WorkflowEngineClient`),工作台负责创建和组装 +```text +-Da2at.samples.negotiation=false +``` -**预定位阶段 (工作台调用 ExtensionSender):** -- 工作台遍历下游 OMC AgentCard,逐个调用 `sendAuthorization` 和 `sendNotification` -- 这是单次消息调用 (one-shot),不进入工作流 DAG -- `sendNotification` 的 `Consumer` 回调注册后,SSE 长连接持续存活,后续 OMC 推送的抢通结果通过此回调接收 +默认仅移除 City1 的 Task-T 任务对象;启用状态下增加 `-Da2at.samples.negotiation.city=city2` 或 `both` +可演示 City2 或两城市同时缺参。宿主仍保留各城市的正确输入,原投诉上下文不变。预期链路是 `DEMO_NEGOTIATION` → `INPUT_REQUIRED / PROPOSE` +→ 工作台 onNegotiation → `ACCEPT` → 两城市完成 → 一次汇总。 +非内嵌 OMC 模式默认不注入缺参,显式设置 `-Da2at.samples.negotiation=true` 也会被拒绝。 +Demo 将开关传入当前 Spring 应用实例,不设置或修改 JVM 全局开关,不影响其他宿主。 +协议日志在控制台及以运行目录为基准的 `logs/spn-demo.log`。 +main 支持直连;dev 默认 order,两种模式使用同一业务回调。 -**工作流执行阶段 (工作台调用 ExecutePsop):** -- `ExecutePsop` 内部创建 `WorkflowExecutor`,执行 DAG 遍历 -- 每个步骤: `WorkflowExecutor` 调用 `ControlPoint.onTask()`,工作台决定发什么内容 -- 工作台在 `onTask` 中调用 `engineClient.sendMessage()`,SDK 自动处理 Task-T 提示词生成 + Bearer 认证 + SSE 流式发送 + Negotiation-T 协商循环 -- `SELF_LOOP` 步骤调用 `onSelfTask()`,工作台本地处理,不发送 A2A-T 消息 +离线重复验证(两个命令顺序执行,避免端口冲突): -**后续通知阶段 (SSE 长连接):** -- OMC 在诊断后自动执行抢通操作 (如光模块更换) -- 抢通结果通过预定位阶段建立的 SSE 长连接推送 -- 回调在 `sendNotification` 时注册,与 `ExecutePsop` 无关 +```powershell +mvn -q -pl samples -am "-Dtest=SpringSpnDemoE2ETest" "-Dsurefire.failIfNoSpecifiedTests=false" test +# 仅 dev:真实供应商 jar + 本地平台/OMC 模拟器 +mvn -q -pl samples -am "-Dtest=SpringSpnDemoOrderE2ETest" "-Dsurefire.failIfNoSpecifiedTests=false" test +``` ---- +每个测试类覆盖无 VM 参数的默认单城市协商、显式关闭、显式开启及两城市同时缺参。测试使用离线 LLM provider,但实际运行当前 SDK 的 +模板、校验和 HTTP/SSE;不是现网 OMC、平台或真实模型语义的验证。 ## 5. 工作台需交付物清单 @@ -886,14 +611,17 @@ sequenceDiagram 生产环境 OMC 通常使用自签名证书。`WorkflowEngineClientConfig` 中: - `sslVerify(true)`: 校验证书 (需配置 CA 信任库 `caCertsPath`) -- `sslVerify(false)`: 跳过校验 (仅开发环境) +- `sslVerify(false)`: 仅跳过证书链校验,主机名校验仍启用;只用于受控的本地诊断 + (HTTP/JSON-RPC 会继续加载 mTLS 客户端证书;默认 gRPC 使用 plaintext,不能同时配置 mTLS/CRL) ```java WorkflowEngineClientConfig.builder() .sslVerify(true) - .caCertsPath("/path/to/ca-truststore.jks") + .caCertsPath("/path/to/ca-certs.pem") + // OMC 要求双向 TLS 时配置: + .clientCertPath("/path/to/client-cert.pem") + .clientKeyPath("/path/to/client-key.pem") .credentialsConfigPath(credentialsPath) - .a2atEnvPath(a2atEnvPath) .build() ``` @@ -917,67 +645,49 @@ public class SsoAuthProvider implements AuthProvider { ### 6.3 协商轮次限制 -默认协商最多 3 轮。如果 OMC 的协商场景复杂,可在创建 `WorkflowEngineClient` 时调整: - -```java -// Java: DefaultWorkflowEngineClient 构造时传入 -WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport, null, null, 5); -``` +WorkflowEngineClientConfig.builder().maxNegotiationExchanges(3) 配置本地交互预算, +与收到的 SDK context.maxRounds 分开。耗尽/超时只本地失败,不自动生成 Abort。 +结束回复保留收到的 id/round/maxRounds,最后一轮仍可回答。 ### 6.4 SSE 长连接管理 -`sendNotification` 建立的 SSE 订阅通道是长连接。SDK 的 `A2ATransport` 管理连接池和超时。工作台应在服务关闭时调用 `transport.close()` 释放连接。`ExecutePsop` 在工作流结束后自动关闭 `engineClient`,但 `ExtensionSender` 的连接需要手动管理 (或共享同一个 transport,随 engineClient 一起关闭)。 +`sendNotification` 建立的 SSE 订阅通道是长连接。SDK 的 `A2ATransport` 管理连接池和超时。工作台应在服务关闭时调用 `extensionTransport.close()` 释放连接。`ExecutePsop` 在工作流结束后自动关闭 `engineClient` 及其任务级 transport;不要把这个 transport 与 `ExtensionSender` 共用,否则 Notification-T 长连接会在工作流结束时被关闭。 ### 6.5 并发与并行 -SDK 的 `WorkflowExecutor` 自动并行调度同一层的步骤 (无依赖关系的步骤并发执行)。步骤内的子任务也并行执行。工作台无需自行管理并发。如果 OMC 有并发限制,通过工作流定义中的步骤分层 (layer) 控制执行顺序。 +SDK 的 `WorkflowExecutor` 自动并行调度同一层的步骤 (无依赖关系的步骤并发执行)。步骤内的子任务也并行执行。工作台无需自行管理并发。如果 OMC 有并发限制,通过 DAG 依赖边或宿主限流控制;layer 不是执行顺序约束。 ### 6.6 错误处理 -`SendMessageResult` 包含 `taskState` 字段。关键状态: -- `COMPLETED`: 任务正常完成 -- `INPUT_REQUIRED`: 需要协商 (SDK 自动处理,超过轮次后返回此状态) -- `FAILED`: 任务失败 +引擎处理远端协议状态和结果解包;onTask 只返回 MessageContent。onSelfTask 返回 TaskResult,失败原因与 outputs 分离。异常和失败 future 由引擎记录;不要把未发送成功包装成成功输出。详见 [业务回调契约](BUSINESS_CALLBACKS.md)。 -工作台在 `onTask` 中应检查 `result.getText()` 是否为空来判断成功: +## 7. 接口速查表 ```java -.thenApply(r -> TaskResponse.builder() - .success(r.getText() != null && !r.getText().isEmpty()) - .output(r.getText()) - .build()) +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); +} ``` ---- - -## 7. 接口速查表 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 +onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 +未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 -### 工作台实现的接口 - -| 接口 | 方法 | 签名 (Java) | 何时调用 | -|------|------|-------------|---------| -| `AgentExecutor` | `execute` | `void execute(RequestContext ctx, AgentEmitter emitter)` | 上层系统 下发任务时 | -| `AgentExecutor` | `cancel` | `void cancel(RequestContext ctx, AgentEmitter emitter)` | 上层系统 取消任务时 | -| `ControlPoint` | `onTask` | `CompletableFuture onTask(TaskRequest, WorkflowEngineClient)` | 步骤需要向下游发任务 | -| `ControlPoint` | `onSelfTask` | `CompletableFuture onSelfTask(TaskRequest)` | 自环节点本地处理 | -| `ControlPoint` | `onRoute` | `CompletableFuture onRoute(String, Map, List)` | 条件分支选择 | -| `ControlPoint` | `onNegotiation` | `CompletableFuture onNegotiation(String, String, Map)` | OMC 返回 INPUT_REQUIRED | -| `AuthProvider` | `applyAuth` | `void applyAuth(String agentName, AgentCard agentCard, Map headers)` | 每次消息发送前注入认证头 (可选) | - -### SDK 提供的接口 - -| 接口 | 方法 | 签名 (Java) | 用途 | -|------|------|-------------|------| -| `WorkflowEngineClient` | `sendMessage` | `CompletableFuture sendMessage(String agent, String message)` | 工作流中向下游发消息 | -| `ExtensionSender` | `sendAuthorization` | `CompletableFuture sendAuthorization(String agent, String instruction, String input)` | 前置授权预定位 | -| `ExtensionSender` | `sendNotification` | `CompletableFuture sendNotification(String agent, String instruction, String input)` | 前置订阅预定位 (无回调) | -| `ExtensionSender` | `sendNotification` | `CompletableFuture sendNotification(String agent, String instruction, String input, Consumer> callback)` | 前置订阅预定位 (接收 SSE 抢通结果回调) | -| `ExecutePsop` | `execute` | `CompletableFuture execute()` (Builder 模式) | 执行完整工作流 | -| `LoadPsop` | `search` | `List search(String url, String intent, int topK, ...)` | 语义搜索工作流 | -| `LoadPsop` | `load` | `Workflow load(String url, String psopId, ...)` | 加载工作流定义 | -| `AuthProvider` | `applyAuth` | `void applyAuth(String, AgentCard, Map)` | 自定义认证头注入 (接口,工作台实现) | +```java +CompletableFuture sendAuthorization(String agentName, MessageContent content); +NotificationSubscription openNotification(String agentName, MessageContent content, + BiConsumer listener); +``` ---- +- ExecutePsop.builder().execute():执行完整工作流。 +- WorkflowEngineClient.sendMessage(String, MessageContent):DAG 外发送最终内容;DAG 内由执行器调用 dispatch。 +- LoadPsop.search/load、RegistryClient.fetchAgentCards:可选发现辅助。 +- AuthProvider.applyAuth(String, AgentCard, Map):宿主认证,不接收引擎强制规定的凭据文件结构。 +- A2atMessages.from / contextOf:metadata 转换与规范上下文辅助,不是内容 SDK 门面。 ## 8. 与 Demo 的关键区别 @@ -992,5 +702,61 @@ SDK 的 `WorkflowExecutor` 自动并行调度同一层的步骤 (无依赖关系 | TLS | sslVerify=false | 生产证书校验 | | PSOP | fallback 固定 ID | 编排中心语义搜索 + 动态加载 | | 事件 | 日志打印 | SSE 推送前端 + 持久化数据库 | -| 协商 | 固定澄清文本 | LLM 生成或业务规则引擎 | -| 抢通结果 | 不处理 | 通过 Notification-T SSE 通道接收并入库 | +| 协商 | 结构化规则示例(typed reply) | 宿主 SDK 生成最终回复,回调返回 Send 或 Stop | +| 抢通结果 | 通过独立订阅接收,收到结果关闭 | 通过 Notification-T SSE 通道接收并入库 | + +--- + +## 9. 集成自查清单 + +- Maven 是否解析到 `a2a-t-client:1.1.0`,且 jar 为 Central 发布制品而非本地同坐标重打包 +- AgentCard 是否包含 `name`、`description`、`version`、`capabilities`、`defaultInputModes`、`defaultOutputModes`、`skills`、`supportedInterfaces` +- 下游 AgentCard 的 `securitySchemes` / `securityRequirements` 是否按实际认证方式配置;AuthProvider-only 推荐 `securityRequirements: []` +- `AuthProvider` 是否已同时配置到前置预定位 transport 和工作流任务 transport +- 前置预定位 transport 是否为工作台级生命周期,工作流 transport 是否为任务级生命周期 +- onTask 是否仅准备 MessageContent,由引擎判断远端终态;本地 onSelfTask 是否明确返回业务成功或失败 +- `sendNotification` 是否传入回调,并在服务关闭时释放前置预定位 transport + +## 协议日志 + +将 `PROTOCOL` logger 设为 DEBUG,可查看实际传输边界的观测记录。 +HTTP/JSON-RPC 记录 A2A SDK 处理后的实际序列化正文和应用头; +A2A-Version 只在真实请求有该头时出现,不为日志展示补造 Header。 +gRPC 记录实际 metadata 和 protobuf 的 JSON 展示,不伪装成 HTTP JSON 报文。 +JDK 自动网络头、HTTP/2 帧、TLS 密文及服务端字节不在此观测范围。 + +dev 中 `ORDER_FORWARD_REQUEST` 表示交给东信 SDK 的请求, +`ORDER_SDK_RESPONSE` 表示 SDK 实际交付的状态、多值响应头和文本。 +`sdk-sse-text` 从 SDK 字符串分片组装可见 SSE 帧;原始字节编码及平台到 OMC 的报文不可见, +不能称为 OMC 抓包。`MODEL_PREVIEW` 只是高层预览,默认关闭,不是协议证据。 + +```properties +logger.protocol.name=PROTOCOL +logger.protocol.level=DEBUG +logger.protocol.additivity=true +WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY=true +WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS=100000 +``` + +DEBUG 开启时正文观测默认开启;敏感部署可显式禁用。JVM 同名属性优先于环境变量。 +认证头、Cookie、Token 及识别出的口令字段强制脱敏,不提供关闭脱敏的开关。 +这是字段级脱敏,不能自动识别所有个人信息和业务敏感内容,生产应另定日志策略。 +缓冲有界:原始收集器按该数值限制字节,输出文本按字符限制; +超限 SSE 帧整帧丢弃至下个分隔符并标记 dropped-capacity,禁用、截断、中断也有明确标记。 +UTF-8 分片先组装再解码,日志观察失败不改变报文投递;文件引用不会为打印日志而下载。 +requestId 关联单次调用;工作流调用还带 executionId/logicalTaskId/attempt、 +agent/contextId/channel,已知远端任务时附 remoteTaskId,均为本地日志字段,不污染协议 metadata。 + +### 协议日志 pretty 展示 + +协议日志默认以 pretty 形式展示:逐行打印头字段,缩进 JSON 正文。SSE 保留 id/event 等事件信息, +JSON 在 `=== SSE data (JSON display; not wire text) ===` 与 `=== End SSE data ===` 之间单独展示, +不再为展开后的每行 JSON 添加 `data:` 前缀。多个事件各自保留边界。 +`WORKFLOW_ENGINE_PROTOCOL_PRETTY=false`(环境变量或同名 JVM 属性)可保留脱敏后的原始正文展示。 +pretty 只影响显示(JSON 缩进及 SSE 数据区标记),不改实际发送字节、metadata、数值或扩展头; +原始观测正文仍保留,SSE pretty 展示不是可直接重放的抓包。 +JSON 字符串中的 `\\n` 保留转义,避免把业务字符串误写成非法 JSON;非 JSON/不完整正文保持原样。 +日志仍执行脱敏和容量限制。Demo 启动记录 `NEGOTIATION_DEMO enabled=..., city=...`; +本地默认 City1 协商、City2 直接诊断,`-Da2at.samples.negotiation=false` 关闭缺参演示。 +协商激活位于 `A2A-Extensions: .../Negotiation-T/v1`,不存在专用的 Negotiation-T HTTP 头; +正文检查对应 URI 的 metadata 和 `negotiationContext`,不要只检索大写 `NEGOTIATION-T`。 diff --git "a/docs/zh/\350\260\203\347\224\250\350\277\207\347\250\213.md" "b/docs/zh/\350\260\203\347\224\250\350\277\207\347\250\213.md" deleted file mode 100644 index 28e81823..00000000 --- "a/docs/zh/\350\260\203\347\224\250\350\277\207\347\250\213.md" +++ /dev/null @@ -1,92 +0,0 @@ -## 过程数据 - -发送给SPN Domain Agent的数据: - -```json -{ - "message": { - "messageId": "0e3ba00e-df2f-4432-9d54-29b916410171", - "contextId": "c3b58035-7196-4987-a7a8-65bba7ff7f25", - "role": "ROLE_AGENT", - "parts": [ - { - "text": "## Runtime Context\n\nUser's original intent and scenario description:\nSPN专线故障诊断,已知信息有:`接入端口名称:P28499-中新水厂(960)-2-D3EM8F-3 障发生时间:2026-06-17T14:20:51Z 故障流水号:zhoujie-qwert-3 故障场景:专线中断`\n\n## Current Task\n请针对当前问题描述进行SPN专线业务投诉诊断\n\n请用中文回复。" - } - ], - "metadata": { - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1": "## 任务类型(Task Type)\nSPN专线投诉诊断\n\n## 任务描述(Task Description)\n要求:提供任务的整体概述,基于<任务对象>、<任务上下文>、<约束条件>进行投诉场景的网络侧故障根因诊断,达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n## 任务目标(Task Target)\n要求:\n1. 基于专线业务的故障现象,诊断并返回网络侧的故障根因\n\n## 任务对象(Task Object)\n接入端口名称:P28499-中新水厂(960)-2-D3EM8F-3\n\n## 任务上下文(Task Context)\n故障现象:专线中断。故障发生时间:2026-06-17T14:20:51Z。故障流水号:adsr-4gds-534g-1234。\n\n## 预期输出(Expected Output)\n" - } - } -} -``` - -SPN Domain Agent回复任务已创建: - -```json -{ - "task": { - "id": "146b73b3-636a-435d-b9bd-45a201df92ec", - "contextId": "c3b58035-7196-4987-a7a8-65bba7ff7f25", - "status": { - "state": "TASK_STATE_SUBMITTED", - "message": { - "messageId": "9d78ed69-016c-4b9c-a255-7419e02a78d7", - "contextId": "c3b58035-7196-4987-a7a8-65bba7ff7f25", - "taskId": "146b73b3-636a-435d-b9bd-45a201df92ec", - "role": "ROLE_AGENT", - "parts": [ - { - "text": "业务投诉诊断任务创建成功,启动网络侧故障根因诊断分析。", - "metadata": {} - } - ], - "metadata": {} - }, - "timestamp": "2026-06-17T09:16:15.526127602Z" - }, - "history": [ - { - "messageId": "0e3ba00e-df2f-4432-9d54-29b916410171", - "contextId": "c3b58035-7196-4987-a7a8-65bba7ff7f25", - "taskId": "146b73b3-636a-435d-b9bd-45a201df92ec", - "role": "ROLE_AGENT", - "parts": [ - { - "text": "## Runtime Context\n\nUser's original intent and scenario description:\nSPN专线故障诊断,已知信息有:`接入端口名称:P28499-中新水厂(960)-2-D3EM8F-3 障发生时间:2026-06-17T14:20:51Z 故障流水号:zhoujie-qwert-3 故障场景:专线中断`\n\n## Current Task\n请针对当前问题描述进行SPN专线业务投诉诊断\n\n请用中文回复。", - "metadata": {} - } - ], - "metadata": { - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1": "## 任务类型(Task Type)\nSPN专线投诉诊断\n\n## 任务描述(Task Description)\n要求:提供任务的整体概述,基于<任务对象>、<任务上下文>、<约束条件>进行投诉场景的网络侧故障根因诊断,达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n## 任务目标(Task Target)\n要求:\n1. 基于专线业务的故障现象,诊断并返回网络侧的故障根因\n\n## 任务对象(Task Object)\n接入端口名称:P28499-中新水厂(960)-2-D3EM8F-3\n\n## 任务上下文(Task Context)\n故障现象:专线中断。故障发生时间:2026-06-17T14:20:51Z。故障流水号:zhoujie-qwert-3。\n\n## 预期输出(Expected Output)\n" - } - } - ], - "metadata": {} - } -} -``` - -SPN Domain Agent回复诊断结果: - -```json -{ - "artifactUpdate": { - "taskId": "146b73b3-636a-435d-b9bd-45a201df92ec", - "contextId": "c3b58035-7196-4987-a7a8-65bba7ff7f25", - "artifact": { - "artifactId": "ca6d90f9-5f67-4045-b14d-ac30fe58e6bd", - "name": "private-line-service-complain-diagnosis", - "parts": [ - { - "text": "业务投诉诊断任务诊断结果消息", - "metadata": {} - } - ], - "metadata": { - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1": "诊断结果为成功。\n诊断结果详情为:【摘要】P28499-中新水厂(960) 2板3 -1.1db 收光强光\n【详情】网元P28499-中新水厂(960) 端口2-D3EM8F-3(PORT-3)(输入光功率:-1.1,参考上门限:-3,参考下门限:-19;\n输出光功率:-6.1,参考上门限:-1.5,参考下门限:-12.5) 收光强光,原因是对端光模块故障导致发光异常、两侧光模块型号不一致、光衰设置不正确,本端发光正常。网元P28499-中新水厂(960) 端口2-D3EM8F-3(PORT-3)影响的业务为广州番禺南沙机楼 -广州增城中新水厂GE0143BS_LTE。\n故障根因1:系统判定根因对象为光模块硬件故障,对象标识为1522b06b-3e46-4041-99b9-2956168675c6,对象类型为:PRT,对象名称为:网元P28499-中新水厂(960) 端口2-D3EM8F-3(PORT-3),详细位置:网元P28499-中新水厂(960) 端口2-D3EM8F-3(PORT-3),故障根因详情:网元P28499-中新水厂(960) 端口2-D3EM8F-3(PORT-3)(输入光功率:-1.1,参考上门限:-3,参考下门限:-19;输出光功率:-6.1,参考上门限:-1.5,参考下门限:-12.5) 收光强光,原因是对端光模块故障导致发光异常、两侧光模块型号不一致、光衰设置不正确。 建议处理方式为:请上站检查或更换光模块。\n" - } - }, - "metadata": {} - } -} -``` \ No newline at end of file diff --git a/pom.xml b/pom.xml index 5cb24a24..0f5bb3cb 100644 --- a/pom.xml +++ b/pom.xml @@ -51,8 +51,11 @@ 1.0.0 17 UTF-8 - 1.0.0.Beta1 - 1.0.0 + 1.2.0.Final + + 4.35.0 + + 1.1.0 1.18.38 2.21.1 2.0.17 @@ -68,6 +71,13 @@ + + org.a2aproject.sdk + a2a-java-sdk-bom + ${a2a.java.sdk.version} + pom + import + com.fasterxml.jackson jackson-bom diff --git a/samples/DESIGN-NOTES.md b/samples/DESIGN-NOTES.md deleted file mode 100644 index a42d7e88..00000000 --- a/samples/DESIGN-NOTES.md +++ /dev/null @@ -1,131 +0,0 @@ -# workflow-exec-engine-java 架构改造要点 - -## 一、引擎职责边界 - -**引擎层 (`workflow-engine`) 只提供 workflow 执行调度能力,不包含任何 Agent server 代码。** - -- 引擎依赖只保留 a2a-java SDK 客户端侧(`a2a-java-sdk-client`、`a2a-java-sdk-client-transport-rest`) -- 不依赖服务端侧(`a2a-java-sdk-server-common`、`a2a-java-sdk-transport-rest`) -- 之前错误地放在引擎里的 `WorkbenchAgentServer.java` 已删除 -- 引擎提供:`WorkflowExecutor`、`ExecutePsop`、`LoadPsop`、`RegistryClient`、`DefaultWorkflowEngineClient` - -## 二、Agent 全部放在 samples - -每个 Agent 是独立的 A2A server(既是客户端也是服务端),通过 agentcard JSON 配置文件启动。 - -### 文件结构 - -``` -samples/src/main/java/.../examples/ - server/EmbeddedA2AServer.java # 可复用 A2A server(a2a-java SDK RestHandler) - agents/BaseAgentExecutor.java # 基类:extractText / buildStatusMessage - agents/SpnDomainAgentExecutor.java # 上海 OMC Agent - agents/SpnDomainAgentCity2Executor.java # 广州 OMC Agent - agents/TransportWorkbenchAgentExecutor.java # 工作台 Agent(编排者) - agents/WorkbenchControlPoint.java # ControlPoint 决策逻辑(SRP 拆分) - StartAgentsServer.java # 从 agentcard JSON 启动所有 Agent - SpnCrossCityDiagnosisDemo.java # Demo 入口 - -samples/src/main/resources/ - agentcard/spn_domain_agent.json # AgentCard 配置(标准格式,URL 带 /a2a/json 前缀) - agentcard/spn_domain_agent_city2.json - agentcard/transport_workbench_agent.json - spn_agent_credentials.json # Bearer 认证配置 -``` - -## 三、AgentCard URL 必须用标准格式 - -**不要改编排中心定义的 AgentCard JSON 格式,尤其是 URL 的路径前缀。** - -现网 AgentCard 的 URL 格式是 `http://host:port/a2a/json`,路径前缀 `/a2a/json` 是标准做法。A2A -REST 端点实际是 `http://host:port/a2a/json/message:stream`。 - -`EmbeddedA2AServer` 必须从 AgentCard URL 提取路径前缀,在该前缀下注册路由: - -```java -// 从 AgentCard URL 提取路径前缀 -this.pathPrefix = extractPathPrefix(agentCard); -// 在前缀下注册(如 /a2a/json) -this.server.createContext(pathPrefix.isEmpty() ? "/" : pathPrefix, handler); -// handleExchange 里 strip 掉前缀再匹配端点 -String path = fullPath.startsWith(pathPrefix) ? fullPath.substring(pathPrefix.length()) : fullPath; -``` - -## 四、Agent card 从配置文件加载,不依赖注册中心 - -**`TransportWorkbenchAgentExecutor` 执行工作流时,从 classpath 的 agentcard JSON 加载 agent card,不从注册中心 fetch。** - -原因:注册中心可能残留 之前注册的旧 card(端口 8904、URL 不带前缀等),与 Java 版的 agent 不匹配。注册返回 409 duplicate -时注册中心不会更新,导致引擎拿到错误的 URL。 - -```java -private static List> loadAgentCardsFromConfig() { - // 从 agentcard/*.json 加载,确保 URL 指向我们自己的 Java agent -} -``` - -## 五、SSE 响应解析必须支持 a2a-java SDK 事件格式 - -a2a-java SDK 的 `RestHandler` 返回的 SSE 事件有 4 种格式,引擎客户端必须全部处理: - -| SSE 事件 key | 内容 | 提取方式 | -|------------------|----------------|-----------------------------------------------------------------------------| -| `task` | 完整 Task 对象 | `task.artifacts[].parts[].text` | -| `message` | Message 事件 | `message.parts[].text` | -| `statusUpdate` | 状态更新 | `statusUpdate.status.state` + `statusUpdate.parts[].text` | -| `artifactUpdate` | 产物更新 | `artifactUpdate.artifact.parts[].text` + `artifactUpdate.artifact.metadata` | - -`extractTextFromResultMap` 必须处理三种来源: - -- `artifacts`(复数,list 形式,task 响应中) -- `artifact`(单数,artifactUpdate 事件中) -- 顶层 `parts`(statusUpdate 事件中 status.message.parts) - -## 六、Demo 用 sendMessage 接口,不要手写 HTTP - -```java -// 正确:用引擎提供的 WorkflowEngineClient.sendMessage() -DefaultWorkflowEngineClient client = new DefaultWorkflowEngineClient( - List.of(agentCard), null, - WorkflowEngineClientConfig.builder().sslVerify(false).build()); -SendMessageResult result = client.sendMessage("Transport Workbench Agent", taskText).join(); - -// 错误:手写 HttpClient + JSON 拼接(绕过了 A2A-T 协议处理) -``` - -`sendMessage` 内部自动处理:Task-T 消息封装、SSE 流式响应解析、Bearer 认证、协商循环等。 - -## 七、SSL 主机名验证 - -`jdk.internal.httpclient.disableHostnameVerification` 是 JVM 级静态缓存属性,必须在任何 HttpClient 创建前设置: - -```java -// StartAgentsServer 入口类的 static 块 -static { - System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); -} -``` - -## 八、SOLID 原则 - -- **SRP**:`WorkbenchControlPoint` 只管工作流决策(路由、授权、通知),`TransportWorkbenchAgentExecutor` 只管消息 I/O -- **DRY**:`BaseAgentExecutor` 提供共享的 `extractText` / `buildStatusMessage`,3 个 Agent executor 都继承它 -- **无 Unicode 转义**:Java 源文件中的中文字符串直接用字面中文,不用 `\uXXXX` - - 用 `apply_patch` 写文件(保持 UTF-8),不用 PowerShell here-string(会损坏中文) - -## 九、PSOP 工作流定义 - -编排中心的 `psop_spn_cross_city_diagnosis.json` 已经正确定义: - -- `merge_analysis` 节点分配给 `"Transport Workbench Agent"`,skill 为 `cross-city-fault-diagnosis` -- `layer=1`,`context_from=["diagnosis_city1", "diagnosis_city2"]` -- `merge_analysis` 是 `SelfLoop` 类型:工作台 Agent 通过 `WorkbenchControlPoint.onSelfTask` 本地汇总,不走 A2A-T 给自己发消息 -- 只有 `diagnosis_city1`/`diagnosis_city2` 走 A2A-T 给 SPN Domain Agent -- 工作台 Agent 收到顶层任务时走 `handleTopLevelTask`:搜索 PSOP → 加载工作流 → 执行(merge_analysis 由引擎自环节点回调处理) - -## 十、验证清单 - -- `mvn compile -pl workflow-engine,samples` 编译通过 -- `mvn test -pl workflow-engine,samples` 71 个测试通过(引擎 69 + samples 2) -- `EmbeddedA2AServerTest` 验证:启动 server → sendMessage → 从 SSE 流提取诊断结果 -- `SpnCrossCityDiagnosisDemo` 端到端:3 个 Agent 启动 → 触发工作流 → 5 步全部执行成功 diff --git a/samples/pom.xml b/samples/pom.xml index 9004c093..b52b81e4 100644 --- a/samples/pom.xml +++ b/samples/pom.xml @@ -25,6 +25,30 @@ workflow-engine ${revision} + + + org.a2aproject.sdk + a2a-java-sdk-spec-grpc + ${a2a.java.sdk.version} + + + + net.openan.a2a-t.sdk + a2a-t-client + ${a2a.t.sdk.version} + + + + net.openan.a2a-t.sdk + a2a-t-server + ${a2a.t.sdk.version} + + + + com.google.protobuf + protobuf-java-util + ${protobuf.java.util.version} + dev.openan.workflow.sdk @@ -56,6 +80,16 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + + + false + + + org.apache.maven.plugins maven-compiler-plugin diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringWorkbenchApplication.java b/samples/src/main/java/dev/openan/workflow/engine/examples/SpringWorkbenchApplication.java similarity index 68% rename from samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringWorkbenchApplication.java rename to samples/src/main/java/dev/openan/workflow/engine/examples/SpringWorkbenchApplication.java index a3796ce8..6a664658 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringWorkbenchApplication.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/SpringWorkbenchApplication.java @@ -17,22 +17,28 @@ * under the License. */ -package dev.openan.workflow.engine.examples.spring; +package dev.openan.workflow.engine.examples; + +import dev.openan.workflow.engine.client.EnvFileLoader; +import dev.openan.workflow.engine.examples.demo.SpringSpnDemo; +import dev.openan.workflow.engine.examples.util.EnvResolver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import java.nio.file.Path; + /** * Spring Boot Workbench Agent -- northbound A2A server. * *

This is the Workbench Agent itself: a Spring Boot service that exposes A2A-T endpoints * (message:send, message:stream) via the {@code spring-boot-starter} auto-configuration. The - * business logic lives in {@link SpringWorkbenchExecutor} (implements {@code AgentExecutor}). + * business logic lives in {@link dev.openan.workflow.engine.examples.workbench.SpringWorkbenchExecutor} (implements {@code AgentExecutor}). * *

Demo orchestration (starting OMC agents, sending Task-T, shutting down) is handled separately - * by {@link SpringSpnDemo}, mirroring the existing {@code SpnCrossCityDiagnosisDemo} pattern. + * by {@link dev.openan.workflow.engine.examples.demo.SpringSpnDemo}. * *

Can also be run standalone as a Spring Boot service (no demo logic). */ @@ -42,8 +48,16 @@ public class SpringWorkbenchApplication { private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchApplication.class); public static void main(String[] args) { - System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); + loadDotEnv(); SpringApplication.run(SpringWorkbenchApplication.class, args); log.info("[SpringWorkbench] A2A server started -- ready to receive A2A-T messages"); } + + /** Loads local demo configuration before Spring resolves application properties. */ + public static void loadDotEnv() { + String envPath = EnvResolver.resolveEnvPath(); + if (envPath != null) { + EnvFileLoader.loadToSystemProperties(Path.of(envPath)); + } + } } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/BaseAgentExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/BaseAgentExecutor.java index 47293b95..e8874248 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/BaseAgentExecutor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/BaseAgentExecutor.java @@ -45,7 +45,7 @@ protected static String extractText(Message message) { return sb.toString(); } - protected static Message buildStatusMessage(String contextId, String taskId, String text) { + public static Message buildStatusMessage(String contextId, String taskId, String text) { return Message.builder() .messageId(UUID.randomUUID().toString()) .contextId(contextId) diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/ExtensionPrePositioner.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/ExtensionPrePositioner.java deleted file mode 100644 index 90d6c288..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/ExtensionPrePositioner.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.agents; - -import dev.openan.workflow.engine.client.ExtensionSender; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; - -/** - * Pre-positions Authorization-T and Notification-T to downstream agents. - * - *

Single responsibility: send the two pre-positioning control messages (whitelist policy + - * result subscription) to each non-workbench agent before the workflow starts. This is a - * separate concern from workflow execution and from negotiation -- the orchestrator calls this - * once, then proceeds to run the PSOP workflow. - */ -public class ExtensionPrePositioner { - - private static final Logger log = LoggerFactory.getLogger(ExtensionPrePositioner.class); - - private final String authInput; - private final String notifInput; - - public ExtensionPrePositioner() { - this.authInput = - "任务类型新增授权,操作名称业务抢通," - + "操作类型光模块更换," - + "操作对象SPN专线业务,溢权策略OMC自动执行," - + "触发执行条件业务投诉诊断确认故障," - + "预期输出返回是否成功。"; - this.notifInput = - "通知主题为service-recovery-execution-result," - + "订阅条件为业务抢通方案执行结果," - + "上报通知数据格式为TextPart。"; - } - - /** - * Pre-position Authorization-T + Notification-T to every non-workbench agent. - * - *

When {@code notificationCallback} is non-null, subsequent events pushed by agents - * through the Notification-T SSE stream (e.g. recovery results) are forwarded to the - * callback. The callback receives a Map with keys: agent, text, metadata, state. - */ - public void prePosition( - ExtensionSender sender, - List agentCards, - Consumer> notificationCallback) { - for (AgentCard card : agentCards) { - String name = card.name(); - if (name.contains("Workbench")) { - continue; - } - log.info("[PrePosition] Authorization-T to {}", name); - sender.sendAuthorization(name, "下发授权放行策略", authInput).join(); - log.info("[PrePosition] Notification-T to {}", name); - sender.sendNotification( - name, "订阅业务抢通结果通知", notifInput, - notificationCallback).join(); - } - log.info("[PrePosition] Extension pre-positioning complete"); - } - - /** Pre-position without Notification-T event callback. */ - public void prePosition(ExtensionSender sender, List agentCards) { - prePosition(sender, agentCards, null); - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationBaseAgentExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationBaseAgentExecutor.java index dc3b35f7..b9f0f002 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationBaseAgentExecutor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationBaseAgentExecutor.java @@ -20,11 +20,16 @@ package dev.openan.workflow.engine.examples.agents; import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.validation.ContentValidationException; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.Part; import org.a2aproject.sdk.spec.TaskState; import org.a2aproject.sdk.spec.TaskStatus; @@ -37,121 +42,221 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.UUID; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; +import dev.openan.workflow.engine.examples.extension.AuthorizationPolicy; +import dev.openan.workflow.engine.examples.extension.NotificationPolicy; +import dev.openan.workflow.engine.examples.extension.PrePositionedExtensionHandler; +import dev.openan.workflow.engine.examples.extension.SdkSlotSchemaLoader; +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; /** * Server-side negotiation base, mirroring the Python reference * (orchestration-center/samples/agents/negotiation_base_agent.py). * *

Every agent that declares the Negotiation-T extension MUST be able to receive and reply to - * negotiation messages. This base implements that capability: on a new task it starts a fulfillment - * negotiation and replies with INPUT_REQUIRED carrying the negotiation context in task metadata; on - * a follow-up ([NEGOTIATION_RESOLUTION]) it re-executes the business and completes. + * negotiation messages. This base implements that capability: on an incomplete task it starts an information + * negotiation and replies with INPUT_REQUIRED carrying the negotiation context; on + * an Accept reply it validates and consumes the filled parameters before diagnosis. Reject and Abort + * are validated separately and end the task without executing diagnosis. * - *

Negotiation triggers on every new task (deterministic), so the demo exercises the full - * Negotiation-T round-trip each run. When the A2A-T .env is absent or LLM is disabled, the - * negotiation context falls back to a minimal in-process context and the business text falls back - * to the subclass default. + *

Negotiation is triggered by incomplete SDK-validated task data, not merely by extension activation. A + * configured A2A-T client/server is mandatory for protocol generation and validation; the sample + * never manufactures protocol-shaped fallback text. */ public abstract class NegotiationBaseAgentExecutor extends BaseAgentExecutor { private static final Logger log = LoggerFactory.getLogger(NegotiationBaseAgentExecutor.class); // Pre-positioned extensions (Authorization-T / Notification-T) are handled // by a dedicated handler, keeping this class focused on Negotiation-T. - private final PrePositionedExtensionHandler prePositionedHandler = - new PrePositionedExtensionHandler(); - private final BlockingQueue notificationQueue = new LinkedBlockingQueue<>(); + private final PrePositionedExtensionHandler prePositionedHandler; + private final BlockingQueue notificationQueue = new LinkedBlockingQueue<>(); + private final java.util.concurrent.ConcurrentMap pendingNegotiationTasks = + new java.util.concurrent.ConcurrentHashMap<>(); private volatile A2ATClient a2atClient; - /** The pre-positioned Authorization-T whitelist policy text, or null. */ - protected final String getAuthorizationPolicy() { + protected NegotiationBaseAgentExecutor() { + this(new PrePositionedExtensionHandler()); + } + + protected NegotiationBaseAgentExecutor(PrePositionedExtensionHandler prePositionedHandler) { + this.prePositionedHandler = java.util.Objects.requireNonNull(prePositionedHandler); + } + + /** The active SDK-validated Authorization-T whitelist, or null. */ + protected final AuthorizationPolicy getAuthorizationPolicy() { return prePositionedHandler.getAuthorizationPolicy(); } - /** The pre-positioned Notification-T subscription text, or null. */ - protected final String getNotificationSubscription() { + /** The active Notification-T subscription, or null. */ + protected final NotificationPolicy getNotificationSubscription() { return prePositionedHandler.getNotificationSubscription(); } - protected void pushNotificationResult(String result) { - notificationQueue.offer(result); + /** + * Enqueues one business-recovery notification for the independent Notification-T stream. + * The artifact part remains the protocol-defined event summary; the full structured prompt is + * carried under the Notification-T metadata URI. + */ + protected void pushRecoveryNotification(String artifactName, String content) { + if (artifactName == null || artifactName.isBlank()) { + throw new IllegalArgumentException("Notification artifact name must not be blank"); + } + if (content == null || content.isBlank()) { + throw new IllegalArgumentException("Notification content must not be blank"); + } + if (getNotificationSubscription() == null) { + log.warn( + "[{}] Dropping {} because no validated Notification-T subscription is active", + getClass().getSimpleName(), + artifactName); + return; + } + if (!notificationQueue.offer(new NotificationEvent(artifactName, content))) { + log.warn( + "[{}] Notification queue rejected {}", + getClass().getSimpleName(), + artifactName); + } + } + + protected final void pushRecoveryPlan(String content) { + pushRecoveryNotification("recovery-plan", content); + } + + protected final void pushRecoveryResult(String content) { + pushRecoveryNotification("recovery-result", content); + } + + private record NotificationEvent(String artifactName, String content) { + private NotificationEvent { + java.util.Objects.requireNonNull(artifactName, "artifactName"); + java.util.Objects.requireNonNull(content, "content"); + } } /** - * Resolve the A2A-T .env path; null disables A2ATClient (negotiation still works with fallback - * context). + * Resolve the A2A-T .env path; null means Negotiation-T cannot be served. */ protected abstract String resolveEnvPath(); - private A2ATClient a2at() { + private synchronized A2ATClient a2at() { if (a2atClient != null) { return a2atClient; } - if (Boolean.getBoolean("a2at.llm.disabled")) { - return null; - } String env = resolveEnvPath(); if (env == null || env.isBlank()) { - return null; + throw new IllegalStateException("A2A-T SDK configuration is required for OMC content validation"); } try { - a2atClient = new A2ATClient(Path.of(env)); + a2atClient = + dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new A2ATClient(Path.of(env))); log.info("[{}] A2ATClient ready for negotiation", getClass().getSimpleName()); return a2atClient; } catch (Exception e) { - log.warn( - "[{}] A2ATClient init failed, negotiation will use fallback context: {}", - getClass().getSimpleName(), - e.getMessage()); - return null; + throw new IllegalStateException( + "A2A-T client initialization failed for " + getClass().getSimpleName(), e); + } + } + + private volatile net.openan.a2at.sdk.server.A2ATServer a2atServer; + + /** Server-side SDK facade; initialization failure is not a validation bypass. */ + protected synchronized net.openan.a2at.sdk.server.A2ATServer a2atServer() { + if (a2atServer != null) { + return a2atServer; + } + String env = resolveEnvPath(); + if (env == null || env.isBlank()) { + throw new IllegalStateException("A2A-T SDK configuration is required for OMC content validation"); + } + try { + a2atServer = + dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new net.openan.a2at.sdk.server.A2ATServer(Path.of(env))); + return a2atServer; + } catch (Exception e) { + throw new IllegalStateException( + "A2A-T server initialization failed for " + getClass().getSimpleName(), e); } } + /** + * The Task-T template this agent validates incoming task prompts against. Default: the + * private-line complaint scenario. + */ + protected net.openan.a2at.sdk.core.model.TemplateUri taskTemplateUri() { + return StandardTemplates.PRIVATE_LINE_COMPLAINT; + } + + /** + * Parameter JSON Schema for the Task-T validate-and-fill pipeline: the task parameters this + * agent requires. A blank/missing slot (or validation rejection) triggers Negotiation-T. + */ + protected Map buildTaskParamSchema() { + return SdkSlotSchemaLoader.loadConfigured(taskTemplateUri()); + } + @Override public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { String taskId = ctx.getTaskId(); String contextId = ctx.getContextId(); + long started = System.nanoTime(); String input = extractText(ctx.getMessage()); // Read Task-T prompt from message metadata if present (mirrors Python SDK) if (ctx.getMessage() != null && ctx.getMessage().metadata() != null) { Object taskTPrompt = ctx.getMessage().metadata().get(NegotiationUtils.TASK_PROMPT_KEY); if (taskTPrompt instanceof String taskTText - && taskTText.length() > input.length()) { + && !taskTText.isBlank()) { log.info("[{}] Using Task-T prompt from message metadata", getClass().getSimpleName()); input = taskTText; } } log.info( - "[{}] Received task: taskId={}, text={} chars, followUp={}, prePositioned={}", + "[{}] TASK_START taskId={}, contextId={}, inputChars={}, followUp={}, " + + "prePositionedExtension={}", getClass().getSimpleName(), taskId, + contextId, input.length(), - NegotiationUtils.isFollowUpTask(input), - PrePositionedExtensionHandler.detect(ctx) != null); + isNegotiationReply(ctx), + PrePositionedExtensionHandler.detect(ctx)); try { String prePositionedExt = PrePositionedExtensionHandler.detect(ctx); if (prePositionedExt != null) { - if (prePositionedExt.contains("Notification")) { + if (A2ATExtension.NOTIFICATION_T.uri().equals(prePositionedExt)) { handleNotificationSubscription(ctx, emitter); } else { prePositionedHandler.handle( ctx, emitter, prePositionedExt, getClass().getSimpleName()); } - } else if (NegotiationUtils.isFollowUpTask(input)) { - handleFollowUp(ctx, emitter, input); + } else if (isNegotiationReply(ctx)) { + handleFollowUp(ctx, emitter); } else { - handleNewTask(ctx, emitter, input); + handleNewTask(ctx, emitter); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.info( - "[{}] Notification-T stream interrupted (shutdown)", - getClass().getSimpleName()); + "[{}] TASK_INTERRUPTED taskId={}, contextId={}, elapsedMs={}, reason=shutdown", + getClass().getSimpleName(), + taskId, + contextId, + elapsedMillis(started)); } catch (Exception e) { - log.error("[{}] execute failed: {}", getClass().getSimpleName(), e.getMessage(), e); + pendingNegotiationTasks.remove(taskId); + log.error( + "[{}] TASK_FAILED taskId={}, contextId={}, elapsedMs={}, errorType={}, message={}", + getClass().getSimpleName(), + taskId, + contextId, + elapsedMillis(started), + e.getClass().getSimpleName(), + e.getMessage(), + e); emitter.fail(buildStatusMessage(contextId, taskId, "Failed: " + e.getMessage())); } } @@ -167,86 +272,237 @@ private void handleNotificationSubscription(RequestContext ctx, AgentEmitter emi String taskId = ctx.getTaskId(); String contextId = ctx.getContextId(); String agentTag = getClass().getSimpleName(); - String notifUri = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; - log.info("[{}] Notification-T subscription received, keeping stream open", agentTag); - List> ackParts = List.of(new TextPart("Subscribed to recovery results")); + String notifUri = A2ATExtension.NOTIFICATION_T.uri(); + Map requestMetadata = ctx.getMessage().metadata(); + Object promptValue = requestMetadata.get(notifUri); + String prompt = promptValue instanceof String text ? text : String.valueOf(promptValue); + Object templateUri = requestMetadata.get( + net.openan.a2at.sdk.core.model.MetadataContent.TEMPLATE_URI_METADATA_KEY); + try { + prePositionedHandler.acceptNotification(prompt, templateUri, agentTag); + } catch (Exception validationError) { + log.warn( + "[{}] Notification-T rejected: {}", + agentTag, + validationError.getMessage()); + emitter.fail(buildStatusMessage( + contextId, taskId, "Notification-T validation failed")); + return; + } + log.info( + "[{}] NOTIFICATION_SUBSCRIBED taskId={}, contextId={}, action=keep-stream-open", + agentTag, + taskId, + contextId); + List> ackParts = List.of(new TextPart("订阅成功,启动业务抢通事件上报任务")); + Map subscriptionMetadata = + Map.of(notifUri, "## 订阅结果\n订阅结果:成功"); emitter.addArtifact( - ackParts, "subscription", agentTag + " subscription", Map.of(), false, true); + ackParts, + "subscription", + agentTag + " subscription", + subscriptionMetadata, + false, + true); emitStatus( emitter, TaskState.TASK_STATE_WORKING, contextId, taskId, - "Notification-T subscribed, waiting for recovery results", - Map.of()); + "订阅成功,启动业务抢通事件上报任务", + subscriptionMetadata); while (!Thread.currentThread().isInterrupted()) { - String result = notificationQueue.poll(30, TimeUnit.SECONDS); - if (result == null) { + long heartbeatSeconds = Math.max( + 1L, Long.getLong("a2at.notification.heartbeat.seconds", 30L)); + NotificationEvent event = notificationQueue.poll(heartbeatSeconds, TimeUnit.SECONDS); + if (event == null) { + emitStatus( + emitter, + TaskState.TASK_STATE_WORKING, + contextId, + taskId, + "Notification-T heartbeat", + Map.of( + "notificationHeartbeat", true, + "timestamp", java.time.Instant.now().toString())); continue; } - log.info("[{}] Pushing recovery result via Notification-T stream", agentTag); + log.info( + "[{}] NOTIFICATION_PUSH taskId={}, contextId={}, artifactName={}, contentChars={}", + agentTag, + taskId, + contextId, + event.artifactName(), + event.content().length()); Map notifMeta = new LinkedHashMap<>(); - notifMeta.put(notifUri, result); - List> resultParts = List.of(new TextPart(result)); + notifMeta.put(notifUri, event.content()); + List> resultParts = List.of(new TextPart("业务抢通事件")); emitter.addArtifact( - resultParts, "recovery-result", "recovery-result", notifMeta, false, true); + resultParts, + event.artifactName(), + event.artifactName(), + notifMeta, + false, + true); } } - /** New task: start negotiation and request input. Business runs on the follow-up. */ - private void handleNewTask(RequestContext ctx, AgentEmitter emitter, String input) { - if (needsNegotiation(input)) { - requestNegotiation(ctx, emitter, input); + /** New task: consume SDK-filled data, not the incoming protocol text. */ + private void handleNewTask(RequestContext ctx, AgentEmitter emitter) { + requireTemplate(ctx.getMessage().metadata(), taskTemplateUri().uri()); + String input = requiredText(ctx.getMessage().metadata(), A2ATExtension.TASK_T.uri()); + Map data; + try { + FilledParamData filled = a2atServer().validateTaskPromptAndDataFilling( + input, buildTaskParamSchema(), taskTemplateUri().uri()); + data = SpnTaskInput.selected(java.util.Objects.requireNonNull(filled, "Task validator returned null").data()); + } catch (ContentValidationException error) { + if (!A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED.equals(error.getCode()) + && !A2ATErrorCodes.SLOT_VALIDATION_ERROR.equals(error.getCode())) throw error; + // A rejected prompt supplies no trusted partial data. Ask for the complete business fields. + data = Map.of(); + } + List missing = invalidTaskFields(data); + if (!missing.isEmpty()) { + requestNegotiation(ctx, emitter, data, missing); } else { - log.info( - "[{}] Parameters sufficient, skipping negotiation", getClass().getSimpleName()); - runBusinessAndComplete(ctx, emitter, input); + log.info("[{}] Parameters sufficient, skipping negotiation", getClass().getSimpleName()); + runBusinessAndComplete(ctx, emitter, new SpnTaskInput(data)); } } - /** Follow-up: clean markers, run business, complete with extension metadata. */ - private void handleFollowUp(RequestContext ctx, AgentEmitter emitter, String input) { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - String cleanInput = NegotiationUtils.cleanupResolutionMarker(input); - log.info("[{}] Follow-up received, re-executing business", getClass().getSimpleName()); - runBusinessAndComplete(ctx, emitter, cleanInput); + private boolean isNegotiationReply(RequestContext ctx) { + Map meta = ctx.getMessage() != null ? ctx.getMessage().metadata() : null; + return NegotiationUtils.hasNegotiationMetadata(meta) && !NegotiationUtils.hasTaskMetadata(meta); } - /** Send INPUT_REQUIRED to start a Negotiation-T round. */ - @SuppressWarnings("unchecked") - private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, String input) { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - Map neg = startNegotiation(input, taskId, contextId); - Map contextData = - neg != null && neg.get(NegotiationUtils.NEGOTIATION_CONTEXT_KEY) instanceof Map - ? (Map) neg.get(NegotiationUtils.NEGOTIATION_CONTEXT_KEY) - : fallbackContext(); - String negText = - neg != null && neg.get(NegotiationUtils.NEGOTIATION_TEXT_KEY) instanceof String - ? (String) neg.get(NegotiationUtils.NEGOTIATION_TEXT_KEY) - : defaultNegotiationText(); - String concern = defaultNegotiationConcern(); - Map metadata = - NegotiationUtils.negotiationResponseMetadata(contextData, negText, concern); - emitStatus( - emitter, - TaskState.TASK_STATE_INPUT_REQUIRED, - contextId, - taskId, - "[NEGOTIATION_REQUEST] " - + getClass().getSimpleName() - + " needs clarification before completing this task.", - metadata); - log.info("[{}] Requested negotiation (INPUT_REQUIRED)", getClass().getSimpleName()); + /** Replies are read from Negotiation-T metadata, validated by phase, then consumed once. */ + private void handleFollowUp(RequestContext ctx, AgentEmitter emitter) { + Map metadata = ctx.getMessage().metadata(); + var context = java.util.Objects.requireNonNull(parseNegotiationContext(metadata), "Missing negotiationContext"); + PendingNegotiation pending = pendingNegotiationTasks.get(ctx.getTaskId()); + if (pending == null || !java.util.Objects.equals(pending.a2aContextId(), ctx.getContextId()) + || !pending.context().id().equals(context.id()) || pending.context().round() != context.round() + || pending.context().maxRounds() != context.maxRounds()) { + throw new IllegalArgumentException("No matching pending task/negotiation context"); + } + String prompt = requiredText(metadata, A2ATExtension.NEGOTIATION_T.uri()); + boolean abort = context.performative() == net.openan.a2at.sdk.core.model.NegotiationPerformative.ABORT; + requireTemplate(metadata, abort ? StandardTemplates.NEGOTIATION_ABORT.uri() + : StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()); + if (!pendingNegotiationTasks.remove(ctx.getTaskId(), pending)) + throw new IllegalStateException("Negotiation reply already consumed"); + switch (context.performative()) { + case ACCEPT -> acceptReply(ctx, emitter, prompt, context, pending); + case REJECT -> { + var filled = a2at().validateRejectPromptAndDataFilling(prompt, context, + endingReasonSchema(), StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()); + endNegotiation(ctx, emitter, "REJECT", filled); + } + case ABORT -> { + var filled = a2at().validateAbortPromptAndDataFilling(prompt, context, + endingReasonSchema(), StandardTemplates.NEGOTIATION_ABORT.uri()); + endNegotiation(ctx, emitter, "ABORT", filled); + } + default -> throw new IllegalArgumentException("Expected Accept, Reject or Abort reply"); + } + } + + private void acceptReply(RequestContext ctx, AgentEmitter emitter, String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, PendingNegotiation pending) { + FilledParamData filled = a2at().validateAcceptPromptAndDataFilling(prompt, context, + replySchema(pending.requestedFields()), StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()); + Map merged = new LinkedHashMap<>(pending.validatedData()); + for (String field : pending.requestedFields()) merged.put(field, requiredText(filled.data(), field)); + if (!invalidTaskFields(merged).isEmpty()) + throw new IllegalArgumentException("Negotiation Accept did not resolve required business fields"); + SpnTaskInput input = new SpnTaskInput(merged); + log.info("[{}] NEGOTIATION_APPLIED taskId={}, port={}, fields={}", + getClass().getSimpleName(), ctx.getTaskId(), input.accessPort(), pending.requestedFields()); + runBusinessAndComplete(ctx, emitter, input); + } + + private void endNegotiation(RequestContext ctx, AgentEmitter emitter, String phase, FilledParamData filled) { + String reason = requiredText(filled.data(), "reason"); + log.info("[{}] NEGOTIATION_ENDED taskId={}, performative={}, action=no-diagnosis", + getClass().getSimpleName(), ctx.getTaskId(), phase); + emitter.fail(buildStatusMessage(ctx.getContextId(), ctx.getTaskId(), + "Negotiation-T " + phase + ": " + reason)); + } + + private static Map endingReasonSchema() { + return Map.of("type", "object", "properties", Map.of("reason", + Map.of("type", "string", "minLength", 1, "description", "从拒绝说明或终止原因中提取实际原因,不臆造")), + "required", List.of("reason")); + } + + private Map replySchema(List fields) { + Map properties = new LinkedHashMap<>(); + for (String field : fields) properties.put(field, Map.of("type", "string", "minLength", 1, + "description", field.equals("任务对象") ? "当前地市的任务对象,必须包含实际接入端口名称" + : "完整投诉上下文,必须包含投诉分类及OSS侧事件流水号,保留其他已有信息")); + return Map.of("type", "object", "properties", properties, "required", fields); + } + + private static String requiredText(Map metadata, String key) { + Object value = metadata == null ? null : metadata.get(key); + if (!(value instanceof String text) || text.isBlank() || "null".equalsIgnoreCase(text.strip())) + throw new IllegalArgumentException("Missing nonblank " + key); + return text; + } + + private static void requireTemplate(Map metadata, String expected) { + if (!expected.equals(requiredText(metadata, net.openan.a2at.sdk.core.model.MetadataContent.TEMPLATE_URI_METADATA_KEY))) + throw new IllegalArgumentException("Unexpected templateUri; expected " + expected); + } + + /** Ask for only unresolved business fields; both cities use the same SDK generation path. */ + private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, + Map data, List missing) { + var context = new net.openan.a2at.sdk.core.model.NegotiationContext( + java.util.UUID.randomUUID().toString(), 1, + net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS, + net.openan.a2at.sdk.core.model.NegotiationPerformative.PROPOSE); + var items = missing.stream().map(field -> new net.openan.a2at.sdk.negotiation.content.NegotiationItem( + field, field.equals("任务对象") ? "请提供本地市实际接入端口名称,不能使用其他地市的端口" + : "请提供投诉分类及本次投诉的完整上下文,保留事件流水号")).toList(); + var generated = a2at().generateNegotiationProposePromptFromData( + new net.openan.a2at.sdk.negotiation.content.NegotiationProposeData(context, + new net.openan.a2at.sdk.negotiation.content.InformationProposeContent( + items, "AND:所列字段均为本次诊断必需,必须全部提供")), + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri()); + PendingNegotiation pending = new PendingNegotiation(ctx.getContextId(), context, Map.copyOf(data), List.copyOf(missing)); + if (pendingNegotiationTasks.putIfAbsent(ctx.getTaskId(), pending) != null) + throw new IllegalStateException("Task already waiting for negotiation"); + emitter.requiresInput(Message.builder().messageId(java.util.UUID.randomUUID().toString()) + .contextId(ctx.getContextId()).taskId(ctx.getTaskId()).role(Message.Role.ROLE_AGENT) + .parts(List.of(new TextPart("存在信息缺失,请补充信息"))) + .metadata(generated.buildMetadataContent()) + .extensions(List.of(A2ATExtension.NEGOTIATION_T.uri())).build()); + log.info("[{}] Requested negotiation via INPUT_REQUIRED fields={}", getClass().getSimpleName(), missing); + } + + private record PendingNegotiation(String a2aContextId, + net.openan.a2at.sdk.core.model.NegotiationContext context, + Map validatedData, List requestedFields) {} + + /** Sample-specific semantic checks after SDK filling; no raw protocol text is inspected. */ + protected List invalidTaskFields(Map data) { + return SpnTaskInput.invalidFields(data); } /** Run business logic, emit artifact, and complete the task. */ - private void runBusinessAndComplete(RequestContext ctx, AgentEmitter emitter, String input) { + private void runBusinessAndComplete( + RequestContext ctx, AgentEmitter emitter, SpnTaskInput input) { String taskId = ctx.getTaskId(); String contextId = ctx.getContextId(); + long started = System.nanoTime(); + log.info( + "[{}] BUSINESS_START taskId={}, contextId={}, inputChars={}", + getClass().getSimpleName(), + taskId, + contextId, + input.diagnosisInput().length()); String response = executeBusiness(ctx, emitter, input); Map metadata = buildResponseMetadata(ctx, response); List> parts = List.of(new TextPart(response)); @@ -254,52 +510,13 @@ private void runBusinessAndComplete(RequestContext ctx, AgentEmitter emitter, St emitStatus( emitter, TaskState.TASK_STATE_COMPLETED, contextId, taskId, "Completed", metadata); emitter.complete(buildStatusMessage(contextId, taskId, "Completed")); - log.info("[{}] Task completed", getClass().getSimpleName()); - } - - /** Start a fulfillment negotiation via A2ATClient, or return a fallback payload. */ - @SuppressWarnings("unchecked") - private Map startNegotiation(String input, String taskId, String contextId) { - A2ATClient client = a2at(); - if (client == null) { - return fallbackNegotiationPayload(input, taskId, contextId); - } - try { - Map facts = new LinkedHashMap<>(); - facts.put("agent", getClass().getSimpleName()); - if (taskId != null) facts.put("task_id", taskId); - if (contextId != null) facts.put("context_id", contextId); - // contentText should be the short negotiation request text, not - // the full task input. The full input is passed as a fact so the - // negotiation context retains the original task context. - facts.put("input", input); - return client.startNegotiation( - NegotiationType.FULFILLMENT, defaultNegotiationText(), facts); - } catch (Exception e) { - log.warn( - "[{}] startNegotiation failed, using fallback: {}", - getClass().getSimpleName(), - e.getMessage()); - return fallbackNegotiationPayload(input, taskId, contextId); - } - } - - private Map fallbackContext() { - Map ctx = new LinkedHashMap<>(); - ctx.put("negotiationType", "fulfillment"); - ctx.put("negotiationId", UUID.randomUUID().toString()); - ctx.put("round", 1); - ctx.put("status", "in-progress"); - ctx.put("extra", Map.of()); - return ctx; - } - - private Map fallbackNegotiationPayload( - String input, String taskId, String contextId) { - Map payload = new LinkedHashMap<>(); - payload.put(NegotiationUtils.NEGOTIATION_CONTEXT_KEY, fallbackContext()); - payload.put(NegotiationUtils.NEGOTIATION_TEXT_KEY, defaultNegotiationText()); - return payload; + log.info( + "[{}] BUSINESS_DONE taskId={}, contextId={}, responseChars={}, elapsedMs={}", + getClass().getSimpleName(), + taskId, + contextId, + response != null ? response.length() : 0, + elapsedMillis(started)); } private void emitStatus( @@ -321,23 +538,12 @@ private void emitStatus( emitter.emitEvent(event); } - // ---- subclass extension points ---- - - /** - * Whether the incoming task parameters are incomplete/wrong and require a Negotiation-T round - * before proceeding. Default: false (parameters are sufficient, skip negotiation and run - * business directly). Override to return true when the agent needs clarification. - */ - protected boolean needsNegotiation(String input) { - return false; - } - /** * Run the agent's actual business logic; return the response text. May emit intermediate * events. */ protected abstract String executeBusiness( - RequestContext ctx, AgentEmitter emitter, String input); + RequestContext ctx, AgentEmitter emitter, SpnTaskInput input); /** Build the task metadata for the completed task (e.g. Authorization-T / Notification-T). */ protected Map buildResponseMetadata(RequestContext ctx, String response) { @@ -346,7 +552,7 @@ protected Map buildResponseMetadata(RequestContext ctx, String r /** Short human-readable summary for the artifact parts. */ protected String buildResultSummary() { - return "业务处理结果"; + return "专线业务投诉诊断任务诊断结果消息"; } /** Artifact display name. Default: subclass simple name + " result". */ @@ -354,18 +560,35 @@ protected String buildArtifactName() { return getClass().getSimpleName() + " result"; } - /** Default negotiation text shown when A2ATClient is unavailable. */ - protected String defaultNegotiationText() { - return "Please confirm the task parameters so I can proceed."; + /** + * Parses the SDK-carried negotiation context for the validate APIs. + * + *

The latest SDK serializes {@code id}, {@code round}, and {@code maxRounds} under the + * canonical {@code negotiationContext} metadata key. The content layer remains stateless; + * callers own and advance this value. + */ + protected static net.openan.a2at.sdk.core.model.NegotiationContext parseNegotiationContext( + Map metadata) { + if (metadata == null) { + return null; + } + Object raw = metadata.get( + net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + return raw instanceof Map contextMap + ? dev.openan.workflow.engine.client.A2atMessages.contextOf( + new dev.openan.workflow.engine.model.ReceivedMessage( + new dev.openan.workflow.engine.model.MessageContent( + List.of(), metadata, java.util.Set.of()), Map.of(), List.of())) + : null; } - /** Default negotiation concern. */ - protected String defaultNegotiationConcern() { - return "needs clarification"; + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); } @Override public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { + pendingNegotiationTasks.remove(ctx.getTaskId()); emitter.cancel(); } } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationStrategy.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationStrategy.java deleted file mode 100644 index 0cfa4f52..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationStrategy.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.agents; - -import dev.openan.workflow.engine.examples.LlmHelper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -/** - * Negotiation clarification strategy for the SPN cross-city workbench. - * - *

Single responsibility: when a downstream agent returns INPUT_REQUIRED (Negotiation-T), - * generate a clarification text that supplements the missing parameters. Uses LLM when the A2A-T - * .env is configured, with a deterministic fallback. - * - *

Extracted from {@code WorkbenchControlPoint.onNegotiation} so that the control point only - * cares about workflow orchestration (task dispatch, routing), while negotiation policy evolves - * independently. - */ -public class NegotiationStrategy implements dev.openan.workflow.engine.control.NegotiationStrategy { - - private static final Logger log = LoggerFactory.getLogger(NegotiationStrategy.class); - - private final String a2atEnvPath; - - public NegotiationStrategy(String a2atEnvPath) { - this.a2atEnvPath = a2atEnvPath; - } - - /** - * Generate a clarification for the given negotiation request. - * - * @param agentName the agent requesting negotiation - * @param negotiationText the concern/question raised by the agent - * @param receiveResult the raw negotiation context (may be null/empty) - * @return clarification text to send back to the agent - */ - public CompletableFuture resolve( - String agentName, String negotiationText, Map receiveResult) { - log.info("[NegotiationStrategy] agent={}: {}", agentName, negotiationText); - String fallback = "根据工作台上下文,客户A城市1-城市2间SPN专线中断," + "城市1OMC告警端口Down,光功率-28dBm。"; - String sys = - "你是SPN跨城专线故障工作台的协商澄清专家。根据协商请求," - + "补充客户A城市1-城市2间SPN专线中断的上下文" - + "(城市1OMC告警端口Down、光功率-28dBm)。中文。"; - String clarification = LlmHelper.text(a2atEnvPath, sys, negotiationText, fallback); - return CompletableFuture.completedFuture(clarification); - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationUtils.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationUtils.java deleted file mode 100644 index e581955a..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationUtils.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.agents; - -import dev.openan.workflow.engine.client.A2ATExtension; - -import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; - -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * Constants and helpers for the server-side negotiation flow, mirroring the Python reference in - * orchestration-center/common/negotiation_utils.py. - * - *

Markers and key names align with the a2a-t-sdk-java negotiation payload mapper - * (NEGOTIATION_CONTEXT_KEY / NEGOTIATION_TEXT_KEY) and the engine-side NegotiationTHandler / - * autoNegotiate follow-up format. - */ -final class NegotiationUtils { - - static final String NEGOTIATION_RESOLUTION_MARKER = "[NEGOTIATION_RESOLUTION]"; - static final String NEGOTIATION_REQUEST_MARKER = "[NEGOTIATION_REQUEST]"; - static final String NEGOTIATION_CONTEXT_MARKER = "[NEGOTIATION_CONTEXT]"; - static final String NEGOTIATION_CONCERN_KEY = "negotiationConcern"; - static final String NEGOTIATION_CONTEXT_KEY = NegotiationHandler.NEGOTIATION_CONTEXT_KEY; - static final String NEGOTIATION_TEXT_KEY = A2ATExtension.NEGOTIATION_T.uri(); - static final String TASK_PROMPT_KEY = A2ATExtension.TASK_T.uri(); - - private NegotiationUtils() {} - - static boolean isFollowUpTask(String text) { - return text != null && text.contains(NEGOTIATION_RESOLUTION_MARKER); - } - - /** Extract the original task text from a [NEGOTIATION_RESOLUTION] follow-up message. */ - static String extractOriginalTask(String text) { - if (text == null || !text.contains(NEGOTIATION_RESOLUTION_MARKER)) { - return text != null ? text : ""; - } - int idx = text.indexOf("Original Task:"); - if (idx < 0) { - return ""; - } - String after = text.substring(idx + "Original Task:".length()).trim(); - int end = after.indexOf("\n\nPlease re-execute the task based on the clarification above."); - if (end >= 0) { - after = after.substring(0, end).trim(); - } - return after; - } - - /** Strip negotiation markers to recover a clean task text for re-execution. */ - static String cleanupResolutionMarker(String text) { - if (text == null) { - return ""; - } - if (!text.contains(NEGOTIATION_RESOLUTION_MARKER)) { - return text; - } - String original = extractOriginalTask(text); - return original.isEmpty() ? text : original; - } - - /** Build the negotiation response metadata placed on an INPUT_REQUIRED task. */ - static Map negotiationResponseMetadata( - Map contextData, String negotiationText, String concern) { - Map metadata = new LinkedHashMap<>(); - if (contextData != null && !contextData.isEmpty()) { - metadata.put(NEGOTIATION_CONTEXT_KEY, contextData); - } - if (negotiationText != null && !negotiationText.isEmpty()) { - metadata.put(NEGOTIATION_TEXT_KEY, negotiationText); - } - if (concern != null && !concern.isEmpty()) { - metadata.put(NEGOTIATION_CONCERN_KEY, concern); - } - return metadata; - } - - /** Build the [NEGOTIATION_RESOLUTION] follow-up text the client resends. */ - static String buildResolutionMessage(String originalTask, String resolutionText) { - return NEGOTIATION_RESOLUTION_MARKER - + "\n" - + "The engine has reviewed your negotiation request and provides the following clarification:\n\n" - + resolutionText - + "\n\n---\nOriginal Task:\n" - + originalTask - + "\n\nPlease re-execute the task based on the clarification above."; - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/PrePositionedExtensionHandler.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/PrePositionedExtensionHandler.java deleted file mode 100644 index 9eaa7150..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/PrePositionedExtensionHandler.java +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ -package dev.openan.workflow.engine.examples.agents; - -import org.a2aproject.sdk.server.agentexecution.RequestContext; -import org.a2aproject.sdk.server.tasks.AgentEmitter; -import org.a2aproject.sdk.spec.Message; -import org.a2aproject.sdk.spec.Part; -import org.a2aproject.sdk.spec.TaskState; -import org.a2aproject.sdk.spec.TaskStatus; -import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; -import org.a2aproject.sdk.spec.TextPart; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Map; - -/** - * Handles pre-positioned Authorization-T / Notification-T messages on the agent server side. - * - *

This is a separate concern from Negotiation-T. Pre-positioned extensions are sent by - * the orchestrator before the workflow starts (via {@code - * WorkflowEngineClient.sendExtensionMessage}) to establish whitelists and subscriptions. When an - * agent receives one, it must: - * - *

    - *
  1. Store the payload for later use during business execution - *
  2. Acknowledge receipt with a short artifact - *
  3. Complete the task immediately (no negotiation, no business logic) - *
- * - *

Extracted from {@code NegotiationBaseAgentExecutor} so that the latter only carries - * Negotiation-T responsibility. - */ -public class PrePositionedExtensionHandler { - private static final Logger log = LoggerFactory.getLogger(PrePositionedExtensionHandler.class); - private volatile String authorizationPolicy; - private volatile String notificationSubscription; - - /** - * Detect whether the incoming message carries an Authorization-T or Notification-T extension in - * its metadata. Returns the extension keyword ("Authorization-T" or "Notification-T"), or null - * for a normal task. - */ - public static String detect(RequestContext ctx) { - Message msg = ctx.getMessage(); - if (msg == null || msg.metadata() == null) { - return null; - } - for (String key : msg.metadata().keySet()) { - if (key.contains("Authorization-T")) { - return "Authorization-T"; - } - if (key.contains("Notification-T")) { - return "Notification-T"; - } - } - return null; - } - - private static void emitStatus( - AgentEmitter emitter, - TaskState state, - String contextId, - String taskId, - String text, - Map metadata) { - TaskStatus status = - new TaskStatus( - state, BaseAgentExecutor.buildStatusMessage(contextId, taskId, text), null); - TaskStatusUpdateEvent event = - TaskStatusUpdateEvent.builder() - .taskId(taskId) - .contextId(contextId) - .status(status) - .metadata(metadata) - .build(); - emitter.emitEvent(event); - } - - /** The pre-positioned Authorization-T whitelist policy text, or null. */ - public String getAuthorizationPolicy() { - return authorizationPolicy; - } - - /** The pre-positioned Notification-T subscription text, or null. */ - public String getNotificationSubscription() { - return notificationSubscription; - } - - /** - * Handle a pre-positioned message: store the payload, emit an ACK artifact, and complete the - * task. - * - * @param agentTag short agent class name for logging - */ - public void handle( - RequestContext ctx, AgentEmitter emitter, String extKeyword, String agentTag) { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - String payloadText = - ctx.getMessage().metadata().entrySet().stream() - .filter(e -> e.getKey().contains(extKeyword)) - .map(Map.Entry::getValue) - .findFirst() - .map(v -> v instanceof String s ? s : String.valueOf(v)) - .orElse(""); - if (extKeyword.contains("Authorization")) { - authorizationPolicy = payloadText; - } else if (extKeyword.contains("Notification")) { - notificationSubscription = payloadText; - } - log.info( - "[{}] Pre-positioned {} received, payload length={}", - agentTag, - extKeyword, - payloadText.length()); - String ackText = extKeyword + " pre-positioning acknowledged"; - List> parts = List.of(new TextPart(ackText)); - emitter.addArtifact(parts, "result", agentTag + " ack", Map.of(), false, true); - emitStatus( - emitter, - TaskState.TASK_STATE_COMPLETED, - contextId, - taskId, - extKeyword + " pre-positioned successfully", - Map.of()); - emitter.complete(BaseAgentExecutor.buildStatusMessage(contextId, taskId, "Completed")); - log.info("[{}] {} pre-positioning completed", agentTag, extKeyword); - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java index cebb38ff..62cdf79b 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java @@ -19,60 +19,59 @@ package dev.openan.workflow.engine.examples.agents; -import dev.openan.workflow.engine.examples.LlmHelper; +import dev.openan.workflow.engine.examples.extension.AuthorizationPolicy; +import dev.openan.workflow.engine.examples.extension.PrePositionedExtensionHandler; +import dev.openan.workflow.engine.examples.util.LlmHelper; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.LinkedHashMap; import java.util.Map; +import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; +import dev.openan.workflow.engine.examples.util.EnvResolver; /** * SPN Domain Agent for City1 (City1 OMC). * - *

Server-side negotiation-capable (extends {@link NegotiationBaseAgentExecutor}): on a new task + *

Server-side negotiation-capable (extends {@link NegotiationBaseAgentExecutor}): on an incomplete task * it replies INPUT_REQUIRED to start a Negotiation-T round, and on the follow-up it runs the - * diagnosis/recovery business. City1 side has a FAULT (port Down, optical power -28dBm). - * Diagnosis/recovery text is LLM-generated (deepseek) when the A2A-T .env is configured, else - * deterministic. Authorization-T and Notification-T are pre-positioned before the workflow starts, - * so this agent no longer injects them in response metadata. + * diagnosis business. City1 side has a FAULT (port Down, optical power -28dBm). Diagnosis text is + * LLM-generated when the A2A-T .env is configured, otherwise deterministic. The recovery event + * payload is formatted deterministically so required protocol fields and identifiers cannot be + * hallucinated. Authorization-T and Notification-T use independent channels. */ public class SpnDomainAgentCity1Executor extends NegotiationBaseAgentExecutor { private static final Logger log = LoggerFactory.getLogger(SpnDomainAgentCity1Executor.class); + // Diagnosis result placed in artifact.metadata[Task-T URI], mirroring spec case 7.1. private static final String FAULT_DIAGNOSIS_RESULT = - "诊断结果:城市1城市OMC诊断结果 - 端口Down,光功率-28dBm(低于阈值),存在故障。\n" - + "修复方案:更换城市1OMC端口光模块,恢复端口Down状态。此修复方案需授权后执行(授权已预置)。\n" - + "故障根因:城市1OMC端口光模块故障。"; + "1. 诊断结果:成功\n" + + "2. 诊断结果详情:P33206-YWHJ-业务汇聚机房1(990)的12-TPJ1EM8F-4端口出现无收光问题," + + "判断原因为对端设备掉电或端口关闭\n" + + "3. 修复建议:恢复供电后重新启动网元,或者重新开启端口"; - private static final String RECOVERY_RESULT = "城市1OMC端口光模块已更换,端口恢复Up,专线业务恢复正常。"; + private static final String ACCESS_PORT = "P781-珠江新城-PTN7900-23-TPA1EG24-17"; + private static final String RECOVERY_DETAIL = + "将时延越限和丢包越限的受影响隧道调优到新的可用路径,保留原路径用于回退。"; - private static String llmDiagnosisResult(String input, String fallback) { - String env = EnvResolver.resolveEnvPath(); - String sys = "你是SPN领域城市1OMC故障诊断专家。根据输入诊断信息,输出诊断结果、修复方案和故障根因,提及城市1。简洁专业,中文。"; - String user = "输入:\n" + input + "\n\n已知:城市1OMC端口port-7=DOWN,光功率-28dBm(阈值-20dBm)。请输出诊断结论。"; - return LlmHelper.text(env, sys, user, fallback); - } - - private static String llmRecoveryResult(String input, String fallback) { - String env = EnvResolver.resolveEnvPath(); - String sys = "你是SPN领域城市1OMC抢通执行专家。用一句话报告抢通成功结果,提及城市1OMC端口恢复Up、专线业务恢复。中文。"; - String user = "输入:\n" + input + "\n\n已更换光模块,端口恢复Up。请输出抢通结果。"; - return LlmHelper.text(env, sys, user, fallback); + public SpnDomainAgentCity1Executor() { + super(); } - private static void sleepBriefly() { - try { - Thread.sleep(300); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + public SpnDomainAgentCity1Executor(PrePositionedExtensionHandler extensionHandler) { + super(extensionHandler); } - @Override - protected boolean needsNegotiation(String input) { - return true; + private static String llmDiagnosisResult(String input, String fallback) { + String env = EnvResolver.resolveEnvPath(); + String sys = "你是SPN领域OMC故障诊断专家。按如下结构输出:1. 诊断结果(成功/失败);" + + "2. 诊断结果详情(故障根因点资源对象、端口、现象);3. 修复建议。简洁专业,中文。"; + String user = "输入:\n" + input + "\n\n已知:P33206-YWHJ-业务汇聚机房1(990)的12-TPJ1EM8F-4端口出现无收光问题。请输出诊断结果。"; + return LlmHelper.text(env, sys, user, fallback); } @Override @@ -81,43 +80,79 @@ protected String resolveEnvPath() { } @Override - protected String executeBusiness(RequestContext ctx, AgentEmitter emitter, String input) { - String diagnosisResult = llmDiagnosisResult(input, FAULT_DIAGNOSIS_RESULT); - String recoveryResult = selfTriggerRecovery(ctx, diagnosisResult); - return diagnosisResult + "\n\n" + recoveryResult; + protected String executeBusiness(RequestContext ctx, AgentEmitter emitter, SpnTaskInput input) { + String diagnosisResult = llmDiagnosisResult(input.diagnosisInput(), FAULT_DIAGNOSIS_RESULT); + publishRecoveryLifecycle(ctx, input); + // Task-T returns diagnosis only. Recovery plan/result belong exclusively to the + // independently established Notification-T subscription. + return diagnosisResult; } /** - * After diagnosis, check the pre-positioned Authorization-T whitelist policy. If the repair - * action matches the whitelist, execute recovery and return the result (reported as - * Notification-T metadata by the base class). If not in whitelist, return a refusal message. + * After diagnosis, publish the recovery plan. If it matches the active Authorization-T + * whitelist, execute it automatically and publish the final result as a second event. */ - private String selfTriggerRecovery(RequestContext ctx, String diagnosisResult) { - String policy = getAuthorizationPolicy(); + private void publishRecoveryLifecycle(RequestContext ctx, SpnTaskInput input) { + AuthorizationPolicy policy = getAuthorizationPolicy(); boolean inWhitelist = policy != null - && !policy.isEmpty() - && (policy.contains("业务抢通") - || policy.contains("光模块") - || policy.contains("授权")); + && policy.authorizes( + "业务投诉诊断", + "业务抢通", + "隧道调优", + LocalDate.now(ZoneId.of("Asia/Shanghai"))); + String taskId = ctx.getTaskId() != null ? ctx.getTaskId() : "unknown-task"; + pushRecoveryPlan(formatRecoveryEvent("未启动", taskId, inWhitelist, null, null, input)); if (inWhitelist) { log.info("[SPN-Domain-Agent] Fault in whitelist, self-triggering recovery"); - String recoveryResult = llmRecoveryResult(diagnosisResult, RECOVERY_RESULT); + String recoveryResult = + formatRecoveryEvent( + "已结束", + taskId, + true, + "成功", + java.time.Instant.now().toString(), input); log.info( "[SPN-Domain-Agent] Recovery result reported via Notification-T: {}", recoveryResult); - pushNotificationResult(recoveryResult); - return recoveryResult; + pushRecoveryResult(recoveryResult); + return; + } + log.info( + "[SPN-Domain-Agent] Recovery plan reported but automatic execution was not started because it is outside the active whitelist"); + } + + private static String formatRecoveryEvent( + String state, + String taskId, + boolean authorized, + String executionResult, + String finishedAt, SpnTaskInput input) { + StringBuilder event = + new StringBuilder("### 业务抢通事件\n") + .append("1. 业务抢通方案执行状态:").append(state).append('\n') + .append("2. 投诉诊断任务流水号:").append(taskId).append('\n') + .append("3. OSS侧事件流水号:").append(input.incidentId()).append('\n') + .append("4. 接入端口名称:").append(input.accessPort()).append('\n') + .append("5. 是否已授权OMC自动抢通:").append(authorized ? "是" : "否").append('\n') + .append("6. 业务抢通方案名称:隧道调优\n") + .append("7. 业务抢通方案详情:").append(RECOVERY_DETAIL); + if (finishedAt != null) { + event.append('\n').append("8. 业务抢通方案执行结束时间:").append(finishedAt); + } + if (executionResult != null) { + event.append('\n').append("9. 业务抢通方案执行结果:").append(executionResult); } - log.info("[SPN-Domain-Agent] Fault not in whitelist, refusing recovery"); - String refusalResult = "操作不在白名单内,拒绝执行抢通。"; - pushNotificationResult(refusalResult); - return refusalResult; + return event.toString(); } @Override - protected String defaultNegotiationText() { - return "城市1OMC诊断需要确认客户专线故障的详细端口信息后再执行,请补充。"; + protected java.util.List invalidTaskFields(Map data) { + var invalid = new java.util.ArrayList<>(super.invalidTaskFields(data)); + // Sample inventory: City1 must not diagnose a foreign or unknown access port. + if (!ACCESS_PORT.equals(SpnTaskInput.field(data, "任务对象", "接入端口名称")) + && !invalid.contains("任务对象")) invalid.add("任务对象"); + return invalid; } @Override @@ -129,7 +164,7 @@ protected Map buildResponseMetadata(RequestContext ctx, String r @Override protected String buildResultSummary() { - return "SPN专线故障诊断结果"; + return "专线业务投诉诊断任务诊断结果消息"; } @Override diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java index 5eecf2c8..b9e4c7fd 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java @@ -19,7 +19,8 @@ package dev.openan.workflow.engine.examples.agents; -import dev.openan.workflow.engine.examples.LlmHelper; +import dev.openan.workflow.engine.examples.extension.PrePositionedExtensionHandler; +import dev.openan.workflow.engine.examples.util.LlmHelper; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.slf4j.Logger; @@ -28,37 +29,51 @@ import java.util.LinkedHashMap; import java.util.Map; +import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; +import dev.openan.workflow.engine.examples.util.EnvResolver; /** * SPN Domain Agent for City2 (City2 OMC). * *

Server-side negotiation-capable (extends {@link NegotiationBaseAgentExecutor}). City2 side is - * NORMAL. Diagnosis/recovery text is LLM-generated when the A2A-T .env is configured, else - * deterministic. Authorization-T and Notification-T are pre-positioned before the workflow starts. + * NORMAL. Diagnosis text is LLM-generated when the A2A-T .env is configured, else deterministic. + * Authorization-T and Notification-T are initiated on independent channels. */ public class SpnDomainAgentCity2Executor extends NegotiationBaseAgentExecutor { private static final Logger log = LoggerFactory.getLogger(SpnDomainAgentCity2Executor.class); + // Diagnosis result placed in artifact.metadata[Task-T URI], mirroring spec case 7.1 (normal). private static final String NORMAL_DIAGNOSIS_RESULT = - "诊断结果:城市2城市OMC诊断结果 - 端口状态正常,光功率-17dBm(正常范围),无异常告警。\n" - + "修复建议:城市2城市无需修复,故障不在此地市。\n" - + "故障根因:无根因(此地市正常)。"; + "1. 诊断结果:成功\n" + + "2. 诊断结果详情:城市2OMC端口状态正常,光功率-17dBm(正常范围),无异常告警,故障不在此地市\n" + + "3. 修复建议:城市2无需修复"; - private static final String RECOVERY_RESULT = "城市2侧OMC抢通完成,业务恢复正常。"; + public SpnDomainAgentCity2Executor() { + super(); + } - private static String llmDiagnosisResult(String input, String fallback) { - String env = EnvResolver.resolveEnvPath(); - String sys = - "你是SPN领域城市2OMC故障诊断专家。根据输入诊断信息,城市2侧端口正常、光功率-17dBm(正常范围)、无异常告警。输出诊断结果:城市2无需修复、故障不在此地市。简洁专业,中文。"; - String user = "输入:\n" + input + "\n\n城市2OMC端口port-3=UP,光功率-17dBm,无告警。请输出诊断结论。"; - return LlmHelper.text(env, sys, user, fallback); + public SpnDomainAgentCity2Executor(PrePositionedExtensionHandler extensionHandler) { + super(extensionHandler); } - private static void sleepBriefly() { - try { - Thread.sleep(300); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + private static String llmDiagnosisResult(String input, String fallback) { + String env = EnvResolver.resolveEnvPath(); + String sys = "你是SPN领域OMC故障诊断专家。诊断结果表示诊断任务是否成功完成,不表示是否发现故障。" + + "本例必须输出“1. 诊断结果:成功”;城市2本地端口正常、无故障,不得写成诊断失败," + + "不得把城市2的任务对象称为城市1。继续输出2.诊断结果详情、3.修复建议,限300个中文字符,确保句子完整。"; + String user = "输入:\n" + input + + "\n\n城市2 OMC 已核验本次任务对象:端口状态UP,光功率-17dBm,无告警。" + + "结论是城市2侧诊断执行成功且未发现本地故障。请按指定结构输出。"; + String result = LlmHelper.text(env, sys, user, fallback); + String normalized = result.replace("*", "").replace("#", ""); + boolean successfulDiagnosis = + normalized.matches("(?s).*诊断结果\\s*[::]?\\s*成功.*") + && (normalized.contains("正常") || normalized.contains("未发现")); + if (!successfulDiagnosis) { + log.warn( + "[SPN-Domain-Agent-City2] LLM output contradicted the verified normal state; using deterministic result"); + return fallback; } + return result; } @Override @@ -67,16 +82,20 @@ protected String resolveEnvPath() { } @Override - protected String executeBusiness(RequestContext ctx, AgentEmitter emitter, String input) { - String result = llmDiagnosisResult(input, NORMAL_DIAGNOSIS_RESULT); + protected String executeBusiness(RequestContext ctx, AgentEmitter emitter, SpnTaskInput input) { + String result = llmDiagnosisResult(input.diagnosisInput(), NORMAL_DIAGNOSIS_RESULT); log.info( "[SPN-Domain-Agent-City2] Diagnosis complete (City2), no fault, no recovery needed"); return result; } @Override - protected String defaultNegotiationText() { - return "城市2OMC诊断需要确认客户专线故障是否涉及城市2侧端口,请补充。"; + protected java.util.List invalidTaskFields(Map data) { + var invalid = new java.util.ArrayList<>(super.invalidTaskFields(data)); + // Sample inventory belongs to City2; negotiation must never reuse City1's input. + if (!"P882-珠江新城-PTN7900-23-TPA1EG24-11".equals(SpnTaskInput.field(data, "任务对象", "接入端口名称")) + && !invalid.contains("任务对象")) invalid.add("任务对象"); + return invalid; } @Override @@ -88,7 +107,7 @@ protected Map buildResponseMetadata(RequestContext ctx, String r @Override protected String buildResultSummary() { - return "SPN专线故障诊断结果"; + return "专线业务投诉诊断任务诊断结果消息"; } @Override diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnTaskInput.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnTaskInput.java new file mode 100644 index 00000000..304eb39b --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnTaskInput.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.agents; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** Sample business input, constructed only from SDK-validated task or negotiation fields. */ +public record SpnTaskInput(Map parameters) { + public SpnTaskInput { + parameters = Map.copyOf(parameters); + List invalid = invalidFields(parameters); + if (!invalid.isEmpty()) throw new IllegalArgumentException("Incomplete complaint parameters: " + invalid); + } + + /** Fields requiring clarification; this is SPN business validation, not an engine constraint. */ + public static List invalidFields(Map parameters) { + List fields = new ArrayList<>(); + if (field(parameters, "任务对象", "接入端口名称").isBlank()) fields.add("任务对象"); + if (field(parameters, "任务上下文", "投诉分类").isBlank() + || field(parameters, "任务上下文", "OSS侧事件流水号").isBlank()) fields.add("任务上下文"); + return fields; + } + + /** The access port belonging to this validated city task. */ + public String accessPort() { return field(parameters, "任务对象", "接入端口名称"); } + + /** The original business incident identifier, not the A2A task identifier. */ + public String incidentId() { return field(parameters, "任务上下文", "OSS侧事件流水号"); } + + /** Business-only diagnostic input; never concatenate raw protocol or unvalidated replies here. */ + public String diagnosisInput() { + return "已校验投诉参数\n任务对象:" + parameters.get("任务对象") + + "\n任务上下文:" + parameters.get("任务上下文"); + } + + static String field(Map parameters, String section, String name) { + Object raw = parameters.get(section); + if (!(raw instanceof String text)) return ""; + var match = Pattern.compile(Pattern.quote(name) + "[::]\\s*[\\\"“]?([^;;\\\"”\\r\\n]+)").matcher(text); + String value = match.find() ? match.group(1).strip() : ""; + return "null".equalsIgnoreCase(value) ? "" : value; + } + + static Map selected(Map values) { + Map result = new LinkedHashMap<>(); + for (String field : List.of("任务对象", "任务上下文")) { + Object value = values.get(field); + if (value instanceof String text) result.put(field, text); + } + return Map.copyOf(result); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/TransportWorkbenchAgentExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/TransportWorkbenchAgentExecutor.java deleted file mode 100644 index 06f47d0a..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/TransportWorkbenchAgentExecutor.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.agents; - -import org.a2aproject.sdk.server.agentexecution.RequestContext; -import org.a2aproject.sdk.server.tasks.AgentEmitter; -import org.a2aproject.sdk.spec.A2AError; -import org.a2aproject.sdk.spec.Part; -import org.a2aproject.sdk.spec.TextPart; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Transport Workbench Agent - the orchestrator. - * - *

Single responsibility: agent server I/O. Receives a Task-T from the upper layer, - * delegates the full orchestration pipeline to {@link WorkbenchOrchestrator}, and returns the - * result. This class does NOT contain workflow logic, pre-positioning, or negotiation strategy -- - * those live in dedicated classes. - */ -public class TransportWorkbenchAgentExecutor extends BaseAgentExecutor { - private static final Logger log = - LoggerFactory.getLogger(TransportWorkbenchAgentExecutor.class); - - private final String orchUrl; - private final String credentialsPath; - private final boolean sslVerify; - private final String a2atEnvPath; - - public TransportWorkbenchAgentExecutor( - String registryUrl, String orchUrl, String credentialsPath, boolean sslVerify) { - this(registryUrl, orchUrl, credentialsPath, sslVerify, null); - } - - public TransportWorkbenchAgentExecutor( - String registryUrl, - String orchUrl, - String credentialsPath, - boolean sslVerify, - String a2atEnvPath) { - this.orchUrl = orchUrl; - this.credentialsPath = credentialsPath; - this.sslVerify = sslVerify; - this.a2atEnvPath = a2atEnvPath; - } - - @Override - public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - String input = extractText(ctx.getMessage()); - log.info("[Workbench] Received task: taskId={}, text={} chars", taskId, input.length()); - emitter.submit(buildStatusMessage(contextId, taskId, "Task received")); - emitter.startWork(buildStatusMessage(contextId, taskId, "Processing")); - try { - String result = - new WorkbenchOrchestrator(orchUrl, credentialsPath, sslVerify, a2atEnvPath) - .run(input); - Map metadata = new LinkedHashMap<>(); - metadata.put(NegotiationUtils.TASK_PROMPT_KEY, result); - List> parts = List.of(new TextPart("跨城故障协同诊断汇总结果")); - emitter.addArtifact( - parts, "result", "cross-city-diagnosis-summary", metadata, false, true); - emitter.complete(buildStatusMessage(contextId, taskId, "Completed")); - log.info("[Workbench] Task completed"); - } catch (Exception e) { - log.error("[Workbench] Failed: {}", e.getMessage(), e); - emitter.fail(buildStatusMessage(contextId, taskId, "Failed: " + e.getMessage())); - } - } - - @Override - public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { - emitter.cancel(); - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchControlPoint.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchControlPoint.java deleted file mode 100644 index 49a5b5a4..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchControlPoint.java +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.agents; - -import dev.openan.workflow.engine.examples.LlmHelper; -import dev.openan.workflow.engine.client.WorkflowEngineClient; -import dev.openan.workflow.engine.control.DefaultControlPoint; -import dev.openan.workflow.engine.model.JumpCondition; -import dev.openan.workflow.engine.model.RouteDecision; -import dev.openan.workflow.engine.model.TaskRequest; -import dev.openan.workflow.engine.model.TaskResponse; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -/** - * ControlPoint for the SPN cross-city diagnosis workflow. - * - *

Handles task dispatch (with city-specific message enrichment), route decisions (fault-based - * routing to recovery steps), and negotiation responses. Authorization-T and Notification-T are - * pre-positioned before the workflow starts, not handled here. - * - *

SRP: this class only contains workflow decision logic, separating it from the agent executor - * that handles message I/O. - */ -public class WorkbenchControlPoint extends DefaultControlPoint { - private static final Logger log = LoggerFactory.getLogger(WorkbenchControlPoint.class); - - private final String a2atEnvPath; - private final NegotiationStrategy negotiationStrategy; - - public WorkbenchControlPoint() { - this(null, null); - } - - public WorkbenchControlPoint(String a2atEnvPath) { - this(a2atEnvPath, null); - } - - public WorkbenchControlPoint(String a2atEnvPath, NegotiationStrategy negotiationStrategy) { - this.a2atEnvPath = a2atEnvPath != null ? a2atEnvPath : EnvResolver.resolveEnvPath(); - this.negotiationStrategy = - negotiationStrategy != null - ? negotiationStrategy - : new NegotiationStrategy(this.a2atEnvPath); - } - - private static String analyzeFaultLocation(String messageText) { - boolean hasCity1Fault = - messageText.contains("城市1") - && (messageText.contains("故障") || messageText.contains("Down")); - boolean hasCity2Fault = - messageText.contains("城市2") - && (messageText.contains("故障") || messageText.contains("Down")); - if (hasCity1Fault) { - return "汇总分析完成。故障定位:城市1地市OMC,端口Down,光功率-28dBm低于阈值。"; - } - if (hasCity2Fault) { - return "汇总分析完成。故障定位:城市2地市OMC,需排查。"; - } - return "汇总分析完成。两地市均未见异常。"; - } - - /** - * Build a targeted task message containing ONLY the parameters relevant to the target agent's - * city. The upstream context (which mixes both cities' info) is NOT forwarded -- each sub-agent - * receives a clean, self-contained task description with only its own scope. - */ - private static String buildTargetedTaskMessage(String step) { - return switch (step) { - case "diagnosis_city1" -> buildCity1Task(); - case "diagnosis_city2" -> buildCity2Task(); - default -> "请执行SPN专线故障诊断任务。"; - }; - } - - private static String buildCity1Task() { - return -""" -## 任务 -SPN专线故障诊断 - 城市1OMC侧 - -## 故障参数 -- 端口状态:port-7 = DOWN -- 光功率:-28dBm(阈值-20dBm) -- 所属单板:line-card-03 -- 端口号:port-7 - -请针对城市1OMC侧端口故障进行诊断。请用中文回复。""" - .stripIndent(); - } - - private static String buildCity2Task() { - return -""" -## 任务 -SPN专线故障诊断 - 城市2OMC侧 - -## 排查要求 -- 排查城市2OMC端口状态是否正常 -- 确认光功率是否在正常范围 -- 确认是否存在异常告警 - -请针对城市2OMC侧进行排查。请用中文回复。""" - .stripIndent(); - } - - @Override - public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - String step = request.getStepName(); - String agentName = request.getAgentName(); - String targetedMessage = buildTargetedTaskMessage(step); - return engineClient - .sendMessage(agentName, targetedMessage) - .thenApply( - r -> { - boolean success = r.getText() != null && !r.getText().isEmpty(); - log.info( - "[onTask] Response from {}: {} chars, success={}", - agentName, - r.getText() != null ? r.getText().length() : 0, - success); - return TaskResponse.builder() - .success(success) - .output(r.getText()) - .build(); - }) - .exceptionally( - e -> { - log.error("[onTask] Failed for {}: {}", agentName, e.getMessage()); - return TaskResponse.builder() - .success(false) - .error("Agent call failed: " + e.getMessage()) - .build(); - }); - } - - @Override - public CompletableFuture onSelfTask(TaskRequest request) { - String step = request.getStepName(); - log.info( - "[onSelfTask] Self-loop step={}, agent={} (local merge, no A2A-T)", - step, - request.getAgentName()); - String message = request.getMessage(); - String fallback = analyzeFaultLocation(message); - String sys = "你是SPN跨城故障协同诊断汇总专家。根据两地市OMC诊断结果,输出故障定位结论。简洁专业,中文。"; - String result = LlmHelper.text(a2atEnvPath, sys, message, fallback); - log.info("[onSelfTask] Merge result ({} chars): {}", result.length(), result); - return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output(result).build()); - } - - @Override - public CompletableFuture onRoute( - String stepName, Map results, List conditions) { - // merge_analysis has an unconditional next -> endNode, so the executor - // never calls onRoute for it. Recovery is self-triggered by SPN agents - // via the pre-positioned Authorization-T whitelist and reported through - // the Notification-T channel. Just delegate to the default routing. - return super.onRoute(stepName, results, conditions); - } - - @Override - public CompletableFuture onNegotiation( - String agentName, String negotiationText, Map receiveResult) { - return negotiationStrategy.resolve(agentName, negotiationText, receiveResult); - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchOrchestrator.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchOrchestrator.java deleted file mode 100644 index 5b64e2a9..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchOrchestrator.java +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.agents; - -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.openan.workflow.engine.client.A2ATransport; -import dev.openan.workflow.engine.client.AgentCardJacksonModule; -import dev.openan.workflow.engine.client.DefaultExtensionSender; -import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; -import dev.openan.workflow.engine.client.ExtensionSender; -import dev.openan.workflow.engine.client.WorkflowEngineClient; -import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; -import dev.openan.workflow.engine.control.EventCallback; -import dev.openan.workflow.engine.control.EventType; -import dev.openan.workflow.engine.model.ExecutionResult; -import dev.openan.workflow.engine.model.Workflow; -import dev.openan.workflow.engine.model.WorkflowSearchResult; -import dev.openan.workflow.engine.registry.LoadPsop; -import dev.openan.workflow.engine.runner.ExecutePsop; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -/** - * Workflow orchestration for the SPN cross-city diagnosis. - * - *

Single responsibility: coordinate the full orchestration pipeline -- load agent cards, - * search/load PSOP, create engine client, pre-position extensions, and run the workflow. Each - * sub-step delegates to a dedicated collaborator: - * - *

    - *
  • {@link ExtensionPrePositioner} -- Authorization-T / Notification-T pre-positioning - *
  • {@link WorkbenchControlPoint} -- workflow decision callbacks (task dispatch, routing) - *
  • {@link NegotiationStrategy} -- negotiation clarification (injected into the control point) - *
- * - *

This class does NOT handle agent server I/O (that stays in {@link - * TransportWorkbenchAgentExecutor}) or the details of any single A2A-T extension protocol. - */ -public class WorkbenchOrchestrator { - - private static final Logger log = LoggerFactory.getLogger(WorkbenchOrchestrator.class); - private static final ObjectMapper mapper = - new ObjectMapper().registerModule(new AgentCardJacksonModule()); - - private static final String FALLBACK_PSOP_ID = "psop_spn_cross_city_diagnosis"; - private static final List AGENT_CARD_RESOURCES = - List.of( - "agentcard/spn_domain_agent_city1.json", - "agentcard/spn_domain_agent_city2.json", - "agentcard/transport_workbench_agent.json"); - - private final String orchUrl; - private final String credentialsPath; - private final boolean sslVerify; - private final String a2atEnvPath; - - public WorkbenchOrchestrator( - String orchUrl, String credentialsPath, boolean sslVerify, String a2atEnvPath) { - this.orchUrl = orchUrl; - this.credentialsPath = credentialsPath; - this.sslVerify = sslVerify; - this.a2atEnvPath = a2atEnvPath; - } - - private static String buildResultText(ExecutionResult result) { - StringBuilder sb = new StringBuilder(); - sb.append("Workflow execution ") - .append(result.isSuccess() ? "succeeded" : "failed") - .append(".\n"); - if (result.getHistory() != null) { - for (Map h : result.getHistory()) { - sb.append("- Step: ") - .append(h.get("step")) - .append(", Agent: ") - .append(h.get("agent")) - .append(", Status: ") - .append(h.get("status")) - .append("\n"); - } - } - if (result.getError() != null) { - sb.append("Error: ").append(result.getError()); - } - return sb.toString(); - } - - /** Run the full orchestration pipeline and return the result text. */ - public String run(String messageText) throws Exception { - log.info("[Orchestrator] Step 1: Load agent cards"); - List agentCards = loadAgentCards(); - log.info("[Orchestrator] Loaded {} agent card(s)", agentCards.size()); - - log.info("[Orchestrator] Step 2: Search + load PSOP workflow"); - String psopId = searchPsop(messageText); - Workflow workflow = LoadPsop.load(orchUrl, psopId, null, sslVerify); - log.info( - "[Orchestrator] Workflow: {} ({} steps)", - workflow.getName(), - workflow.getSteps().size()); - - log.info("[Orchestrator] Step 3: Create engine client"); - A2ATransport transport = - new A2ATransport( - agentCards, - null, - WorkflowEngineClientConfig.builder() - .sslVerify(sslVerify) - .a2atEnvPath(a2atEnvPath) - .credentialsConfigPath(credentialsPath) - .build()); - try { - WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport); - ExtensionSender extensionSender = new DefaultExtensionSender(transport); - - log.info("[Orchestrator] Step 4: Pre-position extensions"); - new ExtensionPrePositioner().prePosition(extensionSender, agentCards, data -> { - Object agent = data.get("agent"); - Object text = data.get("text"); - Object metadata = data.get("metadata"); - log.info("[onNotification] agent={}, text={} chars, metadata={}", - agent, - text != null ? String.valueOf(text).length() : 0, - metadata != null ? "yes" : "no"); - if (text != null) { - log.info("[onNotification] Recovery result from {}: {}", agent, text); - } - }); - - log.info("[Orchestrator] Step 5: Execute workflow"); - WorkbenchControlPoint controlPoint = - new WorkbenchControlPoint(a2atEnvPath, new NegotiationStrategy(a2atEnvPath)); - ExecutionResult result = - ExecutePsop.builder() - .psop(workflow) - .agentCards(agentCards) - .controlPoint(controlPoint) - .engineClient(engineClient) - .runtimeIntent(messageText) - .lang("zh") - .sslVerify(sslVerify) - .credentialsConfigPath(credentialsPath) - .a2atEnvPath(a2atEnvPath) - .eventCallback(createLogCallback()) - .onFinish( - (r, events) -> { - log.info( - "[onFinish] Success={}, Events={}", - r.isSuccess(), - events.size()); - return CompletableFuture.completedFuture(null); - }) - .execute() - .join(); - return buildResultText(result); - } finally { - transport.close(); - } - } - - private List loadAgentCards() { - Map byName = new LinkedHashMap<>(); - for (String res : AGENT_CARD_RESOURCES) { - try { - var url = getClass().getClassLoader().getResource(res); - if (url != null) { - AgentCard card = - mapper.readValue(new java.io.File(url.getPath()), AgentCard.class); - byName.put(card.name(), card); - } - } catch (Exception e) { - log.warn("Failed to load agent card {}: {}", res, e.getMessage()); - } - } - return new ArrayList<>(byName.values()); - } - - private String searchPsop(String messageText) { - try { - List results = - LoadPsop.search(orchUrl, messageText, 3, null, sslVerify); - if (!results.isEmpty()) { - String psopId = results.get(0).getWorkflowId(); - log.info( - "[Orchestrator] Found PSOP: {} (score={})", - psopId, - results.get(0).getScore()); - return psopId; - } - } catch (Exception e) { - log.warn("[Orchestrator] PSOP search failed, using fallback: {}", e.getMessage()); - } - return FALLBACK_PSOP_ID; - } - - private EventCallback createLogCallback() { - return new EventCallback() { - @Override - public void onEvent(String type, Map data) { - switch (type) { - case EventType.START -> log.info(" [START] {}", data.get("workflow")); - case EventType.STEP_START -> log.info(" [STEP_START] {}", data.get("step")); - case EventType.TASK_REQUEST -> - log.info(" [TASK_REQUEST] agent={}", data.get("agent")); - case EventType.TASK_RESPONSE -> - log.info(" [TASK_RESPONSE] agent={}", data.get("agent")); - case EventType.AGENT_STATUS_UPDATE -> - log.info( - " [STATUS_UPDATE] agent={}, state={}, final={}", - data.get("agent"), - data.get("state"), - data.get("is_final")); - case EventType.AGENT_ARTIFACT_UPDATE -> - log.info( - " [ARTIFACT_UPDATE] agent={}, artifact={}", - data.get("agent"), - data.get("artifact_name")); - case EventType.AGENT_MESSAGE_EVENT -> - log.info( - " [MESSAGE] agent={}, {} chars", - data.get("agent"), - data.get("text") != null - ? ((String) data.get("text")).length() - : 0); - case EventType.STEP_COMPLETE -> - log.info(" [STEP_COMPLETE] {}", data.get("step")); - case EventType.ROUTE_DECISION -> - log.info(" [ROUTE] {} -> {}", data.get("step"), data.get("next")); - case EventType.COMPLETE -> log.info(" [COMPLETE]"); - case EventType.ERROR -> log.error(" [ERROR] {}", data.get("error")); - case EventType.CLOSE -> log.info(" [CLOSE]"); - default -> {} - } - } - }; - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java b/samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java new file mode 100644 index 00000000..a9764df8 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** Sample workbench outbound-client settings. Vendor-specific settings live separately. */ +@Component +@ConfigurationProperties(prefix = "a2a") +public class WorkbenchClientProperties { + private String orchUrl = "https://127.0.0.1:5001"; + private String credentialsPath = ""; + private boolean sslVerify; + private String a2atEnvPath = ""; + private boolean demoNegotiationEnabled; + + /** Internal sample-host setting; SpringSpnDemo supplies its local-only default per application context. */ + public boolean isDemoNegotiationEnabled() { return demoNegotiationEnabled; } + + /** Enables missing-input demonstration for this host only, never a JVM-wide switch. */ + public void setDemoNegotiationEnabled(boolean enabled) { demoNegotiationEnabled = enabled; } + + public String getOrchUrl() { + return orchUrl; + } + + public void setOrchUrl(String orchUrl) { + this.orchUrl = orchUrl; + } + + public String getCredentialsPath() { + return credentialsPath; + } + + public void setCredentialsPath(String credentialsPath) { + this.credentialsPath = credentialsPath; + } + + public boolean isSslVerify() { + return sslVerify; + } + + public void setSslVerify(boolean sslVerify) { + this.sslVerify = sslVerify; + } + + public String getA2atEnvPath() { + return a2atEnvPath; + } + + public void setA2atEnvPath(String a2atEnvPath) { + this.a2atEnvPath = a2atEnvPath; + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/demo/A2ATProtocolCases.java b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/A2ATProtocolCases.java new file mode 100644 index 00000000..a8227cac --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/A2ATProtocolCases.java @@ -0,0 +1,425 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.demo; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.client.A2ATransport; +import dev.openan.workflow.engine.client.DefaultExtensionSender; +import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; +import dev.openan.workflow.engine.client.NotificationSubscription; +import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; +import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; +import dev.openan.workflow.engine.examples.server.OmcAgentLauncher; +import dev.openan.workflow.engine.examples.util.EnvResolver; +import dev.openan.workflow.engine.model.SendMessageResult; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Reproduces the 11 protocol verification cases from the A2A-T spec extraction document. + * Run with --case=7.1 to select individual cases, or --case=all for all. + */ +public class A2ATProtocolCases { + private static final Logger log = LoggerFactory.getLogger(A2ATProtocolCases.class); + + private static final String NEGOTIATION_T_URI = A2ATExtension.NEGOTIATION_T.uri(); + + private static final String AGENT_NAME = "SPN Domain Agent City1"; + private static final long STARTUP_WAIT = 3; + + private final OmcAgentLauncher omc = new OmcAgentLauncher(); + private A2ATransport taskTransport; + private A2ATransport authorizationTransport; + private A2ATransport notificationTransport; + private DefaultWorkflowEngineClient client; + private net.openan.a2at.sdk.client.A2ATClient contentClient; + private DefaultWorkflowEngineClient notificationControlClient; + private DefaultExtensionSender authorizationSender; + private DefaultExtensionSender notificationSender; + private final AtomicReference lastTaskId = new AtomicReference<>(); + private final AtomicReference notificationTaskId = new AtomicReference<>(); + private final AtomicBoolean recoveryPlanReceived = new AtomicBoolean(); + private final AtomicBoolean recoveryNotificationReceived = new AtomicBoolean(); + private final AtomicInteger negotiationRequests = new AtomicInteger(); + private final CountDownLatch reconnectEvent = new CountDownLatch(1); + private NotificationSubscription notificationSubscription; + private CompletableFuture reconnectedSubscription; + + public static void main(String[] args) throws Exception { + String caseId = "all"; + for (String arg : args) { + if (arg.startsWith("--case=")) caseId = arg.substring("--case=".length()); + } + new A2ATProtocolCases().run(caseId); + } + + public void run(String caseId) throws Exception { + log.info("[ProtocolCases] START case={}", caseId); + setup(); + try { + if ("all".equalsIgnoreCase(caseId)) { + case_0(); case_7_1(); case_7_2(); case_7_3(); case_7_4(); + case_7_5(); case_7_6(); case_7_7(); case_7_8(); + case_7_9(); case_7_11(); case_7_10(); + } else { + switch (caseId) { + case "0" -> case_0(); + case "7.1" -> case_7_1(); + case "7.2" -> case_7_2(); + case "7.3" -> case_7_3(); + case "7.4" -> case_7_4(); + case "7.5" -> case_7_5(); + case "7.6" -> case_7_6(); + case "7.7" -> case_7_7(); + case "7.8" -> case_7_8(); + case "7.9" -> case_7_9(); + case "7.10" -> case_7_10(); + case "7.11" -> case_7_11(); + default -> log.warn("[ProtocolCases] Unknown case: {}", caseId); + } + } + } finally { teardown(); } + log.info("[ProtocolCases] DONE"); + } + + private void setup() throws Exception { + omc.startFromResource( + "agentcard/spn_domain_agent_city1.json", new SpnDomainAgentCity1Executor()); + TimeUnit.SECONDS.sleep(STARTUP_WAIT); + log.info("[ProtocolCases] OMC agent {} started on https://127.0.0.1:26335", AGENT_NAME); + + List cards = + List.of(OmcAgentLauncher.cardFromResource( + "agentcard/spn_domain_agent_city1.json")); + WorkflowEngineClientConfig config = + WorkflowEngineClientConfig.builder() + .sslVerify(false) + + .credentialsConfigPath( + OmcAgentLauncher.resourcePath("spn_agent_credentials.json")) + .build(); + // Task-T, Authorization-T and Notification-T deliberately use independent transport, + // runtime and context instances. Notification-T owns the only long-lived stream. + taskTransport = new A2ATransport(cards, null, config); + authorizationTransport = new A2ATransport(cards, null, config); + notificationTransport = new A2ATransport(cards, null, config); + client = new DefaultWorkflowEngineClient(taskTransport); + contentClient = dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new net.openan.a2at.sdk.client.A2ATClient(java.nio.file.Path.of(EnvResolver.resolveEnvPath()))); + client.setControlPoint( + new dev.openan.workflow.engine.control.DefaultControlPoint( + new dev.openan.workflow.engine.examples.negotiation.NegotiationStrategy( + EnvResolver.resolveEnvPath()))); + client.setEventCallback( + new dev.openan.workflow.engine.control.EventCallback() { + @Override + public void onEvent(String type, Map data) { + if (dev.openan.workflow.engine.control.EventType.NEGOTIATION_REQUEST + .equals(type)) { + negotiationRequests.incrementAndGet(); + } + } + }); + notificationControlClient = new DefaultWorkflowEngineClient(notificationTransport); + authorizationSender = new DefaultExtensionSender(authorizationTransport); + notificationSender = new DefaultExtensionSender(notificationTransport); + } + + private void teardown() { + if (notificationSubscription != null) notificationSubscription.close(); + if (client != null) client.close(); + if (notificationControlClient != null) notificationControlClient.close(); + if (taskTransport != null) taskTransport.close(); + if (authorizationTransport != null) authorizationTransport.close(); + if (notificationTransport != null) notificationTransport.close(); + omc.close(); + } + + /** Case 0: enumerate available A2A-T templates via the host-owned SDK. */ + private void case_0() { + log.info("[Case 0] Enumerate A2A-T templates"); + var prompts = contentClient.getPrompts(); + log.info("[Case 0] All templates: {}", prompts.size()); + for (var t : prompts) { + log.info("[Case 0] {} ({} chars)", t.templateUri().uri(), + t.content() != null ? t.content().length() : 0); + } + var negPrompts = contentClient.getPrompts().stream().filter(p -> p.templateUri().extensionName().equals("Negotiation-T")).toList(); + log.info("[Case 0] Negotiation templates: {}", negPrompts.size()); + for (var t : negPrompts) { + log.info("[Case 0] {} -> {}", t.templateUri().uri(), t.description()); + } + var one = contentClient.getPrompt(net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); + log.info("[Case 0] getTaskTemplate(private-line-complaint): present={}", one.isPresent()); + } + + private void case_7_1() throws Exception { + log.info("[Case 7.1] Create diagnosis task (Task-T, fromData track)"); + SendMessageResult result = + sendDiagnosis(dev.openan.workflow.engine.client.A2atMessages.from( + contentClient.generateTaskPromptFromDataWithSchema(SpnCasePrompts.privateLineComplaintData(), SpnCasePrompts.privateLineComplaintSchema(), net.openan.a2at.sdk.core.model.StandardTemplates + .PRIVATE_LINE_COMPLAINT.uri()), + List.of(new org.a2aproject.sdk.spec.TextPart(SpnCasePrompts.TASK_TEXT)))) + .join(); + log.info("[Case 7.1] state={}, textLen={}", result.getTaskState(), result.getText() != null ? result.getText().length() : 0); + if (result.getTask() != null) { lastTaskId.set(result.getTask().id()); log.info("[Case 7.1] taskId={}", result.getTask().id()); } + } + + private CompletableFuture sendDiagnosis(dev.openan.workflow.engine.model.MessageContent content) { + // Fault-injection cases omit/mutate the transmitted field; the host keeps authoritative input + // to demonstrate a legitimate correction, never a hardcoded engine negotiation fallback. + var request = dev.openan.workflow.engine.model.TaskRequest.builder().agentName(AGENT_NAME) + .executionId(java.util.UUID.randomUUID().toString()).taskId(java.util.UUID.randomUUID().toString()) + .stepName("diagnosis_city1").instruction(SpnCasePrompts.TASK_TEXT) + .input(dev.openan.workflow.engine.model.BusinessInput.data(SpnCasePrompts.privateLineComplaintData())).build(); + var callbacks = new dev.openan.workflow.engine.control.DefaultControlPoint( + new dev.openan.workflow.engine.examples.negotiation.NegotiationStrategy(EnvResolver.resolveEnvPath())); + return client.dispatch(request, content, callbacks); + } + + private void case_7_2() throws Exception { + String taskId = lastTaskId.get(); + if (taskId == null) { log.info("[Case 7.2] No task, running 7.1 first"); case_7_1(); taskId = lastTaskId.get(); } + log.info("[Case 7.2] Query task: {}", taskId); + SendMessageResult result = client.getTask(AGENT_NAME, taskId).join(); + log.info("[Case 7.2] state={}, artifacts={}", result.getTaskState(), result.getTask() != null && result.getTask().artifacts() != null ? result.getTask().artifacts().size() : 0); + } + + private void case_7_3() { + log.info("[Case 7.3] Negotiation - missing required parameter"); + int before = negotiationRequests.get(); + Map metadata = SpnCasePrompts.taskTMetadata( + SpnCasePrompts.privateLineComplaintPromptBlankObject()); + SendMessageResult result = sendDiagnosis(new dev.openan.workflow.engine.model.MessageContent( + List.of(new org.a2aproject.sdk.spec.TextPart(SpnCasePrompts.TASK_TEXT + "(参数缺失)")), metadata, + java.util.Set.of(A2ATExtension.TASK_T.uri()))).join(); + requireCompletedNegotiation("7.3", result, before); + log.info("[Case 7.3] state={}, metaKeys={}", result.getTaskState(), result.getMetadata() != null ? result.getMetadata().keySet() : "none"); + } + + private void case_7_4() { + log.info("[Case 7.4] Negotiation - semantic error (Task-T fromData + Negotiation-T)"); + int before = negotiationRequests.get(); + Map metadata = new LinkedHashMap<>(); + metadata.putAll(contentClient.generateTaskPromptFromDataWithSchema( + SpnCasePrompts.privateLineComplaintDataUnknownPort(), SpnCasePrompts.privateLineComplaintSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()).buildMetadataContent()); + SendMessageResult result = sendDiagnosis(new dev.openan.workflow.engine.model.MessageContent( + List.of(new org.a2aproject.sdk.spec.TextPart(SpnCasePrompts.TASK_TEXT + "(语义错误)")), metadata, + java.util.Set.of(A2ATExtension.TASK_T.uri()))).join(); + requireCompletedNegotiation("7.4", result, before); + log.info("[Case 7.4] state={}, metaKeys={}", result.getTaskState(), result.getMetadata() != null ? result.getMetadata().keySet() : "none"); + } + + private void requireCompletedNegotiation( + String caseId, SendMessageResult result, int requestCountBefore) { + if (result == null + || result.getTaskState() == null + || !result.getTaskState().endsWith("COMPLETED") + || negotiationRequests.get() <= requestCountBefore) { + throw new IllegalStateException( + "Case " + + caseId + + " did not complete a Negotiation-T round: state=" + + (result != null ? result.getTaskState() : "null") + + ", negotiationRequests=" + + negotiationRequests.get()); + } + } + + private void case_7_5() { + log.info("[Case 7.5] Add authorization (Authorization-T)"); + SendMessageResult result = + authorizationSender.sendAuthorization(AGENT_NAME, + dev.openan.workflow.engine.client.A2atMessages.from( + contentClient.generateAuthPromptFromDataWithSchema(SpnCasePrompts.addAuthorizationData(), SpnCasePrompts.authorizationSchema(), net.openan.a2at.sdk.core.model.StandardTemplates + .AUTHORIZATION_POLICY_MANAGEMENT.uri()), + List.of(new org.a2aproject.sdk.spec.TextPart("新增动网操作授权")))) + .join(); + requireExtensionResponse( + result, + A2ATExtension.AUTHORIZATION_T.uri(), + "授权操作执行结果:成功", + "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"); + log.info("[Case 7.5] state={}", result.getTaskState()); + } + + private void case_7_6() { + log.info("[Case 7.6] Delete authorization (Authorization-T)"); + SendMessageResult result = + authorizationSender.sendAuthorization(AGENT_NAME, + dev.openan.workflow.engine.client.A2atMessages.from( + contentClient.generateAuthPromptFromDataWithSchema(Map.of( + "授权策略的操作类型", + "删除授权策略", + "动网操作的授权策略列表", + "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"), SpnCasePrompts.authorizationSchema(), net.openan.a2at.sdk.core.model.StandardTemplates + .AUTHORIZATION_POLICY_MANAGEMENT.uri()), + List.of(new org.a2aproject.sdk.spec.TextPart("删除动网操作的授权")))) + .join(); + requireExtensionResponse( + result, + A2ATExtension.AUTHORIZATION_T.uri(), + "授权操作执行结果:成功"); + log.info("[Case 7.6] state={}", result.getTaskState()); + } + + private void case_7_7() { + log.info("[Case 7.7] Query authorization (Authorization-T)"); + SendMessageResult result = + authorizationSender.sendAuthorization(AGENT_NAME, + dev.openan.workflow.engine.client.A2atMessages.from( + contentClient.generateAuthPromptFromDataWithSchema(Map.of( + "授权策略的操作类型", + "查询授权策略", + "动网操作的授权策略列表", + ""), SpnCasePrompts.authorizationSchema(), net.openan.a2at.sdk.core.model.StandardTemplates + .AUTHORIZATION_POLICY_MANAGEMENT.uri()), + List.of(new org.a2aproject.sdk.spec.TextPart("查询业务抢通操作授权信息")))) + .join(); + requireExtensionResponse( + result, + A2ATExtension.AUTHORIZATION_T.uri(), + "授权操作执行结果:成功", + "无匹配授权策略"); + log.info("[Case 7.7] state={}", result.getTaskState()); + } + + private void case_7_8() { + log.info("[Case 7.8] Subscribe notification (Notification-T)"); + recoveryPlanReceived.set(false); + recoveryNotificationReceived.set(false); + notificationSubscription = + notificationSender.openNotification(AGENT_NAME, + dev.openan.workflow.engine.client.A2atMessages.from( + contentClient.generateNotificationPromptFromDataWithSchema(SpnCasePrompts.subscribeServiceRecoveryData(), SpnCasePrompts.serviceRecoverySchema(), net.openan.a2at.sdk.core.model.StandardTemplates.SERVICE_RECOVERY.uri()), + List.of(new org.a2aproject.sdk.spec.TextPart("订阅业务抢通事件"))), + (handle, received) -> { + if (received.artifacts().stream().anyMatch(artifact -> "recovery-plan".equals(artifact.name()))) + recoveryPlanReceived.set(true); + if (received.artifacts().stream().anyMatch(artifact -> "recovery-result".equals(artifact.name()))) + recoveryNotificationReceived.set(true); + }); + SendMessageResult result = notificationSubscription.acknowledgement().join(); + requireExtensionResponse( + result, + A2ATExtension.NOTIFICATION_T.uri(), + "订阅结果:成功"); + if (result.getTask() != null) { + notificationTaskId.set(result.getTask().id()); + } + log.info("[Case 7.8] ack state={}", result.getTaskState()); + } + + private static void requireExtensionResponse( + SendMessageResult result, String extensionUri, String... expectedFragments) { + Map metadata = result != null ? result.getMetadata() : null; + Object value = metadata != null ? metadata.get(extensionUri) : null; + String response = value instanceof String text ? text : ""; + for (String fragment : expectedFragments) { + if (!response.contains(fragment)) { + throw new IllegalStateException( + "Protocol response metadata for " + + extensionUri + + " is missing '" + + fragment + + "': " + + response); + } + } + } + + private void case_7_9() throws Exception { + log.info("[Case 7.9] Notification report (Notification-T)"); + if (notificationTaskId.get() == null) { + log.info("[Case 7.9] No notification task, running 7.8 first"); + case_7_8(); + } + // Case 7.6 deliberately removed the policy. Re-add it before diagnosis so the documented + // plan -> whitelist match -> automatic execution -> result lifecycle can complete. + case_7_5(); + case_7_1(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while ((!recoveryPlanReceived.get() || !recoveryNotificationReceived.get()) + && System.nanoTime() < deadline) { + TimeUnit.MILLISECONDS.sleep(100); + } + if (!recoveryPlanReceived.get() || !recoveryNotificationReceived.get()) { + throw new IllegalStateException( + "Case 7.9 expected both recovery-plan and recovery-result events; plan=" + + recoveryPlanReceived.get() + + ", result=" + + recoveryNotificationReceived.get()); + } + log.info("[Case 7.9] Recovery plan and automatic-execution result received"); + } + + private void case_7_10() throws Exception { + if (notificationTaskId.get() == null) { + log.info("[Case 7.10] No notification task, running 7.8 first"); + case_7_8(); + } + String taskId = notificationTaskId.getAndSet(null); + log.info("[Case 7.10] Cancel subscription: {}", taskId); + SendMessageResult result = notificationControlClient.cancelTask(AGENT_NAME, taskId).join(); + if (reconnectedSubscription != null) { + SendMessageResult terminal = reconnectedSubscription.get(5, TimeUnit.SECONDS); + if (terminal.getTaskState() == null + || !terminal.getTaskState().contains("CANCELED")) { + throw new IllegalStateException( + "Case 7.10 expected reconnected stream to end as CANCELED, state=" + + terminal.getTaskState()); + } + reconnectedSubscription = null; + } + if (notificationSubscription != null) { + notificationSubscription.close(); + notificationSubscription = null; + } + log.info("[Case 7.10] state={}", result.getTaskState()); + } + + private void case_7_11() throws Exception { + String taskId = notificationTaskId.get(); + if (taskId == null) { + log.info("[Case 7.11] No notification task, running 7.8 first"); + case_7_8(); + taskId = notificationTaskId.get(); + } + if (notificationSubscription != null) { + notificationSubscription.close(); + notificationSubscription = null; + } + log.info("[Case 7.11] Reconnect: {}", taskId); + reconnectedSubscription = + notificationControlClient + .subscribeToTask( + AGENT_NAME, + taskId, + data -> { + reconnectEvent.countDown(); + log.info("[Case 7.11] callback: keys={}", data.keySet()); + }); + if (!reconnectEvent.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Case 7.11 reconnect produced no protocol event"); + } + if (reconnectedSubscription.isDone()) { + throw new IllegalStateException( + "Case 7.11 reconnect stream ended before explicit cancellation"); + } + log.info("[Case 7.11] reconnected stream is active"); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpnCasePrompts.java b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpnCasePrompts.java new file mode 100644 index 00000000..5c2ee393 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpnCasePrompts.java @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.demo; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.extension.SdkSlotSchemaLoader; + +import net.openan.a2at.sdk.core.model.StandardTemplates; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Original business data for the SPN private-line complaint scenario. + * + *

Holds the raw structured fields (not the rendered prompt): the demo passes these to the + * host business callbacks, which call the A2A-T SDK's FromDataWithSchema API. The SDK + * renders the Task-T prompt from the data + schema. This bypasses scenario recognition but the + * SDK's schema-aware slot extraction may invoke its configured LLM. Callers hand over business + * data, never a pre-rendered protocol prompt. + * + *

The prompt-fixture variants (blank object / unknown port) for the protocol verification + * cases are expressed as data too: the same schema with different values. + */ +public final class SpnCasePrompts { + + /** Standard Task-T extension URI. */ + public static final String TASK_T_URI = A2ATExtension.TASK_T.uri(); + + /** Northbound user text accompanying the Task-T prompt (spec case 7.1). */ + public static final String TASK_TEXT = "创建专线业务投诉诊断任务"; + + private SpnCasePrompts() {} + + /** + * JSON schema for the private-line complaint data, mirroring the SDK's bundled slot schema + * ({@code Task-T/network-layer/private-line-complaint/v1}): 任务对象 identifies the line, + * 任务上下文 carries the complaint context. + */ + public static Map privateLineComplaintSchema() { + return SdkSlotSchemaLoader.loadConfigured(StandardTemplates.PRIVATE_LINE_COMPLAINT); + } + + /** Required business fields returned by Task-T validation for this scenario. */ + @SuppressWarnings("unchecked") + public static List privateLineComplaintSchemaProperties() { + Object required = privateLineComplaintSchema().get("required"); + if (!(required instanceof List fields) + || fields.stream().anyMatch(field -> !(field instanceof String))) { + throw new IllegalStateException( + "Current SDK private-line complaint schema has no string required list"); + } + return (List) fields; + } + + /** Explicit sample data source, shared by initial task preparation and later negotiation. */ + public static Map complaintData(dev.openan.workflow.engine.model.TaskRequest request) { + Object input = request.getInput() == null ? null : request.getInput().data(); + if (input != null) { + if (!(input instanceof Map)) throw new IllegalArgumentException("Complaint input must be an object"); + return new com.fasterxml.jackson.databind.ObjectMapper().convertValue(input, + new com.fasterxml.jackson.core.type.TypeReference>() {}); + } + if ("SPN Domain Agent City1".equals(request.getAgentName()) || "diagnosis_city1".equals(request.getStepName())) + return privateLineComplaintData(); + if ("SPN Domain Agent City2".equals(request.getAgentName()) || "diagnosis_city2".equals(request.getStepName())) + return privateLineComplaintDataCity2(); + throw new IllegalArgumentException("No complaint input configured for this sample task"); + } + + /** Well-formed complaint (spec case 7.1): known faulty port in City1. */ + public static Map privateLineComplaintData() { + Map data = new LinkedHashMap<>(); + data.put("任务对象", "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17"); + data.put( + "任务上下文", + "投诉分类:\"专线质差\";问题发生时间:\"2026-05-11T08:21:46Z\";" + + "OSS侧事件流水号:\"event-id-20260511-09013\";" + + "投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," + + "访问广州机房的核心交易系统非常慢。\""); + return data; + } + + /** Blank task-object variant (spec case 7.3): triggers blank-slot negotiation. */ + public static Map privateLineComplaintDataBlankObject() { + Map data = new LinkedHashMap<>(); + data.put("任务对象", ""); + data.put( + "任务上下文", + "投诉分类:;问题发生时间:\"2026-05-11T08:21:46Z\";" + + "OSS侧事件流水号:\"event-id-20260511-09013\""); + return data; + } + + /** Protocol-case fixture intentionally carrying a blank required task object. */ + public static String privateLineComplaintPromptBlankObject() { + return "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" + + "## 任务对象(Task Object)\n接入端口名称:\n\n" + + "## 任务上下文(Task Context)\n投诉分类:专线质差;" + + "问题发生时间:2026-05-11T08:21:46Z;" + + "OSS侧事件流水号:event-id-20260511-09013"; + } + + /** Unknown-port variant (spec case 7.4): triggers the semantic-error negotiation path. */ + public static Map privateLineComplaintDataUnknownPort() { + Map data = new LinkedHashMap<>(); + data.put("任务对象", "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-18"); + data.put( + "任务上下文", + "投诉分类:\"专线质差\";问题发生时间:\"2026-05-11T08:21:46Z\";" + + "OSS侧事件流水号:\"event-id-20260511-09013\""); + return data; + } + + /** + * City2-scoped complaint for the workbench's southbound dispatch: complete parameters + * (distinct port in City2's range), no negotiation expected. + */ + public static Map privateLineComplaintDataCity2() { + Map data = new LinkedHashMap<>(); + data.put("任务对象", "接入端口名称:P882-珠江新城-PTN7900-23-TPA1EG24-11"); + data.put( + "任务上下文", + "投诉分类:\"专线质差\";问题发生时间:\"2026-05-11T08:21:46Z\";" + + "OSS侧事件流水号:\"event-id-20260511-09013\";" + + "投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," + + "访问广州机房的核心交易系统非常慢。\""); + return data; + } + + /** Task-T metadata map carrying the given prompt under the standard extension key. */ + public static Map taskTMetadata(String taskPrompt) { + Map metadata = new LinkedHashMap<>(); + metadata.put(TASK_T_URI, taskPrompt); + metadata.put(net.openan.a2at.sdk.core.model.MetadataContent.TEMPLATE_URI_METADATA_KEY, + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); + return metadata; + } + + /** Explicit local fault injection; keep the original city complaint context unchanged. */ + public static String withoutTaskObject(String prompt) { + int start = prompt.indexOf("## 任务对象"); + int end = prompt.indexOf("## 任务上下文", start); + if (start < 0 || end < 0) throw new IllegalArgumentException("Missing Task-T sections"); + return prompt.substring(0, start) + "## 任务对象(Task Object)\n接入端口名称:\n\n" + + prompt.substring(end); + } + + /** Local Demo defaults to negotiation; external OMCs never receive automatic fault injection. */ + public static boolean demoNegotiationEnabled(boolean embeddedOmc) { + String value = System.getProperty("a2at.samples.negotiation"); + if (value != null && !List.of("true", "false").contains(value.toLowerCase(java.util.Locale.ROOT))) + throw new IllegalArgumentException("a2at.samples.negotiation must be true or false"); + boolean enabled = value == null ? embeddedOmc : Boolean.parseBoolean(value); + if (enabled && !embeddedOmc) + throw new IllegalArgumentException("a2at.samples.negotiation is only allowed with local embedded OMCs"); + if (enabled) injectNegotiation("diagnosis_city1", true); // Validate city before starting servers. + return enabled; + } + + /** City selection applies only to this host instance's enabled demonstration. */ + public static boolean injectNegotiation(String stepName, boolean enabled) { + if (!enabled) return false; + String city = System.getProperty("a2at.samples.negotiation.city", "city1"); + if (!List.of("city1", "city2", "both").contains(city)) + throw new IllegalArgumentException("a2at.samples.negotiation.city must be city1, city2 or both"); + return ("both".equals(city) && List.of("diagnosis_city1", "diagnosis_city2").contains(stepName)) + || ("diagnosis_" + city).equals(stepName); + } + + // ------------------------------------------------------------------ + // Authorization-T (spec case 7.5: add authorization policy) + // ------------------------------------------------------------------ + + /** Schema mirroring the SDK's Authorization-T slot schema (授权策略的操作类型 + 策略列表). */ + public static Map authorizationSchema() { + return SdkSlotSchemaLoader.loadConfigured( + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + } + + /** Add-authorization data (spec case 7.5): whitelist the tunnel-tuning recovery action. */ + public static Map addAuthorizationData() { + Map data = new LinkedHashMap<>(); + data.put("授权策略的操作类型", "新增授权策略"); + data.put( + "动网操作的授权策略列表", + "1. 业务场景是业务投诉诊断,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-06-01~2030-06-18"); + return data; + } + + // ------------------------------------------------------------------ + // Notification-T (spec case 7.8: subscribe service-recovery events) + // ------------------------------------------------------------------ + + /** Schema mirroring the SDK's Notification-T service-recovery slot schema. */ + public static Map serviceRecoverySchema() { + return SdkSlotSchemaLoader.loadConfigured(StandardTemplates.SERVICE_RECOVERY); + } + + /** Service-recovery subscription data (spec case 7.8). */ + public static Map subscribeServiceRecoveryData() { + Map data = new LinkedHashMap<>(); + data.put("订阅条件", ""); + data.put( + "上报通知数据格式", + "业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、" + + "OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、" + + "业务抢通方案名称、业务抢通方案详情、业务抢通方案执行结束时间、" + + "业务抢通方案执行结果(成功、失败)及业务抢通方案执行失败原因(执行结果为失败时必填)。"); + return data; + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemo.java b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemo.java new file mode 100644 index 00000000..f652d5d8 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemo.java @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.demo; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import dev.openan.workflow.engine.client.A2ATransport; +import dev.openan.workflow.engine.client.AgentCardJacksonModule; +import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; +import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; +import dev.openan.workflow.engine.examples.SpringWorkbenchApplication; +import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; +import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity2Executor; +import dev.openan.workflow.engine.examples.server.OmcAgentLauncher; +import dev.openan.workflow.engine.examples.util.EnvResolver; +import dev.openan.workflow.engine.examples.workbench.SpringWorkbenchExtensionLifecycle; +import dev.openan.workflow.engine.model.SendMessageResult; +import dev.openan.workflow.engine.spring.A2AController; + +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ConfigurableApplicationContext; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Demo entry point for the SPN cross-city diagnosis (direct HTTP+JSON transport). + * + *

The Workbench Agent runs as a Spring Boot service. The demo orchestrates: + * + *

    + *
  1. Start Spring Boot Workbench Agent (A2A server on port 26337) + *
  2. Start OMC agents (embedded servers on ports 26335, 26336) + *
  3. Send a Task-T message to the Workbench Agent via the SDK client + *
  4. Print the response and shut everything down + *
+ * + *

Demonstrates heterogeneous architecture: Workbench (Spring Boot) and OMC (JDK HttpServer) + * communicate via the unified A2A-T protocol. + */ +public class SpringSpnDemo { + private static final Logger log = LoggerFactory.getLogger(SpringSpnDemo.class); + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(new AgentCardJacksonModule()); + + private static final String WB_AGENT_NAME = "Transport Workbench Agent"; + private static final long STARTUP_WAIT = 3; + + private final OmcAgentLauncher omc = new OmcAgentLauncher(); + + public static void main(String[] args) throws Exception { + SpringWorkbenchApplication.loadDotEnv(); + new SpringSpnDemo().run(args); + } + + public void run(String... applicationArgs) throws Exception { + long demoStarted = System.nanoTime(); + ConfigurableApplicationContext ctx = null; + boolean success = false; + boolean demoNegotiation = SpnCasePrompts.demoNegotiationEnabled(true); + log.info("[Demo] NEGOTIATION_DEMO enabled={}, city={} (local default: City1 negotiates, City2 diagnoses directly; " + + "disable with -Da2at.samples.negotiation=false)", demoNegotiation, + System.getProperty("a2at.samples.negotiation.city", "city1")); + log.info( + "[Demo] START mode=direct, " + + "workbench=https://127.0.0.1:26337/a2a/json, omcPorts=[26335,26336]"); + try { + long stageStarted; + stageStarted = System.nanoTime(); + log.info("[Demo] STAGE_START stage=start-omc-agents"); + omc.startFromResource( + "agentcard/spn_domain_agent_city1.json", new SpnDomainAgentCity1Executor()); + omc.startFromResource( + "agentcard/spn_domain_agent_city2.json", new SpnDomainAgentCity2Executor()); + log.info("[Demo] Waiting {}s for agent ports to bind", STARTUP_WAIT); + TimeUnit.SECONDS.sleep(STARTUP_WAIT); + log.info( + "[Demo] STAGE_DONE stage=start-omc-agents, count={}, elapsedMs={}", + omc.servers().size(), + elapsedMillis(stageStarted)); + + stageStarted = System.nanoTime(); + log.info("[Demo] STAGE_START stage=start-spring-workbench"); + SpringApplication application = new SpringApplication(SpringWorkbenchApplication.class); + application.addInitializers(context -> context.getEnvironment().getPropertySources().addFirst( + new org.springframework.core.env.MapPropertySource("spnDemoNegotiation", + java.util.Map.of("a2a.demo-negotiation-enabled", demoNegotiation)))); + ctx = application.run(applicationArgs); + log.info( + "[Demo] STAGE_DONE stage=start-spring-workbench, elapsedMs={}", + elapsedMillis(stageStarted)); + + stageStarted = System.nanoTime(); + log.info( + "[Demo] STAGE_START stage=send-workbench-task, target={}, inputChars={}", + WB_AGENT_NAME, + SpnCasePrompts.TASK_TEXT.length()); + log.debug("[Demo] Workbench task text={}", SpnCasePrompts.TASK_TEXT); + SendMessageResult result = sendTaskToWorkbench(); + requireCompleted(result); + String response = result.getText(); + log.info( + "[Demo] STAGE_DONE stage=send-workbench-task, responseChars={}, elapsedMs={}", + response != null ? response.length() : 0, + elapsedMillis(stageStarted)); + if (response != null) { + log.info("[Demo] Workbench response={}", response); + } else { + log.warn("[Demo] Workbench returned no response text"); + } + success = true; + // Task result is already final. This bounded observation only keeps the local + // demonstration alive long enough to show its independent recovery notification. + { + boolean observed = + ctx.getBean(SpringWorkbenchExtensionLifecycle.class) + .awaitFirstRecovery(java.time.Duration.ofSeconds(10)); + log.info("[Demo] RECOVERY_OBSERVATION received={}, workflowSuccess=true", observed); + } + } catch (Exception e) { + log.error( + "[Demo] FAILED elapsedMs={}, errorType={}, message={}", + elapsedMillis(demoStarted), + e.getClass().getSimpleName(), + e.getMessage(), + e); + throw e; + } finally { + log.info( + "[Demo] SHUTDOWN_START omcCount={}, springStarted={}", + omc.servers().size(), + ctx != null); + if (ctx != null) { + try { + boolean drained = + ctx.getBean(A2AController.class) + .awaitStreamsDrained(java.time.Duration.ofSeconds(2)); + if (!drained) { + log.warn( + "[Demo] SSE_DRAIN_TIMEOUT activeStreams={}, timeoutSeconds=2", + ctx.getBean(A2AController.class).activeStreamCount()); + } else { + log.info("[Demo] SSE_DRAIN_DONE activeStreams=0"); + } + } catch (Exception e) { + log.warn("[Demo] Failed to drain SSE responses: {}", e.getMessage(), e); + } + try { + // Drain outbound Notification-T calls before Spring starts stopping Tomcat. + // The bean's @PreDestroy call is idempotent. + ctx.getBean(SpringWorkbenchExtensionLifecycle.class).close(); + } catch (Exception e) { + log.warn( + "[Demo] Failed to close workbench extension lifecycle: {}", + e.getMessage(), + e); + } + try { + // Outbound subscriptions are closed. Stop locally owned OMC streams before + // dependent gateway/server infrastructure is disposed. + omc.close(); + ctx.close(); + } catch (Exception e) { + log.warn("[Demo] Failed to close Spring context: {}", e.getMessage(), e); + } + } + omc.close(); + log.info( + "[Demo] SHUTDOWN_DONE success={}, elapsedMs={}", + success, + elapsedMillis(demoStarted)); + } + } + + /** + * Northbound Task-T message to the Workbench Agent, mirroring spec case 7.1. + * + *

Structured-data track: the demo hands over the raw complaint data + schema and the + * A2A-T SDK renders the Task-T prompt through its schema-aware fromData pipeline (no + * hand-written prompt and no scenario-recognition call; slot extraction may use the SDK LLM). + */ + private SendMessageResult sendTaskToWorkbench() throws Exception { + long started = System.nanoTime(); + AgentCard wbCard = + OmcAgentLauncher.cardFromResource("agentcard/transport_workbench_agent.json"); + String credPath = OmcAgentLauncher.resourcePath("spn_agent_credentials.json"); + String envPath = EnvResolver.resolveEnvPath(); + + A2ATransport transport = + new A2ATransport( + List.of(wbCard), + null, + WorkflowEngineClientConfig.builder() + .sslVerify(false) + + .credentialsConfigPath(credPath) + .build()); + DefaultWorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); + try { + log.info( + "[Demo] NORTHBOUND_SEND target={}, contextId={}, endpoint={}, inputChars={}, track=fromData", + WB_AGENT_NAME, + transport.getContextId(), + wbCard.supportedInterfaces().isEmpty() + ? "?" + : wbCard.supportedInterfaces().get(0).url(), + SpnCasePrompts.TASK_TEXT.length()); + SendMessageResult result = + client.sendMessage(WB_AGENT_NAME, + dev.openan.workflow.engine.client.A2atMessages.from( + dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new net.openan.a2at.sdk.client.A2ATClient(java.nio.file.Path.of(envPath))) + .generateTaskPromptFromDataWithSchema(SpnCasePrompts.privateLineComplaintData(), SpnCasePrompts.privateLineComplaintSchema(), net.openan.a2at.sdk.core.model.StandardTemplates + .PRIVATE_LINE_COMPLAINT.uri()), + List.of(new org.a2aproject.sdk.spec.TextPart(SpnCasePrompts.TASK_TEXT)))) + .join(); + log.info( + "[Demo] NORTHBOUND_DONE target={}, contextId={}, state={}, responseChars={}, elapsedMs={}", + WB_AGENT_NAME, + transport.getContextId(), + result.getTaskState(), + result.getText() != null ? result.getText().length() : 0, + elapsedMillis(started)); + return result; + } finally { + client.close(); + transport.close(); + } + } + + static void requireCompleted(SendMessageResult result) { + if (result == null + || result.getTaskState() == null + || !result.getTaskState().contains("TASK_STATE_COMPLETED")) { + throw new IllegalStateException( + "Workbench task failed: state=" + + (result != null ? result.getTaskState() : "null") + + ", response=" + + (result != null ? result.getText() : "null")); + } + } + + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/embedded/SpnCrossCityDiagnosisDemo.java b/samples/src/main/java/dev/openan/workflow/engine/examples/embedded/SpnCrossCityDiagnosisDemo.java deleted file mode 100644 index 5cdc8748..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/embedded/SpnCrossCityDiagnosisDemo.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.embedded; - -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.openan.workflow.engine.client.A2ATransport; -import dev.openan.workflow.engine.client.AgentCardJacksonModule; -import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; -import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; -import dev.openan.workflow.engine.control.EventCallback; -import dev.openan.workflow.engine.control.EventType; -import dev.openan.workflow.engine.examples.agents.EnvResolver; -import dev.openan.workflow.engine.model.SendMessageResult; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -/** - * SPN cross-city fault diagnosis demo entry point. - * - *

Starts all 3 A2A agents (via {@link StartAgentsServer}) in a background thread, waits for them - * to bind, then uses {@link DefaultWorkflowEngineClient} to send a Task-T message to the Workbench - * Agent. The client internally handles A2A-T protocol: Task-T prompt generation, SSE streaming - * response parsing, negotiation auto-loop, etc. - * - *

Architecture: - * - *

    - *
  • Transport Workbench Agent (port 26337) - orchestrator + merge - *
  • SPN Domain Agent City1 (port 26335) - City1 OMC, has fault - *
  • SPN Domain Agent City2 (port 26336) - City2 OMC, normal - *
- */ -public class SpnCrossCityDiagnosisDemo { - private static final Logger log = LoggerFactory.getLogger(SpnCrossCityDiagnosisDemo.class); - private static final ObjectMapper mapper = - new ObjectMapper().registerModule(new AgentCardJacksonModule()); - - private static final String AGENT_CARD_RESOURCE = "agentcard/transport_workbench_agent.json"; - private static final String WB_AGENT_NAME = "Transport Workbench Agent"; - private static final long AGENT_STARTUP_WAIT_SECONDS = 3; - - public static void main(String[] args) throws Exception { - log.info("=== SPN Cross-City Fault Diagnosis Demo ==="); - - log.info("=== Step 1: Start all A2A agents ==="); - StartAgentsServer agentsServer = new StartAgentsServer(); - Thread agentThread = new Thread(agentsServer, "agents-starter"); - agentThread.setDaemon(true); - agentThread.start(); - log.info("Waiting for agents to start..."); - TimeUnit.SECONDS.sleep(AGENT_STARTUP_WAIT_SECONDS); - - log.info("=== Step 2: Send Task-T to Workbench Agent ==="); - String taskText = "SPN跨城专线故障诊断:" + "客户A城市1-城市2间SPN专线中断," + "请协同两地市OMC并行诊断," + "汇总分析确定故障在哪个地市"; - log.info("Sending task: {}", taskText); - String response = sendTaskToWorkbench(taskText); - log.info("=== Workbench Agent Response ==="); - if (response != null) { - log.info("Response ({} chars):\n{}", response.length(), response); - } else { - log.warn("Response was null"); - } - - log.info("=== Demo complete, shutting down ==="); - agentsServer.stop(); - // Force exit: JDK HttpClient and SDK internal thread pools may leave - // non-daemon threads that prevent the JVM from exiting. - System.exit(0); - } - - /** - * Send a Task-T message to the Workbench Agent via the workflow engine client. - * - *

Uses {@link DefaultWorkflowEngineClient} which internally handles: A2A REST - * message:stream, SSE response parsing (statusUpdate/artifactUpdate), Task-T prompt generation, - * and negotiation auto-loop. - */ - private static String sendTaskToWorkbench(String taskText) throws Exception { - String cardPath = - SpnCrossCityDiagnosisDemo.class - .getClassLoader() - .getResource(AGENT_CARD_RESOURCE) - .getPath(); - AgentCard agentCard = mapper.readValue(new java.io.File(cardPath), AgentCard.class); - - A2ATransport transport = - new A2ATransport( - List.of(agentCard), - null, - WorkflowEngineClientConfig.builder() - .sslVerify(false) - .a2atEnvPath(EnvResolver.resolveEnvPath()) - .build()); - DefaultWorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport); - - engineClient.setEventCallback( - new EventCallback() { - @Override - public void onEvent(String type, Map data) { - switch (type) { - case EventType.AGENT_STATUS_UPDATE -> - log.info( - " >> [STATUS] agent={}, state={}, final={}", - data.get("agent"), - data.get("state"), - data.get("is_final")); - case EventType.AGENT_ARTIFACT_UPDATE -> - log.info( - " >> [ARTIFACT] agent={}, name={}, text={}", - data.get("agent"), - data.get("artifact_name"), - data.get("text")); - case EventType.AGENT_MESSAGE_EVENT -> - log.info( - " >> [MESSAGE] agent={}, text={}", - data.get("agent"), - data.get("text")); - case EventType.AGENT_REQUEST -> - log.info( - " >> [REQUEST] agent={}, {} chars", - data.get("agent"), - data.get("request") != null - ? String.valueOf(data.get("request")).length() - : 0); - case EventType.AGENT_RESPONSE -> - log.info( - " >> [RESPONSE] agent={}, response={}", - data.get("agent"), - data.get("response") != null - ? data.get("response") - : "(empty)"); - default -> { - /* other event types not shown in this demo */ - } - } - } - }); - - // sendMessage handles A2A-T protocol internally - SendMessageResult result = engineClient.sendMessage(WB_AGENT_NAME, taskText).join(); - log.info("[SelfTrigger] Task state: {}", result.getTaskState()); - engineClient.close(); - transport.close(); - return result.getText(); - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/embedded/StartAgentsServer.java b/samples/src/main/java/dev/openan/workflow/engine/examples/embedded/StartAgentsServer.java deleted file mode 100644 index 415cb5ec..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/embedded/StartAgentsServer.java +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.embedded; - -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; -import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity2Executor; -import dev.openan.workflow.engine.examples.agents.TransportWorkbenchAgentExecutor; -import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; -import dev.openan.workflow.engine.registry.RegistryClient; - -import org.a2aproject.sdk.server.agentexecution.AgentExecutor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.net.URI; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; - -/** - * Starts all sample A2A agents from agentcard JSON config files. - * - *

Each agent is an independent A2A server (client + server). Agents register their AgentCards in - * the registry center on startup. - * - *

Implements {@link Runnable} so it can be started in a background thread by the demo, or run - * standalone via {@link #main}. - * - *

Mirrors Python: samples/start_agents_server.py - */ -public class StartAgentsServer implements Runnable { - static final String REGISTRY_URL = "https://127.0.0.1:5000"; - static final String ORCH_URL = "https://127.0.0.1:5001"; - static final String CRED_FILE = "spn_agent_credentials.json"; - static final String A2AT_ENV_FILE = ".env"; - private static final Logger log = LoggerFactory.getLogger(StartAgentsServer.class); - private static final ObjectMapper mapper = new ObjectMapper(); - - // Must be set before any HttpClient is created: the JDK caches this - // property in a static final field at class-load time. If a2a-java SDK - // or any other HttpClient is initialized first, setting it later has - // no effect and self-signed cert hostname verification will fail. - static { - System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); - } - - private final List servers = new ArrayList<>(); - private final CountDownLatch shutdownLatch = new CountDownLatch(1); - private volatile boolean running = true; - - @SuppressWarnings("unchecked") - private static AgentEntry loadAgent(String resourcePath, AgentExecutor executor) - throws Exception { - String path = StartAgentsServer.class.getClassLoader().getResource(resourcePath).getPath(); - Map card = mapper.readValue(new java.io.File(path), Map.class); - List> ifaces = - (List>) card.getOrDefault("supportedInterfaces", List.of()); - String url = - ifaces.isEmpty() ? "https://127.0.0.1:0" : String.valueOf(ifaces.get(0).get("url")); - URI uri = URI.create(url); - String host = uri.getHost() != null ? uri.getHost() : "127.0.0.1"; - int port = uri.getPort() > 0 ? uri.getPort() : 0; - return new AgentEntry(host, port, card, executor); - } - - private static void registerAgent(Map agentCard, boolean sslVerify) { - try { - RegistryClient registry = new RegistryClient(REGISTRY_URL, sslVerify); - registry.registerAgentCard(agentCard); - log.info("Registered agent: {}", agentCard.get("name")); - } catch (Exception e) { - log.warn("Registration failed for {}: {}", agentCard.get("name"), e.getMessage()); - } - } - - /** - * Resolve the A2AT {@code .env} file path. Searches classpath, then the project root (two - * levels up from the samples target/classes dir), then the current working directory. Returns - * null if not found. - */ - public static String resolveEnvPath() { - var url = StartAgentsServer.class.getClassLoader().getResource(A2AT_ENV_FILE); - if (url != null && "file".equals(url.getProtocol())) { - return new java.io.File(url.getPath()).getAbsolutePath(); - } - java.nio.file.Path cwd = java.nio.file.Paths.get(System.getProperty("user.dir")); - for (java.nio.file.Path dir = cwd; dir != null; dir = dir.getParent()) { - java.io.File candidate = dir.resolve(A2AT_ENV_FILE).toFile(); - if (candidate.exists()) { - return candidate.getAbsolutePath(); - } - } - return null; - } - - public static void main(String[] args) throws Exception { - StartAgentsServer server = new StartAgentsServer(); - Runtime.getRuntime().addShutdownHook(new Thread(server::stop)); - server.start(); - } - - @Override - public void run() { - try { - start(); - } catch (Exception e) { - log.error("Failed to start agents: {}", e.getMessage(), e); - } - } - - public void start() throws Exception { - boolean sslVerify = false; - String credPath = getClass().getClassLoader().getResource(CRED_FILE).getPath(); - String envPath = resolveEnvPath(); - log.info( - "A2AT env file: {}", - envPath != null ? envPath : "(not found, A2A-T extensions disabled)"); - List agents = - List.of( - loadAgent( - "agentcard/spn_domain_agent_city1.json", - new SpnDomainAgentCity1Executor()), - loadAgent( - "agentcard/spn_domain_agent_city2.json", - new SpnDomainAgentCity2Executor()), - loadAgent( - "agentcard/transport_workbench_agent.json", - new TransportWorkbenchAgentExecutor( - REGISTRY_URL, ORCH_URL, credPath, sslVerify, envPath))); - - for (AgentEntry entry : agents) { - try { - JdkHttpA2AServer server = - new JdkHttpA2AServer(entry.host, entry.port, entry.card, entry.executor); - server.start(); - servers.add(server); - registerAgent(entry.card, sslVerify); - log.info( - "Started agent: {} on https://{}:{}/", - entry.card.get("name"), - entry.host, - entry.port); - } catch (Exception e) { - log.error( - "Failed to start agent {}: {}", entry.card.get("name"), e.getMessage(), e); - } - } - - log.info("=== All agents started. Press Ctrl+C to stop. ==="); - shutdownLatch.await(); - } - - public void stop() { - running = false; - log.info("Shutting down all agents..."); - servers.forEach( - s -> { - try { - s.close(); - } catch (Exception ignored) { - } - }); - shutdownLatch.countDown(); - } - - private record AgentEntry( - String host, int port, Map card, AgentExecutor executor) {} -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicy.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicy.java new file mode 100644 index 00000000..9c3ec2a3 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicy.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Validated Authorization-T whitelist used by the SPN business callback. + * + *

The protocol prompt itself is deliberately not used for authorization decisions. Only the + * fields returned by the A2A-T SDK validate-and-fill pipeline can create this value. + */ +public record AuthorizationPolicy(String operationType, List rules) { + + public static final String OPERATION_TYPE_FIELD = "授权策略的操作类型"; + public static final String POLICY_LIST_FIELD = "动网操作的授权策略列表"; + + public static final String ADD = "新增授权策略"; + public static final String MODIFY = "修改授权策略"; + public static final String DELETE = "删除授权策略"; + public static final String QUERY = "查询授权策略"; + + public AuthorizationPolicy { + operationType = requireText(operationType, OPERATION_TYPE_FIELD); + if (!ADD.equals(operationType)) { + throw new IllegalArgumentException("Authorization policy creation requires " + ADD); + } + rules = List.copyOf(Objects.requireNonNull(rules, "Authorization rules are required")); + if (rules.isEmpty()) { + throw new IllegalArgumentException("Authorization policy must contain at least one rule"); + } + } + + /** Builds a strict policy from A2A-T SDK validated-and-filled fields. */ + public static AuthorizationPolicy fromValidated(Map data) { + Objects.requireNonNull(data, "Validated Authorization-T data is required"); + String operationType = operationFromValidated(data); + if (!ADD.equals(operationType)) { + throw new IllegalArgumentException( + "Only add operations can create an active authorization policy"); + } + String policyList = requireText(data.get(POLICY_LIST_FIELD), POLICY_LIST_FIELD); + List rules = new ArrayList<>(); + for (String entry : numberedEntries(policyList)) { + rules.add(Rule.parse(entry)); + } + return new AuthorizationPolicy(operationType, rules); + } + + /** Returns one of the four operation values defined by the SDK Authorization-T template. */ + public static String operationFromValidated(Map data) { + Objects.requireNonNull(data, "Validated Authorization-T data is required"); + String operationType = requireText(data.get(OPERATION_TYPE_FIELD), OPERATION_TYPE_FIELD); + if (!List.of(ADD, MODIFY, DELETE, QUERY).contains(operationType)) { + throw new IllegalArgumentException( + "Unsupported Authorization-T operation: " + operationType); + } + return operationType; + } + + /** Requires a non-empty policy selector/list for operations which need a target. */ + public static String requirePolicyList(Map data) { + Objects.requireNonNull(data, "Validated Authorization-T data is required"); + return requireText(data.get(POLICY_LIST_FIELD), POLICY_LIST_FIELD); + } + + /** Decodes the sample's single exact-id delete selector; conditions are not supported. */ + public static String policyIdSelector(Map data) { + List entries = numberedEntries(requirePolicyList(data)); + if (entries.size() != 1 || entries.get(0).contains(",")) { + throw new IllegalArgumentException("The sample requires one numbered 策略标识是 selector"); + } + return labeledValue(entries.get(0), "策略标识"); + } + + private static List numberedEntries(String policyList) { + String[] lines = policyList.strip().split("\\R", -1); + List entries = new ArrayList<>(); + for (int i = 0; i < lines.length; i++) { + String prefix = (i + 1) + "."; + String line = lines[i].strip(); + if (!line.startsWith(prefix)) { + throw new IllegalArgumentException( + "Authorization policies require consecutive numbered lines starting with 1."); + } + entries.add(requireText(line.substring(prefix.length()), POLICY_LIST_FIELD)); + } + return entries; + } + + private static String labeledValue(String field, String label) { + String prefix = label + "是"; + if (!field.strip().startsWith(prefix)) { + throw new IllegalArgumentException("Authorization field must use " + prefix + ""); + } + return requireText(field.strip().substring(prefix.length()), label); + } + + /** Exact whitelist match; no substring or fuzzy authorization is permitted. */ + public boolean authorizes( + String businessScenario, String disposalType, String actionName, LocalDate onDate) { + Objects.requireNonNull(onDate, "Authorization decision date is required"); + return rules.stream() + .anyMatch(rule -> rule.authorizes(businessScenario, disposalType, actionName, onDate)); + } + + private static String requireText(Object value, String field) { + String text = value instanceof String string ? string.strip() : ""; + if (text.isEmpty()) { + throw new IllegalArgumentException("Authorization field is required: " + field); + } + return text; + } + + /** One exact whitelist rule: scenario / disposal type / action / validity range. */ + public record Rule( + String businessScenario, + String disposalType, + String actionName, + LocalDate validFrom, + LocalDate validUntil) { + + public Rule { + businessScenario = requireText(businessScenario, "业务场景"); + disposalType = requireText(disposalType, "处置类型"); + actionName = requireText(actionName, "操作名称"); + Objects.requireNonNull(validFrom, "Authorization validFrom is required"); + Objects.requireNonNull(validUntil, "Authorization validUntil is required"); + if (validUntil.isBefore(validFrom)) { + throw new IllegalArgumentException("Authorization validity end precedes start"); + } + } + + private static Rule parse(String entry) { + String[] fields = entry.split("\uff0c", -1); + if (fields.length != 4) { + throw new IllegalArgumentException( + "Authorization rule must contain the four fields defined by the current " + + "SDK, separated by full-width commas: " + + entry); + } + String scenario = labeledValue(fields[0], "业务场景"); + String disposal = labeledValue(fields[1], "处置类型"); + String action = labeledValue(fields[2], "操作名称"); + String validity = labeledValue(fields[3], "有效期"); + if ("永久生效".equals(validity)) { + return new Rule( + scenario, + disposal, + action, + LocalDate.MIN, + LocalDate.MAX); + } + String[] range = validity.split("~", -1); + if (range.length != 2) { + throw new IllegalArgumentException( + "Authorization validity must be start~end or 永久生效: " + + validity); + } + try { + return new Rule( + scenario, + disposal, + action, + parseDate(range[0]), + parseDate(range[1])); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException( + "Authorization validity must use ISO dates: " + + fields[3], + e); + } + } + + private static LocalDate parseDate(String value) { + return LocalDate.parse(value.strip()); + } + + private boolean authorizes( + String scenario, String disposal, String action, LocalDate onDate) { + return businessScenario.equals(scenario) + && disposalType.equals(disposal) + && actionName.equals(action) + && !onDate.isBefore(validFrom) + && !onDate.isAfter(validUntil); + } + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositioner.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositioner.java new file mode 100644 index 00000000..533b97b5 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositioner.java @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.extension; + +import dev.openan.workflow.engine.client.ExtensionSender; +import dev.openan.workflow.engine.client.NotificationSubscription; +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; +import dev.openan.workflow.engine.model.SendMessageResult; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Initializes independent Authorization-T and Notification-T operations for downstream agents. + * + *

Single responsibility: send a one-shot whitelist operation and open a long-lived result + * subscription for each non-workbench agent on separate channels. Individual workflow tasks do not own these operations. + */ +public class ExtensionPrePositioner { + + private static final Logger log = LoggerFactory.getLogger(ExtensionPrePositioner.class); + private static final int AUTHORIZATION_VALIDATION_ATTEMPTS = 3; + + private final net.openan.a2at.sdk.client.A2ATClient contentClient; + private final Map authData; + private final Map authSchema; + private final Map notifData; + private final Map notifSchema; + + public ExtensionPrePositioner() { + this(dev.openan.workflow.engine.examples.util.EnvResolver.resolveEnvPath()); + } + + public ExtensionPrePositioner(String envPath) { + contentClient = dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new net.openan.a2at.sdk.client.A2ATClient(java.nio.file.Path.of(envPath))); + // Structured-data track: the raw policy / subscription fields; the SDK renders the + // Authorization-T and Notification-T prompts through the SDK schema-aware pipelines + // (spec cases 7.5/7.8; slot extraction may use the SDK-configured LLM). + this.authData = SpnCasePrompts.addAuthorizationData(); + this.authSchema = + SdkSlotSchemaLoader.loadConfigured(StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + this.notifData = SpnCasePrompts.subscribeServiceRecoveryData(); + this.notifSchema = SdkSlotSchemaLoader.loadConfigured(StandardTemplates.SERVICE_RECOVERY); + } + + /** + * Pre-position Authorization-T + Notification-T to every non-workbench agent. + * + *

When {@code notificationCallback} is non-null, subsequent events pushed by agents through + * the Notification-T SSE stream (e.g. recovery results) are forwarded to the callback. The + * callback receives the registered handle and complete ReceivedMessage snapshot. + */ + public List prePosition( + ExtensionSender authorizationSender, + ExtensionSender notificationSender, + List agentCards, + java.util.function.BiConsumer notificationCallback) { + return prePosition( + authorizationSender, notificationSender, agentCards, notificationCallback, null); + } + + /** + * Pre-position extensions and transfer each opened subscription to its lifecycle owner before + * waiting for the subscription acknowledgement. + */ + public List prePosition( + ExtensionSender authorizationSender, + ExtensionSender notificationSender, + List agentCards, + java.util.function.BiConsumer notificationCallback, + Consumer subscriptionOpened) { + long allStarted = System.nanoTime(); + int targetCount = 0; + for (AgentCard card : agentCards) { + if (!card.name().contains("Workbench")) { + targetCount++; + } + } + log.info( + "[PrePosition] START targetAgents={}, notificationCallback={}", + targetCount, + notificationCallback != null); + java.util.ArrayList subscriptions = new java.util.ArrayList<>(); + int authorizationFailures = 0; + int notificationFailures = 0; + for (AgentCard card : agentCards) { + String name = card.name(); + if (name.contains("Workbench")) { + continue; + } + long agentStarted = System.nanoTime(); + long operationStarted = System.nanoTime(); + try { + log.info("[PrePosition] SEND extension=Authorization-T, agent={}", name); + SendMessageResult authResult = sendAuthorization(authorizationSender, name); + log.info( + "[PrePosition] ACK extension=Authorization-T, agent={}, state={}, " + + "responseChars={}, elapsedMs={}", + name, + authResult.getTaskState(), + authResult.getText() != null ? authResult.getText().length() : 0, + elapsedMillis(operationStarted)); + requireState(authResult, "Authorization-T", name, "TASK_STATE_COMPLETED"); + } catch (RuntimeException e) { + authorizationFailures++; + log.warn( + "[PrePosition] OPERATION_FAILED extension=Authorization-T, agent={}, " + + "elapsedMs={}, errorType={}, message={}, action=continue", + name, + elapsedMillis(operationStarted), + e.getClass().getSimpleName(), + e.getMessage()); + } + + operationStarted = System.nanoTime(); + NotificationSubscription subscription = null; + try { + log.info("[PrePosition] SEND extension=Notification-T, agent={}", name); + var notificationContent = contentClient.generateNotificationPromptFromDataWithSchema( + notifData, notifSchema, StandardTemplates.SERVICE_RECOVERY.uri()); + subscription = notificationSender.openNotification(name, + dev.openan.workflow.engine.client.A2atMessages.from(notificationContent, + List.of(new org.a2aproject.sdk.spec.TextPart("订阅业务抢通事件"))), + notificationCallback == null ? (handle, event) -> {} : notificationCallback); + if (subscriptionOpened != null && subscription.isActive()) { + subscriptionOpened.accept(subscription); + } + var notificationResult = subscription.acknowledgement().join(); + requireState( + notificationResult, + "Notification-T", + name, + "TASK_STATE_WORKING", + "TASK_STATE_COMPLETED"); + subscriptions.add(subscription); + log.info( + "[PrePosition] ACK extension=Notification-T, agent={}, state={}, " + + "responseChars={}, elapsedMs={}", + name, + notificationResult.getTaskState(), + notificationResult.getText() != null ? notificationResult.getText().length() : 0, + elapsedMillis(operationStarted)); + log.info( + "[PrePosition] AGENT_DONE agent={}, elapsedMs={}", name, elapsedMillis(agentStarted)); + } catch (RuntimeException e) { + notificationFailures++; + if (subscription != null) { + subscription.close(); + } + log.warn( + "[PrePosition] OPERATION_FAILED extension=Notification-T, agent={}, " + + "elapsedMs={}, errorType={}, message={}, action=continue", + name, + elapsedMillis(operationStarted), + e.getClass().getSimpleName(), + e.getMessage()); + } + } + log.info( + "[PrePosition] DONE targetAgents={}, activeSubscriptions={}, " + + "authorizationFailures={}, notificationFailures={}, elapsedMs={}", + targetCount, + subscriptions.size(), + authorizationFailures, + notificationFailures, + elapsedMillis(allStarted)); + return List.copyOf(subscriptions); + } + + private SendMessageResult sendAuthorization(ExtensionSender sender, String agentName) { + SendMessageResult result = null; + for (int attempt = 1; attempt <= AUTHORIZATION_VALIDATION_ATTEMPTS; attempt++) { + var authorizationContent = contentClient.generateAuthPromptFromDataWithSchema( + authData, authSchema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT.uri()); + result = sender.sendAuthorization(agentName, + dev.openan.workflow.engine.client.A2atMessages.from(authorizationContent, + List.of(new org.a2aproject.sdk.spec.TextPart("新增动网操作授权")))).join(); + boolean retryableValidationFailure = + "TASK_STATE_FAILED".equals(result.getTaskState()) + && "Authorization-T validation failed".equals(result.getText()); + if (!retryableValidationFailure || attempt == AUTHORIZATION_VALIDATION_ATTEMPTS) { + return result; + } + log.warn( + "[PrePosition] RETRY extension=Authorization-T, agent={}, attempt={}/{}," + + " reason=validator_mismatch", + agentName, + attempt + 1, + AUTHORIZATION_VALIDATION_ATTEMPTS); + } + return result; + } + + /** Pre-position through explicitly isolated Authorization-T and Notification-T senders. */ + public List prePosition( + ExtensionSender authorizationSender, + ExtensionSender notificationSender, + List agentCards) { + return prePosition(authorizationSender, notificationSender, agentCards, null); + } + + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } + + private static void requireState( + dev.openan.workflow.engine.model.SendMessageResult result, + String extension, + String agent, + String... allowedStates) { + String state = result != null ? result.getTaskState() : null; + boolean accepted = + state != null && java.util.Arrays.stream(allowedStates).anyMatch(state::contains); + if (!accepted) { + throw new IllegalStateException( + extension + + " independent protocol operation failed for '" + + agent + + "': state=" + + state + + ", response=" + + (result != null ? result.getText() : "null")); + } + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/NotificationPolicy.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/NotificationPolicy.java new file mode 100644 index 00000000..c22549fb --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/NotificationPolicy.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import java.util.Map; +import java.util.Objects; + +/** SDK-validated Notification-T service-recovery subscription parameters. */ +public record NotificationPolicy(String condition, String reportFormat) { + public static final String CONDITION_FIELD = "订阅条件"; + public static final String REPORT_FORMAT_FIELD = "上报通知数据格式"; + + public NotificationPolicy { + condition = condition == null ? "" : condition.strip(); + reportFormat = requireText(reportFormat, REPORT_FORMAT_FIELD); + } + + public static NotificationPolicy fromValidated(Map data) { + Objects.requireNonNull(data, "Validated Notification-T data is required"); + Object rawCondition = data.get(CONDITION_FIELD); + if (rawCondition != null && !(rawCondition instanceof String)) { + throw new IllegalArgumentException("Notification field must be text: " + CONDITION_FIELD); + } + return new NotificationPolicy( + rawCondition instanceof String text ? text : "", + requireText(data.get(REPORT_FORMAT_FIELD), REPORT_FORMAT_FIELD)); + } + + private static String requireText(Object value, String field) { + String text = value instanceof String string ? string.strip() : ""; + if (text.isEmpty()) { + throw new IllegalArgumentException("Notification field is required: " + field); + } + return text; + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandler.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandler.java new file mode 100644 index 00000000..c748e717 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandler.java @@ -0,0 +1,529 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.examples.extension; + +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatus; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; + +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import java.nio.file.Path; +import java.time.Instant; +import org.a2aproject.sdk.spec.TextPart; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import dev.openan.workflow.engine.examples.agents.BaseAgentExecutor; +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.util.EnvResolver; +/** + * Handles independent Authorization-T / Notification-T messages on the agent server side. + * + *

This is a separate concern from Negotiation-T. Pre-positioned extensions are sent by + * the workbench independently from workflow execution (via {@link + * dev.openan.workflow.engine.client.ExtensionSender}) to establish whitelists and subscriptions. When an + * agent receives one, it must: + * + *

    + *
  1. Store the payload for later use during business execution + *
  2. Return the protocol-defined structured response artifact under the same extension URI + *
  3. Complete the task immediately (no negotiation, no business logic) + *
+ * + *

Extracted from {@code NegotiationBaseAgentExecutor} so that the latter only carries + * Negotiation-T responsibility. + */ +public class PrePositionedExtensionHandler { + private static final Logger log = LoggerFactory.getLogger(PrePositionedExtensionHandler.class); + private final AuthorizationPromptValidator authorizationValidator; + private final NotificationPromptValidator notificationValidator; + private volatile AuthorizationPolicy authorizationPolicy; + private volatile String authorizationPolicyId; + private volatile Instant authorizationCreatedAt; + private volatile Instant authorizationModifiedAt; + private volatile NotificationPolicy notificationSubscription; + private volatile A2ATServer a2atServer; + private volatile String validationLanguage = "zh-CN"; + + /** Validation seam for deterministic integration tests; production uses {@link A2ATServer}. */ + @FunctionalInterface + public interface AuthorizationPromptValidator { + FilledParamData validate(String prompt, Map schema, TemplateUri templateUri); + } + + /** Validation seam for deterministic Notification-T integration tests. */ + @FunctionalInterface + public interface NotificationPromptValidator { + FilledParamData validate(String prompt, Map schema, TemplateUri templateUri); + } + + public PrePositionedExtensionHandler() { + this(null, null); + } + + public PrePositionedExtensionHandler(AuthorizationPromptValidator authorizationValidator) { + this(authorizationValidator, null); + } + + public PrePositionedExtensionHandler( + AuthorizationPromptValidator authorizationValidator, + NotificationPromptValidator notificationValidator) { + this.authorizationValidator = authorizationValidator; + this.notificationValidator = notificationValidator; + } + + private synchronized A2ATServer a2atServer() { + if (a2atServer != null) return a2atServer; + String env = EnvResolver.resolveEnvPath(); + if (env == null || env.isBlank()) return null; + try { + Path envPath = Path.of(env); + validationLanguage = A2ATConfig.load(envPath).prompt().language(); + a2atServer = + dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new A2ATServer(envPath)); + return a2atServer; + } catch (Exception e) { + log.warn("A2ATServer init failed: {}", e.getMessage()); + return null; + } + } + + /** + * Detect whether the incoming message carries an Authorization-T or Notification-T extension in + * its metadata. Returns the extension keyword ("Authorization-T" or "Notification-T"), or null + * for a normal task. + */ + public static String detect(RequestContext ctx) { + Message msg = ctx.getMessage(); + if (msg == null || msg.metadata() == null) { + return null; + } + if (msg.metadata().containsKey(A2ATExtension.AUTHORIZATION_T.uri())) { + return A2ATExtension.AUTHORIZATION_T.uri(); + } + if (msg.metadata().containsKey(A2ATExtension.NOTIFICATION_T.uri())) { + return A2ATExtension.NOTIFICATION_T.uri(); + } + return null; + } + + private static void emitStatus( + AgentEmitter emitter, + TaskState state, + String contextId, + String taskId, + String text, + Map metadata) { + TaskStatus status = + new TaskStatus( + state, BaseAgentExecutor.buildStatusMessage(contextId, taskId, text), null); + TaskStatusUpdateEvent event = + TaskStatusUpdateEvent.builder() + .taskId(taskId) + .contextId(contextId) + .status(status) + .metadata(metadata) + .build(); + emitter.emitEvent(event); + } + + /** The SDK-validated Authorization-T whitelist, or null when none has been accepted. */ + public AuthorizationPolicy getAuthorizationPolicy() { + return authorizationPolicy; + } + + /** The SDK-validated Notification-T subscription, or null. */ + public NotificationPolicy getNotificationSubscription() { + return notificationSubscription; + } + + private Map validationSchema(TemplateUri templateUri) { + return SdkSlotSchemaLoader.load(templateUri, validationLanguage); + } + + /** + * Handle an independent extension message: store the payload, emit an ACK artifact, and complete the + * task. + * + * @param agentTag short agent class name for logging + */ + public void handle( + RequestContext ctx, AgentEmitter emitter, String extKeyword, String agentTag) { + String taskId = ctx.getTaskId(); + String contextId = ctx.getContextId(); + Object payload = ctx.getMessage().metadata().get(extKeyword); + if (!(payload instanceof String payloadText) || payloadText.isBlank()) { + reject(emitter, contextId, taskId, "Extension payload must be a non-empty string"); + return; + } + // Extract templateUri from metadata if present (sent by SDK prompt generation) + Object templateUri = null; + if (ctx.getMessage().metadata() != null) { + templateUri = ctx.getMessage().metadata().get(MetadataContent.TEMPLATE_URI_METADATA_KEY); + } + AuthorizationOperationResult operationResult; + if (A2ATExtension.AUTHORIZATION_T.uri().equals(extKeyword)) { + try { + FilledParamData filled = validateAuthorization(payloadText, templateUri); + operationResult = applyAuthorization(filled.data(), templateUri, agentTag); + } catch (Exception validationError) { + log.warn( + "[{}] Authorization-T rejected; existing policy unchanged: {}", + agentTag, + validationFailureSummary(validationError)); + reject(emitter, contextId, taskId, "Authorization-T validation failed"); + return; + } + } else if (A2ATExtension.NOTIFICATION_T.uri().equals(extKeyword)) { + try { + acceptNotification(payloadText, templateUri, agentTag); + operationResult = + new AuthorizationOperationResult( + "订阅成功,启动业务抢通事件上报任务", + "## 订阅结果\n订阅结果:成功"); + } catch (Exception validationError) { + log.warn( + "[{}] Notification-T rejected; existing subscription unchanged: {}", + agentTag, + validationFailureSummary(validationError)); + reject(emitter, contextId, taskId, "Notification-T validation failed"); + return; + } + } else { + reject(emitter, contextId, taskId, "Unsupported A2A-T extension URI"); + return; + } + log.info( + "[{}] Pre-positioned {} received, payload length={}", + agentTag, + extKeyword, + payloadText.length()); + List> parts = List.of(new TextPart(operationResult.text())); + Map responseMetadata = + Map.of(extKeyword, operationResult.metadataText()); + emitter.addArtifact( + parts, "result", agentTag + " ack", responseMetadata, false, true); + emitStatus( + emitter, + TaskState.TASK_STATE_COMPLETED, + contextId, + taskId, + operationResult.text(), + responseMetadata); + emitter.complete(BaseAgentExecutor.buildStatusMessage(contextId, taskId, "Completed")); + log.info("[{}] {} operation completed", agentTag, extKeyword); + } + + synchronized AuthorizationOperationResult applyAuthorization( + Map validatedData, Object templateUri, String agentTag) { + String operation = AuthorizationPolicy.operationFromValidated(validatedData); + switch (operation) { + case AuthorizationPolicy.ADD -> { + AuthorizationPolicy candidate = AuthorizationPolicy.fromValidated(validatedData); + Instant now = Instant.now(); + authorizationPolicy = candidate; + // The demo models the single policy shown in the scenario specification. A real + // integration callback should replace this with its persisted policy identifier. + authorizationPolicyId = "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"; + authorizationCreatedAt = now; + authorizationModifiedAt = now; + log.info( + "[{}] Authorization-T accepted, templateUri={}, operation={}, rules={}", + agentTag, + templateUri, + operation, + candidate.rules().size()); + return authorizationResult("新增动网操作的授权成功", candidate); + } + case AuthorizationPolicy.MODIFY -> throw new UnsupportedOperationException( + "The SPN sample has no persisted policy identifiers; Authorization-T modify " + + "must be implemented by the integrating policy store"); + case AuthorizationPolicy.DELETE -> { + String selector = AuthorizationPolicy.policyIdSelector(validatedData); + if (authorizationPolicy == null || !selector.equals(authorizationPolicyId)) { + throw new IllegalArgumentException( + "Authorization policy identifier does not match the active policy"); + } + authorizationPolicy = null; + authorizationPolicyId = null; + authorizationCreatedAt = null; + authorizationModifiedAt = null; + log.info( + "[{}] Authorization-T accepted, templateUri={}, operation={}, active policy cleared", + agentTag, + templateUri, + operation); + return new AuthorizationOperationResult( + "删除动网操作的授权成功", + "## 授权操作执行结果\n授权操作执行结果:成功"); + } + case AuthorizationPolicy.QUERY -> { + Object selector = validatedData.get(AuthorizationPolicy.POLICY_LIST_FIELD); + if (selector != null && (!(selector instanceof String text) || !text.isBlank())) { + throw new UnsupportedOperationException( + "The sample supports unfiltered queries only; implement conditions in the integrating policy store"); + } + log.info( + "[{}] Authorization-T accepted, templateUri={}, operation={}, activeRules={}", + agentTag, + templateUri, + operation, + authorizationPolicy == null ? 0 : authorizationPolicy.rules().size()); + return authorizationResult( + "符合查询条件的动网操作的授权策略列表", authorizationPolicy); + } + default -> throw new IllegalArgumentException( + "Unsupported Authorization-T operation: " + operation); + } + } + + private AuthorizationOperationResult authorizationResult( + String text, AuthorizationPolicy policy) { + StringBuilder metadata = + new StringBuilder("## 授权操作执行结果\n授权操作执行结果:成功"); + metadata.append("\n\n## 动网操作的授权策略列表\n"); + if (policy == null) { + metadata.append("(无匹配授权策略)"); + } else { + int index = 1; + for (AuthorizationPolicy.Rule rule : policy.rules()) { + metadata.append("- 动网操作的授权策略").append(index++).append('\n'); + metadata.append(" - 动网操作的授权策略标识:") + .append(authorizationPolicyId) + .append('\n'); + metadata.append(" - 动网操作的业务场景:") + .append(rule.businessScenario()) + .append('\n'); + metadata.append(" - 动网操作的处置类型:") + .append(rule.disposalType()) + .append('\n'); + metadata.append(" - 动网操作名称:").append(rule.actionName()).append('\n'); + metadata.append(" - 有效期:") + .append(formatValidity(rule)) + .append('\n'); + metadata.append(" - 创建时间:") + .append(authorizationCreatedAt) + .append('\n'); + metadata.append(" - 最后修改时间:") + .append(authorizationModifiedAt); + } + } + return new AuthorizationOperationResult(text, metadata.toString()); + } + + private static String formatValidity(AuthorizationPolicy.Rule rule) { + if (java.time.LocalDate.MIN.equals(rule.validFrom()) + && java.time.LocalDate.MAX.equals(rule.validUntil())) { + return "永久生效"; + } + return rule.validFrom() + " ~ " + rule.validUntil(); + } + + record AuthorizationOperationResult(String text, String metadataText) { + AuthorizationOperationResult { + Objects.requireNonNull(text, "Authorization response text"); + Objects.requireNonNull(metadataText, "Authorization response metadata"); + } + } + + private FilledParamData validateAuthorization(String prompt, Object receivedTemplateUri) { + FilledParamData filled; + if (authorizationValidator != null) { + filled = Objects.requireNonNull( + authorizationValidator.validate( + prompt, + validationSchema(StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT), + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT), + "Authorization validator returned null"); + } else { + requireTemplateUri( + receivedTemplateUri, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + A2ATServer server = a2atServer(); + if (server == null) { + throw new IllegalStateException("A2A-T server validator is unavailable"); + } + try { + filled = validateAuthorizationWithSdk(server, prompt); + } catch (ContentValidationException firstFailure) { + // The current SDK validation pipeline may use an external LLM even for a canonical + // prompt rendered from structured data. Retry only the side-effect-free validation + // stage once; business state is not touched until a validated result is returned. + log.warn( + "Authorization-T SDK validation failed once; retrying the same canonical prompt: {}", + validationFailureSummary(firstFailure)); + filled = validateAuthorizationWithSdk(server, prompt); + } + } + requireExtractedSectionsMatch( + prompt, + filled.data(), + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.POLICY_LIST_FIELD); + return filled; + } + + private FilledParamData validateAuthorizationWithSdk(A2ATServer server, String prompt) { + return server.validateAuthPromptAndDataFilling( + prompt, + validationSchema(StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT), + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT.uri()); + } + + /** Validates and stores a Notification-T subscription before the server opens its SSE loop. */ + public NotificationPolicy acceptNotification( + String prompt, Object receivedTemplateUri, String agentTag) { + FilledParamData filled; + if (notificationValidator != null) { + filled = Objects.requireNonNull( + notificationValidator.validate( + prompt, + validationSchema(StandardTemplates.SERVICE_RECOVERY), + StandardTemplates.SERVICE_RECOVERY), + "Notification validator returned null"); + } else { + requireTemplateUri(receivedTemplateUri, StandardTemplates.SERVICE_RECOVERY); + A2ATServer server = a2atServer(); + if (server == null) { + throw new IllegalStateException("A2A-T server validator is unavailable"); + } + try { + filled = server.validateNotificationPromptAndDataFilling( + prompt, + validationSchema(StandardTemplates.SERVICE_RECOVERY), + StandardTemplates.SERVICE_RECOVERY.uri()); + } catch (ContentValidationException error) { + log.warn("[{}] Notification-T SDK validation failed: {}", + agentTag, validationFailureSummary(error)); + throw error; + } + } + requireExtractedSectionsMatch( + prompt, + filled.data(), + NotificationPolicy.CONDITION_FIELD, + NotificationPolicy.REPORT_FORMAT_FIELD); + NotificationPolicy candidate = NotificationPolicy.fromValidated(filled.data()); + notificationSubscription = candidate; + log.info( + "[{}] Notification-T accepted, templateUri={}, conditionChars={}, reportFormatChars={}", + agentTag, + receivedTemplateUri, + candidate.condition().length(), + candidate.reportFormat().length()); + return candidate; + } + + static void requireExtractedSectionsMatch( + String renderedPrompt, Map extractedData, String... slotNames) { + Objects.requireNonNull(renderedPrompt, "Rendered A2A-T prompt is required"); + Objects.requireNonNull(extractedData, "SDK extracted parameters are required"); + for (String slotName : slotNames) { + String renderedValue = sectionValue(renderedPrompt, slotName); + Object extractedValue = extractedData.get(slotName); + String extractedText = extractedValue instanceof String text ? text : ""; + if (!normalizeSlotValue(renderedValue).equals(normalizeSlotValue(extractedText))) { + throw new IllegalArgumentException( + "SDK extracted value does not match rendered prompt section: " + slotName); + } + } + } + + private static String sectionValue(String prompt, String slotName) { + String[] lines = prompt.replace("\r\n", "\n").replace('\r', '\n').split("\n", -1); + String expectedHeading = "## " + slotName; + for (int index = 0; index < lines.length; index++) { + if (!expectedHeading.equals(lines[index].strip())) { + continue; + } + StringBuilder value = new StringBuilder(); + for (int valueIndex = index + 1; valueIndex < lines.length; valueIndex++) { + String line = lines[valueIndex]; + if (line.strip().startsWith("## ")) { + break; + } + if (!line.isBlank()) { + if (!value.isEmpty()) { + value.append('\n'); + } + value.append(line.strip()); + } + } + return value.toString(); + } + throw new IllegalArgumentException( + "Rendered A2A-T prompt is missing section: " + + slotName + + "; headings=" + + java.util.Arrays.stream(lines) + .map(String::strip) + .filter(line -> line.startsWith("## ")) + .toList()); + } + + private static String normalizeSlotValue(String value) { + return value == null ? "" : value.strip().replaceAll("\\s+", " "); + } + + private static void requireTemplateUri(Object actualValue, TemplateUri expected) { + String actual = actualValue instanceof String text && !text.isBlank() ? text : null; + if (!expected.uri().equals(actual)) { + throw new IllegalArgumentException( + "Unexpected template URI: expected=" + expected.uri() + ", actual=" + actual); + } + } + + private static String validationFailureSummary(Exception error) { + if (error instanceof ContentValidationException validationError) { + return "code=" + + validationError.getCode() + + ", slotErrors=" + + validationError.errors() + + ", partialParamKeys=" + + validationError.params().keySet(); + } + return error.getClass().getSimpleName() + ": " + error.getMessage(); + } + + private static void reject( + AgentEmitter emitter, String contextId, String taskId, String publicMessage) { + emitStatus( + emitter, + TaskState.TASK_STATE_FAILED, + contextId, + taskId, + publicMessage, + Map.of()); + emitter.fail(BaseAgentExecutor.buildStatusMessage(contextId, taskId, publicMessage)); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/RecoveryNotification.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/RecoveryNotification.java new file mode 100644 index 00000000..ac53a6d8 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/RecoveryNotification.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.examples.extension; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.model.ReceivedMessage; +import java.util.List; +import java.util.Map; +import java.util.LinkedHashMap; +import java.util.regex.Pattern; + +/** + * SPN business completion policy, not an engine or SDK-wide notification schema. + * SDK notification generation/validation describes subscription requests, not recovery events. + */ +public final class RecoveryNotification { + private RecoveryNotification() {} + + /** Close only for a complete recovery result in formal Notification-T artifact metadata. */ + public static boolean hasCompletedResult(ReceivedMessage received) { + return received.artifacts().stream().filter(a -> "recovery-result".equals(a.name())) + .anyMatch(a -> isCompletedResult(a.metadata())); + } + + private static boolean isCompletedResult(Map metadata) { + Object payload = metadata == null ? null : metadata.get(A2ATExtension.NOTIFICATION_T.uri()); + if (!(payload instanceof String text)) return false; + Map fields = new LinkedHashMap<>(); + var matcher = Pattern.compile("(?m)^\\s*\\d+[.、]\\s*([^::\\r\\n]+)[::]\\s*(.*)$").matcher(text); + while (matcher.find()) { + if (fields.putIfAbsent(matcher.group(1).strip(), matcher.group(2).strip()) != null) return false; + } + List required = List.of("业务抢通方案执行状态", "投诉诊断任务流水号", "OSS侧事件流水号", + "接入端口名称", "是否已授权OMC自动抢通", "业务抢通方案名称", "业务抢通方案详情", + "业务抢通方案执行结束时间", "业务抢通方案执行结果"); + if (required.stream().anyMatch(key -> fields.getOrDefault(key, "").isBlank())) return false; + return "已结束".equals(fields.get("业务抢通方案执行状态")) + && List.of("成功", "失败").contains(fields.get("业务抢通方案执行结果")); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/SdkSlotSchemaLoader.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/SdkSlotSchemaLoader.java new file mode 100644 index 00000000..6e0e0180 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/SdkSlotSchemaLoader.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.TemplateUri; +import dev.openan.workflow.engine.examples.util.EnvResolver; + +/** Loads the slot schema bundled with the active A2A-T SDK version. */ +public final class SdkSlotSchemaLoader { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private SdkSlotSchemaLoader() {} + + /** Loads a template's schema using the language configured for the sample SDK runtime. */ + public static Map loadConfigured(TemplateUri templateUri) { + String language = "zh-CN"; + String envPath = EnvResolver.resolveEnvPath(); + if (envPath != null && !envPath.isBlank()) { + language = A2ATConfig.load(Path.of(envPath)).prompt().language(); + } + return load(templateUri, language); + } + + /** Loads a template's schema for an explicit SDK resource language. */ + public static Map load(TemplateUri templateUri, String language) { + Objects.requireNonNull(templateUri, "templateUri"); + if (language == null || language.isBlank() || language.contains("/") || language.contains("\\")) { + throw new IllegalArgumentException("A2A-T prompt language must be a simple path segment"); + } + String resourcePath = + "/prompt_resources/slots/" + templateUri.uri() + "/" + language.strip() + "/slot.json"; + try (InputStream stream = SdkSlotSchemaLoader.class.getResourceAsStream(resourcePath)) { + if (stream == null) { + throw new IllegalStateException( + "A2A-T SDK slot schema resource not found: " + resourcePath); + } + return MAPPER.readValue(stream, new TypeReference>() {}); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to load A2A-T SDK slot schema: " + resourcePath, e); + } + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategy.java b/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategy.java new file mode 100644 index 00000000..e31a8f24 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategy.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.negotiation; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.client.A2atMessages; +import dev.openan.workflow.engine.examples.util.A2ATInitialization; +import dev.openan.workflow.engine.model.*; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.*; +import net.openan.a2at.sdk.negotiation.content.*; +import org.a2aproject.sdk.spec.TextPart; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Pattern; + +/** Host business policy: validate the proposal, then answer only from this city's authoritative input. */ +public class NegotiationStrategy implements dev.openan.workflow.engine.control.NegotiationStrategy { + private final A2ATClient client; + + /** The host owns SDK initialization and its provider configuration. */ + public NegotiationStrategy(String a2atEnvPath) { + client = A2ATInitialization.create(() -> new A2ATClient(java.nio.file.Path.of(a2atEnvPath))); + } + + @Override + public CompletableFuture resolve(NegotiationRequest request) { + return CompletableFuture.supplyAsync(() -> dev.openan.workflow.engine.examples.util.BusinessSdkErrors.call( + "negotiation-reply", () -> answer(request))); + } + + private NegotiationReply answer(NegotiationRequest request) { + NegotiationContext context = A2atMessages.contextOf(request.received()); + Map metadata = negotiationMetadata(request.received()); + String template = Objects.toString(metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY), ""); + if (!StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri().equals(template)) { + return abort(context, "当前示例策略需要人工确认目标或可行性"); + } + String prompt = Objects.toString(metadata.get(A2ATExtension.NEGOTIATION_T.uri()), ""); + Map schema = Map.of("type", "object", "properties", Map.of( + "items", Map.of("type", "array", "items", Map.of("type", "string"), "minItems", 1, + "description", "逐字提取所需信息项中编号后、冒号前的字段名称,例如任务对象、任务上下文;冒号后的补充要求是说明,不得改写为字段名称"), + "relationship", Map.of("type", "string", "nullable", true, + "description", "请求字段之间的逻辑关系;必须一起提供时为AND,明确为替代项时为OR,未说明可为空"))); + FilledParamData validated = client.validateProposePromptAndDataFilling(prompt, context, schema, template); + Object raw = validated.data().get("items"); + if (!(raw instanceof List requested) || requested.isEmpty()) { + return abort(context, "协商未包含可识别的请求字段"); + } + Map data; + try { data = dev.openan.workflow.engine.examples.demo.SpnCasePrompts.complaintData(request.task()); } + catch (IllegalArgumentException error) { return abort(context, "当前任务没有可核实的结构化补充信息"); } + // SDK semantic validation is mandatory. For this typed template, preserve its literal + // item keys rather than letting LLM paraphrases change the requested scope. + List names = requestedFieldNames(prompt); + if (names.isEmpty()) return abort(context, "协商未包含明确的编号信息项"); + List answers = new ArrayList<>(); + for (String name : names) { + if (!mayProvideField(request.task(), name)) return reject(context, "业务策略不允许提供:" + name); + String value = switch (name) { + case "接入端口名称" -> field(data.get("任务对象"), "接入端口名称"); + case "投诉分类" -> field(data.get("任务上下文"), "投诉分类"); + default -> data.get(name) instanceof String text ? text : null; + }; + if (value == null || value.isBlank()) return abort(context, "无法从当前任务核实:" + name); + answers.add(new NegotiationItem(name, value)); + } + MetadataContent reply = client.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData(context, new InformationEndingContent(NegotiationConclusion.ACCEPT, answers)), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()); + return new NegotiationReply.Send(A2atMessages.from(reply, List.of(new TextPart("补充诊断信息")))); + } + + private static List requestedFieldNames(String prompt) { + var section = Pattern.compile("(?m)^##[ \\t]+所需信息项[ \\t]*\\r?$").matcher(prompt); + if (!section.find()) return List.of(); + int end = prompt.indexOf("\n## ", section.end()); + String body = prompt.substring(section.end(), end < 0 ? prompt.length() : end); + List names = new ArrayList<>(); + var item = Pattern.compile("(?m)^[ \\t]*(\\d+)\\.[ \\t]*([^::\\r\\n]+)[::]").matcher(body); + while (item.find()) { + String name = item.group(2).strip(); + if (!Integer.toString(names.size() + 1).equals(item.group(1)) + || name.isEmpty() || names.contains(name)) return List.of(); + names.add(name); + } + return names; + } + + private NegotiationReply abort(NegotiationContext context, String reason) { + MetadataContent reply = client.generateNegotiationAbortPromptFromData( + new NegotiationAbortData(context, new NegotiationAbortContent(reason)), + StandardTemplates.NEGOTIATION_ABORT.uri()); + return new NegotiationReply.Send(A2atMessages.from(reply, List.of(new TextPart("终止本次协商")))); + } + + /** + * Host-owned disclosure policy. Override to refuse a requested field with a protocol Reject. + * This is separate from the OMC Authorization-T whitelist for automatic recovery operations. + */ + protected boolean mayProvideField(TaskRequest task, String fieldName) { + return true; + } + + private NegotiationReply reject(NegotiationContext context, String reason) { + MetadataContent reply = client.generateNegotiationRejectPromptFromData( + new NegotiationEndingData(context, new InformationEndingContent(NegotiationConclusion.REJECT, + List.of(new NegotiationItem("拒绝原因", reason)))), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()); + return new NegotiationReply.Send(A2atMessages.from(reply, List.of(new TextPart("拒绝本次信息请求")))); + } + + private static Map negotiationMetadata(ReceivedMessage received) { + if (received.message() != null && received.message().metadata().containsKey(A2ATExtension.NEGOTIATION_T.uri())) + return received.message().metadata(); + if (received.taskMetadata().containsKey(A2ATExtension.NEGOTIATION_T.uri())) return received.taskMetadata(); + return received.artifacts().stream().map(org.a2aproject.sdk.spec.Artifact::metadata) + .filter(Objects::nonNull).filter(m -> m.containsKey(A2ATExtension.NEGOTIATION_T.uri())) + .findFirst().orElseThrow(() -> new IllegalArgumentException("Missing negotiation content")); + } + + private static String field(Object raw, String label) { + if (!(raw instanceof String text)) return null; + var matcher = + Pattern.compile(Pattern.quote(label) + "[::]\\s*[\\\"“]?([^;;\\\"”\\r\\n]+)") + .matcher(text); + return matcher.find() ? matcher.group(1).strip() : null; + } + +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationUtils.java b/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationUtils.java new file mode 100644 index 00000000..df7ed634 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationUtils.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the License); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an AS IS BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.negotiation; + +import dev.openan.workflow.engine.client.A2ATExtension; + +import java.util.Map; + +/** + * Negotiation-T helpers using only standard A2A-T protocol fields (spec §5.1 + * Message.role and §6 metadata-key conventions). No custom markers, no internal-only + * keys, no value-content inspection. + */ +public final class NegotiationUtils { + + /** Standard Negotiation-T extension URI (spec §2). */ + public static final String NEGOTIATION_T_URI = A2ATExtension.NEGOTIATION_T.uri(); + + /** Standard Task-T extension URI (spec §2). */ + public static final String TASK_PROMPT_KEY = A2ATExtension.TASK_T.uri(); + + private NegotiationUtils() {} + + /** + * Whether metadata carries the Negotiation-T extension key (key-level check). Used by both + * client and server to detect negotiation messages generically. + */ + public static boolean hasNegotiationMetadata(Map metadata) { + if (metadata == null) return false; + return metadata.containsKey(NEGOTIATION_T_URI); + } + + /** + * Whether metadata carries the Task-T extension key (key-level check). Distinguishes a new + * diagnostic task (has Task-T) from a negotiation reply (only Negotiation-T). + */ + public static boolean hasTaskMetadata(Map metadata) { + if (metadata == null) return false; + return metadata.containsKey(TASK_PROMPT_KEY); + } + + /** + * Metadata key carrying the negotiation session context map ({@code id} / {@code round} / + * {@code maxRounds} / {@code performative}). + * + *

Propose replies must carry this key so the client-side engine can parse the session + * context and advance rounds itself — the content layer is stateless and the caller owns + * the session state (SDK guide §1.10). + */ + public static final String NEGOTIATION_CONTEXT_KEY = + net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY; + +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/server/JdkHttpA2AServer.java b/samples/src/main/java/dev/openan/workflow/engine/examples/server/JdkHttpA2AServer.java index 4223749d..b7638b10 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/server/JdkHttpA2AServer.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/server/JdkHttpA2AServer.java @@ -58,6 +58,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.security.KeyStore; import java.util.LinkedHashMap; @@ -356,14 +357,15 @@ private RestHandler initServerComponents( new MainEventBusProcessor(mainEventBus, taskStore, pushSender, queueManager); startEventBus(eventBusProc); RequestHandler requestHandler = - DefaultRequestHandler.create( - agentExecutor, - taskStore, - queueManager, - pushStore, - eventBusProc, - agentExecutorService, - agentExecutorService); + DefaultRequestHandler.builder() + .agentExecutor(agentExecutor) + .taskStore(taskStore) + .queueManager(queueManager) + .pushConfigStore(pushStore) + .mainEventBusProcessor(eventBusProc) + .executor(agentExecutorService) + .eventConsumerExecutor(agentExecutorService) + .build(); return new RestHandler( typedCard, new AgentCardCacheMetadata(typedCard, null), @@ -372,7 +374,7 @@ private RestHandler initServerComponents( } private void handleExchange(HttpExchange exchange, RestHandler restHandler) throws IOException { - String fullPath = exchange.getRequestURI().getPath(); + String fullPath = exchange.getRequestURI().getRawPath(); String method = exchange.getRequestMethod(); String path = pathPrefix.isEmpty() || !fullPath.startsWith(pathPrefix) @@ -397,7 +399,27 @@ private void handleExchange(HttpExchange exchange, RestHandler restHandler) thro handleStream(exchange, restHandler, readBody(exchange)); return; } + // Task routes (?? A2A: ??/??/??) + String taskId = taskIdFromPath(path, null); + if ("GET".equalsIgnoreCase(method) && taskId != null) { + var resp = restHandler.getTask(buildCallContext(exchange), "", taskId, null); + sendJson(exchange, resp.getStatusCode(), resp.getBody()); + return; + } + taskId = taskIdFromPath(path, ":cancel"); + if ("POST".equalsIgnoreCase(method) && taskId != null) { + var resp = restHandler.cancelTask(buildCallContext(exchange), "", readBody(exchange), taskId); + sendJson(exchange, resp.getStatusCode(), resp.getBody()); + return; + } + taskId = taskIdFromPath(path, ":subscribe"); + if ("POST".equalsIgnoreCase(method) && taskId != null) { + handleSubscribe(exchange, restHandler, taskId); + return; + } exchange.sendResponseHeaders(404, -1); + } catch (IllegalArgumentException e) { + sendJson(exchange, 400, Map.of("error", Map.of("code", 400, "message", e.getMessage()))); } catch (Exception e) { log.error("[{}] Handler error: {}", agentName, e.getMessage(), e); sendJson( @@ -407,6 +429,33 @@ private void handleExchange(HttpExchange exchange, RestHandler restHandler) thro } } + /** Parses exactly one encoded task-id path segment and rejects ambiguous task routes. */ + static String taskIdFromPath(String path, String operationSuffix) { + String prefix = "/tasks/"; + if (path == null || !path.startsWith(prefix)) { + return null; + } + String encoded = path.substring(prefix.length()); + if (operationSuffix != null) { + if (!encoded.endsWith(operationSuffix)) { + return null; + } + encoded = encoded.substring(0, encoded.length() - operationSuffix.length()); + } else if (encoded.contains(":")) { + return null; + } + if (encoded.isEmpty() || encoded.contains("/")) { + return null; + } + try { + // URLDecoder follows form semantics where '+' means space. In a URI path segment an + // unescaped '+' is literal, so protect it before decoding percent escapes. + return URLDecoder.decode(encoded.replace("+", "%2B"), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid encoded task id", e); + } + } + @SuppressWarnings("unchecked") private void handleLogin(HttpExchange exchange) throws IOException { if (!"PUT".equalsIgnoreCase(exchange.getRequestMethod()) @@ -439,6 +488,44 @@ private void handleLogin(HttpExchange exchange) throws IOException { } } + @SuppressWarnings("unchecked") + private void handleSubscribe(HttpExchange exchange, RestHandler restHandler, String taskId) + throws IOException { + try { + var resp = restHandler.subscribeToTask(buildCallContext(exchange), "", taskId); + if (resp instanceof RestHandler.HTTPRestStreamingResponse streaming) { + exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); + exchange.sendResponseHeaders(200, 0); + AtomicLong seq = new AtomicLong(0); + CountDownLatch done = new CountDownLatch(1); + OutputStream os = exchange.getResponseBody(); + streaming.getPublisher().subscribe(new Flow.Subscriber() { + private Flow.Subscription sub; + public void onSubscribe(Flow.Subscription s) { sub = s; s.request(1); } + public void onNext(String item) { + try { + String sse = String.format(Locale.ROOT, "id:%d%n", seq.incrementAndGet()) + + "data:" + item + "\n\n"; + os.write(sse.getBytes(StandardCharsets.UTF_8)); + os.flush(); + sub.request(1); + } catch (IOException e) { + sub.cancel(); done.countDown(); + } + } + public void onError(Throwable t) { done.countDown(); } + public void onComplete() { done.countDown(); } + }); + try { done.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + finally { os.close(); } + } else { + sendJson(exchange, resp.getStatusCode(), resp.getBody()); + } + } catch (A2AError e) { + sendJson(exchange, 500, Map.of("error", e.getMessage())); + } + } + @SuppressWarnings("unchecked") private void handleStream(HttpExchange exchange, RestHandler restHandler, String requestBody) throws IOException { @@ -447,7 +534,6 @@ private void handleStream(HttpExchange exchange, RestHandler restHandler, String SendMessageRequest.Builder builder = SendMessageRequest.newBuilder(); JsonFormat.parser().merge(requestBody, builder); var request = ProtoUtils.FromProto.messageSendParams(builder.build()); - inner.validateRequestedTask(request.message().taskId()); Flow.Publisher publisher = inner.onMessageSendStream(request, buildCallContext(exchange)); exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/server/OmcAgentLauncher.java b/samples/src/main/java/dev/openan/workflow/engine/examples/server/OmcAgentLauncher.java new file mode 100644 index 00000000..ea0a8535 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/server/OmcAgentLauncher.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.server; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import dev.openan.workflow.engine.client.AgentCardJacksonModule; + +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.spec.AgentCard; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Starts and tracks {@link JdkHttpA2AServer} instances from agentcard resources. Centralizes the + * load-card → resolve-bind-address → start boilerplate shared by the demos. + */ +public final class OmcAgentLauncher implements AutoCloseable { + + private static final ObjectMapper MAPPER = + new ObjectMapper().registerModule(new AgentCardJacksonModule()); + + private final List servers = new ArrayList<>(); + + /** Load an agentcard resource, bind to the address declared in its first interface, and start. */ + public JdkHttpA2AServer startFromResource(String resourcePath, AgentExecutor executor) + throws Exception { + String path = OmcAgentLauncher.class.getClassLoader().getResource(resourcePath).getPath(); + @SuppressWarnings("unchecked") + Map card = MAPPER.readValue(new File(path), Map.class); + return startFromCard(card, executor); + } + + /** Bind to the address declared in the card's first supportedInterface and start. */ + public JdkHttpA2AServer startFromCard(Map card, AgentExecutor executor) + throws Exception { + @SuppressWarnings("unchecked") + List> ifaces = + (List>) card.getOrDefault("supportedInterfaces", List.of()); + String url = + ifaces.isEmpty() ? "https://127.0.0.1:0" : String.valueOf(ifaces.get(0).get("url")); + java.net.URI uri = java.net.URI.create(url); + String host = uri.getHost() != null ? uri.getHost() : "127.0.0.1"; + int port = Math.max(uri.getPort(), 0); + JdkHttpA2AServer server = new JdkHttpA2AServer(host, port, card, executor); + server.start(); + servers.add(server); + return server; + } + + /** The {@link AgentCard} view of an agentcard resource, for building engine clients. */ + public static AgentCard cardFromResource(String resourcePath) throws Exception { + String path = OmcAgentLauncher.class.getClassLoader().getResource(resourcePath).getPath(); + return MAPPER.readValue(new File(path), AgentCard.class); + } + + /** Path of a classpath resource as an absolute file path (demo credentials lookup). */ + public static String resourcePath(String resourcePath) { + return OmcAgentLauncher.class.getClassLoader().getResource(resourcePath).getPath(); + } + + public List servers() { + return servers; + } + + @Override + public void close() { + for (JdkHttpA2AServer server : servers) { + try { + server.close(); + } catch (Exception ignored) { + // shutdown is best-effort + } + } + servers.clear(); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringSpnDemo.java b/samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringSpnDemo.java deleted file mode 100644 index 2da30099..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringSpnDemo.java +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.spring; - -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.openan.workflow.engine.client.A2ATransport; -import dev.openan.workflow.engine.client.AgentCardJacksonModule; -import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; -import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; -import dev.openan.workflow.engine.examples.agents.EnvResolver; -import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; -import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity2Executor; -import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; -import dev.openan.workflow.engine.model.SendMessageResult; - -import dev.openan.workflow.engine.examples.embedded.SpnCrossCityDiagnosisDemo; -import org.a2aproject.sdk.server.agentexecution.AgentExecutor; -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.SpringApplication; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -/** - * Demo entry point for the SPN cross-city diagnosis. - * - *

Mirrors {@link SpnCrossCityDiagnosisDemo} but with - * the Workbench Agent running as a Spring Boot service. The demo orchestrates: - * - *

    - *
  1. Start Spring Boot Workbench Agent (A2A server on port 26337) - *
  2. Start OMC agents (embedded servers on ports 26335, 26336) - *
  3. Send a Task-T message to the Workbench Agent via the SDK client - *
  4. Print the response and shut everything down - *
- * - *

Demonstrates heterogeneous architecture: Workbench (Spring Boot) and OMC (JDK HttpServer) - * communicate via the unified A2A-T protocol. - */ -public class SpringSpnDemo { - private static final Logger log = LoggerFactory.getLogger(SpringSpnDemo.class); - private static final ObjectMapper mapper = - new ObjectMapper().registerModule(new AgentCardJacksonModule()); - - private static final String WB_AGENT_NAME = "Transport Workbench Agent"; - private static final long STARTUP_WAIT = 3; - - private final List omcServers = new ArrayList<>(); - - public static void main(String[] args) throws Exception { - System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); - new SpringSpnDemo().run(); - } - - private static String[] args() { - return new String[0]; - } - - public void run() throws Exception { - log.info("=== SPN Cross-City Diagnosis Demo (Spring Workbench) ==="); - - log.info("=== Step 1: Start Spring Boot Workbench Agent ==="); - var ctx = SpringApplication.run(SpringWorkbenchApplication.class, args()); - log.info("Spring Boot Workbench started"); - - log.info("=== Step 2: Start OMC agents (embedded servers) ==="); - startOmcAgents(); - log.info("Waiting for agents to bind..."); - TimeUnit.SECONDS.sleep(STARTUP_WAIT); - - log.info("=== Step 3: Send Task-T to Workbench Agent ==="); - String taskText = - "SPN cross-city fault diagnosis: " - + "Customer A Shanghai-Guangzhou SPN link down, " - + "dispatch two city OMCs for parallel diagnosis, " - + "aggregate analysis to locate the fault city, " - + "authorize recovery, OMC reports recovery result"; - log.info("Task: {}", taskText); - - String response = sendTaskToWorkbench(taskText); - log.info("=== Workbench Agent Response ==="); - if (response != null) { - log.info("Response ({} chars):", response.length()); - log.info("{}", response); - } else { - log.warn("No response received"); - } - - log.info("=== Demo complete, shutting down ==="); - shutdownOmcAgents(); - ctx.close(); - System.exit(0); - } - - private void startOmcAgents() throws Exception { - startOmcAgent("agentcard/spn_domain_agent_city1.json", new SpnDomainAgentCity1Executor()); - startOmcAgent("agentcard/spn_domain_agent_city2.json", new SpnDomainAgentCity2Executor()); - } - - @SuppressWarnings("unchecked") - private void startOmcAgent(String resourcePath, AgentExecutor executor) throws Exception { - String path = getClass().getClassLoader().getResource(resourcePath).getPath(); - Map card = mapper.readValue(new File(path), Map.class); - List> ifaces = - (List>) card.getOrDefault("supportedInterfaces", List.of()); - String url = - ifaces.isEmpty() ? "https://127.0.0.1:0" : String.valueOf(ifaces.get(0).get("url")); - java.net.URI uri = java.net.URI.create(url); - String host = uri.getHost() != null ? uri.getHost() : "127.0.0.1"; - int port = Math.max(uri.getPort(), 0); - JdkHttpA2AServer server = new JdkHttpA2AServer(host, port, card, executor); - server.start(); - omcServers.add(server); - log.info("Started OMC agent: {} on https://{}:{}/", card.get("name"), host, port); - } - - private String sendTaskToWorkbench(String taskText) throws Exception { - String cardPath = - getClass() - .getClassLoader() - .getResource("agentcard/transport_workbench_agent.json") - .getPath(); - AgentCard wbCard = mapper.readValue(new File(cardPath), AgentCard.class); - - String credPath = - getClass().getClassLoader().getResource("spn_agent_credentials.json").getPath(); - String envPath = EnvResolver.resolveEnvPath(); - - A2ATransport transport = - new A2ATransport( - List.of(wbCard), - null, - WorkflowEngineClientConfig.builder() - .sslVerify(false) - .a2atEnvPath(envPath) - .credentialsConfigPath(credPath) - .build()); - DefaultWorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); - try { - SendMessageResult result = client.sendMessage(WB_AGENT_NAME, taskText).join(); - return result.getText(); - } finally { - client.close(); - transport.close(); - } - } - - private void shutdownOmcAgents() { - log.info("Shutting down OMC agents..."); - omcServers.forEach( - s -> { - try { - s.close(); - } catch (Exception ignored) { - } - }); - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringWorkbenchExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringWorkbenchExecutor.java deleted file mode 100644 index 90bd78c1..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringWorkbenchExecutor.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.spring; - -import dev.openan.workflow.engine.client.A2ATExtension; -import dev.openan.workflow.engine.examples.agents.BaseAgentExecutor; -import dev.openan.workflow.engine.examples.agents.EnvResolver; -import dev.openan.workflow.engine.examples.agents.TransportWorkbenchAgentExecutor; -import dev.openan.workflow.engine.examples.agents.WorkbenchOrchestrator; - -import org.a2aproject.sdk.server.agentexecution.RequestContext; -import org.a2aproject.sdk.server.tasks.AgentEmitter; -import org.a2aproject.sdk.spec.A2AError; -import org.a2aproject.sdk.spec.Part; -import org.a2aproject.sdk.spec.TextPart; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.io.File; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Spring-managed Workbench AgentExecutor. - * - *

Same business logic as {@link - * TransportWorkbenchAgentExecutor} -- receives a Task-T from - * the upper layer, delegates to {@link WorkbenchOrchestrator} for the full pipeline, and returns - * the result. The difference is purely the server container: this runs inside Spring Boot instead - * of the JDK HttpServer-based {@code JdkHttpA2AServer}. - */ -@Component -public class SpringWorkbenchExecutor extends BaseAgentExecutor { - private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchExecutor.class); - - @Value("${a2a.orch-url:https://127.0.0.1:5001}") - private String orchUrl; - - @Value("${a2a.credentials-path:}") - private String credentialsPath; - - @Value("${a2a.ssl-verify:false}") - private boolean sslVerify; - - @Value("${a2a.a2at-env-path:}") - private String a2atEnvPath; - - private String resolveEnvPath() { - if (a2atEnvPath != null && !a2atEnvPath.isBlank()) { - return a2atEnvPath; - } - return EnvResolver.resolveEnvPath(); - } - - private String resolveCredentialsPath() { - if (credentialsPath != null && !credentialsPath.isBlank()) { - return credentialsPath; - } - var url = getClass().getClassLoader().getResource("spn_agent_credentials.json"); - if (url != null) { - return new File(url.getPath()).getAbsolutePath(); - } - return null; - } - - @Override - public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - String input = extractText(ctx.getMessage()); - log.info( - "[SpringWorkbench] Received task: taskId={}, text={} chars", - taskId, - input.length()); - - emitter.submit(buildStatusMessage(contextId, taskId, "Task received")); - emitter.startWork(buildStatusMessage(contextId, taskId, "Processing")); - - try { - String result = - new WorkbenchOrchestrator(orchUrl, resolveCredentialsPath(), sslVerify, resolveEnvPath()) - .run(input); - Map metadata = new LinkedHashMap<>(); - metadata.put(A2ATExtension.TASK_T.uri(), result); - List> parts = List.of(new TextPart("SPN cross-city fault diagnosis summary")); - emitter.addArtifact( - parts, "result", "cross-city-diagnosis-summary", metadata, false, true); - emitter.complete(buildStatusMessage(contextId, taskId, "Completed")); - log.info("[SpringWorkbench] Task completed"); - } catch (Exception e) { - log.error("[SpringWorkbench] Failed: {}", e.getMessage(), e); - emitter.fail(buildStatusMessage(contextId, taskId, "Failed: " + e.getMessage())); - } - } - - @Override - public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { - emitter.cancel(); - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/util/A2ATInitialization.java b/samples/src/main/java/dev/openan/workflow/engine/examples/util/A2ATInitialization.java new file mode 100644 index 00000000..23e7f9f6 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/util/A2ATInitialization.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.util; + +import java.io.*; +import java.net.*; +import java.util.*; +import java.util.function.Supplier; + +/** + * Initializes SDK facades with privately owned jar connections during catalog discovery. The + * current SDK closes the catalog's JarFile; disabling caching on those connections prevents it from + * closing another facade's shared handle. No global URL cache setting is changed. + */ +public final class A2ATInitialization { + private A2ATInitialization() {} + + /** + * Temporarily isolates catalog URLs on the current thread and always restores its classloader. + */ + public static T create(Supplier factory) { + Thread thread = Thread.currentThread(); + ClassLoader original = thread.getContextClassLoader(); + ClassLoader parent = + original == null ? A2ATInitialization.class.getClassLoader() : original; + thread.setContextClassLoader( + new ClassLoader(parent) { + @Override + public Enumeration getResources(String name) throws IOException { + List urls = Collections.list(parent.getResources(name)); + List owned = new ArrayList<>(); + for (URL url : urls) owned.add(uncachedJar(url)); + return Collections.enumeration(owned); + } + }); + try { + return factory.get(); + } finally { + thread.setContextClassLoader(original); + } + } + + private static URL uncachedJar(URL original) throws MalformedURLException { + if (!"jar".equals(original.getProtocol())) return original; + return new URL( + null, + original.toExternalForm(), + new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL ignored) throws IOException { + URLConnection connection = original.openConnection(); + connection.setUseCaches(false); + return connection; + } + }); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrors.java b/samples/src/main/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrors.java new file mode 100644 index 00000000..86c6ffeb --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrors.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.examples.util; + +import dev.openan.workflow.engine.model.BusinessFailure; +import net.openan.a2at.sdk.core.exception.A2ATError; +import java.util.Map; +import java.util.function.Supplier; + +/** Host-owned SDK error translation. Raw provider errors/input/configuration never cross the callback boundary. */ +public final class BusinessSdkErrors { + private BusinessSdkErrors() {} + public static T call(String operation, Supplier action) { + try { return action.get(); } + catch (A2ATError error) { + throw new BusinessFailure(error.getCode(), "A2A-T content operation failed: " + operation, + Map.of("operation", operation)); + } + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/EnvResolver.java b/samples/src/main/java/dev/openan/workflow/engine/examples/util/EnvResolver.java similarity index 60% rename from samples/src/main/java/dev/openan/workflow/engine/examples/agents/EnvResolver.java rename to samples/src/main/java/dev/openan/workflow/engine/examples/util/EnvResolver.java index 47063284..3e064207 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/EnvResolver.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/util/EnvResolver.java @@ -17,22 +17,36 @@ * under the License. */ -package dev.openan.workflow.engine.examples.agents; +package dev.openan.workflow.engine.examples.util; import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; /** * Shared utility for resolving the A2A-T {@code .env} file path. * - *

Searches the classpath, then walks up from the working directory to find {@code .env}. Used by - * both demo variants (embedded and Spring) and by shared agent executors that need the env path for - * A2A-T SDK initialization. + *

Resolution order: the {@code a2at.env.path} system property, the {@code A2AT_ENV_PATH} + * environment variable, the classpath, then a walk up from the working directory. Used by both + * demo variants (embedded and Spring) and by shared agent executors that need one consistent SDK + * configuration file. */ public final class EnvResolver { private EnvResolver() {} public static String resolveEnvPath() { + String configured = System.getProperty("a2at.env.path"); + if (configured == null || configured.isBlank()) { + configured = System.getenv("A2AT_ENV_PATH"); + } + if (configured != null && !configured.isBlank()) { + Path path = Path.of(configured.strip()).toAbsolutePath().normalize(); + if (!Files.isRegularFile(path)) { + throw new IllegalArgumentException("A2A-T env file does not exist: " + path); + } + return path.toString(); + } var url = EnvResolver.class.getClassLoader().getResource(".env"); if (url != null && "file".equals(url.getProtocol())) { return new File(url.getPath()).getAbsolutePath(); diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/LlmHelper.java b/samples/src/main/java/dev/openan/workflow/engine/examples/util/LlmHelper.java similarity index 63% rename from samples/src/main/java/dev/openan/workflow/engine/examples/LlmHelper.java rename to samples/src/main/java/dev/openan/workflow/engine/examples/util/LlmHelper.java index 49dfac9d..a21ae860 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/LlmHelper.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/util/LlmHelper.java @@ -17,7 +17,7 @@ * under the License. */ -package dev.openan.workflow.engine.examples; +package dev.openan.workflow.engine.examples.util; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -26,10 +26,10 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; @@ -55,37 +55,95 @@ private LlmHelper() {} public static String text(String envPath, String system, String user, String fallback) { Config config = resolve(envPath); if (config == null) { + log.info("[LlmHelper] LLM_FALLBACK reason=not_configured_or_disabled"); return fallback; } + long started = System.nanoTime(); try { + log.info( + "[LlmHelper] LLM_CALL_START model={}, inputChars={}", + config.model, + (system != null ? system.length() : 0) + (user != null ? user.length() : 0)); String body = buildRequestBody(config, system, user); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(config.baseUrl + "/v1/chat/completions")) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + config.apiKey) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .timeout(Duration.ofSeconds(config.timeoutSeconds)) - .build(); - HttpResponse response = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build() - .send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { - log.warn("[LlmHelper] HTTP {}: {}", response.statusCode(), response.body()); + HttpResponse response = post(config, body); + if (response.statusCode != 200) { + log.warn( + "[LlmHelper] LLM_FALLBACK reason=http_status, status={}, elapsedMs={}", + response.statusCode, + elapsedMillis(started)); return fallback; } - String content = extractContent(response.body()); + ModelReply reply = extractReply(response.body); + String content = reply != null ? reply.content() : null; if (content == null || content.isBlank()) { - log.warn("[LlmHelper] empty model reply, using fallback"); + log.warn( + "[LlmHelper] LLM_FALLBACK reason=empty_reply, elapsedMs={}", + elapsedMillis(started)); return fallback; } + if (reply.finishReason() != null && !"stop".equalsIgnoreCase(reply.finishReason())) { + log.warn( + "[LlmHelper] LLM_FALLBACK reason=incomplete_reply, finishReason={}, outputChars={}, elapsedMs={}", + reply.finishReason(), + content.length(), + elapsedMillis(started)); + return fallback; + } + log.info( + "[LlmHelper] LLM_CALL_DONE model={}, finishReason={}, outputChars={}, elapsedMs={}", + config.model, + reply.finishReason(), + content.length(), + elapsedMillis(started)); return content; } catch (Exception e) { - log.warn("[LlmHelper] LLM call failed, using fallback: {}", e.getMessage()); + log.warn( + "[LlmHelper] LLM_FALLBACK reason=call_failed, errorType={}, elapsedMs={}, message={}", + e.getClass().getSimpleName(), + elapsedMillis(started), + e.getMessage()); return fallback; } } + private static long elapsedMillis(long startedNanos) { + return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis( + System.nanoTime() - startedNanos); + } + + private static HttpResponse post(Config config, String body) throws IOException { + HttpURLConnection connection = + (HttpURLConnection) + URI.create(config.baseUrl + "/v1/chat/completions") + .toURL() + .openConnection(); + try { + connection.setRequestMethod("POST"); + connection.setConnectTimeout((int) Duration.ofSeconds(10).toMillis()); + connection.setReadTimeout( + Math.toIntExact(Duration.ofSeconds(config.timeoutSeconds).toMillis())); + connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + connection.setRequestProperty("Authorization", "Bearer " + config.apiKey); + connection.setDoOutput(true); + try (var output = connection.getOutputStream()) { + output.write(body.getBytes(StandardCharsets.UTF_8)); + } + int status = connection.getResponseCode(); + InputStream responseStream = + status >= 400 ? connection.getErrorStream() : connection.getInputStream(); + String responseBody = + responseStream == null + ? "" + : new String(responseStream.readAllBytes(), StandardCharsets.UTF_8); + if (responseStream != null) { + responseStream.close(); + } + return new HttpResponse(status, responseBody); + } finally { + connection.disconnect(); + } + } + private static Config resolve(String envPath) { if (!Boolean.parseBoolean(System.getProperty("a2at.samples.llm.enabled", "true"))) { log.debug("[LlmHelper] disabled by a2at.samples.llm.enabled"); @@ -199,13 +257,19 @@ private static String buildRequestBody(Config config, String system, String user } } - private static String extractContent(String responseBody) { + private static ModelReply extractReply(String responseBody) { try { JsonNode choices = MAPPER.readTree(responseBody).path("choices"); if (choices.isArray() && !choices.isEmpty()) { - JsonNode message = choices.get(0).path("message").path("content"); + JsonNode choice = choices.get(0); + JsonNode message = choice.path("message").path("content"); if (!message.isMissingNode()) { - return message.asText(); + JsonNode finishReason = choice.path("finish_reason"); + return new ModelReply( + message.asText(), + finishReason.isMissingNode() || finishReason.isNull() + ? null + : finishReason.asText()); } } return null; @@ -235,4 +299,8 @@ private Config(String envPath, String apiKey, String baseUrl, String model, this.timeoutSeconds = timeoutSeconds; } } + + private record HttpResponse(int statusCode, String body) {} + + private record ModelReply(String content, String finishReason) {} } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExecutor.java new file mode 100644 index 00000000..8725db27 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExecutor.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.workbench; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.agents.BaseAgentExecutor; +import dev.openan.workflow.engine.examples.config.WorkbenchClientProperties; +import dev.openan.workflow.engine.examples.util.EnvResolver; + +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.TextPart; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Spring-managed Workbench AgentExecutor. + * + *

Receives a Task-T from the upper layer, delegates to {@link WorkbenchOrchestrator} for the + * full pipeline, and returns the result. The A2A server container is provided by the Spring Boot + * starter's auto-configuration. + */ +@Component +public class SpringWorkbenchExecutor extends BaseAgentExecutor { + private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchExecutor.class); + + private final WorkbenchClientProperties properties; + private volatile WorkbenchTaskInputParser taskInputParser; + + public SpringWorkbenchExecutor(WorkbenchClientProperties properties) { + this.properties = properties; + } + + private String resolveEnvPath() { + if (properties.getA2atEnvPath() != null && !properties.getA2atEnvPath().isBlank()) { + return properties.getA2atEnvPath(); + } + return EnvResolver.resolveEnvPath(); + } + + private String resolveCredentialsPath() { + if (properties.getCredentialsPath() != null + && !properties.getCredentialsPath().isBlank()) { + return properties.getCredentialsPath(); + } + return getClass().getClassLoader().getResource("spn_agent_credentials.json") != null + ? "classpath:spn_agent_credentials.json" + : null; + } + + @Override + public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { + String taskId = ctx.getTaskId(); + String contextId = ctx.getContextId(); + String userText = extractText(ctx.getMessage()); + long started = System.nanoTime(); + emitter.submit(buildStatusMessage(contextId, taskId, "Task received")); + emitter.startWork(buildStatusMessage(contextId, taskId, "Processing")); + + try { + WorkbenchTaskInputParser.ParsedTask taskInput = + taskInputParser() + .parse( + userText, + ctx.getMessage() != null + ? ctx.getMessage().metadata() + : Map.of()); + String input = taskInput.runtimeIntent(); + log.info( + "[SpringWorkbench] TASK_START taskId={}, contextId={}, inputChars={}, " + + "taskTemplate={}, taskParams={}, orchUrl={}, transportMode={}, " + + "sslVerify={}", + taskId, + contextId, + input.length(), + taskInput.templateUri().uri(), + taskInput.parameters().keySet(), + properties.getOrchUrl(), + "direct", + properties.isSslVerify()); + String result = + new WorkbenchOrchestrator( + properties.getOrchUrl(), + resolveCredentialsPath(), + properties.isSslVerify(), + resolveEnvPath()) + .run(input, properties.isDemoNegotiationEnabled()); + Map metadata = new LinkedHashMap<>(); + metadata.put(A2ATExtension.TASK_T.uri(), result); + List> parts = List.of(new TextPart(result)); + emitter.addArtifact( + parts, "result", "cross-city-diagnosis-summary", metadata, false, true); + emitter.complete(buildStatusMessage(contextId, taskId, "Completed")); + log.info( + "[SpringWorkbench] TASK_DONE taskId={}, contextId={}, resultChars={}, elapsedMs={}", + taskId, + contextId, + result.length(), + elapsedMillis(started)); + } catch (Exception e) { + log.error( + "[SpringWorkbench] TASK_FAILED taskId={}, contextId={}, elapsedMs={}, " + + "errorType={}, message={}", + taskId, + contextId, + elapsedMillis(started), + e.getClass().getSimpleName(), + e.getMessage(), + e); + emitter.fail(buildStatusMessage(contextId, taskId, "Failed: " + e.getMessage())); + } + } + + private WorkbenchTaskInputParser taskInputParser() { + WorkbenchTaskInputParser current = taskInputParser; + if (current != null) { + return current; + } + synchronized (this) { + if (taskInputParser == null) { + taskInputParser = WorkbenchTaskInputParser.fromEnv(resolveEnvPath()); + } + return taskInputParser; + } + } + + @Override + public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { + log.warn( + "[SpringWorkbench] TASK_CANCEL taskId={}, contextId={}", + ctx.getTaskId(), + ctx.getContextId()); + emitter.cancel(); + } + + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExtensionLifecycle.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExtensionLifecycle.java new file mode 100644 index 00000000..fbb62e32 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExtensionLifecycle.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.workbench; + +import dev.openan.workflow.engine.examples.config.WorkbenchClientProperties; +import dev.openan.workflow.engine.examples.util.EnvResolver; +import jakarta.annotation.PreDestroy; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** Keeps Authorization-T/Notification-T protocol lifecycles independent from individual tasks. */ +@Component +public final class SpringWorkbenchExtensionLifecycle { + private static final Logger log = + LoggerFactory.getLogger(SpringWorkbenchExtensionLifecycle.class); + + private final WorkbenchClientProperties properties; + private WorkbenchExtensionLifecycle lifecycle; + private volatile java.util.concurrent.CompletableFuture firstRecovery = + new java.util.concurrent.CompletableFuture<>(); + + public SpringWorkbenchExtensionLifecycle(WorkbenchClientProperties properties) { + this.properties = properties; + } + + @EventListener(ApplicationReadyEvent.class) + public synchronized void start() { + if (lifecycle != null && lifecycle.isActive()) { + return; + } + firstRecovery = new java.util.concurrent.CompletableFuture<>(); + WorkbenchExtensionLifecycle candidate = + new WorkbenchExtensionLifecycle( + resolveCredentialsPath(), + properties.isSslVerify(), + resolveEnvPath(), + null, + this::onNotification); + try { + candidate.start(); + lifecycle = candidate; + } catch (RuntimeException e) { + candidate.close(); + lifecycle = null; + log.warn( + "[ExtensionLifecycle] PREPOSITION_FAILED errorType={}, message={}, " + + "action=continue-workflow", + e.getClass().getSimpleName(), + e.getMessage(), + e); + } + } + + @PreDestroy + public synchronized void close() { + if (lifecycle == null) { + return; + } + lifecycle.close(); + lifecycle = null; + } + + /** + * Bounded observation for the local demo before host shutdown, never a workflow prerequisite. + * Only the first recovery is expected: a healthy city may never publish a recovery result. + */ + public boolean awaitFirstRecovery(java.time.Duration timeout) { + if (timeout.isNegative()) + throw new IllegalArgumentException("timeout must not be negative"); + java.util.concurrent.CompletableFuture observed; + synchronized (this) { + if (lifecycle == null) return false; + observed = firstRecovery; + } + try { + observed.get(timeout.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS); + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } catch (java.util.concurrent.ExecutionException + | java.util.concurrent.TimeoutException e) { + return false; + } + } + + private void onNotification(dev.openan.workflow.engine.client.NotificationSubscription handle, + dev.openan.workflow.engine.model.ReceivedMessage received) { + if (dev.openan.workflow.engine.examples.extension.RecoveryNotification.hasCompletedResult(received)) + firstRecovery.complete(null); + log.info("[Notification] EVENT scope=workbench, agent={}, artifacts={}", + handle.agentName(), received.artifacts().size()); + log.debug("[Notification] Content from {}: {}", handle.agentName(), received); + } + + private String resolveEnvPath() { + return properties.getA2atEnvPath() != null && !properties.getA2atEnvPath().isBlank() + ? properties.getA2atEnvPath() + : EnvResolver.resolveEnvPath(); + } + + private String resolveCredentialsPath() { + if (properties.getCredentialsPath() != null + && !properties.getCredentialsPath().isBlank()) { + return properties.getCredentialsPath(); + } + var resource = getClass().getClassLoader().getResource("spn_agent_credentials.json"); + return resource != null ? "classpath:spn_agent_credentials.json" : null; + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalog.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalog.java new file mode 100644 index 00000000..0601c7e1 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalog.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.workbench; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openan.workflow.engine.client.AgentCardJacksonModule; +import java.io.InputStream; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Loads the downstream AgentCards shared by task execution and extension subscriptions. */ +public final class WorkbenchAgentCatalog { + private static final Logger log = LoggerFactory.getLogger(WorkbenchAgentCatalog.class); + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(new AgentCardJacksonModule()); + private static final String LOCATIONS_PROPERTY = "A2A_AGENT_CARD_LOCATIONS"; + private static final List DEFAULT_AGENT_CARD_LOCATIONS = + List.of( + "classpath:agentcard/spn_domain_agent_city1.json", + "classpath:agentcard/spn_domain_agent_city2.json", + "classpath:agentcard/transport_workbench_agent.json"); + + private final List locations; + private final boolean explicitlyConfigured; + + public WorkbenchAgentCatalog() { + String configured = configuredLocations(); + this.explicitlyConfigured = configured != null; + this.locations = + configured == null + ? DEFAULT_AGENT_CARD_LOCATIONS + : Arrays.stream(configured.split("\\s*[,;]\\s*")) + .filter(value -> !value.isBlank()) + .toList(); + if (locations.isEmpty()) { + throw new IllegalArgumentException(LOCATIONS_PROPERTY + " must contain a location"); + } + } + + WorkbenchAgentCatalog(List locations) { + this.locations = List.copyOf(locations); + this.explicitlyConfigured = true; + } + + public List load() { + Map byName = new LinkedHashMap<>(); + for (String location : locations) { + try { + try (InputStream input = open(location)) { + if (input == null) { + if (explicitlyConfigured) { + throw new IllegalArgumentException( + "Configured AgentCard does not exist: " + location); + } + log.warn("[AgentCatalog] CARD_MISSING location={}", location); + continue; + } + AgentCard card = mapper.readValue(input, AgentCard.class); + byName.put(card.name(), card); + log.info( + "[AgentCatalog] CARD_LOADED location={}, agent={}, interfaces={}", + location, + card.name(), + card.supportedInterfaces() != null ? card.supportedInterfaces().size() : 0); + } + } catch (Exception e) { + if (explicitlyConfigured) { + throw new IllegalStateException("Failed to load configured AgentCard " + location, e); + } + log.warn( + "[AgentCatalog] CARD_FAILED location={}, errorType={}, message={}", + location, + e.getClass().getSimpleName(), + e.getMessage(), + e); + } + } + return new ArrayList<>(byName.values()); + } + + private InputStream open(String location) throws Exception { + if (location.startsWith("classpath:")) { + String resource = location.substring("classpath:".length()); + return getClass().getClassLoader().getResourceAsStream(resource); + } + Path path = + location.startsWith("file:") + ? Path.of(URI.create(location)) + : Path.of(location).toAbsolutePath().normalize(); + return Files.isRegularFile(path) ? Files.newInputStream(path) : null; + } + + private static String configuredLocations() { + String value = System.getProperty(LOCATIONS_PROPERTY); + if (value == null || value.isBlank()) { + value = System.getenv(LOCATIONS_PROPERTY); + } + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchControlPoint.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchControlPoint.java new file mode 100644 index 00000000..b1ed88b0 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchControlPoint.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the License); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an AS IS BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.workbench; + +import dev.openan.workflow.engine.examples.util.LlmHelper; +import dev.openan.workflow.engine.control.DefaultControlPoint; +import dev.openan.workflow.engine.model.NegotiationReply; +import dev.openan.workflow.engine.model.NegotiationRequest; +import dev.openan.workflow.engine.model.RouteDecision; +import dev.openan.workflow.engine.model.TaskRequest; +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.RouteRequest; +import dev.openan.workflow.engine.model.TaskResult; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import dev.openan.workflow.engine.examples.negotiation.NegotiationStrategy; +import dev.openan.workflow.engine.examples.util.EnvResolver; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * ControlPoint for the SPN cross-city diagnosis workflow. + * + *

Handles task dispatch (with city-specific message enrichment), route decisions (fault-based + * routing to recovery steps), and negotiation responses. Authorization-T and Notification-T are + * initiated independently of the workflow, not handled here. + * + *

SRP: this class only contains workflow decision logic, separating it from the agent executor + * that handles message I/O. + */ +public class WorkbenchControlPoint extends DefaultControlPoint { + private static final Logger log = LoggerFactory.getLogger(WorkbenchControlPoint.class); + private static final ObjectMapper JSON = new ObjectMapper(); + + private final String a2atEnvPath; + private final NegotiationStrategy negotiationStrategy; + private final net.openan.a2at.sdk.client.A2ATClient contentClient; + private final boolean demoNegotiationEnabled; + + public WorkbenchControlPoint() { + this(null, null); + } + + public WorkbenchControlPoint(String a2atEnvPath) { + this(a2atEnvPath, null); + } + + public WorkbenchControlPoint(String a2atEnvPath, NegotiationStrategy negotiationStrategy) { + this(a2atEnvPath, negotiationStrategy, Boolean.getBoolean("a2at.samples.negotiation")); + } + + /** Explicit per-host demo setting; ordinary integrations do not enable fault injection by default. */ + public WorkbenchControlPoint(String a2atEnvPath, NegotiationStrategy negotiationStrategy, + boolean demoNegotiationEnabled) { + this.demoNegotiationEnabled = demoNegotiationEnabled; + this.a2atEnvPath = a2atEnvPath != null ? a2atEnvPath : EnvResolver.resolveEnvPath(); + this.contentClient = dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new net.openan.a2at.sdk.client.A2ATClient(java.nio.file.Path.of(this.a2atEnvPath))); + this.negotiationStrategy = + negotiationStrategy != null + ? negotiationStrategy + : new NegotiationStrategy(this.a2atEnvPath); + } + + @Override + public CompletableFuture onTask( + TaskRequest request) { + Map data = dev.openan.workflow.engine.examples.demo.SpnCasePrompts.complaintData(request); + return CompletableFuture.supplyAsync(() -> dev.openan.workflow.engine.examples.util.BusinessSdkErrors.call("task-generation", () -> { + var generated = contentClient.generateTaskPromptFromDataWithSchema( + data, dev.openan.workflow.engine.examples.demo.SpnCasePrompts.privateLineComplaintSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + MessageContent content = dev.openan.workflow.engine.client.A2atMessages.from(generated, + List.of(new org.a2aproject.sdk.spec.TextPart(request.getInstruction()))); + if (dev.openan.workflow.engine.examples.demo.SpnCasePrompts.injectNegotiation(request.getStepName(), demoNegotiationEnabled)) { + // Explicit local-demo fault injection, not a production generation fallback. + Map metadata = new java.util.LinkedHashMap<>(content.metadata()); + metadata.put(dev.openan.workflow.engine.client.A2ATExtension.TASK_T.uri(), + dev.openan.workflow.engine.examples.demo.SpnCasePrompts.withoutTaskObject( + generated.buildMetadataContent().get(dev.openan.workflow.engine.client.A2ATExtension.TASK_T.uri()).toString())); + log.info("[onTask] DEMO_NEGOTIATION agent={}, fault=missing-port, source=explicit-sample-switch", + request.getAgentName()); + return new MessageContent(content.parts(), metadata, content.extensions()); + } + return content; + })); + } + + @Override + public CompletableFuture onSelfTask(TaskRequest request) { + String step = request.getStepName(); + log.info( + "[onSelfTask] Self-loop step={}, agent={} (local merge, no A2A-T)", + step, + request.getAgentName()); + String upstreamAgentResults = serialize(request.getWorkflowInput()); + String prompt = + "Current task:\n" + + request.getInstruction() + + "\n\nUpstream agent results:\n" + + upstreamAgentResults; + String fallback = "汇总分析(离线模式,保留原始诊断结果,不生成新结论):\n" + upstreamAgentResults; + String sys = + "你是SPN跨城故障协同诊断汇总专家。仅依据两地市结果进行汇总,不补造网元和端口。" + + "按1.诊断结果、2.诊断结果详情、3.修复建议输出;保留各地市实际结论,失败或证据不足时明确说明。" + + "全文不超过450个中文字符,不使用表格,最后一句必须完整结束。"; + String result = LlmHelper.text(a2atEnvPath, sys, prompt, fallback); + log.info("[onSelfTask] Merge result ({} chars): {}", result.length(), result); + return CompletableFuture.completedFuture( + TaskResult.builder().success(true).outputs(List.of(result)).build()); + } + + private static String serialize(Object value) { + try { + return JSON.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize upstream agent results", e); + } + } + + @Override + public CompletableFuture onRoute(RouteRequest request) { + // merge_analysis has an unconditional next -> endNode, so the executor + // never calls onRoute for it. Recovery is self-triggered by SPN agents + // via the active Authorization-T whitelist and reported through + // the Notification-T channel. Just delegate to the default routing. + return super.onRoute(request); + } + + @Override + public CompletableFuture onNegotiation(NegotiationRequest request) { + return negotiationStrategy.resolve(request); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchExtensionLifecycle.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchExtensionLifecycle.java new file mode 100644 index 00000000..6f1f53fe --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchExtensionLifecycle.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.workbench; + +import dev.openan.workflow.engine.client.A2AJavaClientRuntime; +import dev.openan.workflow.engine.client.A2ATransport; +import dev.openan.workflow.engine.client.DefaultExtensionSender; +import dev.openan.workflow.engine.client.NotificationSubscription; +import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; +import dev.openan.workflow.engine.examples.extension.ExtensionPrePositioner; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Owns workbench-scoped Authorization-T and Notification-T resources outside the workflow DAG. */ +public final class WorkbenchExtensionLifecycle implements AutoCloseable { + private static final Logger log = LoggerFactory.getLogger(WorkbenchExtensionLifecycle.class); + + private final String credentialsPath; + private final boolean sslVerify; + private final String a2atEnvPath; + private final Supplier runtimeSupplier; + private final java.util.function.BiConsumer notificationCallback; + private final Map subscriptions = new ConcurrentHashMap<>(); + private A2ATransport notificationTransport; + + public WorkbenchExtensionLifecycle( + String credentialsPath, + boolean sslVerify, + String a2atEnvPath, + Supplier runtimeSupplier, + java.util.function.BiConsumer notificationCallback) { + this.credentialsPath = credentialsPath; + this.sslVerify = sslVerify; + this.a2atEnvPath = a2atEnvPath; + this.runtimeSupplier = runtimeSupplier; + this.notificationCallback = notificationCallback; + } + + public synchronized void start() { + if (notificationTransport != null) { + log.info( + "[ExtensionLifecycle] START_SKIPPED reason=already_active, contextId={}", + notificationTransport.getContextId()); + return; + } + List agentCards = new WorkbenchAgentCatalog().load(); + A2AJavaClientRuntime authorizationRuntime = + runtimeSupplier != null ? runtimeSupplier.get() : null; + A2AJavaClientRuntime notificationRuntime = + runtimeSupplier != null ? runtimeSupplier.get() : null; + if (authorizationRuntime != null && authorizationRuntime == notificationRuntime) { + throw new IllegalStateException( + "Runtime supplier must create independent Authorization-T and Notification-T instances"); + } + WorkflowEngineClientConfig config = + WorkflowEngineClientConfig.builder() + .sslVerify(sslVerify) + + .credentialsConfigPath(credentialsPath) + .build(); + A2ATransport authorizationTransport = + new A2ATransport(agentCards, authorizationRuntime, config); + A2ATransport notificationCandidate = + new A2ATransport(agentCards, notificationRuntime, config); + long started = System.nanoTime(); + log.info( + "[ExtensionLifecycle] START authorizationContextId={}, notificationContextId={}, scope=workbench", + authorizationTransport.getContextId(), + notificationCandidate.getContextId()); + try { + List opened = + new ExtensionPrePositioner(a2atEnvPath) + .prePosition( + new DefaultExtensionSender(authorizationTransport), + new DefaultExtensionSender(notificationCandidate), + agentCards, + this::handleNotification, + subscription -> + subscriptions.put( + subscription.agentName(), subscription)); + Set activeAgents = + opened.stream() + .map(NotificationSubscription::agentName) + .collect(Collectors.toUnmodifiableSet()); + subscriptions.entrySet().removeIf(entry -> !activeAgents.contains(entry.getKey())); + authorizationTransport.close(); + notificationTransport = notificationCandidate; + log.info( + "[ExtensionLifecycle] ACTIVE contextId={}, subscriptions={}, notificationScope=workbench, elapsedMs={}", + notificationCandidate.getContextId(), + subscriptions.size(), + elapsedMillis(started)); + } catch (RuntimeException e) { + subscriptions.values().forEach(NotificationSubscription::close); + subscriptions.clear(); + authorizationTransport.close(); + notificationCandidate.close(); + throw e; + } + } + + public synchronized boolean isActive() { + return notificationTransport != null; + } + + @Override + public synchronized void close() { + A2ATransport active = notificationTransport; + notificationTransport = null; + if (active == null) { + return; + } + active.close(); + subscriptions.clear(); + } + + private void handleNotification(NotificationSubscription handle, + dev.openan.workflow.engine.model.ReceivedMessage received) { + if (dev.openan.workflow.engine.examples.extension.RecoveryNotification.hasCompletedResult(received)) { + subscriptions.remove(handle.agentName(), handle); + log.info("[ExtensionLifecycle] NOTIFICATION_COMPLETE agent={}, contextId={}, action=close-stream", + handle.agentName(), handle.contextId()); + handle.close(); + } + if (notificationCallback != null) { + try { notificationCallback.accept(handle, received); } + catch (RuntimeException error) { log.warn("Notification observer failed", error); } + } + } + + private static long elapsedMillis(long startedNanos) { + return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java new file mode 100644 index 00000000..0ec95d21 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java @@ -0,0 +1,391 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.workbench; + +import dev.openan.workflow.engine.client.A2ATransport; +import dev.openan.workflow.engine.client.A2AJavaClientRuntime; +import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; +import dev.openan.workflow.engine.client.WorkflowEngineClient; +import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; +import dev.openan.workflow.engine.control.EventCallback; +import dev.openan.workflow.engine.control.EventType; +import dev.openan.workflow.engine.model.ExecutionResult; +import dev.openan.workflow.engine.model.JumpCondition; +import dev.openan.workflow.engine.model.StepType; +import dev.openan.workflow.engine.model.Task; +import dev.openan.workflow.engine.model.Workflow; +import dev.openan.workflow.engine.model.WorkflowSearchResult; +import dev.openan.workflow.engine.model.WorkflowStep; +import dev.openan.workflow.engine.registry.LoadPsop; +import dev.openan.workflow.engine.runner.ExecutePsop; + +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import dev.openan.workflow.engine.examples.negotiation.NegotiationStrategy; +/** + * Workflow orchestration for the SPN cross-city diagnosis. + * + *

Single responsibility: coordinate the full orchestration pipeline -- load agent cards, + * search/load PSOP, create the task-scoped engine client, and run the workflow. Each + * sub-step delegates to a dedicated collaborator: + * + *

    + *
  • {@link WorkbenchControlPoint} -- workflow decision callbacks (task dispatch, routing) + *
  • {@link NegotiationStrategy} -- typed negotiation decisions (injected into the control point) + *
+ * + *

This class does NOT handle agent server I/O (that stays in the Spring-managed + * {@code SpringWorkbenchExecutor}) or the details of any single A2A-T extension protocol. + */ +public class WorkbenchOrchestrator { + + private static final Logger log = LoggerFactory.getLogger(WorkbenchOrchestrator.class); + private static final String FALLBACK_PSOP_ID = "psop_spn_cross_city_diagnosis"; + + private final String orchUrl; + private final String credentialsPath; + private final boolean sslVerify; + private final String a2atEnvPath; + private final A2AJavaClientRuntime clientRuntime; + + public WorkbenchOrchestrator( + String orchUrl, String credentialsPath, boolean sslVerify, String a2atEnvPath) { + this(orchUrl, credentialsPath, sslVerify, a2atEnvPath, null); + } + + public WorkbenchOrchestrator( + String orchUrl, + String credentialsPath, + boolean sslVerify, + String a2atEnvPath, + A2AJavaClientRuntime clientRuntime) { + this.orchUrl = orchUrl; + this.credentialsPath = credentialsPath; + this.sslVerify = sslVerify; + this.a2atEnvPath = a2atEnvPath; + this.clientRuntime = clientRuntime; + } + + static String buildResultText(ExecutionResult result) { + if (result.isSuccess()) { + for (String preferredStep : List.of("merge_analysis", "merge")) { + String output = outputText(result.getStepOutputs().get(preferredStep)); + if (!output.isBlank()) { + return output; + } + } + if (result.getHistory() != null) { + for (int i = result.getHistory().size() - 1; i >= 0; i--) { + Object output = result.getHistory().get(i).get("output"); + if (output != null && !String.valueOf(output).isBlank()) { + return String.valueOf(output); + } + } + } + } + StringBuilder sb = new StringBuilder(); + sb.append("Workflow execution ") + .append(result.isSuccess() ? "succeeded" : "failed") + .append(".\n"); + if (result.getHistory() != null) { + for (Map h : result.getHistory()) { + sb.append("- Step: ") + .append(h.get("step")) + .append(", Agent: ") + .append(h.get("agent")) + .append(", Status: ") + .append(h.get("status")) + .append("\n"); + } + } + if (result.getError() != null) { + sb.append("Error: ").append(result.getError()); + } + return sb.toString(); + } + + private static String outputText(Map output) { + if (output == null || output.isEmpty()) { + return ""; + } + return output.values().stream() + .filter(value -> value != null && !String.valueOf(value).isBlank()) + .map(String::valueOf) + .distinct() + .collect(java.util.stream.Collectors.joining("\n\n")); + } + + /** Run the full orchestration pipeline and return the result text. */ + public String run(String messageText) throws Exception { + return run(messageText, Boolean.getBoolean("a2at.samples.negotiation")); + } + + /** Runs with an explicit sample-host negotiation setting, without changing global properties. */ + public String run(String messageText, boolean demoNegotiationEnabled) throws Exception { + long runStarted = System.nanoTime(); + String runtimeName = + clientRuntime != null ? clientRuntime.getClass().getSimpleName() : "DefaultA2AJavaClientRuntime"; + log.info( + "[Orchestrator] START orchUrl={}, runtime={}, sslVerify={}, inputChars={}", + orchUrl, + runtimeName, + sslVerify, + messageText != null ? messageText.length() : 0); + + long stageStarted = System.nanoTime(); + log.info("[Orchestrator] STAGE_START stage=load-agent-cards"); + List agentCards = loadAgentCards(); + log.info( + "[Orchestrator] STAGE_DONE stage=load-agent-cards, count={}, agents={}, elapsedMs={}", + agentCards.size(), + agentCards.stream().map(AgentCard::name).toList(), + elapsedMillis(stageStarted)); + + stageStarted = System.nanoTime(); + log.info("[Orchestrator] STAGE_START stage=search-load-psop"); + ResolvedWorkflow resolvedWorkflow = resolveWorkflow(messageText); + String psopId = resolvedWorkflow.psopId(); + Workflow workflow = resolvedWorkflow.workflow(); + log.info( + "[Orchestrator] STAGE_DONE stage=search-load-psop, psopId={}, workflow={}, " + + "steps={}, elapsedMs={}", + psopId, + workflow.getName(), + workflow.getSteps().size(), + elapsedMillis(stageStarted)); + + stageStarted = System.nanoTime(); + log.info( + "[Orchestrator] STAGE_START stage=create-engine-client, runtime={}", + runtimeName); + A2ATransport transport = + new A2ATransport( + agentCards, + clientRuntime, + WorkflowEngineClientConfig.builder() + .sslVerify(sslVerify) + + .credentialsConfigPath(credentialsPath) + .build()); + log.info( + "[Orchestrator] STAGE_DONE stage=create-engine-client, contextId={}, elapsedMs={}", + transport.getContextId(), + elapsedMillis(stageStarted)); + try { + WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport); + + stageStarted = System.nanoTime(); + log.info( + "[Orchestrator] STAGE_START stage=execute-workflow, contextId={}, " + + "psopId={}, workflow={}", + transport.getContextId(), + psopId, + workflow.getName()); + WorkbenchControlPoint controlPoint = + new WorkbenchControlPoint(a2atEnvPath, new NegotiationStrategy(a2atEnvPath), demoNegotiationEnabled); + ExecutionResult result = + ExecutePsop.builder() + .psop(workflow) + .agentCards(agentCards) + .controlPoint(controlPoint) + .engineClient(engineClient) + .runtimeIntent(messageText) + .lang("zh") + .sslVerify(sslVerify) + .credentialsConfigPath(credentialsPath) + + .eventCallback(createLogCallback()) + .onFinish( + (r, events) -> { + log.info( + "[Orchestrator] WORKFLOW_FINISH success={}, history={}, events={}", + r.isSuccess(), + r.getHistory() != null ? r.getHistory().size() : 0, + events.size()); + return CompletableFuture.completedFuture(null); + }) + .execute() + .join(); + log.info( + "[Orchestrator] STAGE_DONE stage=execute-workflow, contextId={}, success={}, " + + "history={}, elapsedMs={}", + transport.getContextId(), + result.isSuccess(), + result.getHistory() != null ? result.getHistory().size() : 0, + elapsedMillis(stageStarted)); + String resultText = buildResultText(result); + log.info( + "[Orchestrator] DONE contextId={}, success={}, resultChars={}, elapsedMs={}", + transport.getContextId(), + result.isSuccess(), + resultText.length(), + elapsedMillis(runStarted)); + if (!result.isSuccess()) { + throw new IllegalStateException(resultText); + } + return resultText; + } catch (Exception e) { + log.error( + "[Orchestrator] FAILED contextId={}, elapsedMs={}, errorType={}, message={}", + transport.getContextId(), + elapsedMillis(runStarted), + e.getClass().getSimpleName(), + e.getMessage(), + e); + throw e; + } finally { + log.info("[Orchestrator] TRANSPORT_CLOSE contextId={}", transport.getContextId()); + transport.close(); + } + } + + private List loadAgentCards() { + return new WorkbenchAgentCatalog().load(); + } + + private ResolvedWorkflow resolveWorkflow(String messageText) { + try { + List results = + LoadPsop.search(orchUrl, messageText, 3, null, sslVerify); + if (!results.isEmpty()) { + String psopId = results.get(0).getWorkflowId(); + log.info( + "[Orchestrator] Found PSOP: {} (score={})", + psopId, + results.get(0).getScore()); + return new ResolvedWorkflow( + psopId, LoadPsop.load(orchUrl, psopId, null, sslVerify)); + } + log.warn("[Orchestrator] PSOP search returned no candidates"); + } catch (Exception e) { + log.warn("[Orchestrator] PSOP search failed, using fallback: {}", e.getMessage()); + } + log.warn("[Orchestrator] PSOP_FALLBACK psopId={}", FALLBACK_PSOP_ID); + return new ResolvedWorkflow(FALLBACK_PSOP_ID, fallbackWorkflow()); + } + + static Workflow fallbackWorkflow() { + WorkflowStep city1 = + WorkflowStep.builder() + .name("diagnosis_city1") + .layer(0) + .subtasks(List.of( + Task.builder() + .agent("SPN Domain Agent City1") + .description("SPN专线故障诊断") + .build())) + .next(List.of(JumpCondition.builder() + .step("merge_analysis") + .condition("") + .build())) + .build(); + WorkflowStep city2 = + WorkflowStep.builder() + .name("diagnosis_city2") + .layer(0) + .subtasks(List.of( + Task.builder() + .agent("SPN Domain Agent City2") + .description("SPN专线故障诊断") + .build())) + .next(List.of(JumpCondition.builder() + .step("merge_analysis") + .condition("") + .build())) + .build(); + WorkflowStep merge = + WorkflowStep.builder() + .name("merge_analysis") + .layer(1) + .stepType(StepType.SELF_LOOP) + .contextFrom(List.of("diagnosis_city1", "diagnosis_city2")) + .subtasks(List.of( + Task.builder() + .agent("Workbench") + .description("汇总两地市OMC诊断结论") + .build())) + .next(List.of(JumpCondition.builder() + .step("endNode") + .condition("") + .build())) + .build(); + return Workflow.builder() + .id(FALLBACK_PSOP_ID) + .name("SPN跨地市专线投诉诊断") + .description("两个地市并行诊断后由工作台本地汇总") + .steps(List.of(city1, city2, merge)) + .build(); + } + + private record ResolvedWorkflow(String psopId, Workflow workflow) {} + + private EventCallback createLogCallback() { + return new EventCallback() { + @Override + public void onEvent(String type, Map data) { + switch (type) { + case EventType.START -> log.info(" [START] {}", data.get("workflow")); + case EventType.STEP_START -> log.info(" [STEP_START] {}", data.get("step")); + case EventType.TASK_REQUEST -> + log.info(" [TASK_REQUEST] agent={}", data.get("agent")); + case EventType.TASK_RESPONSE -> + log.info(" [TASK_RESPONSE] agent={}", data.get("agent")); + case EventType.AGENT_STATUS_UPDATE -> + log.info( + " [STATUS_UPDATE] agent={}, state={}, final={}", + data.get("agent"), + data.get("state"), + data.get("is_final")); + case EventType.AGENT_ARTIFACT_UPDATE -> + log.info( + " [ARTIFACT_UPDATE] agent={}, artifact={}", + data.get("agent"), + data.get("artifact_name")); + case EventType.AGENT_MESSAGE_EVENT -> + log.info( + " [MESSAGE] agent={}, {} chars", + data.get("agent"), + data.get("text") != null + ? ((String) data.get("text")).length() + : 0); + case EventType.STEP_COMPLETE -> + log.info(" [STEP_COMPLETE] {}", data.get("step")); + case EventType.ROUTE_DECISION -> + log.info(" [ROUTE] {} -> {}", data.get("step"), data.get("next")); + case EventType.COMPLETE -> log.info(" [COMPLETE]"); + case EventType.ERROR -> log.error(" [ERROR] {}", data.get("error")); + case EventType.CLOSE -> log.info(" [CLOSE]"); + default -> {} + } + } + }; + } + + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskInputParser.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskInputParser.java new file mode 100644 index 00000000..81f78fed --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskInputParser.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.workbench; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; + +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.server.A2ATServer; + +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Validates an inbound Workbench Task-T message and exposes its structured business input. */ +final class WorkbenchTaskInputParser { + + @FunctionalInterface + interface TaskPromptValidator { + FilledParamData validate( + String prompt, Map schema, TemplateUri templateUri); + } + + record ParsedTask( + String userText, + String prompt, + TemplateUri templateUri, + Map parameters) { + + String runtimeIntent() { + StringBuilder intent = new StringBuilder(); + if (userText != null && !userText.isBlank()) { + intent.append(userText.strip()).append('\n'); + } + intent.append("Task-T template: ").append(templateUri.uri()).append('\n'); + intent.append("Validated complaint parameters:"); + parameters.forEach( + (key, value) -> + intent.append("\n- ") + .append(key) + .append(": ") + .append(value != null ? value : "")); + return intent.toString(); + } + } + + private final TaskPromptValidator validator; + + WorkbenchTaskInputParser(TaskPromptValidator validator) { + this.validator = validator; + } + + static WorkbenchTaskInputParser fromEnv(String envPath) { + if (envPath == null || envPath.isBlank()) { + throw new IllegalStateException( + "A2A-T SDK env path is required to validate inbound Workbench Task-T messages"); + } + A2ATServer server = + dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new A2ATServer(Path.of(envPath))); + return new WorkbenchTaskInputParser( + (prompt, schema, uri) -> + server.validateTaskPromptAndDataFilling(prompt, schema, uri.uri())); + } + + ParsedTask parse(String userText, Map metadata) { + if (metadata == null || metadata.isEmpty()) { + throw new IllegalArgumentException("Inbound Workbench request is missing Task-T metadata"); + } + String prompt = extractTaskPrompt(metadata); + TemplateUri templateUri = extractTemplateUri(metadata); + FilledParamData filled = + validator.validate(prompt, SpnCasePrompts.privateLineComplaintSchema(), templateUri); + if (filled == null || filled.data() == null || filled.data().isEmpty()) { + throw new IllegalArgumentException( + "Inbound Workbench Task-T did not contain validated complaint parameters"); + } + Map parameters = new LinkedHashMap<>(filled.data()); + for (String required : SpnCasePrompts.privateLineComplaintSchemaProperties()) { + Object value = parameters.get(required); + if (value == null || String.valueOf(value).isBlank()) { + throw new IllegalArgumentException( + "Inbound Workbench Task-T is missing required parameter: " + required); + } + } + return new ParsedTask(userText, prompt, templateUri, Map.copyOf(parameters)); + } + + private static String extractTaskPrompt(Map metadata) { + Object exact = metadata.get(A2ATExtension.TASK_T.uri()); + if (exact instanceof String prompt && !prompt.isBlank()) { + return prompt; + } + throw new IllegalArgumentException( + "Inbound Workbench request has no canonical Task-T prompt payload"); + } + + private static TemplateUri extractTemplateUri(Map metadata) { + Object raw = metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY); + if (raw == null) { + throw new IllegalArgumentException( + "Inbound Workbench Task-T is missing templateUri metadata"); + } + if (!(raw instanceof String candidate) || candidate.isBlank()) { + throw new IllegalArgumentException( + "Inbound Workbench Task-T has malformed templateUri metadata"); + } + TemplateUri parsed = + TemplateUri.parse(candidate) + .orElseThrow( + () -> + new IllegalArgumentException( + "Inbound Workbench Task-T has invalid templateUri: " + + candidate)); + return requireTaskTemplate(parsed); + } + + private static TemplateUri requireTaskTemplate(TemplateUri templateUri) { + if (!StandardTemplates.PRIVATE_LINE_COMPLAINT.equals(templateUri)) { + throw new IllegalArgumentException( + "Inbound Workbench template is not the SPN private-line complaint template: " + + templateUri.uri()); + } + return templateUri; + } +} diff --git a/samples/src/main/resources/agentcard/spn_domain_agent_city1.json b/samples/src/main/resources/agentcard/spn_domain_agent_city1.json index 412a171f..88ddb933 100644 --- a/samples/src/main/resources/agentcard/spn_domain_agent_city1.json +++ b/samples/src/main/resources/agentcard/spn_domain_agent_city1.json @@ -17,7 +17,7 @@ "required": false }, { - "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T", + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", "description": "Extension for A2A-T negotiation text exchange.", "required": false }, diff --git a/samples/src/main/resources/agentcard/spn_domain_agent_city2.json b/samples/src/main/resources/agentcard/spn_domain_agent_city2.json index b83dc3b9..33fd4a36 100644 --- a/samples/src/main/resources/agentcard/spn_domain_agent_city2.json +++ b/samples/src/main/resources/agentcard/spn_domain_agent_city2.json @@ -17,7 +17,7 @@ "required": false }, { - "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T", + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", "description": "Extension for A2A-T negotiation text exchange.", "required": false }, diff --git a/samples/src/main/resources/agentcard/transport_workbench_agent.json b/samples/src/main/resources/agentcard/transport_workbench_agent.json index 6ca5b163..b44eb6bf 100644 --- a/samples/src/main/resources/agentcard/transport_workbench_agent.json +++ b/samples/src/main/resources/agentcard/transport_workbench_agent.json @@ -17,7 +17,7 @@ "required": false }, { - "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T", + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", "description": "Extension for A2A-T negotiation text exchange.", "required": false }, @@ -79,4 +79,4 @@ "tenant": "" } ] -} \ No newline at end of file +} diff --git a/samples/src/main/resources/application.yml b/samples/src/main/resources/application.yml index c38fbd2c..d13bab05 100644 --- a/samples/src/main/resources/application.yml +++ b/samples/src/main/resources/application.yml @@ -1,6 +1,8 @@ server: port: 26337 ssl: + # Public demo certificate only. Its private key and password are intentionally known; + # replace both before any shared, test, staging, or production deployment. enabled: true key-store: classpath:a2a-server-keystore.p12 key-store-password: changeit @@ -17,4 +19,4 @@ a2at: a2a: orch-url: https://127.0.0.1:5001 - ssl-verify: false + ssl-verify: ${A2A_SSL_VERIFY:false} diff --git a/samples/src/main/resources/log4j2.properties b/samples/src/main/resources/log4j2.properties index 0ef096b2..122b3138 100644 --- a/samples/src/main/resources/log4j2.properties +++ b/samples/src/main/resources/log4j2.properties @@ -15,6 +15,7 @@ appender.file.fileName=logs/spn-demo.log appender.file.filePattern=logs/spn-demo-%d{yyyy-MM-dd}-%i.log.gz appender.file.append=true appender.file.layout.type=PatternLayout +appender.file.layout.charset=UTF-8 appender.file.layout.pattern=${LOG_PATTERN} appender.file.policies.type=Policies appender.file.policies.time.type=TimeBasedTriggeringPolicy @@ -33,7 +34,7 @@ logger.a2a.name=org.a2aproject logger.a2a.level=WARN # Protocol-level message dumps: full request/response bodies + headers logger.protocol.name=PROTOCOL -logger.protocol.level=INFO +logger.protocol.level=DEBUG # Root logger rootLogger.level=INFO rootLogger.appenderRef.console.ref=Console diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/EmbeddedA2AServerTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/EmbeddedA2AServerTest.java deleted file mode 100644 index e063af1b..00000000 --- a/samples/src/test/java/dev/openan/workflow/engine/examples/EmbeddedA2AServerTest.java +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples; - -import static org.junit.jupiter.api.Assertions.*; - -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.openan.workflow.engine.client.A2ATransport; -import dev.openan.workflow.engine.client.AgentCardJacksonModule; -import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; -import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; -import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; -import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; -import dev.openan.workflow.engine.model.SendMessageResult; - -import org.a2aproject.sdk.spec.AgentCard; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.List; -import java.util.Map; - -/** - * Integration test: start an EmbeddedA2AServer, send a message via DefaultWorkflowEngineClient (the - * real A2A client), and verify the response text is extracted correctly from the SSE stream. - */ -class EmbeddedA2AServerTest { - - private static final ObjectMapper mapper = - new ObjectMapper().registerModule(new AgentCardJacksonModule()); - private static final String AGENT_NAME = "Test Agent"; - private JdkHttpA2AServer server; - private DefaultWorkflowEngineClient client; - private int port; - - private static String extractExtensionValue(SendMessageResult result, String extKeyword) { - Map meta = result.getMetadata(); - if (meta == null) return null; - for (String key : meta.keySet()) { - if (key.contains(extKeyword)) { - Object v = meta.get(key); - return v instanceof String s ? s : String.valueOf(v); - } - } - return null; - } - - private static javax.net.ssl.SSLContext createTrustAllSslContext() throws Exception { - javax.net.ssl.SSLContext ctx = javax.net.ssl.SSLContext.getInstance("TLS"); - ctx.init( - null, - new javax.net.ssl.TrustManager[] { - new javax.net.ssl.X509TrustManager() { - public void checkClientTrusted( - java.security.cert.X509Certificate[] chain, String authType) {} - - public void checkServerTrusted( - java.security.cert.X509Certificate[] chain, String authType) {} - - public java.security.cert.X509Certificate[] getAcceptedIssuers() { - return new java.security.cert.X509Certificate[0]; - } - } - }, - null); - return ctx; - } - - @BeforeEach - void setUp() throws Exception { - // Unit tests must stay deterministic and offline: disable LLM calls so - // agents fall back to their hardcoded diagnostic text (asserted below). - System.setProperty("a2at.llm.disabled", "true"); - port = 28000 + (int) (Math.random() * 1000); - Map card = - Map.of( - "name", AGENT_NAME, - "description", "test", - "provider", Map.of("organization", "test", "url", ""), - "version", "1.0.0", - "capabilities", - Map.of( - "streaming", - true, - "pushNotifications", - false, - "extendedAgentCard", - false, - "extensions", - List.of()), - "defaultInputModes", List.of("text/plain"), - "defaultOutputModes", List.of("text/plain"), - "skills", - List.of( - Map.of( - "id", - "test", - "name", - "test", - "description", - "test", - "tags", - List.of())), - "supportedInterfaces", - List.of( - Map.of( - "protocolBinding", - "HTTP+JSON", - "protocolVersion", - "1.0", - "url", - "https://127.0.0.1:" + port, - "tenant", - ""))); - server = new JdkHttpA2AServer("127.0.0.1", port, card, new SpnDomainAgentCity1Executor()); - server.start(); - Thread.sleep(500); - A2ATransport transport = - new A2ATransport( - List.of(mapper.convertValue(card, AgentCard.class)), - null, - WorkflowEngineClientConfig.builder().sslVerify(false).build()); - client = new DefaultWorkflowEngineClient(transport); - } - - @AfterEach - void tearDown() { - if (client != null) client.close(); - if (server != null) server.close(); - } - - @Test - void testGetAgentCard() throws Exception { - HttpClient http = HttpClient.newBuilder().sslContext(createTrustAllSslContext()).build(); - HttpRequest req = - HttpRequest.newBuilder() - .uri(URI.create("https://127.0.0.1:" + port + "/")) - .GET() - .build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString()); - assertEquals(200, resp.statusCode()); - Map card = mapper.readValue(resp.body(), Map.class); - assertEquals(AGENT_NAME, card.get("name")); - } - - @Test - void testSendMessage() throws Exception { - SendMessageResult result = client.sendMessage(AGENT_NAME, "diagnose SPN fault").join(); - assertNotNull(result); - assertFalse( - result.getText().isEmpty(), - "Response text should not be empty, got: " + result.getText()); - // A2A-T extension content is in metadata (Task-T/v1), not in parts.text - String taskMeta = extractExtensionValue(result, "Task-T"); - assertNotNull(taskMeta, "Task-T metadata should be present in response"); - assertTrue( - taskMeta.contains("城市1"), - "Diagnosis metadata should mention City1, got: " + taskMeta); - } -} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/SpnCrossCityE2ETest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/SpnCrossCityE2ETest.java index a2af84c5..6e761e99 100644 --- a/samples/src/test/java/dev/openan/workflow/engine/examples/SpnCrossCityE2ETest.java +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/SpnCrossCityE2ETest.java @@ -15,13 +15,17 @@ import dev.openan.workflow.engine.client.DefaultExtensionSender; import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; import dev.openan.workflow.engine.client.ExtensionSender; +import dev.openan.workflow.engine.client.NotificationSubscription; import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; import dev.openan.workflow.engine.control.EventCallback; import dev.openan.workflow.engine.control.EventType; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity2Executor; -import dev.openan.workflow.engine.examples.agents.WorkbenchControlPoint; -import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; +import dev.openan.workflow.engine.examples.extension.PrePositionedExtensionHandler; +import dev.openan.workflow.engine.examples.testsupport.OfflineA2ATLlmClient; +import dev.openan.workflow.engine.examples.workbench.WorkbenchControlPoint; +import dev.openan.workflow.engine.examples.server.OmcAgentLauncher; import dev.openan.workflow.engine.model.ExecutionResult; import dev.openan.workflow.engine.model.JumpCondition; import dev.openan.workflow.engine.model.StepType; @@ -32,34 +36,53 @@ import org.a2aproject.sdk.spec.AgentCard; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; /** * End-to-end business integration test for the SPN cross-city diagnosis workflow. Starts two real * SPN agents (JdkHttpA2AServer + A2A-T protocol over HTTPS+SSE), pre-positions Authorization-T and * Notification-T, runs a 3-step workflow (diagnose x2 + SelfLoop merge), and asserts the full - * business path: negotiation -> diagnosis -> whitelist self-recovery -> Notification-T report -> - * local merge (no A2A-T to self). + * business path: parallel diagnosis -> whitelist self-recovery -> Notification-T report -> local + * merge (no A2A-T to self). The diagnosis tasks carry complete input parameters; Negotiation-T is + * covered by its independent protocol case and is not part of this workflow. * - *

LLM is disabled so agent outputs are deterministic fallback text. + *

The A2A-T client/server paths use an offline structured-LLM provider, so the real SDK + * generation and validation pipelines run without network access. The demo's unrelated business + * prose LLM remains disabled by the Maven test profile. */ class SpnCrossCityE2ETest { private static final ObjectMapper mapper = new ObjectMapper().registerModule(new AgentCardJacksonModule()); - private final List servers = new ArrayList<>(); + private final OmcAgentLauncher omc = new OmcAgentLauncher(); private DefaultWorkflowEngineClient client; - private ExtensionSender sender; + private ExtensionSender authorizationSender; + private ExtensionSender notificationSender; + private A2ATransport taskTransport; + private A2ATransport authorizationTransport; + private A2ATransport notificationTransport; + private final List subscriptions = new CopyOnWriteArrayList<>(); private int port1; private int port2; + private String sdkEnvPath; + private net.openan.a2at.sdk.client.A2ATClient contentClient; + + @BeforeAll + static void registerMockProvider() { + OfflineA2ATLlmClient.install(); + } private static AgentCard cardFor(String name, int port) { Map card = @@ -77,7 +100,27 @@ private static AgentCard cardFor(String name, int port) { "extendedAgentCard", false, "extensions", - List.of()), + List.of( + Map.of( + "uri", + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1", + "required", + false), + Map.of( + "uri", + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", + "required", + false), + Map.of( + "uri", + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1", + "required", + false), + Map.of( + "uri", + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1", + "required", + false))), "defaultInputModes", List.of("text/plain"), "defaultOutputModes", List.of("text/plain"), "skills", @@ -107,47 +150,44 @@ private static AgentCard cardFor(String name, int port) { @BeforeEach void setUp() throws Exception { - System.setProperty("a2at.llm.disabled", "true"); + sdkEnvPath = OfflineA2ATLlmClient.envPath(); + System.setProperty("a2at.env.path", sdkEnvPath); port1 = 28200 + (int) (Math.random() * 500); port2 = port1 + 1; AgentCard c1 = cardFor("SPN Domain Agent City1", port1); AgentCard c2 = cardFor("SPN Domain Agent City2", port2); - JdkHttpA2AServer s1 = - new JdkHttpA2AServer( - "127.0.0.1", - port1, - mapper.convertValue(c1, Map.class), - new SpnDomainAgentCity1Executor()); - JdkHttpA2AServer s2 = - new JdkHttpA2AServer( - "127.0.0.1", - port2, - mapper.convertValue(c2, Map.class), - new SpnDomainAgentCity2Executor()); - s1.start(); - s2.start(); - servers.add(s1); - servers.add(s2); + omc.startFromCard( + mapper.convertValue(c1, Map.class), + new SpnDomainAgentCity1Executor(new PrePositionedExtensionHandler())); + omc.startFromCard( + mapper.convertValue(c2, Map.class), + new SpnDomainAgentCity2Executor(new PrePositionedExtensionHandler())); Thread.sleep(600); - A2ATransport transport = - new A2ATransport( - List.of(c1, c2), - null, - WorkflowEngineClientConfig.builder().sslVerify(false).build()); - client = new DefaultWorkflowEngineClient(transport); - sender = new DefaultExtensionSender(transport); + WorkflowEngineClientConfig config = + WorkflowEngineClientConfig.builder() + .sslVerify(false) + + .build(); + taskTransport = new A2ATransport(List.of(c1, c2), null, config); + authorizationTransport = new A2ATransport(List.of(c1, c2), null, config); + notificationTransport = new A2ATransport(List.of(c1, c2), null, config); + client = new DefaultWorkflowEngineClient(taskTransport); + contentClient = dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new net.openan.a2at.sdk.client.A2ATClient(java.nio.file.Path.of(sdkEnvPath))); + authorizationSender = new DefaultExtensionSender(authorizationTransport); + notificationSender = new DefaultExtensionSender(notificationTransport); } @AfterEach void tearDown() { + subscriptions.forEach(NotificationSubscription::close); + subscriptions.clear(); if (client != null) client.close(); - servers.forEach( - s -> { - try { - s.close(); - } catch (Exception ignored) { - } - }); + if (taskTransport != null) taskTransport.close(); + if (authorizationTransport != null) authorizationTransport.close(); + if (notificationTransport != null) notificationTransport.close(); + omc.close(); + System.clearProperty("a2at.env.path"); } private Workflow crossCityWorkflow() { @@ -196,24 +236,59 @@ private Workflow crossCityWorkflow() { } @Test - void fullBusinessPathNegotiationDiagnosisRecoveryAndSelfLoopMerge() { + void fullBusinessPathDiagnosisRecoveryAndSelfLoopMerge() { + assertNotEquals(taskTransport.getContextId(), authorizationTransport.getContextId()); + assertNotEquals(taskTransport.getContextId(), notificationTransport.getContextId()); + assertNotEquals(authorizationTransport.getContextId(), notificationTransport.getContextId()); // Pre-position Authorization-T + Notification-T to both SPN agents - sender.sendAuthorization("SPN Domain Agent City1", "下发授权放行策略", "任务类型新增授权,操作名称业务抢通").join(); - sender.sendNotification( - "SPN Domain Agent City1", - "订阅业务抢通结果通知", - "通知主题为service-recovery-execution-result") - .join(); - sender.sendAuthorization("SPN Domain Agent City2", "下发授权放行策略", "任务类型新增授权,操作名称业务抢通").join(); - sender.sendNotification( - "SPN Domain Agent City2", - "订阅业务抢通结果通知", - "通知主题为service-recovery-execution-result") - .join(); + for (String agent : List.of("SPN Domain Agent City1", "SPN Domain Agent City2")) { + authorizationSender.sendAuthorization(agent, dev.openan.workflow.engine.client.A2atMessages.from( + contentClient.generateAuthPromptFromDataWithSchema(SpnCasePrompts.addAuthorizationData(), SpnCasePrompts.authorizationSchema(), net.openan.a2at.sdk.core.model.StandardTemplates + .AUTHORIZATION_POLICY_MANAGEMENT.uri()), + List.of(new org.a2aproject.sdk.spec.TextPart("下发授权放行策略")))) + .join(); + } + + CountDownLatch recoveryPlanNotification = new CountDownLatch(1); + CountDownLatch recoveryResultNotification = new CountDownLatch(1); + AtomicReference recoveryPlanNotificationText = new AtomicReference<>(); + AtomicReference recoveryNotificationText = new AtomicReference<>(); + java.util.function.BiConsumer notificationCallback = + (handle, received) -> { + for (var artifact : received.artifacts()) { + Map event = new java.util.LinkedHashMap<>(); + event.put("metadata", artifact.metadata()); + event.put("artifact_name", artifact.name()); + Object metadataValue = event.get("metadata"); + String protocolContent = ""; + if (metadataValue instanceof Map metadata) { + Object content = metadata.get( + dev.openan.workflow.engine.client.A2ATExtension.NOTIFICATION_T.uri()); + if (content != null) protocolContent = String.valueOf(content); + } + if ("recovery-plan".equals(event.get("artifact_name"))) { + recoveryPlanNotificationText.set(protocolContent); + recoveryPlanNotification.countDown(); + } + if ("recovery-result".equals(event.get("artifact_name"))) { + recoveryNotificationText.set(protocolContent); + recoveryResultNotification.countDown(); + } + } + }; + for (String agent : List.of("SPN Domain Agent City1", "SPN Domain Agent City2")) { + NotificationSubscription subscription = + notificationSender.openNotification(agent, dev.openan.workflow.engine.client.A2atMessages.from( + contentClient.generateNotificationPromptFromDataWithSchema(SpnCasePrompts.subscribeServiceRecoveryData(), SpnCasePrompts.serviceRecoverySchema(), net.openan.a2at.sdk.core.model.StandardTemplates.SERVICE_RECOVERY.uri()), + List.of(new org.a2aproject.sdk.spec.TextPart("订阅业务抢通结果通知"))), notificationCallback); + subscription.acknowledgement().join(); + subscriptions.add(subscription); + } Map allOutputs = new ConcurrentHashMap<>(); - AtomicBoolean sawRecovery = new AtomicBoolean(false); + AtomicBoolean workflowTaskContainedRecovery = new AtomicBoolean(false); AtomicBoolean sawSelfLoop = new AtomicBoolean(false); + AtomicBoolean sawNegotiation = new AtomicBoolean(false); EventCallback cb = new EventCallback() { @Override @@ -223,21 +298,23 @@ public void onEvent(String type, Map data) { data.get("text") != null ? String.valueOf(data.get("text")) : ""; - if (text.contains("抢通") - || text.contains("恢复") - || text.contains("Notification")) { - sawRecovery.set(true); + if (text.contains("业务抢通方案执行状态") + || text.contains("Notification-T")) { + workflowTaskContainedRecovery.set(true); } } if (EventType.TASK_RESPONSE.equals(type) && "Workbench".equals(data.get("agent"))) { sawSelfLoop.set(true); } + if (EventType.NEGOTIATION_REQUEST.equals(type)) { + sawNegotiation.set(true); + } allOutputs.put(type + ":" + data.get("agent"), data); } }; - WorkbenchControlPoint cp = new WorkbenchControlPoint(null); + WorkbenchControlPoint cp = new WorkbenchControlPoint(sdkEnvPath); ExecutionResult result = ExecutePsop.builder() .psop(crossCityWorkflow()) @@ -252,15 +329,57 @@ public void onEvent(String type, Map data) { assertTrue(result.isSuccess(), "Workflow must succeed: " + result.getError()); assertFalse(result.getHistory().isEmpty()); + assertEquals(3, result.getHistory().size(), "two diagnoses plus one merge must execute"); + long mergeExecutions = + result.getHistory().stream() + .filter(entry -> "merge_analysis".equals(entry.get("step"))) + .count(); + assertEquals(1, mergeExecutions, "cross-city diagnoses must be merged exactly once"); + assertFalse( + sawNegotiation.get(), + "The complete-input diagnosis workflow must not trigger Negotiation-T"); // Self-loop merge step executed locally (no A2A-T to self) assertTrue(sawSelfLoop.get(), "Self-loop merge must run via onSelfTask"); - // SPN agents reported recovery via Notification-T channel + // Recovery is outside the workflow task channel: first the plan, then the result. + assertFalse( + workflowTaskContainedRecovery.get(), + "Task-T diagnosis artifacts must not contain Notification-T recovery output"); + assertTrue( + await(recoveryPlanNotification, 5), + "Recovery plan must arrive first on the isolated Notification-T stream"); + assertTrue( + recoveryPlanNotificationText.get() != null + && recoveryPlanNotificationText.get().contains("业务抢通方案执行状态:未启动") + && recoveryPlanNotificationText.get().contains("是否已授权OMC自动抢通:是"), + "Recovery plan must contain the required protocol fields: " + + recoveryPlanNotificationText.get()); assertTrue( - sawRecovery.get(), "SPN must self-trigger recovery and report via Notification-T"); + await(recoveryResultNotification, 5), + "Recovery result must arrive on the isolated Notification-T stream"); + assertTrue( + recoveryNotificationText.get() != null + && recoveryNotificationText.get().contains("业务抢通方案执行结果:成功"), + "Whitelist authorization must produce a successful recovery notification: " + + recoveryNotificationText.get()); + subscriptions.forEach(NotificationSubscription::close); + assertTrue( + subscriptions.stream().noneMatch(NotificationSubscription::isActive), + "Notification-T streams must close after the recovery result is received"); // Merge output contains fault localization Map mergeOut = result.getStepOutputs().get("merge_analysis"); assertNotNull(mergeOut, "merge_analysis output must exist"); String mergeText = String.valueOf(mergeOut.values().iterator().next()); - assertTrue(mergeText.contains("城市1"), "Merge must locate fault in City1: " + mergeText); + assertTrue(mergeText.contains("SPN Domain Agent City1") && mergeText.contains("无收光"), + "Offline merge must preserve actual City1 identity and diagnosis: " + mergeText); } + + private static boolean await(CountDownLatch latch, long seconds) { + try { + return latch.await(seconds, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + } diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DemoNegotiationSettingsTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DemoNegotiationSettingsTest.java new file mode 100644 index 00000000..13aec24a --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DemoNegotiationSettingsTest.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.examples.demo; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import static org.junit.jupiter.api.Assertions.*; + +class DemoNegotiationSettingsTest { + private String enabled; + private String city; + + @BeforeEach void isolateSettings() { + enabled = System.getProperty("a2at.samples.negotiation"); + city = System.getProperty("a2at.samples.negotiation.city"); + System.clearProperty("a2at.samples.negotiation"); + System.clearProperty("a2at.samples.negotiation.city"); + } + + @AfterEach void restoreSettings() { + if (enabled == null) System.clearProperty("a2at.samples.negotiation"); + else System.setProperty("a2at.samples.negotiation", enabled); + if (city == null) System.clearProperty("a2at.samples.negotiation.city"); + else System.setProperty("a2at.samples.negotiation.city", city); + } + + @Test void localDefaultSelectsOnlyCity1AndDoesNotMutateGlobalSettings() { + assertTrue(SpnCasePrompts.demoNegotiationEnabled(true)); + assertTrue(SpnCasePrompts.injectNegotiation("diagnosis_city1", true)); + assertFalse(SpnCasePrompts.injectNegotiation("diagnosis_city2", true)); + assertFalse(SpnCasePrompts.injectNegotiation("merge_analysis", true)); + assertNull(System.getProperty("a2at.samples.negotiation")); + } + + @Test void explicitDisableKeepsCompleteInputs() { + System.setProperty("a2at.samples.negotiation", "false"); + assertFalse(SpnCasePrompts.demoNegotiationEnabled(true)); + assertFalse(SpnCasePrompts.injectNegotiation("diagnosis_city1", false)); + } + + @Test void externalOmcNeverGetsDefaultInjectionAndRejectsExplicitInjection() { + assertFalse(SpnCasePrompts.demoNegotiationEnabled(false)); + System.setProperty("a2at.samples.negotiation", "true"); + assertThrows(IllegalArgumentException.class, () -> SpnCasePrompts.demoNegotiationEnabled(false)); + } + + @Test void invalidSwitchOrCityFailsBeforeStartingServers() { + System.setProperty("a2at.samples.negotiation", "typo"); + assertThrows(IllegalArgumentException.class, () -> SpnCasePrompts.demoNegotiationEnabled(true)); + System.clearProperty("a2at.samples.negotiation"); + System.setProperty("a2at.samples.negotiation.city", "unknown"); + assertThrows(IllegalArgumentException.class, () -> SpnCasePrompts.demoNegotiationEnabled(true)); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DocumentedCallbackExampleTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DocumentedCallbackExampleTest.java new file mode 100644 index 00000000..01002090 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DocumentedCallbackExampleTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.demo; + +import org.junit.jupiter.api.Test; +import java.net.URI; +import java.nio.file.*; +import java.util.*; +import javax.tools.*; +import static org.junit.jupiter.api.Assertions.*; + +class DocumentedCallbackExampleTest { + @Test void bilingualCallbackAndSdkExamplesCompileAgainstCurrentPublicApi() throws Exception { + Path root = Path.of("").toAbsolutePath(); + if (!Files.isDirectory(root.resolve("docs"))) root = root.getParent(); + for (String language : List.of("en", "zh")) { + String doc = Files.readString(root.resolve("docs/" + language + "/BUSINESS_CALLBACKS.md")); + String callbacks = block(doc, "ControlPoint callbacks ="); + String generated = block(doc, "MetadataContent generated ="); + String source = """ + import java.util.*; + import java.util.concurrent.*; + import dev.openan.workflow.engine.control.*; + import dev.openan.workflow.engine.model.*; + import dev.openan.workflow.engine.client.*; + import net.openan.a2at.sdk.client.A2ATClient; + import net.openan.a2at.sdk.core.model.*; + import org.a2aproject.sdk.spec.TextPart; + class DocumentedCallbacks { + ControlPoint callbacks() { + """ + callbacks + "\nreturn callbacks;\n}\n" + + "MessageContent generate(A2ATClient sdk, Map data, Map schema) {\n" + + generated + "\nreturn outgoing;\n}\n}"; + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "Tests require a JDK, not a JRE"); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + JavaFileObject input = new SimpleJavaFileObject(URI.create("string:///DocumentedCallbacks.java"), + JavaFileObject.Kind.SOURCE) { + @Override public CharSequence getCharContent(boolean ignoreEncodingErrors) { return source; } + }; + try (StandardJavaFileManager standard = compiler.getStandardFileManager(diagnostics, null, null); + var output = new ForwardingJavaFileManager(standard) { + @Override public JavaFileObject getJavaFileForOutput(Location location, String name, + JavaFileObject.Kind kind, FileObject sibling) { + return new SimpleJavaFileObject(URI.create("bytes:///" + name + kind.extension), kind) { + @Override public java.io.OutputStream openOutputStream() { + return new java.io.ByteArrayOutputStream(); + } + }; + } + }) { + boolean ok = compiler.getTask(null, output, diagnostics, + List.of("-proc:none", "-classpath", System.getProperty("java.class.path")), + null, List.of(input)).call(); + assertTrue(ok, () -> language + ": " + diagnostics.getDiagnostics()); + } + } + } + + private static String block(String doc, String marker) { + int begin = doc.indexOf(marker); + assertTrue(begin >= 0, marker); + int end = doc.indexOf("\n\u0060\u0060\u0060", begin); + assertTrue(end > begin, marker); + return doc.substring(begin, end); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java new file mode 100644 index 00000000..b52469aa --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.demo; + +import dev.openan.workflow.engine.examples.testsupport.OfflineA2ATLlmClient; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +/** Runs the actual northbound Spring demo with local OMCs and the offline SDK provider. */ +class SpringSpnDemoE2ETest { + + @Test + @Timeout(120) + void defaultLocalDemoNegotiatesOnlyCity1WithoutChangingGlobalSwitch() throws Exception { + String enabled = System.getProperty("a2at.samples.negotiation"); + String city = System.getProperty("a2at.samples.negotiation.city"); + java.nio.file.Path log = java.nio.file.Path.of("logs/spn-demo.log"); + long offset = java.nio.file.Files.exists(log) ? java.nio.file.Files.size(log) : 0; + System.clearProperty("a2at.samples.negotiation"); + System.clearProperty("a2at.samples.negotiation.city"); + try { + runDemo(); + try (var input = java.nio.file.Files.newInputStream(log)) { + input.skipNBytes(offset); + String evidence = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("NEGOTIATION_DEMO enabled=true, city=city1")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("[SpnDomainAgentCity1Executor] NEGOTIATION_APPLIED")); + org.junit.jupiter.api.Assertions.assertFalse(evidence.contains("[SpnDomainAgentCity2Executor] NEGOTIATION_APPLIED")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("[SpnDomainAgentCity2Executor] Parameters sufficient, skipping negotiation")); + assertNegotiationWireEvidence(evidence); + for (String cityName : java.util.List.of("City1", "City2")) { + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains( + "[SpnDomainAgent" + cityName + "Executor] Authorization-T accepted")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains( + "[SpnDomainAgent" + cityName + "Executor] NOTIFICATION_SUBSCRIBED")); + } + org.junit.jupiter.api.Assertions.assertFalse(evidence.contains("OPERATION_FAILED extension=")); + org.junit.jupiter.api.Assertions.assertFalse(evidence.matches("(?s).*\\ndata: +\\\"performative\\\".*")); + } + org.junit.jupiter.api.Assertions.assertNull(System.getProperty("a2at.samples.negotiation")); + } finally { + if (enabled == null) System.clearProperty("a2at.samples.negotiation"); + else System.setProperty("a2at.samples.negotiation", enabled); + if (city == null) System.clearProperty("a2at.samples.negotiation.city"); + else System.setProperty("a2at.samples.negotiation.city", city); + } + } + + @Test + @Timeout(120) + void bothCitiesNegotiateIndependentlyAndComplete() throws Exception { + String previous = System.getProperty("a2at.samples.negotiation.city"); + java.nio.file.Path log = java.nio.file.Path.of("logs/spn-demo.log"); + long offset = java.nio.file.Files.exists(log) ? java.nio.file.Files.size(log) : 0; + System.setProperty("a2at.samples.negotiation.city", "both"); + try { + missingPortNegotiatesAndCompletesThroughTheSameSpringWorkflow(); + try (var input = java.nio.file.Files.newInputStream(log)) { + input.skipNBytes(offset); + String evidence = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + for (String city : java.util.List.of("City1", "City2")) { + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains( + "[SpnDomainAgent" + city + "Executor] NEGOTIATION_APPLIED"), city); + } + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("port=P781-珠江新城-PTN7900-23-TPA1EG24-17")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("port=P882-珠江新城-PTN7900-23-TPA1EG24-11")); + } + } finally { + if (previous == null) System.clearProperty("a2at.samples.negotiation.city"); + else System.setProperty("a2at.samples.negotiation.city", previous); + } + } + + @Test + @Timeout(120) + void missingPortNegotiatesAndCompletesThroughTheSameSpringWorkflow() throws Exception { + java.nio.file.Path log = java.nio.file.Path.of("logs/spn-demo.log"); + long offset = java.nio.file.Files.exists(log) ? java.nio.file.Files.size(log) : 0; + String previous = System.getProperty("a2at.samples.negotiation"); + System.setProperty("a2at.samples.negotiation", "true"); + try { + runDemo(); + try (var input = java.nio.file.Files.newInputStream(log)) { + input.skipNBytes(offset); + String evidence = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("DEMO_NEGOTIATION")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("TASK_STATE_INPUT_REQUIRED")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.matches("(?s).*\\\"performative\\\"\\s*:\\s*\\\"PROPOSE\\\".*")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.matches("(?s).*\\\"performative\\\"\\s*:\\s*\\\"ACCEPT\\\".*")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("NORTHBOUND_DONE")); + assertNegotiationWireEvidence(evidence); + } + } finally { + if (previous == null) System.clearProperty("a2at.samples.negotiation"); + else System.setProperty("a2at.samples.negotiation", previous); + } + } + + protected String negotiationWireBoundary() { return "DIRECT_HTTP"; } + + private void assertNegotiationWireEvidence(String evidence) { + String uri = dev.openan.workflow.engine.client.A2ATExtension.NEGOTIATION_T.uri(); + var records = java.util.Arrays.asList(evidence.split("(?m)^.*? PROTOCOL - ")); + String request = records.stream() + .filter(record -> record.startsWith("[" + negotiationWireBoundary() + "] REQUEST")) + .filter(record -> record.contains("\"performative\": \"ACCEPT\"")) + .findFirst().orElseThrow(() -> new AssertionError("No actual ACCEPT request log")); + var id = java.util.regex.Pattern.compile("requestId=([^\\r\\n]+)").matcher(request); + org.junit.jupiter.api.Assertions.assertTrue(id.find()); + String requestId = id.group(1); + org.junit.jupiter.api.Assertions.assertTrue(records.stream().anyMatch(record -> + record.startsWith("[" + negotiationWireBoundary() + "] REQUEST") + && record.contains("requestId=" + requestId) + && record.contains("A2A-Extensions: " + uri)), "Missing actual negotiation activation header"); + org.junit.jupiter.api.Assertions.assertTrue(request.contains("\"" + uri + "\":")); + org.junit.jupiter.api.Assertions.assertTrue(request.contains("\"negotiationContext\": {")); + org.junit.jupiter.api.Assertions.assertTrue(request.contains("=== Body ===\n{\n")); + org.junit.jupiter.api.Assertions.assertTrue(records.stream().anyMatch(record -> + record.contains("RESPONSE_BODY") && record.contains("=== SSE data (JSON display; not wire text) ===") + && record.contains("\"performative\": \"PROPOSE\"")), "Missing observed PROPOSE SSE data"); + } + + protected String[] arguments() { + return new String[] { + "--a2a.transport-mode=direct", + "--a2a.orch-url=http://127.0.0.1:1", + "--a2a.a2at-env-path=" + OfflineA2ATLlmClient.envPath() + }; + } + + @Test + @Timeout(120) + void northboundComplaintCompletesAndResourcesClose() throws Exception { + String previous = System.getProperty("a2at.samples.negotiation"); + java.nio.file.Path log = java.nio.file.Path.of("logs/spn-demo.log"); + long offset = java.nio.file.Files.exists(log) ? java.nio.file.Files.size(log) : 0; + System.setProperty("a2at.samples.negotiation", "false"); + try { + runDemo(); + try (var input = java.nio.file.Files.newInputStream(log)) { + input.skipNBytes(offset); + String evidence = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("NEGOTIATION_DEMO enabled=false")); + org.junit.jupiter.api.Assertions.assertFalse(evidence.contains("DEMO_NEGOTIATION agent=")); + org.junit.jupiter.api.Assertions.assertFalse(evidence.contains("NEGOTIATION_APPLIED")); + } + } finally { + if (previous == null) System.clearProperty("a2at.samples.negotiation"); + else System.setProperty("a2at.samples.negotiation", previous); + } + } + + private void runDemo() throws Exception { + OfflineA2ATLlmClient.install(); + String previous = System.getProperty("a2at.env.path"); + System.setProperty("a2at.env.path", OfflineA2ATLlmClient.envPath()); + try { + // run() propagates failure unless the northbound Task reaches COMPLETED. + new SpringSpnDemo().run(arguments()); + } finally { + if (previous == null) System.clearProperty("a2at.env.path"); + else System.setProperty("a2at.env.path", previous); + } + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoTest.java new file mode 100644 index 00000000..4a261c2f --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoTest.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.demo; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.openan.workflow.engine.model.SendMessageResult; +import org.junit.jupiter.api.Test; + +class SpringSpnDemoTest { + + @Test + void demoOnlyReportsSuccessForACompletedWorkbenchTask() { + assertDoesNotThrow(() -> SpringSpnDemo.requireCompleted( + SendMessageResult.builder().taskState("TASK_STATE_COMPLETED").text("ok").build())); + assertThrows( + IllegalStateException.class, + () -> SpringSpnDemo.requireCompleted( + SendMessageResult.builder().taskState("TASK_STATE_FAILED").text("failed").build())); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicyTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicyTest.java new file mode 100644 index 00000000..0d793979 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicyTest.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.Map; + +class AuthorizationPolicyTest { + + @Test + void exactRuleAndValidityAuthorizeRecovery() { + AuthorizationPolicy policy = + AuthorizationPolicy.fromValidated(SpnCasePrompts.addAuthorizationData()); + + assertTrue( + policy.authorizes( + "业务投诉诊断", "业务抢通", "隧道调优", LocalDate.of(2026, 8, 25))); + assertFalse( + policy.authorizes( + "业务投诉诊断", "业务抢通", "光模块重启", LocalDate.of(2026, 8, 25))); + assertFalse( + policy.authorizes( + "业务投诉诊断", "业务抢通", "隧道调优", LocalDate.of(2031, 1, 1))); + } + + @Test + void nonMutatingOperationCannotCreateExecutableWhitelist() { + Map data = new LinkedHashMap<>(SpnCasePrompts.addAuthorizationData()); + data.put(AuthorizationPolicy.OPERATION_TYPE_FIELD, "查询授权策略"); + + assertThrows(IllegalArgumentException.class, () -> AuthorizationPolicy.fromValidated(data)); + assertEquals( + AuthorizationPolicy.QUERY, + AuthorizationPolicy.operationFromValidated(data)); + } + + @Test + void sdkDefinedDeleteOperationAndSelectorAreAcceptedAsACommand() { + Map data = Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.DELETE, + AuthorizationPolicy.POLICY_LIST_FIELD, + "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"); + + assertEquals(AuthorizationPolicy.DELETE, AuthorizationPolicy.operationFromValidated(data)); + assertEquals( + "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + AuthorizationPolicy.policyIdSelector(data)); + } + + @Test + void malformedOrMissingRulesFailClosed() { + assertThrows( + IllegalArgumentException.class, + () -> AuthorizationPolicy.fromValidated(Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, "新增授权策略", + AuthorizationPolicy.POLICY_LIST_FIELD, "业务抢通"))); + assertThrows( + IllegalArgumentException.class, + () -> AuthorizationPolicy.fromValidated(Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, "新增授权策略"))); + } + + @Test + void sdkCanonicalPermanentAndLimitedRulesAreSupported() { + AuthorizationPolicy permanent = AuthorizationPolicy.fromValidated(Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, + AuthorizationPolicy.POLICY_LIST_FIELD, + "1. 业务场景是载波调度,处置类型是业务抢通,操作名称是载波调度,有效期是永久生效")); + AuthorizationPolicy limited = AuthorizationPolicy.fromValidated(Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, + AuthorizationPolicy.POLICY_LIST_FIELD, + "1. 业务场景是业务投诉诊断,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-06-01~2030-06-18")); + + assertTrue(permanent.authorizes( + "载波调度", "业务抢通", "载波调度", LocalDate.of(2099, 1, 1))); + assertTrue(limited.authorizes( + "业务投诉诊断", "业务抢通", "隧道调优", LocalDate.of(2028, 1, 1))); + } + + @Test + void oldSlashSeparatedRuleIsRejected() { + assertThrows( + IllegalArgumentException.class, + () -> AuthorizationPolicy.fromValidated(Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, + AuthorizationPolicy.POLICY_LIST_FIELD, + "业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18"))); + } + + @Test + void actualSdkBundledExampleSupportsMultipleNumberedRules() { + Map schema = SpnCasePrompts.authorizationSchema(); + Map properties = (Map) schema.get("properties"); + Map listSchema = (Map) properties.get(AuthorizationPolicy.POLICY_LIST_FIELD); + String example = (String) ((java.util.List) listSchema.get("examples")).get(0); + AuthorizationPolicy policy = policy(example); + assertEquals(2, policy.rules().size()); + assertTrue(policy.authorizes("校园专网", "紧急扩容", "天线调整", LocalDate.of(2026, 8, 1))); + assertTrue(policy.authorizes("校园专网", "紧急扩容", "天线调整", LocalDate.of(2030, 12, 31))); + assertFalse(policy.authorizes("校园专网", "紧急扩容", "天线调整", LocalDate.of(2026, 7, 31))); + assertTrue(policy.authorizes("视频保障", "故障切换", "频谱重耕", LocalDate.of(2099, 1, 1))); + assertFalse(policy.authorizes("视频保障", "紧急扩容", "频谱重耕", LocalDate.of(2026, 8, 31))); + assertEquals(policy.rules(), policy(example.replace("\n", "\r\n")).rules()); + } + + @Test + void malformedCanonicalValuesCannotCreateAnAuthorization() { + String canonical = SpnCasePrompts.addAuthorizationData().get(AuthorizationPolicy.POLICY_LIST_FIELD).toString(); + for (String invalid : java.util.List.of( + canonical.replace("1.", "2."), + canonical.replace(",处置类型是业务抢通", ""), + canonical.replace("操作名称是隧道调优", "操作名称是"), + canonical.replace("业务场景是", "业务场景:"), + canonical.replace("2026-06-01", "2026-02-30"), + canonical.replace("2030-06-18", "2025-06-18"), + canonical + ";" + canonical.replace("1.", "2."), + "业务投诉诊断,业务抢通,隧道调优,2026-06-01~2030-06-18")) { + assertThrows(IllegalArgumentException.class, () -> policy(invalid), invalid); + } + } + + private AuthorizationPolicy policy(String rules) { + return AuthorizationPolicy.fromValidated(Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, + AuthorizationPolicy.POLICY_LIST_FIELD, rules)); + } + + @Test + void currentModifyShapeIsNotMisreadAsAnAddRule() { + assertThrows( + IllegalArgumentException.class, + () -> AuthorizationPolicy.fromValidated(Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.MODIFY, + AuthorizationPolicy.POLICY_LIST_FIELD, + "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效"))); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositionerTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositionerTest.java new file mode 100644 index 00000000..d16ffcd1 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositionerTest.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openan.workflow.engine.client.ExtensionSender; +import dev.openan.workflow.engine.client.NotificationSubscription; +import dev.openan.workflow.engine.examples.workbench.WorkbenchAgentCatalog; +import dev.openan.workflow.engine.model.SendMessageResult; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class ExtensionPrePositionerTest { + @org.junit.jupiter.api.BeforeEach + void configureOfflineContentSdk() throws Exception { + dev.openan.workflow.engine.examples.testsupport.OfflineA2ATLlmClient.install(); + System.setProperty("a2at.env.path", java.nio.file.Path.of(getClass().getResource("/a2at-e2e.env").toURI()).toString()); + } + + @org.junit.jupiter.api.AfterEach + void clearOfflineConfiguration() { + System.clearProperty("a2at.env.path"); + } + + @Test + void notificationFailureDoesNotBecomeAWorkflowStartupCondition() { + AtomicBoolean authorizationAttempted = new AtomicBoolean(); + ExtensionSender authorizationSender = + sender( + (method, args) -> { + if ("sendAuthorization".equals(method.getName())) { + authorizationAttempted.set(true); + return CompletableFuture.completedFuture( + SendMessageResult.builder() + .taskState("TASK_STATE_COMPLETED") + .text("authorized") + .build()); + } + throw new UnsupportedOperationException(method.getName()); + }); + ExtensionSender notificationSender = + sender( + (method, args) -> { + if ("openNotification".equals(method.getName())) { + throw new IllegalStateException("subscription rejected"); + } + throw new UnsupportedOperationException(method.getName()); + }); + + List subscriptions = + new ExtensionPrePositioner() + .prePosition( + authorizationSender, + notificationSender, + List.of(city1Card()), + (handle, received) -> {}); + + assertTrue(authorizationAttempted.get()); + assertTrue(subscriptions.isEmpty()); + } + + @Test + void authorizationFailureDoesNotBlockSubscriptionOrWorkflowStartup() throws Exception { + NotificationSubscription subscription = newSubscription("SPN Domain Agent City1"); + AtomicBoolean notificationAttempted = new AtomicBoolean(); + ExtensionSender authorizationSender = + sender( + (method, args) -> { + if ("sendAuthorization".equals(method.getName())) { + return CompletableFuture.failedFuture( + new IllegalStateException("authorization rejected")); + } + throw new UnsupportedOperationException(method.getName()); + }); + ExtensionSender notificationSender = + sender( + (method, args) -> { + if ("openNotification".equals(method.getName())) { + notificationAttempted.set(true); + return subscription; + } + throw new UnsupportedOperationException(method.getName()); + }); + CountDownLatch registered = new CountDownLatch(1); + AtomicReference registeredSubscription = new AtomicReference<>(); + + CompletableFuture> result = + CompletableFuture.supplyAsync( + () -> + new ExtensionPrePositioner() + .prePosition( + authorizationSender, + notificationSender, + List.of(city1Card()), + (handle, received) -> {}, + opened -> { + registeredSubscription.set(opened); + registered.countDown(); + })); + + assertTrue(registered.await(5, TimeUnit.SECONDS)); + assertFalse(result.isDone(), "registration must happen before waiting for the ACK"); + acknowledge(subscription, "TASK_STATE_WORKING"); + + assertEquals(List.of(subscription), result.get(5, TimeUnit.SECONDS)); + assertTrue(notificationAttempted.get()); + assertSame(subscription, registeredSubscription.get()); + } + + private static org.a2aproject.sdk.spec.AgentCard city1Card() { + return new WorkbenchAgentCatalog() + .load().stream() + .filter(card -> "SPN Domain Agent City1".equals(card.name())) + .findFirst() + .orElseThrow(); + } + + private static NotificationSubscription newSubscription(String agentName) throws Exception { + Constructor constructor = + NotificationSubscription.class.getDeclaredConstructor( + String.class, String.class, Runnable.class); + constructor.setAccessible(true); + return constructor.newInstance(agentName, "notification-context", (Runnable) () -> {}); + } + + private static void acknowledge(NotificationSubscription subscription, String state) + throws Exception { + Method acknowledge = + NotificationSubscription.class.getDeclaredMethod("acknowledge", SendMessageResult.class); + acknowledge.setAccessible(true); + acknowledge.invoke( + subscription, SendMessageResult.builder().taskState(state).text("subscribed").build()); + } + + private static ExtensionSender sender(Invocation invocation) { + return (ExtensionSender) + Proxy.newProxyInstance( + ExtensionSender.class.getClassLoader(), + new Class[] {ExtensionSender.class}, + (proxy, method, args) -> invocation.invoke(method, args)); + } + + @FunctionalInterface + private interface Invocation { + Object invoke(Method method, Object[] args) throws Throwable; + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/extension/NotificationPolicyTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/NotificationPolicyTest.java new file mode 100644 index 00000000..a3e78de6 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/NotificationPolicyTest.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +class NotificationPolicyTest { + + @Test + void acceptsSdkValidatedRecoverySubscription() { + NotificationPolicy policy = + NotificationPolicy.fromValidated( + Map.of( + NotificationPolicy.CONDITION_FIELD, + "业务抢通完成", + NotificationPolicy.REPORT_FORMAT_FIELD, + "投诉诊断任务流水号、执行状态、执行结果")); + + assertEquals("业务抢通完成", policy.condition()); + assertEquals("投诉诊断任务流水号、执行状态、执行结果", policy.reportFormat()); + } + + @Test + void missingReportSchemaOrWrongConditionTypeFailsClosed() { + assertThrows( + IllegalArgumentException.class, + () -> NotificationPolicy.fromValidated(Map.of("订阅条件", "业务抢通完成"))); + assertThrows( + IllegalArgumentException.class, + () -> + NotificationPolicy.fromValidated( + Map.of( + "订阅条件", + 1, + "上报通知数据格式", + "执行结果"))); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandlerTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandlerTest.java new file mode 100644 index 00000000..7856e94a --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandlerTest.java @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; + +class PrePositionedExtensionHandlerTest { + + @Test + void validationUsesTheCurrentSdkPromptWithoutSampleResourceShadowing() + throws IOException { + String resourcePath = "prompt_resources/prompts/content_validation/zh-CN/system.md"; + URL resource = ClasspathResourceStreams.class.getClassLoader().getResource(resourcePath); + assertNotNull(resource); + assertTrue(resource.toExternalForm().contains("a2a-t-resources")); + try (InputStream stream = ClasspathResourceStreams.open( + resourcePath)) { + assertNotNull(stream); + String prompt = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(prompt.contains("输入内容的结构")); + assertTrue(prompt.contains("条目级检查")); + } + } + + @Test + void validationUsesTheCompleteSlotSchemaBundledByTheActiveSdk() { + Map schema = SdkSlotSchemaLoader.load( + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT, "zh-CN"); + assertEquals("https://json-schema.org/draft/2020-12/schema", schema.get("$schema")); + assertEquals(Boolean.FALSE, schema.get("additionalProperties")); + @SuppressWarnings("unchecked") + Map properties = (Map) schema.get("properties"); + @SuppressWarnings("unchecked") + Map operation = + (Map) properties.get(AuthorizationPolicy.OPERATION_TYPE_FIELD); + assertFalse(String.valueOf(operation.get("description")).isBlank()); + assertNotNull(operation.get("x-a2at-value-constraint")); + assertFalse(((List) operation.get("examples")).isEmpty()); + assertTrue( + String.valueOf(operation.get("x-a2at-value-constraint")) + .contains("导出")); + assertEquals(schema, SpnCasePrompts.authorizationSchema()); + + Map notificationSchema = + SdkSlotSchemaLoader.load(StandardTemplates.SERVICE_RECOVERY, "zh-CN"); + assertEquals( + List.of(NotificationPolicy.REPORT_FORMAT_FIELD), + notificationSchema.get("required")); + assertEquals(notificationSchema, SpnCasePrompts.serviceRecoverySchema()); + assertEquals( + SdkSlotSchemaLoader.load(StandardTemplates.PRIVATE_LINE_COMPLAINT, "zh-CN"), + SpnCasePrompts.privateLineComplaintSchema()); + } + + @Test + void sdkExtractionMustExactlyMatchValuesInRenderedSections() { + String prompt = + "## 授权策略的操作类型\n" + + "新增授权策略\n\n" + + "## 动网操作的授权策略列表\n" + + "1. 业务场景是业务投诉诊断,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-06-01~2030-06-18\n"; + Map exact = SpnCasePrompts.addAuthorizationData(); + + PrePositionedExtensionHandler.requireExtractedSectionsMatch( + prompt, + exact, + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.POLICY_LIST_FIELD); + + Map wrongOperation = new java.util.LinkedHashMap<>(exact); + wrongOperation.put( + AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.QUERY); + assertThrows( + IllegalArgumentException.class, + () -> PrePositionedExtensionHandler.requireExtractedSectionsMatch( + prompt, + wrongOperation, + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.POLICY_LIST_FIELD)); + } + + @Test + void sdkExtractionCannotHallucinateAnEmptyNotificationCondition() { + String prompt = + "## 订阅条件\n\n## 上报通知数据格式\n业务抢通事件数据包含执行状态\n"; + assertThrows( + IllegalArgumentException.class, + () -> PrePositionedExtensionHandler.requireExtractedSectionsMatch( + prompt, + Map.of( + NotificationPolicy.CONDITION_FIELD, + "xx子网", + NotificationPolicy.REPORT_FORMAT_FIELD, + "业务抢通事件数据包含执行状态"), + NotificationPolicy.CONDITION_FIELD, + NotificationPolicy.REPORT_FORMAT_FIELD)); + } + + @Test + void queryIsSideEffectFreeAndDeleteClearsTheDemoWhitelist() { + PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); + PrePositionedExtensionHandler.AuthorizationOperationResult addResult = + handler.applyAuthorization( + SpnCasePrompts.addAuthorizationData(), "template", "test"); + AuthorizationPolicy added = handler.getAuthorizationPolicy(); + assertNotNull(added); + assertTrue(addResult.metadataText().contains("授权操作执行结果:成功")); + assertTrue( + addResult.metadataText() + .contains("7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3")); + + PrePositionedExtensionHandler.AuthorizationOperationResult queryResult = + handler.applyAuthorization( + Map.of(AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.QUERY), + "template", + "test"); + assertSame(added, handler.getAuthorizationPolicy()); + assertTrue(queryResult.metadataText().contains("动网操作的授权策略列表")); + + handler.applyAuthorization( + Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.DELETE, + AuthorizationPolicy.POLICY_LIST_FIELD, + "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"), + "template", + "test"); + assertNull(handler.getAuthorizationPolicy()); + } + + @Test + void deleteWithoutASelectorFailsClosedAndKeepsTheExistingWhitelist() { + PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); + handler.applyAuthorization(SpnCasePrompts.addAuthorizationData(), "template", "test"); + AuthorizationPolicy existing = handler.getAuthorizationPolicy(); + + assertThrows( + IllegalArgumentException.class, + () -> handler.applyAuthorization( + Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.DELETE), + "template", + "test")); + assertSame(existing, handler.getAuthorizationPolicy()); + } + + @Test + void deleteMustTargetTheExactActivePolicyIdentifier() { + PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); + handler.applyAuthorization(SpnCasePrompts.addAuthorizationData(), "template", "test"); + AuthorizationPolicy existing = handler.getAuthorizationPolicy(); + + assertThrows( + IllegalArgumentException.class, + () -> handler.applyAuthorization( + Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.DELETE, + AuthorizationPolicy.POLICY_LIST_FIELD, + "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c4"), + "template", + "test")); + assertSame(existing, handler.getAuthorizationPolicy()); + } + + @Test + void aMalformedSecondRuleCannotPartiallyReplaceTheExistingWhitelist() { + PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); + handler.applyAuthorization(SpnCasePrompts.addAuthorizationData(), "template", "test"); + AuthorizationPolicy existing = handler.getAuthorizationPolicy(); + String rules = SpnCasePrompts.addAuthorizationData().get(AuthorizationPolicy.POLICY_LIST_FIELD) + + "\n2. 业务场景是其他,处置类型是业务抢通,操作名称是任意操作,有效期是2026-02-30~2030-06-18"; + assertThrows(IllegalArgumentException.class, () -> handler.applyAuthorization( + Map.of(AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, + AuthorizationPolicy.POLICY_LIST_FIELD, rules), "template", "test")); + assertSame(existing, handler.getAuthorizationPolicy()); + } + + @Test + void unsupportedQueryConditionsAreNotSilentlyIgnored() { + PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); + handler.applyAuthorization(SpnCasePrompts.addAuthorizationData(), "template", "test"); + AuthorizationPolicy existing = handler.getAuthorizationPolicy(); + assertThrows(UnsupportedOperationException.class, () -> handler.applyAuthorization( + Map.of(AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.QUERY, + AuthorizationPolicy.POLICY_LIST_FIELD, "1. 业务场景是其他"), + "template", "test")); + assertSame(existing, handler.getAuthorizationPolicy()); + } + + @Test + void deleteCannotTreatBareIdsOrMultipleSelectorsAsTheActiveIdentifier() { + PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); + handler.applyAuthorization(SpnCasePrompts.addAuthorizationData(), "template", "test"); + AuthorizationPolicy existing = handler.getAuthorizationPolicy(); + String id = "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"; + for (String selector : List.of(id, "1. 业务场景是业务投诉诊断", + "1. 策略标识是" + id + "\n2. 策略标识是" + id)) { + assertThrows(IllegalArgumentException.class, () -> handler.applyAuthorization( + Map.of(AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.DELETE, + AuthorizationPolicy.POLICY_LIST_FIELD, selector), "template", "test")); + } + assertSame(existing, handler.getAuthorizationPolicy()); + } + + @Test + void modifyRequiresAnIntegratingPolicyStoreAndKeepsTheExistingWhitelist() { + PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); + handler.applyAuthorization(SpnCasePrompts.addAuthorizationData(), "template", "test"); + AuthorizationPolicy existing = handler.getAuthorizationPolicy(); + + assertThrows( + UnsupportedOperationException.class, + () -> handler.applyAuthorization( + Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.MODIFY, + AuthorizationPolicy.POLICY_LIST_FIELD, + "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效"), + "template", + "test")); + assertSame(existing, handler.getAuthorizationPolicy()); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/extension/RecoveryNotificationTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/RecoveryNotificationTest.java new file mode 100644 index 00000000..aa65a11c --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/RecoveryNotificationTest.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.examples.extension; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.model.ReceivedMessage; +import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.TextPart; +import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class RecoveryNotificationTest { + private static final String RESULT = "### 业务抢通事件\n" + + "1. 业务抢通方案执行状态:已结束\n2. 投诉诊断任务流水号:task-42\n" + + "3. OSS侧事件流水号:event-42\n4. 接入端口名称:port-42\n" + + "5. 是否已授权OMC自动抢通:是\n6. 业务抢通方案名称:隧道调优\n" + + "7. 业务抢通方案详情:执行指定调优策略\n8. 业务抢通方案执行结束时间:2026-08-31T00:00:00Z\n" + + "9. 业务抢通方案执行结果:成功"; + + private ReceivedMessage received(String name, String formal, String summary) { + return new ReceivedMessage(null, Map.of(), List.of(Artifact.builder().artifactId("event").name(name) + .parts(List.of(new TextPart(summary))).metadata(formal == null ? Map.of() + : Map.of(A2ATExtension.NOTIFICATION_T.uri(), formal)).build())); + } + + @Test void completedSuccessAndFailureAreBothTerminalRecoveryResults() { + assertTrue(RecoveryNotification.hasCompletedResult(received("recovery-result", RESULT, "summary"))); + assertTrue(RecoveryNotification.hasCompletedResult(received("recovery-result", RESULT.replace("结果:成功", "结果:失败"), "summary"))); + } + + @Test void artifactNameOrPartsAloneNeverCompletesSubscription() { + assertFalse(RecoveryNotification.hasCompletedResult(received("recovery-result", null, RESULT))); + assertFalse(RecoveryNotification.hasCompletedResult(received("recovery-result", "订阅成功", RESULT))); + assertFalse(RecoveryNotification.hasCompletedResult(received("recovery-plan", RESULT, "summary"))); + } + + @Test void incompleteOrAmbiguousResultKeepsSubscriptionOpen() { + assertFalse(RecoveryNotification.hasCompletedResult(received("recovery-result", RESULT.replace("已结束", "未启动"), "summary"))); + assertFalse(RecoveryNotification.hasCompletedResult(received("recovery-result", RESULT.replace("OSS侧事件流水号:event-42", "OSS侧事件流水号:"), "summary"))); + assertFalse(RecoveryNotification.hasCompletedResult(received("recovery-result", RESULT + "\n10. 接入端口名称:other", "summary"))); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategyTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategyTest.java new file mode 100644 index 00000000..a2591646 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategyTest.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.negotiation; + +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; +import dev.openan.workflow.engine.examples.testsupport.OfflineA2ATLlmClient; +import dev.openan.workflow.engine.examples.util.A2ATInitialization; +import dev.openan.workflow.engine.client.*; +import dev.openan.workflow.engine.model.*; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.*; +import net.openan.a2at.sdk.negotiation.content.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class NegotiationStrategyTest { + private String env; + @BeforeEach void configure() throws Exception { + OfflineA2ATLlmClient.install(); + env = java.nio.file.Path.of(getClass().getResource("/a2at-e2e.env").toURI()).toString(); + } + + @Test void twoCitiesUseTheirOwnInputs() { + String first = answer(SpnCasePrompts.privateLineComplaintData()); + String second = answer(SpnCasePrompts.privateLineComplaintDataCity2()); + assertTrue(first.contains("P781-")); + assertTrue(second.contains("P882-")); + assertNotEquals(first, second); + } + + @Test void missingInformationSendsAbortWithoutInventingPort() { + var reply = assertInstanceOf(NegotiationReply.Send.class, + new NegotiationStrategy(env).resolve(request(SpnCasePrompts.privateLineComplaintDataBlankObject())).join()); + var received = new ReceivedMessage(reply.content(), Map.of(), List.of()); + assertEquals(NegotiationPerformative.ABORT, A2atMessages.contextOf(received).performative()); + } + + @Test void hostDisclosurePolicyUsesSdkReject() { + var strategy = new NegotiationStrategy(env) { + @Override protected boolean mayProvideField(TaskRequest task, String fieldName) { return false; } + }; + var reply = assertInstanceOf(NegotiationReply.Send.class, + strategy.resolve(request(SpnCasePrompts.privateLineComplaintData())).join()); + assertEquals(NegotiationPerformative.REJECT, + A2atMessages.contextOf(new ReceivedMessage(reply.content(), Map.of(), List.of())).performative()); + assertTrue(reply.content().metadata().get(A2ATExtension.NEGOTIATION_T.uri()).toString().contains("业务策略不允许提供")); + } + + @Test void llmDescriptionParaphraseCannotRenameTheRequestedField() { + var reply = assertInstanceOf(NegotiationReply.Send.class, + new NegotiationStrategy(env).resolve(request(SpnCasePrompts.privateLineComplaintData(), + List.of(new NegotiationItem("任务对象", "请提供本地市实际接入端口名称")))).join()); + assertEquals(NegotiationPerformative.ACCEPT, + A2atMessages.contextOf(new ReceivedMessage(reply.content(), Map.of(), List.of())).performative()); + String prompt = reply.content().metadata().get(A2ATExtension.NEGOTIATION_T.uri()).toString(); + assertTrue(prompt.contains("任务对象:")); + assertTrue(prompt.contains("P781-")); + assertFalse(prompt.contains("任务上下文:")); + } + + @Test void unknownLiteralItemCannotBeReplacedByARecognizableDescription() { + var reply = assertInstanceOf(NegotiationReply.Send.class, + new NegotiationStrategy(env).resolve(request(SpnCasePrompts.privateLineComplaintData(), + List.of(new NegotiationItem("其他用户的信息", "本地市实际接入端口名称")))).join()); + assertEquals(NegotiationPerformative.ABORT, + A2atMessages.contextOf(new ReceivedMessage(reply.content(), Map.of(), List.of())).performative()); + } + + private String answer(Map input) { + var reply = assertInstanceOf(NegotiationReply.Send.class, + new NegotiationStrategy(env).resolve(request(input)).join()); + return (String) reply.content().metadata().get(A2ATExtension.NEGOTIATION_T.uri()); + } + + private NegotiationRequest request(Map input) { + return request(input, List.of(new NegotiationItem("接入端口名称", "补充实际端口"), + new NegotiationItem("投诉分类", "补充实际分类"))); + } + + private NegotiationRequest request(Map input, List items) { + var sdk = A2ATInitialization.create(() -> new A2ATClient(java.nio.file.Path.of(env))); + var generated = sdk.generateNegotiationProposePromptFromData(new NegotiationProposeData( + new NegotiationContext(UUID.randomUUID().toString(), 1, 3, NegotiationPerformative.PROPOSE), + new InformationProposeContent(items, "AND")), + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri()); + var received = new ReceivedMessage(A2atMessages.from(generated, List.of()), Map.of(), List.of()); + return new NegotiationRequest(TaskRequest.builder().taskId(UUID.randomUUID().toString()).agentName("city") + .input(BusinessInput.data(input)).build(), MessageContent.text("diagnose"), received, + List.of(), java.time.Duration.ofSeconds(10)); + } + + @Test void hostCanChooseNaturalLanguageOrStructuredTaskGeneration() { + var sdk = A2ATInitialization.create(() -> new A2ATClient(java.nio.file.Path.of(env))); + var data = SpnCasePrompts.privateLineComplaintData(); + String text = "请进行投诉诊断。任务对象:" + data.get("任务对象") + + ";任务上下文:" + data.get("任务上下文"); + for (var generated : List.of( + sdk.generateTaskPromptFromText(text, StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()), + sdk.generateTaskPromptFromDataWithSchema(data, SpnCasePrompts.privateLineComplaintSchema(), + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()))) { + MessageContent content = A2atMessages.from(generated, + List.of(new org.a2aproject.sdk.spec.TextPart("business-owned accompanying text"))); + assertEquals(generated.buildMetadataContent(), content.metadata()); + assertEquals(Set.of(A2ATExtension.TASK_T.uri()), content.extensions()); + assertTrue(generated.promptText().contains("P781-")); + } + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/server/EmbeddedA2AServerTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/server/EmbeddedA2AServerTest.java new file mode 100644 index 00000000..8405bbaf --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/server/EmbeddedA2AServerTest.java @@ -0,0 +1,427 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the License); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an AS IS BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.server; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openan.workflow.engine.client.A2ATransport; +import dev.openan.workflow.engine.client.AgentCardJacksonModule; +import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; +import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; +import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; +import dev.openan.workflow.engine.examples.testsupport.OfflineA2ATLlmClient; +import dev.openan.workflow.engine.examples.workbench.WorkbenchControlPoint; +import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; +import dev.openan.workflow.engine.examples.server.OmcAgentLauncher; +import dev.openan.workflow.engine.model.SendMessageResult; +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.ReceivedMessage; +import dev.openan.workflow.engine.client.A2atMessages; +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.agents.SpnTaskInput; +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.*; +import net.openan.a2at.sdk.negotiation.content.*; + +import org.a2aproject.sdk.spec.AgentCard; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; + +/** + * Integration test: start an EmbeddedA2AServer, send a message via DefaultWorkflowEngineClient (the + * real A2A client), and verify the response text is extracted correctly from the SSE stream. + */ +class EmbeddedA2AServerTest { + + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(new AgentCardJacksonModule()); + private static final String AGENT_NAME = "Test Agent"; + private JdkHttpA2AServer server; + private DefaultWorkflowEngineClient client; + private A2ATransport transport; + private int port; + private String sdkEnvPath; + private AgentCard agentCard; + private SpnTaskInput diagnosed; + private int diagnosisCount; + + private class RecordingCity1 extends SpnDomainAgentCity1Executor { + @Override protected String executeBusiness( + org.a2aproject.sdk.server.agentexecution.RequestContext ctx, + org.a2aproject.sdk.server.tasks.AgentEmitter emitter, SpnTaskInput input) { + diagnosed = input; + diagnosisCount++; + return super.executeBusiness(ctx, emitter, input); + } + } + + @BeforeAll + static void installOfflineSdkProvider() { + OfflineA2ATLlmClient.install(); + } + + private static String extractExtensionValue(SendMessageResult result, String extKeyword) { + Map meta = result.getMetadata(); + if (meta == null) return null; + for (String key : meta.keySet()) { + if (key.contains(extKeyword)) { + Object v = meta.get(key); + return v instanceof String s ? s : String.valueOf(v); + } + } + return null; + } + + private static javax.net.ssl.SSLContext createTrustAllSslContext() throws Exception { + javax.net.ssl.SSLContext ctx = javax.net.ssl.SSLContext.getInstance("TLS"); + ctx.init( + null, + new javax.net.ssl.TrustManager[] { + new javax.net.ssl.X509TrustManager() { + public void checkClientTrusted( + java.security.cert.X509Certificate[] chain, String authType) {} + + public void checkServerTrusted( + java.security.cert.X509Certificate[] chain, String authType) {} + + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return new java.security.cert.X509Certificate[0]; + } + } + }, + null); + return ctx; + } + + @BeforeEach + void setUp() throws Exception { + // Exercise real A2A-T client/server pipelines through the offline structured provider. + sdkEnvPath = OfflineA2ATLlmClient.envPath(); + System.setProperty("a2at.env.path", sdkEnvPath); + port = 28000 + (int) (Math.random() * 1000); + Map card = + Map.of( + "name", AGENT_NAME, + "description", "test", + "provider", Map.of("organization", "test", "url", ""), + "version", "1.0.0", + "capabilities", + Map.of( + "streaming", + true, + "pushNotifications", + false, + "extendedAgentCard", + false, + "extensions", + List.of( + Map.of( + "uri", + "https://projects.tmforum.org/a2aproject" + + "/telecommunication/extensions" + + "/Negotiation-T/v1", + "description", + "Negotiation-T test extension", + "required", + false))), + "defaultInputModes", List.of("text/plain"), + "defaultOutputModes", List.of("text/plain"), + "skills", + List.of( + Map.of( + "id", + "test", + "name", + "test", + "description", + "test", + "tags", + List.of())), + "supportedInterfaces", + List.of( + Map.of( + "protocolBinding", + "HTTP+JSON", + "protocolVersion", + "1.0", + "url", + "https://127.0.0.1:" + port, + "tenant", + ""))); + server = + new OmcAgentLauncher().startFromCard(card, new RecordingCity1()); + agentCard = mapper.convertValue(card, AgentCard.class); + Thread.sleep(500); + transport = + new A2ATransport( + List.of(mapper.convertValue(card, AgentCard.class)), + null, + WorkflowEngineClientConfig.builder() + .sslVerify(false) + + .build()); + client = new DefaultWorkflowEngineClient(transport); + client.setControlPoint(new WorkbenchControlPoint(sdkEnvPath)); + } + + private dev.openan.workflow.engine.model.TaskRequest request() { + return dev.openan.workflow.engine.model.TaskRequest.builder().agentName(AGENT_NAME) + .executionId("test").taskId("logical-task").stepName("diagnosis_city1").instruction("diagnose SPN fault") + .input(dev.openan.workflow.engine.model.BusinessInput.data( + dev.openan.workflow.engine.examples.demo.SpnCasePrompts.privateLineComplaintData())).build(); + } + + @AfterEach + void tearDown() { + if (client != null) client.close(); + if (transport != null) transport.close(); + if (server != null) server.close(); + System.clearProperty("a2at.env.path"); + } + + @Test + void testGetAgentCard() throws Exception { + HttpClient http = HttpClient.newBuilder().sslContext(createTrustAllSslContext()).build(); + HttpRequest req = + HttpRequest.newBuilder() + .uri(URI.create("https://127.0.0.1:" + port + "/")) + .GET() + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, resp.statusCode()); + Map card = mapper.readValue(resp.body(), Map.class); + assertEquals(AGENT_NAME, card.get("name")); + } + + @Test + void testSendMessage() throws Exception { + SendMessageResult result = client.dispatch(request(), new WorkbenchControlPoint(sdkEnvPath).onTask(request()).join(), new WorkbenchControlPoint(sdkEnvPath)).join(); + assertNotNull(result); + assertFalse( + result.getText().isEmpty(), + "Response text should not be empty, got: " + result.getText()); + // A2A-T extension content is in metadata (Task-T/v1), not in parts.text + String taskMeta = extractExtensionValue(result, "Task-T"); + assertNotNull(taskMeta, "Task-T metadata should be present in response"); + assertTrue( + taskMeta.contains("诊断结果"), + "Diagnosis metadata should contain structured diagnosis result, got: " + taskMeta); + } + + /** + * Negotiation path: incomplete Task-T data starts negotiation (propose + INPUT_REQUIRED); the engine's + * auto-loop must observe the interruption, ask the (default) control point for a + * clarification, send the follow-up, and complete with the business result. Guards against + * the SSE stream hanging open on INPUT_REQUIRED (A2A interrupted states are not final). + */ + @Test + void testNegotiationAutoLoopCompletes() { + // Task-T validation rejects the blank object; no artificial Negotiation-T key is needed. + String taskPrompt = + "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" + + "## 任务对象(Task Object)\n接入端口名称:\n\n" + + "## 任务上下文(Task Context)\n1. 投诉分类:\"专线质差\""; + Map metadata = new java.util.LinkedHashMap<>(); + metadata.put( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1", + taskPrompt); + metadata.put(MetadataContent.TEMPLATE_URI_METADATA_KEY, StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); + SendMessageResult result = + client + .dispatch(request(), new dev.openan.workflow.engine.model.MessageContent( + List.of(new org.a2aproject.sdk.spec.TextPart("diagnose SPN fault")), metadata, + java.util.Set.of(A2ATExtension.TASK_T.uri())), new WorkbenchControlPoint(sdkEnvPath)) + .orTimeout(60, java.util.concurrent.TimeUnit.SECONDS) + .join(); + assertNotNull(result); + assertFalse( + result.getText().isEmpty(), + "Negotiation round must end with the business response, got: " + result.getText()); + assertTrue( + result.getText().contains("诊断结果"), + "Final response must be the completed diagnosis, got: " + result.getText()); + } + + + private A2ATClient contentSdk() { + return dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new A2ATClient(java.nio.file.Path.of(sdkEnvPath))); + } + + private SendMessageResult startIncomplete() { + var content = new MessageContent(List.of(new org.a2aproject.sdk.spec.TextPart("diagnose")), + SpnCasePrompts.taskTMetadata(SpnCasePrompts.privateLineComplaintPromptBlankObject()), + java.util.Set.of(A2ATExtension.TASK_T.uri())); + var result = transport.send(agentCard, AGENT_NAME, content, java.util.UUID.randomUUID().toString(), null, null).join(); + assertEquals("TASK_STATE_INPUT_REQUIRED", result.getTaskState()); + assertEquals(0, diagnosisCount); + return result; + } + + private NegotiationContext negotiationContext(SendMessageResult pending) { + return pending.getReceivedMessages().stream().map(A2atMessages::contextOf) + .filter(java.util.Objects::nonNull).findFirst().orElseThrow(); + } + + private MetadataContent accept(NegotiationContext context, Map data) { + return contentSdk().generateNegotiationAcceptPromptFromData(new NegotiationEndingData(context, + new InformationEndingContent(NegotiationConclusion.ACCEPT, data.entrySet().stream() + .map(e -> new NegotiationItem(e.getKey(), e.getValue().toString())).toList())), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()); + } + + private SendMessageResult followUp(SendMessageResult pending, MessageContent content) { + return transport.send(agentCard, AGENT_NAME, content, pending.getTask().contextId(), + pending.getTask().id(), null).orTimeout(30, java.util.concurrent.TimeUnit.SECONDS).join(); + } + + @Test void acceptUsesValidatedMetadataAndPreservesActualIncidentNotPartsSummary() { + var pending = startIncomplete(); + var data = new java.util.LinkedHashMap<>(SpnCasePrompts.privateLineComplaintData()); + data.put("任务上下文", data.get("任务上下文").toString().replace("event-id-20260511-09013", "incident-actual-42")); + var reply = A2atMessages.from(accept(negotiationContext(pending), data), + List.of(new org.a2aproject.sdk.spec.TextPart("misleading summary P999-wrong"))); + var result = followUp(pending, reply); + assertEquals("TASK_STATE_COMPLETED", result.getTaskState()); + assertEquals(1, diagnosisCount); + assertEquals("incident-actual-42", diagnosed.incidentId()); + assertEquals(data, diagnosed.parameters()); + assertFalse(diagnosed.diagnosisInput().contains("misleading summary")); + } + + @Test void normalTaskUsesFilledInputWithoutNegotiation() { + var data = new java.util.LinkedHashMap<>(SpnCasePrompts.privateLineComplaintData()); + data.put("任务上下文", data.get("任务上下文").toString().replace("event-id-20260511-09013", "incident-normal-19")); + var generated = contentSdk().generateTaskPromptFromDataWithSchema(data, + SpnCasePrompts.privateLineComplaintSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); + var result = transport.send(agentCard, AGENT_NAME, A2atMessages.from(generated, List.of(new org.a2aproject.sdk.spec.TextPart("business summary"))), + java.util.UUID.randomUUID().toString(), null, null).join(); + assertEquals("TASK_STATE_COMPLETED", result.getTaskState()); + assertEquals(data, diagnosed.parameters()); + assertEquals("incident-normal-19", diagnosed.incidentId()); + assertEquals(1, diagnosisCount); + } + + + @Test void unknownPortNegotiatesOnlyObjectAndKeepsValidatedContext() { + var original = new java.util.LinkedHashMap<>(SpnCasePrompts.privateLineComplaintDataUnknownPort()); + original.put("任务上下文", original.get("任务上下文").toString().replace("event-id-20260511-09013", "incident-preserved-73")); + var generated = contentSdk().generateTaskPromptFromDataWithSchema(original, + SpnCasePrompts.privateLineComplaintSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); + var pending = transport.send(agentCard, AGENT_NAME, A2atMessages.from(generated, + List.of(new org.a2aproject.sdk.spec.TextPart("diagnose unknown port"))), + java.util.UUID.randomUUID().toString(), null, null).join(); + assertEquals("TASK_STATE_INPUT_REQUIRED", pending.getTaskState()); + var reply = accept(negotiationContext(pending), + Map.of("任务对象", SpnCasePrompts.privateLineComplaintData().get("任务对象"))); + var result = followUp(pending, A2atMessages.from(reply, List.of(new org.a2aproject.sdk.spec.TextPart("correct port")))); + assertEquals("TASK_STATE_COMPLETED", result.getTaskState()); + assertEquals(original.get("任务上下文"), diagnosed.parameters().get("任务上下文")); + assertEquals("incident-preserved-73", diagnosed.incidentId()); + assertEquals(1, diagnosisCount); + } + + @Test void taskPartsCannotReplaceMissingFormalTaskMetadata() { + var generated = contentSdk().generateTaskPromptFromDataWithSchema(SpnCasePrompts.privateLineComplaintData(), + SpnCasePrompts.privateLineComplaintSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); + var metadata = new java.util.LinkedHashMap<>(generated.buildMetadataContent()); + metadata.remove(A2ATExtension.TASK_T.uri()); + var content = new MessageContent(List.of(new org.a2aproject.sdk.spec.TextPart(generated.promptText())), + metadata, java.util.Set.of(A2ATExtension.TASK_T.uri())); + var result = transport.send(agentCard, AGENT_NAME, content, + java.util.UUID.randomUUID().toString(), null, null).join(); + assertEquals("TASK_STATE_FAILED", result.getTaskState()); + assertEquals(0, diagnosisCount); + } + + @Test void partsCannotRescueInvalidFormalNegotiationBody() { + var pending = startIncomplete(); + var generated = accept(negotiationContext(pending), SpnCasePrompts.privateLineComplaintData()); + var metadata = new java.util.LinkedHashMap<>(generated.buildMetadataContent()); + metadata.put(A2ATExtension.NEGOTIATION_T.uri(), "补充诊断信息"); + var result = followUp(pending, new MessageContent( + List.of(new org.a2aproject.sdk.spec.TextPart(generated.promptText())), metadata, + java.util.Set.of(A2ATExtension.NEGOTIATION_T.uri()))); + assertEquals("TASK_STATE_FAILED", result.getTaskState()); + assertEquals(0, diagnosisCount); + } + + @Test void incompleteAcceptDoesNotDiagnose() { + var pending = startIncomplete(); + var generated = accept(negotiationContext(pending), Map.of("任务对象", "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17")); + var result = followUp(pending, A2atMessages.from(generated, List.of(new org.a2aproject.sdk.spec.TextPart("business summary")))); + assertEquals("TASK_STATE_FAILED", result.getTaskState()); + assertEquals(0, diagnosisCount); + } + + @Test void rejectIsValidatedAndEndsWithoutDiagnosis() { + var pending = startIncomplete(); + var generated = contentSdk().generateNegotiationRejectPromptFromData( + new NegotiationEndingData(negotiationContext(pending), + new InformationEndingContent(NegotiationConclusion.REJECT, + List.of(new NegotiationItem("拒绝原因", "当前调用方无权提供该端口资料")))), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()); + var result = followUp(pending, A2atMessages.from(generated, List.of(new org.a2aproject.sdk.spec.TextPart("business summary")))); + assertEquals("TASK_STATE_FAILED", result.getTaskState()); + assertTrue(result.getText().contains("Negotiation-T REJECT"), result.getText()); + assertEquals(0, diagnosisCount); + } + + @Test void abortIsValidatedAndEndsWithoutDiagnosis() { + var pending = startIncomplete(); + var generated = contentSdk().generateNegotiationAbortPromptFromData( + new NegotiationAbortData(negotiationContext(pending), new NegotiationAbortContent("业务取消本次诊断")), + StandardTemplates.NEGOTIATION_ABORT.uri()); + var result = followUp(pending, A2atMessages.from(generated, List.of(new org.a2aproject.sdk.spec.TextPart("business summary")))); + assertEquals("TASK_STATE_FAILED", result.getTaskState()); + assertTrue(result.getText().contains("Negotiation-T ABORT"), result.getText()); + assertEquals(0, diagnosisCount); + } + + @Test void replyFromAnotherNegotiationDoesNotDiagnose() { + var pending = startIncomplete(); + var context = negotiationContext(pending); + var other = new NegotiationContext(java.util.UUID.randomUUID().toString(), context.round(), + context.maxRounds(), context.performative()); + var result = followUp(pending, A2atMessages.from(accept(other, SpnCasePrompts.privateLineComplaintData()), List.of(new org.a2aproject.sdk.spec.TextPart("reply")))); + assertEquals("TASK_STATE_FAILED", result.getTaskState()); + assertEquals(0, diagnosisCount); + } + + @Test + void taskRouteParserDecodesExactlyOnePathSegment() { + assertEquals("task id+/city", JdkHttpA2AServer.taskIdFromPath("/tasks/task%20id%2B%2Fcity", null)); + assertEquals("task+id", JdkHttpA2AServer.taskIdFromPath("/tasks/task+id:cancel", ":cancel")); + assertNull(JdkHttpA2AServer.taskIdFromPath("/other/task", null)); + assertNull(JdkHttpA2AServer.taskIdFromPath("/tasks/a/b", null)); + assertNull(JdkHttpA2AServer.taskIdFromPath("/tasks/id:subscribe", ":cancel")); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/testsupport/OfflineA2ATLlmClient.java b/samples/src/test/java/dev/openan/workflow/engine/examples/testsupport/OfflineA2ATLlmClient.java new file mode 100644 index 00000000..9db0d822 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/testsupport/OfflineA2ATLlmClient.java @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.testsupport; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; + +/** Offline structured provider shared by the sample's real A2A-T integration tests. */ +public final class OfflineA2ATLlmClient implements LLMClient { + public static final String PROVIDER = "workflow-engine-e2e-test"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final LLMClientConfig config; + + public OfflineA2ATLlmClient(LLMClientConfig config) { + this.config = config; + } + + public static void install() { + if (!LLMClientFactory.availableProviders().contains(PROVIDER)) { + LLMClientFactory.register(PROVIDER, OfflineA2ATLlmClient.class); + } + } + + public static String envPath() { + try { + var env = + OfflineA2ATLlmClient.class + .getClassLoader() + .getResource("a2at-e2e.env"); + if (env == null) { + throw new IllegalStateException("Missing test resource a2at-e2e.env"); + } + return Path.of(env.toURI()).toString(); + } catch (java.net.URISyntaxException e) { + throw new IllegalStateException("Invalid a2at-e2e.env resource URI", e); + } + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + String prompt = + messages.isEmpty() + ? "" + : messages.get(messages.size() - 1).getOrDefault("content", ""); + Object propertiesValue = jsonSchema.get("properties"); + if (propertiesValue instanceof Map properties + && properties.containsKey("semantic_verdict")) { + Map response = new LinkedHashMap<>(); + String source = validationInput(prompt); + Map params = validationParams(properties, source); + boolean valid = verdict(properties, params, source); + response.put("semantic_verdict", valid); + if (properties.containsKey("negotiation_type")) { + response.put("negotiation_type", "information"); + } + response.put("errors", valid ? List.of() : List.of(Map.of("slot_name", "input", + "code", "validation.semantic_rejected", "facts", Map.of("reason", "Missing actual input fields")))); + response.put("params", params); + return response(response); + } + if (propertiesValue instanceof Map properties + && properties.containsKey("conclusion") + && properties.containsKey("items")) { + return response( + Map.of( + "conclusion", + "Accept", + "items", + List.of( + Map.of( + "name", + "接入端口名称", + "value", + portValue(validationInput(prompt))), + Map.of("name", "投诉分类", "value", label(validationInput(prompt), "投诉分类"))))); + } + + Object slotNames = jsonSchema.get("slotNames"); + if (slotNames instanceof List names) { + Map slots = new LinkedHashMap<>(); + for (Object rawName : names) { + String name = String.valueOf(rawName); + slots.put(name, slotValue(name, validationInput(prompt))); + } + return response(Map.of("slots", slots, "slot_errors", List.of())); + } + throw new IllegalArgumentException( + "Unsupported offline A2A-T LLM schema: " + jsonSchema.keySet()); + } + + private static Map validationParams(Map outputProperties, String source) { + Map properties = outputProperties.get("params") instanceof Map params + && params.get("properties") instanceof Map fields ? fields : Map.of(); + if (properties.isEmpty()) { + List names = source.contains("## 任务类型") ? List.of("任务对象", "任务上下文") + : source.contains("授权策略") ? List.of("授权策略的操作类型", "动网操作的授权策略列表") + : source.contains("订阅条件") ? List.of("订阅条件", "上报通知数据格式") : List.of(); + Map inferred = new LinkedHashMap<>(); + names.forEach(name -> inferred.put(name, Map.of())); + properties = inferred; + } + Map values = new LinkedHashMap<>(); + for (Object rawName : properties.keySet()) { + String name = String.valueOf(rawName); + if ("items".equals(name)) { + // Replay the real provider's paraphrase of an item description, not just ideal keys. + values.put(name, negotiationItems(source).keySet().stream() + .map(key -> key.equals("任务对象") ? "本地市实际接入端口名称" : key).toList()); + } else if ("relationship".equals(name)) { + values.put(name, source.contains("AND") ? "AND" : null); + } else if ("reason".equals(name)) { + String reason = section(source, "协商终止原因"); + values.put(name, reason.isEmpty() ? section(source, "信息协商结果内容") : reason); + } else { + values.put(name, slotValue(name, source)); + } + } + return values; + } + + private static boolean verdict(Map outputProperties, Map params, String source) { + if (outputProperties.containsKey("negotiation_type")) { + boolean formal = source.contains("## 信息协商") || source.contains("## 协商结果"); + if (!formal) return false; // A parts summary is NOT a negotiation prompt. + } + if (outputProperties.get("params") instanceof Map schema && schema.get("required") instanceof List required) { + for (Object key : required) { + Object value = params.get(key); + if (value == null || value instanceof String s && s.isBlank() + || value instanceof List list && list.isEmpty()) return false; + } + } + if (source.contains("## 任务类型")) { + String object = String.valueOf(params.getOrDefault("任务对象", "")); + String context = String.valueOf(params.getOrDefault("任务上下文", "")); + return !portValue(object).isEmpty() && !label(context, "投诉分类").isEmpty() + && !label(context, "OSS侧事件流水号").isEmpty(); + } + if (source.contains("## 授权策略的操作类型")) { + String operation = String.valueOf(params.get("授权策略的操作类型")); + String policies = String.valueOf(params.getOrDefault("动网操作的授权策略列表", "")); + if ("新增授权策略".equals(operation)) { + String[] entries = policies.split("\\R", -1); + for (int i = 0; i < entries.length; i++) { + if (!entries[i].startsWith((i + 1) + ". 业务场景是") + || !entries[i].contains(",处置类型是") + || !entries[i].contains(",操作名称是") + || !entries[i].contains(",有效期是")) return false; + } + } + } + return true; + } + + private static Map negotiationItems(String source) { + String content = section(source, "信息协商结果内容"); + if (content.isEmpty()) content = section(source, "所需信息项"); + Map values = new LinkedHashMap<>(); + var matcher = java.util.regex.Pattern.compile("(?m)^\\s*\\d+[.、]\\s*([^::\\r\\n]+)[::]\\s*(.*)$").matcher(content); + while (matcher.find()) values.put(matcher.group(1).strip(), matcher.group(2).strip()); + return values; + } + + private static Object slotValue(String name, String source) { + String value = section(source, name); + if (!value.isEmpty()) return value; + Map items = negotiationItems(source); + if (items.containsKey(name)) return items.get(name); + // Structured SDK input uses Map.toString(); values are confined to the actual [input] block. + String keys = "任务对象|任务上下文|授权策略的操作类型|动网操作的授权策略列表|订阅条件|上报通知数据格式"; + var field = java.util.regex.Pattern.compile("(?:^|[,{])\\s*" + java.util.regex.Pattern.quote(name) + + "=(.*?)(?=,\\s*(?:" + keys + ")=|}\\s*$)", java.util.regex.Pattern.DOTALL).matcher(source); + if (field.find()) return field.group(1).strip(); + if ("接入端口名称".equals(name)) return portValue(source); + if ("投诉分类".equals(name)) return label(source, name); + // Small explicit natural-language fixture, not general LLM emulation. + if ("任务对象".equals(name) || "任务上下文".equals(name)) { + int start = source.indexOf(name + ":"); + if (start >= 0) { + start += name.length() + 1; + int end = source.indexOf(";任务上下文:", start); + return source.substring(start, end < 0 ? source.length() : end).strip(); + } + } + return ""; + } + + private static String section(String source, String name) { + var heading = java.util.regex.Pattern.compile("(?m)^##\\s+" + java.util.regex.Pattern.quote(name) + + "(?:\\([^\\r\\n]*\\))?[ \\t]*\\r?\\n").matcher(source); + if (!heading.find()) return ""; + int end = source.indexOf("\n## ", heading.end()); + String body = source.substring(heading.end(), end < 0 ? source.length() : end); + int guidance = body.indexOf("\n要求"); + if (guidance >= 0) body = body.substring(0, guidance); + return body.replace("(必选)", "").replace("(必填)", "").strip(); + } + + private static String label(String source, String name) { + var matcher = java.util.regex.Pattern.compile(java.util.regex.Pattern.quote(name) + + "[::]\\s*[\\\"“]?([^;;\\\"”\\r\\n]+)").matcher(source); + return matcher.find() ? matcher.group(1).strip() : ""; + } + + /** Only actual inputs, never explanatory examples or template guidance. */ + private static String validationInput(String prompt) { + int start = prompt.indexOf("[input]\n"); + if (start >= 0) { + start += "[input]\n".length(); + int end = prompt.indexOf("\n\n[slots]", start); + return prompt.substring(start, end < 0 ? prompt.length() : end); + } + start = prompt.indexOf("输入内容:"); + if (start >= 0) { + int end = prompt.indexOf("\n模板标识:", start); + return prompt.substring(start + "输入内容:".length(), end < 0 ? prompt.length() : end); + } + start = prompt.indexOf("待校验的协商报文:\n"); + if (start >= 0) { + int end = prompt.indexOf("\n\n请按系统提示词", start); + return prompt.substring(start + "待校验的协商报文:\n".length(), end < 0 ? prompt.length() : end); + } + return prompt; + } + + private static String portValue(String prompt) { + var matcher = java.util.regex.Pattern.compile("P\\d+-[\\p{L}\\p{N}-]+").matcher(prompt); + return matcher.find() ? matcher.group() : ""; + } + + private LLMResponse response(Map body) { + try { + return new LLMResponse( + MAPPER.writeValueAsString(body), config.model(), Map.of(), Map.of()); + } catch (Exception e) { + throw new IllegalStateException("Cannot serialize offline A2A-T response", e); + } + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/util/A2ATInitializationTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/util/A2ATInitializationTest.java new file mode 100644 index 00000000..de848e34 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/util/A2ATInitializationTest.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.util; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class A2ATInitializationTest { + @Test + void restoresContextClassLoaderAfterSuccessAndFailure() { + var original = Thread.currentThread().getContextClassLoader(); + assertEquals( + "created", + A2ATInitialization.create( + () -> { + assertNotSame(original, Thread.currentThread().getContextClassLoader()); + return "created"; + })); + assertSame(original, Thread.currentThread().getContextClassLoader()); + assertThrows( + IllegalStateException.class, + () -> + A2ATInitialization.create( + () -> { + throw new IllegalStateException("failure"); + })); + assertSame(original, Thread.currentThread().getContextClassLoader()); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrorsTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrorsTest.java new file mode 100644 index 00000000..9832670f --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrorsTest.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.examples.util; + +import dev.openan.workflow.engine.model.BusinessFailure; +import net.openan.a2at.sdk.core.exception.A2ATError; +import org.junit.jupiter.api.Test; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class BusinessSdkErrorsTest { + @Test void exposesSdkCodeAndOnlyHostSelectedDetails() { + BusinessFailure failure = assertThrows(BusinessFailure.class, () -> BusinessSdkErrors.call( + "task-generation", () -> { throw new A2ATError("slot.validation_error", + "password=must-not-leak accessSession=secret"); })); + assertEquals("slot.validation_error", failure.code()); + assertEquals(Map.of("operation", "task-generation"), failure.details()); + assertNull(failure.getCause()); + assertFalse(failure.toString().contains("must-not-leak")); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalogTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalogTest.java new file mode 100644 index 00000000..6f91b279 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalogTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.workbench; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class WorkbenchAgentCatalogTest { + + @TempDir Path tempDir; + + @Test + void loadsAnExternalAgentCard() throws Exception { + Path externalCard = tempDir.resolve("city1.json"); + try (var input = + getClass().getClassLoader().getResourceAsStream("agentcard/spn_domain_agent_city1.json")) { + Files.copy(input, externalCard, StandardCopyOption.REPLACE_EXISTING); + } + + var cards = new WorkbenchAgentCatalog(List.of(externalCard.toString())).load(); + + assertEquals(1, cards.size()); + assertEquals("SPN Domain Agent City1", cards.get(0).name()); + } + + @Test + void failsFastWhenAnExplicitCardDoesNotExist() { + Path missing = tempDir.resolve("missing.json"); + + assertThrows( + IllegalStateException.class, + () -> new WorkbenchAgentCatalog(List.of(missing.toString())).load()); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskFlowTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskFlowTest.java new file mode 100644 index 00000000..538db3ce --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskFlowTest.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.workbench; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.model.ExecutionResult; +import dev.openan.workflow.engine.model.StepType; +import dev.openan.workflow.engine.model.Workflow; + +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +class WorkbenchTaskFlowTest { + + @Test + void localFallbackKeepsBothCityDiagnosesParallelBeforeSelfLoopMerge() { + Workflow workflow = WorkbenchOrchestrator.fallbackWorkflow(); + + assertEquals(3, workflow.getSteps().size()); + assertEquals(0, workflow.getSteps().get(0).getLayer()); + assertEquals(0, workflow.getSteps().get(1).getLayer()); + assertEquals("SPN Domain Agent City1", workflow.getSteps().get(0).getSubtasks().get(0).getAgent()); + assertEquals("SPN Domain Agent City2", workflow.getSteps().get(1).getSubtasks().get(0).getAgent()); + assertEquals(StepType.SELF_LOOP, workflow.getSteps().get(2).getStepType()); + assertEquals( + List.of("diagnosis_city1", "diagnosis_city2"), + workflow.getSteps().get(2).getContextFrom()); + } + + @Test + void validatedTaskTParametersDriveRuntimeIntent() { + AtomicReference validatedPrompt = new AtomicReference<>(); + AtomicReference validatedTemplate = new AtomicReference<>(); + WorkbenchTaskInputParser parser = + new WorkbenchTaskInputParser( + (prompt, schema, templateUri) -> { + validatedPrompt.set(prompt); + validatedTemplate.set(templateUri.uri()); + return new FilledParamData( + Map.of( + "任务对象", + "接入端口名称:P781-city1-port", + "任务上下文", + "OSS侧事件流水号:event-123")); + }); + + WorkbenchTaskInputParser.ParsedTask parsed = + parser.parse( + "创建专线业务投诉诊断任务", + Map.of( + A2ATExtension.TASK_T.uri(), + "rendered Task-T prompt", + MetadataContent.TEMPLATE_URI_METADATA_KEY, + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri())); + + assertEquals("rendered Task-T prompt", validatedPrompt.get()); + assertEquals(StandardTemplates.PRIVATE_LINE_COMPLAINT.uri(), validatedTemplate.get()); + assertTrue(parsed.runtimeIntent().contains("P781-city1-port")); + assertTrue(parsed.runtimeIntent().contains("event-123")); + } + + @Test + void missingOrBlankTaskTBusinessFieldsAreRejected() { + WorkbenchTaskInputParser parser = + new WorkbenchTaskInputParser( + (prompt, schema, templateUri) -> + new FilledParamData(Map.of("任务对象", "", "任务上下文", "context"))); + + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> + parser.parse( + "task", + Map.of( + A2ATExtension.TASK_T.uri(), + "rendered prompt", + MetadataContent.TEMPLATE_URI_METADATA_KEY, + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()))); + + assertTrue(error.getMessage().contains("任务对象")); + } + + @Test + void missingTaskTMetadataIsRejectedBeforeWorkflowSelection() { + WorkbenchTaskInputParser parser = + new WorkbenchTaskInputParser( + (prompt, schema, templateUri) -> new FilledParamData(Map.of())); + + assertThrows(IllegalArgumentException.class, () -> parser.parse("plain text", Map.of())); + } + + @Test + void missingTemplateUriIsRejectedInsteadOfDefaultingTheScenario() { + WorkbenchTaskInputParser parser = + new WorkbenchTaskInputParser( + (prompt, schema, templateUri) -> new FilledParamData(Map.of())); + + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> + parser.parse( + "task", + Map.of( + A2ATExtension.TASK_T.uri(), + "rendered prompt"))); + + assertTrue(error.getMessage().contains("templateUri")); + } + + @Test + void successfulWorkflowReturnsMergedBusinessOutput() { + ExecutionResult result = + ExecutionResult.builder() + .success(true) + .history( + List.of( + Map.of( + "step", "diagnosis_city1", + "output", "city1"), + Map.of( + "step", "diagnosis_city2", + "output", "city2"), + Map.of( + "step", "merge_analysis", + "output", "merged result"))) + .stepOutputs( + Map.of( + "diagnosis_city1", Map.of("diagnose", "city1"), + "diagnosis_city2", Map.of("diagnose", "city2"), + "merge_analysis", Map.of("merge", "merged result"))) + .build(); + + assertEquals("merged result", WorkbenchOrchestrator.buildResultText(result)); + } +} diff --git a/samples/src/test/resources/a2at-e2e.env b/samples/src/test/resources/a2at-e2e.env new file mode 100644 index 00000000..64710823 --- /dev/null +++ b/samples/src/test/resources/a2at-e2e.env @@ -0,0 +1,16 @@ +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=classpath +A2AT_PROMPT_COMPLIANCE_ENABLED=false +A2AT_LLM_PROVIDER=workflow-engine-e2e-test +A2AT_LLM_MODEL=offline-e2e-model +A2AT_LLM_API_KEY=test-key-not-used +A2AT_LLM_BASE_URL=https://llm.example.test/v1 +A2AT_LLM_MAX_TOKENS=256 +A2AT_LLM_TEMPERATURE=0 +A2AT_LLM_TIMEOUT_SECONDS=1 +A2AT_LLM_HISTORY_WINDOW=1 +A2AT_LLM_DISABLE_SYSTEM_PROXY=true +A2AT_LLM_SESSION_MAX_TOTAL=10 +A2AT_LLM_SESSION_MAX_PER_PROVIDER=10 +A2AT_LLM_MAX_ATTEMPTS=1 +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory diff --git a/samples/src/test/resources/spn_agent_credentials.json b/samples/src/test/resources/spn_agent_credentials.json new file mode 100644 index 00000000..1e035169 --- /dev/null +++ b/samples/src/test/resources/spn_agent_credentials.json @@ -0,0 +1,38 @@ +{ + "profiles": { + "local-demo": { + "bearerAuth": { + "login_url": "https://127.0.0.1/rest/plat/smapp/v1/oauth/token", + "method": "PUT", + "content_type": "application/json", + "request_fields": { + "grantType": "password", + "userName": "admin", + "value": "Admin@123", + "ipaddr": "*" + }, + "token_field": "accessSession", + "order_token_header": "bearToken", + "token_ttl": 3600 + } + } + }, + "agents": { + "SPN Domain Agent City1": { + "profile": "local-demo", + "overrides": { + "bearerAuth": { + "login_url": "https://127.0.0.1:26335/rest/plat/smapp/v1/oauth/token" + } + } + }, + "SPN Domain Agent City2": { + "profile": "local-demo", + "overrides": { + "bearerAuth": { + "login_url": "https://127.0.0.1:26336/rest/plat/smapp/v1/oauth/token" + } + } + } + } +} diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java index 4a13720e..a0d705f5 100644 --- a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java @@ -191,7 +191,17 @@ public RequestHandler requestHandler( PushNotificationConfigStore pushStore, MainEventBusProcessor proc, ExecutorService pool) { - return DefaultRequestHandler.create(executor, store, qm, pushStore, proc, pool, pool); + RequestHandler delegate = + DefaultRequestHandler.builder() + .agentExecutor(executor) + .taskStore(store) + .queueManager(qm) + .pushConfigStore(pushStore) + .mainEventBusProcessor(proc) + .executor(pool) + .eventConsumerExecutor(pool) + .build(); + return new SpringCompatibleRequestHandler(delegate); } @Bean @@ -203,7 +213,7 @@ public RestHandler restHandler(AgentCard card, RequestHandler handler, ExecutorS @Bean @ConditionalOnMissingBean public A2AController a2aController( - RestHandler restHandler, RequestHandler requestHandler, A2AProperties properties) { - return new A2AController(restHandler, requestHandler, properties); + RestHandler restHandler, RequestHandler requestHandler) { + return new A2AController(restHandler, requestHandler); } } diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java index 2e408adf..3efb4b99 100644 --- a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java @@ -35,16 +35,23 @@ import org.slf4j.LoggerFactory; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.time.Duration; import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; /** @@ -69,13 +76,12 @@ public class A2AController { private final RestHandler restHandler; private final RequestHandler requestHandler; - private final String pathPrefix; + private final AtomicInteger activeStreams = new AtomicInteger(); + private final Object streamCompletionMonitor = new Object(); - public A2AController( - RestHandler restHandler, RequestHandler requestHandler, A2AProperties properties) { + public A2AController(RestHandler restHandler, RequestHandler requestHandler) { this.restHandler = restHandler; this.requestHandler = requestHandler; - this.pathPrefix = properties.getPathPrefix(); } @PostMapping("${a2at.server.path-prefix}/message:send") @@ -94,14 +100,13 @@ public ResponseEntity sendMessage(HttpServletRequest req, @RequestBody S @PostMapping( value = "${a2at.server.path-prefix}/message:stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public ResponseBodyEmitter streamMessage(HttpServletRequest req, @RequestBody String body) { - ResponseBodyEmitter emitter = new ResponseBodyEmitter(0L); + public SseEmitter streamMessage(HttpServletRequest req, @RequestBody String body) { + SseEmitter emitter = track(new SseEmitter(0L)); try { SendMessageRequest.Builder builder = SendMessageRequest.newBuilder(); JsonFormat.parser().merge(body, builder); MessageSendParams params = ProtoUtils.FromProto.messageSendParams(builder.build()); - requestHandler.validateRequestedTask(params.message().taskId()); var ctx = buildContext(req); Flow.Publisher publisher = requestHandler.onMessageSendStream(params, ctx); @@ -121,16 +126,14 @@ public void onSubscribe(Flow.Subscription s) { public void onNext(StreamingEventKind item) { try { StreamResponse sr = ProtoUtils.ToProto.streamResponse(item); - String compact = - JsonFormat.printer() - .omittingInsignificantWhitespace() - .print(sr); - String sse = - String.format(Locale.ROOT, "id:%d%n", seq.incrementAndGet()) - + "data:" - + compact - + "\n\n"; - emitter.send(sse); + String compact = + JsonFormat.printer() + .omittingInsignificantWhitespace() + .print(sr); + emitter.send( + SseEmitter.event() + .id(Long.toString(seq.incrementAndGet())) + .data(compact, MediaType.APPLICATION_JSON)); } catch (Exception e) { log.error("[SSE] Write failed: {}", e.getMessage()); sub.cancel(); @@ -158,15 +161,171 @@ public void onComplete() { return emitter; } + @GetMapping("${a2at.server.path-prefix}/tasks/{id}") + public ResponseEntity getTask( + HttpServletRequest req, + @PathVariable("id") String taskId) { + var ctx = buildContext(req); + var resp = restHandler.getTask(ctx, "", taskId, null); + return ResponseEntity.status(resp.getStatusCode()) + .contentType( + MediaType.parseMediaType( + resp.getContentType() != null + ? resp.getContentType() + : MediaType.APPLICATION_JSON_VALUE)) + .body(resp.getBody()); + } + + @PostMapping("${a2at.server.path-prefix}/tasks/{id}:cancel") + public ResponseEntity cancelTask( + HttpServletRequest req, + @PathVariable("id") String taskId, + @RequestBody(required = false) String body) { + var ctx = buildContext(req); + var resp = restHandler.cancelTask(ctx, "", body != null ? body : "{}", taskId); + return ResponseEntity.status(resp.getStatusCode()) + .contentType( + MediaType.parseMediaType( + resp.getContentType() != null + ? resp.getContentType() + : MediaType.APPLICATION_JSON_VALUE)) + .body(resp.getBody()); + } + + @PostMapping( + value = "${a2at.server.path-prefix}/tasks/{id}:subscribe", + produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter subscribeToTask( + HttpServletRequest req, + @PathVariable("id") String taskId) { + SseEmitter emitter = track(new SseEmitter(0L)); + try { + var ctx = buildContext(req); + var resp = restHandler.subscribeToTask(ctx, "", taskId); + if (resp instanceof RestHandler.HTTPRestStreamingResponse streaming) { + final AtomicLong seq = new AtomicLong(0); + streaming.getPublisher().subscribe( + new Flow.Subscriber() { + private Flow.Subscription sub; + @Override + public void onSubscribe(Flow.Subscription s) { + sub = s; + s.request(1); + } + @Override + public void onNext(String item) { + try { + emitter.send( + SseEmitter.event() + .id(Long.toString(seq.incrementAndGet())) + .data(item, MediaType.APPLICATION_JSON)); + } catch (Exception e) { + log.error("[SSE] Subscribe write failed: {}", e.getMessage()); + sub.cancel(); + emitter.completeWithError(e); + return; + } + sub.request(1); + } + @Override + public void onError(Throwable t) { + log.error("[SSE] Subscribe stream error: {}", t.getMessage()); + emitter.completeWithError(t); + } + @Override + public void onComplete() { + emitter.complete(); + } + }); + } else { + emitter.send( + SseEmitter.event() + .id("1") + .data(resp.getBody(), MediaType.APPLICATION_JSON)); + emitter.complete(); + } + } catch (Exception e) { + log.error("[SSE] Subscribe setup failed: {}", e.getMessage(), e); + emitter.completeWithError(e); + } + return emitter; + } + + /** + * Waits until every SSE response has left the Servlet asynchronous context. This is mainly + * useful for deterministic application shutdown; receiving the terminal A2A event can precede + * the container's response-completion callback by a few milliseconds. + * + * @return {@code true} when all streams drained before the timeout + */ + public boolean awaitStreamsDrained(Duration timeout) { + if (timeout == null || timeout.isNegative()) { + throw new IllegalArgumentException("timeout must not be null or negative"); + } + long deadline = System.nanoTime() + timeout.toNanos(); + synchronized (streamCompletionMonitor) { + while (activeStreams.get() > 0) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + return false; + } + try { + TimeUnit.NANOSECONDS.timedWait(streamCompletionMonitor, remaining); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + } + return true; + } + + public int activeStreamCount() { + return activeStreams.get(); + } + + private T track(T emitter) { + activeStreams.incrementAndGet(); + AtomicBoolean completed = new AtomicBoolean(); + Runnable finish = + () -> { + if (completed.compareAndSet(false, true)) { + activeStreams.decrementAndGet(); + synchronized (streamCompletionMonitor) { + streamCompletionMonitor.notifyAll(); + } + } + }; + emitter.onCompletion(finish); + emitter.onTimeout(finish); + emitter.onError(ignored -> finish.run()); + return emitter; + } + private ServerCallContext buildContext(HttpServletRequest req) { Map state = new LinkedHashMap<>(); Map headers = new LinkedHashMap<>(); - java.util.Collections.list(req.getHeaderNames()) - .forEach(h -> headers.put(h.toLowerCase(Locale.ROOT), req.getHeader(h))); + if (req.getHeaderNames() != null) { + java.util.Collections.list(req.getHeaderNames()) + .forEach(h -> headers.put(h.toLowerCase(Locale.ROOT), req.getHeader(h))); + } state.put("headers", headers); String ext = req.getHeader("A2A-Extensions"); - Set exts = (ext == null || ext.isBlank()) ? Set.of() : Set.of(ext.split(",")); - String ver = req.getHeader("A2A-Protocol-Version"); + Set exts; + if (ext == null || ext.isBlank()) { + exts = Set.of(); + } else { + java.util.LinkedHashSet parsed = new java.util.LinkedHashSet<>(); + for (String value : ext.split(",")) { + if (!value.isBlank()) parsed.add(value.strip()); + } + exts = java.util.Collections.unmodifiableSet(parsed); + } + // OMC spec uses A2A-Version; SDK also supports A2A-Protocol-Version alias + String ver = req.getHeader("A2A-Version"); + if (ver == null || ver.isBlank()) { + ver = req.getHeader("A2A-Protocol-Version"); + } return new ServerCallContext(null, state, exts, ver); } } diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/SpringCompatibleRequestHandler.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/SpringCompatibleRequestHandler.java new file mode 100644 index 00000000..d7219486 --- /dev/null +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/SpringCompatibleRequestHandler.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.spring; + +import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; +import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.auth.TaskOperation; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.EventKind; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.spec.ListTasksParams; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; + +import java.util.Objects; +import java.util.concurrent.Flow; + +/** + * Keeps the A2A request handler's CDI-only injection points outside Spring bean processing. + * + *

A2A Java SDK 1.2 supports both builder-created handlers and CDI-managed handlers. Its concrete + * {@code DefaultRequestHandler} therefore contains optional CDI {@code Instance} fields. Spring + * treats those fields as mandatory injection points when the concrete object itself is registered + * as a bean. This typed delegate exposes only the public {@link RequestHandler} contract to Spring; + * the builder-created delegate remains framework-neutral. + */ +final class SpringCompatibleRequestHandler implements RequestHandler { + private final RequestHandler delegate; + + SpringCompatibleRequestHandler(RequestHandler delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + } + + @Override + public Task onGetTask(TaskQueryParams params, ServerCallContext context) throws A2AError { + return delegate.onGetTask(params, context); + } + + @Override + public ListTasksResult onListTasks(ListTasksParams params, ServerCallContext context) + throws A2AError { + return delegate.onListTasks(params, context); + } + + @Override + public Task onCancelTask(CancelTaskParams params, ServerCallContext context) throws A2AError { + return delegate.onCancelTask(params, context); + } + + @Override + public EventKind onMessageSend(MessageSendParams params, ServerCallContext context) + throws A2AError { + return delegate.onMessageSend(params, context); + } + + @Override + public Flow.Publisher onMessageSendStream( + MessageSendParams params, ServerCallContext context) throws A2AError { + return delegate.onMessageSendStream(params, context); + } + + @Override + public TaskPushNotificationConfig onCreateTaskPushNotificationConfig( + TaskPushNotificationConfig config, ServerCallContext context) throws A2AError { + return delegate.onCreateTaskPushNotificationConfig(config, context); + } + + @Override + public TaskPushNotificationConfig onGetTaskPushNotificationConfig( + GetTaskPushNotificationConfigParams params, ServerCallContext context) throws A2AError { + return delegate.onGetTaskPushNotificationConfig(params, context); + } + + @Override + public Flow.Publisher onSubscribeToTask( + TaskIdParams params, ServerCallContext context) throws A2AError { + return delegate.onSubscribeToTask(params, context); + } + + @Override + public ListTaskPushNotificationConfigsResult onListTaskPushNotificationConfigs( + ListTaskPushNotificationConfigsParams params, ServerCallContext context) + throws A2AError { + return delegate.onListTaskPushNotificationConfigs(params, context); + } + + @Override + public void onDeleteTaskPushNotificationConfig( + DeleteTaskPushNotificationConfigParams params, ServerCallContext context) + throws A2AError { + delegate.onDeleteTaskPushNotificationConfig(params, context); + } + + @Override + public void authorizeTaskAccess( + String taskId, ServerCallContext context, TaskOperation operation) throws A2AError { + delegate.authorizeTaskAccess(taskId, context, operation); + } +} diff --git a/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java index 55d8ab32..8ae00dc1 100644 --- a/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java +++ b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java @@ -17,12 +17,56 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.concurrent.ExecutorService; import java.util.concurrent.ThreadPoolExecutor; class A2AAutoConfigurationTest { + @Test + void everyTaskRouteUsesConfiguredA2aPathPrefix() throws Exception { + String placeholder = "${a2at.server.path-prefix}"; + + assertEquals( + placeholder + "/tasks/{id}", + A2AController.class + .getMethod( + "getTask", + jakarta.servlet.http.HttpServletRequest.class, + String.class) + .getAnnotation(GetMapping.class) + .value()[0]); + assertEquals( + placeholder + "/tasks/{id}:cancel", + A2AController.class + .getMethod( + "cancelTask", + jakarta.servlet.http.HttpServletRequest.class, + String.class, + String.class) + .getAnnotation(PostMapping.class) + .value()[0]); + var subscribeMethod = + A2AController.class.getMethod( + "subscribeToTask", + jakarta.servlet.http.HttpServletRequest.class, + String.class); + assertEquals( + placeholder + "/tasks/{id}:subscribe", + subscribeMethod.getAnnotation(PostMapping.class).value()[0]); + assertEquals(SseEmitter.class, subscribeMethod.getReturnType()); + + var streamMethod = + A2AController.class.getMethod( + "streamMessage", + jakarta.servlet.http.HttpServletRequest.class, + String.class); + assertEquals(SseEmitter.class, streamMethod.getReturnType()); + } + @Test void blockingTimeoutsComeFromProperties() { A2AProperties properties = new A2AProperties(); diff --git a/workflow-engine/pom.xml b/workflow-engine/pom.xml index d1bf3d9e..258878bc 100644 --- a/workflow-engine/pom.xml +++ b/workflow-engine/pom.xml @@ -41,10 +41,17 @@ a2a-java-sdk-client-transport-grpc ${a2a.java.sdk.version} + + + io.grpc + grpc-netty-shaded + 1.81.0 + runtime + net.openan.a2a-t.sdk - a2a-t-client + a2a-t-core ${a2a.t.sdk.version} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2AJavaClientRuntime.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2AJavaClientRuntime.java index 2fba1fc7..f5de5959 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2AJavaClientRuntime.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2AJavaClientRuntime.java @@ -21,7 +21,12 @@ import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import dev.openan.workflow.engine.model.SendMessageResult; import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.TaskQueryParams; import org.a2aproject.sdk.spec.MessageSendParams; import java.util.function.Consumer; @@ -57,6 +62,32 @@ Iterable sendMessage( Consumer eventSink, Consumer logSink); + + /** + * Get a task by ID (A2A GET tasks/{id}). + */ + default Task getTask(AgentCard agentCard, String taskId, ClientCallContext callContext) { + throw new UnsupportedOperationException("getTask not supported by this runtime"); + } + + /** + * Cancel a task by ID (A2A POST tasks/{id}:cancel). + */ + default Task cancelTask(AgentCard agentCard, String taskId, ClientCallContext callContext) { + throw new UnsupportedOperationException("cancelTask not supported by this runtime"); + } + + /** + * Subscribe to a task stream (A2A POST tasks/{id}:subscribe). + */ + default java.util.concurrent.CompletableFuture subscribeToTask( + AgentCard agentCard, + String taskId, + ClientCallContext callContext, + java.util.function.Consumer eventSink) { + throw new UnsupportedOperationException("subscribeToTask not supported by this runtime"); + } + /** Release any cached resources (e.g. HTTP clients). */ void close(); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATExtension.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATExtension.java index 6e4b7649..723482cd 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATExtension.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATExtension.java @@ -19,40 +19,41 @@ package dev.openan.workflow.engine.client; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; + /** * A2A-T extension types supported by the workflow execution engine. * *

Each enum constant encapsulates the full extension URI so callers never need to hardcode URI - * strings. Use these with {@link ExtensionSender#sendExtensionMessage}. + * strings. Use these when explicitly activating final message content. * *

The engine handles these extensions automatically: * *

    - *
  • {@link #TASK_T} - structured task prompt generation (in-workflow) + *
  • {@link #TASK_T} - host-generated task content (in-workflow) *
  • {@link #NEGOTIATION_T} - negotiation auto-loop (in-workflow) - *
  • {@link #AUTHORIZATION_T} - whitelist pre-positioning (before workflow) - *
  • {@link #NOTIFICATION_T} - result subscription pre-positioning (before workflow) + *
  • {@link #AUTHORIZATION_T} - independent whitelist authorization operation + *
  • {@link #NOTIFICATION_T} - independent result subscription *
* - *

{@code DATA-NEGOTIATION-T/v1} is intentionally absent -- it is an SDK-internal metadata key - * for negotiation context, not a user-declared extension. */ public enum A2ATExtension { - /** Structured task prompt. Handled automatically during workflow execution. */ - TASK_T("https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"), + /** Host-generated task content. The engine does not generate it. */ + TASK_T(ExtensionUriConstants.TASK_T_EXTENSION_URI), /** Negotiation text exchange. Handled automatically via auto-loop. */ - NEGOTIATION_T( - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T"), + NEGOTIATION_T(ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI), /** Authorization whitelist. Pre-positioned before workflow starts. */ - AUTHORIZATION_T( - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1"), + AUTHORIZATION_T(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI), /** Result notification subscription. Pre-positioned before workflow starts. */ - NOTIFICATION_T( - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"); + NOTIFICATION_T(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); + + /** Canonical SDK metadata key carrying id/round/maxRounds/performative. */ + public static final String NEGOTIATION_CONTEXT_META_KEY = + net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY; private final String uri; diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java index 2ee2f283..5be94324 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java @@ -21,7 +21,8 @@ import dev.openan.workflow.engine.model.SendMessageResult; -import net.openan.a2at.sdk.client.A2ATClient; +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.ReceivedMessage; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; @@ -37,24 +38,27 @@ import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.DataPart; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.time.Duration; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; /** @@ -74,21 +78,25 @@ public class A2ATransport implements AutoCloseable { private static final Logger log = LoggerFactory.getLogger(A2ATransport.class); - private final Map cardMap = new ConcurrentHashMap<>(); + private volatile Map cardMap = Map.of(); private final A2AJavaClientRuntime a2aClientRuntime; private final AgentAuthManager authManager; private final AuthProvider authProvider; - private final A2ATClient a2atClient; private final String contextId; private final ClientCallContextFactory clientCallContextFactory; private final ExecutorService asyncExecutor; private final long notificationAckTimeoutSeconds; + private final long sendTimeoutSeconds; private final AtomicBoolean closed = new AtomicBoolean(); + private final java.util.Set notificationSubscriptions = + ConcurrentHashMap.newKeySet(); public A2ATransport( List agentCards, A2AJavaClientRuntime a2aClientRuntime, WorkflowEngineClientConfig config) { + config = java.util.Objects.requireNonNull(config, "config"); + Map validatedCards = validateAgentCards(agentCards); this.a2aClientRuntime = a2aClientRuntime != null ? a2aClientRuntime @@ -106,6 +114,7 @@ public A2ATransport( config.getSendExecutorQueueCapacity()); this.contextId = UUID.randomUUID().toString(); this.notificationAckTimeoutSeconds = config.getNotificationAckTimeoutSeconds(); + this.sendTimeoutSeconds = config.getSendTimeoutSeconds(); this.asyncExecutor = new ThreadPoolExecutor( config.getSendExecutorCoreSize(), @@ -119,62 +128,117 @@ public A2ATransport( return t; }, new ThreadPoolExecutor.CallerRunsPolicy()); - if (config.getA2atEnvPath() != null) { - EnvFileLoader.loadToSystemProperties(java.nio.file.Path.of(config.getA2atEnvPath())); - } - java.net.http.HttpClient credentialHttpClient = null; + CredentialHttpTransport credentialHttpTransport = null; + String credentialEncryptionKey = config.getCredentialEncryptionKey(); if (config.getCredentialsConfigPath() != null || config.getCredentialsConfig() != null) { - credentialHttpClient = - JdkHttpClientFactory.create( + credentialHttpTransport = + CredentialHttpTransport.create( config.isSslVerify(), config.getCaCertsPath(), config.getClientCertPath(), config.getClientKeyPath(), config.getClientKeyPassword(), config.getCrlPath(), - Duration.ofSeconds(30), - null); + Duration.ofSeconds(30)); } if (config.getCredentialsConfigPath() != null) { this.authManager = new AgentAuthManager( - config.getCredentialsConfigPath(), credentialHttpClient); + config.getCredentialsConfigPath(), + credentialHttpTransport, + credentialEncryptionKey); } else if (config.getCredentialsConfig() != null) { this.authManager = - new AgentAuthManager(config.getCredentialsConfig(), credentialHttpClient); + new AgentAuthManager( + config.getCredentialsConfig(), + credentialHttpTransport, + credentialEncryptionKey); } else { this.authManager = new AgentAuthManager(); } - this.a2atClient = initA2atClient(config.getA2atEnvPath()); this.authProvider = config.getAuthProvider(); this.clientCallContextFactory = new ClientCallContextFactory( new AuthProviderHeaderContributor(authProvider), - new CredentialHeaderContributor(authManager, authProvider), - new ExtensionHeaderContributor(authManager)); - for (AgentCard card : agentCards) { - if (!card.name().isEmpty()) { - cardMap.put(card.name(), card); - } - } - log.info( - "[Transport] Initialized with {} agent(s), a2at={}", - cardMap.size(), - a2atClient != null); + new CredentialHeaderContributor(authManager, authProvider)); + cardMap = java.util.Collections.unmodifiableMap(validatedCards); + log.info("[Transport] Initialized with {} agent(s)", cardMap.size()); } public static String extractResponseText(Iterable events) { - StringBuilder text = new StringBuilder(); + // A2A clients commonly expose the same artifact three times: first as an artifact update, + // then in the Task snapshot attached to a status update, and finally in the terminal Task. + // Treat artifact updates as the authoritative stream and use snapshots/messages only as + // fallbacks. Blindly concatenating every representation corrupts workflow input. + Map streamedArtifacts = new LinkedHashMap<>(); + Map snapshotArtifacts = new LinkedHashMap<>(); + Set fallbackMessages = new LinkedHashSet<>(); for (ClientEvent event : events) { - if (event instanceof TaskEvent te) extractTextFromTask(te.getTask(), text); - else if (event instanceof TaskUpdateEvent tue) { - extractTextFromTask(tue.getTask(), text); - if (tue.getUpdateEvent() instanceof TaskArtifactUpdateEvent ae) - extractTextFromArtifact(ae.artifact(), text); - } else if (event instanceof MessageEvent me) - extractTextFromMessage(me.getMessage(), text); + if (event instanceof TaskEvent te) { + captureTaskArtifacts(te.getTask(), snapshotArtifacts); + } else if (event instanceof TaskUpdateEvent tue) { + captureTaskArtifacts(tue.getTask(), snapshotArtifacts); + if (tue.getUpdateEvent() instanceof TaskArtifactUpdateEvent ae) { + String artifactId = artifactKey(ae.artifact(), streamedArtifacts.size()); + String chunk = artifactText(ae.artifact()); + if (Boolean.TRUE.equals(ae.append())) { + streamedArtifacts + .computeIfAbsent(artifactId, ignored -> new StringBuilder()) + .append(chunk); + } else { + streamedArtifacts.put(artifactId, new StringBuilder(chunk)); + } + } + if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue + && sue.status().message() != null) { + addFallbackMessage(sue.status().message(), fallbackMessages); + } + } else if (event instanceof MessageEvent me) { + addFallbackMessage(me.getMessage(), fallbackMessages); + } + } + if (!streamedArtifacts.isEmpty()) { + return concatenate(streamedArtifacts.values()); + } + if (!snapshotArtifacts.isEmpty()) { + return concatenate(snapshotArtifacts.values()); + } + return String.join("", fallbackMessages); + } + + private static void captureTaskArtifacts(Task task, Map artifacts) { + if (task == null || task.artifacts() == null) return; + for (Artifact artifact : task.artifacts()) { + artifacts.put(artifactKey(artifact, artifacts.size()), artifactText(artifact)); } + } + + private static String artifactKey(Artifact artifact, int fallbackIndex) { + if (artifact.artifactId() != null && !artifact.artifactId().isBlank()) { + return artifact.artifactId(); + } + if (artifact.name() != null && !artifact.name().isBlank()) { + return artifact.name(); + } + return "artifact-" + fallbackIndex; + } + + private static String artifactText(Artifact artifact) { + StringBuilder text = new StringBuilder(); + extractTextFromArtifact(artifact, text); + return text.toString(); + } + + private static void addFallbackMessage(Message message, Set messages) { + StringBuilder text = new StringBuilder(); + extractTextFromMessage(message, text); + if (!text.isEmpty()) messages.add(text.toString()); + } + + private static String concatenate(Iterable values) { + StringBuilder text = new StringBuilder(); + for (Object value : values) text.append(value); return text.toString(); } @@ -208,20 +272,29 @@ else if (event instanceof TaskUpdateEvent tue) { return state; } - public static Map extractResponseMetadata(Iterable events) { - Map metadata = new HashMap<>(); - for (ClientEvent event : events) { - if (event instanceof TaskEvent te) { - mergeTaskMetadata(te.getTask(), metadata); - } else if (event instanceof TaskUpdateEvent tue) { - mergeTaskMetadata(tue.getTask(), metadata); - if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { - Map em = sue.metadata(); - if (em != null && !em.isEmpty()) metadata.putAll(em); - } - } - } - return metadata; + public static Map extractResponseMetadata(Iterable events) { + Map metadata = new HashMap<>(); + for (ClientEvent event : events) { + if (event instanceof TaskEvent te) { + mergeTaskMetadata(te.getTask(), metadata); + } else if (event instanceof TaskUpdateEvent tue) { + mergeTaskMetadata(tue.getTask(), metadata); + if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { + Map em = sue.metadata(); + if (em != null && !em.isEmpty()) metadata.putAll(em); + // A status can carry its own Message (e.g. a Negotiation-T propose riding on + // an INPUT_REQUIRED status); merge that message's metadata too. + if (sue.status().message() != null) { + Map sm = sue.status().message().metadata(); + if (sm != null && !sm.isEmpty()) metadata.putAll(sm); + } + } + } else if (event instanceof MessageEvent me) { + Map mm = me.getMessage().metadata(); + if (mm != null && !mm.isEmpty()) metadata.putAll(mm); + } + } + return metadata; } // ------------------------------------------------------------------ @@ -255,33 +328,21 @@ public static Task extractResponseTask(Iterable events) { public static List extractExtensionUris(AgentCard agentCard) { List uris = new ArrayList<>(); + if (agentCard == null || agentCard.capabilities() == null) { + return uris; + } var extensions = agentCard.capabilities().extensions(); if (extensions == null) { return uris; } for (var ext : extensions) { + if (ext == null) continue; String uri = ext.uri(); - if (!uri.isEmpty()) uris.add(uri); + if (uri != null && !uri.isBlank()) uris.add(uri); } return uris; } - private A2ATClient initA2atClient(String a2atEnvPath) { - if (a2atEnvPath == null || a2atEnvPath.isEmpty()) { - return null; - } - try { - return new A2ATClient(java.nio.file.Path.of(a2atEnvPath)); - } catch (Exception e) { - log.warn("Failed to init A2ATClient: {}", e.getMessage()); - return null; - } - } - - // ------------------------------------------------------------------ - // ClientEvent extraction (static helpers, shared by facades) - // ------------------------------------------------------------------ - public AgentCard getCard(String agentName) { return cardMap.get(agentName); } @@ -290,22 +351,40 @@ public List getAgentNames() { return new ArrayList<>(cardMap.keySet()); } - public A2ATClient getA2atClient() { - return a2atClient; - } + /** Time budget shared by sending and business interaction callbacks. */ + public long sendTimeoutSeconds() { return sendTimeoutSeconds; } public String getContextId() { return contextId; } public void updateAgentCards(List agentCards) { - cardMap.clear(); + Map validatedCards = validateAgentCards(agentCards); + cardMap = java.util.Collections.unmodifiableMap(validatedCards); + log.info("[Transport] Updated agent cards: {} agent(s)", cardMap.size()); + } + + private static Map validateAgentCards(List agentCards) { + java.util.Objects.requireNonNull(agentCards, "agentCards"); + Map validated = new java.util.LinkedHashMap<>(); for (AgentCard card : agentCards) { - if (!card.name().isEmpty()) { - cardMap.put(card.name(), card); + java.util.Objects.requireNonNull(card, "agentCard"); + if (card.name() == null || card.name().isBlank()) { + throw new IllegalArgumentException("AgentCard name must not be blank"); + } + if (card.capabilities() == null) { + throw new IllegalArgumentException( + "AgentCard capabilities are required: " + card.name()); + } + if (card.supportedInterfaces() == null || card.supportedInterfaces().isEmpty()) { + throw new IllegalArgumentException( + "AgentCard supportedInterfaces are required: " + card.name()); + } + if (validated.putIfAbsent(card.name(), card) != null) { + throw new IllegalArgumentException("Duplicate AgentCard name: " + card.name()); } } - log.info("[Transport] Updated agent cards: {} agent(s)", cardMap.size()); + return validated; } /** @@ -314,39 +393,31 @@ public void updateAgentCards(List agentCards) { * wires it to its event callback, the one-shot sender passes {@code null}. */ public CompletableFuture send( - AgentCard agentCard, - String agentName, - String message, - String contextId, - Map metadata, - Consumer eventSink) { - if (closed.get()) { - return CompletableFuture.failedFuture( - new IllegalStateException("A2A transport is closed")); - } - return send(agentCard, agentName, message, contextId, null, metadata, eventSink); + AgentCard agentCard, String agentName, MessageContent content, String contextId, + String taskId, Consumer eventSink) { + return send(agentCard, agentName, content, contextId, taskId, eventSink, () -> true); } - /** Send a continuation message for an existing A2A task. */ - public CompletableFuture send( - AgentCard agentCard, - String agentName, - String message, - String contextId, - String taskId, - Map metadata, - Consumer eventSink) { + CompletableFuture send( + AgentCard agentCard, String agentName, MessageContent content, String contextId, + String taskId, Consumer eventSink, java.util.function.BooleanSupplier active) { if (closed.get()) { return CompletableFuture.failedFuture( new IllegalStateException("A2A transport is closed")); } + Map trace = new HashMap<>(WireLog.context()); + trace.put("agent", agentName); + trace.put("contextId", contextId); + if (taskId != null) trace.put("remoteTaskId", taskId); + trace.put("channel", content.extensions().contains(A2ATExtension.AUTHORIZATION_T.uri()) + ? "authorization" : "task"); return CompletableFuture.supplyAsync( - () -> { + () -> WireLog.call(trace, () -> { try { - MessageSendParams params = - buildMessageSendParams(message, contextId, taskId, metadata); - ClientCallContext callContext = - buildClientCallContext(agentCard, agentName, metadata); + if (!active.getAsBoolean()) throw new java.util.concurrent.CancellationException("Late send ignored"); + MessageSendParams params = buildMessageSendParams(content, contextId, taskId); + ClientCallContext callContext = buildContentCallContext(agentCard, agentName, content); + if (!active.getAsBoolean()) throw new java.util.concurrent.CancellationException("Late authenticated send ignored"); String endpoint = agentCard.supportedInterfaces().isEmpty() ? "?" @@ -375,6 +446,7 @@ public CompletableFuture send( .task(task) .metadata(respMetadata) .taskState(taskState) + .receivedMessages(ProtocolResponses.assemble(events)) .build(); } catch (Exception e) { log.error( @@ -387,7 +459,7 @@ public CompletableFuture send( } throw new RuntimeException("Agent call failed: " + e.getMessage(), e); } - }, + }), asyncExecutor); } @@ -410,29 +482,45 @@ public void closeConversation(AgentCard agentCard, String contextId) { /** * Long-lived SSE stream for Notification-T subscription. Opens a daemon thread that keeps the * SSE response stream open. The eventSink callback processes events in real-time (subscribed - * ack + later recovery results). The returned future completes on the first event (subscription - * confirmed). + * ack + later recovery results). The returned future completes on the first event carrying a + * concrete task state; an artifact alone is application data, not a protocol acknowledgement. */ - public CompletableFuture sendNotificationStream( + /** Opens a long-lived Notification-T stream and returns its explicit lifecycle handle. */ + public NotificationSubscription openNotificationStream( AgentCard agentCard, String agentName, - String message, + MessageContent content, String contextId, - Map metadata, - Consumer eventSink) { + java.util.function.BiConsumer eventSink) { if (closed.get()) { - return CompletableFuture.failedFuture( - new IllegalStateException("A2A transport is closed")); + throw new IllegalStateException("A2A transport is closed"); } - CompletableFuture future = new CompletableFuture<>(); + AtomicReference streamThreadRef = new AtomicReference<>(); + NotificationSubscription subscription = + new NotificationSubscription( + agentName, + contextId, + () -> { + closeConversation(agentCard, contextId); + Thread running = streamThreadRef.get(); + if (running != null) { + running.interrupt(); + } + }); + notificationSubscriptions.add(subscription); + Map trace = new HashMap<>(WireLog.context()); + trace.put("agent", agentName); + trace.put("contextId", contextId); + trace.put("channel", "notification"); + ProtocolResponses.Accumulator responses = new ProtocolResponses.Accumulator(); Thread streamThread = new Thread( - () -> { + () -> WireLog.inContext(trace, () -> { try { MessageSendParams params = - buildMessageSendParams(message, contextId, metadata); + buildMessageSendParams(content, contextId, null); ClientCallContext callContext = - buildClientCallContext(agentCard, agentName, metadata); + buildContentCallContext(agentCard, agentName, content); String endpoint = agentCard.supportedInterfaces().isEmpty() ? "?" @@ -451,32 +539,45 @@ public CompletableFuture sendNotificationStream( "[Transport] Notification-T event from {}: {}", agentName, event.getClass().getSimpleName()); + subscription.recordEvent(); if (eventSink != null) { - eventSink.accept(event); + responses.accept(event); + for (ReceivedMessage received : responses.snapshots()) { + eventSink.accept(subscription, received); + } } - if (!future.isDone()) { - future.complete( - SendMessageResult.builder() - .text("Subscribed") - .taskState("TASK_STATE_WORKING") - .build()); + SendMessageResult acknowledgement = + eventResult(event); + if (isFailureState(acknowledgement.getTaskState())) { + subscription.failAcknowledgement( + new IllegalStateException( + "Notification-T subscription rejected by '" + + agentName + + "': state=" + + acknowledgement + .getTaskState() + + ", response=" + + acknowledgement.getText())); + } else if (isAcknowledgementState( + acknowledgement.getTaskState())) { + subscription.acknowledge(acknowledgement); } }, s -> log.info("[A2A] {}", s)); log.info( "[Transport] Notification-T stream closed for {}", agentName); - if (!future.isDone()) { - future.complete( - SendMessageResult.builder() - .text("Stream closed") - .taskState("TASK_STATE_COMPLETED") - .build()); + if (!subscription.acknowledgement().isDone()) { + subscription.failAcknowledgement( + new IllegalStateException("Stream closed before acknowledgement")); } + subscription.completeStream(); } catch (Exception e) { String msg = e.getMessage() != null ? e.getMessage() : ""; boolean connectionClosed = - msg.contains("connection closed locally") + !subscription.isActive() + || causedByInterruption(e) + || msg.contains("connection closed locally") || msg.contains( "chunked transfer encoding, state: READING_LENGTH"); if (connectionClosed) { @@ -490,57 +591,105 @@ public CompletableFuture sendNotificationStream( e.getMessage(), e); } - if (!future.isDone()) { - future.completeExceptionally(e); + if (!subscription.acknowledgement().isDone()) { + subscription.failAcknowledgement(e); } + if (!connectionClosed && subscription.isActive()) { + subscription.failStream(e); + } else { + subscription.completeStream(); + } + } finally { + notificationSubscriptions.remove(subscription); + subscription.markStreamTerminated(); } - }, + }), "notif-t-" + agentName); streamThread.setDaemon(true); + streamThreadRef.set(streamThread); streamThread.start(); - return future.orTimeout(notificationAckTimeoutSeconds, TimeUnit.SECONDS) - .handle( - (result, error) -> { - if (error == null) return result; - Throwable cause = unwrapCompletionError(error); - if (!(cause instanceof TimeoutException)) { - throw new CompletionException(cause); - } - log.warn( - "[Transport] Notification-T subscription: no event in {}s, assuming active (stream stays open)", - notificationAckTimeoutSeconds); - return SendMessageResult.builder() - .text("Subscribed (no-ack)") - .taskState("TASK_STATE_WORKING") - .build(); - }); + CompletableFuture.runAsync( + () -> { + if (subscription.isActive() + && !subscription.acknowledgement().isDone()) { + log.warn( + "[Transport] Notification-T subscription: no acknowledgement in {}s; closing unconfirmed stream", + notificationAckTimeoutSeconds); + subscription.failAcknowledgement( + new java.util.concurrent.TimeoutException("No subscription acknowledgement received")); + subscription.close(); + } + }, + CompletableFuture.delayedExecutor( + notificationAckTimeoutSeconds, TimeUnit.SECONDS)); + return subscription; } - private static Throwable unwrapCompletionError(Throwable error) { + private static SendMessageResult eventResult(ClientEvent event) { + List eventList = List.of(event); + return SendMessageResult.builder() + .text(extractResponseText(eventList)) + .task(extractResponseTask(eventList)) + .metadata(extractResponseMetadata(eventList)) + .taskState(extractResponseTaskState(eventList)) + .receivedMessages(ProtocolResponses.assemble(eventList)) + .build(); + } + + private static boolean isFailureState(String state) { + if (state == null) return false; + return state.contains("FAILED") + || state.contains("CANCELED") + || state.contains("CANCELLED") + || state.contains("REJECTED"); + } + + static boolean isAcknowledgementState(String state) { + return state != null && !state.isBlank(); + } + + private static boolean causedByInterruption(Throwable error) { Throwable current = error; - while (current instanceof CompletionException && current.getCause() != null) { + while (current != null) { + if (current instanceof InterruptedException) { + return true; + } current = current.getCause(); } - return current; + return false; } private MessageSendParams buildMessageSendParams( - String message, String contextId, Map metadata) { - return buildMessageSendParams(message, contextId, null, metadata); + MessageContent content, String contextId, String taskId) { + Message msg = Message.builder() + .messageId(UUID.randomUUID().toString()) + .contextId(contextId).taskId(taskId).role(Message.Role.ROLE_USER) + .parts(content.parts()).metadata(content.metadata()) + .extensions(new ArrayList<>(content.extensions())).build(); + return MessageSendParams.builder().message(msg).build(); } - private MessageSendParams buildMessageSendParams( - String message, String contextId, String taskId, Map metadata) { - Message msg = - Message.builder() - .messageId(UUID.randomUUID().toString()) - .contextId(contextId) - .taskId(taskId) - .role(Message.Role.ROLE_USER) - .parts(new TextPart(message)) - .metadata(metadata != null ? metadata : Map.of()) - .build(); - return MessageSendParams.builder().message(msg).build(); + private ClientCallContext buildContentCallContext( + AgentCard card, String agentName, MessageContent content) { + ClientCallContext context = buildClientCallContext(card, agentName, content.metadata()); + Map headers = new HashMap<>(context.getHeaders()); + Set extensions = new LinkedHashSet<>(content.extensions()); + String existingKey = headers.keySet().stream() + .filter(key -> key.equalsIgnoreCase("A2A-Extensions")).findFirst().orElse(null); + if (existingKey != null) { + for (String value : headers.remove(existingKey).split(",")) { + if (!value.isBlank()) extensions.add(value.trim()); + } + } + if (card.capabilities() != null && card.capabilities().extensions() != null) { + for (var extension : card.capabilities().extensions()) { + if (Boolean.TRUE.equals(extension.required()) && !extensions.contains(extension.uri())) { + throw new IllegalArgumentException("Required extension not activated: " + extension.uri()); + } + } + } + if (!extensions.isEmpty()) headers.put("A2A-Extensions", String.join(",", extensions)); + return new ClientCallContext(new HashMap<>(), headers); } private ClientCallContext buildClientCallContext( @@ -552,10 +701,108 @@ private ClientCallContext buildClientCallContext( // Lifecycle // ------------------------------------------------------------------ + public CompletableFuture getTask( + AgentCard agentCard, String agentName, String taskId) { + if (closed.get()) { + return CompletableFuture.failedFuture(new IllegalStateException("A2A transport is closed")); + } + Map trace = taskTrace(agentName, taskId); + return CompletableFuture.supplyAsync(() -> WireLog.call(trace, () -> { + try { + ClientCallContext ctx = buildClientCallContext(agentCard, agentName, Map.of()); + Task task = a2aClientRuntime.getTask(agentCard, taskId, ctx); + StringBuilder text = new StringBuilder(); + extractTextFromTask(task, text); + Map metadata = new HashMap<>(); + mergeTaskMetadata(task, metadata); + return SendMessageResult.builder() + .text(text.toString()) + .task(task) + .metadata(metadata) + .taskState(taskState(task)) + .receivedMessages(ProtocolResponses.assemble(List.of(new TaskEvent(task)))) + .build(); + } catch (Exception e) { + throw new RuntimeException("getTask failed for " + agentName + ": " + e.getMessage(), e); + } + }), asyncExecutor); + } + + public CompletableFuture cancelTask( + AgentCard agentCard, String agentName, String taskId) { + if (closed.get()) { + return CompletableFuture.failedFuture(new IllegalStateException("A2A transport is closed")); + } + Map trace = taskTrace(agentName, taskId); + return CompletableFuture.supplyAsync(() -> WireLog.call(trace, () -> { + try { + ClientCallContext ctx = buildClientCallContext(agentCard, agentName, Map.of()); + Task task = a2aClientRuntime.cancelTask(agentCard, taskId, ctx); + StringBuilder text = new StringBuilder(); + extractTextFromTask(task, text); + Map metadata = new HashMap<>(); + mergeTaskMetadata(task, metadata); + return SendMessageResult.builder() + .text(text.toString()) + .task(task) + .metadata(metadata) + .taskState(taskState(task)) + .receivedMessages(ProtocolResponses.assemble(List.of(new TaskEvent(task)))) + .build(); + } catch (Exception e) { + throw new RuntimeException("cancelTask failed for " + agentName + ": " + e.getMessage(), e); + } + }), asyncExecutor); + } + + public CompletableFuture subscribeToTask( + AgentCard agentCard, String agentName, String taskId, + Consumer eventSink) { + if (closed.get()) { + return CompletableFuture.failedFuture(new IllegalStateException("A2A transport is closed")); + } + ClientCallContext ctx = buildClientCallContext(agentCard, agentName, Map.of()); + return WireLog.call(taskTrace(agentName, taskId), () -> a2aClientRuntime.subscribeToTask(agentCard, taskId, ctx, + event -> { + if (eventSink != null) { + try { + eventSink.accept(event); + } catch (RuntimeException callbackError) { + log.warn( + "[Transport] Task subscription callback failed for {}: {}", + agentName, + callbackError.getMessage(), + callbackError); + } + } + })); + } + + private static Map taskTrace(String agentName, String taskId) { + Map trace = new HashMap<>(WireLog.context()); + trace.put("agent", agentName); + trace.put("remoteTaskId", taskId); + trace.putIfAbsent("channel", "task"); + return trace; + } + + private static String taskState(Task task) { + return task != null && task.status() != null && task.status().state() != null + ? task.status().state().name() + : null; + } + @Override public void close() { if (!closed.compareAndSet(false, true)) return; log.info("[Transport] Closing"); + List subscriptions = + new ArrayList<>(notificationSubscriptions); + for (NotificationSubscription subscription : subscriptions) { + subscription.close(); + } + awaitNotificationShutdown(subscriptions); + notificationSubscriptions.clear(); asyncExecutor.shutdown(); try { if (!asyncExecutor.awaitTermination(2, TimeUnit.SECONDS)) { @@ -571,4 +818,38 @@ public void close() { log.warn("[Transport] Runtime close failed: {}", e.getMessage(), e); } } + + private void awaitNotificationShutdown(List subscriptions) { + if (subscriptions.isEmpty()) { + return; + } + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + int terminated = 0; + for (NotificationSubscription subscription : subscriptions) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + break; + } + try { + subscription.streamTermination().get(remaining, TimeUnit.NANOSECONDS); + terminated++; + } catch (java.util.concurrent.TimeoutException e) { + break; + } catch (java.util.concurrent.ExecutionException e) { + terminated++; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + if (terminated == subscriptions.size()) { + log.info( + "[Transport] NOTIFICATION_SHUTDOWN_DONE subscriptions={}", terminated); + } else { + log.warn( + "[Transport] NOTIFICATION_SHUTDOWN_TIMEOUT terminated={}, total={}, timeoutSeconds=2", + terminated, + subscriptions.size()); + } + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2atMessages.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2atMessages.java new file mode 100644 index 00000000..2c83f780 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2atMessages.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.client; + +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.ReceivedMessage; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import org.a2aproject.sdk.spec.Part; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Thin A2A-T core conversion. Does not generate, validate or interpret business content. */ +public final class A2atMessages { + private A2atMessages() {} + + /** Preserves SDK metadata and activates only this generated extension; parts stay explicit. */ + public static MessageContent from(MetadataContent generated, List> parts) { + Objects.requireNonNull(generated, "generated"); + return new MessageContent(parts, generated.buildMetadataContent(), Set.of(generated.extensionUri())); + } + + /** Reads the canonical context, rejecting conflicting contexts instead of flattening metadata. */ + public static NegotiationContext contextOf(ReceivedMessage received) { + Objects.requireNonNull(received, "received"); + List> layers = new ArrayList<>(); + if (received.message() != null) layers.add(received.message().metadata()); + layers.add(received.taskMetadata()); + received.artifacts().forEach(a -> { if (a.metadata() != null) layers.add(a.metadata()); }); + NegotiationContext context = null; + for (Map layer : layers) { + if (!layer.containsKey(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY)) continue; + NegotiationContext candidate = contextOf(layer); + if (context != null && !context.equals(candidate)) { + throw new IllegalArgumentException("Conflicting negotiation contexts in response"); + } + context = candidate; + } + if (context == null) throw new IllegalArgumentException("Missing negotiationContext"); + return context; + } + + /** Reads canonical wire fields only, without coercing missing or fractional rounds. */ + static NegotiationContext contextOf(Map metadata) { + Object raw = metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + if (!(raw instanceof Map fields) + || !(fields.get("id") instanceof String id) + || !(fields.get("performative") instanceof String action)) { + throw new IllegalArgumentException("Invalid negotiationContext"); + } + return new NegotiationContext(id, integer(fields.get("round")), integer(fields.get("maxRounds")), + NegotiationPerformative.tryParse(action) + .orElseThrow(() -> new IllegalArgumentException("Invalid negotiation performative"))); + } + + private static int integer(Object value) { + if (!(value instanceof Number)) throw new IllegalArgumentException("Negotiation round must be an integer"); + try { + return new BigDecimal(value.toString()).intValueExact(); + } catch (ArithmeticException | NumberFormatException e) { + throw new IllegalArgumentException("Negotiation round must be an integer", e); + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java index 30c9f6dd..3fd03335 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java @@ -19,20 +19,15 @@ package dev.openan.workflow.engine.client; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; import java.net.http.HttpClient; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Loads agent credentials from config, creates per-agent CredentialService, and builds @@ -42,133 +37,137 @@ */ class AgentAuthManager { - private static final Logger log = LoggerFactory.getLogger(AgentAuthManager.class); - private static final ObjectMapper mapper = new ObjectMapper(); - - private final Map>> config; - private final HttpClient credentialHttpClient; - private final Map services = new ConcurrentHashMap<>(); - - /** Create with a config map (agent name -> scheme name -> scheme config). */ - public AgentAuthManager(Map>> config) { - this(config, null); - } - - /** Create with a config map and an explicitly configured login HTTP client. */ - public AgentAuthManager( - Map>> config, - HttpClient credentialHttpClient) { - this.config = config != null ? config : new HashMap<>(); - this.credentialHttpClient = credentialHttpClient; - validateEncryptedCredentials(this.config); - if (!this.config.isEmpty()) { - log.info( - "[Auth] Loaded credentials for {} agent(s): {}", - this.config.size(), - new ArrayList<>(this.config.keySet())); - } - } - - /** Create by loading credentials from a JSON file. */ - public AgentAuthManager(String configPath) { - this(loadFromFile(configPath), null); + private static final Logger log = LoggerFactory.getLogger(AgentAuthManager.class); + private final Map>> config; + private final HttpClient credentialHttpClient; + private final CredentialHttpTransport credentialHttpTransport; + private final String credentialEncryptionKey; + private final Map services = new ConcurrentHashMap<>(); + + /** Create with a config map (agent name -> scheme name -> scheme config). */ + public AgentAuthManager(Map>> config) { + this(config, null); + } + + /** Create with a config map and an explicitly configured login HTTP client. */ + public AgentAuthManager( + Map>> config, HttpClient credentialHttpClient) { + this(config, credentialHttpClient, null); + } + + AgentAuthManager( + Map>> config, + HttpClient credentialHttpClient, + String credentialEncryptionKey) { + this(config, credentialHttpClient, null, credentialEncryptionKey); + } + + AgentAuthManager( + Map>> config, + CredentialHttpTransport credentialHttpTransport, + String credentialEncryptionKey) { + this(config, null, credentialHttpTransport, credentialEncryptionKey); + } + + private AgentAuthManager( + Map>> config, + HttpClient credentialHttpClient, + CredentialHttpTransport credentialHttpTransport, + String credentialEncryptionKey) { + this.config = config != null ? config : new HashMap<>(); + this.credentialHttpClient = credentialHttpClient; + this.credentialHttpTransport = credentialHttpTransport; + this.credentialEncryptionKey = credentialEncryptionKey; + validateEncryptedCredentials(this.config, credentialEncryptionKey); + if (!this.config.isEmpty()) { + log.info( + "[Auth] Loaded credentials for {} agent(s): {}", + this.config.size(), + new ArrayList<>(this.config.keySet())); } - - /** Create from a JSON file and use the supplied client for login requests. */ - public AgentAuthManager(String configPath, HttpClient credentialHttpClient) { - this(loadFromFile(configPath), credentialHttpClient); + } + + /** Create by loading credentials from a JSON file. */ + public AgentAuthManager(String configPath) { + this(loadFromFile(configPath), null); + } + + /** Create from a JSON file and use the supplied client for login requests. */ + public AgentAuthManager(String configPath, HttpClient credentialHttpClient) { + this(loadFromFile(configPath), credentialHttpClient); + } + + AgentAuthManager( + String configPath, HttpClient credentialHttpClient, String credentialEncryptionKey) { + this(loadFromFile(configPath), credentialHttpClient, credentialEncryptionKey); + } + + AgentAuthManager( + String configPath, + CredentialHttpTransport credentialHttpTransport, + String credentialEncryptionKey) { + this(loadFromFile(configPath), credentialHttpTransport, credentialEncryptionKey); + } + + /** Create with no credentials (auth disabled). */ + public AgentAuthManager() { + this(new HashMap<>()); + } + + private static Map>> loadFromFile(String path) { + Map>> loaded = CredentialConfigLoader.load(path); + log.info("[Auth] Loaded credentials for {} agent(s) from {}", loaded.size(), path); + return loaded; + } + + private static List extractExtensionUris(AgentCard agentCard) { + List uris = new ArrayList<>(); + var extensions = agentCard.capabilities().extensions(); + if (extensions == null) { + return uris; } - - /** Create with no credentials (auth disabled). */ - public AgentAuthManager() { - this(new HashMap<>()); + for (var ext : extensions) { + String uri = ext.uri(); + if (!uri.isEmpty()) { + uris.add(uri); + } } - - private static Map>> loadFromFile(String path) { - if (path == null) { - return new HashMap<>(); - } - try { - Map>> loaded; - if (path.startsWith("classpath:")) { - String resource = path.substring("classpath:".length()); - java.io.InputStream is = - AgentAuthManager.class.getClassLoader().getResourceAsStream(resource); - if (is == null) { - throw new IllegalStateException( - "Credentials classpath resource not found: " + resource); - } - loaded = - mapper.readValue( - is, - new TypeReference< - Map>>>() {}); - } else { - File file = new File(path); - if (!file.exists()) { - throw new IllegalStateException("Credentials file not found: " + path); - } - loaded = - mapper.readValue( - file, - new TypeReference< - Map>>>() {}); - } - log.info("[Auth] Loaded credentials for {} agent(s) from {}", loaded.size(), path); - return loaded; - } catch (Exception e) { - throw new IllegalStateException( - "Failed to load credentials from " + path + ": " + e.getMessage(), e); - } + return uris; + } + + /** Get or create a credential service for the given agent. */ + public AgentCredentialService getService(String agentName) { + return services.computeIfAbsent( + agentName, + name -> { + Map> agentCreds = config.get(name); + if (agentCreds == null) { + return null; + } + log.info("[Auth] Created credential service for agent: {}", name); + return new AgentCredentialService( + name, + agentCreds, + credentialHttpClient, + credentialHttpTransport, + credentialEncryptionKey); + }); + } + + /** Get the raw config for an agent. */ + public Map> getConfig(String agentName) { + return config.get(agentName); + } + + private static void validateEncryptedCredentials( + Map values, String credentialEncryptionKey) { + for (Object value : values.values()) { + if (value instanceof Map nested) { + validateEncryptedCredentials(nested, credentialEncryptionKey); + } else if (value instanceof String text && text.startsWith("enc:")) { + CredentialCrypto.decryptIfNeeded(text, credentialEncryptionKey); + } } + } - private static List extractExtensionUris(AgentCard agentCard) { - List uris = new ArrayList<>(); - var extensions = agentCard.capabilities().extensions(); - if (extensions == null) { - return uris; - } - for (var ext : extensions) { - String uri = ext.uri(); - if (!uri.isEmpty()) { - uris.add(uri); - } - } - return uris; - } - - /** Get or create a credential service for the given agent. */ - public AgentCredentialService getService(String agentName) { - return services.computeIfAbsent( - agentName, - name -> { - Map> agentCreds = config.get(name); - if (agentCreds == null) { - return null; - } - log.info("[Auth] Created credential service for agent: {}", name); - return new AgentCredentialService(name, agentCreds, credentialHttpClient); - }); - } - - /** Get the raw config for an agent. */ - public Map> getConfig(String agentName) { - return config.get(agentName); - } - - private static void validateEncryptedCredentials(Map values) { - for (Object value : values.values()) { - if (value instanceof Map nested) { - validateEncryptedCredentials(nested); - } else if (value instanceof String text && text.startsWith("enc:")) { - CredentialCrypto.decryptIfNeeded(text); - } - } - } - - /** Build the extension-header contributor for an AgentCard, if it declares extensions. */ - public ExtensionInterceptor buildExtensionInterceptor(AgentCard agentCard) { - List extUris = extractExtensionUris(agentCard); - return extUris.isEmpty() ? null : new ExtensionInterceptor(extUris); - } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardJacksonModule.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardJacksonModule.java index 6d3338a7..44123816 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardJacksonModule.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardJacksonModule.java @@ -157,9 +157,7 @@ public org.a2aproject.sdk.spec.SecurityRequirement deserialize( JsonNode node = p.getCodec().readTree(p); var schemes = new java.util.LinkedHashMap>(); if (node.has("schemes") && node.get("schemes").isObject()) { - var fields = node.get("schemes").fields(); - while (fields.hasNext()) { - var entry = fields.next(); + for (var entry : node.get("schemes").properties()) { schemes.put(entry.getKey(), java.util.List.of()); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCredentialService.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCredentialService.java index 9aef5477..5dd629b0 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCredentialService.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCredentialService.java @@ -24,7 +24,7 @@ import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; import org.a2aproject.sdk.client.transport.spi.interceptors.auth.CredentialService; -import org.jetbrains.annotations.NotNull; +import org.a2aproject.sdk.util.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,21 +57,46 @@ class AgentCredentialService implements CredentialService { private final HttpClient httpClient; + private final CredentialHttpTransport httpTransport; + + private final String credentialEncryptionKey; + private final Map tokenCache = new ConcurrentHashMap<>(); public AgentCredentialService( String agentName, Map> schemeConfigs) { - this(agentName, schemeConfigs, null); + this(agentName, schemeConfigs, null, null); } public AgentCredentialService( String agentName, Map> schemeConfigs, HttpClient httpClient) { + this(agentName, schemeConfigs, httpClient, null); + } + + AgentCredentialService( + String agentName, + Map> schemeConfigs, + HttpClient httpClient, + String credentialEncryptionKey) { + this(agentName, schemeConfigs, httpClient, null, credentialEncryptionKey); + } + + AgentCredentialService( + String agentName, + Map> schemeConfigs, + HttpClient httpClient, + CredentialHttpTransport httpTransport, + String credentialEncryptionKey) { this.agentName = agentName; this.schemeConfigs = schemeConfigs != null ? schemeConfigs : Map.of(); + this.credentialEncryptionKey = credentialEncryptionKey; + this.httpTransport = httpTransport; if (httpClient != null) { this.httpClient = httpClient; + } else if (httpTransport != null) { + this.httpClient = null; } else { this.httpClient = JdkHttpClientFactory.create( @@ -98,17 +123,21 @@ private static String extractNestedValue(Map data, String path) } @SuppressWarnings("unchecked") - private static Map buildLoginBody(Map schemeCfg) { + private Map buildLoginBody(Map schemeCfg) { Map body = new HashMap<>(); Object requestFields = schemeCfg.get("request_fields"); if (requestFields instanceof Map) { for (var entry : ((Map) requestFields).entrySet()) { String val = entry.getValue() != null ? entry.getValue().toString() : ""; - body.put(entry.getKey(), CredentialCrypto.decryptIfNeeded(val)); + body.put( + entry.getKey(), + CredentialCrypto.decryptIfNeeded(val, credentialEncryptionKey)); } } else { String username = (String) schemeCfg.get("username"); - String password = CredentialCrypto.decryptIfNeeded((String) schemeCfg.get("password")); + String password = + CredentialCrypto.decryptIfNeeded( + (String) schemeCfg.get("password"), credentialEncryptionKey); if (username == null || password == null) return body; body.put(schemeCfg.getOrDefault("username_field", "username").toString(), username); body.put(schemeCfg.getOrDefault("password_field", "password").toString(), password); @@ -191,7 +220,7 @@ private String login(Map schemeCfg) { method, loginUrl, sanitizeBody(body)); - HttpRequest.BodyPublisher bodyPublisher; + String requestBody; if ("application/x-www-form-urlencoded".equals(contentType)) { StringBuilder form = new StringBuilder(); for (Map.Entry e : body.entrySet()) { @@ -202,35 +231,25 @@ private String login(Map schemeCfg) { .append("=") .append(encodeForm(String.valueOf(e.getValue()))); } - bodyPublisher = HttpRequest.BodyPublishers.ofString(form.toString()); + requestBody = form.toString(); } else { - bodyPublisher = - HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)); + requestBody = mapper.writeValueAsString(body); + } + if (httpTransport != null) { + CredentialHttpTransport.Response response = + httpTransport.send(URI.create(loginUrl), method, contentType, requestBody); + return parseTokenResponse(tokenField, response.statusCode(), response.body()); } HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(loginUrl)) .header("Content-Type", contentType) - .method(method, bodyPublisher) + .method(method, HttpRequest.BodyPublishers.ofString(requestBody)) .timeout(Duration.ofSeconds(30)) .build(); HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (resp.statusCode() >= 400) { - log.error("[Auth] Login failed: agent={}, status={}", agentName, resp.statusCode()); - return null; - } - Map data = - mapper.readValue(resp.body(), new TypeReference>() {}); - String token = extractNestedValue(data, tokenField); - if (token == null) { - token = - (String) - data.getOrDefault( - "accessSession", - data.getOrDefault("access_token", data.get("token"))); - } - return token; + return parseTokenResponse(tokenField, resp.statusCode(), resp.body()); } catch (Exception e) { log.error( "[Auth] Login failed: agent={}, url={}, error={}", @@ -241,6 +260,25 @@ private String login(Map schemeCfg) { } } + private String parseTokenResponse(String tokenField, int status, String responseBody) + throws Exception { + if (status >= 400) { + log.error("[Auth] Login failed: agent={}, status={}", agentName, status); + return null; + } + Map data = + mapper.readValue(responseBody, new TypeReference>() {}); + String token = extractNestedValue(data, tokenField); + if (token == null) { + token = + (String) + data.getOrDefault( + "accessSession", + data.getOrDefault("access_token", data.get("token"))); + } + return token; + } + private static String encodeForm(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientEventMapper.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientEventMapper.java new file mode 100644 index 00000000..39997073 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientEventMapper.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; + +import java.util.HashMap; +import java.util.Map; + +/** Converts SDK events into the stable map exposed to integration callbacks. */ +public final class ClientEventMapper { + private ClientEventMapper() {} + + public static Map toMap(ClientEvent event, String agentName) { + Map data = new HashMap<>(); + data.put("agent", agentName); + if (event instanceof TaskEvent te) { + data.put("event_kind", "task"); + data.put("task_id", te.getTask().id()); + data.put("state", te.getTask().status().state().name()); + data.put("is_final", te.getTask().status().state().isFinal()); + StringBuilder text = new StringBuilder(); + A2ATransport.extractTextFromTask(te.getTask(), text); + if (!text.isEmpty()) data.put("text", text.toString()); + Map metadata = new HashMap<>(); + A2ATransport.mergeTaskMetadata(te.getTask(), metadata); + if (!metadata.isEmpty()) data.put("metadata", metadata); + } else if (event instanceof TaskUpdateEvent tue) { + data.put("task_id", tue.getTask().id()); + if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { + data.put("event_kind", "status"); + data.put("state", sue.status().state().name()); + data.put("is_final", sue.isFinal()); + StringBuilder text = new StringBuilder(); + A2ATransport.extractTextFromMessage(sue.status().message(), text); + if (!text.isEmpty()) data.put("text", text.toString()); + if (sue.metadata() != null && !sue.metadata().isEmpty()) { + data.put("metadata", sue.metadata()); + } + } else if (tue.getUpdateEvent() instanceof TaskArtifactUpdateEvent ae) { + data.put("event_kind", "artifact"); + data.put("artifact_id", ae.artifact().artifactId()); + data.put("artifact_name", ae.artifact().name()); + data.put("append", ae.append()); + data.put("last_chunk", ae.lastChunk()); + StringBuilder text = new StringBuilder(); + A2ATransport.extractTextFromArtifact(ae.artifact(), text); + if (!text.isEmpty()) data.put("text", text.toString()); + Map metadata = ae.artifact().metadata(); + if (metadata != null && !metadata.isEmpty()) { + data.put("metadata", metadata); + } else if (ae.metadata() != null && !ae.metadata().isEmpty()) { + data.put("metadata", ae.metadata()); + } + } + } else if (event instanceof MessageEvent me) { + data.put("event_kind", "message"); + data.put("role", me.getMessage().role().name()); + StringBuilder text = new StringBuilder(); + A2ATransport.extractTextFromMessage(me.getMessage(), text); + if (!text.isEmpty()) data.put("text", text.toString()); + if (me.getMessage().metadata() != null && !me.getMessage().metadata().isEmpty()) { + data.put("metadata", me.getMessage().metadata()); + } + } + return data; + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialConfigLoader.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialConfigLoader.java new file mode 100644 index 00000000..cc5f16dd --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialConfigLoader.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.File; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Loads and resolves reusable per-agent authentication profiles. */ +final class CredentialConfigLoader { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference> ROOT_TYPE = new TypeReference<>() {}; + private static final TypeReference>>> RESOLVED_TYPE = + new TypeReference<>() {}; + + private CredentialConfigLoader() {} + + /** + * Loads either the original agent-keyed form or the profile form: {@code {"profiles": {...}, + * "agents": {"agent": {"profile": "name"}}}}. + */ + static Map>> load(String path) { + if (path == null || path.isBlank()) { + return Map.of(); + } + try { + Map root = readRoot(path); + if (root.containsKey("profiles") || root.containsKey("agents")) { + return resolveProfiles(root); + } + return MAPPER.convertValue(root, RESOLVED_TYPE); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to load credentials from " + path + ": " + e.getMessage(), e); + } + } + + private static Map readRoot(String path) throws Exception { + if (path.startsWith("classpath:")) { + String resource = path.substring("classpath:".length()); + try (InputStream input = + CredentialConfigLoader.class.getClassLoader().getResourceAsStream(resource)) { + if (input == null) { + throw new IllegalStateException("Credentials classpath resource not found: " + resource); + } + return MAPPER.readValue(input, ROOT_TYPE); + } + } + File file = new File(path); + if (!file.isFile()) { + throw new IllegalStateException("Credentials file not found: " + path); + } + return MAPPER.readValue(file, ROOT_TYPE); + } + + private static Map>> resolveProfiles( + Map root) { + Map>> profiles = + convertProfiles(requiredMap(root.get("profiles"), "profiles")); + Map agents = requiredMap(root.get("agents"), "agents"); + Map>> resolved = new LinkedHashMap<>(); + for (Map.Entry entry : agents.entrySet()) { + Map binding = requiredMap(entry.getValue(), "agents." + entry.getKey()); + String profileName = requiredText(binding.get("profile"), "profile for " + entry.getKey()); + Map> profile = profiles.get(profileName); + if (profile == null) { + throw new IllegalArgumentException( + "Unknown credential profile '" + profileName + "' for agent " + entry.getKey()); + } + Map> agentConfig = deepCopy(profile); + Object overridesValue = binding.get("overrides"); + if (overridesValue != null) { + Map overrides = + requiredMap(overridesValue, "agents." + entry.getKey() + ".overrides"); + for (Map.Entry override : overrides.entrySet()) { + Map schemeConfig = + agentConfig.computeIfAbsent(override.getKey(), ignored -> new LinkedHashMap<>()); + mergeInto( + schemeConfig, + requiredMap( + override.getValue(), + "agents." + entry.getKey() + ".overrides." + override.getKey())); + } + } + resolved.put(entry.getKey(), agentConfig); + } + return resolved; + } + + private static Map>> convertProfiles( + Map rawProfiles) { + Map>> result = new LinkedHashMap<>(); + for (Map.Entry entry : rawProfiles.entrySet()) { + Map rawSchemes = requiredMap(entry.getValue(), "profiles." + entry.getKey()); + Map> schemes = new LinkedHashMap<>(); + for (Map.Entry scheme : rawSchemes.entrySet()) { + schemes.put( + scheme.getKey(), + new LinkedHashMap<>( + requiredMap( + scheme.getValue(), "profiles." + entry.getKey() + "." + scheme.getKey()))); + } + result.put(entry.getKey(), schemes); + } + return result; + } + + private static Map> deepCopy( + Map> source) { + Map> copy = new LinkedHashMap<>(); + source.forEach((name, config) -> copy.put(name, deepCopyMap(config))); + return copy; + } + + private static void mergeInto(Map target, Map overrides) { + overrides.forEach( + (key, value) -> { + Object existing = target.get(key); + if (existing instanceof Map existingMap && value instanceof Map overrideMap) { + Map merged = deepCopyMap(requiredMap(existingMap, key)); + mergeInto(merged, requiredMap(overrideMap, key)); + target.put(key, merged); + } else { + target.put(key, deepCopyValue(value)); + } + }); + } + + private static Map deepCopyMap(Map source) { + Map copy = new LinkedHashMap<>(); + source.forEach((key, value) -> copy.put(key, deepCopyValue(value))); + return copy; + } + + private static Object deepCopyValue(Object value) { + if (value instanceof Map map) { + return deepCopyMap(requiredMap(map, "nested value")); + } + return value; + } + + @SuppressWarnings("unchecked") + private static Map requiredMap(Object value, String name) { + if (!(value instanceof Map map)) { + throw new IllegalArgumentException(name + " must be an object"); + } + return (Map) map; + } + + private static String requiredText(Object value, String name) { + if (value == null || String.valueOf(value).isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return String.valueOf(value).trim(); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialCrypto.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialCrypto.java index 3f1e02a3..6715f126 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialCrypto.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialCrypto.java @@ -93,7 +93,9 @@ public static void main(String[] args) { System.err.println("Error: " + e.getMessage()); System.exit(1); } - }/** + } + + /** * Decrypt a credential value if it has the {@code enc:} prefix. Values without the prefix are * returned as-is (plaintext fallback). * @@ -102,10 +104,15 @@ public static void main(String[] args) { * @throws IllegalStateException when an encrypted value cannot be decrypted */ public static String decryptIfNeeded(String value) { + return decryptIfNeeded(value, null); + } + + /** Decrypts with an optional instance-scoped key read from a configured SDK env file. */ + static String decryptIfNeeded(String value, String configuredKeyHex) { if (value == null || !value.startsWith(PREFIX)) { return value; } - String keyHex = resolveKey(); + String keyHex = resolveKey(configuredKeyHex); if (keyHex == null || keyHex.isBlank()) { throw new IllegalStateException( "Encrypted credential found but " @@ -174,17 +181,29 @@ public static String encrypt(String plaintext) { * @return the hex key string, or null if not found */ private static String resolveKey() { + return resolveKey(null); + } + + private static String resolveKey(String configuredKeyHex) { + if (configuredKeyHex != null && !configuredKeyHex.isBlank()) { + return configuredKeyHex; + } String key = System.getenv(ENV_KEY); if (key != null && !key.isBlank()) { return key; } - return System.getProperty(ENV_KEY); + key = System.getProperty(ENV_KEY); + if (key != null && !key.isBlank()) { + return key; + } + return null; } private static byte[] hexToBytes(String hex) { int len = hex.length(); if (len != 64) { - throw new IllegalArgumentException("A2AT_CRED_KEY must contain 64 hexadecimal characters"); + throw new IllegalArgumentException( + "A2AT_CRED_KEY must contain 64 hexadecimal characters"); } byte[] data = new byte[len / 2]; for (int i = 0; i < len; i += 2) { @@ -194,8 +213,7 @@ private static byte[] hexToBytes(String hex) { throw new IllegalArgumentException( "A2AT_CRED_KEY must contain hexadecimal characters only"); } - data[i / 2] = - (byte) ((high << 4) + low); + data[i / 2] = (byte) ((high << 4) + low); } return data; } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHttpTransport.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHttpTransport.java new file mode 100644 index 00000000..1e5f4616 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHttpTransport.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** Synchronous, conversation-free HTTP boundary used by credential login requests. */ +final class CredentialHttpTransport { + private final SSLContext sslContext; + private final int timeoutMillis; + + private CredentialHttpTransport(SSLContext sslContext, Duration timeout) { + this.sslContext = sslContext; + this.timeoutMillis = Math.toIntExact(timeout.toMillis()); + } + + static CredentialHttpTransport create( + boolean sslVerify, + String caCertsPath, + String clientCertPath, + String clientKeyPath, + String clientKeyPassword, + String crlPath, + Duration timeout) { + SSLContext context = + SslContextFactory.create( + sslVerify, + caCertsPath, + clientCertPath, + clientKeyPath, + clientKeyPassword, + crlPath) + .orElse(null); + return new CredentialHttpTransport(context, timeout); + } + + Response send(URI uri, String method, String contentType, String body) throws Exception { + HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection(); + if (sslContext != null && connection instanceof HttpsURLConnection https) { + https.setSSLSocketFactory(sslContext.getSocketFactory()); + } + try { + connection.setConnectTimeout(timeoutMillis); + connection.setReadTimeout(timeoutMillis); + connection.setInstanceFollowRedirects(true); + connection.setRequestMethod(method); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", contentType); + try (OutputStream output = connection.getOutputStream()) { + output.write(body.getBytes(StandardCharsets.UTF_8)); + } + int status = connection.getResponseCode(); + InputStream input = + status >= 400 ? connection.getErrorStream() : connection.getInputStream(); + String responseBody; + if (input == null) { + responseBody = ""; + } else { + try (input) { + responseBody = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + return new Response(status, responseBody); + } finally { + connection.disconnect(); + } + } + + record Response(int statusCode, String body) {} +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java index 513a83bb..635d0e66 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java @@ -22,6 +22,11 @@ import io.grpc.ManagedChannelBuilder; import org.a2aproject.sdk.client.Client; +import dev.openan.workflow.engine.model.SendMessageResult; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.CancelTaskParams; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; import org.a2aproject.sdk.client.TaskEvent; @@ -73,7 +78,8 @@ * for clients created by this runtime. Hostname verification remains enabled and no JVM-wide TLS * property is changed. */ -public class DefaultA2AJavaClientRuntime implements A2AJavaClientRuntime { +public class DefaultA2AJavaClientRuntime + implements A2AJavaClientRuntime, ConversationScopedA2AJavaClientRuntime { private static final Logger log = LoggerFactory.getLogger(DefaultA2AJavaClientRuntime.class); @@ -87,6 +93,8 @@ public class DefaultA2AJavaClientRuntime implements A2AJavaClientRuntime { private final String preferredProtocol; private final java.util.concurrent.ExecutorService httpClientExecutor; private final Map clientCache = new ConcurrentHashMap<>(); + /** Notification-T streams need a client that can be closed without disrupting task traffic. */ + private final Map streamClients = new ConcurrentHashMap<>(); private final AtomicBoolean closed = new AtomicBoolean(); /** @@ -315,7 +323,12 @@ public Iterable sendMessage( Consumer logSink) { if (closed.get()) throw new IllegalStateException("A2A client runtime is closed"); String agentUrl = extractAgentUrl(agentCard); - Client client = getOrCreateClient(agentCard, agentUrl); + Client client = + isNotificationStream(params) + ? getOrCreateStreamClient( + agentCard, + params.message() != null ? params.message().contextId() : null) + : getOrCreateClient(agentCard, agentUrl); List events = Collections.synchronizedList(new ArrayList<>()); CountDownLatch done = new CountDownLatch(1); AtomicReference errorRef = new AtomicReference<>(); @@ -394,6 +407,57 @@ private Client getOrCreateClient(AgentCard agentCard, String agentUrl) { }); } + private Client getOrCreateStreamClient(AgentCard agentCard, String contextId) { + if (contextId == null || contextId.isBlank()) { + throw new IllegalArgumentException("Notification-T contextId must not be blank"); + } + StreamClientKey key = new StreamClientKey(agentCard.name(), contextId); + return streamClients.computeIfAbsent( + key, + ignored -> { + AgentInterface selected = selectInterface(agentCard); + try { + Client client = + buildClientWithTransport( + agentCard, selected.protocolBinding(), createHttpClient()); + log.info( + "[A2ARuntime] Created conversation-scoped Notification-T transport for '{}' (contextId={})", + agentCard.name(), + contextId); + return client; + } catch (A2AClientException e) { + throw new IllegalStateException( + "Failed to create Notification-T client for " + + agentCard.name() + + " context " + + contextId, + e); + } + }); + } + + private static boolean isNotificationStream( + org.a2aproject.sdk.spec.MessageSendParams params) { + return params.message() != null + && params.message().metadata() != null + && params.message().metadata().containsKey(A2ATExtension.NOTIFICATION_T.uri()); + } + + @Override + public void closeConversation(AgentCard agentCard, String contextId) { + if (agentCard == null || contextId == null || contextId.isBlank()) { + return; + } + Client client = streamClients.remove(new StreamClientKey(agentCard.name(), contextId)); + if (client != null) { + client.close(); + log.info( + "[A2ARuntime] Closed conversation-scoped Notification-T transport for '{}' (contextId={})", + agentCard.name(), + contextId); + } + } + /** Select the best AgentInterface based on preferredProtocol or first available. */ private AgentInterface selectInterface(AgentCard agentCard) { List interfaces = agentCard.supportedInterfaces(); @@ -465,6 +529,77 @@ private void awaitCompletion( } } + @Override + public Task getTask(AgentCard agentCard, String taskId, ClientCallContext callContext) { + if (closed.get()) throw new IllegalStateException("A2A client runtime is closed"); + Client client = getOrCreateClient(agentCard, extractAgentUrl(agentCard)); + try { + return client.getTask(new TaskQueryParams(taskId), callContext); + } catch (A2AClientException e) { + throw new RuntimeException("A2A getTask failed for " + agentCard.name() + ": " + e.getMessage(), e); + } + } + + @Override + public Task cancelTask(AgentCard agentCard, String taskId, ClientCallContext callContext) { + if (closed.get()) throw new IllegalStateException("A2A client runtime is closed"); + Client client = getOrCreateClient(agentCard, extractAgentUrl(agentCard)); + try { + return client.cancelTask(CancelTaskParams.builder().id(taskId).build(), callContext); + } catch (A2AClientException e) { + throw new RuntimeException("A2A cancelTask failed for " + agentCard.name() + ": " + e.getMessage(), e); + } + } + + @Override + public java.util.concurrent.CompletableFuture subscribeToTask( + AgentCard agentCard, + String taskId, + ClientCallContext callContext, + java.util.function.Consumer eventSink) { + if (closed.get()) { + return java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("A2A client runtime is closed")); + } + java.util.concurrent.CompletableFuture future = new java.util.concurrent.CompletableFuture<>(); + Client client = getOrCreateClient(agentCard, extractAgentUrl(agentCard)); + List events = Collections.synchronizedList(new ArrayList<>()); + try { + client.subscribeToTask( + new TaskIdParams(taskId), + List.of((event, card) -> { + events.add(event); + if (eventSink != null) { + try { eventSink.accept(event); } catch (Exception ignored) {} + } + if (isTerminal(event) && !future.isDone()) { + List snapshot; + synchronized (events) { + snapshot = List.copyOf(events); + } + future.complete(SendMessageResult.builder() + .text(A2ATransport.extractResponseText(snapshot)) + .task(A2ATransport.extractResponseTask(snapshot)) + .taskState(A2ATransport.extractResponseTaskState(snapshot)) + .metadata(A2ATransport.extractResponseMetadata(snapshot)) + .receivedMessages(ProtocolResponses.assemble(snapshot)) + .build()); + } + }), + error -> future.completeExceptionally( + new RuntimeException( + "A2A subscribeToTask stream failed for " + + agentCard.name() + + ": " + + error.getMessage(), + error)), + callContext); + } catch (A2AClientException e) { + return java.util.concurrent.CompletableFuture.failedFuture( + new RuntimeException("A2A subscribeToTask failed for " + agentCard.name() + ": " + e.getMessage(), e)); + } + return future; + } + @Override public void close() { if (!closed.compareAndSet(false, true)) return; @@ -477,10 +612,23 @@ public void close() { } }); clientCache.clear(); + streamClients.values().forEach( + client -> { + try { + client.close(); + } catch (RuntimeException e) { + log.warn( + "[A2ARuntime] Failed to close Notification-T client: {}", + e.getMessage()); + } + }); + streamClients.clear(); httpClientExecutor.shutdownNow(); log.info("[A2ARuntime] Closed cached clients and HTTP executor"); } + private record StreamClientKey(String agentName, String contextId) {} + /** * Create a gRPC channel with SSL settings matching the engine config. * @@ -498,7 +646,7 @@ private io.grpc.Channel createGrpcChannel(String url) { throw new IllegalArgumentException( "sslVerify=false uses plaintext gRPC and cannot check a CRL"); } - return ManagedChannelBuilder.forTarget(url).usePlaintext().build(); + return ManagedChannelBuilder.forTarget(url).usePlaintext().intercept(new WireGrpcInterceptor()).build(); } if (crlPath != null && !crlPath.isBlank()) { throw new IllegalArgumentException( @@ -526,7 +674,7 @@ private io.grpc.Channel createGrpcChannel(String url) { clientKeyPassword); } } - return io.grpc.Grpc.newChannelBuilder(url, credentials.build()).build(); + return io.grpc.Grpc.newChannelBuilder(url, credentials.build()).intercept(new WireGrpcInterceptor()).build(); } catch (java.io.IOException e) { throw new IllegalStateException("Failed to configure gRPC TLS", e); } @@ -543,7 +691,7 @@ private A2AHttpClient createHttpClient() { crlPath, Duration.ofSeconds(60), httpClientExecutor); - return new JdkA2AHttpClient(httpClient); + return new JdkA2AHttpClient(new ObservedHttpClient(httpClient)); } private record ClientCacheKey( diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultExtensionSender.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultExtensionSender.java index 1212f5ae..f676f3af 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultExtensionSender.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultExtensionSender.java @@ -19,254 +19,55 @@ package dev.openan.workflow.engine.client; +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.ReceivedMessage; import dev.openan.workflow.engine.model.SendMessageResult; - -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.model.PromptGenerationFailure; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; - import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.a2aproject.sdk.client.ClientEvent; -import org.a2aproject.sdk.client.MessageEvent; -import org.a2aproject.sdk.client.TaskUpdateEvent; -import org.a2aproject.sdk.spec.Message; -import org.a2aproject.sdk.spec.Part; -import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; -import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; -import org.a2aproject.sdk.spec.TextPart; - -import java.util.HashMap; -import java.util.Map; +import java.util.Objects; +import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.function.Consumer; - -/** - * Default {@link ExtensionSender} built on a shared {@link A2ATransport}. - * - *

Owns extension prompt-generation dispatch (Task-T via the A2A-T SDK; Negotiation-T / - * Authorization-T / Notification-T reserved for future SDK support). All wire-level work delegates - * to the transport. - */ -public record DefaultExtensionSender(A2ATransport transport) - implements ExtensionSender, AutoCloseable { - - private static final Logger log = LoggerFactory.getLogger(DefaultExtensionSender.class); +import java.util.function.BiConsumer; - @Override - public CompletableFuture sendExtensionMessage( - String agentName, - String instruction, - String naturalLanguageInput, - A2ATExtension extension) { - AgentCard agentCard = transport.getCard(agentName); - if (agentCard == null) { - log.error("[ExtensionSender] Agent not found: {}", agentName); - return CompletableFuture.failedFuture( - new RuntimeException("Agent not found: " + agentName)); - } - return CompletableFuture.supplyAsync( - () -> generateExtensionPrompt(extension, naturalLanguageInput)) - .thenCompose( - metadataValue -> { - String value = metadataValue; - if (value == null || value.isEmpty()) { - value = naturalLanguageInput; - log.info( - "[ExtensionSender] SDK prompt generation unavailable for {} ({}), using input as metadata", - agentName, - extension.displayName()); - } - log.info( - "[ExtensionSender] sendExtensionMessage to {}: extension={}, metadataValue={} chars", - agentName, - extension.displayName(), - value.length()); - Map metadata = Map.of(extension.uri(), value); - if (extension == A2ATExtension.NOTIFICATION_T) { - return transport.sendNotificationStream( - agentCard, - agentName, - instruction, - transport.getContextId(), - metadata, - null); - } - CompletableFuture send = - transport - .send( - agentCard, - agentName, - instruction, - transport.getContextId(), - metadata, - null) - .thenApply( - result -> { - log.info( - "[ExtensionSender] Extension response from {}: state={}", - agentName, - result.getTaskState()); - return result; - }); - return send.whenComplete( - (ignored, error) -> - transport.closeConversation( - agentCard, transport.getContextId())); - }); +/** Final-content sender on a caller-owned transport, independent of the workflow client. */ +public record DefaultExtensionSender(A2ATransport transport) implements ExtensionSender, AutoCloseable { + public DefaultExtensionSender { + Objects.requireNonNull(transport, "transport"); } @Override - public CompletableFuture sendNotification( - String agentName, - String instruction, - String naturalLanguageInput, - Consumer> eventCallback) { - AgentCard agentCard = transport.getCard(agentName); - if (agentCard == null) { - log.error("[ExtensionSender] Agent not found: {}", agentName); - return CompletableFuture.failedFuture( - new RuntimeException("Agent not found: " + agentName)); - } - return CompletableFuture.supplyAsync( - () -> - generateExtensionPrompt( - A2ATExtension.NOTIFICATION_T, naturalLanguageInput)) - .thenCompose( - metadataValue -> { - String value = metadataValue; - if (value == null || value.isEmpty()) { - value = naturalLanguageInput; - } - Map metadata = - Map.of(A2ATExtension.NOTIFICATION_T.uri(), value); - Consumer eventSink = - eventCallback != null - ? event -> - forwardNotificationEvent( - event, agentName, eventCallback) - : null; - log.info( - "[ExtensionSender] sendNotification to {}: metadataValue={} chars, callback={}", - agentName, - value.length(), - eventCallback != null); - return transport.sendNotificationStream( - agentCard, - agentName, - instruction, - transport.getContextId(), - metadata, - eventSink); - }); - } - - private void forwardNotificationEvent( - ClientEvent event, String agentName, Consumer> callback) { - Map data = new HashMap<>(); - data.put("agent", agentName); - if (event instanceof TaskUpdateEvent tue) { - if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { - data.put("state", sue.status().state().name()); - data.put("is_final", sue.isFinal()); - StringBuilder text = new StringBuilder(); - A2ATransport.extractTextFromMessage(sue.status().message(), text); - if (!text.isEmpty()) data.put("text", text.toString()); - if (sue.metadata() != null && !sue.metadata().isEmpty()) - data.put("metadata", sue.metadata()); - } else if (tue.getUpdateEvent() instanceof TaskArtifactUpdateEvent ae) { - StringBuilder text = new StringBuilder(); - for (Part part : ae.artifact().parts()) { - if (part instanceof TextPart tp) text.append(tp.text()); - } - data.put("artifact_name", ae.artifact().name()); - data.put("append", ae.append()); - if (!text.isEmpty()) data.put("text", text.toString()); - if (ae.metadata() != null && !ae.metadata().isEmpty()) - data.put("metadata", ae.metadata()); - } - } else if (event instanceof MessageEvent me) { - Message msg = me.getMessage(); - StringBuilder text = new StringBuilder(); - A2ATransport.extractTextFromMessage(msg, text); - data.put("role", msg.role().name()); - if (!text.isEmpty()) data.put("text", text.toString()); - if (msg.metadata() != null && !msg.metadata().isEmpty()) - data.put("metadata", msg.metadata()); - } - if (!data.isEmpty()) { - log.info("[ExtensionSender] Notification-T callback for {}: {} keys", agentName, data.keySet()); - callback.accept(data); + public CompletableFuture sendAuthorization(String agentName, MessageContent content) { + try { + AgentCard card = requireExtension(agentName, content, A2ATExtension.AUTHORIZATION_T); + String contextId = UUID.randomUUID().toString(); + return transport.send(card, agentName, content, contextId, null, null) + .whenComplete((result, error) -> transport.closeConversation(card, contextId)); + } catch (RuntimeException error) { + return CompletableFuture.failedFuture(error); } } - // ------------------------------------------------------------------ - // Extension prompt generation dispatch - // ------------------------------------------------------------------ - - String generateExtensionPrompt(A2ATExtension extension, String naturalLanguageInput) { - if (extension == A2ATExtension.TASK_T) { - return generateTaskPromptText(naturalLanguageInput); - } - if (extension == A2ATExtension.NEGOTIATION_T) { - return generateNegotiationPrompt(naturalLanguageInput); - } - if (extension == A2ATExtension.AUTHORIZATION_T) { - return generateAuthorizationPrompt(naturalLanguageInput); - } - if (extension == A2ATExtension.NOTIFICATION_T) { - return generateNotificationPrompt(naturalLanguageInput); - } - return null; + @Override + public NotificationSubscription openNotification(String agentName, MessageContent content, + BiConsumer listener) { + AgentCard card = requireExtension(agentName, content, A2ATExtension.NOTIFICATION_T); + return transport.openNotificationStream(card, agentName, content, UUID.randomUUID().toString(), + Objects.requireNonNull(listener, "listener")); } - private String generateTaskPromptText(String naturalLanguageInput) { - A2ATClient a2atClient = transport.getA2atClient(); - if (a2atClient == null) { - return null; - } - try { - PromptGenerationResult result = a2atClient.generateTaskPrompt(naturalLanguageInput); - if (result.success()) { - return result.promptText(); - } - PromptGenerationFailure f = result.failure(); - log.warn( - "[ExtensionSender] SDK Task-T prompt generation failed: code={}, stage={}, message={}", - f != null ? f.code() : "unknown", - f != null ? f.stage() : "unknown", - f != null ? f.message() : "unknown"); - } catch (Exception e) { - log.warn("[ExtensionSender] SDK Task-T prompt generation error: {}", e.getMessage()); - } - return null; - } - // TODO - private String generateNegotiationPrompt(String naturalLanguageInput) { - if (transport.getA2atClient() == null) { - return null; - } - return null; - } - // TODO - private String generateAuthorizationPrompt(String naturalLanguageInput) { - if (transport.getA2atClient() == null) { - return null; - } - return null; - } - // TODO - private String generateNotificationPrompt(String naturalLanguageInput) { - if (transport.getA2atClient() == null) { - return null; + private AgentCard requireExtension(String agentName, MessageContent content, A2ATExtension extension) { + Objects.requireNonNull(content, "content"); + AgentCard card = transport.getCard(agentName); + if (card == null) throw new IllegalArgumentException("Agent not found: " + agentName); + if (!A2ATransport.extractExtensionUris(card).contains(extension.uri()) + || !content.extensions().contains(extension.uri()) + || !content.metadata().containsKey(extension.uri())) { + throw new IllegalArgumentException("Target capability and content must use " + extension.uri()); } - return null; + return card; } @Override public void close() { - // Transport is owned by the caller; do not close it here. + // Caller owns the independent transport and its subscriptions. } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java index f410b2b9..ab1a1d92 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java @@ -22,13 +22,9 @@ import dev.openan.workflow.engine.control.ControlPoint; import dev.openan.workflow.engine.control.EventCallback; import dev.openan.workflow.engine.control.EventType; -import dev.openan.workflow.engine.model.SendMessageResult; - -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.negotiation.runtime.helper.NegotiationPayloadMapper; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; - +import dev.openan.workflow.engine.model.*; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; import org.a2aproject.sdk.client.TaskUpdateEvent; @@ -38,323 +34,272 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.time.Duration; +import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; -/** - * Workflow-execution send facade built on a shared {@link A2ATransport}. - * - *

Single responsibility: the workflow execution send path. Owns the Task-T/Negotiation-T - * extension handler chain, the Negotiation-T auto-loop, the global EventCallback, and the - * ControlPoint wiring. All wire-level work (client runtime, auth, SSE event extraction) delegates - * to the transport. - * - *

One-shot pre-positioning sends (Authorization-T / Notification-T) are a separate concern and - * live on {@link DefaultExtensionSender}. - */ +/** Coordinates A2A task interaction; all content generation and semantic validation belong to the host. */ public class DefaultWorkflowEngineClient implements WorkflowEngineClient, AutoCloseable { - private static final Logger log = LoggerFactory.getLogger(DefaultWorkflowEngineClient.class); - private final A2ATransport transport; - private final ExtensionRegistry extensionRegistry; - private final int maxNegotiationRounds; + private final int maxNegotiationExchanges; + private final boolean closeTransportOnClose; + private final AtomicBoolean closed = new AtomicBoolean(); + private final Set invocations = java.util.concurrent.ConcurrentHashMap.newKeySet(); private EventCallback eventCallback = new EventCallback(); private ControlPoint controlPoint; - public DefaultWorkflowEngineClient( - A2ATransport transport, - int maxNegotiationRounds, - List customHandlers) { - this.transport = transport; - this.extensionRegistry = new ExtensionRegistry(); - if (customHandlers != null) { - for (ExtensionHandler h : customHandlers) { - extensionRegistry.register(h); - } - } - this.maxNegotiationRounds = maxNegotiationRounds; - log.info( - "[EngineClient] Initialized over transport ({} agent(s)), maxNeg={}", - transport.getAgentNames().size(), - maxNegotiationRounds); + /** Creates a caller-owned transport facade. */ + public DefaultWorkflowEngineClient(A2ATransport transport) { + this(transport, 3, false); } - public DefaultWorkflowEngineClient(A2ATransport transport) { - this(transport, 3, null); + /** Uses the configured resource exchange budget, not an SDK protocol round counter. */ + public DefaultWorkflowEngineClient(A2ATransport transport, WorkflowEngineClientConfig config) { + this(transport, config.getMaxNegotiationExchanges(), false); } - // ------------------------------------------------------------------ - // Wiring - // ------------------------------------------------------------------ + private DefaultWorkflowEngineClient(A2ATransport transport, int maxExchanges, boolean owning) { + this.transport = Objects.requireNonNull(transport, "transport"); + if (maxExchanges < 1) throw new IllegalArgumentException("Negotiation exchange budget must be positive"); + this.maxNegotiationExchanges = maxExchanges; + this.closeTransportOnClose = owning; + } - private static boolean isNegotiationNeeded(SendMessageResult result) { - return result.getTaskState() != null && result.getTaskState().contains("INPUT_REQUIRED"); + /** Creates a facade owning the supplied transport. */ + public static DefaultWorkflowEngineClient owning(A2ATransport transport) { + return new DefaultWorkflowEngineClient(transport, 3, true); } - @Override - public void setControlPoint(ControlPoint controlPoint) { - this.controlPoint = controlPoint; + /** Creates an owning facade with explicit resource settings. */ + public static DefaultWorkflowEngineClient owning(A2ATransport transport, WorkflowEngineClientConfig config) { + return new DefaultWorkflowEngineClient(transport, config.getMaxNegotiationExchanges(), true); } + @Override + public void setControlPoint(ControlPoint callbacks) { this.controlPoint = callbacks; } + @Override public void setEventCallback(EventCallback callback) { - this.eventCallback = callback != null ? callback : new EventCallback(); + this.eventCallback = callback == null ? new EventCallback() : callback; } - // ------------------------------------------------------------------ - // Workflow send path - // ------------------------------------------------------------------ - private void emit(String type, Map data) { - eventCallback.onEvent(type, data); - } + @Override + public long callbackTimeoutSeconds() { return transport.sendTimeoutSeconds(); } - // ------------------------------------------------------------------ - // Auto-negotiation - // ------------------------------------------------------------------ @Override - public CompletableFuture sendMessage( - String agentName, String message, String contextId, Map metadata) { - AgentCard agentCard = transport.getCard(agentName); - if (agentCard == null) { - log.error("[EngineClient] Agent not found: {}", agentName); - return CompletableFuture.failedFuture( - new RuntimeException("Agent not found: " + agentName)); - } - log.info("[EngineClient] send_message to {}: {} chars", agentName, message.length()); - String effectiveContextId = contextId != null ? contextId : transport.getContextId(); - return runBeforeSendHandlers(agentCard, message, metadata) - .thenCompose( - processedMetadata -> { - emit( - EventType.AGENT_REQUEST, - Map.of( - "agent", agentName, - "request", message, - "metadata", - processedMetadata != null - ? processedMetadata - : Map.of())); - return transport - .send( - agentCard, - agentName, - message, - effectiveContextId, - processedMetadata, - event -> forwardIntermediateEvent(event, agentName)) - .thenCompose( - result -> runAfterReceiveHandlers(agentCard, result)) - .thenCompose( - result -> - autoNegotiate( - agentCard, - agentName, - message, - effectiveContextId, - result, 1)); - }) - .whenComplete( - (ignored, error) -> - transport.closeConversation(agentCard, effectiveContextId)); + public CompletableFuture sendMessage(String agentName, MessageContent content) { + TaskRequest request = TaskRequest.builder().agentName(agentName) + .executionId(UUID.randomUUID().toString()).taskId(UUID.randomUUID().toString()).build(); + return dispatch(request, content, controlPoint); } - private CompletableFuture autoNegotiate( - AgentCard agentCard, - String agentName, - String originalMessage, - String contextId, - SendMessageResult result, - int round) { - if (!isNegotiationNeeded(result) || round > maxNegotiationRounds) { - emit( - EventType.AGENT_RESPONSE, - Map.of("agent", agentName, "response", result.getText(), "metadata", result.getMetadata() != null ? result.getMetadata() : Map.of())); - return CompletableFuture.completedFuture(result); + private final class Invocation { + final TaskRequest task; + final MessageContent original; + final ControlPoint callbacks; + final String contextId = UUID.randomUUID().toString(); + final String attempt = UUID.randomUUID().toString(); + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(callbackTimeoutSeconds()); + final CompletableFuture completion = new CompletableFuture<>(); + final Map> history = new HashMap<>(); + final Set answered = new HashSet<>(); + final Map contexts = new HashMap<>(); + String remoteTaskId; + int exchanges; + + Invocation(TaskRequest task, MessageContent content, ControlPoint callbacks) { + this.task = task; + this.original = content; + this.callbacks = callbacks; } - Map negMeta = - result.getMetadata() != null ? result.getMetadata() : new HashMap<>(); - String negText = negMeta.getOrDefault("negotiation_message", - negMeta.getOrDefault("negotiationConcern", "")).toString(); - log.info("[Negotiation] Round {} for '{}': {}", round, agentName, negText); - emit( - EventType.NEGOTIATION_REQUEST, - Map.of("agent", agentName, "round", round, "concern", negText)); - CompletableFuture clarFuture; - if (controlPoint != null) { - clarFuture = controlPoint.onNegotiation(agentName, negText, negMeta); - } else { - clarFuture = - CompletableFuture.completedFuture( - "Please proceed with the original task using available information."); + + Duration remaining() { + return Duration.ofNanos(Math.max(0, deadline - System.nanoTime())); } - return clarFuture.thenCompose( - clarification -> { - if (clarification == null || clarification.isEmpty()) { - emit( - EventType.NEGOTIATION_FAILED, - Map.of( - "agent", - agentName, - "round", - round, - "reason", - "no clarification")); - emit( - EventType.AGENT_RESPONSE, - Map.of("agent", agentName, "response", result.getText(), "metadata", result.getMetadata() != null ? result.getMetadata() : Map.of())); - return CompletableFuture.completedFuture(result); - } - log.info( - "[Negotiation] Clarification for '{}' round {}: {}", - agentName, - round, - clarification); - emit( - EventType.NEGOTIATION_RESOLVED, - Map.of( - "agent", - agentName, - "round", - round, - "clarification", - clarification)); - String followUp = - "[NEGOTIATION_RESOLUTION]\nThe engine has reviewed your negotiation request and provides the following clarification:\n\n" - + clarification - + "\n\n---\nOriginal Task:\n" - + originalMessage - + "\n\nPlease re-execute the task based on the clarification above."; - String taskId = result.getTask() != null ? result.getTask().id() : null; - return buildNegotiationFollowUpMeta(agentName, negMeta, clarification) - .thenCompose( - followUpMeta -> - runBeforeSendHandlers(agentCard, followUp, followUpMeta) - .thenCompose( - meta -> { - String ctx = - contextId != null - ? contextId - : transport.getContextId(); - return transport - .send( - agentCard, - agentName, - followUp, - ctx, - taskId, - meta, - event -> - forwardIntermediateEvent( - event, agentName)) - .thenCompose( - r -> runAfterReceiveHandlers(agentCard, r)) - .thenCompose( - r -> - autoNegotiate( - agentCard, - agentName, - originalMessage, - contextId, - r, - round + 1)); - })); + + boolean active() { return !closed.get() && !completion.isDone() && !remaining().isZero(); } + } + + @Override + public CompletableFuture dispatch( + TaskRequest request, MessageContent content, ControlPoint callbacks) { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(content, "onTask returned null content"); + AgentCard card = transport.getCard(request.getAgentName()); + if (closed.get() || card == null) return CompletableFuture.failedFuture( + new IllegalStateException(closed.get() ? "Workflow client closed" : "Agent not found: " + request.getAgentName())); + Invocation invocation = new Invocation(request, content, callbacks); + invocations.add(invocation); + invocation.completion.orTimeout(callbackTimeoutSeconds(), TimeUnit.SECONDS); + invocation.completion.whenComplete((result, error) -> { + invocations.remove(invocation); + transport.closeConversation(card, invocation.contextId); + if (error != null && invocation.exchanges > 0) emit(EventType.NEGOTIATION_FAILED, + Map.of("agent", request.getAgentName(), "exchange", invocation.exchanges, + "errorType", error.getClass().getSimpleName())); + }); + if (closed.get()) invocation.completion.cancel(true); + send(card, invocation, content, null) + .thenCompose(result -> advance(card, invocation, result)) + .whenComplete((result, error) -> { + if (error != null) invocation.completion.completeExceptionally(error); + else invocation.completion.complete(result); }); + return invocation.completion; + } + + private CompletableFuture send( + AgentCard card, Invocation invocation, MessageContent content, String taskId) { + if (!invocation.active()) return CompletableFuture.failedFuture( + new java.util.concurrent.CancellationException("Task interaction is no longer active")); + emit(EventType.AGENT_REQUEST, Map.of("agent", invocation.task.getAgentName(), "content", content)); + Map trace = invocationTrace(invocation); + return WireLog.call(trace, () -> transport.send(card, invocation.task.getAgentName(), content, + invocation.contextId, taskId, event -> forwardIntermediateEvent(event, invocation.task.getAgentName()), + invocation::active)); + } + + private Map invocationTrace(Invocation invocation) { + Map trace = new HashMap<>(); + if (invocation.task.getExecutionId() != null) trace.put("executionId", invocation.task.getExecutionId()); + if (invocation.task.getTaskId() != null) trace.put("logicalTaskId", invocation.task.getTaskId()); + trace.put("attempt", invocation.attempt); + trace.put("contextId", invocation.contextId); + return trace; } - private CompletableFuture> buildNegotiationFollowUpMeta( - String agentName, Map negMeta, String clarification) { - A2ATClient a2atClient = transport.getA2atClient(); - if (a2atClient == null) { - return CompletableFuture.completedFuture(buildFallbackMeta(clarification)); + private CompletableFuture advance( + AgentCard card, Invocation invocation, SendMessageResult result) { + if (!invocation.active()) return CompletableFuture.failedFuture( + new java.util.concurrent.CancellationException("Task interaction is no longer active")); + if (result.getTask() != null) { + if (result.getTask().id() == null || result.getTask().id().isBlank() + || !invocation.contextId.equals(result.getTask().contextId()) + || invocation.remoteTaskId != null && !invocation.remoteTaskId.equals(result.getTask().id())) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Remote task/context identity changed")); + } + invocation.remoteTaskId = result.getTask().id(); } - return CompletableFuture.supplyAsync( - () -> { - try { - Object ctxObj = negMeta.get("negotiation_context"); - if (ctxObj instanceof Map rr) { - Object innerCtx = rr.get("context"); - if (innerCtx instanceof Map contextMap) { - @SuppressWarnings("unchecked") - NegotiationContext context = - NegotiationPayloadMapper.contextFromMap( - (Map) contextMap); - Map payload = - a2atClient.continueNegotiation( - context, NegotiationStatus.AGREED, clarification); - log.info( - "[Negotiation] SDK continueNegotiation payload for '{}': round {} -> AGREED", - agentName, - context.round()); - return new HashMap<>(payload); - } - } - } catch (Exception e) { - log.warn( - "[Negotiation] continueNegotiation failed for '{}': {}; using fallback", - agentName, - e.getMessage()); - } - return buildFallbackMeta(clarification); - }); + if ("TASK_STATE_SUBMITTED".equals(result.getTaskState()) + || "TASK_STATE_WORKING".equals(result.getTaskState())) { + return observeTask(card, invocation).thenCompose(next -> advance(card, invocation, next)); + } + if (!"TASK_STATE_INPUT_REQUIRED".equals(result.getTaskState())) { + emitAgentResponse(invocation.task.getAgentName(), result); + return CompletableFuture.completedFuture(result); + } + if (result.getTask() == null || result.getTask().id() == null) { + return CompletableFuture.failedFuture(new IllegalArgumentException("INPUT_REQUIRED has no remote task identity")); + } + String remoteTask = result.getTask().id(); + ReceivedMessage received = negotiationResponse(result); + NegotiationContext context = A2atMessages.contextOf(received); + if (context.performative() != NegotiationPerformative.PROPOSE || context.isExhausted()) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Expected a valid Negotiation-T Propose")); + } + NegotiationContext previous = invocation.contexts.put(context.id(), context); + if (previous != null && (context.round() < previous.round() || context.maxRounds() != previous.maxRounds())) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Negotiation round regressed or maxRounds changed")); + } + String key = remoteTask + ":" + context.id() + ":" + context.round(); + if (!invocation.answered.add(key)) { + return observeTask(card, invocation).thenCompose(next -> advance(card, invocation, next)); + } + if (++invocation.exchanges > maxNegotiationExchanges) { + return CompletableFuture.failedFuture(new IllegalStateException("Negotiation exchange budget exhausted; no Abort generated")); + } + List history = + invocation.history.computeIfAbsent(context.id(), ignored -> new ArrayList<>()); + NegotiationRequest request = new NegotiationRequest(invocation.task, invocation.original, + received, history, invocation.remaining()); + emit(EventType.NEGOTIATION_REQUEST, Map.of("agent", invocation.task.getAgentName(), + "request", request, "exchange", invocation.exchanges)); + CompletableFuture answer; + try { + answer = Objects.requireNonNull( + Objects.requireNonNull(invocation.callbacks, "onNegotiation handler is required").onNegotiation(request), + "onNegotiation returned null future"); + } catch (RuntimeException error) { return CompletableFuture.failedFuture(error); } + return answer.thenCompose(reply -> { + Objects.requireNonNull(reply, "onNegotiation returned null reply"); + if (!invocation.active()) return CompletableFuture.failedFuture( + new java.util.concurrent.CancellationException("Late negotiation reply ignored")); + history.add(new NegotiationRequest.Exchange(received, reply)); + if (reply instanceof NegotiationReply.Stop stop) { + return CompletableFuture.failedFuture(new BusinessFailure(stop.code(), stop.reason(), Map.of())); + } + MessageContent content = ((NegotiationReply.Send) reply).content(); + NegotiationContext ending = validateReply(context, content); + emit(EventType.NEGOTIATION_RESOLVED, Map.of("agent", invocation.task.getAgentName(), + "exchange", invocation.exchanges, "reply", reply)); + return send(card, invocation, content, remoteTask).thenCompose(next -> { + if (ending.performative() == NegotiationPerformative.ABORT) { + // A remote completion/ACK after Abort is not successful diagnosis. + next.setFailureCode("negotiation.aborted"); + next.setFailureMessage("Business sent Abort; diagnosis was not completed successfully"); + emitAgentResponse(invocation.task.getAgentName(), next); + return CompletableFuture.completedFuture(next); + } + return advance(card, invocation, next); + }); + }); } - private static Map buildFallbackMeta(String clarification) { - Map meta = new HashMap<>(); - meta.put( - A2ATExtension.NEGOTIATION_T.uri(), - "## Data Return Confirmation\n" + clarification + "\n"); - return meta; + /** A send ACK is not a task result. Observe without resending the business command. */ + private CompletableFuture observeTask(AgentCard card, Invocation invocation) { + if (invocation.remoteTaskId == null) return CompletableFuture.failedFuture( + new IllegalArgumentException("Non-terminal response has no remote task identity")); + return CompletableFuture.supplyAsync(() -> { + if (!invocation.active()) throw new java.util.concurrent.CancellationException("Task wait ended"); + return Boolean.TRUE; + }, CompletableFuture.delayedExecutor(250, TimeUnit.MILLISECONDS)).thenCompose(ignored -> { + if (!invocation.active()) return CompletableFuture.failedFuture( + new java.util.concurrent.CancellationException("Task wait ended")); + return WireLog.call(invocationTrace(invocation), () -> + transport.getTask(card, invocation.task.getAgentName(), invocation.remoteTaskId)); + }); } - // ------------------------------------------------------------------ - // Extension handler chain - // ------------------------------------------------------------------ - private CompletableFuture> runBeforeSendHandlers( - AgentCard agentCard, String message, Map presetMetadata) { - Map metadata = - presetMetadata != null ? new HashMap<>(presetMetadata) : new HashMap<>(); - List extUris = A2ATransport.extractExtensionUris(agentCard); - List handlers = extensionRegistry.getHandlersForExtensions(extUris); - CompletableFuture> future = CompletableFuture.completedFuture(metadata); - for (ExtensionHandler handler : handlers) { - future = - future.thenCompose( - m -> - handler.beforeSend( - agentCard, - message, - m, - transport.getA2atClient(), - controlPoint)); + private static ReceivedMessage negotiationResponse(SendMessageResult result) { + for (ReceivedMessage received : result.getReceivedMessages()) { + boolean present = received.taskMetadata().containsKey(A2ATExtension.NEGOTIATION_T.uri()) + || received.message() != null && received.message().metadata().containsKey(A2ATExtension.NEGOTIATION_T.uri()) + || received.artifacts().stream().anyMatch(a -> + a.metadata() != null && a.metadata().containsKey(A2ATExtension.NEGOTIATION_T.uri())); + if (present) return received; } - return future; + throw new IllegalArgumentException("Unsupported INPUT_REQUIRED interaction: no Negotiation-T Propose"); } - private CompletableFuture runAfterReceiveHandlers( - AgentCard agentCard, SendMessageResult result) { - List extUris = A2ATransport.extractExtensionUris(agentCard); - List handlers = extensionRegistry.getHandlersForExtensions(extUris); - CompletableFuture future = CompletableFuture.completedFuture(result); - for (ExtensionHandler handler : handlers) { - future = - future.thenCompose( - r -> - handler.afterReceive( - agentCard, - r, - transport.getA2atClient(), - controlPoint, - eventCallback)); + private static NegotiationContext validateReply(NegotiationContext original, MessageContent reply) { + if (!reply.extensions().contains(A2ATExtension.NEGOTIATION_T.uri()) + || !reply.metadata().containsKey(A2ATExtension.NEGOTIATION_T.uri())) { + throw new IllegalArgumentException("Negotiation reply must carry and activate Negotiation-T"); } - return future; + NegotiationContext ending = A2atMessages.contextOf(reply.metadata()); + if (!original.id().equals(ending.id()) || original.round() != ending.round() + || original.maxRounds() != ending.maxRounds() + || ending.performative() == NegotiationPerformative.PROPOSE) { + throw new IllegalArgumentException("Reply does not match the received negotiation context/round"); + } + return ending; } - // ------------------------------------------------------------------ - // Intermediate event forwarding - // ------------------------------------------------------------------ + private void emit(String type, Map data) { + try { eventCallback.onEvent(type, data); } + catch (RuntimeException error) { log.warn("Event callback failed for {}", type, error); } + } + + private void emitAgentResponse(String agentName, SendMessageResult result) { + Map data = new HashMap<>(); + data.put("agent", agentName); + data.put("response", result.getText()); + data.put("receivedMessages", result.getReceivedMessages()); + emit(EventType.AGENT_RESPONSE, data); + } private void forwardIntermediateEvent(ClientEvent event, String agentName) { if (event instanceof TaskUpdateEvent tue) { @@ -386,8 +331,11 @@ private void forwardIntermediateEvent(ClientEvent event, String agentName) { data.put("append", ae.append()); data.put("last_chunk", ae.lastChunk()); if (!text.isEmpty()) data.put("text", text.toString()); - if (ae.metadata() != null && !ae.metadata().isEmpty()) - data.put("metadata", ae.metadata()); + // Business metadata belongs to the Artifact. Event metadata describes delivery + // (chunking, tracing, etc.) and must not hide the protocol payload. + Map artifactMetadata = ae.artifact().metadata(); + if (artifactMetadata != null && !artifactMetadata.isEmpty()) + data.put("metadata", artifactMetadata); log.info( "[EngineClient] Agent {} artifact update: {} ({})", agentName, @@ -411,12 +359,60 @@ private void forwardIntermediateEvent(ClientEvent event, String agentName) { } } - // ------------------------------------------------------------------ - // Lifecycle - // ------------------------------------------------------------------ + @Override + public CompletableFuture getTask(String agentName, String taskId) { + if (closed.get()) return CompletableFuture.failedFuture(new IllegalStateException("Workflow client closed")); + if (agentName == null || agentName.isBlank() || taskId == null || taskId.isBlank()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("agentName and taskId must not be blank")); + } + AgentCard agentCard = transport.getCard(agentName); + if (agentCard == null) { + return CompletableFuture.failedFuture(new RuntimeException("Agent not found: " + agentName)); + } + return transport.getTask(agentCard, agentName, taskId); + } + + @Override + public CompletableFuture cancelTask(String agentName, String taskId) { + if (closed.get()) return CompletableFuture.failedFuture(new IllegalStateException("Workflow client closed")); + if (agentName == null || agentName.isBlank() || taskId == null || taskId.isBlank()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("agentName and taskId must not be blank")); + } + AgentCard agentCard = transport.getCard(agentName); + if (agentCard == null) { + return CompletableFuture.failedFuture(new RuntimeException("Agent not found: " + agentName)); + } + return transport.cancelTask(agentCard, agentName, taskId); + } + + @Override + public CompletableFuture subscribeToTask( + String agentName, String taskId, + java.util.function.Consumer> eventCallback) { + if (closed.get()) return CompletableFuture.failedFuture(new IllegalStateException("Workflow client closed")); + if (agentName == null || agentName.isBlank() || taskId == null || taskId.isBlank()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("agentName and taskId must not be blank")); + } + AgentCard agentCard = transport.getCard(agentName); + if (agentCard == null) { + return CompletableFuture.failedFuture(new RuntimeException("Agent not found: " + agentName)); + } + return transport.subscribeToTask(agentCard, agentName, taskId, + event -> { + forwardIntermediateEvent(event, agentName); + if (eventCallback != null) { + eventCallback.accept(ClientEventMapper.toMap(event, agentName)); + } + }); + } @Override public void close() { - // Transport is owned by the caller; do not close it here. + if (!closed.compareAndSet(false, true)) return; + invocations.forEach(invocation -> invocation.completion.cancel(true)); + if (closeTransportOnClose) transport.close(); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/EnvFileLoader.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/EnvFileLoader.java index 4bb0bea8..6b0438ce 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/EnvFileLoader.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/EnvFileLoader.java @@ -25,15 +25,18 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** - * Loads key-value pairs from a {@code .env} file and sets them as system properties (only if not - * already present in the OS environment). + * Explicit application-bootstrap utility that copies key-value pairs from a {@code .env} file to + * system properties (only if not already present in the OS environment or JVM). * - *

This bridges the gap between the A2A-T SDK's internal {@code .env} loading and engine - * components that use {@link System#getenv} or {@link System#getProperty} to read configuration - * values like {@code A2AT_CRED_KEY}. + *

{@link A2ATransport} deliberately does not call this utility: SDK clients read their explicit + * env path per instance, preserving channel isolation. Applications may call it once, before + * creating any clients, only when a separate application component must read a value such as + * {@code A2AT_CRED_KEY} through {@link System#getenv} or {@link System#getProperty}. */ public final class EnvFileLoader { @@ -48,21 +51,41 @@ private EnvFileLoader() {} * @param envFilePath path to the {@code .env} file */ public static void loadToSystemProperties(Path envFilePath) { + Map values = read(envFilePath); + int count = 0; + for (Map.Entry entry : values.entrySet()) { + String key = entry.getKey(); + if (System.getenv(key) != null || System.getProperty(key) != null) { + continue; + } + System.setProperty(key, entry.getValue()); + count++; + } + if (count > 0) { + log.info( + "[EnvLoader] Loaded {} property(ies) from {} into system properties", + count, + envFilePath); + } + } + + /** Reads an env file without mutating process-global state. */ + static Map read(Path envFilePath) { if (envFilePath == null) { - return; + return Map.of(); } if (!Files.exists(envFilePath)) { log.debug("[EnvLoader] File not found: {}", envFilePath); - return; + return Map.of(); } List lines; try { lines = Files.readAllLines(envFilePath); } catch (IOException e) { log.warn("[EnvLoader] Failed to read {}: {}", envFilePath, e.getMessage()); - return; + return Map.of(); } - int count = 0; + Map values = new LinkedHashMap<>(); for (String line : lines) { String trimmed = line.trim(); if (trimmed.isEmpty() || trimmed.startsWith("#")) { @@ -77,20 +100,8 @@ public static void loadToSystemProperties(Path envFilePath) { if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { value = value.substring(1, value.length() - 1); } - if (System.getenv(key) != null) { - continue; - } - if (System.getProperty(key) != null) { - continue; - } - System.setProperty(key, value); - count++; - } - if (count > 0) { - log.info( - "[EnvLoader] Loaded {} property(ies) from {} into system properties", - count, - envFilePath); + values.putIfAbsent(key, value); } + return java.util.Collections.unmodifiableMap(values); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java deleted file mode 100644 index 5d8ccb0f..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.client; - -import dev.openan.workflow.engine.control.ControlPoint; -import dev.openan.workflow.engine.control.EventCallback; -import dev.openan.workflow.engine.model.SendMessageResult; - -import net.openan.a2at.sdk.client.A2ATClient; - -import org.a2aproject.sdk.spec.AgentCard; - -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -/** - * Extension handler for A2A-T extensions (SDK-internal). - * - *

The in-workflow handler chain registers Task-T and Negotiation-T. Task-T generates the - * structured task prompt on send; Negotiation-T extracts the negotiation context on receive and - * feeds the auto-loop. - * - *

Authorization-T / Notification-T are pre-positioning operations (one-shot, via {@link - * ExtensionSender#sendExtensionMessage}), so they are not part of this in-workflow chain. - */ -public interface ExtensionHandler { - - /** The extension keyword (e.g. "Task-T", "Negotiation-T"). */ - String extensionKeyword(); - - /** - * Called before sending a message to an agent. May modify the metadata (e.g. inject Task-T - * prompt). - * - * @param agentCard the agent's card as a map - * @param messageText the message being sent - * @param metadata current metadata (mutable) - * @param a2atClient optional A2ATClient (may be null) - * @param controlPoint optional ControlPoint (may be null) - * @return updated metadata - */ - CompletableFuture> beforeSend( - AgentCard agentCard, - String messageText, - Map metadata, - A2ATClient a2atClient, - ControlPoint controlPoint); - - /** - * Called after receiving a response from an agent. May inspect/modify the result (e.g. handle - * negotiation, authorization). - * - * @param agentCard the agent's card as a map - * @param result the received result - * @param a2atClient optional A2ATClient (may be null) - * @param controlPoint optional ControlPoint (may be null) - * @param eventCallback optional event callback (may be null) - * @return updated result - */ - CompletableFuture afterReceive( - AgentCard agentCard, - SendMessageResult result, - A2ATClient a2atClient, - ControlPoint controlPoint, - EventCallback eventCallback); -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHeaderContributor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHeaderContributor.java deleted file mode 100644 index dd7e15ad..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHeaderContributor.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package dev.openan.workflow.engine.client; - -import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; -import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; -import org.a2aproject.sdk.spec.AgentCard; - -import java.util.HashMap; -import java.util.Map; - -/** Adds headers required by extensions declared by the target AgentCard. */ -final class ExtensionHeaderContributor implements HeaderContributor { - private final AgentAuthManager authManager; - - ExtensionHeaderContributor(AgentAuthManager authManager) { - this.authManager = authManager; - } - - @Override - public Map contribute( - AgentCard agentCard, - String agentName, - Map messageMetadata, - Map currentHeaders) { - ExtensionInterceptor interceptor = authManager.buildExtensionInterceptor(agentCard); - if (interceptor == null) { - return Map.of(); - } - Map headers = new HashMap<>(currentHeaders); - ClientCallContext interceptContext = new ClientCallContext(new HashMap<>(), headers); - PayloadAndHeaders payloadAndHeaders = - interceptor.intercept( - "message/send", messageMetadata, headers, agentCard, interceptContext); - return payloadAndHeaders.getHeaders(); - } -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionInterceptor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionInterceptor.java deleted file mode 100644 index 5cb5c887..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionInterceptor.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.client; - -import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; -import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; -import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; -import org.a2aproject.sdk.spec.AgentCard; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -/** - * Injects A2A extension URIs into HTTP headers - but only the extensions that are actually present - * in the current message's metadata. - * - *

Sets the {@code A2A-Extensions} header so the remote agent knows which extensions this - * specific message uses. Extensions declared on the AgentCard but not present in the message - * metadata are not included. - */ -class ExtensionInterceptor extends ClientCallInterceptor { - - public static final String HTTP_EXTENSION_HEADER = "A2A-Extensions"; - - private static final Logger log = LoggerFactory.getLogger(ExtensionInterceptor.class); - - private final List extensionUris; - - public ExtensionInterceptor(List extensionUris) { - this.extensionUris = extensionUris != null ? new ArrayList<>(extensionUris) : List.of(); - } - - @NotNull - @Override - public PayloadAndHeaders intercept( - @NotNull String method, - Object payload, - @NotNull Map headers, - AgentCard agentCard, - ClientCallContext context) { - // Only advertise extensions that are actually present in this message's metadata - Set activeExtensions = filterActiveExtensions(payload); - if (activeExtensions.isEmpty()) { - return new PayloadAndHeaders(payload, headers); - } - Set merged = new LinkedHashSet<>(); - String existing = headers.get(HTTP_EXTENSION_HEADER); - if (existing != null && !existing.isEmpty()) { - for (String v : existing.split(",")) { - String trimmed = v.trim(); - if (!trimmed.isEmpty()) { - merged.add(trimmed); - } - } - } - merged.addAll(activeExtensions); - String extensionValue = String.join(",", merged); - Map newHeaders = new java.util.HashMap<>(headers); - newHeaders.put(HTTP_EXTENSION_HEADER, extensionValue); - log.info("[Extensions] Set {}={}", HTTP_EXTENSION_HEADER, extensionValue); - return new PayloadAndHeaders(payload, newHeaders); - } - - /** Return only the extension URIs that appear as keys in the message metadata. */ - private Set filterActiveExtensions(Object payload) { - if (!(payload instanceof Map meta)) { - return Set.of(); - } - Set active = new LinkedHashSet<>(); - for (String uri : extensionUris) { - if (meta.containsKey(uri)) { - active.add(uri); - } - } - return active; - } -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java deleted file mode 100644 index ace13511..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.client; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -/** - * Registry of A2A-T extension handlers. - * - *

The workflow engine only handles Task-T (task prompt generation) and Negotiation-T (auto - * negotiation loop). Authorization-T and Notification-T are pre-positioning operations done once - * before the workflow starts (see {@link WorkflowEngineClient#sendExtensionMessage}), so they are - * NOT part of the workflow's extension handler chain. - */ -class ExtensionRegistry { - - private static final Logger log = LoggerFactory.getLogger(ExtensionRegistry.class); - - private final Map handlers = new LinkedHashMap<>(); - - public ExtensionRegistry() { - register(new TaskTHandler()); - register(new NegotiationTHandler()); - } - - public void register(ExtensionHandler handler) { - handlers.put(handler.extensionKeyword(), handler); - } - - /** - * Find handlers matching the given extension URIs. - * - * @param extensionUris list of extension URIs from the AgentCard - * @return matched handlers (deduplicated) - */ - public List getHandlersForExtensions(List extensionUris) { - List matched = new ArrayList<>(); - java.util.Set seen = new java.util.HashSet<>(); - if (extensionUris == null) { - return matched; - } - for (String uri : extensionUris) { - if (uri == null) { - continue; - } - java.util.Set pathSegments = extensionPathSegments(uri); - for (Map.Entry entry : handlers.entrySet()) { - String keyword = entry.getKey().toLowerCase(Locale.ROOT); - if (pathSegments.contains(keyword) - && !seen.contains(entry.getKey())) { - matched.add(entry.getValue()); - seen.add(entry.getKey()); - break; - } - } - } - return matched; - } - - private static java.util.Set extensionPathSegments(String value) { - try { - String path = new URI(value).getPath(); - if (path == null || path.isBlank()) return java.util.Set.of(); - java.util.Set segments = new java.util.HashSet<>(); - for (String segment : path.split("/")) { - if (!segment.isBlank()) segments.add(segment.toLowerCase(Locale.ROOT)); - } - return segments; - } catch (URISyntaxException e) { - log.warn("Ignoring malformed extension URI: {}", value); - return java.util.Set.of(); - } - } -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java index c2c75653..989fcde0 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java @@ -19,62 +19,18 @@ package dev.openan.workflow.engine.client; +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.ReceivedMessage; import dev.openan.workflow.engine.model.SendMessageResult; - -import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.function.Consumer; +import java.util.function.BiConsumer; -/** - * One-shot pre-positioning facade over a shared {@link A2ATransport}. - * - *

Single responsibility: send Authorization-T, establish Notification-T subscriptions, or send - * another pre-positioning extension before a workflow starts. It bypasses Task-T prompt - * generation and the Negotiation-T auto-loop. The returned future carries the first response; - * later Notification-T events flow through the subscription callback. - * - *

Kept separate from {@link WorkflowEngineClient} so a caller that only wants to pre-position is - * not forced to hold a workflow-machinery facade. - */ +/** Operations independent of workflow execution; all content is supplied by the host. */ public interface ExtensionSender { + /** Sends final authorization content on this sender's independent transport. */ + CompletableFuture sendAuthorization(String agentName, MessageContent content); - /** - * Send a one-shot extension message for pre-positioning. - * - * @param agentName target agent name - * @param instruction short instruction text (becomes message parts) - * @param naturalLanguageInput input for SDK prompt generation - * @param extension extension type (never hardcode URIs) - */ - CompletableFuture sendExtensionMessage( - String agentName, - String instruction, - String naturalLanguageInput, - A2ATExtension extension); - - /** Convenience for Authorization-T pre-positioning. */ - default CompletableFuture sendAuthorization( - String agentName, String instruction, String naturalLanguageInput) { - return sendExtensionMessage( - agentName, instruction, naturalLanguageInput, A2ATExtension.AUTHORIZATION_T); - } - - /** - * Establish a Notification-T subscription. - * - *

The returned future completes on the first acknowledgement or event. Subsequent events - * pushed by the agent must be forwarded to {@code eventCallback}; implementations cannot - * silently discard it. - */ - CompletableFuture sendNotification( - String agentName, - String instruction, - String naturalLanguageInput, - Consumer> eventCallback); - - /** Convenience for Notification-T pre-positioning (no event callback). */ - default CompletableFuture sendNotification( - String agentName, String instruction, String naturalLanguageInput) { - return sendNotification(agentName, instruction, naturalLanguageInput, null); - } + /** Registers a handle before starting I/O; early callbacks may close it directly. */ + NotificationSubscription openNotification(String agentName, MessageContent content, + BiConsumer listener); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java deleted file mode 100644 index 8659d628..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.client; - -import dev.openan.workflow.engine.control.ControlPoint; -import dev.openan.workflow.engine.control.EventCallback; -import dev.openan.workflow.engine.model.SendMessageResult; - -import net.openan.a2at.sdk.client.A2ATClient; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -/** - * Negotiation-T extension handler. - * - *

Mirrors the Python SDK's {@code NegotiationTHandler}. When an agent returns INPUT_REQUIRED and - * supports Negotiation-T, this handler calls the A2ATClient to extract the negotiation context and - * message. - */ -class NegotiationTHandler implements ExtensionHandler { - - private static final Logger log = LoggerFactory.getLogger(NegotiationTHandler.class); - - @SuppressWarnings("unchecked") - private static Map extractNegotiationContext(Map metadata) { - if (metadata == null) { - return null; - } - for (var entry : metadata.entrySet()) { - if (entry.getKey().contains("DATA-NEGOTIATION-T") && entry.getValue() instanceof Map) { - return (Map) entry.getValue(); - } - } - return null; - } - - private static String extractNegotiationText(Map metadata) { - if (metadata == null) { - return null; - } - for (var entry : metadata.entrySet()) { - if (entry.getKey().contains("NEGOTIATION-T") - && !entry.getKey().contains("DATA-NEGOTIATION-T") - && entry.getValue() instanceof String) { - return (String) entry.getValue(); - } - } - return null; - } - - private static boolean supportsNegotiation(AgentCard agentCard) { - var extensions = agentCard.capabilities().extensions(); - if (extensions == null) { - return false; - } - for (var ext : extensions) { - String uri = ext.uri(); - if (uri.contains("NEGOTIATION-T")) { - return true; - } - } - return false; - } - - private static String getAgentName(AgentCard agentCard) { - return agentCard.name(); - } - - @Override - public String extensionKeyword() { - return "Negotiation-T"; - } - - @Override - public CompletableFuture> beforeSend( - AgentCard agentCard, - String messageText, - Map metadata, - A2ATClient a2atClient, - ControlPoint controlPoint) { - return CompletableFuture.completedFuture(metadata); - } - - @Override - public CompletableFuture afterReceive( - AgentCard agentCard, - SendMessageResult result, - A2ATClient a2atClient, - ControlPoint controlPoint, - EventCallback eventCallback) { - if (a2atClient == null - || result.getTaskState() == null - || !result.getTaskState().contains("INPUT_REQUIRED") - || !supportsNegotiation(agentCard)) { - return CompletableFuture.completedFuture(result); - } - return CompletableFuture.supplyAsync( - () -> { - Map metadata = - result.getMetadata() != null - ? new HashMap<>(result.getMetadata()) - : new HashMap<>(); - try { - Map contextMap = extractNegotiationContext(metadata); - if (contextMap == null) { - contextMap = metadata; - } - Map receiveResult = - a2atClient.receiveNegotiation(result.getText(), contextMap); - { - Map rr = receiveResult; - Boolean needResponse = (Boolean) rr.get("needResponse"); - if (Boolean.TRUE.equals(needResponse)) { - String negMsg = (String) rr.getOrDefault("message", ""); - metadata.put("negotiation_message", negMsg); - metadata.put("negotiation_context", rr); - log.info( - "[Negotiation-T] Agent '{}' requested negotiation: {}", - getAgentName(agentCard), - negMsg); - } - } - } catch (Exception e) { - if (e.getMessage() != null - && e.getMessage().contains("Unsupported negotiation type")) { - log.debug( - "[Negotiation-T] SDK receiveNegotiation has no handler for '{}' ({}); using direct extraction", - getAgentName(agentCard), - e.getMessage()); - } else { - log.warn( - "[Negotiation-T] receiveNegotiation error for '{}': {}; using direct extraction", - getAgentName(agentCard), - e.getMessage()); - } - String fallbackText = extractNegotiationText(metadata); - if (fallbackText != null && !fallbackText.isEmpty()) { - metadata.put("negotiation_message", fallbackText); - log.info( - "[Negotiation-T] Agent '{}' requested negotiation (direct): {}", - getAgentName(agentCard), - fallbackText); - } - } - result.setMetadata(metadata); - return result; - }); - } -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NotificationSubscription.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NotificationSubscription.java new file mode 100644 index 00000000..c8b61fb3 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NotificationSubscription.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import dev.openan.workflow.engine.model.SendMessageResult; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CancellationException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Explicit lifecycle handle for one long-lived Notification-T subscription. + * + *

The acknowledgement and stream completion are intentionally separate: an acknowledgement + * only means that the subscription is active, while {@link #completion()} represents the lifetime + * of the SSE channel. + */ +public final class NotificationSubscription implements AutoCloseable { + private final String agentName; + private final String contextId; + private final Instant openedAt = Instant.now(); + private final CompletableFuture acknowledgement = new CompletableFuture<>(); + private final CompletableFuture completion = new CompletableFuture<>(); + private final CompletableFuture streamTermination = new CompletableFuture<>(); + private final Runnable closeAction; + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicLong eventCount = new AtomicLong(); + private final AtomicReference lastEventAt = new AtomicReference<>(); + private volatile Throwable streamFailure; + + NotificationSubscription(String agentName, String contextId, Runnable closeAction) { + this.agentName = Objects.requireNonNull(agentName, "agentName"); + this.contextId = Objects.requireNonNull(contextId, "contextId"); + this.closeAction = Objects.requireNonNull(closeAction, "closeAction"); + } + + public String agentName() { + return agentName; + } + + public String contextId() { + return contextId; + } + + public CompletableFuture acknowledgement() { + return acknowledgement; + } + + public CompletableFuture completion() { + return completion; + } + + public boolean isActive() { + return !closed.get() && !completion.isDone(); + } + + /** Current local liveness snapshot; every protocol event, including heartbeats, refreshes it. */ + public Heartbeat heartbeat() { + return new Heartbeat( + openedAt, lastEventAt.get(), eventCount.get(), isActive()); + } + + public boolean isHealthy(Duration maximumIdle) { + Objects.requireNonNull(maximumIdle, "maximumIdle"); + if (maximumIdle.isNegative()) { + throw new IllegalArgumentException("maximumIdle must not be negative"); + } + Instant activity = lastEventAt.get(); + if (!isActive() || activity == null) { + return false; + } + return Duration.between(activity, Instant.now()).compareTo(maximumIdle) <= 0; + } + + void recordEvent() { + eventCount.incrementAndGet(); + lastEventAt.set(Instant.now()); + } + + void acknowledge(SendMessageResult result) { + acknowledgement.complete(result); + } + + void failAcknowledgement(Throwable error) { + acknowledgement.completeExceptionally(error); + } + + void completeStream() { + closed.set(true); + } + + void failStream(Throwable error) { + closed.set(true); + streamFailure = error; + } + + /** Internal signal that the thread executing the transport call has actually exited. */ + CompletableFuture streamTermination() { + return streamTermination; + } + + void markStreamTerminated() { + streamTermination.complete(null); + if (streamFailure == null) completion.complete(null); + else completion.completeExceptionally(streamFailure); + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + try { + closeAction.run(); + } finally { + acknowledgement.completeExceptionally( + new CancellationException( + "Notification-T subscription closed before acknowledgement")); + } + } + + public record Heartbeat( + Instant openedAt, Instant lastEventAt, long eventCount, boolean active) {} +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ObservedHttpClient.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ObservedHttpClient.java new file mode 100644 index 00000000..ec387a52 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ObservedHttpClient.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.client; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import java.io.IOException; +import java.net.*; +import java.net.http.*; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Consumer; + +/** Observes the JDK boundary after the A2A SDK has serialized and added its application headers. */ +final class ObservedHttpClient extends HttpClient { + private final HttpClient delegate; + private final Consumer observer; + + ObservedHttpClient(HttpClient delegate) { this(delegate, null); } + ObservedHttpClient(HttpClient delegate, Consumer observer) { + this.delegate = Objects.requireNonNull(delegate); + this.observer = observer; + } + + private final class Exchange { + final String id = UUID.randomUUID().toString(); + final HttpRequest request; + final Map context = WireLog.context(); + Exchange(HttpRequest request) { + this.request = request; + emit("REQUEST_HEADERS", null, request.headers().map(), "headers", "", ""); + } + void emit(String direction, Integer status, Map> headers, + String representation, String body, String detail) { + WireLog.emit(observer, "DIRECT_HTTP", direction, id, request.uri().toString(), request.method(), + status, headers, representation, body, + "application-boundary; automatic network headers/TLS/framing=unobserved; " + detail, context); + } + void failure(Throwable error) { + emit("FAILURE", null, Map.of(), "transport-error", "", + "errorType=" + error.getClass().getSimpleName()); + } + HttpRequest observedRequest() { + return new HttpRequest() { + @Override public Optional bodyPublisher() { + return request.bodyPublisher().map(original -> new BodyPublisher() { + @Override public long contentLength() { return original.contentLength(); } + @Override public void subscribe(Flow.Subscriber downstream) { + WireLog.Body body = new WireLog.Body(false, true, + value -> emit("REQUEST_BODY", null, Map.of(), "serialized-utf8", + value[0], value[1])); + original.subscribe(new Flow.Subscriber() { + @Override public void onSubscribe(Flow.Subscription subscription) { downstream.onSubscribe(subscription); } + @Override public void onNext(ByteBuffer item) { body.accept(item); downstream.onNext(item); } + @Override public void onError(Throwable error) { body.end(true); downstream.onError(error); } + @Override public void onComplete() { body.end(false); downstream.onComplete(); } + }); + } + }); + } + @Override public String method() { return request.method(); } + @Override public Optional timeout() { return request.timeout(); } + @Override public boolean expectContinue() { return request.expectContinue(); } + @Override public URI uri() { return request.uri(); } + @Override public Optional version() { return request.version(); } + @Override public HttpHeaders headers() { return request.headers(); } + }; + } + HttpResponse.BodyHandler handler(HttpResponse.BodyHandler handler) { + return info -> { + emit("RESPONSE_HEADERS", info.statusCode(), info.headers().map(), "headers", "", ""); + String type = info.headers().firstValue("Content-Type").orElse("").toLowerCase(Locale.ROOT); + boolean sse = type.contains("text/event-stream"); + WireLog.Body body = new WireLog.Body(sse, type.isEmpty() || type.contains("json") + || type.startsWith("text/") || type.contains("xml"), + value -> emit("RESPONSE_BODY", info.statusCode(), Map.of(), + sse ? "raw-sse-frame" : "raw-body", value[0], value[1])); + HttpResponse.BodySubscriber target = handler.apply(info); + return new HttpResponse.BodySubscriber() { + @Override public CompletionStage getBody() { return target.getBody(); } + @Override public void onSubscribe(Flow.Subscription subscription) { + target.onSubscribe(new Flow.Subscription() { + @Override public void request(long count) { subscription.request(count); } + @Override public void cancel() { body.end(true); subscription.cancel(); } + }); + } + @Override public void onNext(List items) { + items.forEach(body::accept); + target.onNext(items); + } + @Override public void onError(Throwable error) { body.end(true); failure(error); target.onError(error); } + @Override public void onComplete() { body.end(false); target.onComplete(); } + }; + }; + } + } + + @Override public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler handler) + throws IOException, InterruptedException { + Exchange exchange = new Exchange(request); + try { return delegate.send(exchange.observedRequest(), exchange.handler(handler)); } + catch (IOException | InterruptedException | RuntimeException error) { exchange.failure(error); throw error; } + } + + @Override public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler handler) { + Exchange exchange = new Exchange(request); + try { + CompletableFuture> future = + delegate.sendAsync(exchange.observedRequest(), exchange.handler(handler)); + future.whenComplete((result, error) -> { if (error != null) exchange.failure(error); }); + return future; + } catch (RuntimeException error) { exchange.failure(error); throw error; } + } + + @Override public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler handler, + HttpResponse.PushPromiseHandler pushHandler) { + Exchange exchange = new Exchange(request); + try { + CompletableFuture> future = delegate.sendAsync(exchange.observedRequest(), + exchange.handler(handler), pushHandler); + future.whenComplete((result, error) -> { if (error != null) exchange.failure(error); }); + return future; + } catch (RuntimeException error) { exchange.failure(error); throw error; } + } + + @Override public Optional cookieHandler() { return delegate.cookieHandler(); } + @Override public Optional connectTimeout() { return delegate.connectTimeout(); } + @Override public Redirect followRedirects() { return delegate.followRedirects(); } + @Override public Optional proxy() { return delegate.proxy(); } + @Override public SSLContext sslContext() { return delegate.sslContext(); } + @Override public SSLParameters sslParameters() { return delegate.sslParameters(); } + @Override public Optional authenticator() { return delegate.authenticator(); } + @Override public Version version() { return delegate.version(); } + @Override public Optional executor() { return delegate.executor(); } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java index fe7dd229..762757ad 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java @@ -36,46 +36,21 @@ import org.slf4j.LoggerFactory; import java.time.OffsetDateTime; -import java.util.Locale; import java.util.Map; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; -/** - * Logs complete A2A protocol messages (headers + body) for protocol-level verification against real - * network captures. Uses a dedicated "PROTOCOL" logger so output can be independently enabled or - * suppressed via logging configuration. - * - *

Request side: serializes {@link MessageSendParams} to pretty-printed JSON. Sensitive headers - * are redacted unless {@code WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=true} is explicitly - * configured. - * - *

Response side: serializes each {@link ClientEvent} payload (Task, TaskStatusUpdateEvent, - * TaskArtifactUpdateEvent, Message) to JSON. - */ +/** Optional model preview only; actual transport evidence is emitted by WireLog. */ final class ProtocolLogger { private static final Logger log = LoggerFactory.getLogger("PROTOCOL"); - private static final String INCLUDE_SENSITIVE_HEADERS = - "WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS"; private static final String INCLUDE_BODY = "WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY"; private static final String MAX_BODY_CHARS = "WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS"; private static final int DEFAULT_MAX_BODY_CHARS = 100_000; - private static final Set SENSITIVE_HEADERS = - Set.of( - "authorization", - "proxy-authorization", - "cookie", - "set-cookie", - "x-api-key", - "api-key"); - private static final AtomicBoolean sensitiveWarningLogged = new AtomicBoolean(); private static final ObjectMapper mapper = new ObjectMapper() .enable(SerializationFeature.INDENT_OUTPUT) .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL) .registerModule( new SimpleModule() .addSerializer( @@ -96,13 +71,13 @@ static void logRequest( String endpoint, MessageSendParams params, Map headers) { - if (!log.isInfoEnabled()) { + if (!log.isDebugEnabled() || !booleanSetting("WORKFLOW_ENGINE_PROTOCOL_MODEL_PREVIEW", false)) { return; } try { String bodyJson = formatBody(mapper.writeValueAsString(params)); - log.info( - ">>> [{}] REQUEST to {}\n=== Headers ===\n{}\n=== Body ===\n{}", + log.debug( + "MODEL_PREVIEW >>> [{}] REQUEST to {}\n=== Headers ===\n{}\n=== Body ===\n{}", agentName, endpoint, formatHeaders(headers), @@ -119,18 +94,19 @@ static void logRequest( * @param event the received client event */ static void logResponseEvent(String agentName, ClientEvent event) { - if (!log.isInfoEnabled()) { + if (!log.isDebugEnabled() || !booleanSetting("WORKFLOW_ENGINE_PROTOCOL_MODEL_PREVIEW", false)) { return; } try { Object payload = extractPayload(event); String eventType = event.getClass().getSimpleName(); if (payload == null) { - log.info("<<< [{}] RESPONSE [{}]: (no serializable payload)", agentName, eventType); + log.debug( + "MODEL_PREVIEW <<< [{}] RESPONSE [{}]: (no serializable payload)", agentName, eventType); return; } String json = formatBody(mapper.writeValueAsString(payload)); - log.info("<<< [{}] RESPONSE [{}]\n{}", agentName, eventType, json); + log.debug("MODEL_PREVIEW <<< [{}] RESPONSE [{}]\n{}", agentName, eventType, json); } catch (Exception e) { log.warn("<<< [{}] Failed to serialize response event: {}", agentName, e.getMessage()); } @@ -165,22 +141,12 @@ static String formatHeaders(Map headers) { if (headers == null || headers.isEmpty()) { return "(none)"; } - boolean includeSensitive = booleanSetting(INCLUDE_SENSITIVE_HEADERS, false); - if (includeSensitive && sensitiveWarningLogged.compareAndSet(false, true)) { - log.warn( - "PROTOCOL sensitive-header logging is enabled; protect logs and disable it outside controlled troubleshooting"); - } StringBuilder sb = new StringBuilder(); headers.forEach( (k, v) -> { - String normalized = k.toLowerCase(Locale.ROOT); - boolean sensitive = - SENSITIVE_HEADERS.contains(normalized) - || normalized.contains("token") - || normalized.contains("secret"); sb.append(k) .append(": ") - .append(sensitive && !includeSensitive ? "***" : v) + .append(WireLog.sensitive(k) ? "***" : v) .append("\n"); }); return sb.toString().trim(); @@ -188,6 +154,7 @@ static String formatHeaders(Map headers) { private static String formatBody(String body) { if (!booleanSetting(INCLUDE_BODY, true)) return "(body logging disabled)"; + body = WireLog.redact(body); int maxChars = intSetting(MAX_BODY_CHARS, DEFAULT_MAX_BODY_CHARS); if (body.length() <= maxChars) return body; return body.substring(0, maxChars) diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolResponses.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolResponses.java new file mode 100644 index 00000000..0e034a4c --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolResponses.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.client; + +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.ReceivedMessage; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.spec.*; + +import java.util.*; + +/** Assembles response snapshots without flattening task/message/artifact metadata. */ +public final class ProtocolResponses { + private ProtocolResponses() {} + + /** Reduces ordered SDK events to final response snapshots. */ + public static List assemble(Iterable events) { + Accumulator accumulator = new Accumulator(); + events.forEach(accumulator::accept); + return accumulator.snapshots(); + } + + /** Per-stream accumulator, never shared across tasks or subscriptions. */ + static final class Accumulator { + private final Map artifacts = new LinkedHashMap<>(); + private final Map messages = new LinkedHashMap<>(); + private Map taskMetadata = Map.of(); + private MessageContent statusMessage; + private boolean hasTask; + + void accept(ClientEvent event) { + if (event instanceof MessageEvent message) { + Message value = message.getMessage(); + messages.put(value.messageId(), content(value)); + } else if (event instanceof TaskEvent task) { + snapshot(task.getTask(), true); + } else if (event instanceof TaskUpdateEvent update) { + snapshot(update.getTask(), false); + if (update.getUpdateEvent() instanceof TaskArtifactUpdateEvent artifact) { + append(artifact); + } else if (update.getUpdateEvent() instanceof TaskStatusUpdateEvent status) { + statusMessage = content(status.status().message()); + // SDK task snapshots already include the delta. Seed missing artifacts, + // but never append that snapshot or overwrite raw delta fields with it. + if (update.getTask().artifacts() != null) { + update.getTask().artifacts().forEach(a -> artifacts.putIfAbsent(a.artifactId(), a)); + } + } + } + } + + private void snapshot(Task task, boolean authoritative) { + if (task == null) return; + hasTask = true; + if (task.metadata() != null) taskMetadata = task.metadata(); + if (task.status() != null) statusMessage = content(task.status().message()); + if (authoritative && task.artifacts() != null) { + artifacts.clear(); + for (Artifact artifact : task.artifacts()) { + artifacts.put(artifact.artifactId(), artifact); + } + } + } + + private void append(TaskArtifactUpdateEvent event) { + Artifact value = event.artifact(); + Artifact previous = artifacts.get(value.artifactId()); + if (Boolean.TRUE.equals(event.append()) && previous != null) { + List> parts = new ArrayList<>(previous.parts()); + parts.addAll(value.parts()); + Map metadata = new LinkedHashMap<>(); + if (previous.metadata() != null) metadata.putAll(previous.metadata()); + if (value.metadata() != null) metadata.putAll(value.metadata()); + value = new Artifact(value.artifactId(), + value.name() == null ? previous.name() : value.name(), + value.description() == null ? previous.description() : value.description(), + parts, metadata, value.extensions() == null ? previous.extensions() : value.extensions()); + } + artifacts.put(value.artifactId(), value); + } + + List snapshots() { + List result = new ArrayList<>(); + messages.values().forEach(m -> result.add(new ReceivedMessage(m, Map.of(), List.of()))); + if (hasTask) result.add(new ReceivedMessage(statusMessage, taskMetadata, new ArrayList<>(artifacts.values()))); + return List.copyOf(result); + } + } + + static MessageContent content(Message message) { + return message == null ? null : new MessageContent( + message.parts(), message.metadata(), + message.extensions() == null ? Set.of() : new LinkedHashSet<>(message.extensions())); + } + + static boolean terminal(TaskState state) { + return state == TaskState.TASK_STATE_COMPLETED || state == TaskState.TASK_STATE_FAILED + || state == TaskState.TASK_STATE_CANCELED || state == TaskState.TASK_STATE_REJECTED; + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SseNormalization.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SseNormalization.java index 7e0c5fcf..3fe4f7c9 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SseNormalization.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SseNormalization.java @@ -62,14 +62,26 @@ public static Map normalize(Map data) { if (data == null) { return data; } - if (STREAM_RESPONSE_KEYS.stream().noneMatch(data::containsKey)) { - if (data.containsKey("id") && data.containsKey("status")) { - log.info("[A2A] Non-SSE response detected: bare Task, wrapping"); - return Map.of("task", data); - } - if (data.containsKey("artifact") && data.containsKey("taskId")) { - return Map.of("artifactUpdate", data); - } + if (STREAM_RESPONSE_KEYS.stream().noneMatch(data::containsKey)) { + if (data.containsKey("id") && data.containsKey("status")) { + log.info("[A2A] Non-SSE response detected: bare Task, wrapping"); + return Map.of("task", data); + } + // Some agents return "artifacts" (array) instead of "artifact" (single object) + // in artifactUpdate. Coerce to the standard single-object form. + if (data.containsKey("artifacts") && data.containsKey("taskId")) { + Object artifacts = data.get("artifacts"); + if (artifacts instanceof java.util.List list && !list.isEmpty()) { + Map normalized = new java.util.LinkedHashMap<>(data); + normalized.put("artifact", list.get(0)); + normalized.remove("artifacts"); + log.info("[A2A] Non-standard 'artifacts' array coerced to single 'artifact'"); + return Map.of("artifactUpdate", normalized); + } + } + if (data.containsKey("artifact") && data.containsKey("taskId")) { + return Map.of("artifactUpdate", data); + } if (data.containsKey("status") && data.containsKey("taskId")) { return Map.of("statusUpdate", data); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java deleted file mode 100644 index 33677f4b..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.client; - -import dev.openan.workflow.engine.control.ControlPoint; -import dev.openan.workflow.engine.control.EventCallback; -import dev.openan.workflow.engine.model.SendMessageResult; - -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.model.PromptGenerationFailure; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -/** - * Task-T extension handler. - * - *

Mirrors the Python SDK's {@code TaskTHandler}. When an AgentCard declares the Task-T - * extension, this handler calls the A2ATClient to generate a structured task prompt and injects it - * into the message metadata. - */ -class TaskTHandler implements ExtensionHandler { - - private static final Logger log = LoggerFactory.getLogger(TaskTHandler.class); - - private static String findTaskTUri(AgentCard agentCard) { - var extensions = agentCard.capabilities().extensions(); - if (extensions == null) { - return null; - } - for (var ext : extensions) { - String uri = ext.uri(); - if (uri.contains("Task-T")) { - return uri; - } - } - return null; - } - - private static String getAgentName(AgentCard agentCard) { - return agentCard.name(); - } - - @Override - public String extensionKeyword() { - return "Task-T"; - } - - @Override - public CompletableFuture> beforeSend( - AgentCard agentCard, - String messageText, - Map metadata, - A2ATClient a2atClient, - ControlPoint controlPoint) { - if (a2atClient == null) { - return CompletableFuture.completedFuture(metadata); - } - if (messageText != null && messageText.contains("[NEGOTIATION_RESOLUTION]")) { - log.info("[Task-T] Skipping prompt generation for negotiation follow-up"); - return CompletableFuture.completedFuture(metadata); - } - String taskTUri = findTaskTUri(agentCard); - if (taskTUri == null) { - return CompletableFuture.completedFuture(metadata); - } - Map result = metadata != null ? new HashMap<>(metadata) : new HashMap<>(); - if (result.containsKey(taskTUri)) { - log.info("[Task-T] Metadata already preset, skipping generation"); - return CompletableFuture.completedFuture(result); - } - return CompletableFuture.supplyAsync( - () -> { - try { - PromptGenerationResult promptResult = - a2atClient.generateTaskPrompt(messageText); - if (promptResult.success()) { - String promptText = promptResult.promptText(); - if (promptText != null && !promptText.isEmpty()) { - result.put(taskTUri, promptText); - log.info( - "[Task-T] Generated prompt for '{}': {} chars", - getAgentName(agentCard), - promptText.length()); - log.debug("[Task-T] Prompt content: [{}]", promptText); - } - } else { - PromptGenerationFailure f = promptResult.failure(); - log.warn( - "[Task-T] Prompt generation failed for '{}': code={}, stage={}, message={}", - getAgentName(agentCard), - f != null ? f.code() : "unknown", - f != null ? f.stage() : "unknown", - f != null ? f.message() : "unknown"); - } - } catch (Exception e) { - log.warn("[Task-T] Failed: {}", e.getMessage()); - } - return result; - }); - } - - @Override - public CompletableFuture afterReceive( - AgentCard agentCard, - SendMessageResult result, - A2ATClient a2atClient, - ControlPoint controlPoint, - EventCallback eventCallback) { - return CompletableFuture.completedFuture(result); - } -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireGrpcInterceptor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireGrpcInterceptor.java new file mode 100644 index 00000000..5a93ee2a --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireGrpcInterceptor.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.client; + +import com.google.protobuf.MessageOrBuilder; +import com.google.protobuf.util.JsonFormat; +import io.grpc.*; +import java.util.*; +import java.util.function.Consumer; + +/** Captures actual gRPC metadata and protobuf values, never labels them HTTP JSON. */ +final class WireGrpcInterceptor implements ClientInterceptor { + private final Consumer observer; + WireGrpcInterceptor() { this(null); } + WireGrpcInterceptor(Consumer observer) { this.observer = observer; } + + @Override public ClientCall interceptCall( + MethodDescriptor method, CallOptions options, Channel channel) { + String requestId = UUID.randomUUID().toString(); + Map context = WireLog.context(); + return new ForwardingClientCall.SimpleForwardingClientCall(channel.newCall(method, options)) { + private void emit(String direction, Metadata metadata, Object value, String detail) { + String body = ""; + String representation = "grpc-metadata"; + if (value instanceof MessageOrBuilder protobuf) { + representation = "protobuf-json-view"; + try { body = JsonFormat.printer().print(protobuf); } + catch (com.google.protobuf.InvalidProtocolBufferException error) { + detail += "; protobuf-view=unavailable"; + } + } else if (value != null) { + representation = "unobserved-message"; + detail += "; non-protobuf message=" + value.getClass().getName(); + } + WireLog.emit(observer, "DIRECT_GRPC", direction, requestId, channel.authority(), + method.getFullMethodName(), null, headers(metadata), representation, body, + "grpc-call-boundary; HTTP/2 frames/TLS=unobserved; " + detail, context); + } + @Override public void start(Listener listener, Metadata headers) { + emit("REQUEST_HEADERS", headers, null, ""); + super.start(new ForwardingClientCallListener.SimpleForwardingClientCallListener(listener) { + @Override public void onHeaders(Metadata headers) { + emit("RESPONSE_HEADERS", headers, null, ""); + super.onHeaders(headers); + } + @Override public void onMessage(RespT value) { + emit("RESPONSE_BODY", null, value, ""); + super.onMessage(value); + } + @Override public void onClose(Status status, Metadata trailers) { + emit("RESPONSE_TRAILERS", trailers, null, "grpcStatus=" + status.getCode()); + super.onClose(status, trailers); + } + }, headers); + } + @Override public void sendMessage(ReqT value) { + emit("REQUEST_BODY", null, value, ""); + super.sendMessage(value); + } + }; + } + + private static Map> headers(Metadata metadata) { + Map> result = new LinkedHashMap<>(); + if (metadata == null) return result; + for (String name : metadata.keys()) { + List values = new ArrayList<>(); + if (name.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Iterable raw = metadata.getAll(Metadata.Key.of(name, Metadata.BINARY_BYTE_MARSHALLER)); + if (raw != null) raw.forEach(bytes -> values.add(Base64.getEncoder().encodeToString(bytes))); + } else { + Iterable raw = metadata.getAll(Metadata.Key.of(name, Metadata.ASCII_STRING_MARSHALLER)); + if (raw != null) raw.forEach(values::add); + } + result.put(name, values); + } + return result; + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLog.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLog.java new file mode 100644 index 00000000..58008e35 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLog.java @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.client; + +import org.slf4j.LoggerFactory; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +/** Application-boundary evidence, not a TLS/HTTP packet capture. Never logs secrets verbatim. */ +public final class WireLog { + private WireLog() {} + private static final org.slf4j.Logger LOG = LoggerFactory.getLogger("PROTOCOL"); + private static final ThreadLocal> CONTEXT = ThreadLocal.withInitial(Map::of); + private static final com.fasterxml.jackson.databind.ObjectMapper JSON = + new com.fasterxml.jackson.databind.ObjectMapper(); + private static final Pattern SECRET_FORM = Pattern.compile( + "(?i)((?:password|passwd|pwd|[\\w-]*token|[\\w-]*secret|accessSession|api[-_]?key)=)[^&\\s]*"); + + /** Values are already redacted, including for test/custom observers. */ + public record Entry(String boundary, String direction, String requestId, String target, String method, + Integer status, Map> headers, String representation, + String body, String visibility, Map correlation) {} + + public static Map context() { return CONTEXT.get(); } + + public static T call(Map context, java.util.function.Supplier action) { + Map previous = CONTEXT.get(); + CONTEXT.set(Map.copyOf(context)); + try { return action.get(); } finally { CONTEXT.set(previous); } + } + + /** Local-only correlation; never inserted into a protocol header or message metadata. */ + public static void inContext(Map context, Runnable action) { + Map previous = CONTEXT.get(); + CONTEXT.set(Map.copyOf(context)); + try { action.run(); } finally { CONTEXT.set(previous); } + } + + static boolean setting(String key, boolean fallback) { + String value = System.getProperty(key); + if (value == null) value = System.getenv(key); + return value == null ? fallback : Boolean.parseBoolean(value); + } + + static int limit() { + String value = System.getProperty("WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS", + System.getenv("WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS")); + try { return Math.max(256, Integer.parseInt(value)); } + catch (RuntimeException ignored) { return 100_000; } + } + + public static String redact(String text) { + if (text == null) return ""; + StringBuilder result = new StringBuilder(); + int copied = 0; + for (int cursor = 0; cursor < text.length(); cursor++) { + if (text.charAt(cursor) != '"') continue; + int endOfString = valueEnd(text, cursor); + if (endOfString <= cursor || text.charAt(endOfString - 1) != '"') break; + int colon = endOfString; + while (colon < text.length() && Character.isWhitespace(text.charAt(colon))) colon++; + if (colon == text.length() || text.charAt(colon) != ':') { + cursor = endOfString - 1; + continue; + } + String key; + try { key = JSON.readValue(text.substring(cursor, endOfString), String.class); } + catch (com.fasterxml.jackson.core.JsonProcessingException invalid) { + cursor = endOfString - 1; + continue; + } + cursor = colon; + if (!sensitive(key)) continue; + int start = colon + 1; + while (start < text.length() && Character.isWhitespace(text.charAt(start))) start++; + int end = valueEnd(text, start); + result.append(text, copied, start).append("\"***\""); + copied = end; + cursor = end - 1; + } + result.append(text, copied, text.length()); + return SECRET_FORM.matcher(result).replaceAll("$1***"); + } + + /** Skip a complete JSON value without reserializing the surrounding wire text. */ + private static int valueEnd(String text, int start) { + boolean quoted = false; + boolean escaped = false; + int depth = 0; + for (int i = start; i < text.length(); i++) { + char c = text.charAt(i); + if (quoted) { + if (escaped) escaped = false; + else if (c == '\\') escaped = true; + else if (c == '"') { + quoted = false; + if (depth == 0) return i + 1; + } + } else if (c == '"') quoted = true; + else if (c == '{' || c == '[') depth++; + else if (c == '}' || c == ']') { + if (depth == 0) return i; + if (--depth == 0) return i + 1; + } else if (depth == 0 && (c == ',' || Character.isWhitespace(c))) return i; + } + // Incomplete secret values are redacted to the end, never logged as a raw prefix. + return text.length(); + } + + static boolean sensitive(String name) { + String key = name.toLowerCase(Locale.ROOT); + return key.contains("authorization") || key.contains("cookie") || key.contains("token") + || key.contains("secret") || key.contains("password") || key.equals("pwd") + || key.equals("passwd") || key.equals("accesssession") + || key.equals("x-api-key") || key.equals("api-key") || key.equals("apikey"); + } + + public static Map> safeHeaders(Map> headers) { + Map> result = new LinkedHashMap<>(); + headers.forEach((key, values) -> result.put(key, + sensitive(key) ? List.of("***") : List.copyOf(values))); + return Collections.unmodifiableMap(result); + } + + /** Actual serialized data only. SDK-decoded data must explicitly say decoded-data. */ + public static void record(String boundary, String direction, String requestId, String target, + String method, Integer status, Map> headers, + String representation, String body, String visibility) { + emit(null, boundary, direction, requestId, target, method, status, headers, + representation, body, visibility, context()); + } + + static void emit(Consumer observer, String boundary, String direction, String requestId, + String target, String method, Integer status, Map> headers, + String representation, String body, String visibility, Map context) { + try { + String safeBody = redact(body); + if (!setting("WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY", true)) { + safeBody = "(body logging disabled)"; + visibility += "; body=disabled"; + } else if (safeBody.length() > limit()) { + safeBody = safeBody.substring(0, limit()) + "\n(body truncated)"; + visibility += "; body=truncated"; + } + Entry entry = new Entry(boundary, direction, requestId, redact(target), method, status, + safeHeaders(headers), representation, safeBody, visibility, Map.copyOf(context)); + if (observer != null) observer.accept(entry); + else if (LOG.isDebugEnabled()) LOG.debug("{}", WireLogFormatter.format(entry, + setting("WORKFLOW_ENGINE_PROTOCOL_PRETTY", true))); + } catch (RuntimeException ignored) { + // Logging must never change delivery, backpressure or application state. + LOG.warn("Protocol observation failed; record dropped"); + } + } + + /** Bounded raw-byte collector. SSE emits each frame as it arrives, not at stream termination. */ + public static final class Body { + private final Consumer sink; + private final boolean sse; + private final boolean textual; + private final int capacity = limit(); + private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + private long size; + private boolean dropped; + private boolean ended; + private boolean pendingCr; + private int lineBytes; + public Body(boolean sse, boolean textual, Consumer sink) { + this.sse = sse; + this.textual = textual; + this.sink = sink; + } + public synchronized void accept(ByteBuffer input) { + if (ended) return; + ByteBuffer copy = input.asReadOnlyBuffer(); + while (copy.hasRemaining()) { + int value = copy.get() & 255; + if (sse && pendingCr && value != 10) { + lineEnd(); + pendingCr = false; + } + size++; + if (bytes.size() < capacity && !dropped) bytes.write(value); + else { dropped = true; bytes.reset(); } + if (sse) { + // CR, LF, and CRLF are line endings. A blank line terminates a frame. + if (value == 13) pendingCr = true; + else if (value == 10) { lineEnd(); pendingCr = false; } + else lineBytes++; + } + } + } + public synchronized void end(boolean interrupted) { + if (ended) return; + ended = true; + if (pendingCr) { lineEnd(); pendingCr = false; } + if (size > 0 || !sse) flush(interrupted); + } + private void lineEnd() { + if (lineBytes == 0) flush(false); + lineBytes = 0; + } + private void flush(boolean interrupted) { + String content = dropped ? "(body/frame dropped: capacity exceeded)" + : !textual ? "(binary body omitted)" + : new String(bytes.toByteArray(), StandardCharsets.UTF_8); + // Decode only complete frames/body, keeping UTF-8 code points intact across chunks. + String visible = "observedBytes=" + size + (dropped ? "; body=dropped-capacity" : "") + + (interrupted ? "; stream=interrupted" : ""); + try { sink.accept(new String[]{content, visible}); } + catch (RuntimeException ignored) { LOG.warn("Protocol observation failed; record dropped"); } + bytes.reset(); size = 0; dropped = false; + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLogFormatter.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLogFormatter.java new file mode 100644 index 00000000..9024970e --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLogFormatter.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.client; + +import com.fasterxml.jackson.core.JsonFactory; + +/** Display only: transport bytes and observer entries are never reformatted. */ +final class WireLogFormatter { + private static final JsonFactory JSON = new JsonFactory(); + + private WireLogFormatter() {} + + static String format(WireLog.Entry entry, boolean pretty) { + StringBuilder out = new StringBuilder() + .append("[").append(entry.boundary()).append("] ").append(entry.direction()) + .append(" requestId=").append(entry.requestId()) + .append("\nTarget: ").append(entry.method()).append(" ").append(entry.target()) + .append("\nStatus: ").append(entry.status() == null ? "(not observed)" : entry.status()) + .append("\nRepresentation: ").append(entry.representation()) + .append("\nVisibility: ").append(entry.visibility()) + .append("\nDisplay: ").append(pretty ? "pretty (not wire bytes)" : "raw observed text") + .append("\n=== Correlation ===\n"); + entry.correlation().forEach((key, value) -> out.append(key).append(": ").append(value).append('\n')); + out.append("=== Headers ===\n"); + if (entry.headers().isEmpty()) out.append("(none observed)\n"); + entry.headers().forEach((key, values) -> + values.forEach(value -> out.append(key).append(": ").append(value).append('\n'))); + return out.append("=== Body ===\n") + .append(pretty ? prettyBody(entry.body(), entry.representation()) : entry.body()).toString(); + } + + static String prettyBody(String body, String representation) { + if (body == null || body.isBlank()) return body == null ? "" : body; + String result = representation.toLowerCase(java.util.Locale.ROOT).contains("sse") + ? prettySse(body) : prettyJson(body); + return result.length() <= WireLog.limit() ? result + : body + "\n(pretty skipped: display capacity exceeded)"; + } + + private static String prettySse(String body) { + String[] lines = body.split("\\r\\n|\\r|\\n", -1); + StringBuilder out = new StringBuilder(); + for (int i = 0; i < lines.length; i++) { + if (!lines[i].startsWith("data:")) { + out.append(lines[i]); + } else { + int first = i; + StringBuilder data = new StringBuilder(); + do { + String value = lines[i].substring(5); + if (value.startsWith(" ")) value = value.substring(1); + if (i > first) data.append('\n'); + data.append(value); + i++; + } while (i < lines.length && lines[i].startsWith("data:")); + String formatted = prettyJson(data.toString()); + if (!isJsonContainer(data.toString().strip())) { + for (int j = first; j < i; j++) { + if (j > first) out.append('\n'); + out.append(lines[j]); + } + } else { + out.append("=== SSE data (JSON display; not wire text) ===\n") + .append(formatted).append("\n=== End SSE data ==="); + } + i--; + } + if (i < lines.length - 1) out.append('\n'); + if (out.length() > WireLog.limit()) return body + "\n(pretty skipped: display capacity exceeded)"; + } + return out.toString(); + } + + private static boolean isJsonContainer(String text) { + if (!text.startsWith("{") && !text.startsWith("[")) return false; + try (var parser = JSON.createParser(text)) { + parser.nextToken(); + parser.skipChildren(); + return parser.nextToken() == null; + } catch (java.io.IOException | RuntimeException invalid) { + return false; + } + } + + /** Insert whitespace only; preserve duplicate keys, numbers and escaped string tokens exactly. */ + private static String prettyJson(String text) { + String trimmed = text.strip(); + if (!isJsonContainer(trimmed)) return text; + StringBuilder out = new StringBuilder(); + boolean quoted = false; + boolean escaped = false; + int depth = 0; + char previous = 0; + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (quoted) { + out.append(c); + if (escaped) escaped = false; + else if (c == '\\') escaped = true; + else if (c == '"') quoted = false; + } else if (c == '"') { + quoted = true; + out.append(c); + } else if (!Character.isWhitespace(c)) { + switch (c) { + case '{', '[' -> { + out.append(c); + if (++depth > 64) return text; + int next = i + 1; + while (next < trimmed.length() && Character.isWhitespace(trimmed.charAt(next))) next++; + if (next < trimmed.length() && trimmed.charAt(next) != '}' && trimmed.charAt(next) != ']') + newline(out, depth); + } + case '}', ']' -> { + depth--; + if (previous != '{' && previous != '[') newline(out, depth); + out.append(c); + } + case ',' -> { out.append(c); newline(out, depth); } + case ':' -> out.append(": "); + default -> out.append(c); + } + } + if (!Character.isWhitespace(c) || quoted) previous = c; + if (out.length() > WireLog.limit()) return text + "\n(pretty skipped: display capacity exceeded)"; + } + return out.toString(); + } + + private static void newline(StringBuilder out, int depth) { + out.append('\n').append(" ".repeat(depth)); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClient.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClient.java index c0ca0930..a600ace4 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClient.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClient.java @@ -21,55 +21,38 @@ import dev.openan.workflow.engine.control.ControlPoint; import dev.openan.workflow.engine.control.EventCallback; +import dev.openan.workflow.engine.model.MessageContent; import dev.openan.workflow.engine.model.SendMessageResult; +import dev.openan.workflow.engine.model.TaskRequest; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; -/** - * Workflow-execution send facade over a shared {@link A2ATransport}. - * - *

Single responsibility: the workflow execution send path. This facade owns Task-T prompt - * generation, the Negotiation-T auto-loop, the global {@link EventCallback}, and the ControlPoint - * wiring. All wire-level work (client runtime, auth, SSE event extraction) delegates to the - * transport. - * - *

One-shot pre-positioning (Authorization-T / Notification-T) is a separate concern and lives on - * {@link ExtensionSender}; callers that only need pre-positioning hold that lighter facade over the - * same transport. - * - *

The single message type on this facade: - * - *

    - *
  • {@link #sendMessage} - streaming send used during workflow execution (invoked from {@link - * ControlPoint#onTask}). Runs through Task-T prompt generation, the Negotiation-T auto-loop, - * and the global {@link EventCallback}. - *
- */ +/** Sends final content and coordinates task interaction. Content generation belongs to the host. */ public interface WorkflowEngineClient { + /** Dispatches a prepared workflow activation; target and protocol association remain internal. */ + CompletableFuture dispatch( + TaskRequest request, MessageContent content, ControlPoint callbacks); - /** - * Send a message to an agent via SSE streaming. Used during workflow execution. The engine - * handles Task-T prompt generation, Negotiation-T auto-loop, auth, and extension header - * injection automatically. - * - * @param agentName target agent name (must match AgentCard.name) - * @param message full assembled message text - * @param contextId optional context ID (null = auto-generated) - * @param metadata optional preset metadata - * @return future completing with response text, task, metadata, task state - */ - CompletableFuture sendMessage( - String agentName, String message, String contextId, Map metadata); + /** Sends final content outside the DAG using explicitly configured interaction callbacks. */ + CompletableFuture sendMessage(String agentName, MessageContent content); - /** Convenience: no context ID, no preset metadata. */ - default CompletableFuture sendMessage(String agentName, String message) { - return sendMessage(agentName, message, null, null); - } + /** Maximum wait for each business callback, including initial content preparation. */ + default long callbackTimeoutSeconds() { return 600; } void setControlPoint(ControlPoint controlPoint); - void setEventCallback(EventCallback callback); + /** Queries an existing remote task. */ + CompletableFuture getTask(String agentName, String taskId); + + /** Cancels an existing remote task; this is not a Negotiation-T Abort. */ + CompletableFuture cancelTask(String agentName, String taskId); + + /** Subscribes to an existing task, preserving its identity. */ + CompletableFuture subscribeToTask( + String agentName, String taskId, Consumer> eventCallback); + void close(); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java index 8d89e20a..ad84a81f 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java @@ -21,6 +21,9 @@ import lombok.Getter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -46,10 +49,9 @@ public class WorkflowEngineClientConfig { private final int sendExecutorQueueCapacity; private final AuthProvider authProvider; private final String credentialsConfigPath; + private final String credentialEncryptionKey; private final Map>> credentialsConfig; - private final String a2atEnvPath; - private final int maxNegotiationRounds; - private final List customHandlers; + private final int maxNegotiationExchanges; private final String preferredProtocol; private WorkflowEngineClientConfig(Builder b) { @@ -66,13 +68,53 @@ private WorkflowEngineClientConfig(Builder b) { this.sendExecutorQueueCapacity = b.sendExecutorQueueCapacity; this.authProvider = b.authProvider; this.credentialsConfigPath = b.credentialsConfigPath; - this.credentialsConfig = b.credentialsConfig; - this.a2atEnvPath = b.a2atEnvPath; - this.maxNegotiationRounds = b.maxNegotiationRounds; - this.customHandlers = b.customHandlers; + this.credentialEncryptionKey = b.credentialEncryptionKey; + this.credentialsConfig = + b.credentialsConfig != null ? copyCredentials(b.credentialsConfig) : null; + this.maxNegotiationExchanges = b.maxNegotiationExchanges; this.preferredProtocol = b.preferredProtocol; } + private static Map>> copyCredentials( + Map>> source) { + Map>> top = new LinkedHashMap<>(); + source.forEach( + (agent, schemes) -> { + Map> schemeCopy = new LinkedHashMap<>(); + if (schemes != null) { + schemes.forEach( + (scheme, values) -> + schemeCopy.put( + scheme, + values != null + ? immutableObjectMap(values) + : Map.of())); + } + top.put(agent, Collections.unmodifiableMap(schemeCopy)); + }); + return Collections.unmodifiableMap(top); + } + + private static Map immutableObjectMap(Map source) { + Map copy = new LinkedHashMap<>(); + source.forEach((key, value) -> copy.put(key, immutableValue(value))); + return Collections.unmodifiableMap(copy); + } + + private static Object immutableValue(Object value) { + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + map.forEach((key, nested) -> copy.put(key, immutableValue(nested))); + return Collections.unmodifiableMap(copy); + } + if (value instanceof List list) { + List copy = new ArrayList<>(list.size()); + list.forEach(item -> copy.add(immutableValue(item))); + return Collections.unmodifiableList(copy); + } + return value; + } + public static Builder builder() { return new Builder(); } @@ -91,10 +133,9 @@ public static class Builder { private int sendExecutorQueueCapacity = 256; private AuthProvider authProvider; private String credentialsConfigPath = null; + private String credentialEncryptionKey; private Map>> credentialsConfig = null; - private String a2atEnvPath = null; - private int maxNegotiationRounds = 3; - private List customHandlers = null; + private int maxNegotiationExchanges = 3; private String preferredProtocol = null; public Builder sslVerify(boolean v) { @@ -157,6 +198,12 @@ public Builder sendExecutorQueueCapacity(int v) { return this; } + /** Optional credential decryption key supplied by the host, not loaded from LLM config. */ + public Builder credentialEncryptionKey(String key) { + this.credentialEncryptionKey = key; + return this; + } + public Builder credentialsConfigPath(String v) { this.credentialsConfigPath = v; return this; @@ -167,13 +214,8 @@ public Builder credentialsConfig(Map>> v return this; } - public Builder a2atEnvPath(String v) { - this.a2atEnvPath = v; - return this; - } - - public Builder maxNegotiationRounds(int v) { - this.maxNegotiationRounds = v; + public Builder maxNegotiationExchanges(int v) { + this.maxNegotiationExchanges = v; return this; } @@ -182,11 +224,6 @@ public Builder preferredProtocol(String v) { return this; } - public Builder customHandlers(List v) { - this.customHandlers = v; - return this; - } - public WorkflowEngineClientConfig build() { if (sendTimeoutSeconds <= 0 || notificationAckTimeoutSeconds <= 0) { throw new IllegalArgumentException("Timeouts must be positive"); @@ -196,6 +233,9 @@ public WorkflowEngineClientConfig build() { || sendExecutorQueueCapacity <= 0) { throw new IllegalArgumentException("Invalid send executor configuration"); } + if (maxNegotiationExchanges <= 0) { + throw new IllegalArgumentException("maxNegotiationExchanges must be positive"); + } return new WorkflowEngineClientConfig(this); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/ControlPoint.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/ControlPoint.java index 3628be0e..b6401c1d 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/ControlPoint.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/ControlPoint.java @@ -19,68 +19,93 @@ package dev.openan.workflow.engine.control; -import dev.openan.workflow.engine.core.WorkflowExecutor; -import dev.openan.workflow.engine.model.JumpCondition; -import dev.openan.workflow.engine.model.RouteDecision; -import dev.openan.workflow.engine.model.TaskRequest; -import dev.openan.workflow.engine.model.TaskResponse; -import dev.openan.workflow.engine.client.WorkflowEngineClient; - -import java.util.List; -import java.util.Map; +import dev.openan.workflow.engine.model.*; import java.util.concurrent.CompletableFuture; +import java.util.function.Function; -/** - * Workflow-control decision interface. - * - *

Each method drives the workflow forward and is called by the {@link - * WorkflowExecutor} (onTask / onSelfTask / onRoute) or the client - * auto-negotiate loop (onNegotiation). ControlPoint methods make business decisions; the SDK owns - * all A2A-T protocol mechanics. - */ +/** Business-only callbacks. Implementations must support concurrent calls from different tasks. */ public interface ControlPoint { + /** Prepares content; the engine sends after completion. No transport is exposed. */ + default CompletableFuture onTask( + TaskRequest request) { + return CompletableFuture.failedFuture( + new IllegalStateException("onTask handler is required for " + request.getStepName())); + } + + /** Runs a local task. Missing implementation is an error, never an echo-success. */ + default CompletableFuture onSelfTask(TaskRequest request) { + return CompletableFuture.failedFuture( + new IllegalStateException( + "onSelfTask handler is required for " + request.getStepName())); + } + + /** Selects a permitted branch. Unconditional edges bypass this callback. */ + default CompletableFuture onRoute(RouteRequest request) { + return CompletableFuture.failedFuture( + new IllegalStateException("onRoute handler is required for " + request.stepName())); + } + + /** Answers the proposal. Missing handlers never implicitly consent. */ + default CompletableFuture onNegotiation(NegotiationRequest request) { + return CompletableFuture.failedFuture( + new IllegalStateException("onNegotiation handler is required")); + } - /** - * Send a task to an agent. Call {@code engineClient.sendMessage(...)}. - * - *

The SDK handles Task-T prompt generation, the Negotiation-T auto-loop (calling {@link - * #onNegotiation} on INPUT_REQUIRED), auth, and extension header injection. Just send the - * message. - */ - CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient); - - /** - * Handle a self-loop task locally. Called when a workflow step is marked SELF_LOOP: the agent - * executing the workflow processes the task itself, WITHOUT sending an A2A-T message to itself. - * Only steps that dispatch to OTHER agents go through {@link #onTask} and the A2A-T protocol. - * - *

No {@code engineClient} is passed on purpose: self-loop tasks must not send A2A-T - * messages. Implement this to handle local aggregation, merge, or any business logic the - * workflow-executing agent owns. - * - *

Default: echoes the task message back as the output. - */ - default CompletableFuture onSelfTask(TaskRequest request) { - return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output(request.getMessage()).build()); + /** Registers only the capabilities the host needs. */ + static Builder builder() { + return new Builder(); } - /** - * Branch decision at a conditional step. Return the next step to take. Only decide which - * branch; do not send messages here. - */ - CompletableFuture onRoute( - String stepName, Map results, List conditions); - - /** - * Provide supplementary data when an agent returns INPUT_REQUIRED (Negotiation-T). Return the - * clarification text - the SDK internally resends the follow-up message. Do NOT send messages - * here. Default: returns a generic clarification. - */ - default CompletableFuture onNegotiation( - String agentName, String negotiationText, Map receiveResult) { - return CompletableFuture.completedFuture( - "Please proceed with the original task using available information."); + /** Independent registration; omitted callbacks retain the documented defaults. */ + final class Builder { + private Function> task; + private Function> self; + private Function> route; + private Function> negotiation; + + public Builder onTask(Function> handler) { + task = handler; + return this; + } + + public Builder onSelfTask(Function> handler) { + self = handler; + return this; + } + + public Builder onRoute(Function> handler) { + route = handler; + return this; + } + + public Builder onNegotiation( + Function> handler) { + negotiation = handler; + return this; + } + + public ControlPoint build() { + var t = task; + var s = self; + var r = route; + var n = negotiation; + return new ControlPoint() { + public CompletableFuture onTask(TaskRequest q) { + return t == null ? ControlPoint.super.onTask(q) : t.apply(q); + } + + public CompletableFuture onSelfTask(TaskRequest q) { + return s == null ? ControlPoint.super.onSelfTask(q) : s.apply(q); + } + + public CompletableFuture onRoute(RouteRequest q) { + return r == null ? ControlPoint.super.onRoute(q) : r.apply(q); + } + + public CompletableFuture onNegotiation(NegotiationRequest q) { + return n == null ? ControlPoint.super.onNegotiation(q) : n.apply(q); + } + }; + } } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/DefaultControlPoint.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/DefaultControlPoint.java index 13f8cbe6..e534f908 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/DefaultControlPoint.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/DefaultControlPoint.java @@ -19,121 +19,25 @@ package dev.openan.workflow.engine.control; -import dev.openan.workflow.engine.client.WorkflowEngineClient; - -import dev.openan.workflow.engine.model.JumpCondition; -import dev.openan.workflow.engine.model.RouteDecision; -import dev.openan.workflow.engine.model.TaskRequest; -import dev.openan.workflow.engine.model.TaskResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Map; +import dev.openan.workflow.engine.model.*; import java.util.concurrent.CompletableFuture; -/** - * Default ControlPoint with single-responsibility methods. - * - *

Negotiation-T auto-loop delegates to an injected {@link NegotiationStrategy} (or returns a - * generic clarification if none is provided). Override onNegotiation directly when a full strategy - * object is unnecessary. - */ +/** Explicit business callbacks, with no implicit local success, branch selection or consent. */ public class DefaultControlPoint implements ControlPoint { - private static final Logger log = LoggerFactory.getLogger(DefaultControlPoint.class); - - private final NegotiationStrategy negotiationStrategy; + private final NegotiationStrategy strategy; public DefaultControlPoint() { - this.negotiationStrategy = null; - } - - public DefaultControlPoint(NegotiationStrategy negotiationStrategy) { - this.negotiationStrategy = negotiationStrategy; - } - - @Override - public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - log.info( - "[DefaultCP] onTask: agent={}, step={}", - request.getAgentName(), - request.getStepName()); - return engineClient - .sendMessage(request.getAgentName(), request.getMessage()) - .thenApply( - r -> { - boolean success = isSuccessful(r); - log.info( - "[DefaultCP] Response from {}: {} chars, state={}, success={}", - request.getAgentName(), - r.getText() != null ? r.getText().length() : 0, - r.getTaskState(), - success); - return TaskResponse.builder() - .success(success) - .output(r.getText()) - .build(); - }) - .exceptionally( - e -> { - log.error( - "[DefaultCP] Task failed for {}: {}", - request.getAgentName(), - e.getMessage()); - return TaskResponse.builder() - .success(false) - .error("Agent call failed: " + e.getMessage()) - .build(); - }); - } - - private static boolean isSuccessful( - dev.openan.workflow.engine.model.SendMessageResult result) { - String state = result.getTaskState(); - if (state == null || state.isBlank() || state.endsWith("UNSPECIFIED")) { - return result.getText() != null && !result.getText().isBlank(); - } - return state.endsWith("COMPLETED"); + this(null); } - @Override - public CompletableFuture onSelfTask(TaskRequest request) { - log.info( - "[DefaultCP] onSelfTask: step={}, agent={} (local, no A2A-T)", - request.getStepName(), - request.getAgentName()); - return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output(request.getMessage()).build()); - } - - @Override - public CompletableFuture onRoute( - String stepName, Map results, List conditions) { - String nextStep = conditions.get(0).getStep(); - for (JumpCondition jc : conditions) { - String step = jc.getStep(); - if (!"end".equals(step) && !"retry".equals(step) && !"endNode".equals(step)) { - nextStep = step; - break; - } - } - log.info("[DefaultCP] onRoute: {} -> {}", stepName, nextStep); - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(nextStep) - .reason("default: first non-terminal branch") - .build()); + public DefaultControlPoint(NegotiationStrategy strategy) { + this.strategy = strategy; } @Override - public CompletableFuture onNegotiation( - String agentName, String negotiationText, Map receiveResult) { - if (negotiationStrategy != null) { - return negotiationStrategy.resolve(agentName, negotiationText, receiveResult); - } - log.info("[DefaultCP] onNegotiation: agent={}, concern={}", agentName, negotiationText); - return CompletableFuture.completedFuture( - "Please proceed with the original task using available information."); + public CompletableFuture onNegotiation(NegotiationRequest request) { + return strategy == null + ? ControlPoint.super.onNegotiation(request) + : strategy.resolve(request); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/NegotiationStrategy.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/NegotiationStrategy.java index 233a91f9..1c1e48c9 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/NegotiationStrategy.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/NegotiationStrategy.java @@ -19,15 +19,17 @@ package dev.openan.workflow.engine.control; -import java.util.Map; +import dev.openan.workflow.engine.model.NegotiationReply; +import dev.openan.workflow.engine.model.NegotiationRequest; + import java.util.concurrent.CompletableFuture; /** - * Strategy for generating negotiation clarifications. + * Strategy for making typed negotiation decisions. * *

Single responsibility: when an agent returns INPUT_REQUIRED (Negotiation-T), produce the - * clarification text to send back. This is a separate concern from workflow orchestration - * (task dispatch, routing). Users who need custom negotiation logic (LLM-based clarification, + * typed decision to send back. This is a separate concern from workflow orchestration + * (task dispatch, routing). Users who need custom negotiation logic (LLM-based decisions, * DAG-predecessor forwarding, etc.) implement this interface and inject it into {@link * DefaultControlPoint} rather than mixing negotiation policy into their ControlPoint class. * @@ -38,13 +40,10 @@ public interface NegotiationStrategy { /** - * Generate a clarification for the given negotiation request. + * Make a business decision for the given negotiation request. * - * @param agentName the agent requesting negotiation - * @param negotiationText the concern/question raised by the agent - * @param receiveResult the raw negotiation context (may be null/empty) - * @return future completing with the clarification text + * @param request typed business view of the received negotiation request + * @return future completing with final content to send, or a local stop */ - CompletableFuture resolve( - String agentName, String negotiationText, Map receiveResult); + CompletableFuture resolve(NegotiationRequest request); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ContextBuilder.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ContextBuilder.java index 3987b4cc..2b6c6396 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ContextBuilder.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ContextBuilder.java @@ -21,6 +21,9 @@ import dev.openan.workflow.engine.model.Workflow; import dev.openan.workflow.engine.model.WorkflowStep; +import dev.openan.workflow.engine.model.TaskExecutionResult; +import dev.openan.workflow.engine.model.UpstreamStepResult; +import dev.openan.workflow.engine.model.WorkflowInput; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,7 +37,7 @@ import java.util.Map; import java.util.Set; -/** Context assembly from upstream step outputs. Mirrors Python ContextBuilder. */ +/** Selects typed upstream execution results according to the workflow graph. */ class ContextBuilder { private static final Logger log = LoggerFactory.getLogger(ContextBuilder.class); private final Workflow workflow; @@ -88,63 +91,38 @@ public List getAllPredecessors(String stepName) { return new ArrayList<>(ancestors); } - public String buildContext(WorkflowStep step, Map> stepOutputs) { - if (step.getLayer() <= 0) { - log.info("[Context] Step {}: layer 0, using runtime intent only", step.getName()); - return runtimeIntent.isEmpty() ? "" : "## Runtime Context\n\n" + runtimeIntent; - } - List parts = new ArrayList<>(); - if (!runtimeIntent.isEmpty()) { - parts.add("## Runtime Context\n\n" + runtimeIntent); - } - parts.add("## Previous Step Execution Results\n"); - List>> refPairs = new ArrayList<>(); + public WorkflowInput buildWorkflowInput(WorkflowStep step, Map> stepResults) { + List selectedSteps = new ArrayList<>(); if (step.getContextFrom() != null && step.getContextFrom().contains("*")) { for (String name : getAllPredecessors(step.getName())) { - if (stepOutputs.containsKey(name)) { - refPairs.add(Map.entry(name, stepOutputs.get(name))); + if (stepResults.containsKey(name)) { + selectedSteps.add(name); } } } else if (step.getContextFrom() != null && !step.getContextFrom().isEmpty()) { for (String name : step.getContextFrom()) { - if (stepOutputs.containsKey(name)) { - refPairs.add(Map.entry(name, stepOutputs.get(name))); + if (stepResults.containsKey(name)) { + selectedSteps.add(name); } } - } else { + } else if (step.getContextFrom() == null) { for (String name : getStepPredecessors(step.getName())) { - if (stepOutputs.containsKey(name)) { - refPairs.add(Map.entry(name, stepOutputs.get(name))); + if (stepResults.containsKey(name)) { + selectedSteps.add(name); } } } - for (var entry : refPairs) { - parts.add("### " + entry.getKey() + " Results\n"); - for (var taskEntry : entry.getValue().entrySet()) { - String text = - taskEntry.getValue() instanceof String - ? (String) taskEntry.getValue() - : String.valueOf(taskEntry.getValue()); - parts.add("**Task**: " + taskEntry.getKey() + "\n**Output**: " + text + "\n\n"); - } - } - String result = String.join("\n", parts).trim(); - log.info("[Context] Step {}: built context ({} chars)", step.getName(), result.length()); - return result; - } - - public String buildTaskMessage(String taskDescription, String contextMessage, String lang) { - String langHint = ""; - if ("en".equals(lang)) { - langHint = "\n\nPlease respond in English."; - } - if ("zh".equals(lang)) { - langHint = "\n\n请用中文回复。"; - } - if (contextMessage != null && !contextMessage.isEmpty()) { - return contextMessage + "\n\n## Current Task\n" + taskDescription + langHint; + List upstreamResults = new ArrayList<>(); + for (String name : selectedSteps) { + upstreamResults.add(new UpstreamStepResult(name, stepResults.get(name))); } - return taskDescription + langHint; + int resultCount = + upstreamResults.stream().mapToInt(result -> result.taskResults().size()).sum(); + log.info( + "[Context] Step {}: selected {} upstream step(s), {} task result(s)", step.getName(), + upstreamResults.size(), + resultCount); + return new WorkflowInput(runtimeIntent, upstreamResults); } public Integer findStepIndex(String stepName) { diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/FailureMapping.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/FailureMapping.java new file mode 100644 index 00000000..212c35a7 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/FailureMapping.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.core; + +import dev.openan.workflow.engine.model.BusinessFailure; +import dev.openan.workflow.engine.model.TaskResult; +import java.util.*; + +/** Maps only generic failures or explicitly safe host-selected business facts. */ +final class FailureMapping { + static TaskResult from(Throwable error) { + Throwable root = error; + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + while (root.getCause() != null && seen.add(root) && !(root instanceof BusinessFailure)) { + root = root.getCause(); + } + if (root instanceof BusinessFailure business) { + return TaskResult.builder().success(false).errorCode(business.code()) + .error(business.getMessage()).errorDetails(business.details()).build(); + } + String code = root instanceof java.util.concurrent.TimeoutException ? "workflow.timeout" + : root instanceof java.util.concurrent.CancellationException ? "workflow.cancelled" + : "workflow.execution_failed"; + // Raw SDK/provider exceptions may contain credentials; the host chooses safe business facts. + return TaskResult.failure(code, root.getClass().getSimpleName()); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ProtocolResultAdapter.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ProtocolResultAdapter.java new file mode 100644 index 00000000..0408883b --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ProtocolResultAdapter.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.core; + +import dev.openan.workflow.engine.model.SendMessageResult; +import dev.openan.workflow.engine.model.TaskResult; + +/** Maps transport state independently from the presence or absence of business content. */ +final class ProtocolResultAdapter { + private ProtocolResultAdapter() {} + + static TaskResult toTaskResult(SendMessageResult result) { + if (result == null) return TaskResult.failure("remote.no_response", "Agent returned no response"); + String state = result.getTaskState(); + boolean standaloneMessage = result.getTask() == null && !result.getReceivedMessages().isEmpty(); + boolean success = "TASK_STATE_COMPLETED".equals(state) + || ((state == null || state.isBlank() || state.endsWith("UNSPECIFIED")) && standaloneMessage); + if (result.getFailureCode() != null) success = false; + return TaskResult.builder().success(success) + .receivedMessages(result.getReceivedMessages()) + .error(success ? null : result.getFailureCode() == null + ? "Agent returned state=" + state : result.getFailureMessage()) + .errorCode(success ? null : result.getFailureCode() == null ? "remote.task_failed" : result.getFailureCode()) + .build(); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java index 22884d79..41a5ab58 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java @@ -28,10 +28,12 @@ import dev.openan.workflow.engine.model.StepType; import dev.openan.workflow.engine.model.Task; import dev.openan.workflow.engine.model.TaskRequest; -import dev.openan.workflow.engine.model.TaskResponse; import dev.openan.workflow.engine.model.TaskStatus; import dev.openan.workflow.engine.model.Workflow; import dev.openan.workflow.engine.model.WorkflowStep; +import dev.openan.workflow.engine.model.TaskExecutionResult; +import dev.openan.workflow.engine.model.TaskResult; +import dev.openan.workflow.engine.model.WorkflowInput; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,12 +42,14 @@ import java.util.Collections; import java.util.Deque; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; /** @@ -56,12 +60,17 @@ public class WorkflowExecutor { private static final Logger log = LoggerFactory.getLogger(WorkflowExecutor.class); private final Workflow workflow; + private final AtomicBoolean started = new AtomicBoolean(); + private final AtomicBoolean stopped = new AtomicBoolean(); + private final Set> activeTasks = ConcurrentHashMap.newKeySet(); private final ControlPoint controlPoint; private final WorkflowEngineClient engineClient; private final EventCallback eventCallback; private final ContextBuilder contextBuilder; private final String lang; + private final String executionId = java.util.UUID.randomUUID().toString(); private final Map> stepOutputs = new ConcurrentHashMap<>(); + private final Map> stepExecutionResults = new ConcurrentHashMap<>(); private final List> executionHistory = Collections.synchronizedList(new ArrayList<>()); @@ -72,11 +81,13 @@ public WorkflowExecutor( EventCallback eventCallback, String runtimeIntent, String lang) { - this.workflow = workflow; + this.workflow = + new com.fasterxml.jackson.databind.ObjectMapper() + .convertValue(workflow, Workflow.class); this.controlPoint = controlPoint; this.engineClient = engineClient; this.eventCallback = eventCallback != null ? eventCallback : new EventCallback(); - this.contextBuilder = new ContextBuilder(workflow, runtimeIntent); + this.contextBuilder = new ContextBuilder(this.workflow, runtimeIntent); this.lang = lang != null ? lang : "zh"; try { this.engineClient.setControlPoint(this.controlPoint); @@ -121,6 +132,9 @@ private void emit(String type, Map data) { } public CompletableFuture run() { + if (!started.compareAndSet(false, true)) + return CompletableFuture.failedFuture( + new IllegalStateException("WorkflowExecutor is single-use")); // NOTE: START lifecycle event is emitted by the runner (ExecutePsop), // not here. Mirrors Python SDK where the executor emits only // step/task/route events and the runner emits start/complete/error/close. @@ -142,17 +156,19 @@ public CompletableFuture run() { .build()); } Deque pending = new ConcurrentLinkedDeque<>(); + Set scheduled = ConcurrentHashMap.newKeySet(); Set activated = ConcurrentHashMap.newKeySet(); for (int i = 0; i < workflow.getSteps().size(); i++) { var s = workflow.getSteps().get(i); if (contextBuilder.getStepPredecessors(s.getName()).isEmpty()) { pending.add(i); + scheduled.add(i); activated.add(i); } } Set executed = ConcurrentHashMap.newKeySet(); - boolean[] failed = {false}; - return executeSteps(pending, executed, activated, failed) + AtomicBoolean failed = new AtomicBoolean(); + CompletableFuture execution = executeSteps(pending, scheduled, executed, activated, failed) .thenApply( v -> { emit(EventType.WORKFLOW_COMPLETE, Map.of()); @@ -161,10 +177,10 @@ public CompletableFuture run() { workflow.getName(), executionHistory.size()); return ExecutionResult.builder() - .success(!failed[0]) + .success(!failed.get()) .history(new ArrayList<>(executionHistory)) .stepOutputs(new HashMap<>(stepOutputs)) - .error(failed[0] ? "Step execution failed" : null) + .error(failed.get() ? "Step execution failed" : null) .build(); }) .exceptionally( @@ -178,14 +194,20 @@ public CompletableFuture run() { .error(e.getMessage()) .build(); }); + execution.whenComplete((value, error) -> { + stopped.set(true); + activeTasks.forEach(task -> task.cancel(true)); + }); + return execution; } private CompletableFuture executeSteps( Deque pending, + Set scheduled, Set executed, Set activated, - boolean[] failed) { - if (pending.isEmpty() || failed[0]) { + AtomicBoolean failed) { + if (pending.isEmpty() || failed.get() || stopped.get()) { return CompletableFuture.completedFuture(null); } @@ -258,24 +280,25 @@ private CompletableFuture executeSteps( for (int idx : readySteps) { executed.add(idx); var step = workflow.getSteps().get(idx); - stepFutures.add(executeStep(step, executed, activated, pending, failed)); + stepFutures.add(executeStep(step, scheduled, activated, pending, failed)); } return CompletableFuture.allOf(stepFutures.toArray(new CompletableFuture[0])) - .thenCompose(v -> executeSteps(pending, executed, activated, failed)); + .thenCompose(v -> executeSteps(pending, scheduled, executed, activated, failed)); } private CompletableFuture executeStep( WorkflowStep step, - Set executed, + Set scheduled, Set activated, Deque pending, - boolean[] failed) { + AtomicBoolean failed) { emit(EventType.STEP_START, Map.of("step", step.getName())); log.info("--- Executing step: {} ---", step.getName()); return executeSubtasks(step) .thenCompose( result -> { stepOutputs.put(step.getName(), result.results()); + stepExecutionResults.put(step.getName(), result.taskResults()); if (!result.success()) { log.error("Step {} failed, stopping.", step.getName()); emit( @@ -285,7 +308,7 @@ private CompletableFuture executeStep( step.getName(), "results", result.results())); - failed[0] = true; + failed.set(true); return CompletableFuture.completedFuture(null); } emit( @@ -296,9 +319,8 @@ private CompletableFuture executeStep( nextIndices -> { for (int i = nextIndices.size() - 1; i >= 0; i--) { int nxt = nextIndices.get(i); - if (!executed.contains(nxt) - && !pending.contains(nxt)) { - activated.add(nxt); + activated.add(nxt); + if (scheduled.add(nxt)) { pending.addFirst(nxt); } } @@ -332,6 +354,35 @@ private void validateWorkflowGraph() { graph.get(i).add(target); } } + for (WorkflowStep step : workflow.getSteps()) { + List contextFrom = step.getContextFrom(); + if (contextFrom == null || contextFrom.isEmpty()) continue; + if (contextFrom.contains("*") && contextFrom.size() != 1) { + throw new IllegalArgumentException( + "Step " + + step.getName() + + " cannot combine '*' with named context sources"); + } + if (contextFrom.contains("*")) continue; + Set ancestors = Set.copyOf(contextBuilder.getAllPredecessors(step.getName())); + for (String source : contextFrom) { + if (!indices.containsKey(source)) { + throw new IllegalArgumentException( + "Step " + + step.getName() + + " references missing context source " + + source); + } + if (!ancestors.contains(source)) { + throw new IllegalArgumentException( + "Step " + + step.getName() + + " context source " + + source + + " is not an upstream dependency"); + } + } + } int[] state = new int[workflow.getSteps().size()]; for (int i = 0; i < state.length; i++) detectCycle(i, graph, state); } @@ -355,15 +406,21 @@ private static boolean isTerminalRoute(String stepName) { } private record StepResult( - String taskDesc, String output, boolean success, Map results) {} + String taskDesc, + String agentName, + int subtaskIndex, + List outputs, + boolean success, + Map results, + List taskResults) {} private CompletableFuture executeSubtasks(WorkflowStep step) { - String contextMessage = contextBuilder.buildContext(step, stepOutputs); + var workflowInput = contextBuilder.buildWorkflowInput(step, stepExecutionResults); List> futures = new ArrayList<>(); for (int i = 0; i < step.getSubtasks().size(); i++) { final int subtaskIndex = i; final var task = step.getSubtasks().get(i); - var request = buildTaskRequest(step, task, subtaskIndex, contextMessage); + var request = buildTaskRequest(step, task, subtaskIndex, workflowInput); emit( EventType.TASK_REQUEST, Map.of( @@ -373,10 +430,14 @@ private CompletableFuture executeSubtasks(WorkflowStep step) { task.getAgent(), "task", task.getDescription())); - futures.add( - dispatchTask(step, request) - .thenApply(r -> processTaskResponse(step, task, subtaskIndex, r)) - .exceptionally(e -> processTaskError(step, task, subtaskIndex, e))); + CompletableFuture dispatch = dispatchTask(step, request); + CompletableFuture processed = dispatch + .thenApply(r -> processTaskResult(step, task, subtaskIndex, r)) + .exceptionally(e -> processTaskError(step, task, subtaskIndex, e)); + processed.whenComplete((result, error) -> { + if (processed.isCancelled()) dispatch.cancel(true); + }); + futures.add(processed); } if (step.getStepType() == StepType.ANY_SUCCESS) { return anySuccess(futures); @@ -385,54 +446,131 @@ private CompletableFuture executeSubtasks(WorkflowStep step) { .thenApply(v -> collectAllSuccess(futures)); } + private String taskId(String stepName, int index) { + return java.util + .UUID + .nameUUIDFromBytes( + (executionId + ":" + stepName + ":" + index) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)) + .toString(); + } + private TaskRequest buildTaskRequest( - WorkflowStep step, Task task, int subtaskIndex, String contextMessage) { - String taskMessage = - contextBuilder.buildTaskMessage(task.getDescription(), contextMessage, lang); + WorkflowStep step, Task task, int subtaskIndex, WorkflowInput workflowInput) { log.info( "[Executor] Dispatching task: step={}, agent={}, subtask_index={}, desc={}", step.getName(), task.getAgent(), subtaskIndex, task.getDescription()); - log.debug("[Executor] Task message to {}: [{}]", task.getAgent(), taskMessage); return TaskRequest.builder() .agentName(task.getAgent()) .skill(task.getSkill()) - .message(taskMessage) - .description(task.getDescription()) - .context(contextMessage) + .instruction(task.getDescription()) + .language(lang) .stepName(step.getName()) - .subtaskIndex(subtaskIndex) + .executionId(executionId) + .taskId(taskId(step.getName(), subtaskIndex)) + .input( + task.getInput() == null + ? dev.openan.workflow.engine.model.BusinessInput.text( + task.getDescription()) + : task.getInput()) + .workflowInput(workflowInput) .build(); } - private CompletableFuture dispatchTask(WorkflowStep step, TaskRequest request) { - if (step.getStepType() == StepType.SELF_LOOP) { - log.info( - "[Executor] Self-loop task: step={}, agent={} (local, no A2A-T)", - step.getName(), - request.getAgentName()); - return controlPoint.onSelfTask(request); - } - return controlPoint.onTask(request, engineClient); + private CompletableFuture dispatchTask(WorkflowStep step, TaskRequest request) { + CompletableFuture result = new CompletableFuture<>(); + activeTasks.add(result); + result.orTimeout(engineClient.callbackTimeoutSeconds(), java.util.concurrent.TimeUnit.SECONDS); + result.whenComplete((value, error) -> activeTasks.remove(result)); + CompletableFuture.runAsync(() -> { + try { + if (result.isDone() || stopped.get()) return; + if (step.getStepType() == StepType.SELF_LOOP) { + completeFrom(result, java.util.Objects.requireNonNull(controlPoint.onSelfTask(request), + "onSelfTask returned null future")); + return; + } + var prepared = java.util.Objects.requireNonNull(controlPoint.onTask(request), "onTask returned null future"); + prepared.whenComplete((content, error) -> { + if (result.isDone() || stopped.get()) { + log.debug("[Executor] Late callback ignored step={}, taskId={}", request.getStepName(), request.getTaskId()); + return; + } + if (error != null) { result.completeExceptionally(error); return; } + try { + var sent = engineClient.dispatch(request, + java.util.Objects.requireNonNull(content, "onTask returned null content"), controlPoint); + result.whenComplete((value, failure) -> { if (!sent.isDone()) sent.cancel(true); }); + completeFrom(result, sent.thenApply(ProtocolResultAdapter::toTaskResult)); + } catch (RuntimeException failure) { result.completeExceptionally(failure); } + }); + } catch (RuntimeException error) { result.completeExceptionally(error); } + }); + return result; + } + + private static void completeFrom(CompletableFuture destination, CompletableFuture source) { + source.whenComplete((value, error) -> { + if (error != null) destination.completeExceptionally(error); + else if (value == null) destination.completeExceptionally(new IllegalStateException("Callback returned null")); + else destination.complete(value); + }); } private StepResult collectAllSuccess(List> futures) { - Map results = new HashMap<>(); + List completedResults = new ArrayList<>(); boolean anyFailed = false; for (var f : futures) { var r = f.join(); - results.put(r.taskDesc(), r.output()); + completedResults.add(r); if (!r.success()) { anyFailed = true; } } - return new StepResult(null, null, !anyFailed, results); + return new StepResult( + null, null, -1, + List.of(), !anyFailed, collectTaskResults(completedResults), + collectExecutionResults(completedResults)); + } + + private static List collectExecutionResults( + List completedResults) { + return completedResults.stream().flatMap(result -> result.taskResults().stream()).toList(); } - private StepResult processTaskResponse( - WorkflowStep step, Task task, int subtaskIndex, TaskResponse response) { + /** Keeps every subtask result even when descriptions repeat within one parallel step. */ + private static Map collectTaskResults(List completedResults) { + Map descriptionCounts = + completedResults.stream() + .filter(result -> result.taskDesc() != null) + .collect( + Collectors.groupingBy( + StepResult::taskDesc, + LinkedHashMap::new, + Collectors.counting())); + Map results = new LinkedHashMap<>(); + for (StepResult result : completedResults) { + if (result.taskDesc() == null) continue; + boolean duplicate = descriptionCounts.getOrDefault(result.taskDesc(), 0L) > 1; + String key = + duplicate + ? result.taskDesc() + + " [" + + result.agentName() + + "#" + + result.subtaskIndex() + + "]" + : result.taskDesc(); + results.put(key, result.outputs()); + } + return results; + } + + private StepResult processTaskResult( + WorkflowStep step, Task task, int subtaskIndex, TaskResult response) { task.setStatus(response.isSuccess() ? TaskStatus.SUCCESS : TaskStatus.FAILED); emit( EventType.TASK_STATUS_CHANGED, @@ -447,72 +585,78 @@ private StepResult processTaskResponse( task.getStatus().getValue())); String status = response.isSuccess() ? "success" : "failed"; log.info("[Executor] Task {} -> {}: {}", task.getDescription(), task.getAgent(), status); - if (response.isSuccess() && response.getOutput() != null) { + if (response.isSuccess() && !response.getOutputs().isEmpty()) { log.debug( - "[Executor] Task output from {}: [{}]", task.getAgent(), response.getOutput()); + "[Executor] Task outputs from {}: [{}]", task.getAgent(), response.getOutputs()); } - String output = - response.isSuccess() - ? response.getOutput() - : (response.getError() != null ? response.getError() : ""); + List outputs = + response.getOutputs(); executionHistory.add( Map.of( "step", step.getName(), + "subtask_index", + subtaskIndex, "task", task.getDescription(), "agent", task.getAgent(), "status", status, - "output", - output)); + "outputs", + outputs, + "error", + response.getError() == null ? "" : response.getError(), + "errorCode", + response.getErrorCode() == null ? "" : response.getErrorCode(), + "errorDetails", + response.getErrorDetails())); emit( EventType.TASK_RESPONSE, Map.of( "step", step.getName(), + "subtask_index", + subtaskIndex, "agent", task.getAgent(), "task", task.getDescription(), - "output", - output)); - return new StepResult(task.getDescription(), output, response.isSuccess(), null); + "outputs", + outputs, + "success", + response.isSuccess(), + "error", + response.getError() == null ? "" : response.getError(), + "errorCode", + response.getErrorCode() == null ? "" : response.getErrorCode(), + "errorDetails", + response.getErrorDetails())); + return new StepResult( + task.getDescription(), + task.getAgent(), + subtaskIndex, + outputs, + response.isSuccess(), + null, + List.of( + new TaskExecutionResult( + task.getAgent(), + task.getSkill(), + taskId(step.getName(), subtaskIndex), + task.getDescription(), + task.getStatus(), + outputs, + response.getReceivedMessages(), + response.getError(), + response.getErrorCode(), + response.getErrorDetails()))); } private StepResult processTaskError( WorkflowStep step, Task task, int subtaskIndex, Throwable e) { - task.setStatus(TaskStatus.FAILED); - emit( - EventType.TASK_STATUS_CHANGED, - Map.of( - "step", - step.getName(), - "subtask_index", - subtaskIndex, - "agent", - task.getAgent(), - "status", - TaskStatus.FAILED.getValue())); - log.error( - "[Executor] Task {} -> {}: exception: {}", - task.getDescription(), - task.getAgent(), - e.getMessage()); - executionHistory.add( - Map.of( - "step", - step.getName(), - "task", - task.getDescription(), - "agent", - task.getAgent(), - "status", - "failed", - "output", - e.getMessage())); - return new StepResult(task.getDescription(), e.getMessage(), false, null); + return processTaskResult(step, task, + subtaskIndex, FailureMapping.from(e)); } /** @@ -522,12 +666,14 @@ private StepResult processTaskError( */ private CompletableFuture anySuccess(List> futures) { if (futures.isEmpty()) { - return CompletableFuture.completedFuture(new StepResult(null, null, true, Map.of())); + return CompletableFuture.completedFuture( + new StepResult(null, null, -1, List.of(), true, Map.of(), List.of())); } CompletableFuture result = new CompletableFuture<>(); int total = futures.size(); int[] completed = {0}; int[] failedCount = {0}; + boolean[] winnerChosen = {false}; for (CompletableFuture f : futures) { f.handle( @@ -536,6 +682,7 @@ private CompletableFuture anySuccess(List other : futures) { if (!other.isDone()) { @@ -543,38 +690,54 @@ private CompletableFuture anySuccess(List results = new HashMap<>(); + List completedResults = new ArrayList<>(); for (CompletableFuture cf : futures) { if (cf.isDone() && !cf.isCompletedExceptionally()) { try { var r = cf.join(); if (r != null && r.taskDesc() != null) { - results.put(r.taskDesc(), r.output()); + completedResults.add(r); } } catch (Exception ignored) { // Cancellation race, skip } } } - result.complete(new StepResult(null, null, true, results)); - } else if (!success) { + result.complete( + new StepResult( + null, + null, + -1, + List.of(), + true, + collectTaskResults(completedResults), + collectExecutionResults(completedResults))); + } else if (!success && !winnerChosen[0]) { failedCount[0]++; if (completed[0] == total && !result.isDone()) { // All failed - Map results = new HashMap<>(); + List completedResults = new ArrayList<>(); for (CompletableFuture cf : futures) { if (cf.isDone() && !cf.isCompletedExceptionally()) { try { var r = cf.join(); if (r != null && r.taskDesc() != null) { - results.put(r.taskDesc(), r.output()); + completedResults.add(r); } } catch (Exception ignored) { // Cancellation race, skip } } } - result.complete(new StepResult(null, null, false, results)); + result.complete( + new StepResult( + null, + null, + -1, + List.of(), + false, + collectTaskResults(completedResults), + collectExecutionResults(completedResults))); } } } @@ -603,19 +766,21 @@ private CompletableFuture> determineNextSteps( } return CompletableFuture.completedFuture(indices); } - // Build context for onRoute: upstream context_from results + current step results - Map routeContext = new HashMap<>(); - if (step.getContextFrom() != null) { - for (String ref : step.getContextFrom()) { - Map out = stepOutputs.get(ref); - if (out != null) { - routeContext.put(ref, out); - } - } - } - routeContext.put(step.getName(), stepResult); + var routeRequest = new dev.openan.workflow.engine.model.RouteRequest( + executionId, + step.getName(), + contextBuilder.buildWorkflowInput(step, stepExecutionResults), + stepExecutionResults.getOrDefault(step.getName(), List.of()), + step.getNext().stream() + .map( + option -> + new dev.openan.workflow.engine.model.RouteRequest + .RouteOption( + option.getStep(), option.getCondition())) + .toList()); return controlPoint - .onRoute(step.getName(), routeContext, step.getNext()) + .onRoute(routeRequest) + .orTimeout(engineClient.callbackTimeoutSeconds(), java.util.concurrent.TimeUnit.SECONDS) .thenApply( decision -> { log.info( diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessFailure.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessFailure.java new file mode 100644 index 00000000..76b3995b --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessFailure.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.Map; + +/** Host-supplied safe business failure. Do not include credentials or raw provider errors. */ +public final class BusinessFailure extends RuntimeException { + private final String code; + private final Map details; + + /** Constructs a failure with an application code and explicitly safe diagnostic fields. */ + public BusinessFailure(String code, String message, Map details) { + super(message); + if (code == null || code.isBlank()) throw new IllegalArgumentException("Failure code required"); + this.code = code; + this.details = details == null ? Map.of() : BusinessValues.map(details); + } + + /** Machine-readable business failure code. */ + public String code() { return code; } + + /** Safe business facts selected by the host. */ + public Map details() { return details; } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessInput.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessInput.java new file mode 100644 index 00000000..216b11ea --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessInput.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.Map; + +/** Business content, not a protocol message. Exactly one of text/data is present. */ +public record BusinessInput(String text, Object data) { + public BusinessInput { + if ((text == null) == (data == null)) + throw new IllegalArgumentException("Exactly one of text or data is required"); + if (text != null && text.isBlank()) + throw new IllegalArgumentException("Text must not be blank"); + data = data == null ? null : BusinessValues.snapshot(data); + } + + /** Natural-language input. */ + public static BusinessInput text(String text) { + return new BusinessInput(text, null); + } + + /** Arbitrary JSON input; any schema and interpretation belong to the host. */ + public static BusinessInput data(Object data) { + return new BusinessInput(null, data); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessValues.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessValues.java new file mode 100644 index 00000000..5a6cb7b4 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessValues.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.*; + +/** Defensive JSON snapshots: payloads are unrestricted JSON, never shared mutable objects. */ +public final class BusinessValues { + private static final ObjectMapper JSON = new ObjectMapper(); + + private BusinessValues() {} + + /** Snapshots any JSON value, preserving nested arrays as values rather than outputs. */ + public static Object snapshot(Object value) { + return freeze(JSON.convertValue(value, Object.class)); + } + + /** Snapshots an ordered JSON object. */ + @SuppressWarnings("unchecked") + public static Map map(Map value) { + return (Map) freeze(JSON.convertValue(value, Map.class)); + } + + /** Snapshots ordered outputs without flattening nested arrays. */ + @SuppressWarnings("unchecked") + public static List list(List value) { + return value == null + ? List.of() + : (List) freeze(JSON.convertValue(value, List.class)); + } + + private static Object freeze(Object value) { + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + map.forEach((k, v) -> copy.put(String.valueOf(k), freeze(v))); + return Collections.unmodifiableMap(copy); + } + if (value instanceof List list) { + List copy = new ArrayList<>(); + list.forEach(v -> copy.add(freeze(v))); + return Collections.unmodifiableList(copy); + } + return value; + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/MessageContent.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/MessageContent.java new file mode 100644 index 00000000..dc6adcf0 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/MessageContent.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.FilePart; +import org.a2aproject.sdk.spec.FileWithBytes; +import org.a2aproject.sdk.spec.FileWithUri; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.TextPart; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Final business content. The engine owns the A2A envelope and transport headers. */ +public record MessageContent( + List> parts, Map metadata, Set extensions) { + + public MessageContent { + parts = snapshotParts(Objects.requireNonNull(parts, "parts")); + metadata = metadata == null ? Map.of() : BusinessValues.map(metadata); + LinkedHashSet active = new LinkedHashSet<>(); + if (extensions != null) { + for (String uri : extensions) { + if (uri == null || uri.isBlank()) { + throw new IllegalArgumentException("Extension URI must not be blank"); + } + active.add(uri); + } + } + extensions = Collections.unmodifiableSet(active); + } + + /** Plain A2A text, without implicit extension activation or generation. */ + public static MessageContent text(String text) { + return parts(List.of(new TextPart(Objects.requireNonNull(text, "text")))); + } + + /** Ordered standard A2A parts without message-level metadata or extensions. */ + public static MessageContent parts(List> parts) { + return new MessageContent(parts, Map.of(), Set.of()); + } + + static List> snapshotParts(List> parts) { + return parts.stream().map(MessageContent::snapshotPart).toList(); + } + + private static Part snapshotPart(Part part) { + Objects.requireNonNull(part, "part"); + if (part instanceof TextPart text) { + return new TextPart(text.text(), snapshotMetadata(text.metadata())); + } + if (part instanceof DataPart data) { + return new DataPart(BusinessValues.snapshot(data.data()), snapshotMetadata(data.metadata())); + } + if (part instanceof FilePart file + && (file.file() instanceof FileWithUri || file.file() instanceof FileWithBytes)) { + // File references are never dereferenced by snapshotting. Byte sources are SDK-owned. + return new FilePart(file.file(), snapshotMetadata(file.metadata())); + } + throw new IllegalArgumentException("Unsupported A2A part: " + part.getClass().getName()); + } + + static Map snapshotMetadata(Map metadata) { + return metadata == null ? null : BusinessValues.map(metadata); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationReply.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationReply.java new file mode 100644 index 00000000..cf20e27c --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationReply.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.Objects; + +/** Business chooses a final protocol reply or a local stop; the engine never generates an Abort. */ +public sealed interface NegotiationReply { + /** Final content to send on the original remote task and context. */ + record Send(MessageContent content) implements NegotiationReply { + public Send { + Objects.requireNonNull(content, "content"); + } + } + + /** Ends local execution only. Does not imply a protocol Abort was sent or acknowledged. */ + record Stop(String code, String reason) implements NegotiationReply { + public Stop { + if (code == null || code.isBlank() || reason == null || reason.isBlank()) { + throw new IllegalArgumentException("Stop code and reason are required"); + } + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationRequest.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationRequest.java new file mode 100644 index 00000000..dddacdbd --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationRequest.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** Uninterpreted received content and history for one task's current negotiation session. */ +public record NegotiationRequest( + TaskRequest task, + MessageContent originalSubmission, + ReceivedMessage received, + List previousExchanges, + Duration remainingWait) { + + /** One completed local interaction, without an engine-defined business schema. */ + public record Exchange(ReceivedMessage received, NegotiationReply reply) { + public Exchange { + Objects.requireNonNull(received, "received"); + Objects.requireNonNull(reply, "reply"); + } + } + + public NegotiationRequest { + Objects.requireNonNull(task, "task"); + Objects.requireNonNull(originalSubmission, "originalSubmission"); + Objects.requireNonNull(received, "received"); + previousExchanges = List.copyOf(previousExchanges); + Objects.requireNonNull(remainingWait, "remainingWait"); + if (remainingWait.isNegative()) throw new IllegalArgumentException("Negative remaining wait"); + } + + /** Counterpart identity, not a remote protocol task identifier. */ + public String agentName() { + return task.getAgentName(); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/ReceivedMessage.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/ReceivedMessage.java new file mode 100644 index 00000000..86334bd7 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/ReceivedMessage.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.TextPart; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Response snapshot preserving message, task and artifact metadata at their original levels. */ +public record ReceivedMessage( + MessageContent message, Map taskMetadata, List artifacts) { + + public ReceivedMessage { + taskMetadata = taskMetadata == null ? Map.of() : BusinessValues.map(taskMetadata); + artifacts = artifacts == null ? List.of() : artifacts.stream() + .map(a -> new Artifact(a.artifactId(), a.name(), a.description(), + MessageContent.snapshotParts(a.parts()), + MessageContent.snapshotMetadata(a.metadata()), + a.extensions() == null ? List.of() : List.copyOf(a.extensions()))) + .toList(); + } + + /** Projects deliverables, or the message when there are no artifacts; never parses text. */ + public List outputs() { return outputs(true); } + + /** Failed/non-final task status messages remain evidence, never business outputs. */ + public List outputs(boolean includeMessage) { + List values = new ArrayList<>(); + // A task's status message is still available in the complete view, but must not + // become an extra deliverable alongside its artifacts (e.g. "Completed"). + if (includeMessage && message != null && artifacts.isEmpty()) { + project(message.parts(), values); + } + for (Artifact artifact : artifacts) { + project(artifact.parts(), values); + } + return BusinessValues.list(values); + } + + private static void project(List> parts, List values) { + for (Part part : parts) { + if (part instanceof TextPart text) { + values.add(text.text()); + } else if (part instanceof DataPart data) { + values.add(data.data()); + } + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/RouteRequest.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/RouteRequest.java new file mode 100644 index 00000000..4496161d --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/RouteRequest.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.List; + +/** Conditional route input; upstream selection is identical to task callbacks. */ +public record RouteRequest( + String executionId, + String stepName, + WorkflowInput workflowInput, + List currentResults, + List candidates) { + public RouteRequest { + currentResults = List.copyOf(currentResults); + candidates = List.copyOf(candidates); + } + + /** A permitted target and its business condition. */ + public record RouteOption(String nextStep, String condition) {} +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/SendMessageResult.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/SendMessageResult.java index bad7b437..9f215d21 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/SendMessageResult.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/SendMessageResult.java @@ -19,7 +19,6 @@ package dev.openan.workflow.engine.model; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @@ -27,14 +26,43 @@ import org.a2aproject.sdk.spec.Task; import java.util.Map; +import java.util.List; @Data @NoArgsConstructor -@AllArgsConstructor -@Builder public class SendMessageResult { - @Builder.Default private String text = ""; + private String text = ""; private Task task; private Map metadata; - @Builder.Default private String taskState = ""; + private String taskState = ""; + /** Local interaction outcome, separate from the actual remote task state. */ + private String failureCode; + private String failureMessage; + + /** Lossless source of all convenience outputs. */ + private List receivedMessages = List.of(); + + @Builder + public SendMessageResult(String text, Task task, Map metadata, String taskState, + String failureCode, String failureMessage, List receivedMessages) { + this.text = text == null ? "" : text; + this.task = task; + this.metadata = metadata == null ? Map.of() : BusinessValues.map(metadata); + this.taskState = taskState == null ? "" : taskState; + this.failureCode = failureCode; + this.failureMessage = failureMessage; + this.receivedMessages = receivedMessages == null ? List.of() : List.copyOf(receivedMessages); + } + + /** Projects text/data values from the retained response, without fallback or semantic parsing. */ + public List getOutputs() { + boolean includeMessage = failureCode == null + && (task == null || "TASK_STATE_COMPLETED".equals(taskState)); + return receivedMessages.stream().flatMap(message -> message.outputs(includeMessage).stream()).toList(); + } + + /** Snapshots the list supplied by transport adapters. */ + public void setReceivedMessages(List messages) { + this.receivedMessages = List.copyOf(messages); + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/Task.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/Task.java index 2c1f0f7d..e96a5a25 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/Task.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/Task.java @@ -30,6 +30,7 @@ @Builder public class Task { private String agent; + private BusinessInput input; @Builder.Default private String skill = ""; @Builder.Default private String description = ""; @Builder.Default private TaskStatus status = TaskStatus.PENDING; diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskExecutionResult.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskExecutionResult.java new file mode 100644 index 00000000..30390cfa --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskExecutionResult.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.List; +import java.util.Objects; + +/** Immutable result of one upstream workflow subtask. */ +public record TaskExecutionResult( + String agentName, + String skill, + String taskId, + String taskDescription, + TaskStatus status, + List outputs, + List receivedMessages, + String error, + String errorCode, + java.util.Map errorDetails) { + + public TaskExecutionResult { + if (agentName == null || agentName.isBlank()) { + throw new IllegalArgumentException("Task result agentName must not be blank"); + } + skill = skill == null ? "" : skill; + Objects.requireNonNull(taskId, "Task id"); + taskDescription = taskDescription == null ? "" : taskDescription; + status = Objects.requireNonNull(status, "Task result status is required"); + receivedMessages = receivedMessages == null ? List.of() : List.copyOf(receivedMessages); + outputs = receivedMessages.isEmpty() ? BusinessValues.list(outputs) + : receivedMessages.stream().flatMap(message -> message.outputs().stream()).toList(); + errorDetails = errorDetails == null ? java.util.Map.of() : BusinessValues.map(errorDetails); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskRequest.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskRequest.java index f2fc1235..e970e627 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskRequest.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskRequest.java @@ -19,21 +19,30 @@ package dev.openan.workflow.engine.model; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.Value; -@Data -@NoArgsConstructor -@AllArgsConstructor +/** Immutable business input for one logical workflow task; protocol identifiers stay internal. */ +@Value @Builder public class TaskRequest { + /** Unique execution identity, separate from remote A2A context IDs. */ + private String executionId; + + /** Logical task identity, stable across self-loop re-entry within this execution. */ + private String taskId; + + /** Business text or structured data, independent of upstream evidence. */ + private BusinessInput input; + private String agentName; private String skill; - private String message; - @Builder.Default private String description = ""; - private String context; + + /** Current task instruction only; never contains rendered upstream results. */ + private String instruction; + @Builder.Default private String language = "zh"; private String stepName; - @Builder.Default private int subtaskIndex = 0; + + /** Engine-assembled, protocol-neutral input selected by the workflow definition. */ + @Builder.Default private WorkflowInput workflowInput = WorkflowInput.empty(); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResponse.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResponse.java deleted file mode 100644 index a7c05db1..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResponse.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.model; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.Map; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class TaskResponse { - private boolean success; - @Builder.Default private String output = ""; - private String error; - private Map metadata; -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResult.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResult.java new file mode 100644 index 00000000..e5407feb --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResult.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the License); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an AS IS BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import lombok.Builder; +import lombok.Value; + +import java.util.List; + +@Value +@Builder +public class TaskResult { + private boolean success; + + /** Ordered business outputs; values may be text or JSON-serializable structured data. */ + @Builder.Default private List outputs = List.of(); + /** Remote evidence; local onSelfTask outputs leave this empty. */ + @Builder.Default private List receivedMessages = List.of(); + + private String error; + private String errorCode; + @Builder.Default private java.util.Map errorDetails = java.util.Map.of(); + + /** Successful local business output, including an intentionally empty output list. */ + public static TaskResult success(List outputs) { + return builder().success(true).outputs(outputs).build(); + } + + /** Business failure; never mixes the error message into the output list. */ + public static TaskResult failure(String code, String message) { + return builder().success(false).errorCode(code).error(message).build(); + } + + public TaskResult( + boolean success, + List outputs, + List receivedMessages, + String error, + String errorCode, + java.util.Map errorDetails) { + this.success = success; + this.receivedMessages = receivedMessages == null ? List.of() : List.copyOf(receivedMessages); + this.outputs = this.receivedMessages.isEmpty() ? BusinessValues.list(outputs) + : this.receivedMessages.stream().flatMap(message -> message.outputs(success).stream()).toList(); + this.error = error; + this.errorCode = errorCode; + this.errorDetails = + BusinessValues.map(errorDetails == null ? java.util.Map.of() : errorDetails); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/UpstreamStepResult.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/UpstreamStepResult.java new file mode 100644 index 00000000..7c8df2f1 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/UpstreamStepResult.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.List; + +/** Immutable, step-scoped group of upstream task results selected by {@code contextFrom}. */ +public record UpstreamStepResult(String stepName, List taskResults) { + + public UpstreamStepResult { + if (stepName == null || stepName.isBlank()) { + throw new IllegalArgumentException("Upstream stepName must not be blank"); + } + taskResults = taskResults == null ? List.of() : List.copyOf(taskResults); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowInput.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowInput.java new file mode 100644 index 00000000..ce6f269c --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowInput.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.List; + +/** + * Protocol-neutral input assembled by the engine for one workflow node. + * + *

The workflow definition decides which upstream steps participate through {@code contextFrom}. + * Agent Task-T/A2A objects are decoded inside the engine before values enter this model, so + * business callbacks only see ordered business outputs and their workflow provenance. + */ +public record WorkflowInput(String runtimeIntent, List upstreamResults) { + + public WorkflowInput { + runtimeIntent = runtimeIntent == null ? "" : runtimeIntent; + upstreamResults = upstreamResults == null ? List.of() : List.copyOf(upstreamResults); + } + + public static WorkflowInput empty() { + return new WorkflowInput("", List.of()); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java index ff5e4d4a..371f2e96 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java @@ -27,13 +27,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.net.ssl.HttpsURLConnection; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; import java.net.URI; import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; -import java.time.Duration; import java.util.List; import java.util.Map; @@ -60,9 +61,7 @@ public static Workflow load( } String url = urlBuilder.toString(); log.info("[Registry] Loading PSOP from {} (ssl_verify={})", url, sslVerify); - HttpClient client = createHttpClient(sslVerify); - HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build(); - HttpResponse resp = client.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResult resp = execute("GET", url, null, sslVerify); if (resp.statusCode() != 200) { throw new RuntimeException("Orchestration center returned " + resp.statusCode()); } @@ -79,21 +78,6 @@ public static Workflow load(String baseUrl, String psopId) throws Exception { return load(baseUrl, psopId, null, true); } - /** - * @param sslVerify set false for self-signed certs (dev only) - * @return ranked list of workflow summaries - */ - private static HttpClient createHttpClient(boolean sslVerify) { - HttpClient.Builder b = - HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(30)) - .followRedirects(HttpClient.Redirect.ALWAYS); - if (!sslVerify) { - b.sslContext(SslContextFactory.createTrustAll()); - } - return b.build(); - } - public static List search( String baseUrl, String intent, int topN, String accessToken, boolean sslVerify) throws Exception { @@ -106,14 +90,7 @@ public static List search( String url = urlBuilder.toString(); log.info("[Registry] Searching PSOP at {} (intent={}, top_n={})", url, intent, topN); String jsonBody = mapper.writeValueAsString(Map.of("intent", intent, "top_n", topN)); - HttpClient client = createHttpClient(sslVerify); - HttpRequest request = - HttpRequest.newBuilder() - .uri(URI.create(url)) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) - .build(); - HttpResponse resp = client.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResult resp = execute("POST", url, jsonBody, sslVerify); if (resp.statusCode() != 200) { throw new RuntimeException("Orchestration center returned " + resp.statusCode()); } @@ -147,6 +124,42 @@ public static List search( return results; } + private static HttpResult execute( + String method, String url, String jsonBody, boolean sslVerify) throws Exception { + HttpURLConnection connection = + (HttpURLConnection) URI.create(url).toURL().openConnection(); + if (!sslVerify && connection instanceof HttpsURLConnection https) { + // Trust policy and hostname policy remain independent: this accepts an untrusted + // development CA, but still rejects a certificate issued for another host. + https.setSSLSocketFactory(SslContextFactory.createTrustAll().getSocketFactory()); + } + try { + connection.setInstanceFollowRedirects(true); + connection.setConnectTimeout(30_000); + connection.setReadTimeout(30_000); + connection.setRequestMethod(method); + if (jsonBody != null) { + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json"); + try (OutputStream output = connection.getOutputStream()) { + output.write(jsonBody.getBytes(StandardCharsets.UTF_8)); + } + } + int status = connection.getResponseCode(); + InputStream input = + status >= 400 ? connection.getErrorStream() : connection.getInputStream(); + String body = input == null ? "" : new String(input.readAllBytes(), StandardCharsets.UTF_8); + if (input != null) { + input.close(); + } + return new HttpResult(status, body); + } finally { + connection.disconnect(); + } + } + + private record HttpResult(int statusCode, String body) {} + /** Convenience: search with defaults (top_n=5, no token, ssl_verify=true). */ public static List search(String baseUrl, String intent) throws Exception { diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/runner/ExecutePsop.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/runner/ExecutePsop.java index 278388a6..a6f367f0 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/runner/ExecutePsop.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/runner/ExecutePsop.java @@ -41,6 +41,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.function.BiConsumer; import java.util.function.BiFunction; @@ -65,7 +66,6 @@ public class ExecutePsop { * @param engineClient optional pre-created client (null = auto-create) * @param runtimeIntent original user intent * @param lang language hint (zh/en) - * @param a2atEnvPath path to A2A-T .env file * @param credentialsConfigPath path to credentials JSON * @param sslVerify whether to verify TLS certificates * @param caCertsPath path to CA trust store @@ -82,7 +82,6 @@ public static CompletableFuture execute( WorkflowEngineClient engineClient, String runtimeIntent, String lang, - String a2atEnvPath, String credentialsConfigPath, boolean sslVerify, String caCertsPath, @@ -92,6 +91,8 @@ public static CompletableFuture execute( onFinish, Function, Object> onEvent) { + Objects.requireNonNull(psop, "psop"); + Objects.requireNonNull(controlPoint, "controlPoint"); EventCallback emitter = eventCallback != null ? eventCallback : new EventCallback(); List> collected = Collections.synchronizedList(new ArrayList<>()); EventCallback collectingCallback = createCollectingCallback(emitter, collected, onEvent); @@ -104,8 +105,8 @@ public static CompletableFuture execute( sslVerify, caCertsPath, credentialsConfigPath, - a2atEnvPath, collectingCallback); + boolean closeClientOnFinish = engineClient == null; WorkflowExecutor executor = new WorkflowExecutor( psop, @@ -127,10 +128,15 @@ public static CompletableFuture execute( .thenCompose( result -> finalizeResult( - result, client, collectingCallback, collected, onFinish)); + result, + client, + closeClientOnFinish, + collectingCallback, + collected, + onFinish)); } - /** Simplified overload without SSL/auth/A2AT config (legacy compatibility). */ + /** Convenience overload using default transport/authentication configuration. */ public static CompletableFuture execute( Workflow psop, List agentCards, @@ -158,7 +164,6 @@ public static CompletableFuture execute( runtimeIntent, lang, null, - null, true, null, a2aClientRuntime, @@ -227,9 +232,13 @@ private static void emitEventMap( collected.add(m); Object typeObj = m.get("type"); Object dataObj = m.get("data"); - emitter.onEvent( - typeObj != null ? typeObj.toString() : defaultType, - dataObj instanceof Map ? (Map) dataObj : Map.of()); + try { + emitter.onEvent( + typeObj != null ? typeObj.toString() : defaultType, + dataObj instanceof Map ? (Map) dataObj : Map.of()); + } catch (Exception e) { + log.warn("[execute_psop] Event callback failed: {}", e.getMessage()); + } } private static WorkflowEngineClient createEngineClient( @@ -238,19 +247,16 @@ private static WorkflowEngineClient createEngineClient( boolean sslVerify, String caCertsPath, String credentialsConfigPath, - String a2atEnvPath, EventCallback callback) { + WorkflowEngineClientConfig config = + WorkflowEngineClientConfig.builder() + .sslVerify(sslVerify) + .caCertsPath(caCertsPath) + .credentialsConfigPath(credentialsConfigPath) + .build(); A2ATransport transport = - new A2ATransport( - agentCards, - a2aClientRuntime, - WorkflowEngineClientConfig.builder() - .sslVerify(sslVerify) - .caCertsPath(caCertsPath) - .credentialsConfigPath(credentialsConfigPath) - .a2atEnvPath(a2atEnvPath) - .build()); - WorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); + new A2ATransport(agentCards, a2aClientRuntime, config); + WorkflowEngineClient client = DefaultWorkflowEngineClient.owning(transport, config); client.setEventCallback(callback); return client; } @@ -268,22 +274,35 @@ private static ExecutionResult handleExecutionError(Throwable error) { private static CompletableFuture finalizeResult( ExecutionResult result, WorkflowEngineClient client, + boolean closeClientOnFinish, EventCallback callback, List> collected, BiFunction>, CompletableFuture> onFinish) { - emitResultEvent(result, callback); + try { + emitResultEvent(result, callback); + } catch (Exception e) { + log.warn("[execute_psop] Result event callback failed: {}", e.getMessage()); + } CompletableFuture finishFuture = invokeOnFinish(result, collected, onFinish); return finishFuture.thenApply( v -> { - log.info( - "[execute_psop] Finished: workflow success={}, history={}", - result.isSuccess(), - result.getHistory() != null ? result.getHistory().size() : 0); - callback.onEvent(EventType.CLOSE, Map.of()); try { - client.close(); - } catch (Exception ignored) { + log.info( + "[execute_psop] Finished: workflow success={}, history={}", + result.isSuccess(), + result.getHistory() != null ? result.getHistory().size() : 0); + callback.onEvent(EventType.CLOSE, Map.of()); + } finally { + if (closeClientOnFinish) { + try { + client.close(); + } catch (Exception e) { + log.warn( + "[execute_psop] Owned client close failed: {}", + e.getMessage()); + } + } } return result; }); @@ -317,12 +336,26 @@ private static CompletableFuture invokeOnFinish( if (onFinish == null) { return CompletableFuture.completedFuture(null); } - return onFinish.apply(result, new ArrayList<>(collected)) - .exceptionally( - e -> { - log.error("[execute_psop] on_finish failed: {}", e.getMessage()); - return null; - }); + try { + List> eventSnapshot; + synchronized (collected) { + eventSnapshot = new ArrayList<>(collected); + } + CompletableFuture completion = + onFinish.apply(result, eventSnapshot); + if (completion == null) { + log.warn("[execute_psop] on_finish returned null; treating as completed"); + return CompletableFuture.completedFuture(null); + } + return completion.exceptionally( + e -> { + log.error("[execute_psop] on_finish failed: {}", e.getMessage()); + return null; + }); + } catch (Exception e) { + log.error("[execute_psop] on_finish failed synchronously: {}", e.getMessage()); + return CompletableFuture.completedFuture(null); + } } @SuppressWarnings("unchecked") @@ -350,7 +383,6 @@ public static final class Builder { private WorkflowEngineClient engineClient; private String runtimeIntent = ""; private String lang = "zh"; - private String a2atEnvPath; private String credentialsConfigPath; private boolean sslVerify = true; private String caCertsPath; @@ -390,11 +422,6 @@ public Builder lang(String v) { return this; } - public Builder a2atEnvPath(String v) { - this.a2atEnvPath = v; - return this; - } - public Builder credentialsConfigPath(String v) { this.credentialsConfigPath = v; return this; @@ -453,7 +480,6 @@ public CompletableFuture execute() { engineClient, runtimeIntent, lang, - a2atEnvPath, credentialsConfigPath, sslVerify, caCertsPath, diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/StubWorkflowEngineClient.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/StubWorkflowEngineClient.java index a8ba0490..c6a8331b 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/StubWorkflowEngineClient.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/StubWorkflowEngineClient.java @@ -43,7 +43,13 @@ public class StubWorkflowEngineClient implements WorkflowEngineClient { private EventCallback eventCallback = new EventCallback(); private ControlPoint controlPoint; private String defaultResponse = "stub-response"; - private String defaultTaskState = "COMPLETED"; + private String defaultTaskState = "TASK_STATE_COMPLETED"; + private List defaultOutputs = List.of(); + + public StubWorkflowEngineClient withDefaultOutputs(List outputs) { + defaultOutputs = outputs; + return this; + } public StubWorkflowEngineClient(String... agentNames) { this.agentNames.addAll(List.of(agentNames)); @@ -64,9 +70,21 @@ public StubWorkflowEngineClient withDefaultTaskState(String state) { return this; } + @Override + public CompletableFuture dispatch( + dev.openan.workflow.engine.model.TaskRequest request, + dev.openan.workflow.engine.model.MessageContent content, ControlPoint callbacks) { + return sendMessage(request.getAgentName(), content); + } + @Override public CompletableFuture sendMessage( - String agentName, String message, String contextId, Map metadata) { + String agentName, dev.openan.workflow.engine.model.MessageContent content) { + String message = content.parts().stream().filter(org.a2aproject.sdk.spec.TextPart.class::isInstance) + .map(org.a2aproject.sdk.spec.TextPart.class::cast).map(org.a2aproject.sdk.spec.TextPart::text) + .collect(java.util.stream.Collectors.joining()); + String contextId = null; + Map metadata = content.metadata(); sent.add(new SentMessage(agentName, message, contextId, metadata)); String text = cannedResponses.getOrDefault(agentName, defaultResponse); if (eventCallback != null) { @@ -82,6 +100,11 @@ public CompletableFuture sendMessage( } SendMessageResult result = SendMessageResult.builder() + .receivedMessages(List.of(new dev.openan.workflow.engine.model.ReceivedMessage( + dev.openan.workflow.engine.model.MessageContent.parts(defaultOutputs.isEmpty() + ? text.isEmpty() ? List.of() : List.of(new org.a2aproject.sdk.spec.TextPart(text)) + : defaultOutputs.stream().>map(value -> + new org.a2aproject.sdk.spec.DataPart(value)).toList()), Map.of(), List.of()))) .text(text) .taskState(defaultTaskState) .metadata(new HashMap<>()) @@ -102,6 +125,33 @@ public void setEventCallback(EventCallback callback) { this.eventCallback = callback != null ? callback : new EventCallback(); } + @Override + public CompletableFuture getTask(String agentName, String taskId) { + return cannedResult(agentName, defaultTaskState); + } + + @Override + public CompletableFuture cancelTask(String agentName, String taskId) { + return cannedResult(agentName, "TASK_STATE_CANCELED"); + } + + @Override + public CompletableFuture subscribeToTask( + String agentName, + String taskId, + java.util.function.Consumer> eventCallback) { + return cannedResult(agentName, defaultTaskState); + } + + private CompletableFuture cannedResult(String agentName, String taskState) { + return CompletableFuture.completedFuture( + SendMessageResult.builder() + .text(cannedResponses.getOrDefault(agentName, defaultResponse)) + .taskState(taskState) + .metadata(Map.of()) + .build()); + } + @Override public void close() {} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java index 0c6e81f9..08f204de 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java @@ -12,6 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; +import net.openan.a2at.sdk.core.model.StandardTemplates; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; @@ -22,7 +23,11 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.CompletionException; import java.util.function.Consumer; @@ -44,13 +49,7 @@ void forwardsAuthorizationAndActiveExtensionHeadersToRuntime() throws Exception .build(); try (A2ATransport transport = new A2ATransport(List.of(card), runtime, config)) { - transport.send( - card, - card.name(), - "diagnose", - "context-1", - Map.of(TASK_T_URI, "structured task"), - null) + transport.send(card, card.name(), content("diagnose", Map.of(TASK_T_URI, "structured task")), "context-1", null, null) .join(); } @@ -83,13 +82,7 @@ public void close() {} assertThrows( CompletionException.class, () -> - transport.sendNotificationStream( - card, - card.name(), - "subscribe", - "context-1", - Map.of(TASK_T_URI, "notification"), - null) + transport.openNotificationStream(card, card.name(), content("subscribe", Map.of(TASK_T_URI, "notification")), "context-1", null).acknowledgement() .join()); assertEquals("gateway unavailable", error.getCause().getMessage()); } @@ -120,7 +113,7 @@ public void close() {} assertThrows( CompletionException.class, () -> - transport.send(card, card.name(), "diagnose", "context-1", null, null) + transport.send(card, card.name(), content("diagnose", null), "context-1", null, null) .join()); assertTrue(error.getCause() instanceof SecurityException); assertTrue(error.getCause().getMessage().contains("none are configured")); @@ -140,7 +133,7 @@ void authProviderCanSatisfySecurityRequirementWithoutCredentials() throws Except .build(); try (A2ATransport transport = new A2ATransport(List.of(card), runtime, config)) { - transport.send(card, card.name(), "diagnose", "context-1", null, null).join(); + transport.send(card, card.name(), content("diagnose", null), "context-1", null, null).join(); } assertEquals("Bearer gateway-token", capturedHeaders.get().get("Authorization")); @@ -166,6 +159,102 @@ void authenticationHeaderConflictsAreCaseInsensitive() { assertEquals(1, headers.size()); } + @Test + void closeWaitsUntilNotificationTransportCallHasExited() throws Exception { + AgentCard card = agentCard(); + CountDownLatch entered = new CountDownLatch(1); + AtomicBoolean exited = new AtomicBoolean(); + A2AJavaClientRuntime runtime = + new A2AJavaClientRuntime() { + @Override + public Iterable sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer eventSink, + Consumer logSink) { + entered.countDown(); + try { + new CountDownLatch(1).await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + exited.set(true); + } + return List.of(); + } + + @Override + public void close() {} + }; + A2ATransport transport = + new A2ATransport( + List.of(card), runtime, WorkflowEngineClientConfig.builder().build()); + + transport.openNotificationStream(card, card.name(), content("subscribe", Map.of()), "context-1", null); + assertTrue(entered.await(1, TimeUnit.SECONDS)); + + transport.close(); + + assertTrue(exited.get(), "close must wait for the notification call to terminate"); + } + + @Test + void borrowedClientDoesNotCloseCallerOwnedTransport() throws Exception { + AtomicInteger closeCalls = new AtomicInteger(); + A2ATransport transport = + new A2ATransport( + List.of(agentCard()), + new CountingRuntime(closeCalls), + WorkflowEngineClientConfig.builder().build()); + + new DefaultWorkflowEngineClient(transport).close(); + assertEquals(0, closeCalls.get()); + + transport.close(); + assertEquals(1, closeCalls.get()); + } + + @Test + void owningClientClosesTransportExactlyOnce() throws Exception { + AtomicInteger closeCalls = new AtomicInteger(); + A2ATransport transport = + new A2ATransport( + List.of(agentCard()), + new CountingRuntime(closeCalls), + WorkflowEngineClientConfig.builder().build()); + DefaultWorkflowEngineClient client = DefaultWorkflowEngineClient.owning(transport); + + client.close(); + client.close(); + + assertEquals(1, closeCalls.get()); + } + + @Test + void finalStructuredContentNeedsNoContentSdk() throws Exception { + AtomicReference captured = new AtomicReference<>(); + try (var transport = new A2ATransport(List.of(agentCard()), + DefaultWorkflowEngineClientNegotiationTest.runtime(params -> { + captured.set(params); + return List.of(); + }), WorkflowEngineClientConfig.builder().build())) { + var content = new dev.openan.workflow.engine.model.MessageContent( + List.of(new org.a2aproject.sdk.spec.DataPart(Map.of("any", List.of(1, 2)))), + Map.of("contextId", "business-only"), java.util.Set.of("urn:optional:custom")); + new DefaultWorkflowEngineClient(transport).sendMessage("Test Agent", content).join(); + assertEquals(content.parts(), captured.get().message().parts()); + assertEquals(content.metadata(), captured.get().message().metadata()); + assertTrue(!"business-only".equals(captured.get().message().contextId())); + } + } + + private static dev.openan.workflow.engine.model.MessageContent content(String text, Map metadata) { + return new dev.openan.workflow.engine.model.MessageContent( + List.of(new org.a2aproject.sdk.spec.TextPart(text)), metadata, + metadata == null ? java.util.Set.of() : metadata.keySet()); + } + private static AgentCard agentCard() throws Exception { String json = """ @@ -249,4 +338,37 @@ public Iterable sendMessage( @Override public void close() {} } + + private record CountingRuntime(AtomicInteger closeCalls) implements A2AJavaClientRuntime { + @Override + public Iterable sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer eventSink, + Consumer logSink) { + return List.of(); + } + + @Override + public void close() { + closeCalls.incrementAndGet(); + } + } + + private record SendCountingRuntime(AtomicInteger sendCalls) implements A2AJavaClientRuntime { + @Override + public Iterable sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer eventSink, + Consumer logSink) { + sendCalls.incrementAndGet(); + return List.of(); + } + + @Override + public void close() {} + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportResponseTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportResponseTest.java new file mode 100644 index 00000000..52d5cc19 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportResponseTest.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatus; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.a2aproject.sdk.spec.TextPart; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +class A2ATransportResponseTest { + + @Test + void notificationAcknowledgementRequiresANonBlankProtocolState() { + assertFalse(A2ATransport.isAcknowledgementState(null)); + assertFalse(A2ATransport.isAcknowledgementState("")); + assertFalse(A2ATransport.isAcknowledgementState(" ")); + assertTrue(A2ATransport.isAcknowledgementState("TASK_STATE_WORKING")); + } + + @Test + void extractsArtifactOnlyOnceAcrossUpdateSnapshotAndTerminalTask() { + Artifact artifact = artifact("result", "diagnosis"); + Task working = task(TaskState.TASK_STATE_WORKING, List.of(artifact)); + Task completed = task(TaskState.TASK_STATE_COMPLETED, List.of(artifact)); + TaskArtifactUpdateEvent update = + TaskArtifactUpdateEvent.builder() + .taskId("task-1") + .contextId("context-1") + .artifact(artifact) + .append(false) + .lastChunk(true) + .metadata(Map.of()) + .build(); + + List events = + List.of( + new TaskUpdateEvent(working, update), + new TaskEvent(completed)); + + assertEquals("diagnosis", A2ATransport.extractResponseText(events)); + } + + @Test + void assemblesAppendChunksForEachArtifact() { + Task task = task(TaskState.TASK_STATE_WORKING, List.of()); + List events = + List.of( + new TaskUpdateEvent(task, artifactUpdate("result", "part-1", false)), + new TaskUpdateEvent(task, artifactUpdate("result", "+part-2", true))); + + assertEquals("part-1+part-2", A2ATransport.extractResponseText(events)); + } + + @Test + void fallsBackToDistinctStatusOrMessageTextWhenNoArtifactExists() { + Message concern = message("need parameter"); + TaskStatus status = new TaskStatus(TaskState.TASK_STATE_INPUT_REQUIRED, concern, null); + Task task = + Task.builder() + .id("task-1") + .contextId("context-1") + .status(status) + .artifacts(List.of()) + .history(List.of()) + .metadata(Map.of()) + .build(); + TaskStatusUpdateEvent update = + new TaskStatusUpdateEvent("task-1", status, "context-1", Map.of()); + + assertEquals( + "need parameter", + A2ATransport.extractResponseText( + List.of(new TaskUpdateEvent(task, update), new MessageEvent(concern)))); + } + + private static TaskArtifactUpdateEvent artifactUpdate( + String id, String text, boolean append) { + return TaskArtifactUpdateEvent.builder() + .taskId("task-1") + .contextId("context-1") + .artifact(artifact(id, text)) + .append(append) + .lastChunk(!append) + .metadata(Map.of()) + .build(); + } + + private static Task task(TaskState state, List artifacts) { + return Task.builder() + .id("task-1") + .contextId("context-1") + .status(new TaskStatus(state)) + .artifacts(artifacts) + .history(List.of()) + .metadata(Map.of()) + .build(); + } + + private static Artifact artifact(String id, String text) { + return Artifact.builder() + .artifactId(id) + .name(id) + .parts(new TextPart(text)) + .metadata(Map.of()) + .build(); + } + + @Test + void mixedStreamingTextAndStructuredDataAreBothRetained() { + Artifact mixed = + Artifact.builder() + .artifactId("mixed") + .parts( + List.of( + new TextPart("diagnosis"), + new org.a2aproject.sdk.spec.DataPart( + Map.of("ports", List.of("a", "b"))))) + .build(); + Task task = task(TaskState.TASK_STATE_WORKING, List.of()); + var update = + TaskArtifactUpdateEvent.builder() + .taskId("task-1") + .contextId("context-1") + .artifact(mixed) + .append(false) + .lastChunk(true) + .build(); + assertEquals( + List.of("diagnosis", Map.of("ports", List.of("a", "b"))), + ProtocolResponses.assemble(List.of(new TaskUpdateEvent(task, update))).get(0).outputs()); + } + + private static Message message(String text) { + return Message.builder() + .role(Message.Role.ROLE_AGENT) + .messageId("message-1") + .parts(new TextPart(text)) + .metadata(Map.of()) + .build(); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentAuthManagerTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentAuthManagerTest.java index 87fd7d42..18952e40 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentAuthManagerTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentAuthManagerTest.java @@ -11,50 +11,61 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import org.junit.jupiter.api.Test; - import java.net.URL; +import org.junit.jupiter.api.Test; class AgentAuthManagerTest { - @Test - void loadsCredentialsFromClasspathLocation() throws Exception { - URL resource = getClass().getClassLoader().getResource("test-agent-credentials.json"); - assertNotNull(resource, "test resource must exist on classpath"); - AgentAuthManager manager = new AgentAuthManager(resource.getPath()); - - var config = manager.getConfig("Test Agent"); - - assertNotNull(config); - assertEquals( - "https://auth.example.test/token", - config.get("bearerAuth").get("login_url")); - } - - @Test - void loadsCredentialsFromClasspathPrefix() { - AgentAuthManager manager = - new AgentAuthManager("classpath:test-agent-credentials.json"); - - var config = manager.getConfig("Test Agent"); - - assertNotNull(config); - assertEquals( - "https://auth.example.test/token", - config.get("bearerAuth").get("login_url")); - } - - @Test - void missingFileFailsClosed() { - assertThrows( - IllegalStateException.class, - () -> new AgentAuthManager("/nonexistent/missing-agent-credentials.json")); - } - - @Test - void missingClasspathResourceFailsClosed() { - assertThrows( - IllegalStateException.class, - () -> new AgentAuthManager("classpath:nonexistent-credentials.json")); - } + @Test + void loadsCredentialsFromClasspathLocation() throws Exception { + URL resource = getClass().getClassLoader().getResource("test-agent-credentials.json"); + assertNotNull(resource, "test resource must exist on classpath"); + AgentAuthManager manager = new AgentAuthManager(resource.getPath()); + + var config = manager.getConfig("Test Agent"); + + assertNotNull(config); + assertEquals("https://auth.example.test/token", config.get("bearerAuth").get("login_url")); + } + + @Test + void loadsCredentialsFromClasspathPrefix() { + AgentAuthManager manager = new AgentAuthManager("classpath:test-agent-credentials.json"); + + var config = manager.getConfig("Test Agent"); + + assertNotNull(config); + assertEquals("https://auth.example.test/token", config.get("bearerAuth").get("login_url")); + } + + @Test + void resolvesSharedProfilesAndPerAgentOverrides() { + AgentAuthManager manager = new AgentAuthManager("classpath:test-profile-credentials.json"); + + var city1 = manager.getConfig("City 1").get("bearerAuth"); + var city2 = manager.getConfig("City 2").get("bearerAuth"); + + assertEquals("https://city1.example.test/token", city1.get("login_url")); + assertEquals("https://shared.example.test/token", city2.get("login_url")); + assertEquals( + "shared-user", ((java.util.Map) city1.get("request_fields")).get("userName")); + assertEquals( + "city1-password", ((java.util.Map) city1.get("request_fields")).get("value")); + assertEquals( + "shared-password", ((java.util.Map) city2.get("request_fields")).get("value")); + } + + @Test + void missingFileFailsClosed() { + assertThrows( + IllegalStateException.class, + () -> new AgentAuthManager("/nonexistent/missing-agent-credentials.json")); + } + + @Test + void missingClasspathResourceFailsClosed() { + assertThrows( + IllegalStateException.class, + () -> new AgentAuthManager("classpath:nonexistent-credentials.json")); + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ContentDependencyBoundaryTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ContentDependencyBoundaryTest.java new file mode 100644 index 00000000..c10270dc --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ContentDependencyBoundaryTest.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.client; + +import org.junit.jupiter.api.Test; +import java.nio.file.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class ContentDependencyBoundaryTest { + @Test void engineClasspathHasNoContentClientOrLlmModules() { + for (String name : List.of("net.openan.a2at.sdk.client.A2ATClient", + "net.openan.a2at.sdk.server.A2ATServer", "net.openan.a2at.sdk.llm.LlmClient")) { + assertThrows(ClassNotFoundException.class, () -> Class.forName(name), name); + } + String classpath = System.getProperty("java.class.path"); + for (String module : List.of("a2a-t-client-", "a2a-t-llm-", "a2a-t-prompt-", + "a2a-t-resources-", "a2a-t-negotiation-")) assertFalse(classpath.contains(module), module); + } + + @Test void sourcePackagesHaveOnlyAllowedDependencyDirection() throws Exception { + Path root = Path.of("src/main/java/dev/openan/workflow/engine"); + try (var paths = Files.walk(root)) { + for (Path file : paths.filter(p -> p.toString().endsWith(".java")).toList()) { + String source = Files.readString(file, StandardCharsets.UTF_8); + String relative = root.relativize(file).toString().replace('\\', '/'); + if (relative.startsWith("core/") || relative.startsWith("model/") || relative.startsWith("control/")) { + assertFalse(source.contains("net.openan.a2at"), relative); + } + for (String forbidden : List.of("net.openan.a2at.sdk.client", "net.openan.a2at.sdk.server", + "net.openan.a2at.sdk.llm", "net.openan.a2at.sdk.negotiation", "net.openan.a2at.sdk.prompt")) { + assertFalse(source.contains(forbidden), relative + " imports " + forbidden); + } + } + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/CredentialCryptoTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/CredentialCryptoTest.java index f5953fed..196508eb 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/CredentialCryptoTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/CredentialCryptoTest.java @@ -13,6 +13,8 @@ class CredentialCryptoTest { private static final String KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private static final String OTHER_KEY = + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; @AfterEach void clearKey() { @@ -38,4 +40,14 @@ void corruptedEncryptedCredentialFailsClosed() { void plaintextRemainsBackwardCompatible() { assertEquals("plain", CredentialCrypto.decryptIfNeeded("plain")); } + + @Test + void instanceScopedKeyTakesPrecedenceOverJvmProperty() { + System.setProperty("A2AT_CRED_KEY", KEY); + String encrypted = CredentialCrypto.encrypt("city-specific-secret"); + System.setProperty("A2AT_CRED_KEY", OTHER_KEY); + + assertEquals( + "city-specific-secret", CredentialCrypto.decryptIfNeeded(encrypted, KEY)); + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClientNegotiationTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClientNegotiationTest.java new file mode 100644 index 00000000..ca9f01b2 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClientNegotiationTest.java @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.client; + +import dev.openan.workflow.engine.control.ControlPoint; +import dev.openan.workflow.engine.model.*; +import net.openan.a2at.sdk.core.model.*; +import org.a2aproject.sdk.client.*; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.*; +import org.junit.jupiter.api.Test; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.function.*; +import static org.junit.jupiter.api.Assertions.*; + +class DefaultWorkflowEngineClientNegotiationTest { + static AgentCard card() throws Exception { + return new com.fasterxml.jackson.databind.ObjectMapper().registerModule(new AgentCardJacksonModule()) + .readValue(""" + {"name":"test","description":"test","version":"1", + "capabilities":{"streaming":true,"extensions":[]}, + "defaultInputModes":["text/plain"],"defaultOutputModes":["text/plain"], + "skills":[],"supportedInterfaces":[{"protocolBinding":"HTTP+JSON","protocolVersion":"1.0", + "url":"http://localhost:1","tenant":""}]} + """, AgentCard.class); + } + + static MessageContent negotiation(String id, int round, NegotiationPerformative performative) { + return A2atMessages.from(new MetadataContent("Negotiation-T/information-negotiation/propose/v1", "opaque", + A2ATExtension.NEGOTIATION_T.uri(), new NegotiationContext(id, round, round, performative)), + List.of(new TextPart("not interpreted"))); + } + + static TaskEvent response(MessageSendParams request, TaskState state, MessageContent content) { + Message message = Message.builder().messageId(UUID.randomUUID().toString()) + .role(Message.Role.ROLE_AGENT).parts(content.parts()).metadata(content.metadata()).build(); + return new TaskEvent(org.a2aproject.sdk.spec.Task.builder() + .id("remote-task").contextId(request.message().contextId()) + .status(new org.a2aproject.sdk.spec.TaskStatus(state, message, null)) + .history(List.of()).metadata(Map.of()).build()); + } + + static A2AJavaClientRuntime runtime(Function> send) { + return new A2AJavaClientRuntime() { + public Iterable sendMessage(AgentCard card, MessageSendParams params, + ClientCallContext context, Consumer sink, Consumer logs) { + List events = send.apply(params); + if (sink != null) events.forEach(sink); + return events; + } + public void close() {} + }; + } + + @Test void answersFinalRoundAndKeepsOriginalRemoteAssociation() throws Exception { + List sent = new CopyOnWriteArrayList<>(); + var runtime = runtime(params -> { + sent.add(params); + return List.of(response(params, sent.size() == 1 ? TaskState.TASK_STATE_INPUT_REQUIRED : TaskState.TASK_STATE_COMPLETED, + sent.size() == 1 ? negotiation("city1", 3, NegotiationPerformative.PROPOSE) : MessageContent.text("done"))); + }); + AtomicReference seen = new AtomicReference<>(); + try (var transport = new A2ATransport(List.of(card()), runtime, WorkflowEngineClientConfig.builder().build())) { + var client = new DefaultWorkflowEngineClient(transport); + client.setControlPoint(ControlPoint.builder().onNegotiation(request -> { + seen.set(request); + return CompletableFuture.completedFuture(new NegotiationReply.Send( + negotiation("city1", 3, NegotiationPerformative.ACCEPT))); + }).build()); + assertEquals("TASK_STATE_COMPLETED", client.sendMessage("test", MessageContent.text("start")).join().getTaskState()); + } + assertEquals(2, sent.size()); + assertNull(sent.get(0).message().taskId()); + assertEquals("remote-task", sent.get(1).message().taskId()); + assertEquals(sent.get(0).message().contextId(), sent.get(1).message().contextId()); + assertTrue(seen.get().previousExchanges().isEmpty()); + assertFalse(seen.get().remainingWait().isZero()); + assertEquals(MessageContent.text("start"), seen.get().originalSubmission()); + } + + @Test void terminalStateNeverRestartsOldNegotiation() throws Exception { + AtomicInteger callbacks = new AtomicInteger(); + try (var transport = new A2ATransport(List.of(card()), + runtime(p -> List.of(response(p, TaskState.TASK_STATE_COMPLETED, + negotiation("old", 1, NegotiationPerformative.PROPOSE)))), + WorkflowEngineClientConfig.builder().build())) { + var client = new DefaultWorkflowEngineClient(transport); + client.setControlPoint(ControlPoint.builder().onNegotiation(q -> { + callbacks.incrementAndGet(); + return CompletableFuture.failedFuture(new AssertionError()); + }).build()); + client.sendMessage("test", MessageContent.text("start")).join(); + } + assertEquals(0, callbacks.get()); + } + + @Test void rejectsWrongContextNewProposeAndMissingCallbackWithoutSendingAgain() throws Exception { + for (NegotiationReply reply : List.of( + new NegotiationReply.Send(negotiation("other-city", 1, NegotiationPerformative.ACCEPT)), + new NegotiationReply.Send(negotiation("city1", 1, NegotiationPerformative.PROPOSE)), + new NegotiationReply.Stop("manual.required", "Need human"))) { + AtomicInteger sends = new AtomicInteger(); + try (var transport = new A2ATransport(List.of(card()), runtime(p -> { + sends.incrementAndGet(); + return List.of(response(p, TaskState.TASK_STATE_INPUT_REQUIRED, negotiation("city1", 1, NegotiationPerformative.PROPOSE))); + }), WorkflowEngineClientConfig.builder().build())) { + var client = new DefaultWorkflowEngineClient(transport); + client.setControlPoint(ControlPoint.builder().onNegotiation(q -> CompletableFuture.completedFuture(reply)).build()); + assertThrows(CompletionException.class, () -> client.sendMessage("test", MessageContent.text("start")).join()); + } + assertEquals(1, sends.get()); + } + } + + @Test void plainInputRequiredIsExplicitlyUnsupported() throws Exception { + try (var transport = new A2ATransport(List.of(card()), + runtime(p -> List.of(response(p, TaskState.TASK_STATE_INPUT_REQUIRED, MessageContent.text("question")))), + WorkflowEngineClientConfig.builder().build())) { + var client = new DefaultWorkflowEngineClient(transport); + assertThrows(CompletionException.class, () -> client.sendMessage("test", MessageContent.text("start")).join()); + } + } + + @Test void abortAcknowledgementIsNotDiagnosisSuccessAndPreservesRemoteState() throws Exception { + AtomicInteger sends = new AtomicInteger(); + try (var transport = new A2ATransport(List.of(card()), runtime(p -> List.of( + response(p, sends.incrementAndGet() == 1 ? TaskState.TASK_STATE_INPUT_REQUIRED : TaskState.TASK_STATE_COMPLETED, + negotiation("city1", 1, NegotiationPerformative.PROPOSE)))), + WorkflowEngineClientConfig.builder().build())) { + var client = new DefaultWorkflowEngineClient(transport); + client.setControlPoint(ControlPoint.builder().onNegotiation(q -> CompletableFuture.completedFuture( + new NegotiationReply.Send(negotiation("city1", 1, NegotiationPerformative.ABORT)))).build()); + var result = client.sendMessage("test", MessageContent.text("start")).join(); + assertEquals("TASK_STATE_COMPLETED", result.getTaskState()); + assertEquals("negotiation.aborted", result.getFailureCode()); + } + assertEquals(2, sends.get()); + } + + @Test void workingAckAndDuplicateProposeAreObservedWithoutRepeatedSubmissionOrCallback() throws Exception { + for (TaskState secondState : List.of(TaskState.TASK_STATE_WORKING, TaskState.TASK_STATE_INPUT_REQUIRED)) { + AtomicInteger sends = new AtomicInteger(); + AtomicInteger polls = new AtomicInteger(); + AtomicInteger callbacks = new AtomicInteger(); + AtomicReference last = new AtomicReference<>(); + A2AJavaClientRuntime runtime = new A2AJavaClientRuntime() { + public Iterable sendMessage(AgentCard card, MessageSendParams params, + ClientCallContext context, Consumer sink, Consumer logs) { + last.set(params); + return List.of(response(params, sends.incrementAndGet() == 1 + ? TaskState.TASK_STATE_INPUT_REQUIRED : secondState, + negotiation("city1", 1, NegotiationPerformative.PROPOSE))); + } + public org.a2aproject.sdk.spec.Task getTask(AgentCard card, String taskId, ClientCallContext context) { + polls.incrementAndGet(); + assertEquals("remote-task", taskId); + assertNotNull(WireLog.context().get("attempt")); + return response(last.get(), TaskState.TASK_STATE_COMPLETED, MessageContent.text("diagnosis")).getTask(); + } + public void close() {} + }; + try (var transport = new A2ATransport(List.of(card()), runtime, WorkflowEngineClientConfig.builder().build())) { + var client = new DefaultWorkflowEngineClient(transport); + client.setControlPoint(ControlPoint.builder().onNegotiation(q -> { + callbacks.incrementAndGet(); + return CompletableFuture.completedFuture(new NegotiationReply.Send( + negotiation("city1", 1, NegotiationPerformative.ACCEPT))); + }).build()); + assertEquals("TASK_STATE_COMPLETED", + client.sendMessage("test", MessageContent.text("start")).get(5, TimeUnit.SECONDS).getTaskState()); + assertEquals(2, sends.get()); + assertEquals(1, callbacks.get()); + assertEquals(1, polls.get()); + } + } + } + + @Test void canceledOrTimedOutNegotiationNeverSendsLateReply() throws Exception { + for (boolean timeout : List.of(false, true)) { + AtomicInteger sends = new AtomicInteger(); + CompletableFuture answer = new CompletableFuture<>(); + CountDownLatch entered = new CountDownLatch(1); + try (var transport = new A2ATransport(List.of(card()), runtime(p -> { + sends.incrementAndGet(); + return List.of(response(p, TaskState.TASK_STATE_INPUT_REQUIRED, + negotiation("city1", 1, NegotiationPerformative.PROPOSE))); + }), WorkflowEngineClientConfig.builder().sendTimeoutSeconds(1).build())) { + var client = new DefaultWorkflowEngineClient(transport); + client.setControlPoint(ControlPoint.builder().onNegotiation(q -> { + entered.countDown(); return answer; + }).build()); + var result = client.sendMessage("test", MessageContent.text("start")); + assertTrue(entered.await(1, TimeUnit.SECONDS)); + if (timeout) assertThrows(ExecutionException.class, () -> result.get(3, TimeUnit.SECONDS)); + else assertTrue(result.cancel(true)); + answer.complete(new NegotiationReply.Send(negotiation("city1", 1, NegotiationPerformative.ACCEPT))); + assertEquals(1, sends.get()); + } + } + } + + @Test void closingNonOwningFacadePreventsNewWorkButDoesNotCloseSharedTransport() throws Exception { + try (var transport = new A2ATransport(List.of(card()), runtime(p -> + List.of(response(p, TaskState.TASK_STATE_COMPLETED, MessageContent.text("done")))), + WorkflowEngineClientConfig.builder().build())) { + var closedClient = new DefaultWorkflowEngineClient(transport); + closedClient.close(); + assertThrows(CompletionException.class, () -> closedClient.sendMessage("test", MessageContent.text("no")).join()); + assertEquals("TASK_STATE_COMPLETED", new DefaultWorkflowEngineClient(transport) + .sendMessage("test", MessageContent.text("yes")).join().getTaskState()); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/EnvFileLoaderTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/EnvFileLoaderTest.java new file mode 100644 index 00000000..058e4cc6 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/EnvFileLoaderTest.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +class EnvFileLoaderTest { + private static final String ISOLATED_KEY = "A2AT_TEST_INSTANCE_SCOPED_KEY"; + + @TempDir Path tempDirectory; + + @Test + void readParsesValuesWithoutMutatingJvmProperties() throws Exception { + Path envFile = tempDirectory.resolve("city-one.env"); + Files.writeString( + envFile, + "# city-specific configuration\n" + + ISOLATED_KEY + + "=city-one\n" + + "QUOTED_VALUE=\"with spaces\"\n"); + System.clearProperty(ISOLATED_KEY); + + Map values = EnvFileLoader.read(envFile); + + assertEquals("city-one", values.get(ISOLATED_KEY)); + assertEquals("with spaces", values.get("QUOTED_VALUE")); + assertNull(System.getProperty(ISOLATED_KEY)); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java deleted file mode 100644 index 0321f1e0..00000000 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.client; - -import static org.junit.jupiter.api.Assertions.*; - -import dev.openan.workflow.engine.control.ControlPoint; -import dev.openan.workflow.engine.control.EventCallback; -import dev.openan.workflow.engine.model.SendMessageResult; -import org.junit.jupiter.api.Test; - -import java.util.List; - -/** Tests for ExtensionRegistry: URI matching, dedup, built-in handlers. */ -class ExtensionRegistryTest { - - @Test - void preRegistersTwoBuiltinHandlers() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = - reg.getHandlersForExtensions( - List.of( - "https://example.com/Task-T", - "https://example.com/Negotiation-T", - "https://example.com/Authorization-T", - "https://example.com/Notification-T")); - // Authorization-T and Notification-T are pre-positioning operations, - // not part of the workflow engine's extension handler chain. - assertEquals(2, handlers.size()); - } - - @Test - void matchesTaskTHandler() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = - reg.getHandlersForExtensions( - List.of("https://a2a.example.org/extensions/Task-T/v1")); - assertEquals(1, handlers.size()); - assertEquals("Task-T", handlers.get(0).extensionKeyword()); - } - - @Test - void matchesNegotiationTHandler() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = - reg.getHandlersForExtensions( - List.of("https://a2a.example.org/extensions/NEGOTIATION-T/v1")); - assertEquals(1, handlers.size()); - assertEquals("Negotiation-T", handlers.get(0).extensionKeyword()); - } - - @Test - void deduplicatesWhenMultipleUrisMatchSameHandler() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = - reg.getHandlersForExtensions( - List.of("https://example.com/Task-T/v1", "https://example.com/Task-T/v2")); - assertEquals(1, handlers.size(), "Task-T should be matched once even with two URIs"); - } - - @Test - void returnsEmptyForNoExtensions() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = reg.getHandlersForExtensions(List.of()); - assertTrue(handlers.isEmpty()); - } - - @Test - void returnsEmptyForUnmatchedUris() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = - reg.getHandlersForExtensions(List.of("https://example.com/Unknown-Extension")); - assertTrue(handlers.isEmpty()); - } - - @Test - void doesNotMatchKeywordThatIsOnlyPartOfAnotherPathSegment() { - ExtensionRegistry reg = new ExtensionRegistry(); - assertTrue( - reg.getHandlersForExtensions( - List.of("https://example.com/extensions/Task-T-Extended/v1")) - .isEmpty()); - } - - @Test - void ignoresMalformedUris() { - ExtensionRegistry reg = new ExtensionRegistry(); - assertTrue(reg.getHandlersForExtensions(List.of("https://bad uri/Task-T/v1")).isEmpty()); - } - - @Test - void nullUrisHandledGracefully() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = reg.getHandlersForExtensions(null); - assertTrue(handlers.isEmpty()); - } - - @Test - void customHandlerCanBeRegistered() { - ExtensionRegistry reg = new ExtensionRegistry(); - ExtensionHandler custom = - new ExtensionHandler() { - @Override - public String extensionKeyword() { - return "Custom-T"; - } - - @Override - public java.util.concurrent.CompletableFuture> - beforeSend( - org.a2aproject.sdk.spec.AgentCard agentCard, - String messageText, - java.util.Map metadata, - net.openan.a2at.sdk.client.A2ATClient a2atClient, - ControlPoint controlPoint) { - return java.util.concurrent.CompletableFuture.completedFuture(metadata); - } - - @Override - public java.util.concurrent.CompletableFuture< - SendMessageResult> - afterReceive( - org.a2aproject.sdk.spec.AgentCard agentCard, - SendMessageResult result, - net.openan.a2at.sdk.client.A2ATClient a2atClient, - ControlPoint controlPoint, - EventCallback eventCallback) { - return java.util.concurrent.CompletableFuture.completedFuture(result); - } - }; - reg.register(custom); - List handlers = - reg.getHandlersForExtensions(List.of("https://example.com/Custom-T/v1")); - assertTrue(handlers.size() >= 1); - assertEquals("Custom-T", handlers.get(0).extensionKeyword()); - } -} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java index 64091c59..b9a0ea51 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java @@ -1,30 +1,96 @@ /* * Copyright (c) 2026 Huawei Technologies Co., Ltd. * All Rights Reserved. + * * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. */ package dev.openan.workflow.engine.client; - -import static org.junit.jupiter.api.Assertions.assertTrue; - +import dev.openan.workflow.engine.model.*; +import net.openan.a2at.sdk.core.model.MetadataContent; +import org.a2aproject.sdk.spec.*; +import org.a2aproject.sdk.client.*; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; import org.junit.jupiter.api.Test; - -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.function.*; +import static org.junit.jupiter.api.Assertions.*; class ExtensionSenderTest { + static AgentCard card() throws Exception { + var card = DefaultWorkflowEngineClientNegotiationTest.card(); + return AgentCard.builder(card).capabilities(new AgentCapabilities(true, false, false, List.of( + new AgentExtension("", Map.of(), false, A2ATExtension.AUTHORIZATION_T.uri()), + new AgentExtension("", Map.of(), false, A2ATExtension.NOTIFICATION_T.uri())))).build(); + } - @Test - void notificationCallbackMethodIsAnInterfaceContract() throws Exception { - Method method = - ExtensionSender.class.getMethod( - "sendNotification", - String.class, - String.class, - String.class, - java.util.function.Consumer.class); + static MessageContent content(A2ATExtension extension) { + return A2atMessages.from(new MetadataContent("host-template", "final-content", extension.uri()), + List.of(new TextPart("instruction"))); + } + + @Test void sendsFinalAuthorizationWithoutGeneratingOrSharingContext() throws Exception { + List contexts = new CopyOnWriteArrayList<>(); + try (var transport = new A2ATransport(List.of(card()), + DefaultWorkflowEngineClientNegotiationTest.runtime(params -> { + contexts.add(params.message().contextId()); + assertEquals(content(A2ATExtension.AUTHORIZATION_T).metadata(), params.message().metadata()); + return List.of(DefaultWorkflowEngineClientNegotiationTest.response(params, + TaskState.TASK_STATE_COMPLETED, MessageContent.text("ack"))); + }), WorkflowEngineClientConfig.builder().build())) { + var sender = new DefaultExtensionSender(transport); + sender.sendAuthorization("test", content(A2ATExtension.AUTHORIZATION_T)).join(); + sender.sendAuthorization("test", content(A2ATExtension.AUTHORIZATION_T)).join(); + assertNotEquals(contexts.get(0), contexts.get(1)); + assertThrows(CompletionException.class, () -> sender.sendAuthorization("test", MessageContent.text("wrong")).join()); + } + } - assertTrue(Modifier.isAbstract(method.getModifiers())); + @Test void earlyEventCanCloseHandleAndCompletionWaitsForActualExit() throws Exception { + CountDownLatch callbackRan = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference observed = new AtomicReference<>(); + var runtime = new A2AJavaClientRuntime() { + public Iterable sendMessage(AgentCard card, MessageSendParams params, + ClientCallContext context, Consumer sink, Consumer logs) { + sink.accept(DefaultWorkflowEngineClientNegotiationTest.response(params, + TaskState.TASK_STATE_WORKING, MessageContent.text("ack"))); + while (true) { + try { release.await(); break; } + catch (InterruptedException ignored) { /* keep transport alive until test releases it */ } + } + return List.of(); + } + public void close() {} + }; + try (var transport = new A2ATransport(List.of(card()), runtime, WorkflowEngineClientConfig.builder().build())) { + var subscription = new DefaultExtensionSender(transport).openNotification("test", + content(A2ATExtension.NOTIFICATION_T), (handle, event) -> { + observed.set(handle); + handle.close(); + callbackRan.countDown(); + }); + assertTrue(callbackRan.await(2, TimeUnit.SECONDS)); + assertSame(subscription, observed.get()); + assertFalse(subscription.completion().isDone()); + subscription.close(); + release.countDown(); + subscription.completion().get(2, TimeUnit.SECONDS); + assertFalse(subscription.isActive()); + } finally { release.countDown(); } } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/JsonRpcWireObservationTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/JsonRpcWireObservationTest.java new file mode 100644 index 00000000..609c798c --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/JsonRpcWireObservationTest.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import com.sun.net.httpserver.HttpServer; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.http.JdkA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.*; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.*; +import org.junit.jupiter.api.Test; +import java.net.*; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; + +class JsonRpcWireObservationTest { + @Test void recordsSdkSerializedJsonRpcRequestAndRealErrorResponse() throws Exception { + var json = new ObjectMapper().registerModule(new AgentCardJacksonModule()); + List logs = new CopyOnWriteArrayList<>(); + AtomicReference received = new AtomicReference<>(); + AtomicReference> version = new AtomicReference<>(); + AtomicReference returned = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/rpc", exchange -> { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + received.set(body); + version.set(exchange.getRequestHeaders().get("A2A-Version")); + String error = "{\"jsonrpc\":\"2.0\",\"id\":" + json.readTree(body).get("id") + + ",\"error\":{\"code\":-32602,\"message\":\"invalid input\"}}"; + returned.set(error); + byte[] bytes = error.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + Client client = null; + try { + AgentCard card = json.readValue(""" + {"name":"rpc","description":"rpc","version":"1", + "capabilities":{"streaming":false,"extensions":[]}, + "defaultInputModes":["text/plain"],"defaultOutputModes":["text/plain"],"skills":[], + "supportedInterfaces":[{"protocolBinding":"JSONRPC","protocolVersion":"1.0","tenant":"", + "url":"http://127.0.0.1:%d/rpc"}]} + """.formatted(server.getAddress().getPort()), AgentCard.class); + client = Client.builder(card).withTransport(JSONRPCTransport.class, + new JSONRPCTransportConfig(new JdkA2AHttpClient( + new ObservedHttpClient(HttpClient.newHttpClient(), logs::add)))).build(); + CountDownLatch done = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + try { + client.sendMessage(MessageSendParams.builder().message(Message.builder() + .messageId(UUID.randomUUID().toString()).role(Message.Role.ROLE_USER) + .parts(new TextPart("诊断输入")).build()).build(), + List.of((event, agent) -> done.countDown()), + error -> { failure.set(error); done.countDown(); }, + new ClientCallContext(Map.of(), Map.of("Authorization", "Bearer hidden"))); + } catch (A2AClientException error) { + failure.set(error); done.countDown(); + } + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertNotNull(failure.get()); + var request = json.readTree(received.get()); + assertEquals("2.0", request.get("jsonrpc").asText()); + assertEquals(A2AMethods.SEND_MESSAGE_METHOD, request.get("method").asText()); + assertTrue(received.get().contains("诊断输入")); + var body = logs.stream().filter(e -> e.direction().equals("REQUEST_BODY")).findFirst().orElseThrow(); + assertEquals(received.get(), body.body()); + var headers = logs.stream().filter(e -> e.direction().equals("REQUEST_HEADERS")).findFirst().orElseThrow(); + assertEquals(version.get(), headers.headers().get("A2A-Version")); + assertNotNull(version.get()); + assertTrue(logs.stream().anyMatch(e -> e.direction().equals("RESPONSE_BODY") && returned.get().equals(e.body()))); + assertFalse(logs.toString().contains("hidden")); + } finally { + if (client != null) client.close(); + server.stop(0); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/NotificationSubscriptionTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/NotificationSubscriptionTest.java new file mode 100644 index 00000000..6b55b05e --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/NotificationSubscriptionTest.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openan.workflow.engine.model.SendMessageResult; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +class NotificationSubscriptionTest { + + @Test + void eventsRefreshHeartbeatAndExplicitCloseIsIdempotent() { + AtomicInteger closes = new AtomicInteger(); + NotificationSubscription subscription = + new NotificationSubscription("city1", "ctx-1", closes::incrementAndGet); + + assertTrue(subscription.isActive()); + assertEquals(0, subscription.heartbeat().eventCount()); + assertFalse(subscription.isHealthy(Duration.ofSeconds(1))); + + subscription.recordEvent(); + subscription.acknowledge( + SendMessageResult.builder().text("ack").taskState("TASK_STATE_WORKING").build()); + + assertEquals(1, subscription.heartbeat().eventCount()); + assertTrue(subscription.isHealthy(Duration.ofSeconds(1))); + subscription.close(); + subscription.close(); + + assertEquals(1, closes.get()); + assertFalse(subscription.isActive()); + assertFalse(subscription.completion().isDone()); + assertFalse(subscription.streamTermination().isDone()); + subscription.markStreamTerminated(); + assertTrue(subscription.completion().isDone()); + assertTrue(subscription.streamTermination().isDone()); + assertEquals("ack", subscription.acknowledgement().join().getText()); + } + + @Test + void closingBeforeAcknowledgementNeverLeavesCallerWaiting() { + NotificationSubscription subscription = + new NotificationSubscription("city1", "ctx-1", () -> {}); + + subscription.close(); + + assertTrue(subscription.acknowledgement().isCompletedExceptionally()); + assertThrows( + java.util.concurrent.CancellationException.class, + () -> subscription.acknowledgement().join()); + assertThrows( + IllegalArgumentException.class, + () -> subscription.isHealthy(Duration.ofSeconds(-1))); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ObservedHttpClientTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ObservedHttpClientTest.java new file mode 100644 index 00000000..cef21698 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ObservedHttpClientTest.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.client; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; +import java.net.*; +import java.net.http.*; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +class ObservedHttpClientTest { + @AfterEach void clearSettings() { + System.clearProperty("WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY"); + System.clearProperty("WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS"); + } + + @Test void logsActualRequestAndResponseWithoutReencodingOrExposingCredentials() throws Exception { + String body = "{ \"message\": \"任务\", \"password\": \"login-secret\" }"; + String response = "{\"ok\":true,\"accessSession\":\"omc-secret\"}"; + List records = new CopyOnWriteArrayList<>(); + List capturedBody = new CopyOnWriteArrayList<>(); + List> capturedVersion = new CopyOnWriteArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/message", exchange -> { + capturedBody.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + capturedVersion.add(exchange.getRequestHeaders().get("A2A-Version")); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.getResponseHeaders().add("X-Multi", "one"); + exchange.getResponseHeaders().add("X-Multi", "two"); + exchange.getResponseHeaders().add("bearToken", "header-secret"); + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + try { + HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + + server.getAddress().getPort() + "/message")) + .header("A2A-Version", "1.0").header("Authorization", "Bearer request-secret") + .header("X-Multi", "a").header("X-Multi", "b") + .POST(HttpRequest.BodyPublishers.ofString(body)).build(); + HttpResponse result = new ObservedHttpClient(HttpClient.newHttpClient(), records::add) + .send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(response, result.body()); + assertEquals(List.of(body), capturedBody); + WireLog.Entry sent = records.stream().filter(e -> e.direction().equals("REQUEST_BODY")).findFirst().orElseThrow(); + assertEquals(body.replace("login-secret", "***"), sent.body()); + WireLog.Entry header = records.stream().filter(e -> e.direction().equals("REQUEST_HEADERS")).findFirst().orElseThrow(); + assertEquals(capturedVersion.get(0), header.headers().get("A2A-Version")); + assertEquals(List.of("a", "b"), header.headers().get("X-Multi")); + WireLog.Entry received = records.stream().filter(e -> e.direction().equals("RESPONSE_HEADERS")).findFirst().orElseThrow(); + assertEquals(List.of("one", "two"), received.headers().get("x-multi")); + assertEquals(List.of("***"), received.headers().get("beartoken")); + assertEquals(1, records.stream().map(WireLog.Entry::requestId).distinct().count()); + String logs = records.toString(); + assertFalse(logs.contains("login-secret")); + assertFalse(logs.contains("omc-secret")); + assertFalse(logs.contains("header-secret")); + assertFalse(logs.contains("request-secret")); + } finally { server.stop(0); } + } + + @Test void streamingFramesKeepUtf8ControlLinesAndArriveBeforeCompletion() { + List frames = new ArrayList<>(); + WireLog.Body collector = new WireLog.Body(true, true, frames::add); + String first = ": heartbeat\r\nevent: result\r\nid: 7\r\nretry: 100\r\ndata: 中文\r\n\r\n"; + for (byte value : first.getBytes(StandardCharsets.UTF_8)) collector.accept(ByteBuffer.wrap(new byte[]{value})); + assertEquals(1, frames.size()); + assertEquals(first, frames.get(0)[0]); + String second = "data: 第二帧\n\n"; + collector.accept(ByteBuffer.wrap(second.getBytes(StandardCharsets.UTF_8))); + assertEquals(second, frames.get(1)[0]); + collector.end(false); + assertEquals(2, frames.size()); + } + + @Test void oversizedFramesAreExplicitlyDroppedWithoutLeakingPartialSecrets() { + System.setProperty("WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS", "256"); + List frames = new ArrayList<>(); + WireLog.Body collector = new WireLog.Body(true, true, frames::add); + collector.accept(ByteBuffer.wrap(("data: {\"password\":\"" + "secret".repeat(100) + "\"}\n\n" + + "data: ok\n\n").getBytes(StandardCharsets.UTF_8))); + assertTrue(frames.get(0)[1].contains("dropped-capacity")); + assertFalse(frames.get(0)[0].contains("secret")); + assertEquals("data: ok\n\n", frames.get(1)[0]); + } + + @Test void loggingFailureCannotChangeBodyDeliveryAndBodySwitchIsExplicit() throws Exception { + List entries = new ArrayList<>(); + System.setProperty("WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY", "false"); + WireLog.emit(entries::add, "DIRECT_HTTP", "REQUEST", "1", "/test", "POST", + null, Map.of(), "serialized-utf8", "private", "", Map.of()); + assertTrue(entries.get(0).visibility().contains("disabled")); + assertFalse(entries.get(0).body().contains("private")); + assertDoesNotThrow(() -> WireLog.emit(e -> { throw new IllegalStateException("observer"); }, + "DIRECT_HTTP", "REQUEST", "1", "/test", "POST", null, Map.of(), "serialized-utf8", "", "", Map.of())); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolLoggerTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolLoggerTest.java index b34195fd..78c2d640 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolLoggerTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolLoggerTest.java @@ -35,10 +35,11 @@ void sensitiveHeadersAreRedactedByDefault() { } @Test - void sensitiveHeadersCanBeExplicitlyEnabledForControlledDiagnostics() { + void obsoleteSettingCannotDisableMandatoryRedaction() { System.setProperty(SETTING, "true"); String formatted = ProtocolLogger.formatHeaders(Map.of("Authorization", "Bearer visible")); - assertTrue(formatted.contains("Bearer visible")); + assertFalse(formatted.contains("Bearer visible")); + assertTrue(formatted.contains("Authorization: ***")); } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolResponsesTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolResponsesTest.java new file mode 100644 index 00000000..aa20ae62 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolResponsesTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.client; + +import org.a2aproject.sdk.client.*; +import org.a2aproject.sdk.spec.*; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class ProtocolResponsesTest { + @Test void appendDoesNotCountSdkSnapshotTwiceAndReplaceIsAuthoritative() { + Artifact first = artifact("a", "first", Map.of("x", 1)); + Artifact second = artifact("a", "second", Map.of("y", 2)); + Task seed = task(TaskState.TASK_STATE_WORKING, List.of(), Message.builder() + .messageId("progress").role(Message.Role.ROLE_AGENT).parts(new TextPart("working")).build()); + Task afterFirst = org.a2aproject.sdk.spec.util.Utils.appendArtifactToTask(seed, update(first, false), "task"); + Task afterSecond = org.a2aproject.sdk.spec.util.Utils.appendArtifactToTask(afterFirst, update(second, true), "task"); + List events = new ArrayList<>(List.of(new TaskUpdateEvent(afterFirst, update(first, false)), + new TaskUpdateEvent(afterSecond, update(second, true)))); + var partial = ProtocolResponses.assemble(events).get(0); + assertEquals(List.of("first", "second"), partial.outputs()); + assertEquals(Map.of("x", 1, "y", 2), partial.artifacts().get(0).metadata()); + Artifact replacement = artifact("a", "replaced", Map.of("final", true)); + Artifact other = artifact("b", "city2", Map.of()); + events.add(new TaskUpdateEvent(task(TaskState.TASK_STATE_WORKING, List.of(replacement), null), update(replacement, false))); + events.add(new TaskEvent(task(TaskState.TASK_STATE_COMPLETED, List.of(replacement, other), null))); + var completed = ProtocolResponses.assemble(events).get(0); + assertEquals(List.of("replaced", "city2"), completed.outputs()); + assertEquals(List.of("a", "b"), completed.artifacts().stream().map(Artifact::artifactId).toList()); + assertEquals(Map.of("final", true), completed.artifacts().get(0).metadata()); + } + + @Test void failureRetainsPartialArtifactButNotErrorTextAsOutput() { + Message error = Message.builder().messageId("error").role(Message.Role.ROLE_AGENT) + .parts(new TextPart("failed detail")).metadata(Map.of("level", "message")).build(); + var received = ProtocolResponses.assemble(List.of(new TaskEvent(task(TaskState.TASK_STATE_FAILED, + List.of(artifact("a", "partial", Map.of("level", "artifact"))), error)))).get(0); + assertEquals(List.of("partial"), received.outputs()); + assertEquals("message", received.message().metadata().get("level")); + assertEquals("task", received.taskMetadata().get("level")); + assertEquals("artifact", received.artifacts().get(0).metadata().get("level")); + } + + @Test void metadataOnlyAndNestedDataRemainAvailable() { + var received = ProtocolResponses.assemble(List.of(new TaskEvent(task(TaskState.TASK_STATE_COMPLETED, + List.of(), null)))).get(0); + assertTrue(received.outputs().isEmpty()); + assertEquals("task", received.taskMetadata().get("level")); + Artifact nested = Artifact.builder().artifactId("a").parts(new DataPart(List.of(List.of(1, 2)))).build(); + assertEquals(List.of(List.of(List.of(1, 2))), ProtocolResponses.assemble(List.of( + new TaskEvent(task(TaskState.TASK_STATE_COMPLETED, List.of(nested), null)))).get(0).outputs()); + } + + private static Task task(TaskState state, List artifacts, Message message) { + return Task.builder().id("task").contextId("context").status(new TaskStatus(state, message, null)) + .artifacts(artifacts).metadata(Map.of("level", "task")).build(); + } + private static Artifact artifact(String id, String text, Map metadata) { + return Artifact.builder().artifactId(id).name("report").description("description") + .parts(new TextPart(text)).metadata(metadata).extensions(List.of("urn:test")).build(); + } + private static TaskArtifactUpdateEvent update(Artifact artifact, boolean append) { + return TaskArtifactUpdateEvent.builder().taskId("task").contextId("context") + .artifact(artifact).append(append).lastChunk(false).build(); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireGrpcInterceptorTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireGrpcInterceptorTest.java new file mode 100644 index 00000000..6ad0e564 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireGrpcInterceptorTest.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import com.google.protobuf.StringValue; +import io.grpc.*; +import io.grpc.protobuf.ProtoUtils; +import io.grpc.stub.ServerCalls; +import org.junit.jupiter.api.Test; +import java.net.InetSocketAddress; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; + +class WireGrpcInterceptorTest { + @Test void capturesRealGrpcMetadataAndProtobufWithoutInventingHttpBody() throws Exception { + var method = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY).setFullMethodName("test.Protocol/Echo") + .setRequestMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())) + .setResponseMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())).build(); + AtomicReference captured = new AtomicReference<>(); + AtomicReference capturedHeaders = new AtomicReference<>(); + var service = ServerServiceDefinition.builder("test.Protocol").addMethod(method, + ServerCalls.asyncUnaryCall((StringValue request, io.grpc.stub.StreamObserver response) -> { + captured.set(request); + response.onNext(request); + response.onCompleted(); + })).build(); + ServerInterceptor receiver = new ServerInterceptor() { + public ServerCall.Listener interceptCall(ServerCall call, Metadata headers, + ServerCallHandler next) { + capturedHeaders.set(headers); + return next.startCall(new ForwardingServerCall.SimpleForwardingServerCall(call) { + public void sendHeaders(Metadata headers) { + var key = Metadata.Key.of("x-multi", Metadata.ASCII_STRING_MARSHALLER); + headers.put(key, "one"); headers.put(key, "two"); + super.sendHeaders(headers); + } + }, headers); + } + }; + Server server = ServerBuilder.forPort(0) + .addService(ServerInterceptors.intercept(service, receiver)).build().start(); + List logs = new CopyOnWriteArrayList<>(); + ManagedChannel channel = ManagedChannelBuilder.forAddress("127.0.0.1", server.getPort()).usePlaintext() + .intercept(new WireGrpcInterceptor(logs::add)).build(); + try { + Metadata headers = new Metadata(); + var version = Metadata.Key.of("a2a-version", Metadata.ASCII_STRING_MARSHALLER); + headers.put(version, "1.0"); + headers.put(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER), "Bearer hidden"); + CountDownLatch done = new CountDownLatch(1); + AtomicReference status = new AtomicReference<>(); + AtomicReference received = new AtomicReference<>(); + ClientCall call = WireLog.call(Map.of("executionId", "exec-1"), + () -> channel.newCall(method, CallOptions.DEFAULT.withDeadlineAfter(5, TimeUnit.SECONDS))); + call.start(new ClientCall.Listener<>() { + public void onMessage(StringValue value) { received.set(value); } + public void onClose(Status value, Metadata trailers) { status.set(value); done.countDown(); } + }, headers); + call.request(1); + StringValue payload = StringValue.of("诊断输入"); + call.sendMessage(payload); call.halfClose(); + assertTrue(done.await(6, TimeUnit.SECONDS)); + assertTrue(status.get().isOk()); + assertEquals(payload, captured.get()); + assertEquals(payload, received.get()); + assertEquals("1.0", capturedHeaders.get().get(version)); + assertTrue(logs.stream().allMatch(e -> e.boundary().equals("DIRECT_GRPC"))); + assertTrue(logs.stream().allMatch(e -> e.correlation().get("executionId").equals("exec-1"))); + assertEquals(1, logs.stream().map(WireLog.Entry::requestId).distinct().count()); + var request = logs.stream().filter(e -> e.direction().equals("REQUEST_BODY")).findFirst().orElseThrow(); + assertEquals("protobuf-json-view", request.representation()); + assertEquals("\"诊断输入\"", request.body()); + var response = logs.stream().filter(e -> e.direction().equals("RESPONSE_HEADERS")).findFirst().orElseThrow(); + assertEquals(List.of("one", "two"), response.headers().get("x-multi")); + assertFalse(logs.toString().contains("hidden")); + } finally { + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogFormatterTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogFormatterTest.java new file mode 100644 index 00000000..a1897774 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogFormatterTest.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.client; + +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class WireLogFormatterTest { + @Test void prettyJsonPreservesDuplicateKeysNumericAndEscapedStringTokens() { + String raw = "{\"n\":1e+02,\"n\":-0.0,\"text\":\"x\\n\\\"中文\\\"\",\"a\":[{},1]}"; + String pretty = WireLogFormatter.prettyBody(raw, "serialized-utf8"); + assertTrue(pretty.contains("\n \"n\": 1e+02,")); + assertTrue(pretty.contains("\n \"n\": -0.0,")); + assertTrue(pretty.contains("\"x\\n\\\"中文\\\"\"")); + assertTrue(pretty.contains("\n {},\n 1\n ]")); + } + + @Test void prettySseKeepsEventControlFieldsAndMakesDataReadable() { + String raw = "id: 12\r\nevent: task\r\n: heartbeat\r\ndata: {\"metadata\":{\r\ndata: \"performative\":\"PROPOSE\"}}\r\n\r\n"; + String pretty = WireLogFormatter.prettyBody(raw, "raw-sse-frame"); + assertTrue(pretty.startsWith("id: 12\nevent: task\n: heartbeat\n=== SSE data (JSON display; not wire text) ===\n{\n")); + assertTrue(pretty.contains(" \"performative\": \"PROPOSE\"")); + assertFalse(pretty.contains("\ndata: ")); + assertTrue(pretty.contains("=== End SSE data ===")); + assertTrue(pretty.endsWith("\n\n")); + assertEquals(pretty, WireLogFormatter.prettyBody(raw, "sdk-sse-text")); + } + + + @Test void sseDisplaySeparatesEventsAndKeepsRawObservationAvailable() { + String raw = "id: 1\ndata: {\n" + "data: \"n\": 1\n" + "data: }\n\n" + + "id: 2\ndata: {}\n\n"; + List observed = new ArrayList<>(); + WireLog.emit(observed::add, "DIRECT_HTTP", "RESPONSE_BODY", "sse-1", "/", "POST", 200, + Map.of(), "raw-sse-frame", raw, "observed", Map.of()); + assertEquals(raw, observed.get(0).body()); + assertTrue(WireLogFormatter.format(observed.get(0), false).endsWith(raw)); + String pretty = WireLogFormatter.format(observed.get(0), true); + assertTrue(pretty.contains("id: 1\n=== SSE data (JSON display; not wire text) ===\n{\n \"n\": 1\n}")); + assertTrue(pretty.contains("id: 2\n=== SSE data (JSON display; not wire text) ===\n{}")); + assertFalse(pretty.contains("\ndata:")); + assertEquals("data: {\"unfinished\":\n\n", + WireLogFormatter.prettyBody("data: {\"unfinished\":\n\n", "raw-sse-frame")); + } + + @Test void invalidTruncatedAndNonJsonContentAreNotRepaired() { + for (String raw : List.of("{\"x\":", "{\"x\":1} trailing", "[DONE]", "plain text")) + assertEquals(raw, WireLogFormatter.prettyBody(raw, "serialized-utf8")); + assertEquals("data: [DONE]\n\n", WireLogFormatter.prettyBody("data: [DONE]\n\n", "raw-sse-frame")); + } + + @Test void logLayoutIsPrettyButObserversKeepExactRedactedWireText() { + List entries = new ArrayList<>(); + String raw = "{\"password\":\"hidden\",\"negotiationContext\":{\"performative\":\"ACCEPT\"}}"; + WireLog.emit(entries::add, "DIRECT_HTTP", "REQUEST_BODY", "req-1", "/send", "POST", null, + Map.of("Authorization", List.of("hidden"), "A2A-Extensions", List.of("Negotiation-T/v1"), + "X-Trace", List.of("one", "two")), "serialized-utf8", raw, "observed", Map.of()); + WireLog.Entry entry = entries.get(0); + assertEquals(raw.replace("hidden", "***"), entry.body()); + String pretty = WireLogFormatter.format(entry, true); + assertTrue(pretty.contains("=== Headers ===\n")); + assertTrue(pretty.contains("A2A-Extensions: Negotiation-T/v1\n")); + assertTrue(pretty.contains("X-Trace: one\nX-Trace: two\n")); + assertTrue(pretty.contains("\n \"performative\": \"ACCEPT\"")); + assertFalse(pretty.contains("hidden")); + assertTrue(WireLogFormatter.format(entry, false).endsWith(entry.body())); + assertEquals(raw.replace("hidden", "***"), entry.body()); + } + + @Test void displayExpansionIsBoundedAndDoesNotRecurseOnDeepJson() { + String key = "WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS"; + String previous = System.getProperty(key); + System.setProperty(key, "256"); + try { + String raw = "{\"a\":[" + "1,".repeat(80) + "2]}"; + String display = WireLogFormatter.prettyBody(raw, "serialized-utf8"); + assertTrue(display.startsWith(raw)); + assertTrue(display.contains("pretty skipped")); + String deep = "[".repeat(70) + "0" + "]".repeat(70); + assertEquals(deep, WireLogFormatter.prettyBody(deep, "serialized-utf8").split("\n\\(pretty")[0]); + } finally { + if (previous == null) System.clearProperty(key); else System.setProperty(key, previous); + } + } + + @Test void disabledBodyIsNotReconstructedByPrettyDisplay() { + String key = "WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY"; + String previous = System.getProperty(key); + System.setProperty(key, "false"); + try { + List entries = new ArrayList<>(); + WireLog.emit(entries::add, "DIRECT_HTTP", "REQUEST_BODY", "req", "/", "POST", null, + Map.of(), "serialized-utf8", "{\"privateBusiness\":42}", "observed", Map.of()); + String display = WireLogFormatter.format(entries.get(0), true); + assertTrue(display.contains("(body logging disabled)")); + assertFalse(display.contains("privateBusiness")); + } finally { + if (previous == null) System.clearProperty(key); else System.setProperty(key, previous); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogRedactionTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogRedactionTest.java new file mode 100644 index 00000000..0b8ffe84 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogRedactionTest.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import org.junit.jupiter.api.Test; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class WireLogRedactionTest { + @Test void largeEscapedContentDoesNotUseRecursiveRegexBacktracking() { + String payload = "{\"result\":\"" + "escaped \\\"text\\\" ".repeat(8000) + + "\",\"token\":\"hidden\"}"; + assertEquals(payload.replace("hidden", "***"), WireLog.redact(payload)); + } + + @Test void redactsNestedValuesEscapedKeysAndIncompleteSecretsWithoutReserializingOtherFields() { + String original = "{ \"password\": {\"nested\":[\"secret\",{\"value\":\"hidden\"}]}, " + + "\"to\\u006ben\":\"hidden\", \"ok\" : [1, 2] }"; + assertEquals("{ \"password\": \"***\", \"to\\u006ben\":\"***\", \"ok\" : [1, 2] }", WireLog.redact(original)); + assertEquals("data: {\"accessSession\":\"***\"", WireLog.redact("data: {\"accessSession\":\"partial")); + assertEquals("password=***&ok=1", WireLog.redact("password=hidden&ok=1")); + } + + @Test void splitSdkTextChunksDoNotExposeFragmentsOfCredentials() { + List observed = new ArrayList<>(); + WireLog.Body body = new WireLog.Body(true, true, frame -> observed.add(WireLog.redact(frame[0]))); + for (String chunk : List.of("id: 1\ndata: {\"to", "ken\":\"sensitive", "-tail\",\"ok\":true}\n", "\n")) { + body.accept(ByteBuffer.wrap(chunk.getBytes(StandardCharsets.UTF_8))); + } + assertEquals(List.of("id: 1\ndata: {\"token\":\"***\",\"ok\":true}\n\n"), observed); + body.end(false); + assertEquals(1, observed.size()); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfigTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfigTest.java new file mode 100644 index 00000000..96765f7c --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfigTest.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.client; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class WorkflowEngineClientConfigTest { + @Test void rejectsInvalidResourceLimits() { + assertThrows(IllegalArgumentException.class, () -> WorkflowEngineClientConfig.builder().maxNegotiationExchanges(0).build()); + assertThrows(IllegalArgumentException.class, () -> WorkflowEngineClientConfig.builder().sendTimeoutSeconds(0).build()); + } + @Test void credentialKeyIsExplicitAndConfigurationIsSnapshotted() { + Map scheme = new LinkedHashMap<>(Map.of("username", "test")); + var config = WorkflowEngineClientConfig.builder() + .credentialsConfig(Map.of("agent", Map.of("auth", scheme))).credentialEncryptionKey("test-key").build(); + scheme.clear(); + assertEquals("test", config.getCredentialsConfig().get("agent").get("auth").get("username")); + assertEquals("test-key", config.getCredentialEncryptionKey()); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/control/DefaultControlPointTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/control/DefaultControlPointTest.java index 52765193..f95c4bee 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/control/DefaultControlPointTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/control/DefaultControlPointTest.java @@ -4,43 +4,58 @@ */ package dev.openan.workflow.engine.control; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.openan.workflow.engine.StubWorkflowEngineClient; -import dev.openan.workflow.engine.model.TaskRequest; +import static org.junit.jupiter.api.Assertions.*; +import dev.openan.workflow.engine.model.*; import org.junit.jupiter.api.Test; +import java.util.List; class DefaultControlPointTest { - private static final TaskRequest REQUEST = - TaskRequest.builder().agentName("agent").stepName("step").message("run").build(); + private final TaskRequest request = + TaskRequest.builder().agentName("agent").stepName("step").instruction("run").build(); + + @Test + void preparesTextWithoutSending() { + assertThrows(java.util.concurrent.CompletionException.class, + () -> new DefaultControlPoint().onTask(request).join()); + } @Test - void completedTaskWithEmptyTextIsSuccessful() { - StubWorkflowEngineClient client = - new StubWorkflowEngineClient("agent") - .withDefaultResponse("") - .withDefaultTaskState("TASK_STATE_COMPLETED"); - assertTrue(new DefaultControlPoint().onTask(REQUEST, client).join().isSuccess()); + void localExecutionRequiresBusinessHandler() { + assertThrows( + java.util.concurrent.CompletionException.class, + () -> new DefaultControlPoint().onSelfTask(request).join()); } @Test - void failedTaskWithErrorTextIsNotSuccessful() { - StubWorkflowEngineClient client = - new StubWorkflowEngineClient("agent") - .withDefaultResponse("remote error") - .withDefaultTaskState("TASK_STATE_FAILED"); - assertFalse(new DefaultControlPoint().onTask(REQUEST, client).join().isSuccess()); + void conditionalRoutingRequiresBusinessHandler() { + assertThrows( + java.util.concurrent.CompletionException.class, + () -> + new DefaultControlPoint().onRoute( + new RouteRequest( + "run", + "step", + WorkflowInput.empty(), + List.of(), + List.of())) + .join()); } @Test - void messageOnlyLegacyResponseFallsBackToText() { - StubWorkflowEngineClient client = - new StubWorkflowEngineClient("agent") - .withDefaultResponse("ok") - .withDefaultTaskState(""); - assertTrue(new DefaultControlPoint().onTask(REQUEST, client).join().isSuccess()); + void handlersCanBeRegisteredIndependently() { + var handler = + ControlPoint.builder() + .onSelfTask( + q -> + java.util.concurrent.CompletableFuture.completedFuture( + TaskResult.builder() + .success(true) + .outputs(List.of("done")) + .build())) + .build(); + assertEquals(List.of("done"), handler.onSelfTask(request).join().getOutputs()); + assertThrows(java.util.concurrent.CompletionException.class, () -> handler.onTask(request).join()); } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/BusinessBoundaryTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/BusinessBoundaryTest.java new file mode 100644 index 00000000..32ad16a8 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/BusinessBoundaryTest.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.core; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.openan.workflow.engine.StubWorkflowEngineClient; +import dev.openan.workflow.engine.control.*; +import dev.openan.workflow.engine.model.*; + +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.concurrent.*; + +class BusinessBoundaryTest { + private WorkflowStep step(String name, String next, List context) { + return WorkflowStep.builder() + .name(name) + .stepType(StepType.SELF_LOOP) + .contextFrom(context) + .subtasks(List.of(Task.builder().agent("host").description(name).build())) + .next(next == null ? List.of() : List.of(new JumpCondition(next, ""))) + .build(); + } + + @Test + void identifiersAndWorkflowStateAreIsolatedAcrossExecutions() { + var workflow = + Workflow.builder() + .name("shared") + .steps(List.of(step("one", null, List.of()))) + .build(); + List seen = new CopyOnWriteArrayList<>(); + var callbacks = + ControlPoint.builder() + .onSelfTask( + q -> { + seen.add(q); + return CompletableFuture.completedFuture( + TaskResult.builder() + .success(true) + .outputs(List.of("ok")) + .build()); + }) + .build(); + var client = new StubWorkflowEngineClient(); + var one = new WorkflowExecutor(workflow, callbacks, client, null, "first", "zh"); + var two = new WorkflowExecutor(workflow, callbacks, client, null, "second", "zh"); + CompletableFuture.allOf(one.run(), two.run()).join(); + assertEquals(2, seen.size()); + assertNotEquals(seen.get(0).getTaskId(), seen.get(1).getTaskId()); + assertNotEquals(seen.get(0).getExecutionId(), seen.get(1).getExecutionId()); + assertEquals( + TaskStatus.PENDING, workflow.getSteps().get(0).getSubtasks().get(0).getStatus()); + assertThrows(CompletionException.class, () -> one.run().join()); + } + + @Test + void routingUsesSelectedAncestorsAndCurrentTypedOutputsSeparately() { + for (List selection : + Arrays.asList(null, List.of(), List.of("*"), List.of("first"))) { + var first = step("first", "second", List.of()); + var second = step("second", "route", null); + var route = step("route", null, selection); + route.setNext(List.of(new JumpCondition("endNode", "all done"))); + var workflow = + Workflow.builder() + .name("route-context") + .steps(List.of(first, second, route)) + .build(); + List seen = new ArrayList<>(); + var callbacks = + ControlPoint.builder() + .onSelfTask( + q -> + CompletableFuture.completedFuture( + TaskResult.builder() + .success(true) + .outputs(List.of(q.getStepName())) + .build())) + .onRoute( + q -> { + seen.add(q); + return CompletableFuture.completedFuture( + new RouteDecision("endNode", "done")); + }) + .build(); + assertTrue( + new WorkflowExecutor( + workflow, + callbacks, + new StubWorkflowEngineClient(), + null, + "intent", + "zh") + .run() + .join() + .isSuccess()); + var q = seen.get(0); + assertEquals("intent", q.workflowInput().runtimeIntent()); + assertEquals(List.of("route"), q.currentResults().get(0).outputs()); + int expected = + selection == null + ? 1 + : selection.isEmpty() ? 0 : selection.contains("*") ? 2 : 1; + assertEquals(expected, q.workflowInput().upstreamResults().size()); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ContextBuilderTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ContextBuilderTest.java index cdee4b27..7b5eba19 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ContextBuilderTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ContextBuilderTest.java @@ -19,12 +19,15 @@ package dev.openan.workflow.engine.core; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import dev.openan.workflow.engine.model.JumpCondition; import dev.openan.workflow.engine.model.Task; import dev.openan.workflow.engine.model.Workflow; import dev.openan.workflow.engine.model.WorkflowStep; +import dev.openan.workflow.engine.model.TaskExecutionResult; +import dev.openan.workflow.engine.model.TaskStatus; import org.junit.jupiter.api.Test; @@ -42,145 +45,114 @@ private WorkflowStep step(String name, int layer, List next, List JumpCondition.builder().step(s).build()) + .map(target -> JumpCondition.builder().step(target).build()) .toList()) .contextFrom(contextFrom) .subtasks(List.of(Task.builder().agent("A").description("t").build())) .build(); } - @Test - void layerZeroReturnsRuntimeIntentOnly() { - Workflow wf = - Workflow.builder() - .name("w") - .steps(List.of(step("s1", 0, List.of("s2"), null))) - .build(); - ContextBuilder cb = new ContextBuilder(wf, "my intent"); - String ctx = cb.buildContext(wf.getSteps().get(0), new HashMap<>()); - assertEquals("## Runtime Context\n\nmy intent", ctx); + private TaskExecutionResult result(String agent, String output) { + return new TaskExecutionResult( + agent, + "diagnose", + agent + "-task", + "task", + TaskStatus.SUCCESS, + List.of(output), + List.of(), null, null, Map.of()); } @Test - void layerZeroNoIntentReturnsEmpty() { - Workflow wf = + void layerZeroKeepsRuntimeIntentSeparateFromTaskInstruction() { + Workflow workflow = Workflow.builder() .name("w") .steps(List.of(step("s1", 0, List.of("s2"), null))) .build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - String ctx = cb.buildContext(wf.getSteps().get(0), new HashMap<>()); - assertEquals("", ctx); + + var context = new ContextBuilder(workflow, "my intent") + .buildWorkflowInput(workflow.getSteps().get(0), Map.of()); + assertEquals("my intent", context.runtimeIntent()); + assertTrue(context.upstreamResults().isEmpty()); } @Test - void directPredecessorsProvideContext() { + void directPredecessorsProvideTypedResults() { WorkflowStep s1 = step("s1", 0, List.of("s2"), null); WorkflowStep s2 = step("s2", 1, List.of(), null); - Workflow wf = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); - ContextBuilder cb = new ContextBuilder(wf, "intent"); - Map> outputs = new HashMap<>(); - outputs.put("s1", Map.of("do A", "result-from-A")); - String ctx = cb.buildContext(s2, outputs); - assertTrue(ctx.contains("## Runtime Context")); - assertTrue(ctx.contains("## Previous Step Execution Results")); - assertTrue(ctx.contains("### s1 Results")); - assertTrue(ctx.contains("result-from-A")); + Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); + Map> results = + Map.of("s1", List.of(result("OMC-1", "result-from-A"))); + + var context = new ContextBuilder(workflow, "intent").buildWorkflowInput(s2, results); + + assertEquals("intent", context.runtimeIntent()); + assertEquals(1, context.upstreamResults().size()); + assertEquals("s1", context.upstreamResults().get(0).stepName()); + assertEquals("OMC-1", context.upstreamResults().get(0).taskResults().get(0).agentName()); + assertEquals( + List.of("result-from-A"), + context.upstreamResults().get(0).taskResults().get(0).outputs()); } @Test - void contextFromExplicitListOverridesPredecessors() { + void contextFromExplicitListOverridesDirectPredecessors() { WorkflowStep s1 = step("s1", 0, List.of("s3"), null); WorkflowStep s2 = step("s2", 0, List.of("s3"), null); WorkflowStep s3 = step("s3", 1, List.of(), List.of("s2")); - Workflow wf = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); - ContextBuilder cb = new ContextBuilder(wf, "intent"); - Map> outputs = new HashMap<>(); - outputs.put("s1", Map.of("t1", "out1")); - outputs.put("s2", Map.of("t2", "out2")); - String ctx = cb.buildContext(s3, outputs); - assertTrue(ctx.contains("out2")); - assertFalse(ctx.contains("out1")); + Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); + Map> results = new HashMap<>(); + results.put("s1", List.of(result("OMC-1", "out1"))); + results.put("s2", List.of(result("OMC-2", "out2"))); + + var context = new ContextBuilder(workflow, "intent").buildWorkflowInput(s3, results); + + assertEquals( + List.of("s2"), + context.upstreamResults().stream().map(value -> value.stepName()).toList()); } @Test void contextFromStarIncludesAllAncestors() { - // DAG: s1 -> s2 -> s3, context_from=["*"] on s3 should include s1 and s2 WorkflowStep s1 = step("s1", 0, List.of("s2"), null); WorkflowStep s2 = step("s2", 1, List.of("s3"), null); - WorkflowStep s3 = step("s3", 1, List.of(), List.of("*")); - Workflow wf = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); - ContextBuilder cb = new ContextBuilder(wf, "intent"); - Map> outputs = new HashMap<>(); - outputs.put("s1", Map.of("t1", "out1")); - outputs.put("s2", Map.of("t2", "out2")); - String ctx = cb.buildContext(s3, outputs); - assertTrue(ctx.contains("out1")); - assertTrue(ctx.contains("out2")); + WorkflowStep s3 = step("s3", 2, List.of(), List.of("*")); + Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); + Map> results = new HashMap<>(); + results.put("s1", List.of(result("OMC-1", "out1"))); + results.put("s2", List.of(result("OMC-2", "out2"))); + + var context = new ContextBuilder(workflow, "intent").buildWorkflowInput(s3, results); + + assertEquals(2, context.upstreamResults().size()); + assertEquals( + List.of("s2", "s1"), + context.upstreamResults().stream().map(value -> value.stepName()).toList()); } @Test - void getStepPredecessorsFindsDirectParents() { - WorkflowStep s1 = step("s1", 0, List.of("s3"), null); - WorkflowStep s2 = step("s2", 0, List.of("s3"), null); - WorkflowStep s3 = step("s3", 1, List.of(), null); - Workflow wf = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - List preds = cb.getStepPredecessors("s3"); - assertEquals(2, preds.size()); - assertTrue(preds.contains("s1")); - assertTrue(preds.contains("s2")); - } - - @Test - void getStepPredecessorsEmptyForRoot() { + void explicitEmptyContextFromDisablesUpstreamAggregation() { WorkflowStep s1 = step("s1", 0, List.of("s2"), null); - WorkflowStep s2 = step("s2", 1, List.of(), null); - Workflow wf = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - assertTrue(cb.getStepPredecessors("s1").isEmpty()); - } + WorkflowStep s2 = step("s2", 1, List.of(), List.of()); + Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); + Map> results = + Map.of("s1", List.of(result("OMC-1", "out1"))); - @Test - void buildTaskMessageWithZhLang() { - Workflow wf = - Workflow.builder().name("w").steps(List.of(step("s", 0, List.of(), null))).build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - String msg = cb.buildTaskMessage("do task", "context here", "zh"); - assertTrue(msg.contains("context here")); - assertTrue(msg.contains("## Current Task")); - assertTrue(msg.contains("do task")); - assertTrue(msg.contains("请用中文回复")); - } - - @Test - void buildTaskMessageWithEnLang() { - Workflow wf = - Workflow.builder().name("w").steps(List.of(step("s", 0, List.of(), null))).build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - String msg = cb.buildTaskMessage("do task", "context here", "en"); - assertTrue(msg.contains("Please respond in English")); + var context = new ContextBuilder(workflow, "intent").buildWorkflowInput(s2, results); + assertTrue(context.upstreamResults().isEmpty()); } @Test - void buildTaskMessageNoContext() { - Workflow wf = - Workflow.builder().name("w").steps(List.of(step("s", 0, List.of(), null))).build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - String msg = cb.buildTaskMessage("just a task", "", "zh"); - assertTrue(msg.startsWith("just a task")); - assertTrue(msg.contains("请用中文回复")); - assertFalse(msg.contains("## Current Task")); - } + void getStepPredecessorsFindsDirectParents() { + WorkflowStep s1 = step("s1", 0, List.of("s3"), null); + WorkflowStep s2 = step("s2", 0, List.of("s3"), null); + WorkflowStep s3 = step("s3", 1, List.of(), null); + Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); - @Test - void findStepIndexCorrect() { - WorkflowStep s1 = step("s1", 0, null, null); - WorkflowStep s2 = step("s2", 1, null, null); - Workflow wf = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - assertEquals(0, cb.findStepIndex("s1")); - assertEquals(1, cb.findStepIndex("s2")); - assertNull(cb.findStepIndex("nonexistent")); + List predecessors = new ContextBuilder(workflow, "").getStepPredecessors("s3"); + assertEquals(2, predecessors.size()); + assertTrue(predecessors.contains("s1")); + assertTrue(predecessors.contains("s2")); } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ProtocolResultAdapterTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ProtocolResultAdapterTest.java new file mode 100644 index 00000000..b09f54cf --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ProtocolResultAdapterTest.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openan.workflow.engine.model.SendMessageResult; + +import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatus; +import org.a2aproject.sdk.spec.TextPart; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +class ProtocolResultAdapterTest { + + @Test + void convertsOrderedTextAndStructuredArtifactsToBusinessOutputs() { + Task task = + Task.builder() + .id("task-1") + .contextId("context-1") + .status(new TaskStatus(TaskState.TASK_STATE_COMPLETED)) + .artifacts( + List.of( + Artifact.builder() + .artifactId("text-result") + .parts(new TextPart("diagnosis")) + .build(), + Artifact.builder() + .artifactId("data-result") + .parts(new DataPart(Map.of("alarmCount", 1))) + .build())) + .history(List.of()) + .metadata(Map.of("protocol", "must-not-leak")) + .build(); + + var response = + ProtocolResultAdapter.toTaskResult( + SendMessageResult.builder() + .task(task) + .receivedMessages(dev.openan.workflow.engine.client.ProtocolResponses.assemble( + List.of(new org.a2aproject.sdk.client.TaskEvent(task)))) + .text("flattened-value-must-not-win") + .taskState("TASK_STATE_COMPLETED") + .metadata(Map.of("wire", "must-not-leak")) + .build()); + + assertTrue(response.isSuccess()); + assertEquals(List.of("diagnosis", Map.of("alarmCount", 1)), response.getOutputs()); + } + + @Test + void doesNotInventTextFallbackWhenTransportHasNoBusinessContent() { + var response = + ProtocolResultAdapter.toTaskResult( + SendMessageResult.builder() + .text("diagnosis") + .taskState("TASK_STATE_COMPLETED") + .build()); + + assertEquals(List.of(), response.getOutputs()); + } + + @Test + void keepsFailedProtocolStateOutOfBusinessOutputs() { + var response = + ProtocolResultAdapter.toTaskResult( + SendMessageResult.builder() + .text("remote failure detail") + .taskState("TASK_STATE_FAILED") + .build()); + + assertFalse(response.isSuccess()); + assertEquals(List.of(), response.getOutputs()); + assertEquals("Agent returned state=TASK_STATE_FAILED", response.getError()); + } + + @Test + void failedStatusIsRetainedAsEvidenceButOnlyArtifactsBecomePartialOutputs() { + var status = dev.openan.workflow.engine.model.MessageContent.text("error: remote rejected"); + var evidence = new dev.openan.workflow.engine.model.ReceivedMessage(status, + Map.of("reason", "rejected"), List.of()); + var failed = SendMessageResult.builder().taskState("TASK_STATE_FAILED") + .receivedMessages(List.of(evidence)).build(); + var result = ProtocolResultAdapter.toTaskResult(failed); + assertFalse(result.isSuccess()); + assertEquals(List.of(), result.getOutputs()); + assertEquals(status, result.getReceivedMessages().get(0).message()); + + var partial = new dev.openan.workflow.engine.model.ReceivedMessage(status, Map.of(), + List.of(Artifact.builder().artifactId("partial") + .parts(new DataPart(Map.of("ports", List.of("a", "b")))).build())); + failed.setReceivedMessages(List.of(partial)); + assertEquals(List.of(Map.of("ports", List.of("a", "b"))), + ProtocolResultAdapter.toTaskResult(failed).getOutputs()); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java index 598b27c3..83f7e463 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java @@ -22,16 +22,22 @@ import static org.junit.jupiter.api.Assertions.*; import dev.openan.workflow.engine.StubWorkflowEngineClient; -import dev.openan.workflow.engine.client.WorkflowEngineClient; import dev.openan.workflow.engine.control.ControlPoint; import dev.openan.workflow.engine.control.EventCallback; import dev.openan.workflow.engine.control.EventType; import dev.openan.workflow.engine.model.*; +import dev.openan.workflow.engine.model.RouteRequest; +import dev.openan.workflow.engine.model.MessageContent; import org.junit.jupiter.api.Test; import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; /** * Tests for WorkflowExecutor: DAG traversal, parallel subtasks, ANY_SUCCESS, conditional routing, @@ -63,21 +69,25 @@ private JumpCondition jump(String step, String cond) { private ControlPoint autoCp() { return new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - return engineClient - .sendMessage(request.getAgentName(), request.getMessage()) - .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + public CompletableFuture onTask( + TaskRequest request) { + return CompletableFuture.completedFuture( + MessageContent.text( + request.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, Map results, List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(conditions.get(0).getStep()) @@ -154,6 +164,123 @@ void parallelFanOutAllUnconditionalNext() { assertEquals(Set.of("A", "B", "C"), agents); } + @Test + void parallelBranchesScheduleSharedMergeExactlyOnce() { + WorkflowStep city1 = + WorkflowStep.builder() + .name("diagnosis_city1") + .layer(0) + .subtasks(List.of(task("OMC-1", "diagnose city 1"))) + .next(List.of(jump("merge", ""))) + .build(); + WorkflowStep city2 = + WorkflowStep.builder() + .name("diagnosis_city2") + .layer(0) + .subtasks(List.of(task("OMC-2", "diagnose city 2"))) + .next(List.of(jump("merge", ""))) + .build(); + WorkflowStep merge = + WorkflowStep.builder() + .name("merge") + .layer(1) + .stepType(StepType.SELF_LOOP) + .contextFrom(List.of("diagnosis_city1", "diagnosis_city2")) + .subtasks(List.of(task("Workbench", "merge diagnoses"))) + .build(); + Workflow workflow = + Workflow.builder() + .name("parallel-join") + .steps(List.of(city1, city2, merge)) + .build(); + + ExecutorService taskExecutor = Executors.newFixedThreadPool(2); + CyclicBarrier diagnosisBarrier = new CyclicBarrier(2); + CyclicBarrier schedulingBarrier = new CyclicBarrier(2); + AtomicInteger mergeCalls = new AtomicInteger(); + ControlPoint controlPoint = + new ControlPoint() { + @Override + public CompletableFuture onTask( + TaskRequest request) { + return CompletableFuture.supplyAsync( + () -> { + awaitBarrier(diagnosisBarrier); + return MessageContent.text(request.getInstruction()); + }, + taskExecutor); + } + + @Override + public CompletableFuture onSelfTask(TaskRequest request) { + mergeCalls.incrementAndGet(); + return CompletableFuture.completedFuture( + TaskResult.builder().success(true).outputs(List.of("merged")).build()); + } + + @Override + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + + return CompletableFuture.completedFuture( + RouteDecision.builder() + .nextStep(conditions.get(0).getStep()) + .reason("first") + .build()); + } + + }; + EventCallback callback = + new EventCallback() { + @Override + public void onEvent(String type, Map data) { + if (EventType.STEP_COMPLETE.equals(type) + && String.valueOf(data.get("step")).startsWith("diagnosis_")) { + awaitBarrier(schedulingBarrier); + } + } + }; + + try { + ExecutionResult result = + new WorkflowExecutor( + workflow, + controlPoint, + new StubWorkflowEngineClient("OMC-1", "OMC-2"), + callback, + "cross-city complaint", + "zh") + .run() + .join(); + + assertTrue(result.isSuccess()); + assertEquals(1, mergeCalls.get(), "a converging step must execute exactly once"); + assertEquals(3, result.getHistory().size()); + assertEquals( + Set.of("diagnosis_city1", "diagnosis_city2", "merge"), + result.getStepOutputs().keySet()); + } finally { + taskExecutor.shutdownNow(); + } + } + + private static void awaitBarrier(CyclicBarrier barrier) { + try { + barrier.await(5, TimeUnit.SECONDS); + } catch (Exception e) { + throw new IllegalStateException("Timed out synchronizing parallel workflow test", e); + } + } + @Test void conditionalRouteOnRouteCalled() { WorkflowStep s1 = @@ -181,22 +308,26 @@ void conditionalRouteOnRouteCalled() { ControlPoint cp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest req, WorkflowEngineClient ec) { - return ec.sendMessage(req.getAgentName(), req.getMessage()) - .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + public CompletableFuture onTask( + TaskRequest req) { + return CompletableFuture.completedFuture( + MessageContent.text( + req.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder().nextStep("s3").reason("chose s3").build()); } @@ -230,22 +361,26 @@ void onRouteInvalidStepFailsWorkflow() { ControlPoint cp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest req, WorkflowEngineClient ec) { - return ec.sendMessage(req.getAgentName(), req.getMessage()) - .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + public CompletableFuture onTask( + TaskRequest req) { + return CompletableFuture.completedFuture( + MessageContent.text( + req.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep("nonexistent") @@ -282,29 +417,30 @@ void taskFailurePropagatesAndStopsWorkflow() { ControlPoint cp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest req, WorkflowEngineClient ec) { + public CompletableFuture onTask( + TaskRequest req) { if (req.getAgentName().equals("A")) { - return CompletableFuture.completedFuture( - TaskResponse.builder() - .success(false) - .error("A failed") - .build()); + return CompletableFuture.failedFuture( + new IllegalStateException("business preparation failed")); } - return ec.sendMessage(req.getAgentName(), req.getMessage()) - .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + return CompletableFuture.completedFuture( + MessageContent.text( + req.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(conditions.get(0).getStep()) @@ -359,21 +495,29 @@ void anySuccessAllFailReturnsFailure() { .next(List.of()) .build(); Workflow wf = Workflow.builder().name("any-fail").steps(List.of(s1)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B").withDefaultTaskState("TASK_STATE_FAILED"); ControlPoint cp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest req, WorkflowEngineClient ec) { + public CompletableFuture onTask( + TaskRequest req) { return CompletableFuture.completedFuture( - TaskResponse.builder().success(false).error("failed").build()); + MessageContent.text(req.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(conditions.get(0).getStep()) @@ -429,22 +573,30 @@ void runtimeIntentPassedToContext() { .build(); Workflow wf = Workflow.builder().name("intent").steps(List.of(s1)).build(); StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A"); - List messages = Collections.synchronizedList(new ArrayList<>()); + List requests = Collections.synchronizedList(new ArrayList<>()); ControlPoint cp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest req, WorkflowEngineClient ec) { - messages.add(req.getMessage()); + public CompletableFuture onTask( + TaskRequest req) { + requests.add(req); return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output("ok").build()); + MessageContent.text(req.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(conditions.get(0).getStep()) @@ -454,8 +606,10 @@ public CompletableFuture onRoute( WorkflowExecutor exec = new WorkflowExecutor(wf, cp, stub, new EventCallback(), "my intent", "zh"); exec.run().join(); - assertFalse(messages.isEmpty()); - assertTrue(messages.get(0).contains("my intent")); + assertFalse(requests.isEmpty()); + assertEquals("do A", requests.get(0).getInstruction()); + assertEquals("my intent", requests.get(0).getWorkflowInput().runtimeIntent()); + assertTrue(requests.get(0).getWorkflowInput().upstreamResults().isEmpty()); } @Test @@ -566,22 +720,26 @@ void mergeDoesNotWaitForUnselectedConditionalBranch() { ControlPoint chooseRight = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - return engineClient.sendMessage(request.getAgentName(), request.getMessage()) - .thenApply( - response -> - TaskResponse.builder() - .success(true) - .output(response.getText()) - .build()); + public CompletableFuture onTask( + TaskRequest request) { + return CompletableFuture.completedFuture( + MessageContent.text( + request.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder().nextStep("right").reason("test").build()); } @@ -627,32 +785,44 @@ void selfLoopStepCallsOnSelfTaskNotOnTask() { .next(List.of()) .build(); Workflow wf = Workflow.builder().name("self-loop").steps(List.of(remote, selfStep)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("SPN"); - List selfTaskMsgs = Collections.synchronizedList(new ArrayList<>()); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("SPN") + .withDefaultOutputs( + List.of(Map.of("diagnosis", List.of("found"), "alarmCount", 1))); + List selfTaskRequests = Collections.synchronizedList(new ArrayList<>()); ControlPoint cp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest req, WorkflowEngineClient ec) { - return ec.sendMessage(req.getAgentName(), req.getMessage()) + public CompletableFuture onTask( + TaskRequest req) { + return CompletableFuture.completedFuture( + MessageContent.text( + req.getInstruction())) .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + r -> MessageContent.text(req.getInstruction())); } @Override - public CompletableFuture onSelfTask(TaskRequest req) { - selfTaskMsgs.add(req.getMessage()); + public CompletableFuture onSelfTask(TaskRequest req) { + selfTaskRequests.add(req); return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output("merged").build()); + TaskResult.builder() + .success(true) + .outputs(List.of("merged")).build()); } @Override - public CompletableFuture onRoute( - String s, Map r, List c) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String s = routeRequest.stepName(); + Map r = java.util.Map.of(); + List c = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(c.get(0).getStep()) @@ -667,8 +837,200 @@ public CompletableFuture onRoute( // Only the remote diagnosis step sends via A2A-T; the merge step is local. assertEquals(1, stub.getSentCount()); assertEquals("SPN", stub.getSentMessages().get(0).agentName); - assertEquals(1, selfTaskMsgs.size()); + assertEquals(1, selfTaskRequests.size()); + TaskRequest mergeRequest = selfTaskRequests.get(0); + assertEquals("merge results", mergeRequest.getInstruction()); + assertFalse( + mergeRequest.getInstruction().contains("diagnose"), + "Current instruction must not contain upstream output"); + assertEquals("intent", mergeRequest.getWorkflowInput().runtimeIntent()); + assertEquals(1, mergeRequest.getWorkflowInput().upstreamResults().size()); + assertEquals("diag", mergeRequest.getWorkflowInput().upstreamResults().get(0).stepName()); + List upstreamOutputs = + mergeRequest + .getWorkflowInput() + .upstreamResults() + .get(0) + .taskResults() + .get(0) + .outputs(); + Object upstreamOutput = upstreamOutputs.get(0); + assertInstanceOf(Map.class, upstreamOutput); + assertEquals(1, ((Map) upstreamOutput).get("alarmCount")); + assertEquals(1, upstreamOutputs.size()); assertTrue(events.contains(EventType.TASK_REQUEST)); assertTrue(events.contains(EventType.TASK_RESPONSE)); } + + @Test + void repeatedDescriptionsInParallelStepDoNotOverwriteResults() { + WorkflowStep parallel = + WorkflowStep.builder() + .name("diagnosis") + .layer(0) + .subtasks( + List.of( + task("City1", "SPN专线故障诊断"), + task("City2", "SPN专线故障诊断"))) + .build(); + ControlPoint cp = + new ControlPoint() { + @Override + public CompletableFuture onTask( + TaskRequest request) { + return CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction())); + } + + @Override + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep("end").build()); + } + }; + + ExecutionResult result = + new WorkflowExecutor( + Workflow.builder() + .name("duplicate-descriptions") + .steps(List.of(parallel)) + .build(), + cp, + new StubWorkflowEngineClient() + .withResponse("City1", "City1 result") + .withResponse("City2", "City2 result"), + recordingCallback(), + "", + "zh") + .run() + .join(); + + assertTrue(result.isSuccess()); + assertEquals( + Map.of( + "SPN专线故障诊断 [City1#0]", List.of("City1 result"), + "SPN专线故障诊断 [City2#1]", List.of("City2 result")), + result.getStepOutputs().get("diagnosis")); + assertEquals(List.of(0, 1), + result.getHistory().stream().map(item -> (Integer) item.get("subtask_index")).sorted().toList()); + } + + @Test + void nullTaskOutputAndNullExceptionMessageAreNormalized() { + WorkflowStep step = + WorkflowStep.builder() + .name("nullable") + .layer(0) + .subtasks(List.of(task("A", "empty"))) + .build(); + ControlPoint nullOutput = + new ControlPoint() { + @Override + public CompletableFuture onTask( + TaskRequest request) { + return CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction())); + } + + @Override + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep("end").build()); + } + }; + ExecutionResult success = + new WorkflowExecutor( + Workflow.builder().name("null-output").steps(List.of(step)).build(), + nullOutput, + new StubWorkflowEngineClient().withDefaultResponse(""), + recordingCallback(), + "", + "zh") + .run() + .join(); + assertTrue(success.isSuccess()); + assertEquals(List.of(), success.getStepOutputs().get("nullable").get("empty")); + + ControlPoint nullMessageFailure = + new ControlPoint() { + @Override + public CompletableFuture onTask( + TaskRequest request) { + return CompletableFuture.failedFuture(new RuntimeException((String) null)); + } + + @Override + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep("end").build()); + } + }; + ExecutionResult failure = + new WorkflowExecutor( + Workflow.builder().name("null-error").steps(List.of(step)).build(), + nullMessageFailure, + new StubWorkflowEngineClient().withDefaultResponse(""), + recordingCallback(), + "", + "zh") + .run() + .join(); + assertFalse(failure.isSuccess()); + assertFalse(String.valueOf(failure.getHistory().get(0).get("output")).isBlank()); + } + + @Test + void canceledAndTimedOutPreparationCannotSubmitLateContent() throws Exception { + for (boolean timeout : List.of(false, true)) { + Workflow wf = Workflow.builder().name("late").steps(List.of(WorkflowStep.builder() + .name("s1").subtasks(List.of(task("A", "work"))).build())).build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A") { + @Override public long callbackTimeoutSeconds() { return 1; } + }; + CompletableFuture prepared = new CompletableFuture<>(); + java.util.concurrent.CountDownLatch entered = new java.util.concurrent.CountDownLatch(1); + ControlPoint cp = ControlPoint.builder().onTask(request -> { + entered.countDown(); return prepared; + }).build(); + var run = new WorkflowExecutor(wf, cp, stub, recordingCallback(), "", "zh").run(); + assertTrue(entered.await(1, TimeUnit.SECONDS)); + if (timeout) assertFalse(run.get(3, TimeUnit.SECONDS).isSuccess()); + else assertTrue(run.cancel(true)); + prepared.complete(MessageContent.text("too late")); + assertEquals(0, stub.getSentCount()); + } + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/model/MessageContentTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/MessageContentTest.java new file mode 100644 index 00000000..7937d240 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/MessageContentTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import org.a2aproject.sdk.spec.*; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class MessageContentTest { + @Test void snapshotsPartsMetadataAndActivationWithoutChangingOrder() { + List nested = new ArrayList<>(List.of("city1")); + Map data = new LinkedHashMap<>(Map.of("cities", nested)); + Map metadata = new LinkedHashMap<>(Map.of("nested", data)); + Set extensions = new LinkedHashSet<>(List.of("urn:example:task")); + List> parts = new ArrayList<>(List.of( + new TextPart("instruction", metadata), new DataPart(data, metadata), + new FilePart(new FileWithUri("text/plain", "report", "https://invalid.example/report"), metadata))); + MessageContent content = new MessageContent(parts, metadata, extensions); + nested.add("changed"); + data.clear(); + parts.clear(); + metadata.clear(); + extensions.clear(); + assertEquals(3, content.parts().size()); + assertEquals(Map.of("cities", List.of("city1")), ((DataPart) content.parts().get(1)).data()); + assertEquals("https://invalid.example/report", ((FileWithUri) ((FilePart) content.parts().get(2)).file()).uri()); + assertEquals(Set.of("urn:example:task"), content.extensions()); + assertThrows(UnsupportedOperationException.class, () -> content.metadata().put("new", true)); + assertThrows(UnsupportedOperationException.class, () -> content.parts().clear()); + } + + @Test void snapshotsPrimitiveAndArrayDataWithoutFlattening() { + List nested = new ArrayList<>(List.of("one", Map.of("value", 2))); + ReceivedMessage received = new ReceivedMessage( + MessageContent.parts(List.of(new DataPart(nested), new DataPart(3), new TextPart("{not parsed}"))), + Map.of(), List.of()); + nested.clear(); + assertEquals(List.of(List.of("one", Map.of("value", 2)), 3, "{not parsed}"), received.outputs()); + } + + @Test void keepsMetadataOnlyResponseAndAllArtifactFields() { + List items = new ArrayList<>(List.of("a")); + Artifact artifact = new Artifact("artifact1", "report", "description", + List.of(new DataPart(items)), Map.of("same", "artifact"), List.of("urn:artifact")); + ReceivedMessage received = new ReceivedMessage( + new MessageContent(List.of(), Map.of("same", "message"), Set.of()), + Map.of("same", "task"), List.of(artifact)); + items.clear(); + assertEquals("message", received.message().metadata().get("same")); + assertEquals("task", received.taskMetadata().get("same")); + assertEquals("artifact", received.artifacts().get(0).metadata().get("same")); + assertEquals(List.of(List.of("a")), received.outputs()); + assertEquals("description", received.artifacts().get(0).description()); + assertEquals(List.of("urn:artifact"), received.artifacts().get(0).extensions()); + assertEquals(List.of(), new ReceivedMessage(new MessageContent(List.of(), Map.of("task-t", "body"), + Set.of()), Map.of(), List.of()).outputs()); + } + + @Test void preservesFileReferenceAndRejectsUnknownParts() { + assertInstanceOf(TextPart.class, MessageContent.text("").parts().get(0)); + assertThrows(IllegalArgumentException.class, + () -> MessageContent.parts(List.of(new Part() {}))); + assertThrows(NullPointerException.class, () -> MessageContent.parts(null)); + assertThrows(IllegalArgumentException.class, + () -> new MessageContent(List.of(), Map.of(), Set.of(""))); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationRequestTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationRequestTest.java new file mode 100644 index 00000000..2cf32649 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationRequestTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; +import org.junit.jupiter.api.Test; +import java.util.*; +import java.time.Duration; +import static org.junit.jupiter.api.Assertions.*; + +class NegotiationRequestTest { + @Test void preservesTaskContentAndHistorySnapshot() { + var task = TaskRequest.builder().agentName("omc").taskId("local-task").build(); + var content = MessageContent.text("opaque"); + var received = new ReceivedMessage(content, Map.of("task-only", true), List.of()); + List history = new ArrayList<>(List.of( + new NegotiationRequest.Exchange(received, new NegotiationReply.Stop("code", "reason")))); + var request = new NegotiationRequest(task, content, received, history, Duration.ofSeconds(3)); + history.clear(); + assertEquals("omc", request.agentName()); + assertEquals("local-task", request.task().getTaskId()); + assertEquals(1, request.previousExchanges().size()); + assertSame(received, request.received()); + assertThrows(IllegalArgumentException.class, () -> + new NegotiationRequest(task, content, received, List.of(), Duration.ofSeconds(-1))); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/runner/ExecutePsopTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/runner/ExecutePsopTest.java index 851886a6..7417f969 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/runner/ExecutePsopTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/runner/ExecutePsopTest.java @@ -22,12 +22,13 @@ import static org.junit.jupiter.api.Assertions.*; import dev.openan.workflow.engine.StubWorkflowEngineClient; -import dev.openan.workflow.engine.client.WorkflowEngineClient; import dev.openan.workflow.engine.control.ControlPoint; import dev.openan.workflow.engine.control.EventCallback; import dev.openan.workflow.engine.control.EventType; import dev.openan.workflow.engine.model.*; +import dev.openan.workflow.engine.model.RouteRequest; +import dev.openan.workflow.engine.model.MessageContent; import org.junit.jupiter.api.Test; import java.util.*; @@ -50,21 +51,22 @@ private Task task(String agent, String desc) { private ControlPoint autoCp() { return new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - return engineClient - .sendMessage(request.getAgentName(), request.getMessage()) - .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + public CompletableFuture onTask( + TaskRequest request) { + return CompletableFuture.completedFuture( + MessageContent.text( + request.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, Map results, List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map(c -> new JumpCondition(c.nextStep(), c.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(conditions.get(0).getStep()) @@ -103,25 +105,11 @@ public void onEvent(String type, Map data) { } }; ExecutionResult result = - ExecutePsop.execute( - linearWorkflow(), - List.of(), - autoCp(), - stub, - "intent", - "zh", - null, - null, - false, - null, - null, - cb, - (BiFunction< + ExecutePsop.execute(linearWorkflow(), List.of(), autoCp(), stub, "intent", "zh", null, false, null, null, cb, (BiFunction< ExecutionResult, List>, CompletableFuture>) - null, - null) + null, null) .join(); assertTrue(result.isSuccess()); // Verify lifecycle bracket: start ... complete ... close @@ -153,21 +141,7 @@ void onFinishCalledWithResultAndEvents() { return CompletableFuture.completedFuture(null); }; ExecutionResult result = - ExecutePsop.execute( - linearWorkflow(), - List.of(), - autoCp(), - stub, - "intent", - "zh", - null, - null, - false, - null, - null, - new EventCallback(), - onFinish, - null) + ExecutePsop.execute(linearWorkflow(), List.of(), autoCp(), stub, "intent", "zh", null, false, null, null, new EventCallback(), onFinish, null) .join(); assertEquals(1, finishCalls.get()); assertTrue(capturedResult.get().isSuccess()); @@ -195,21 +169,7 @@ public void onEvent(String type, Map data) { } return event; }; - ExecutePsop.execute( - linearWorkflow(), - List.of(), - autoCp(), - stub, - "intent", - "zh", - null, - null, - false, - null, - null, - cb, - null, - onEvent) + ExecutePsop.execute(linearWorkflow(), List.of(), autoCp(), stub, "intent", "zh", null, false, null, null, cb, null, onEvent) .join(); assertTrue(events.contains("psop_update")); int psopIdx = events.indexOf("psop_update"); @@ -238,21 +198,7 @@ public void onEvent(String type, Map data) { } return event; }; - ExecutePsop.execute( - linearWorkflow(), - List.of(), - autoCp(), - stub, - "intent", - "zh", - null, - null, - false, - null, - null, - cb, - null, - onEvent) + ExecutePsop.execute(linearWorkflow(), List.of(), autoCp(), stub, "intent", "zh", null, false, null, null, cb, null, onEvent) .join(); assertFalse(events.contains(EventType.TASK_REQUEST)); } @@ -262,21 +208,7 @@ void builderProducesSameResultAsStaticExecute() { StubWorkflowEngineClient stub1 = new StubWorkflowEngineClient("A", "B"); StubWorkflowEngineClient stub2 = new StubWorkflowEngineClient("A", "B"); ExecutionResult r1 = - ExecutePsop.execute( - linearWorkflow(), - List.of(), - autoCp(), - stub1, - "intent", - "zh", - null, - null, - false, - null, - null, - new EventCallback(), - null, - null) + ExecutePsop.execute(linearWorkflow(), List.of(), autoCp(), stub1, "intent", "zh", null, false, null, null, new EventCallback(), null, null) .join(); ExecutionResult r2 = ExecutePsop.builder() @@ -307,33 +239,55 @@ void builderRequiresControlPoint() { () -> ExecutePsop.builder().psop(linearWorkflow()).execute()); } + @Test + void injectedClientRemainsCallerOwnedAfterExecution() { + AtomicInteger closeCalls = new AtomicInteger(); + StubWorkflowEngineClient client = + new StubWorkflowEngineClient("A", "B") { + @Override + public void close() { + closeCalls.incrementAndGet(); + } + }; + + ExecutionResult result = + ExecutePsop.builder() + .psop(linearWorkflow()) + .controlPoint(autoCp()) + .engineClient(client) + .execute() + .join(); + + assertTrue(result.isSuccess()); + assertEquals(0, closeCalls.get()); + } + @Test void errorLifecycleOnTaskFailure() { StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); ControlPoint failCp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { + public CompletableFuture onTask( + TaskRequest request) { if (request.getAgentName().equals("A")) { - return CompletableFuture.completedFuture( - TaskResponse.builder().success(false).error("A broke").build()); + return CompletableFuture.failedFuture( + new IllegalStateException("business preparation failed")); } - return engineClient - .sendMessage(request.getAgentName(), request.getMessage()) - .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + return CompletableFuture.completedFuture( + MessageContent.text( + request.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map(c -> new JumpCondition(c.nextStep(), c.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(conditions.get(0).getStep()) @@ -349,21 +303,7 @@ public void onEvent(String type, Map data) { } }; ExecutionResult result = - ExecutePsop.execute( - linearWorkflow(), - List.of(), - failCp, - stub, - "intent", - "zh", - null, - null, - false, - null, - null, - cb, - null, - null) + ExecutePsop.execute(linearWorkflow(), List.of(), failCp, stub, "intent", "zh", null, false, null, null, cb, null, null) .join(); assertFalse(result.isSuccess()); assertEquals(EventType.START, events.get(0)); diff --git a/workflow-engine/src/test/resources/a2at-mock.env b/workflow-engine/src/test/resources/a2at-mock.env new file mode 100644 index 00000000..0a9f2d84 --- /dev/null +++ b/workflow-engine/src/test/resources/a2at-mock.env @@ -0,0 +1,16 @@ +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=classpath +A2AT_PROMPT_COMPLIANCE_ENABLED=false +A2AT_LLM_PROVIDER=workflow-engine-test +A2AT_LLM_MODEL=test-model +A2AT_LLM_API_KEY=test-key-not-used +A2AT_LLM_BASE_URL=https://llm.example.test/v1 +A2AT_LLM_MAX_TOKENS=256 +A2AT_LLM_TEMPERATURE=0 +A2AT_LLM_TIMEOUT_SECONDS=1 +A2AT_LLM_HISTORY_WINDOW=1 +A2AT_LLM_DISABLE_SYSTEM_PROXY=true +A2AT_LLM_SESSION_MAX_TOTAL=10 +A2AT_LLM_SESSION_MAX_PER_PROVIDER=10 +A2AT_LLM_MAX_ATTEMPTS=1 +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory diff --git a/workflow-engine/src/test/resources/a2at-test.env b/workflow-engine/src/test/resources/a2at-test.env new file mode 100644 index 00000000..9c09c95d --- /dev/null +++ b/workflow-engine/src/test/resources/a2at-test.env @@ -0,0 +1,16 @@ +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=classpath +A2AT_PROMPT_COMPLIANCE_ENABLED=false +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=test-model +A2AT_LLM_API_KEY=test-key-not-used +A2AT_LLM_BASE_URL=http://127.0.0.1:1/v1 +A2AT_LLM_MAX_TOKENS=256 +A2AT_LLM_TEMPERATURE=0 +A2AT_LLM_TIMEOUT_SECONDS=1 +A2AT_LLM_HISTORY_WINDOW=1 +A2AT_LLM_DISABLE_SYSTEM_PROXY=true +A2AT_LLM_SESSION_MAX_TOTAL=10 +A2AT_LLM_SESSION_MAX_PER_PROVIDER=10 +A2AT_LLM_MAX_ATTEMPTS=1 +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory diff --git a/workflow-engine/src/test/resources/test-profile-credentials.json b/workflow-engine/src/test/resources/test-profile-credentials.json new file mode 100644 index 00000000..1ace62db --- /dev/null +++ b/workflow-engine/src/test/resources/test-profile-credentials.json @@ -0,0 +1,30 @@ +{ + "profiles": { + "shared": { + "bearerAuth": { + "login_url": "https://shared.example.test/token", + "request_fields": { + "userName": "shared-user", + "value": "shared-password" + }, + "token_field": "accessSession" + } + } + }, + "agents": { + "City 1": { + "profile": "shared", + "overrides": { + "bearerAuth": { + "login_url": "https://city1.example.test/token", + "request_fields": { + "value": "city1-password" + } + } + } + }, + "City 2": { + "profile": "shared" + } + } +}