diff --git a/.env.example b/.env.example index 7945f4e7..9e450327 100644 --- a/.env.example +++ b/.env.example @@ -17,8 +17,7 @@ # Copy this file to .env and fill in your values # 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 +# Credentials are always anonymized; there is no sensitive-header opt-in. # Set false to omit request/response bodies; large bodies are truncated at this character count. WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY=true WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS=100000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f7cc79a..f97bb026 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,10 @@ name: CI on: push: - branches: [main] + branches: [ main ] pull_request: - branches: [main] + branches: [ main ] + workflow_dispatch: jobs: build-and-test: @@ -41,8 +42,7 @@ jobs: samples-compilation-check: runs-on: ubuntu-latest - name: Samples Compilation Check (optional) - continue-on-error: true + name: Direct sample release gate steps: - name: Checkout code @@ -57,3 +57,10 @@ jobs: - name: Build and test samples run: mvn -B -pl samples -am verify + + - name: Upload sample test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: direct-sample-test-reports + path: samples/target/surefire-reports/ diff --git a/.vscode/settings.json b/.vscode/settings.json index e0120650..75132b4b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ { - "java.compile.nullAnalysis.mode": "automatic", - "java.configuration.updateBuildConfiguration": "interactive" + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "interactive" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b68f9c30..2a7795a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,149 +1,28 @@ # Changelog -## Unreleased — business callback boundary +## [Unreleased] — 1.0.0 release candidate -- 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. +- 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. +- 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. +- 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. +### Release hardening -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres -to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +- Propagate workflow cancellation and close execution-owned resources exactly once. +- Anonymize orchestration access tokens and bound registry response deadlines. +- Preserve multi-value results from arbitrary terminal workflow nodes. +- Publish a compiled host integration example and require sample regression tests. +- Keep documentation host-neutral; sample class and AgentCard identifiers remain unchanged. -## [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 - -- TLS configuration is now per client; it no longer changes JVM-wide hostname-verification properties -- Credential login uses the same TLS policy as agent traffic and encrypted credentials now fail closed -- AgentCards that declare security requirements now fail closed when neither matching credentials nor an `AuthProvider` is configured -- `securitySchemes` no longer implies non-empty `securityRequirements`; an `AuthProvider` can be the sole authentication source -- Notification-T distinguishes acknowledgement timeout from real stream failure and keeps its lifecycle independent from - workflow request transports -- 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 - -- Task success is derived from the A2A task state instead of non-empty response text alone -- Conflicting authentication headers from `AuthProvider` and credential configuration now fail fast -- 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 - -First public release. The SDK ships a clean transport-facade architecture with single-responsibility decision interfaces -and full A2A-T extension support. Full A2A-T extension support and multi-protocol transport. - -### Added - -- `A2ATransport`: shared wire layer owning the A2A client runtime, auth manager, agent-card map, and streaming event - extraction -- `ExtensionSender` / `DefaultExtensionSender`: pre-positioning facade for one-shot Authorization-T and long-lived - Notification-T SSE subscriptions -- `ControlPoint` / `ExtensionSender` split: flow decisions (`onTask` / `onSelfTask` / `onRoute` / `onNegotiation`) on `ControlPoint`; Authorization-T / Notification-T pre-positioning on `ExtensionSender` -- `NegotiationStrategy`: pluggable clarification strategy injected into - `DefaultControlPoint` -- `SELF_LOOP` step type for local task handling without an A2A-T message -- `ANY_SUCCESS` step policy with early cancellation of remaining subtasks -- Parallel DAG step dispatch and context assembly (`ContextBuilder`) -- `EventType` constants covering runner lifecycle, step/task execution, agent traffic, and A2A-T extension events -- `ExecutePsop.Builder`: fluent builder with event stream, lifecycle bracket, and `onFinish` persistence hook -- `A2ATExtension` enum encapsulating all extension URIs (no hardcoded strings) -- `DefaultExtensionSender` prompt-generation dispatch (Task-T via the A2A-T SDK; Authorization-T / Notification-T / - Negotiation-T reserved for SDK support) -- [Design Document](docs/en/DESIGN.md) architecture document - -### Changed - -- `DefaultWorkflowEngineClient` is now a facade over `A2ATransport`, owning only the workflow send path (Task-T prompt - generation, Negotiation-T auto-loop, event callback, ControlPoint/ExtensionCallback wiring) -- Pre-positioning sends moved from `WorkflowEngineClient` to `ExtensionSender` -- `ExtensionRegistry` auto-registers only Task-T and Negotiation-T (in-workflow handlers); Authorization-T / - Notification-T are pre-positioning operations with request and subscription lifecycles respectively - -## [0.3.0] - 2026-07-25 - -### Added - -- `EnvFileLoader`: loads `.env` file entries into system properties, bridging A2A-T SDK's internal `.env` loading with - `CredentialCrypto` and other components that read from `System.getenv()` / `System.getProperty()` -- `ProtocolLogger`: dedicated `PROTOCOL` SLF4J logger for full protocol-level request/response dumps (headers + body), - enabling verification against real network captures -- `ExtensionInterceptor`: metadata-aware `A2A-Extensions` header injection; only advertises extensions actually present - in the current message metadata -- `AuthProvider` interface: custom authentication for non-standard auth mechanisms (SSO, API keys, custom headers) -- `sendExtensionMessage()` on `WorkflowEngineClient`: one-shot extension messages for Authorization-T pre-positioning - and Notification-T subscription -- `ExecutePsop.Builder`: fluent builder API replacing the 14-argument static - `execute()` method -- AgentCard Jackson module for deserializing AgentCard JSON with security scheme normalization -- HTTPS/TLS support with configurable verification and CA trust store -- AES-GCM credential encryption (`enc:iv:ciphertext` format) with - `A2AT_CRED_KEY` environment variable -- SSE timeout configurable via `sendTimeoutSeconds` (default 600s) -- Complete Chinese and English documentation: - - [Integration Guide](docs/en/INTEGRATION_GUIDE.md) / [中文](docs/zh/INTEGRATION_GUIDE.md) - - [API Reference](docs/en/API_REFERENCE.md) / [中文](docs/zh/API_REFERENCE.md) - - [Developer Guide](docs/en/DEVELOPER_GUIDE.md) - - [Contributing Guide](CONTRIBUTING.md) - -### Changed - -- `A2A-T extension content placed in artifact `metadata` (not `parts.text`) - per protocol specification in `调用过程.md` -- Negotiation-T metadata URI aligned with AgentCard declaration (uppercase, no `/v1` suffix) -- Negotiation text passed to SDK's `startNegotiation` uses short request text, not full task input -- AgentCard is now strongly typed (`org.a2aproject.sdk.spec.AgentCard`) - throughout the codebase; `List` and `Map` replaced with typed equivalents -- Default SSL verify changed from `false` to `true` for production safety -- Default send timeout increased to 600 seconds (10 minutes) -- `DEVELOPER_GUIDE.md` moved from project root to `docs/` - -### Fixed - -- Authentication 401 failures caused by `A2AT_CRED_KEY` not being available to `System.getenv()` when `.env` file is - used (loaded by A2A-T SDK internally but not propagated to OS environment) -- All four extensions no longer injected into every request's - `A2A-Extensions` header; only active extensions are advertised -- `chunked transfer encoding, state: READING_LENGTH` errors after terminal SSE events downgraded to DEBUG level - (expected behavior) -- SDK `receiveNegotiation` "Unsupported negotiation type: FULFILLMENT" - warning downgraded to DEBUG (known SDK limitation, fallback works) - -### Removed - -- 14-argument static `ExecutePsop.execute()` method (replaced by Builder) -- `AgentCardMapper` (replaced by Jackson + `AgentCardJacksonModule`) -- Redundant blank lines in method bodies -- `AuthorizationTHandler` and `NotificationTHandler` (these are now pre-positioning operations done via - `sendExtensionMessage`, not part of the workflow extension handler chain) +This repository prepares the initial 1.0.0 SDK release. Earlier internal development notes are not published API +contracts or evidence of a Maven release. Release publication/tagging is a separate, explicitly authorized operation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 008d036e..3cc2e404 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to a2at-engine-java +# Contributing to workflow-engine-sdk-java Thank you for your interest in contributing! This document covers the contribution process and coding standards. @@ -11,16 +11,15 @@ Thank you for your interest in contributing! This document covers the contributi ## Development Setup ```bash -git clone git@github.com:Zhoujie628/a2at-engine-java.git -cd a2at-engine-java -mvn -o clean compile -mvn -o test +git clone https://github.com/project-openan/workflow-engine-sdk-java.git +cd workflow-engine-sdk-java +mvn -B clean verify ``` ## Project Structure ``` -a2at-engine-java/ +workflow-engine-sdk-java/ |-- workflow-engine/ SDK engine module | +-- src/main/java/dev/openan/workflow/engine/ | |-- client/ A2A transport, auth, extensions (package-private internals) @@ -91,21 +90,21 @@ Every Java file must start with the Apache 2.0 license header: - WARN: recoverable failures, fallbacks - ERROR: auth failures, agent call failures - Dedicated `PROTOCOL` logger for protocol-level request/response dumps -- No log truncation in demo/samples; full messages should be visible +- Always anonymize credentials; protocol bodies have configurable size limits and sensitive-data redaction ### Testing - JUnit 5 - Unit tests for all public methods - Integration tests for end-to-end workflows -- Run tests: `mvn -o test` +- Run the complete reactor: `mvn -B clean verify`; `-o` is only for a fully populated local dependency cache ## Commit Process 1. Fork the repository and create a feature branch 2. Write code following the standards above 3. Add/update tests -4. Run `mvn -o test` and ensure all tests pass +4. Run `mvn -B clean verify` and ensure all tests pass (including samples) 5. Add DCO signoff to your commit: ``` Signed-off-by: Your Name @@ -133,3 +132,13 @@ Every Java file must start with the Apache 2.0 license header: - Use GitHub Issues - Include: Java version, Maven version, error log, reproduction steps - For protocol issues: include the `PROTOCOL` logger output + +## Release acceptance + +- Run the complete `mvn -B clean verify` reactor; sample failures block release. +- Documentation code snippets are compiled by DocumentedCallbackExampleTest; HostQuickStartTest executes the minimal host flow. +- Check default single-city negotiation, final serialized protocol logs, independent extension lifecycle, cancellation and secret anonymization. +- Verify published POM coordinates, sources/Javadoc artifacts and the absence of private configuration/reference documents. +- Record the tested commit and Surefire reports. Offline providers and local OMC simulators are not live acceptance. +- Live model/OMC validation requires authorized endpoints and a separate acceptance record. Never commit customer integration notes. +- Do not publish, tag, or push as a side effect of running tests. diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 321a337c..c94a4076 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,23 +1,25 @@ # Maintainers -This file lists the maintainers of this repository. Maintainers have write access and are responsible for reviewing and merging contributions. Changes to this file are made by pull request and reviewed by the existing maintainers. +This file lists the maintainers of this repository. Maintainers have write access and are responsible for reviewing and +merging contributions. Changes to this file are made by pull request and reviewed by the existing maintainers. -| Organization | Name | GitHub Handle | -| ------------ | ---- | ------------- | -| Huawei | Fei Guo | @guofei66 | -| Huawei | Jie Zhou | @Zhoujie628 | +| Organization | Name | GitHub Handle | +|--------------|----------|---------------| +| Huawei | Fei Guo | @guofei66 | +| Huawei | Jie Zhou | @Zhoujie628 | ## Emeritus Maintainers Maintainers who are no longer active but have made significant contributions to this repository. | Organization | Name | GitHub Handle | -| ------------ | ---- | ------------- | +|--------------|------|---------------| | (none yet) | | | ## Becoming a Maintainer -Maintainer status is granted by the existing maintainers of this repository, or by the TSC, based on a track record of sustained, quality contributions. To be considered, a contributor should demonstrate: +Maintainer status is granted by the existing maintainers of this repository, or by the TSC, based on a track record of +sustained, quality contributions. To be considered, a contributor should demonstrate: - Consistent contributions over time (code, reviews, documentation) - Understanding of the component's goals and architecture diff --git a/README.md b/README.md index 6026cd55..6ee6339f 100644 --- a/README.md +++ b/README.md @@ -4,41 +4,46 @@ [![Java](https://img.shields.io/badge/Java-17+-orange.svg)](https://adoptium.net/) [![Maven](https://img.shields.io/badge/Maven-3.6+-red.svg)](https://maven.apache.org/) -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. +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 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. +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 (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 +- **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**: actual HTTP/JSON-RPC boundaries, gRPC metadata/protobuf views and dev Order SDK observations; bodies default on at DEBUG with mandatory secret-field redaction +- **Protocol Logging**: actual HTTP/JSON-RPC boundaries and gRPC metadata/protobuf views; + 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. +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. +In `SpringSpnDemo`, WAIMO sends a Task-T complaint to the integrator. The integrator 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 integrator 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 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. +(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 @@ -60,49 +65,25 @@ For Spring Boot server-side integration: ### 2. Execute a workflow +The complete, compiled example is [HostQuickStart.java](samples/src/main/java/dev/openan/workflow/engine/examples/demo/HostQuickStart.java). +It converts registry JSON maps to typed AgentCards, runs a remote task and local aggregation, cancels timed-out work, +and closes caller-owned transport resources. Run its main method in IDEA with three arguments: registry URL, +target AgentCard name, and credentials JSON path. The registry and target agent must be reachable; TLS verification is enabled. + ```java -import java.util.concurrent.*; - -// 1. Load workflow (PSOP) from orchestration center -Workflow workflow = LoadPsop.load( - "https://127.0.0.1:5001", "psop-id", null, false); - -// 2. Load agent cards -RegistryClient registry = new RegistryClient("https://127.0.0.1:5000", false); -List agentCards = registry.fetchAgentCards(); - -// 3. Create transport + engine client -A2ATransport transport = new A2ATransport(agentCards, null, - WorkflowEngineClientConfig.builder() - .sslVerify(true) - .credentialsConfigPath("credentials.json") - .build()); -WorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); - -// 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() - .psop(workflow) - .agentCards(agentCards) - .controlPoint(controlPoint) - .engineClient(client) - .runtimeIntent("Diagnose fault") - .lang("zh") - .execute() - .get(10, TimeUnit.MINUTES); - -System.out.println("Success: " + result.isSuccess()); +// In the samples module; HostQuickStart is example source, not a class shipped in the SDK jar. +HostQuickStart.main(new String[] { + "https://registry.example.com", + "Your Agent Name", + "/secure/credentials.json" +}); ``` +This minimal example sends plain A2A content. For Task-T, Negotiation-T, Authorization-T and Notification-T content +generation/validation in host business callbacks, follow SpringSpnDemo and the business callback guide. -Final content callbacks, complete dependency inputs and negotiation Send/Stop: [English](docs/en/BUSINESS_CALLBACKS.md) / [中文](docs/zh/BUSINESS_CALLBACKS.md). +Final content callbacks, complete dependency inputs and negotiation +Send/Stop: [English](docs/en/BUSINESS_CALLBACKS.md) / [中文](docs/zh/BUSINESS_CALLBACKS.md). ## Architecture @@ -117,12 +98,12 @@ graph TD L0 -.-> F ``` -| Layer | Entry Point | Responsibility | -|-------|-------------|----------------| -| High | `ExecutePsop.Builder` | Event stream, lifecycle, `onFinish` persistence | -| Mid | `WorkflowExecutor` | DAG traversal, context assembly, ControlPoint dispatch | -| Low | `WorkflowEngineClient` / `A2ATransport` | A2A send, auth, extensions, SSL, SSE normalization | -| Foundation | `ControlPoint` | User-implemented business decisions (onTask, onSelfTask, onRoute, onNegotiation) | +| Layer | Entry Point | Responsibility | +|------------|-----------------------------------------|----------------------------------------------------------------------------------| +| High | `ExecutePsop.Builder` | Event stream, lifecycle, `onFinish` persistence | +| Mid | `WorkflowExecutor` | DAG traversal, context assembly, ControlPoint dispatch | +| Low | `WorkflowEngineClient` / `A2ATransport` | A2A send, auth, extensions, SSL, SSE normalization | +| Foundation | `ControlPoint` | User-implemented business decisions (onTask, onSelfTask, onRoute, onNegotiation) | ## Package Structure @@ -144,16 +125,17 @@ graph TD root --> runner ``` -| Package | Key Classes | Description | -|---------|-------------|-------------| -| `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`, `MessageContent`, `TaskResult`, `NegotiationRequest`, `NegotiationReply`, `ExecutionResult` | Data models | -| `registry` | `LoadPsop`, `RegistryClient` | PSOP loading and AgentCard registry | -| `runner` | `ExecutePsop` | Entry point for workflow execution | +| Package | Key Classes | Description | +|------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------| +| `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`, `MessageContent`, `TaskResult`, `NegotiationRequest`, `NegotiationReply`, `ExecutionResult` | Data models | +| `registry` | `LoadPsop`, `RegistryClient` | PSOP loading and AgentCard registry | +| `runner` | `ExecutePsop` | Entry point for workflow execution | -> **Note:** `LlmHelper` lives in the **samples** module (`dev.openan.workflow.engine.examples`), not in the `client` package. The workflow engine itself does not call an LLM directly. +> **Note:** `LlmHelper` lives in the **samples** module (`dev.openan.workflow.engine.examples`), not in the `client` +> package. The workflow engine itself does not call an LLM directly. ## Documentation @@ -170,15 +152,15 @@ graph TD - [API 参考](docs/zh/API_REFERENCE.md) - 公共接口和类文档 - [架构设计](docs/zh/DESIGN.md) - 架构、模块结构、设计决策 - [开发者指南](docs/zh/DEVELOPER_GUIDE.md) - 内部架构、贡献、调试 -- [工作台集成指南](docs/zh/工作台集成指南.md) - 工作台 Spring Boot 集成完整指南 +- [集成方集成指南](docs/zh/集成方场景接入指南.md) - 集成方 Spring Boot 集成完整指南 ## Modules -| Module | Description | -|--------|-------------| -| `workflow-engine` | Core SDK: workflow execution, A2A transport, extensions, auth | -| `spring-boot-starter` | Spring Boot auto-configuration for A2A server side | -| `samples` | Demo applications (embedded + Spring Boot variants) | +| Module | Description | +|-----------------------|---------------------------------------------------------------| +| `workflow-engine` | Core SDK: workflow execution, A2A transport, extensions, auth | +| `spring-boot-starter` | Spring Boot auto-configuration for A2A server side | +| `samples` | Demo applications (embedded + Spring Boot variants) | ## License @@ -186,20 +168,20 @@ graph TD ### 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**: +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. +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` for direct transport. It 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/SECURITY.md b/SECURITY.md index 70bdfdfb..19e6224d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,8 +9,7 @@ ## Reporting a Vulnerability -If you discover a security vulnerability in this project, please report it -responsibly. +If you discover a security vulnerability in this project, please report it responsibly. **Do NOT open a public GitHub issue for security vulnerabilities.** @@ -23,21 +22,20 @@ Include the following in your report: - Potential impact - Suggested fix (if any) -We will acknowledge receipt within 48 hours and provide a fix or mitigation -within 90 days. Valid reports will be credited in the release notes. +We will acknowledge receipt within 48 hours and provide a fix or mitigation within 90 days. Valid reports will be +credited in the release notes. ## Security Features -- **Credential encryption**: Passwords in credential config files support - AES-256-GCM encryption via the `A2AT_CRED_KEY` environment variable. +- **Credential encryption**: Passwords in credential config files support AES-256-GCM encryption via the `A2AT_CRED_KEY` + environment variable. See [Integration Guide](docs/en/INTEGRATION_GUIDE.md#521-credential-encryption-and-key-management). -- **Custom AuthProvider**: For environments requiring external identity - providers (SSO, OAuth2, etc.), implement `AuthProvider` to control - authentication without storing credentials locally. +- **Custom AuthProvider**: For environments requiring external identity providers (SSO, OAuth2, etc.), implement + `AuthProvider` to control authentication without storing credentials locally. See [Integration Guide](docs/en/INTEGRATION_GUIDE.md#53-custom-authentication-authprovider). -- **TLS/HTTPS**: All agent communication supports HTTPS with configurable - certificate verification. Self-signed certificates should be trusted through - `caCertsPath`; `sslVerify(false)` is limited to controlled local diagnostics and - does not disable hostname verification. +- **TLS/HTTPS**: All agent communication supports HTTPS with configurable certificate verification. Self-signed + certificates should be trusted through + `caCertsPath`; `sslVerify(false)` is limited to controlled local diagnostics and does not disable hostname + verification. diff --git a/docs/diagrams/workflow-engine-surrounding-systems.mmd b/docs/diagrams/workflow-engine-surrounding-systems.mmd index d255c752..c3906c96 100644 --- a/docs/diagrams/workflow-engine-surrounding-systems.mmd +++ b/docs/diagrams/workflow-engine-surrounding-systems.mmd @@ -1,10 +1,18 @@ -%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 55, "rankSpacing": 75}}}%% +%%{ + 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 + 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 @@ -13,7 +21,7 @@ flowchart TB ORCH["编排中心 / Orchestration Center
工作流检索与 PSOP 加载"]:::external end - subgraph HOST["宿主智能体:传输工作台 / Workbench Agent"] + subgraph HOST["宿主智能体:集成方 / Integrator Agent"] direction TB ENTRY["A2A 服务端入口
接收 Task-T,解析诊断意图"]:::host ADAPTER["宿主集成层
准备 AgentCard、Workflow、配置和业务上下文"]:::host @@ -26,7 +34,7 @@ flowchart TB EXT["流程外协议操作
ExtensionSender
Authorization-T / Notification-T"]:::sdk end - BIZ["工作台业务实现
业务接管、路由、汇总、持久化、通知处理"]:::host + BIZ["集成方业务实现
业务接管、路由、汇总、持久化、通知处理"]:::host end subgraph SAMPLE["Demo 本地替代路径(非生产数据源)"] @@ -39,18 +47,17 @@ flowchart TB 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 + 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 d5ec5252..4584beab 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -2,11 +2,11 @@ ## Package Overview -| Package | Description | -|-----------------------------------|-------------------------------------------------| +| Package | Description | +|---------------------------------------|-------------------------------------------------| | `dev.openan.workflow.engine.client` | A2A message transport, auth, extensions, config | | `dev.openan.workflow.engine.control` | User decision points and event system | -| `dev.openan.workflow.engine.core` | DAG traversal engine and context assembly | +| `dev.openan.workflow.engine.core` | DAG traversal engine and context assembly | | `dev.openan.workflow.engine.model` | Data models (Workflow, Task, results) | | `dev.openan.workflow.engine.registry` | PSOP loading and AgentCard registry | | `dev.openan.workflow.engine.runner` | Entry point for workflow execution | @@ -26,7 +26,7 @@ Entry point for executing a PSOP workflow. Uses the Builder pattern. | `psop(Workflow)` | required | - | PSOP workflow definition | | `agentCards(List)` | required | `List.of()` | Agent cards for all agents in the workflow | | `controlPoint(ControlPoint)` | required | - | User decision implementation | -| `engineClient(WorkflowEngineClient)` | optional | null | Pre-configured client (null = auto-create) | +| `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"`) | | `credentialsConfigPath(String)` | optional | null | Path to credentials JSON file | @@ -68,22 +68,23 @@ void setEventCallback(EventCallback callback); void close(); ``` -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 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. -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. +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. +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 @@ -93,30 +94,33 @@ NotificationSubscription openNotification(String agentName, MessageContent conte BiConsumer listener); ``` -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. +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 Builder-based configuration for the workflow engine client. -| Property | Type | Default | Description | -|---------------------------------|--------------------------|---------|----------------------------------------------| -| `sslVerify` | `boolean` | `true` | TLS chain verification for HTTP/JSON-RPC; disabling it keeps hostname checks and the mTLS client identity. Disabling it uses plaintext for default gRPC | -| `caCertsPath` | `String` | null | Path to CA certs PEM file | -| `clientCertPath` | `String` | null | Path to the mTLS client certificate chain; default gRPC requires `sslVerify=true` | -| `clientKeyPath` | `String` | null | Path to a PKCS#8 PEM/DER mTLS private key | -| `clientKeyPassword` | `String` | null | Password for an encrypted PKCS#8 private key | -| `crlPath` | `String` | null | X.509 CRL for HTTP/JSON-RPC; the default gRPC runtime rejects this unsupported combination | -| `sendTimeoutSeconds` | `long` | `600` | SSE stream timeout (10 min default) | -| `notificationAckTimeoutSeconds` | `long` | `5` | Wait for the first Notification-T ACK/event | -| `sendExecutorCoreSize` | `int` | `4` | Send executor core threads | -| `sendExecutorMaxSize` | `int` | `16` | Send executor maximum threads | -| `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 | -| `maxNegotiationExchanges` | `int` | `3` | Local interaction budget, independent of SDK maxRounds | +| Property | Type | Default | Description | +|---------------------------------|----------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| `sslVerify` | `boolean` | `true` | TLS chain verification for HTTP/JSON-RPC; disabling it keeps hostname checks and the mTLS client identity. Disabling it uses plaintext for default gRPC | +| `caCertsPath` | `String` | null | Path to CA certs PEM file | +| `clientCertPath` | `String` | null | Path to the mTLS client certificate chain; default gRPC requires `sslVerify=true` | +| `clientKeyPath` | `String` | null | Path to a PKCS#8 PEM/DER mTLS private key | +| `clientKeyPassword` | `String` | null | Password for an encrypted PKCS#8 private key | +| `crlPath` | `String` | null | X.509 CRL for HTTP/JSON-RPC; the default gRPC runtime rejects this unsupported combination | +| `sendTimeoutSeconds` | `long` | `600` | SSE stream timeout (10 min default) | +| `notificationAckTimeoutSeconds` | `long` | `5` | Wait for the first Notification-T ACK/event | +| `sendExecutorCoreSize` | `int` | `4` | Send executor core threads | +| `sendExecutorMaxSize` | `int` | `16` | Send executor maximum threads | +| `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 | +| `maxNegotiationExchanges` | `int` | `3` | Local interaction budget, independent of SDK maxRounds | ```java WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() @@ -142,11 +146,17 @@ 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. +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. ### A2atMessages -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. +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 @@ -168,7 +178,8 @@ A default implementation is provided. Implement this interface only if you need ### ConversationScopedA2AJavaClientRuntime -Optional lifecycle callback for runtime implementations whose transport session spans multiple A2A requests (e.g. a gateway login that must stay alive across all negotiation rounds). +Optional lifecycle callback for runtime implementations whose transport session spans multiple A2A requests (e.g. a +gateway login that must stay alive across all negotiation rounds). ```java public interface ConversationScopedA2AJavaClientRuntime { @@ -176,7 +187,9 @@ public interface ConversationScopedA2AJavaClientRuntime { } ``` -When the runtime also implements this interface, the engine calls `closeConversation` after the full send + negotiation cycle completes — not after each individual HTTP request. This allows gateway sessions to be released only after the logical conversation is done. +When the runtime also implements this interface, the engine calls `closeConversation` after the full send + negotiation +cycle completes — not after each individual HTTP request. This allows gateway sessions to be released only after the +logical conversation is done. Implement alongside `A2AJavaClientRuntime`: @@ -198,7 +211,8 @@ public class MyGatewayRuntime ### AgentCardJacksonModule -Jackson module for deserializing AgentCard JSON with security scheme normalization. Handles the OpenAPI-format `securitySchemes` / `securityRequirements` fields that the A2A SDK's strongly-typed `AgentCard` record expects. +Jackson module for deserializing AgentCard JSON with security scheme normalization. Handles the OpenAPI-format +`securitySchemes` / `securityRequirements` fields that the A2A SDK's strongly-typed `AgentCard` record expects. ```java ObjectMapper mapper = new ObjectMapper() @@ -209,7 +223,9 @@ AgentCard card = mapper.readValue( ### AgentCardNormalizer -Utility that normalizes a raw `Map` (as returned by the Registry Center API) into the `AgentCard`-compatible format. Used internally by `RegistryClient.fetchAgentCards()`. Also available as a public static method for custom normalization: +Utility that normalizes a raw `Map` (as returned by the Registry Center API) into the `AgentCard` +-compatible format. Used internally by `RegistryClient.fetchAgentCards()`. Also available as a public static method for +custom normalization: ```java Map normalized = AgentCardNormalizer.normalize(rawMap); @@ -248,30 +264,30 @@ Override to receive real-time execution events. Event types are defined in `Even ### EventType -| Constant | Description | -|--------------------------|----------------------------------------------------| -| `STEP_START` | A workflow step began | -| `STEP_COMPLETE` | A workflow step completed | -| `TASK_REQUEST` | A task was dispatched to an agent | -| `TASK_RESPONSE` | A task response was received | +| Constant | Description | +|--------------------------|--------------------------------------------------------------| +| `STEP_START` | A workflow step began | +| `STEP_COMPLETE` | A workflow step completed | +| `TASK_REQUEST` | A task was dispatched to an agent | +| `TASK_RESPONSE` | A task response was received | | `TASK_STATUS_CHANGED` | A task's status changed (pending → running → success/failed) | -| `AGENT_REQUEST` | A message was sent to an agent | -| `AGENT_RESPONSE` | A response was received from an agent | -| `AGENT_STATUS_UPDATE` | Agent SSE status update (SUBMITTED, WORKING, etc.) | -| `AGENT_ARTIFACT_UPDATE` | Agent SSE artifact update | -| `AGENT_MESSAGE_EVENT` | Agent SSE message event | -| `NEGOTIATION_REQUEST` | Agent requested negotiation (INPUT_REQUIRED) | -| `NEGOTIATION_RESOLVED` | Clarification was sent to agent | -| `NEGOTIATION_FAILED` | Negotiation could not be resolved | -| `AUTHORIZATION_REQUEST` | Agent requested authorization | -| `AUTHORIZATION_RESOLVED` | Authorization decision was made | -| `NOTIFICATION` | Notification received from agent | -| `ROUTE_DECISION` | Route decision was made | -| `WORKFLOW_COMPLETE` | The workflow completed (all steps finished) | -| `START` | Workflow execution started | -| `COMPLETE` | Workflow execution completed successfully | -| `ERROR` | Workflow execution failed | -| `CLOSE` | Engine client closed | +| `AGENT_REQUEST` | A message was sent to an agent | +| `AGENT_RESPONSE` | A response was received from an agent | +| `AGENT_STATUS_UPDATE` | Agent SSE status update (SUBMITTED, WORKING, etc.) | +| `AGENT_ARTIFACT_UPDATE` | Agent SSE artifact update | +| `AGENT_MESSAGE_EVENT` | Agent SSE message event | +| `NEGOTIATION_REQUEST` | Agent requested negotiation (INPUT_REQUIRED) | +| `NEGOTIATION_RESOLVED` | Clarification was sent to agent | +| `NEGOTIATION_FAILED` | Negotiation could not be resolved | +| `AUTHORIZATION_REQUEST` | Agent requested authorization | +| `AUTHORIZATION_RESOLVED` | Authorization decision was made | +| `NOTIFICATION` | Notification received from agent | +| `ROUTE_DECISION` | Route decision was made | +| `WORKFLOW_COMPLETE` | The workflow completed (all steps finished) | +| `START` | Workflow execution started | +| `COMPLETE` | Workflow execution completed successfully | +| `ERROR` | Workflow execution failed | +| `CLOSE` | Engine client closed | --- @@ -307,6 +323,12 @@ POST `/api/v1/orchestrate/search`. Returns ranked workflow summaries matched by ### RegistryClient +The two-argument constructor uses a 30-second complete-response deadline. Override it with +`new RegistryClient(url, sslVerify, Duration.ofSeconds(15))`; the duration must be positive. +The deadline includes response-body consumption, and interruption cancels the pending request. +Registry methods return JSON maps; convert them to AgentCard with AgentCardJacksonModule as shown in the integration guide. + + Fetch and register AgentCards from the Registry Center. ```java @@ -331,9 +353,12 @@ Map registerAgentCard(Map agentCard) ### WorkflowExecutor -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. +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. +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. ```java WorkflowExecutor executor = new WorkflowExecutor( @@ -344,7 +369,9 @@ ExecutionResult result = executor.run().join(); ### ContextBuilder -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. +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. --- @@ -363,14 +390,14 @@ Static factory: `Workflow.fromMap(Map)` parses from orchestratio ### WorkflowStep -| Field | Type | Default | Description | -|---------------|-----------------------|---------------|----------------------------------------------------------| -| `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` | Design-time hint; dependency graph selects context | +| Field | Type | Default | Description | +|---------------|-----------------------|---------------|-------------------------------------------------------------------------------------------------------------------| +| `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` | 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` | `StepType` | `ALL_SUCCESS` | Execution mode | ### StepType @@ -384,12 +411,12 @@ Static factory: `Workflow.fromMap(Map)` parses from orchestratio Task lifecycle status, used in `TASK_STATUS_CHANGED` events for cross-SDK consistency with the Python SDK. -| Value | String | Description | -|-----------|------------|--------------------------------------| -| `PENDING` | `"pending"` | Task created, not yet started | -| `RUNNING` | `"running"` | Task in progress | -| `SUCCESS` | `"success"` | Task completed successfully | -| `FAILED` | `"failed"` | Task failed | +| Value | String | Description | +|-----------|-------------|-------------------------------| +| `PENDING` | `"pending"` | Task created, not yet started | +| `RUNNING` | `"running"` | Task in progress | +| `SUCCESS` | `"success"` | Task completed successfully | +| `FAILED` | `"failed"` | Task failed | ### Task @@ -408,35 +435,34 @@ Task lifecycle status, used in `TASK_STATUS_CHANGED` events for cross-SDK consis ### TaskRequest / BusinessInput -TaskRequest uses getXxx() accessors: +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 | +| 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. +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 | +| 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. +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. +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 @@ -445,26 +471,24 @@ record MessageContent(List> parts, Map metadata, Set 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. +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. - - +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. ### NegotiationRequest / NegotiationReply @@ -472,7 +496,9 @@ See [BUSINESS_CALLBACKS](BUSINESS_CALLBACKS.md). ### SendMessageResult -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. +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 @@ -512,7 +538,7 @@ getReceivedMessages() preserves response levels; getOutputs() is a convenience p | 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/v1` | +| 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` | @@ -529,33 +555,39 @@ Canonical negotiation metadata is `templateUri` plus ## Error Handling -- 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/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. +- Stream-exit/transport-error observations do not independently define workflow outcome; inspect ExecutionResult and + remote task state. --- ## spring-boot-starter Module -The `spring-boot-starter` module provides Spring Boot auto-configuration for the A2A **server** side (not the client/workflow side). When on the classpath of a Spring Boot web application, it auto-registers all A2A SDK server components as Spring beans. +The `spring-boot-starter` module provides Spring Boot auto-configuration for the A2A **server** side (not the +client/workflow side). When on the classpath of a Spring Boot web application, it auto-registers all A2A SDK server +components as Spring beans. ### A2AProperties Configuration properties prefixed with `a2at.server`: -| Property | Default | Description | -|----------|---------|-------------| -| `a2at.server.agent-card` | `classpath:agentcard.json` | Path to the AgentCard JSON file (classpath: or file: prefix supported) | -| `a2at.server.path-prefix` | `/a2a/json` | URL path prefix for A2A endpoints | -| `a2at.server.agent-timeout-seconds` | `30` | Agent execution timeout in seconds | -| `a2at.server.consumption-timeout-seconds` | `5` | Consumption timeout in seconds | -| `a2at.server.reconciliation-timeout-seconds` | `1` | Reconciliation wait timeout in seconds | -| `a2at.server.executor-core-size` | `8` | Server executor core threads | -| `a2at.server.executor-max-size` | `8` | Server executor maximum threads | -| `a2at.server.executor-queue-capacity` | `100` | Bounded server executor queue capacity | -| `a2at.server.executor-keep-alive-seconds` | `60` | Non-core thread keep-alive in seconds | +| Property | Default | Description | +|----------------------------------------------|----------------------------|------------------------------------------------------------------------| +| `a2at.server.agent-card` | `classpath:agentcard.json` | Path to the AgentCard JSON file (classpath: or file: prefix supported) | +| `a2at.server.path-prefix` | `/a2a/json` | URL path prefix for A2A endpoints | +| `a2at.server.agent-timeout-seconds` | `30` | Agent execution timeout in seconds | +| `a2at.server.consumption-timeout-seconds` | `5` | Consumption timeout in seconds | +| `a2at.server.reconciliation-timeout-seconds` | `1` | Reconciliation wait timeout in seconds | +| `a2at.server.executor-core-size` | `8` | Server executor core threads | +| `a2at.server.executor-max-size` | `8` | Server executor maximum threads | +| `a2at.server.executor-queue-capacity` | `100` | Bounded server executor queue capacity | +| `a2at.server.executor-keep-alive-seconds` | `60` | Non-core thread keep-alive in seconds | ```yaml a2at: @@ -572,19 +604,19 @@ a2at: Auto-configures the following beans (all `@ConditionalOnMissingBean`, so you can override any): -| Bean | Type | Purpose | -|------|------|---------| -| `agentCard` | `AgentCard` | Loaded from `a2at.server.agent-card` path via Jackson | -| `a2aConfigProvider` | `A2AConfigProvider` | SDK configuration values | -| `taskStore` | `InMemoryTaskStore` | In-memory task storage | -| `eventBus` | `MainEventBus` | Event bus for SSE streaming | -| `queueManager` | `InMemoryQueueManager` | Event queue manager | -| `pushStore` | `PushNotificationConfigStore` | Push notification config storage | -| `agentExecutorPool` | `ExecutorService` | Thread pool for agent execution (8 threads, daemon) | -| `eventBusProcessor` | `MainEventBusProcessor` | Event bus processor | -| `requestHandler` | `RequestHandler` | Default request handler | -| `restHandler` | `RestHandler` | REST protocol handler | -| `a2aController` | `A2AController` | Spring MVC controller (`message:send` + `message:stream`) | +| Bean | Type | Purpose | +|---------------------|-------------------------------|-----------------------------------------------------------| +| `agentCard` | `AgentCard` | Loaded from `a2at.server.agent-card` path via Jackson | +| `a2aConfigProvider` | `A2AConfigProvider` | SDK configuration values | +| `taskStore` | `InMemoryTaskStore` | In-memory task storage | +| `eventBus` | `MainEventBus` | Event bus for SSE streaming | +| `queueManager` | `InMemoryQueueManager` | Event queue manager | +| `pushStore` | `PushNotificationConfigStore` | Push notification config storage | +| `agentExecutorPool` | `ExecutorService` | Thread pool for agent execution (8 threads, daemon) | +| `eventBusProcessor` | `MainEventBusProcessor` | Event bus processor | +| `requestHandler` | `RequestHandler` | Default request handler | +| `restHandler` | `RestHandler` | REST protocol handler | +| `a2aController` | `A2AController` | Spring MVC controller (`message:send` + `message:stream`) | ### A2AController diff --git a/docs/en/BUSINESS_CALLBACKS.md b/docs/en/BUSINESS_CALLBACKS.md index 5011d43b..eb68a07f 100644 --- a/docs/en/BUSINESS_CALLBACKS.md +++ b/docs/en/BUSINESS_CALLBACKS.md @@ -1,9 +1,8 @@ # 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. +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. +The business contracts are independent of the transport runtime. ## 1. Callbacks @@ -35,42 +34,40 @@ ControlPoint callbacks = ControlPoint.builder() .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`. +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: +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 | +| 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. +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 | +| 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. +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. +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 @@ -79,29 +76,29 @@ record MessageContent(List> parts, Map metadata, Set 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. +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. +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. +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. @@ -110,167 +107,161 @@ MetadataContent generated = sdk.generateTaskPromptFromDataWithSchema( 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 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. +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. +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. +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. +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. +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. +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. +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. +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. +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**: +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. +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` for direct transport. It 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. +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 → integrator | 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. +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. +Reject and Abort. NegotiationStrategyTest covers city isolation and host Abort/Reject policy. RecoveryNotificationTest +covers complete success/failure and malformed/incomplete events. SpringSpnDemoE2ETest covers 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. +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. +`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 f11451f4..9c5dd2b7 100644 --- a/docs/en/DESIGN.md +++ b/docs/en/DESIGN.md @@ -9,43 +9,106 @@ ## 1. Overview -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 | +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. +```mermaid +%%{ + 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["宿主智能体:集成方 / Integrator 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 +``` + +The SDK in this diagram is a library embedded in the host-agent process, not a separately deployed orchestration +service. In `SpringSpnDemo`, the transport integrator is the host agent: + +1. WAIMO invokes the integrator's A2A server endpoint with a Task-T diagnosis request. +2. The integrator 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 integrator 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 integrator for local aggregation, routing, and clarification decisions. +4. The integrator returns the aggregate as a Task-T artifact and terminal status to WAIMO. +5. Authorization-T and Notification-T are invoked by the integrator 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 +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). --- @@ -69,24 +132,25 @@ graph TD ### 3.1 Layer 0 - Communication 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. +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. ### 3.2 Layer 1 - Traversal **`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 +(`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. +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. +- Steps whose predecessors are all satisfied are collected and dispatched in parallel (`CompletableFuture`), so steps at + the same layer run concurrently. - Subtasks within a step also run in parallel. - `ALL_SUCCESS` - all subtasks must succeed. - `ANY_SUCCESS` - the first successful subtask wins; the rest are cancelled. @@ -94,8 +158,8 @@ Step dispatch rules: ### 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 +**`ExecutePsop`** is the high-level runner. It wraps the executor with a lifecycle (start / complete / error / close), +event serialization, client-disconnect cancellation, and an `onFinish` persistence hook. Most integrations use this layer. --- @@ -118,26 +182,28 @@ See [Business callback contract](BUSINESS_CALLBACKS.md) for fields and working e ## 5. A2A-T Extension Model -Task-T: the host generates final content; the engine envelopes and sends it. AgentCard declarations do not trigger generation. +Task-T: the host generates final content; the engine envelopes and sends it. AgentCard declarations do not trigger +generation. Negotiation-T: -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. +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. +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. +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. ## 6. Condition Routing @@ -168,8 +234,8 @@ Events are emitted to an optional `EventCallback` as stable string types (`Event - **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 +`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. --- @@ -188,9 +254,9 @@ sequenceDiagram 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->>H: Validate proposal and generate reply H-->>E: Send(MessageContent) or local Stop - E->>A: Same task/context; final reply content + E->>A: Same task/context with final reply content end A-->>E: Task result / artifacts E->>H: onSelfTask(selected complete upstream results) @@ -219,16 +285,15 @@ sequenceDiagram ## 9. Dependencies 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. +`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. ## 10. Design Decisions Summary -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). +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; transport runtimes share the same callback contract. 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 03d31a9c..5d5d1be8 100644 --- a/docs/en/DEVELOPER_GUIDE.md +++ b/docs/en/DEVELOPER_GUIDE.md @@ -15,7 +15,8 @@ Add to your `pom.xml`: ``` -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. +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 @@ -84,26 +85,26 @@ and a sync `BiConsumer` overload. Events come from three layers: the runner (lifecycle bracket), the executor (step/task/routing), and the engine client (agent traffic, negotiation). -| Event | Layer | When | Key Data | -|-------------------------|--------------------|--------------------------------------------------|---------------------------------------------------------| -| `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` | +| Event | Layer | When | Key Data | +|-------------------------|--------------------|----------------------------------------------------------|---------------------------------------------------------| +| `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 | 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 | 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 | 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) | +| `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 | 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 | 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) | ## 6. Mid-Level (Layer 1: WorkflowExecutor) @@ -127,20 +128,19 @@ ExecutionResult result = executor.run().join(); ### 6.1 Negotiation Auto-Loop -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. +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. +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 @@ -150,7 +150,7 @@ A business-sent Abort is never diagnosis success, even if the remote acknowledge | `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` | 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. | +| `steps[].contextFrom` | `WorkflowStep` | Selects steps exposed through `workflowInput.upstreamResults`; omitted = direct predecessors, `[]` = none, `"*"` = all ancestors, or explicit ancestor names. | ### 6.3 AgentCard Type @@ -191,11 +191,12 @@ seconds, and attaches the auth header to outbound requests. ``` Passwords can be AES-GCM encrypted with `enc::` prefix. The decryption key is read from `A2AT_CRED_KEY` -(env var or system property, loaded from `.env` by `EnvFileLoader`). +(explicit instance configuration > OS environment > JVM property). The host owns loading; the engine does not read `.env` automatically. ### 7.2 Custom AuthProvider For non-standard auth (SSO, API keys, custom headers): + ```java WorkflowEngineClientConfig.builder() .authProvider((agentName, agentCard, headers) -> { @@ -232,13 +233,13 @@ Set `sslVerify=false` only for dev with self-signed certs. ## 9. A2A-T Environment (.env) -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. +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 @@ -272,15 +273,15 @@ public Flux execute(@PathVariable String psopId) { ### Cancellation -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. - +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` (implement final onTask content plus local tasks, conditional routes and negotiation when required) +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 @@ -289,15 +290,14 @@ A remote task already submitted may require an explicit cancelTask operation; lo ### 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. +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 713e3974..1dd8bcc4 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 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. +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 @@ -30,6 +30,12 @@ Host callbacks own A2A-T generation, semantic validation, schemas and any LLM ca Four steps: define workflow -> load AgentCard -> implement ControlPoint -> execute. +Complete runnable source: [HostQuickStart.java](../../samples/src/main/java/dev/openan/workflow/engine/examples/demo/HostQuickStart.java). +It is compiled and its remote-task/local-aggregation flow is tested by HostQuickStartTest. +Copy that source into your host project or run it from samples in IDEA (registry URL, target agent name, credentials path). +The snippets below explain the same API; use one AgentCard loading option, and implement domain-specific content for Task-T. +Nonempty business conditions require an onRoute policy; this minimal workflow uses unconditional edges. + ### 4.1 Define a Workflow ```java @@ -47,7 +53,7 @@ Workflow workflow = Workflow.builder() .next(List.of( JumpCondition.builder() .step("merge") - .condition("success") + .condition("") .build())) .layer(0) .build(), @@ -63,7 +69,7 @@ Workflow workflow = Workflow.builder() .next(List.of( JumpCondition.builder() .step("end") - .condition("success") + .condition("") .build())) .layer(1) .contextFrom(List.of("*")) @@ -83,9 +89,17 @@ AgentCard card = mapper.readValue( // Option B: From Registry Center RegistryClient registry = new RegistryClient("https://127.0.0.1:5000", false); -List> cards = registry.fetchAgentCards(); +ObjectMapper cardMapper = new ObjectMapper().registerModule(new AgentCardJacksonModule()); +List cards = registry.fetchAgentCards().stream() + .map(raw -> cardMapper.convertValue(raw, AgentCard.class)).toList(); ``` + + +RegistryClient defaults to a 30-second deadline including response-body consumption. For another budget, use +`new RegistryClient(url, true, Duration.ofSeconds(15))`; only positive durations are accepted. Query access tokens +are logged as ``; do not log raw discovery URLs from host code. + ### 4.3 Implement ControlPoint ```java @@ -119,10 +133,10 @@ ControlPoint callbacks = ControlPoint.builder() ### 4.4 Execute ```java -ExecutionResult result = ExecutePsop.builder() +CompletableFuture execution = ExecutePsop.builder() .psop(workflow) - .agentCards(List.of(card1, card2)) - .controlPoint(new MyControlPoint()) + .agentCards(cards) + .controlPoint(callbacks) .runtimeIntent("SPN cross-city fault diagnosis") .lang("zh") .credentialsConfigPath("credentials.json") @@ -130,8 +144,13 @@ ExecutionResult result = ExecutePsop.builder() .onFinish((r, history) -> { System.out.println("Result: " + r.isSuccess()); }) - .execute() - .get(10, TimeUnit.MINUTES); + .execute(); +try { + ExecutionResult result = execution.get(10, TimeUnit.MINUTES); + System.out.println(result.getStepOutputs()); +} finally { + if (!execution.isDone()) execution.cancel(true); +} ``` Required: `psop`, `controlPoint`. All other config items have defaults. @@ -140,13 +159,13 @@ Required: `psop`, `controlPoint`. All other config items have defaults. ### 5.1 .env File -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. +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 @@ -191,21 +210,30 @@ Example output: 4f8a2b1c3d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b ``` -Write the key to the `.env` file: +Supply the key explicitly as `credentialEncryptionKey`, or set the OS environment variable below. +Resolution order is explicit instance key > OS environment > JVM property. The engine does not load `.env` automatically: ``` A2AT_CRED_KEY=4f8a2b1c3d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b ``` +For instance-local encryption, call `CredentialCrypto.encrypt(plaintext, keyHex)`. An explicit key overrides OS/JVM +configuration without modifying system properties. Never log plaintext or the key. + **Encrypt a password** +Build the jar with `mvn -pl workflow-engine -am package`; commands below run from the repository root. +`set` is Windows cmd syntax (PowerShell: `$env:A2AT_CRED_KEY='...'`). This CLI needs only the SDK jar and JDK. +Use disposable example values here: command-line passwords/keys can appear in shell history and process listings. +For production, obtain secrets securely in the host and use the Java encryption API. + ```bash # Option 1: set env var first set A2AT_CRED_KEY=4f8a2b1c3d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b -java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" +java -cp workflow-engine/target/workflow-engine-1.0.0.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" # Option 2: pass key as second argument -java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" 4f8a2b1c3d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b +java -cp workflow-engine/target/workflow-engine-1.0.0.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" 4f8a2b1c3d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b ``` Output: @@ -219,14 +247,17 @@ Paste the output into the `value` field of the credentials JSON. **Rotating the key** 1. Generate a new key: `openssl rand -hex 32` -2. Update `A2AT_CRED_KEY` in `.env` -3. Re-encrypt all passwords: `java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "plaintext" new-key` +2. Update the host secret store / explicit `credentialEncryptionKey`, or its OS/JVM `A2AT_CRED_KEY` +3. Re-encrypt all passwords: + `java -cp workflow-engine/target/workflow-engine-1.0.0.jar dev.openan.workflow.engine.client.CredentialCrypto "plaintext" new-key` 4. Update the `enc:...` results in the credentials JSON file > The `.env` file should not be committed to version control. Add it to `.gitignore`. + ### 5.3 Custom Authentication (AuthProvider) -When tokens are obtained by the workbench or an external identity service, or the mechanism is non-standard, implement the `AuthProvider` interface. It has a single method: +When tokens are obtained by the integrator or an external identity service, or the mechanism is non-standard, implement +the `AuthProvider` interface. It has a single method: ```java public interface AuthProvider { @@ -285,10 +316,14 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() **Notes:** - `applyAuth` is called on every message send; implement token caching/refresh logic inside -- `securitySchemes` lists authentication methods the agent supports; `securityRequirements` marks the methods required by this integration. Empty `securityRequirements` disables built-in credential authentication, but `AuthProvider` is still called +- `securitySchemes` lists authentication methods the agent supports; `securityRequirements` marks the methods required + by this integration. Empty `securityRequirements` disables built-in credential authentication, but `AuthProvider` is + still called - `AuthProvider` can be the sole authentication source even when `securityRequirements` is non-empty -- If both credentials and `AuthProvider` are configured, their headers are generated independently and merged; different values for the same header fail fast +- If both credentials and `AuthProvider` are configured, their headers are generated independently and merged; different + values for the same header fail fast - On auth failure (e.g. token retrieval throws), the exception propagates to `send()` and the request is blocked + ## 6. AgentCard Definition AgentCards declare extensions via `capabilities.extensions`: @@ -346,24 +381,25 @@ AgentCards declare extensions via `capabilities.extensions`: Extension URIs must match the A2A-T definitions exactly. -Both `securitySchemes` and `securityRequirements` are optional. The former lists authentication methods the agent supports; the latter marks methods required by this integration. `securityRequirements: []` disables built-in credential authentication. +Both `securitySchemes` and `securityRequirements` are optional. The former lists authentication methods the agent +supports; the latter marks methods required by this integration. `securityRequirements: []` disables built-in credential +authentication. ## 7. A2A-T Extensions -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. +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. +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 CompletableFuture sendAuthorization(String agentName, MessageContent content); @@ -371,7 +407,10 @@ NotificationSubscription openNotification(String agentName, MessageContent conte BiConsumer listener); ``` -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. +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 @@ -397,16 +436,13 @@ so mTLS and `crlPath` cannot be combined with that mode and fail fast instead of ## 9. Logging -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. +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. -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. +`MODEL_PREVIEW` is optional, disabled by default, and never wire proof. ```properties logger.protocol.name=PROTOCOL @@ -416,36 +452,35 @@ WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY=true WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS=100000 ``` -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. +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**: +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. +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. +Run `SpringSpnDemoE2ETest` for direct transport. It 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 @@ -489,21 +524,23 @@ Workflow workflow = LoadPsop.load( ## 12. Custom Extensions -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. +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 -| Interface/Class | Purpose | -|--------------------------------------------------------|-----------------------------------------------------------------------| -| `ExecutePsop.Builder` | Workflow execution entry point | -| `ControlPoint` / `DefaultControlPoint` | Business decisions (onTask, onSelfTask, onRoute, onNegotiation, etc.) | -| `WorkflowEngineClient` / `DefaultWorkflowEngineClient` | Workflow send (sendMessage, auth, extensions) | +| Interface/Class | Purpose | +|--------------------------------------------------------|-------------------------------------------------------------------------| +| `ExecutePsop.Builder` | Workflow execution entry point | +| `ControlPoint` / `DefaultControlPoint` | Business decisions (onTask, onSelfTask, onRoute, onNegotiation, etc.) | +| `WorkflowEngineClient` / `DefaultWorkflowEngineClient` | Workflow send (sendMessage, auth, extensions) | | `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 | -| `EventCallback` / `EventType` | Event callback | -| `LoadPsop` / `RegistryClient` | Workflow loading / AgentCard fetching | -| `Workflow` / `WorkflowStep` / `Task` / `JumpCondition` | Workflow definition | -| `ExecutionResult` | Execution result | -| `SendMessageResult` / `TaskResult` | Message/task response | +| `A2ATransport` | Shared wire layer (A2A Java client runtime, auth, SSE consumer) | +| `WorkflowEngineClientConfig` | Configuration (TLS, auth, deadlines, executor limits, negotiation exchange budget) | +| `AuthProvider` | Custom authentication | +| `EventCallback` / `EventType` | Event callback | +| `LoadPsop` / `RegistryClient` | Workflow loading / AgentCard fetching | +| `Workflow` / `WorkflowStep` / `Task` / `JumpCondition` | Workflow definition | +| `ExecutionResult` | Execution result | +| `SendMessageResult` / `TaskResult` | Message/task response | diff --git a/docs/images/workflow-engine-surrounding-systems.png b/docs/images/workflow-engine-surrounding-systems.png deleted file mode 100644 index 6d501ab1..00000000 Binary files a/docs/images/workflow-engine-surrounding-systems.png and /dev/null differ diff --git a/docs/zh/A2AT-SDK-DEPENDENCY.md b/docs/zh/A2AT-SDK-DEPENDENCY.md index 51029242..35f20b14 100644 --- a/docs/zh/A2AT-SDK-DEPENDENCY.md +++ b/docs/zh/A2AT-SDK-DEPENDENCY.md @@ -5,48 +5,42 @@ - 仓库:`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。 +- 引擎使用 `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` 的 -离线协议测试不受此限制;真实模型调用后的生命周期仍需额外观察, +`DefaultSleeper` 定时线程无法由工作流引擎通过公共 API 关闭。`SpringSpnDemo` 的 离线协议测试不受此限制;真实模型调用后的生命周期仍需额外观察, 一次性关闭内嵌 Tomcat 时可能提示上游线程生命周期告警。 -上游 SDK 应在新提交中补充幂等 `close()`,由 `A2ATClient/A2ATServer` 级联关闭其拥有的 -LLM provider;执行引擎届时应升级到该新提交对应的新 Maven 版本。禁止在不改变版本号的 -情况下覆盖 `1.1.0`,否则 IDEA 与 CI 将得到不可复现的同坐标异内容制品。 +上游 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 已发布到 [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 的 PromptTemplateCatalog 会关闭 JarURLConnection 返回的 JarFile。 默认缓存可能让并行门面共用该句柄,导致 zip file +closed。 samples 宿主 A2ATInitialization 在初始化线程临时包装资源 URL,仅该连接 useCaches=false, 完成后恢复类加载器;不更改全局 +URL 缓存或 SDK jar。 每次 SDK 升级需重新验证该隔离是否仍必要。 -SDK 当前内容接口接受字符串模板 URI,宿主通过 StandardTemplates.*.uri() 指定。 -业务调用生成/校验 API,再经 A2atMessages.from 转为最终 MessageContent。 -引擎只检查协商上下文关联,不解释业务内容。 +SDK 当前内容接口接受字符串模板 URI,宿主通过 StandardTemplates.*.uri () 指定。 业务调用生成/校验 API,再经 +A2atMessages.from 转为最终 MessageContent。 引擎只检查协商上下文关联,不解释业务内容。 完整契约见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 ## Maven / IDEA 接入 -根 POM 的 `a2a.t.sdk.version` 是唯一依赖版本来源,当前为 `1.1.0`。 -在执行引擎目录直接执行: +根 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 本地构建物替代正式发布制品。本次通过本机镜像下载并核对 +如果企业或公共镜像尚未同步正式版本,应等待同步或按组织要求调整 Maven 镜像, 不要同坐标 install 本地构建物替代正式发布制品。本次通过本机镜像下载并核对 SDK jar 的 SHA-1 与 Central 发布校验值一致。 宿主若调用 A2A-T 内容生成,需显式声明: @@ -63,118 +57,74 @@ SDK jar 的 SHA-1 与 Central 发布校验值一致。 ## 升级 A2A-T SDK -升级时更新根 `pom.xml` 的 `a2a.t.sdk.version`、本文发布标签和相关接入文档, -核对正式制品与源码/资源差异,并运行完整回归。CI 直接使用根 POM,不单独维护版本或编译 SDK。 +升级时更新根 `pom.xml` 的 `a2a.t.sdk.version`、本文发布标签和相关接入文档, 核对正式制品与源码/资源差异,并运行完整回归。CI +直接使用根 POM,不单独维护版本或编译 SDK。 -禁止只覆盖本地同版本 jar。新版本至少要验证 Task-T 生成/校验、无状态 Negotiation-T -metadata、Authorization-T、Notification-T 长连接,以及现行传输路径: -验证直连 OMC 的 SpringSpnDemo 端到端流程。 +禁止只覆盖本地同版本 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。 +执行引擎使用 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 后必须重新运行直连 E2E。 ## 单一资源基线 -模板、slot schema、slot extraction 和 content validation 提示词全部来自锁定的 SDK jar。 -samples 不再用同名 classpath 资源覆盖 SDK 内容,也不保留旧 renderer/validator 的兼容提示词。 -测试会核对实际加载资源来自 `a2a-t-resources`,避免升级后仍静默运行旧规则。 +模板、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 修正。 +Authorization-T 只接受当前 SDK 定义的规范数据形态:每条策略以从 1 连续递增的编号 `1.` 开头, 字段为“字段名是值”,字段间使用全角逗号 +`,`,多条策略用换行分隔。 例如: +`1. 业务场景是业务投诉诊断,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-06-01~2030-06-18`。 +日期范围与“永久生效”均支持;裸值列表、分号分隔和旧 `/` 格式不接受。 此前本节关于全角分号的描述不正确,已按 1.1.0 正式 jar 的 +slot.json 修正。 -## 1.1.0 升级核对 +## 示例业务接口调用复核(2026-08-31) -相对此前 `ec14175` 源码基线,`v1.1.0` 的 core/client/server/llm/prompt/resources/negotiation -模块源码与资源没有差异;无需修改业务回调或增加版本兼容层。上述 JarFile 初始化隔离仍有必要, -LLM close 公共接口缺口也未随发布解决。发布之后当前 upstream/main 的新增差异仅为开发指南版本说明。 +此前发现的正文来源、填充结果丢弃、City2 缺参路径、Reject/Abort 分发及离线宽松校验问题, 现已在 samples +业务层整改;通用引擎未重新引入内容生成或语义判断职责。 -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/`。 +| 路径 | 发送端生成 | 接收端校验/实际使用 | +|----------------------------|--------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------| +| 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 | -## 示例业务接口调用复核(2026-08-31) +自然语言 FromText 和结构化 FromDataWithSchema 是替代入口,不是同一消息必须串行全部执行的步骤。 本 Demo 主路径选择结构化数据;Task-T +自然语言另有测试,不宣称覆盖 SDK 全部公开接口。 端口资源归属、投诉分类和 OSS 流水号是 SPN 业务层规则;正式集成应替换为宿主/OMC +的实际业务判断。 -此前发现的正文来源、填充结果丢弃、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 拒绝原始任务时,不使用未经验证的局部数据,重新协商完整必要字段。 +协商校验不再读取 parts 摘要;会核对 A2A taskId/contextId 和协商 id/round/maxRounds。 SDK 拒绝原始任务时,不使用未经验证的局部数据,重新协商完整必要字段。 接收 Accept 后只合并本轮请求字段;错误正文、缺参和跨协商回复均不能进入诊断。 -1.1.0 的授权/通知生成入口面向授权操作及订阅请求,并不是任意业务结果生成接口。 -诊断结果、授权回执和抢通事件由 OMC 业务产生。RecoveryNotification 从正式 Notification-T artifact -metadata 检查 SPN 最终抢通结果,避免仅凭 artifact 名称/摘要就关闭订阅。 -这不是对任意外部通知的通用语义验证,也不在工作流引擎内实施。 +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 错误详情, -不能断言此前真实模型拒绝订阅的唯一原因已经确认;需真实模型重跑核验。 +[业务回调指南第 9 节](BUSINESS_CALLBACKS.md#9-示例业务侧的-a2a-t-110-调用参考)。 严格离线测试只处理明确样例输入,不从模板示例臆造参数;不能代替真实 +LLM 和现网 OMC 联调。 + +## 发布验证入口 + +执行 `mvn -B clean verify`,必须包含 core、starter 与 samples;测试统计以当前提交的 Surefire XML 为准。 +发布前必须通过直连 SpringSpnDemo。 +默认本地演示为 City1 缺参协商、City2 完整输入;外部 OMC 不注入演示缺参。 + +SSE pretty 展示保留事件控制字段,JSON 数据区明确标注为展示格式,不逐行重复 data:。 +设置 `WORKFLOW_ENGINE_PROTOCOL_PRETTY=false` 可查看脱敏后的原文,容量限制仍生效。 +Header 凭据始终脱敏,编排中心 query token 仅显示匿名标记,不存在敏感头放开开关。 + +离线 LLM provider 和本地 OMC 结果不能代替真实模型/OMC 验收。 +真实环境验收须单独记录配置来源、版本、操作授权和脱敏报文,私有联调记录不得提交到公共仓库。 diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index 469be164..e8caf69d 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -2,8 +2,8 @@ ## 包总览 -| 包 | 说明 | -|-----------------------------------|---------------------------------------| +| 包 | 说明 | +|---------------------------------------|---------------------------------------| | `dev.openan.workflow.engine.client` | A2A 消息传输、认证、扩展、配置 | | `dev.openan.workflow.engine.control` | 用户决策点和事件系统 | | `dev.openan.workflow.engine.core` | DAG 遍历引擎和上下文组装 | @@ -21,21 +21,21 @@ #### ExecutePsop.Builder -| 方法 | 类型 | 默认值 | 说明 | -|------------------------------------------|------|-------------|-----------------------------------| -| `psop(Workflow)` | 必填 | - | PSOP 工作流定义 | -| `agentCards(List)` | 必填 | `List.of()` | 工作流中所有智能体的 AgentCard | -| `engineClient(WorkflowEngineClient)` | 可选 | null | 预配置客户端(null=自动创建) | -| `controlPoint(ControlPoint)` | 必填 | - | 用户决策实现 | -| `runtimeIntent(String)` | 可选 | `""` | 自然语言意图,用于上下文组装 | -| `lang(String)` | 可选 | `"zh"` | 语言提示(`"zh"` 或 `"en"`) | -| `credentialsConfigPath(String)` | 可选 | null | 凭证 JSON 文件路径 | -| `sslVerify(boolean)` | 可选 | `true` | 是否验证 TLS 证书 | -| `caCertsPath(String)` | 可选 | null | CA 证书 PEM 文件路径 | -| `a2aClientRuntime(A2AJavaClientRuntime)` | 可选 | null | 自定义运行时(null = 自动创建) | -| `eventCallback(EventCallback)` | 可选 | null | 实时事件回调 | -| `onFinish(BiConsumer)` | 可选 | null | 执行完成回调 | -| `onEvent(Function)` | 可选 | null | 单事件转换钩子 | +| 方法 | 类型 | 默认值 | 说明 | +|------------------------------------------|------|-------------|---------------------------------| +| `psop(Workflow)` | 必填 | - | PSOP 工作流定义 | +| `agentCards(List)` | 必填 | `List.of()` | 工作流中所有智能体的 AgentCard | +| `engineClient(WorkflowEngineClient)` | 可选 | null | 预配置客户端(null=自动创建) | +| `controlPoint(ControlPoint)` | 必填 | - | 用户决策实现 | +| `runtimeIntent(String)` | 可选 | `""` | 自然语言意图,用于上下文组装 | +| `lang(String)` | 可选 | `"zh"` | 语言提示(`"zh"` 或 `"en"`) | +| `credentialsConfigPath(String)` | 可选 | null | 凭证 JSON 文件路径 | +| `sslVerify(boolean)` | 可选 | `true` | 是否验证 TLS 证书 | +| `caCertsPath(String)` | 可选 | null | CA 证书 PEM 文件路径 | +| `a2aClientRuntime(A2AJavaClientRuntime)` | 可选 | null | 自定义运行时(null = 自动创建) | +| `eventCallback(EventCallback)` | 可选 | null | 实时事件回调 | +| `onFinish(BiConsumer)` | 可选 | null | 执行完成回调 | +| `onEvent(Function)` | 可选 | null | 单事件转换钩子 | ```java ExecutionResult result = ExecutePsop.builder() @@ -68,21 +68,19 @@ void setEventCallback(EventCallback callback); void close(); ``` -执行器内部调用 dispatch;onTask 不自行发送。内容是最终 parts/metadata/extensions,引擎只管理信封和交互,不创建 A2ATClient,不按 AgentCard 声明生成内容。模板查询和生成接口请直接使用宿主 SDK。 +执行器内部调用 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。 +只有远端 `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 确认,也不能判为诊断成功。 +返回 `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 @@ -92,30 +90,32 @@ NotificationSubscription openNotification(String agentName, MessageContent conte BiConsumer listener); ``` -授权和订阅接收宿主生成的最终内容。三类操作使用独立 transport/runtime/context,成功与否不阻断工作流。openNotification 先注册 handle 再开始 I/O,监听器直接收到 handle 和完整 ReceivedMessage。acknowledgement() 是实际 ACK;超时失败。close() 请求关闭,completion() 在流真正退出后完成。 +授权和订阅接收宿主生成的最终内容。三类操作使用独立 transport/runtime/context,成功与否不阻断工作流。openNotification 先注册 +handle 再开始 I/O,监听器直接收到 handle 和完整 ReceivedMessage。acknowledgement () 是实际 ACK;超时失败。close () +请求关闭,completion () 在流真正退出后完成。 ### WorkflowEngineClientConfig 工作流引擎客户端的 Builder 配置。 -| 属性 | 类型 | 默认值 | 说明 | -|---------------------------------|--------------------------|--------|-------------------------------------------| -| `sslVerify` | `boolean` | `true` | HTTP/JSON-RPC 的 TLS 证书链验证;关闭时仍校验主机名并保留 mTLS 客户端身份。默认 gRPC 关闭时使用 plaintext | -| `caCertsPath` | `String` | null | CA 证书 PEM 文件路径 | -| `clientCertPath` | `String` | null | mTLS 客户端证书链路径;默认 gRPC 需配合 `sslVerify=true` | -| `clientKeyPath` | `String` | null | mTLS PKCS#8 PEM/DER 私钥路径 | -| `clientKeyPassword` | `String` | null | 加密 PKCS#8 私钥密码 | -| `crlPath` | `String` | null | HTTP/JSON-RPC 的 X.509 CRL 路径;默认 gRPC runtime 暂不支持并会拒绝启动 | -| `sendTimeoutSeconds` | `long` | `600` | SSE 流超时(默认 10 分钟) | -| `notificationAckTimeoutSeconds` | `long` | `5` | Notification-T 首个 ACK/事件等待时间 | -| `sendExecutorCoreSize` | `int` | `4` | 发送线程池核心线程数 | -| `sendExecutorMaxSize` | `int` | `16` | 发送线程池最大线程数 | -| `sendExecutorQueueCapacity` | `int` | `256` | 发送线程池有界队列容量 | -| `authProvider` | `AuthProvider` | null | 自定义认证提供器 | -| `credentialsConfigPath` | `String` | null | 凭证 JSON 文件路径;显式配置缺失或损坏时启动失败 | -| `credentialEncryptionKey` | `String` | null | 宿主显式传入凭据解密密钥,不从 LLM .env 加载 | -| `credentialsConfig` | `Map` | null | 内联凭证配置;AgentCard 声明安全要求时必须匹配 | -| `maxNegotiationExchanges` | `int` | `3` | 本地交互预算,独立于 SDK maxRounds;耗尽仅本地失败 | +| 属性 | 类型 | 默认值 | 说明 | +|---------------------------------|----------------|--------|-----------------------------------------------------------------------------------------------------------| +| `sslVerify` | `boolean` | `true` | HTTP/JSON-RPC 的 TLS 证书链验证;关闭时仍校验主机名并保留 mTLS 客户端身份。默认 gRPC 关闭时使用 plaintext | +| `caCertsPath` | `String` | null | CA 证书 PEM 文件路径 | +| `clientCertPath` | `String` | null | mTLS 客户端证书链路径;默认 gRPC 需配合 `sslVerify=true` | +| `clientKeyPath` | `String` | null | mTLS PKCS#8 PEM/DER 私钥路径 | +| `clientKeyPassword` | `String` | null | 加密 PKCS#8 私钥密码 | +| `crlPath` | `String` | null | HTTP/JSON-RPC 的 X.509 CRL 路径;默认 gRPC runtime 暂不支持并会拒绝启动 | +| `sendTimeoutSeconds` | `long` | `600` | SSE 流超时(默认 10 分钟) | +| `notificationAckTimeoutSeconds` | `long` | `5` | Notification-T 首个 ACK/事件等待时间 | +| `sendExecutorCoreSize` | `int` | `4` | 发送线程池核心线程数 | +| `sendExecutorMaxSize` | `int` | `16` | 发送线程池最大线程数 | +| `sendExecutorQueueCapacity` | `int` | `256` | 发送线程池有界队列容量 | +| `authProvider` | `AuthProvider` | null | 自定义认证提供器 | +| `credentialsConfigPath` | `String` | null | 凭证 JSON 文件路径;显式配置缺失或损坏时启动失败 | +| `credentialEncryptionKey` | `String` | null | 宿主显式传入凭据解密密钥,不从 LLM .env 加载 | +| `credentialsConfig` | `Map` | null | 内联凭证配置;AgentCard 声明安全要求时必须匹配 | +| `maxNegotiationExchanges` | `int` | `3` | 本地交互预算,独立于 SDK maxRounds;耗尽仅本地失败 | ```java WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() @@ -141,11 +141,15 @@ public interface AuthProvider { } ``` -每次消息发送时调用。`headers` 是可变 Map;直接添加 `Authorization`、自定义头等。`AuthProvider` 可作为唯一认证来源,包括 AgentCard 的 `securityRequirements` 非空但未配置 credentials 的场景。若同时配置 credentials,两者分别计算后合并;若同名 Header 生成不同值,引擎会抛出 `SecurityException`,不会静默覆盖。 +每次消息发送时调用。`headers` 是可变 Map;直接添加 `Authorization`、自定义头等。`AuthProvider` 可作为唯一认证来源,包括 +AgentCard 的 `securityRequirements` 非空但未配置 credentials 的场景。若同时配置 credentials,两者分别计算后合并;若同名 +Header 生成不同值,引擎会抛出 `SecurityException`,不会静默覆盖。 ### A2atMessages -通过 MessageContent(parts, metadata, extensions) 提交自定义扩展内容,不注册引擎内容处理器。A2atMessages.from(MetadataContent, List>) 保留 SDK metadata 原位置并激活对应 URI;contextOf(ReceivedMessage) 或 contextOf(Map) 读取并检查规范协商上下文。只有 a2a-t-core 依赖,没有内容生成或语义校验。 +通过 MessageContent (parts, metadata, extensions) 提交自定义扩展内容,不注册引擎内容处理器。A2atMessages.from +(MetadataContent, List>) 保留 SDK metadata 原位置并激活对应 URI;contextOf (ReceivedMessage) 或 contextOf (Map< +String,Object>) 读取并检查规范协商上下文。只有 a2a-t-core 依赖,没有内容生成或语义校验。 ### A2AJavaClientRuntime @@ -175,7 +179,8 @@ public interface ConversationScopedA2AJavaClientRuntime { } ``` -当运行时同时实现此接口时,引擎会在完整的发送 + 协商周期完成后调用 `closeConversation`——而不是每次 HTTP 请求之后。这样网关会话只会在逻辑会话结束后才释放。 +当运行时同时实现此接口时,引擎会在完整的发送 + 协商周期完成后调用 `closeConversation`——而不是每次 HTTP +请求之后。这样网关会话只会在逻辑会话结束后才释放。 与 `A2AJavaClientRuntime` 一起实现: @@ -197,7 +202,8 @@ public class MyGatewayRuntime ### AgentCardJacksonModule -用于反序列化 AgentCard JSON 的 Jackson 模块,包含安全方案归一化。处理 A2A SDK 强类型 `AgentCard` record 所需的 OpenAPI 格式 `securitySchemes` / `securityRequirements` 字段。 +用于反序列化 AgentCard JSON 的 Jackson 模块,包含安全方案归一化。处理 A2A SDK 强类型 `AgentCard` record 所需的 OpenAPI 格式 +`securitySchemes` / `securityRequirements` 字段。 ```java ObjectMapper mapper = new ObjectMapper() @@ -208,7 +214,8 @@ AgentCard card = mapper.readValue( ### AgentCardNormalizer -将原始 `Map`(注册中心 API 返回)归一化为 `AgentCard` 兼容格式的工具类。`RegistryClient.fetchAgentCards()` 内部使用。也作为公共静态方法供自定义归一化使用: +将原始 `Map`(注册中心 API 返回)归一化为 `AgentCard` 兼容格式的工具类。`RegistryClient.fetchAgentCards()` +内部使用。也作为公共静态方法供自定义归一化使用: ```java Map normalized = AgentCardNormalizer.normalize(rawMap); @@ -229,9 +236,8 @@ interface ControlPoint { } ``` -onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 -onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 -未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 onSelfTask 返回本地 TaskResult;onRoute +选择允许的候选;onNegotiation 返回 Send 或 Stop。 未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 ### EventCallback @@ -247,30 +253,30 @@ public class EventCallback { ### EventType -| 常量 | 说明 | -|--------------------------|--------------------------| -| `STEP_START` | 工作流步骤开始 | -| `STEP_COMPLETE` | 工作流步骤完成 | -| `TASK_REQUEST` | 任务分派给智能体 | -| `TASK_RESPONSE` | 收到任务响应 | +| 常量 | 说明 | +|--------------------------|----------------------------------------------------| +| `STEP_START` | 工作流步骤开始 | +| `STEP_COMPLETE` | 工作流步骤完成 | +| `TASK_REQUEST` | 任务分派给智能体 | +| `TASK_RESPONSE` | 收到任务响应 | | `TASK_STATUS_CHANGED` | 任务状态变更(pending → running → success/failed) | -| `AGENT_REQUEST` | 消息发送给智能体 | -| `AGENT_RESPONSE` | 收到智能体响应 | -| `AGENT_STATUS_UPDATE` | 智能体 SSE 状态更新 | -| `AGENT_ARTIFACT_UPDATE` | 智能体 SSE artifact 更新 | -| `AGENT_MESSAGE_EVENT` | 智能体 SSE 消息事件 | -| `NEGOTIATION_REQUEST` | 智能体请求协商 | -| `NEGOTIATION_RESOLVED` | 补充信息已发送 | -| `NEGOTIATION_FAILED` | 协商无法解决 | -| `AUTHORIZATION_REQUEST` | 智能体请求授权 | -| `AUTHORIZATION_RESOLVED` | 授权决策已做出 | -| `NOTIFICATION` | 收到智能体通知 | -| `ROUTE_DECISION` | 路由决策已做出 | -| `WORKFLOW_COMPLETE` | 工作流完成(所有步骤结束) | -| `START` | 工作流执行开始 | -| `COMPLETE` | 工作流执行成功完成 | -| `ERROR` | 工作流执行失败 | -| `CLOSE` | 引擎客户端已关闭 | +| `AGENT_REQUEST` | 消息发送给智能体 | +| `AGENT_RESPONSE` | 收到智能体响应 | +| `AGENT_STATUS_UPDATE` | 智能体 SSE 状态更新 | +| `AGENT_ARTIFACT_UPDATE` | 智能体 SSE artifact 更新 | +| `AGENT_MESSAGE_EVENT` | 智能体 SSE 消息事件 | +| `NEGOTIATION_REQUEST` | 智能体请求协商 | +| `NEGOTIATION_RESOLVED` | 补充信息已发送 | +| `NEGOTIATION_FAILED` | 协商无法解决 | +| `AUTHORIZATION_REQUEST` | 智能体请求授权 | +| `AUTHORIZATION_RESOLVED` | 授权决策已做出 | +| `NOTIFICATION` | 收到智能体通知 | +| `ROUTE_DECISION` | 路由决策已做出 | +| `WORKFLOW_COMPLETE` | 工作流完成(所有步骤结束) | +| `START` | 工作流执行开始 | +| `COMPLETE` | 工作流执行成功完成 | +| `ERROR` | 工作流执行失败 | +| `CLOSE` | 引擎客户端已关闭 | --- @@ -306,6 +312,11 @@ POST `/api/v1/orchestrate/search`。返回按自然语言意图匹配的工作 ### RegistryClient +双参数构造默认使用 30 秒完整响应截止时间。可通过 +`new RegistryClient(url, sslVerify, Duration.ofSeconds(15))` 设置正值预算,包含响应正文读取;线程中断会取消待处理请求。 +注册中心方法返回 JSON Map,使用 AgentCardJacksonModule 转换为 AgentCard,详见集成指南。 + + 从注册中心获取和注册 AgentCard。 ```java @@ -330,7 +341,8 @@ Map registerAgentCard(Map agentCard) ### WorkflowExecutor -中层 DAG 遍历引擎。遍历工作流步骤,通过 `ContextBuilder` 选择强类型上游执行结果,并行分派子任务,应用步骤成功策略(`ALL_SUCCESS` / `ANY_SUCCESS` / `SELF_LOOP`),并路由到下一步。 +中层 DAG 遍历引擎。遍历工作流步骤,通过 `ContextBuilder` 选择强类型上游执行结果,并行分派子任务,应用步骤成功策略( +`ALL_SUCCESS` / `ANY_SUCCESS` / `SELF_LOOP`),并路由到下一步。 SDK 用户通常不直接实例化——`ExecutePsop` 内部封装了它。供需要在不含 runner 生命周期管理的情况下运行遍历层的高级集成使用。 @@ -343,7 +355,8 @@ ExecutionResult result = executor.run().join(); ### ContextBuilder -包级私有辅助类,按 `contextFrom` 规则选择上游步骤结果并构造 `WorkflowInput`(省略 = 直接前驱,`[]` = 不聚合上游,`"*"` = 所有祖先,指定名称 = 选择性聚合)。它不生成提示词,也不属于公共 API 接口。 +包级私有辅助类,按 `contextFrom` 规则选择上游步骤结果并构造 `WorkflowInput`(省略 = 直接前驱,`[]` = 不聚合上游,`"*"` = +所有祖先,指定名称 = 选择性聚合)。它不生成提示词,也不属于公共 API 接口。 --- @@ -362,14 +375,14 @@ ExecutionResult result = executor.run().join(); ### WorkflowStep -| 字段 | 类型 | 默认值 | 说明 | -|---------------|-----------------------|---------------|------------------------------------------| -| `name` | `String` | - | 步骤名(工作流内唯一) | -| `subtasks` | `List` | `List.of()` | 此步骤分派的子任务 | -| `next` | `List` | `List.of()` | 条件后续步骤 | -| `layer` | `int` | `0` | 上下文层(0 = 仅运行时意图) | +| 字段 | 类型 | 默认值 | 说明 | +|---------------|-----------------------|---------------|----------------------------------------------------------------------------| +| `name` | `String` | - | 步骤名(工作流内唯一) | +| `subtasks` | `List` | `List.of()` | 此步骤分派的子任务 | +| `next` | `List` | `List.of()` | 条件后续步骤 | +| `layer` | `int` | `0` | 上下文层(0 = 仅运行时意图) | | `contextFrom` | `List` | null | 聚合来源;省略 = 直接前驱,`[]` = 不聚合,`"*"` = 所有祖先,或指定祖先名称 | -| `stepType` | `StepType` | `ALL_SUCCESS` | 执行模式 | +| `stepType` | `StepType` | `ALL_SUCCESS` | 执行模式 | ### StepType @@ -383,12 +396,12 @@ ExecutionResult result = executor.run().join(); 任务生命周期状态,用于 `TASK_STATUS_CHANGED` 事件,与 Python SDK 保持跨 SDK 一致性。 -| 值 | 字符串 | 说明 | -|------------|------------|------------------------| -| `PENDING` | `"pending"` | 任务已创建,尚未开始 | -| `RUNNING` | `"running"` | 任务进行中 | -| `SUCCESS` | `"success"` | 任务成功完成 | -| `FAILED` | `"failed"` | 任务失败 | +| 值 | 字符串 | 说明 | +|-----------|-------------|----------------------| +| `PENDING` | `"pending"` | 任务已创建,尚未开始 | +| `RUNNING` | `"running"` | 任务进行中 | +| `SUCCESS` | `"success"` | 任务成功完成 | +| `FAILED` | `"failed"` | 任务失败 | ### Task @@ -407,34 +420,32 @@ ExecutionResult result = executor.run().join(); ### TaskRequest / BusinessInput -TaskRequest 使用 getXxx() 访问器: +TaskRequest 使用 getXxx () 访问器: -| 字段 | 含义 | -|---|---| -| executionId / taskId | 本地执行/逻辑任务标识,不是远端协议 ID | -| stepName / agentName / skill | 当前步骤、目标智能体和技能 | -| instruction / language | 当前指令,不含引擎拼接的历史 | -| input | BusinessInput:文本或任意 JSON 可序列化数据,二选一,不含 schema | -| workflowInput | WorkflowInput(runtimeIntent, upstreamResults),与当前输入分离 | +| 字段 | 含义 | +|------------------------------|------------------------------------------------------------------| +| 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() 访问器。 +BusinessInput.text (value) / BusinessInput.data (value) 创建输入快照。 +WorkflowInput、UpstreamStepResult、ReceivedMessage、NegotiationRequest 等 record 使用 field () 访问器。 -| contextFrom | 上游选择 | -|---|---| -| 未指定 / null | 已产生结果的直接前驱 | -| [] | 不聚合上游,runtimeIntent 仍保留 | -| ["*"] | 已产生结果的全部祖先 | -| 显式祖先名称 | 按声明顺序选取对应结果 | +| contextFrom | 上游选择 | +|---------------|----------------------------------| +| 未指定 / null | 已产生结果的直接前驱 | +| [] | 不聚合上游,runtimeIntent 仍保留 | +| ["*"] | 已产生结果的全部祖先 | +| 显式祖先名称 | 按声明顺序选取对应结果 | -contextFrom 只选择证据,不建立执行依赖;依赖由 next 定义。 -未知或非祖先名称、通配符与名称混用均非法;未激活分支不虚构结果。 +contextFrom 只选择证据,不建立执行依赖;依赖由 next 定义。 未知或非祖先名称、通配符与名称混用均非法;未激活分支不虚构结果。 引擎不把窗口附加到 instruction/parts,也不调用 LLM 映射上游;由宿主决定怎么消费或映射下游输入。 -窗口结构:stepName → taskResults[] → outputs[] / receivedMessages[]。 -TaskExecutionResult 还保留 agentName、skill、逻辑 taskId、taskDescription、status、 -error、errorCode、errorDetails。多子任务不混合,嵌套数组仍作为一个输出项。 -输出不要求来自 LLM,也不要求符合投诉模板。 +窗口结构:stepName → taskResults[] → outputs[] / receivedMessages[]。 TaskExecutionResult 还保留 agentName、skill、逻辑 +taskId、taskDescription、status、 error、errorCode、errorDetails。多子任务不混合,嵌套数组仍作为一个输出项。 输出不要求来自 +LLM,也不要求符合投诉模板。 ### MessageContent / ReceivedMessage @@ -443,26 +454,18 @@ record MessageContent(List> parts, Map metadata, Set 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 也可完成交互。 -进度/协商提示不会因为含文本就变成工作流成功。 +通过 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 也可完成交互。 进度/协商提示不会因为含文本就变成工作流成功。 ### NegotiationRequest / NegotiationReply @@ -470,7 +473,8 @@ TaskResult.failure(code, message) 将错误与输出分开,builder 可保留 ### SendMessageResult -getReceivedMessages() 是保留层级的响应来源,getOutputs() 为便利投影;getTask()/getTaskState() 保留实际远端状态,failureCode/failureMessage 为独立本地交互失败。text 和扁平 metadata 仅用于传输诊断,不用它们代替完整业务响应。 +getReceivedMessages () 是保留层级的响应来源,getOutputs () 为便利投影;getTask ()/getTaskState () +保留实际远端状态,failureCode/failureMessage 为独立本地交互失败。text 和扁平 metadata 仅用于传输诊断,不用它们代替完整业务响应。 ### ExecutionResult @@ -510,7 +514,7 @@ getReceivedMessages() 是保留层级的响应来源,getOutputs() 为便利投 | 扩展 | URI | |-----------------|-------------------------------------------------------------------------------------------| | Task-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1` | -| Negotiation-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1` | +| 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` | @@ -533,7 +537,7 @@ getReceivedMessages() 是保留层级的响应来源,getOutputs() 为便利投 ## 错误处理 - 回调异常/空值/超时明确失败;SDK 内容错误由宿主转换为 BusinessFailure,必要时保留安全的 code/details。 -- maxNegotiationExchanges 耗尽或本地 Stop 只结束本地交互,不自动发送 Abort。业务 Send(Abort) 不算诊断成功。 +- maxNegotiationExchanges 耗尽或本地 Stop 只结束本地交互,不自动发送 Abort。业务 Send (Abort) 不算诊断成功。 - 远端状态和错误与业务输出分开,失败状态消息不成为 outputs,已有部分 artifact 保留。 - 缺失必需认证信息时拒绝发送,不静默匿名访问。 - SDK 流退出/传输错误日志是传输观察,不等同于工作流最终状态;以 ExecutionResult 和远端任务状态判断结果。 @@ -542,23 +546,24 @@ getReceivedMessages() 是保留层级的响应来源,getOutputs() 为便利投 ## spring-boot-starter 模块 -`spring-boot-starter` 模块为 A2A **服务端**(非客户端/工作流侧)提供 Spring Boot 自动配置。当位于 Spring Boot Web 应用的 classpath 时,自动将所有 A2A SDK 服务端组件注册为 Spring Bean。 +`spring-boot-starter` 模块为 A2A **服务端**(非客户端/工作流侧)提供 Spring Boot 自动配置。当位于 Spring Boot Web 应用的 +classpath 时,自动将所有 A2A SDK 服务端组件注册为 Spring Bean。 ### A2AProperties 以 `a2at.server` 为前缀的配置属性: -| 属性 | 默认值 | 说明 | -|-------------------------|-------------------------------|-----------------------------------------------| -| `a2at.server.agent-card` | `classpath:agentcard.json` | AgentCard JSON 文件路径(支持 classpath: 或 file: 前缀) | -| `a2at.server.path-prefix` | `/a2a/json` | A2A 端点的 URL 路径前缀 | -| `a2at.server.agent-timeout-seconds` | `30` | Agent 执行超时(秒) | -| `a2at.server.consumption-timeout-seconds` | `5` | 消费超时(秒) | -| `a2at.server.reconciliation-timeout-seconds` | `1` | 协调等待超时(秒) | -| `a2at.server.executor-core-size` | `8` | 服务端执行器核心线程数 | -| `a2at.server.executor-max-size` | `8` | 服务端执行器最大线程数 | -| `a2at.server.executor-queue-capacity` | `100` | 服务端执行器有界队列容量 | -| `a2at.server.executor-keep-alive-seconds` | `60` | 非核心线程存活时间(秒) | +| 属性 | 默认值 | 说明 | +|----------------------------------------------|----------------------------|----------------------------------------------------------| +| `a2at.server.agent-card` | `classpath:agentcard.json` | AgentCard JSON 文件路径(支持 classpath: 或 file: 前缀) | +| `a2at.server.path-prefix` | `/a2a/json` | A2A 端点的 URL 路径前缀 | +| `a2at.server.agent-timeout-seconds` | `30` | Agent 执行超时(秒) | +| `a2at.server.consumption-timeout-seconds` | `5` | 消费超时(秒) | +| `a2at.server.reconciliation-timeout-seconds` | `1` | 协调等待超时(秒) | +| `a2at.server.executor-core-size` | `8` | 服务端执行器核心线程数 | +| `a2at.server.executor-max-size` | `8` | 服务端执行器最大线程数 | +| `a2at.server.executor-queue-capacity` | `100` | 服务端执行器有界队列容量 | +| `a2at.server.executor-keep-alive-seconds` | `60` | 非核心线程存活时间(秒) | ```yaml a2at: @@ -575,19 +580,19 @@ a2at: 自动配置以下 Bean(均为 `@ConditionalOnMissingBean`,可覆盖任意一个): -| Bean | 类型 | 用途 | -|---------------------|-------------------------------|---------------------------------------------------| -| `agentCard` | `AgentCard` | 通过 Jackson 从 `a2at.server.agent-card` 路径加载 | -| `a2aConfigProvider` | `A2AConfigProvider` | SDK 配置值 | -| `taskStore` | `InMemoryTaskStore` | 内存任务存储 | -| `eventBus` | `MainEventBus` | 用于 SSE 流的事件总线 | -| `queueManager` | `InMemoryQueueManager` | 事件队列管理器 | -| `pushStore` | `PushNotificationConfigStore` | 推送通知配置存储 | -| `agentExecutorPool` | `ExecutorService` | 智能体执行线程池(8 线程,守护线程) | -| `eventBusProcessor` | `MainEventBusProcessor` | 事件总线处理器 | -| `requestHandler` | `RequestHandler` | 默认请求处理器 | -| `restHandler` | `RestHandler` | REST 协议处理器 | -| `a2aController` | `A2AController` | Spring MVC 控制器(`message:send` + `message:stream`) | +| Bean | 类型 | 用途 | +|---------------------|-------------------------------|--------------------------------------------------------| +| `agentCard` | `AgentCard` | 通过 Jackson 从 `a2at.server.agent-card` 路径加载 | +| `a2aConfigProvider` | `A2AConfigProvider` | SDK 配置值 | +| `taskStore` | `InMemoryTaskStore` | 内存任务存储 | +| `eventBus` | `MainEventBus` | 用于 SSE 流的事件总线 | +| `queueManager` | `InMemoryQueueManager` | 事件队列管理器 | +| `pushStore` | `PushNotificationConfigStore` | 推送通知配置存储 | +| `agentExecutorPool` | `ExecutorService` | 智能体执行线程池(8 线程,守护线程) | +| `eventBusProcessor` | `MainEventBusProcessor` | 事件总线处理器 | +| `requestHandler` | `RequestHandler` | 默认请求处理器 | +| `restHandler` | `RestHandler` | REST 协议处理器 | +| `a2aController` | `A2AController` | Spring MVC 控制器(`message:send` + `message:stream`) | ### A2AController diff --git a/docs/zh/BUSINESS_CALLBACKS.md b/docs/zh/BUSINESS_CALLBACKS.md index 49246a28..aaf31ee7 100644 --- a/docs/zh/BUSINESS_CALLBACKS.md +++ b/docs/zh/BUSINESS_CALLBACKS.md @@ -1,8 +1,7 @@ # 业务回调集成契约 -首发 API,A2A-T SDK `1.1.0`,A2A Java `1.2.0.Final`。 -引擎负责 DAG、标准 A2A 信封、认证、传输和任务等待;宿主负责消息内容、schema、模板、语义校验和 LLM。 -直连与 dev 东信转发使用相同业务契约。 +首发 API,A2A-T SDK `1.1.0`,A2A Java `1.2.0.Final`。 引擎负责 DAG、标准 A2A 信封、认证、传输和任务等待;宿主负责消息内容、schema、模板、语义校验和 +LLM。业务契约独立于具体传输实现。 ## 1. 回调接口 @@ -15,9 +14,8 @@ interface ControlPoint { } ``` -onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 -onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 -未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 onSelfTask 返回本地 TaskResult;onRoute +选择允许的候选;onNegotiation 返回 Send 或 Stop。 未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 ```java @@ -34,41 +32,37 @@ ControlPoint callbacks = ControlPoint.builder() .build(); ``` -示例显式发送普通文本,不宣称为 Task-T;本地透传、路由和停止策略需替换为真实业务逻辑。 -onTask 只返回内容,不自行再次发起任务网络请求。 -导入 `dev.openan.workflow.engine.control.ControlPoint`、`model.*`, -以及 Java 的 List、Map、CompletableFuture。 +示例显式发送普通文本,不宣称为 Task-T;本地透传、路由和停止策略需替换为真实业务逻辑。 onTask 只返回内容,不自行再次发起任务网络请求。 +导入 `dev.openan.workflow.engine.control.ControlPoint`、`model.*`, 以及 Java 的 List、Map、CompletableFuture。 ## 2. TaskRequest 与上游窗口 -TaskRequest 使用 getXxx() 访问器: +TaskRequest 使用 getXxx () 访问器: -| 字段 | 含义 | -|---|---| -| executionId / taskId | 本地执行/逻辑任务标识,不是远端协议 ID | -| stepName / agentName / skill | 当前步骤、目标智能体和技能 | -| instruction / language | 当前指令,不含引擎拼接的历史 | -| input | BusinessInput:文本或任意 JSON 可序列化数据,二选一,不含 schema | -| workflowInput | WorkflowInput(runtimeIntent, upstreamResults),与当前输入分离 | +| 字段 | 含义 | +|------------------------------|------------------------------------------------------------------| +| 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() 访问器。 +BusinessInput.text (value) / BusinessInput.data (value) 创建输入快照。 +WorkflowInput、UpstreamStepResult、ReceivedMessage、NegotiationRequest 等 record 使用 field () 访问器。 -| contextFrom | 上游选择 | -|---|---| -| 未指定 / null | 已产生结果的直接前驱 | -| [] | 不聚合上游,runtimeIntent 仍保留 | -| ["*"] | 已产生结果的全部祖先 | -| 显式祖先名称 | 按声明顺序选取对应结果 | +| contextFrom | 上游选择 | +|---------------|----------------------------------| +| 未指定 / null | 已产生结果的直接前驱 | +| [] | 不聚合上游,runtimeIntent 仍保留 | +| ["*"] | 已产生结果的全部祖先 | +| 显式祖先名称 | 按声明顺序选取对应结果 | -contextFrom 只选择证据,不建立执行依赖;依赖由 next 定义。 -未知或非祖先名称、通配符与名称混用均非法;未激活分支不虚构结果。 +contextFrom 只选择证据,不建立执行依赖;依赖由 next 定义。 未知或非祖先名称、通配符与名称混用均非法;未激活分支不虚构结果。 引擎不把窗口附加到 instruction/parts,也不调用 LLM 映射上游;由宿主决定怎么消费或映射下游输入。 -窗口结构:stepName → taskResults[] → outputs[] / receivedMessages[]。 -TaskExecutionResult 还保留 agentName、skill、逻辑 taskId、taskDescription、status、 -error、errorCode、errorDetails。多子任务不混合,嵌套数组仍作为一个输出项。 -输出不要求来自 LLM,也不要求符合投诉模板。 +窗口结构:stepName → taskResults[] → outputs[] / receivedMessages[]。 TaskExecutionResult 还保留 agentName、skill、逻辑 +taskId、taskDescription、status、 error、errorCode、errorDetails。多子任务不混合,嵌套数组仍作为一个输出项。 输出不要求来自 +LLM,也不要求符合投诉模板。 ## 3. 最终内容与完整响应 @@ -77,29 +71,23 @@ record MessageContent(List> parts, Map metadata, Set 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 也可完成交互。 -进度/协商提示不会因为含文本就变成工作流成功。 +通过 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 配置。 +纯引擎仅依赖 a2a-t-core;生成内容的宿主显式依赖 a2a-t-client。 宿主创建、配置自己的 A2ATClient,不通过 +WorkflowEngineClientConfig 或 ExecutePsop 承载 LLM 配置。 ```java // sdk、data、schema、模板选择均属于宿主。 @@ -108,147 +96,128 @@ MetadataContent generated = sdk.generateTaskPromptFromDataWithSchema( MessageContent outgoing = A2atMessages.from(generated, List.of(new TextPart("诊断当前专线"))); ``` -自然语言入口为 `sdk.generateTaskPromptFromText(text, StandardTemplates.PRIVATE_LINE_COMPLAINT.uri())`,随后同样调用 A2atMessages.from。 +自然语言入口为 `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 初始化隔离,不属于引擎。 +自然语言、结构化生成和校验等能力直接调用宿主 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 确认,也不能判为诊断成功。 +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 专属内容异常类,也不自动转存可能含敏感数据的原异常。 +不得虚构缺失客户事实;样例只从本地当前地市任务输入提取实际请求的字段。 SDK 内容异常由宿主映射成 BusinessFailure (code, +safeMessage, safeDetails); 引擎不识别 SDK 专属内容异常类,也不自动转存可能含敏感数据的原异常。 ## 6. 路由、并发与失败 -RouteRequest(executionId, stepName, workflowInput, currentResults, candidates)。 -候选为 RouteOption(nextStep, condition),返回 RouteDecision.builder().nextStep(允许目标).build()。 -无条件边自动并行推进,条件分支只接受候选中的目标。 +RouteRequest (executionId, stepName, workflowInput, currentResults, candidates)。 候选为 RouteOption (nextStep, +condition),返回 RouteDecision.builder ().nextStep (允许目标).build ()。 无条件边自动并行推进,条件分支只接受候选中的目标。 -回调可能并发,不要共享可变“当前任务/城市”字段。 -每个工作流任务从内容准备到传输完成受客户端 timeout 总体限制,默认 sendTimeoutSeconds=600; -路由单独限制回调等待时间,dispatch/协商另有总等待截止时间。取消/超时后晚到结果不发送, -但不等于自动取消宿主正在运行的 LLM 或业务操作,宿主负责清理其资源。 -同步回调入口应迅速返回,阻塞任务应交给异步执行器。 +回调可能并发,不要共享可变“当前任务/城市”字段。 每个工作流任务从内容准备到传输完成受客户端 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() 在流实际退出后完成。 +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 是否允许额外的自动抢通操作。 +任务、授权、通知使用独立 transport/runtime/context。 授权和订阅成功与否均不作为工作流执行前提; 有效白名单只决定 OMC +是否允许额外的自动抢通操作。 ## 8. 验证和日志 ### 查看真实协商与协议日志 -直接运行本地 SpringSpnDemo,默认 City1 缺参并协商、City2 参数完整直接诊断,无需添加 VM 参数。 -Negotiation-T 扩展激活本身不强制协商;这是本地 Demo 专门设置的场景,不是引擎默认行为。 -要关闭本地缺参演示、让两城市都直接诊断,在 IDEA **VM options** 增加: +直接运行本地 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,两种模式使用同一业务回调。 +可演示 City2 或两城市同时缺参。宿主仍保留各城市的正确输入,原投诉上下文不变。预期链路是 `DEMO_NEGOTIATION` → +`INPUT_REQUIRED / PROPOSE` +→ 集成方 onNegotiation → `ACCEPT` → 两城市完成 → 一次汇总。 非内嵌 OMC 模式默认不注入缺参,显式设置 +`-Da2at.samples.negotiation=true` 也会被拒绝。 Demo 将开关传入当前 Spring 应用实例,不设置或修改 JVM 全局开关,不影响其他宿主。 +协议日志在控制台及以运行目录为基准的 `logs/spn-demo.log`。 本分支使用直连模式。 -离线重复验证(两个命令顺序执行,避免端口冲突): +离线重复验证: ```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、平台或真实模型语义的验证。 +每个测试类覆盖无 VM 参数的默认单城市协商、显式关闭、显式开启及两城市同时缺参。测试使用离线 LLM provider,但实际运行当前 SDK +的 模板、校验和 HTTP/SSE;不是现网 OMC、平台或真实模型语义的验证。 -启动测试使用公开模拟凭据,不覆盖 SDK 模板或 schema。 -运行时固定测试端口需空闲;本地工作流完成后最多观察首次抢通通知十秒, +启动测试使用公开模拟凭据,不覆盖 SDK 模板或 schema。 运行时固定测试端口需空闲;本地工作流完成后最多观察首次抢通通知十秒, 没有抢通通知不改变工作流结果。认证和日志配置详见 [集成指南](INTEGRATION_GUIDE.md)。 ## 9. 示例业务侧的 A2A-T 1.1.0 调用参考 -引擎只调度最终 A2A 消息;下列生成、校验、填充和业务判断都在 samples 中完成。 -不是每个智能体都调用全部 API,也不是同一消息依次调用 FromText 和 FromData。 +引擎只调度最终 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,保持独立通知连接 | +| 业务位置 | 生成/校验入口 | 对结果的业务使用 | +|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------| +| 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, -不能等同于完全不调用模型。 +有结构化业务参数时使用上述 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。 +无论选择哪一种生成入口,都保留返回 MetadataContent 的完整 metadata,再通过 A2atMessages.from (generated, List.of (new +TextPart ("业务摘要"))) 转成引擎 MessageContent。 parts 必须有合法内容;摘要不是正式扩展正文,不能拿摘要替代校验输入。不要只复制 +promptText 而丢失 templateUri、negotiationContext 或扩展 URI。 ### 信息协商的数据与失败边界 本场景按 SDK 投诉模板的业务字段协商:任务对象中包含本地市接入端口,任务上下文中包含投诉分类、 -OSS侧事件流水号及已有的时间和详情。已验证任务只有部分字段不足时仅请求这些字段; -SDK 拒绝整个原始输入时不信任其中的局部内容,重新请求完整业务字段。当前请求项采用 AND,所有请求字段必需。 +OSS侧事件流水号及已有的时间和详情。已验证任务只有部分字段不足时仅请求这些字段; SDK 拒绝整个原始输入时不信任其中的局部内容,重新请求完整业务字段。当前请求项采用 +AND,所有请求字段必需。 -接收 Propose 的参数 schema 中,items 是非空字段名数组,relationship 是可空逻辑关系字符串; -其 description 说明“实际请求字段”和 AND/OR 含义。Accept 校验 schema 则按本轮请求动态生成, -字段为非空字符串并列入 required。SpnTaskInput 的端口、流水号等规则仅是 SPN 示例业务规则, -不是通用执行引擎的输出格式约束;正式 OMC 应接入自身资源清单与领域校验。 +接收 Propose 的参数 schema 中,items 是非空字段名数组,relationship 是可空逻辑关系字符串; 其 description 说明“实际请求字段”和 +AND/OR 含义。Accept 校验 schema 则按本轮请求动态生成, 字段为非空字符串并列入 required。SpnTaskInput 的端口、流水号等规则仅是 +SPN 示例业务规则, 不是通用执行引擎的输出格式约束;正式 OMC 应接入自身资源清单与领域校验。 回复必须匹配 A2A taskId/contextId 及协商 id/round/maxRounds,正式正文从 Negotiation-T metadata 读取。 -一次有效回复只消费一次。错误正文、漏填、外地市端口或跨协商回复不能进入诊断。 -Reject 表示业务拒绝提供;Abort 表示无法核实信息或终止协商,均不是诊断成功。 +一次有效回复只消费一次。错误正文、漏填、外地市端口或跨协商回复不能进入诊断。 Reject 表示业务拒绝提供;Abort +表示无法核实信息或终止协商,均不是诊断成功。 + +默认 NegotiationStrategy 允许提供已掌握的当前任务字段;无法核实时生成 Abort,不编造字段。 宿主可通过 mayProvideField (task, +fieldName) 注入披露策略,拒绝时由示例生成 Reject: -默认 NegotiationStrategy 允许提供已掌握的当前任务字段;无法核实时生成 Abort,不编造字段。 -宿主可通过 mayProvideField(task, fieldName) 注入披露策略,拒绝时由示例生成 Reject: ```java var strategy = new dev.openan.workflow.engine.examples.negotiation.NegotiationStrategy(envPath) { @Override @@ -259,29 +228,27 @@ var strategy = new dev.openan.workflow.engine.examples.negotiation.NegotiationSt }; 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、未启动方案均不能触发关闭。不完整事件仍交给通知回调观察。 +诊断结果、授权回执、业务抢通事件由 OMC 业务生成。1.1.0 的上述授权/通知入口面向授权操作和订阅请求, 不能把抢通事件再次交给订阅请求生成/校验接口。示例 +RecoveryNotification 按 SPN 业务事件格式检查正式 Notification-T artifact metadata:包含任务流水号、OSS流水号、端口、方案及完成结果等必要字段且状态为已结束, +才确认收到最终结果并关闭该订阅;成功和失败都是“执行已结束”,不把失败结果冒充成功。 单独 artifact 名称、parts 摘要、订阅 +ACK、未启动方案均不能触发关闭。不完整事件仍交给通知回调观察。 -City1 上报的端口和 OSS 流水号来自本次已校验 SpnTaskInput,不再固定写成样例常量。 -City2 没有故障可以不产生抢通结果;授权/订阅失败和是否收到抢通通知,均不决定工作流诊断是否启动或成功。 +City1 上报的端口和 OSS 流水号来自本次已校验 SpnTaskInput,不再固定写成样例常量。 City2 +没有故障可以不产生抢通结果;授权/订阅失败和是否收到抢通通知,均不决定工作流诊断是否启动或成功。 ### 可重复验证范围 -EmbeddedA2AServerTest 覆盖正常填充数据、Accept 填充数据保留、错误正文、漏填、跨协商回复、 -Reject 和 Abort;NegotiationStrategyTest 覆盖城市输入隔离、不可核实的 Abort 和业务拒绝的 Reject; -RecoveryNotificationTest 覆盖完整成功/失败结果、仅摘要、错误命名和不完整事件。 -SpringSpnDemoE2ETest 及 dev 的 SpringSpnDemoOrderE2ETest 均覆盖完整输入、City1 缺参、两城市同时缺参。 -完整业务抢通链路由 SpnCrossCityE2ETest 验证。 +EmbeddedA2AServerTest 覆盖正常填充数据、Accept 填充数据保留、错误正文、漏填、跨协商回复、 Reject 和 +Abort;NegotiationStrategyTest 覆盖城市输入隔离、不可核实的 Abort 和业务拒绝的 Reject; RecoveryNotificationTest +覆盖完整成功/失败结果、仅摘要、错误命名和不完整事件。 SpringSpnDemoE2ETest 覆盖完整输入、City1 缺参、两城市同时缺参。 完整业务抢通链路由 SpnCrossCityE2ETest 验证。 -这些测试用真实 SDK 1.1.0 的生成和校验管线、真实 A2A HTTP/SSE;Order 测试使用供应商 jar 和本地平台模拟器。 -离线 LLM provider 仅解析明确的样例输入,拒绝错误摘要/缺参,不代表真实模型的语义质量或现网联调已通过。 +这些测试用真实 SDK 1.1.0 的生成和校验管线、真实 A2A HTTP/SSE。 离线 LLM provider +仅解析明确的样例输入,拒绝错误摘要/缺参,不代表真实模型的语义质量或现网联调已通过。 ### 授权策略格式与协商字段(SDK 1.1.0) @@ -292,21 +259,17 @@ SpringSpnDemoE2ETest 及 dev 的 SpringSpnDemoOrderE2ETest 均覆盖完整输入 2. 业务场景是视频保障,处置类型是故障切换,操作名称是频谱重耕,有效期是永久生效 ``` -OMC 示例仅从 SDK validate-and-fill 的数据构造白名单,逐字段提取标签后的值, -按业务场景、处置类型、操作名称精确匹配,有效期包含起止日。全部规则解析成功才替换原策略; +OMC 示例仅从 SDK validate-and-fill 的数据构造白名单,逐字段提取标签后的值, 按业务场景、处置类型、操作名称精确匹配,有效期包含起止日。全部规则解析成功才替换原策略; 任意一条非法均保持原策略,不降级成默认授权。 -示例只保存一组内存白名单(固定示例 ID),不代表生产策略库: -删除请求使用 `1. 策略标识是<当前策略UUID>`;仅支持单个精确 ID,不支持批量或条件删除。 -查询省略策略列表或传空字符串,返回全部当前规则;不支持带条件查询,不会静默忽略条件。 +示例只保存一组内存白名单(固定示例 ID),不代表生产策略库: 删除请求使用 `1. 策略标识是<当前策略UUID>`;仅支持单个精确 +ID,不支持批量或条件删除。 查询省略策略列表或传空字符串,返回全部当前规则;不支持带条件查询,不会静默忽略条件。 修改、条件查询、批量/条件删除需由集成方的策略库实现。该业务限制不代表 A2A-T SDK 不支持这些操作。 信息协商仍先调用 SDK `validateProposePromptAndDataFilling`。当前中文信息协商模板由 typed API 生成,示例从已通过语义校验的“所需信息项”中保留编号后的原始名称(如“任务对象”)用于回复, -不把模型提取出的说明文字(如“本地市实际接入端口名称”)当成新字段,也不模糊映射到其他数据。 -未知字段或本地数据缺失时 Abort,业务披露策略拒绝时 Reject;不能用其他地市的信息补齐。 -这些处理均在 samples 的业务层,不进入通用引擎。 +不把模型提取出的说明文字(如“本地市实际接入端口名称”)当成新字段,也不模糊映射到其他数据。 未知字段或本地数据缺失时 +Abort,业务披露策略拒绝时 Reject;不能用其他地市的信息补齐。 这些处理均在 samples 的业务层,不进入通用引擎。 -Notification-T 的失败原因字段使用完整名称“业务抢通方案执行失败原因”,执行失败时必填。 -SDK 校验拒绝时记录错误码、slot 错误和已提取参数的键名,方便定位;不绕过校验。 -授权、订阅的成功与否仍不阻断独立工作流。 +Notification-T 的失败原因字段使用完整名称“业务抢通方案执行失败原因”,执行失败时必填。 SDK 校验拒绝时记录错误码、slot +错误和已提取参数的键名,方便定位;不绕过校验。 授权、订阅的成功与否仍不阻断独立工作流。 diff --git a/docs/zh/DESIGN.md b/docs/zh/DESIGN.md index 0a79f90b..11377939 100644 --- a/docs/zh/DESIGN.md +++ b/docs/zh/DESIGN.md @@ -7,39 +7,99 @@ ## 1. 概述 -SDK 是集成在宿主智能体进程中的工作流协议调度库,不是独立业务平台。 -宿主提供最终内容,引擎管理 DAG、并行调度、标准 A2A 信封、认证、传输、等待和结果关联。 -业务回调决定是否调用 A2A-T 的自然语言/结构化内容接口,模板、schema、LLM 与语义校验均归宿主。 +SDK 是集成在宿主智能体进程中的工作流协议调度库,不是独立业务平台。 宿主提供最终内容,引擎管理 DAG、并行调度、标准 A2A +信封、认证、传输、等待和结果关联。 业务回调决定是否调用 A2A-T 的自然语言/结构化内容接口,模板、schema、LLM 与语义校验均归宿主。 引擎只通过 a2a-t-core 读取规范协商上下文和复制扩展 metadata,不包含内容生成管线。 -| 引擎职责 | 宿主职责 | -|---|---| -| DAG、任务/会话关联、并行与路由校验 | 内容生成、业务路由决策、本地汇总 | -| 按 contextFrom 选择完整上游结果 | 决定如何使用这些来源、映射下游输入 | -| A2A 认证与传输机制 | 凭据来源、AuthProvider、部署配置 | +| 引擎职责 | 宿主职责 | +|--------------------------------------|----------------------------------------| +| 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。这两项是样例替代路径,不代表生产环境的数据来源或容灾策略。 -图的可编辑源文件见 +```mermaid +%%{ + 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["宿主智能体:集成方 / Integrator 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 +``` + +上图中的 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)。 --- @@ -61,21 +121,18 @@ graph TD ### 3.1 Layer 0 - 通信层 -A2ATransport 使用 A2A SDK 的 REST、JSON-RPC、gRPC 绑定,负责认证头、实际传输和完整响应组装。 -WorkflowEngineClient 接收 MessageContent 并管理远端任务和必要的协商续发。 -ExtensionSender 负责流程外 sendAuthorization 与 openNotification。 -三种协议操作复用实现而不共用 transport/runtime/context 实例。 -ProtocolResponses 按 artifact 身份合并流式增量,ReceivedMessage 保留各层 metadata。 +A2ATransport 使用 A2A SDK 的 REST、JSON-RPC、gRPC 绑定,负责认证头、实际传输和完整响应组装。 WorkflowEngineClient 接收 +MessageContent 并管理远端任务和必要的协商续发。 ExtensionSender 负责流程外 sendAuthorization 与 openNotification。 +三种协议操作复用实现而不共用 transport/runtime/context 实例。 ProtocolResponses 按 artifact 身份合并流式增量,ReceivedMessage +保留各层 metadata。 ### 3.2 Layer 1 - 遍历层 **`WorkflowExecutor`** 遍历 DAG。在每个步骤按 `contextFrom` 选择强类型上游结果(`ContextBuilder`), -并发下发子任务,应用步骤成功策略,确定下一步。所有决策委托给 `ControlPoint`, -所有发送委托给 `WorkflowEngineClient`。 - -TaskRequest 当前输入与 workflowInput 分离。上游完整视图和便利 outputs 由 contextFrom 选择, -不会自动拼接、生成或套用业务 schema;业务自行决定怎么消费。详见 [回调契约](BUSINESS_CALLBACKS.md)。 +并发下发子任务,应用步骤成功策略,确定下一步。所有决策委托给 `ControlPoint`, 所有发送委托给 `WorkflowEngineClient`。 +TaskRequest 当前输入与 workflowInput 分离。上游完整视图和便利 outputs 由 contextFrom 选择, 不会自动拼接、生成或套用业务 +schema;业务自行决定怎么消费。详见 [回调契约](BUSINESS_CALLBACKS.md)。 - 前驱步骤全部完成的步骤被收集并并行下发 - 同一层的步骤并发执行 @@ -85,8 +142,8 @@ TaskRequest 当前输入与 workflowInput 分离。上游完整视图和便利 o ### 3.3 Layer 2 - 编排层 -**`ExecutePsop`** 是高层运行器。包装执行器,提供生命周期管理(启动/完成/错误/关闭)、 -事件序列化、客户端断连取消、`onFinish` 持久化钩子。大多数集成使用这一层。 +**`ExecutePsop`** 是高层运行器。包装执行器,提供生命周期管理(启动/完成/错误/关闭)、 事件序列化、客户端断连取消、`onFinish` +持久化钩子。大多数集成使用这一层。 --- @@ -101,9 +158,8 @@ interface ControlPoint { } ``` -onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 -onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 -未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 onSelfTask 返回本地 TaskResult;onRoute +选择允许的候选;onNegotiation 返回 Send 或 Stop。 未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 ## 5. A2A-T 扩展模型 @@ -112,21 +168,19 @@ Task-T:宿主生成最终内容,引擎只封装并发送。AgentCard 声明 Negotiation-T: -只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 -终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 -宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 -通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; -结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 +只有远端 `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 确认,也不能判为诊断成功。 +返回 `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 自动抢通,订阅保持到业务主动关闭。 +Authorization-T 和 Notification-T 是独立业务操作,不属于 DAG。宿主生成内容并使用独立发送器;失败不影响工作流。白名单仅影响 +OMC 自动抢通,订阅保持到业务主动关闭。 ## 6. 条件路由 @@ -173,9 +227,9 @@ sequenceDiagram 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->>H: Validate proposal and generate reply H-->>E: Send(MessageContent) or local Stop - E->>A: Same task/context; final reply content + E->>A: Same task/context with final reply content end A-->>E: Task result / artifacts E->>H: onSelfTask(selected complete upstream results) @@ -204,16 +258,11 @@ sequenceDiagram ## 9. 依赖 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,样例不覆盖同名资源。 +`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,样例不覆盖同名资源。 ## 10. 设计决策总结 -最终内容与协议调度分离,宿主不自行维护 A2A 信封。 -本地多输出和远端完整证据统一进入下游窗口,不丢失 metadata、不拍平数组。 -协商回复内容归业务,任务关联/去重/有界等待归引擎;本地 Stop 与协议 Abort 分离。 -独立授权和通知不成为工作流前提,直连与 dev 东信转发继续共享回调。 -协议日志在实际边界采集并强制脱敏,详情见 [集成指南](INTEGRATION_GUIDE.md)。 +最终内容与协议调度分离,宿主不自行维护 A2A 信封。 本地多输出和远端完整证据统一进入下游窗口,不丢失 metadata、不拍平数组。 +协商回复内容归业务,任务关联/去重/有界等待归引擎;本地 Stop 与协议 Abort 分离。 独立授权和通知不成为工作流前提,业务回调与传输实现分离。 协议日志在实际边界采集并强制脱敏,详情见 [集成指南](INTEGRATION_GUIDE.md)。 diff --git a/docs/zh/DEVELOPER_GUIDE.md b/docs/zh/DEVELOPER_GUIDE.md index a753eb03..202e0ee4 100644 --- a/docs/zh/DEVELOPER_GUIDE.md +++ b/docs/zh/DEVELOPER_GUIDE.md @@ -14,16 +14,16 @@ ``` -引擎会传递性引入 A2A 协议 SDK(`a2a-java-sdk-client`,含 REST、JSON-RPC、gRPC 传输) -和最小 A2A-T 核心(`a2a-t-core`)。生成内容的宿主显式依赖 a2a-t-client,OMC 接收端另引入 a2a-t-server。 +引擎会传递性引入 A2A 协议 SDK(`a2a-java-sdk-client`,含 REST、JSON-RPC、gRPC 传输) 和最小 A2A-T 核心(`a2a-t-core` +)。生成内容的宿主显式依赖 a2a-t-client,OMC 接收端另引入 a2a-t-server。 ## 2. 核心概念 -| 层级 | 入口 | 职责 | 你需要提供 | -|---------|-------------------------|-------------------------------------------|-------------------------------------| -| 2(高) | `ExecutePsop.builder()` | 事件收集、生命周期、onFinish | ControlPoint + AgentCards + 配置 | +| 层级 | 入口 | 职责 | 你需要提供 | +|---------|-------------------------|---------------------------------------------|----------------------------------------| +| 2(高) | `ExecutePsop.builder()` | 事件收集、生命周期、onFinish | ControlPoint + AgentCards + 配置 | | 1(中) | `WorkflowExecutor` | DAG 遍历、上下文、下发(onTask/onSelfTask) | ControlPoint + EngineClient + Workflow | -| 0(低) | `WorkflowEngineClient` | A2A 发送、响应提取 | AgentCards + A2AJavaClientRuntime | +| 0(低) | `WorkflowEngineClient` | A2A 发送、响应提取 | AgentCards + A2AJavaClientRuntime | ## 3. 实现 ControlPoint @@ -36,9 +36,8 @@ interface ControlPoint { } ``` -onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 -onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 -未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 onSelfTask 返回本地 TaskResult;onRoute +选择允许的候选;onNegotiation 返回 Send 或 Stop。 未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 ```java @@ -83,26 +82,26 @@ ExecutionResult result = ExecutePsop.builder() 事件来自三层:运行器(生命周期括号)、执行器(步骤/任务/路由)、引擎客户端(智能体流量、协商)。 -| 事件 | 层级 | 触发时机 | 关键数据 | -|-------------------------|-------------|------------------------------------------------|-------------------------------------------------------| -| `start` | 运行器 | 工作流开始 | `workflow`、`steps` | -| `step_start` | 执行器 | 步骤开始 | `step` | -| `task_request` | 执行器 | 子任务下发到 `onTask`/`onSelfTask` | `step`、`agent`、`task` | -| `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` | 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` | 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` | 运行器 | 清理完成 | (空) | +| 事件 | 层级 | 触发时机 | 关键数据 | +|-------------------------|----------------|----------------------------------------------------|-------------------------------------------------------| +| `start` | 运行器 | 工作流开始 | `workflow`、`steps` | +| `step_start` | 执行器 | 步骤开始 | `step` | +| `task_request` | 执行器 | 子任务下发到 `onTask`/`onSelfTask` | `step`、`agent`、`task` | +| `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` | 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` | 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` | 运行器 | 清理完成 | (空) | ## 6. 中间层(Layer 1: WorkflowExecutor) @@ -126,35 +125,32 @@ try (var client = new DefaultWorkflowEngineClient(agentCards, a2aRuntime, ### 6.1 协商自动循环 -只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 -终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 -宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 -通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; -结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 +只有远端 `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 确认,也不能判为诊断成功。 +返回 `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 工作流模型字段 -| 字段 | 位置 | 含义 | -|------------------------|---------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| -| `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` | 编排层级提示;实际就绪条件由 DAG 前驱关系决定。 | -| `steps[].contextFrom` | `WorkflowStep` | 选择传入 `workflowInput.upstreamResults` 的步骤;省略 = 直接前驱,`[]` = 不聚合,`"*"` = 所有祖先,或显式指定祖先名称。 | +| 字段 | 位置 | 含义 | +|-----------------------|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `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` | 编排层级提示;实际就绪条件由 DAG 前驱关系决定。 | +| `steps[].contextFrom` | `WorkflowStep` | 选择传入 `workflowInput.upstreamResults` 的步骤;省略 = 直接前驱,`[]` = 不聚合,`"*"` = 所有祖先,或显式指定祖先名称。 | ### 6.3 AgentCard 类型 Java SDK 全程使用 `org.a2aproject.sdk.spec.AgentCard`(强类型 record)。 -`RegistryClient.fetchAgentCards()` 返回 `List>`(从 OpenAPI 格式规范化)。 -使用 `AgentCardJacksonModule` 和 Jackson 将 JSON 反序列化为 `AgentCard`: +`RegistryClient.fetchAgentCards()` 返回 `List>`(从 OpenAPI 格式规范化)。 使用 +`AgentCardJacksonModule` 和 Jackson 将 JSON 反序列化为 `AgentCard`: ```java ObjectMapper mapper = new ObjectMapper() @@ -187,7 +183,7 @@ AgentCard card = mapper.readValue(json, AgentCard.class); ``` 密码支持 AES-GCM 加密(`enc::` 前缀)。解密密钥从 `A2AT_CRED_KEY` -(环境变量或系统属性,由 `EnvFileLoader` 从 `.env` 加载)读取。 +(显式实例配置 > OS 环境变量 > JVM 属性)读取;集成方负责配置加载,引擎不自动读取 `.env`。 ### 7.2 自定义 AuthProvider @@ -204,17 +200,17 @@ WorkflowEngineClientConfig.builder() ### 7.3 凭证文件字段 -| 字段 | 必填 | 默认值 | 描述 | -|----------------------|------|--------------------|-----------------------------| -| `login_url` | 是 | - | 获取 access token 的 URL | -| `method` | 否 | `POST` | HTTP 方法 | -| `content_type` | 否 | `application/json` | 内容类型 | -| `request_fields` | 否 | - | 请求体字段(覆盖 username/password)| -| `token_field` | 否 | `accessSession` | 点分隔的 token 路径 | -| `token_ttl` | 否 | `3600` | Token 缓存 TTL(秒) | -| `auth_header` | 否 | `Authorization` | 自定义 Header 名 | -| `auth_header_prefix` | 否 | (空) | Token 前的前缀 | -| `accept_header` | 否 | - | 自定义 Accept Header | +| 字段 | 必填 | 默认值 | 描述 | +|----------------------|------|--------------------|--------------------------------------| +| `login_url` | 是 | - | 获取 access token 的 URL | +| `method` | 否 | `POST` | HTTP 方法 | +| `content_type` | 否 | `application/json` | 内容类型 | +| `request_fields` | 否 | - | 请求体字段(覆盖 username/password) | +| `token_field` | 否 | `accessSession` | 点分隔的 token 路径 | +| `token_ttl` | 否 | `3600` | Token 缓存 TTL(秒) | +| `auth_header` | 否 | `Authorization` | 自定义 Header 名 | +| `auth_header_prefix` | 否 | (空) | Token 前的前缀 | +| `accept_header` | 否 | - | 自定义 Accept Header | ## 8. SSL / TLS @@ -229,13 +225,11 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() ## 9. A2A-T 环境(.env) -引擎不读取 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,不覆盖模板,也不是生产失败兜底。 +引擎不读取 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. 集成模式 @@ -268,8 +262,9 @@ public Flux execute(@PathVariable String psopId) { ### 取消 -`ExecutePsop.builder().execute()` 返回 `CompletableFuture`。可以 `cancel(true)`, -但内部执行器不会主动中断运行中的 A2A 调用。对于 SSE,丢弃订阅者,让 future 自然完成。 +`ExecutePsop.builder().execute()` 返回 `CompletableFuture`。`cancel(true)` 停止后续本地调度、忽略迟到回调并清理执行器拥有的资源。 +集成方需要自行取消其 LLM/业务操作;已提交到远端的任务可能需要显式 cancelTask,本地取消不等于协议 Abort 或业务回滚。 +`get(timeout)` 超时不会自动取消 Future,应在 finally 中显式取消未完成的执行。取消不会关闭独立授权/订阅通道。 ## 11. 检查清单 @@ -283,14 +278,12 @@ public Flux execute(@PathVariable String psopId) { ### 协议日志 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`。 +协议日志默认以 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 3b194f88..620fc21c 100644 --- a/docs/zh/INTEGRATION_GUIDE.md +++ b/docs/zh/INTEGRATION_GUIDE.md @@ -28,6 +28,12 @@ A2A-T 工作流执行引擎是一个 Java SDK,用于基于 A2A 协议和 A2A-T 整个集成过程分四步:定义工作流 -> 加载 AgentCard -> 实现 ControlPoint -> 执行。 +完整可运行源码:[HostQuickStart.java](../../samples/src/main/java/dev/openan/workflow/engine/examples/demo/HostQuickStart.java)。 +该源码参与编译,远端任务→本地汇总流程由 HostQuickStartTest 验证。 +可复制到集成方工程,或在 IDEA 的 samples 模块运行,传入注册中心 URL、目标 AgentCard 名称、凭证路径。 +下列片段解释同一套 API;AgentCard 加载方式二选一,Task-T 内容生成需要由业务实现。 +非空业务条件必须实现 onRoute 策略;这里使用无条件边,不依赖默认选路。 + ### 4.1 定义工作流 ```java @@ -45,7 +51,7 @@ Workflow workflow = Workflow.builder() .next(List.of( JumpCondition.builder() .step("merge") - .condition("success") + .condition("") .build())) .layer(0) .build(), @@ -61,7 +67,7 @@ Workflow workflow = Workflow.builder() .next(List.of( JumpCondition.builder() .step("end") - .condition("success") + .condition("") .build())) .layer(1) .contextFrom(List.of("*")) @@ -81,9 +87,17 @@ AgentCard card = mapper.readValue( // 方式二:从注册中心拉取 RegistryClient registry = new RegistryClient("https://127.0.0.1:5000", false); -List> cards = registry.fetchAgentCards(); +ObjectMapper cardMapper = new ObjectMapper().registerModule(new AgentCardJacksonModule()); +List cards = registry.fetchAgentCards().stream() + .map(raw -> cardMapper.convertValue(raw, AgentCard.class)).toList(); ``` + + +RegistryClient 默认设置覆盖正文读取的 30 秒截止时间;可使用 +`new RegistryClient(url, true, Duration.ofSeconds(15))` 设置正值预算。编排中心 query token 日志仅显示 ``, +集成方也不要自行打印带凭据的原始 URL。 + ### 4.3 实现 ControlPoint ```java @@ -95,9 +109,8 @@ interface ControlPoint { } ``` -onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 -onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 -未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 onSelfTask 返回本地 TaskResult;onRoute +选择允许的候选;onNegotiation 返回 Send 或 Stop。 未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 ```java @@ -117,10 +130,10 @@ ControlPoint callbacks = ControlPoint.builder() ### 4.4 执行 ```java -ExecutionResult result = ExecutePsop.builder() +CompletableFuture execution = ExecutePsop.builder() .psop(workflow) - .agentCards(List.of(card1, card2)) - .controlPoint(new MyControlPoint()) + .agentCards(cards) + .controlPoint(callbacks) .runtimeIntent("SPN跨城专线故障诊断与抢通") .lang("zh") .credentialsConfigPath("credentials.json") @@ -128,8 +141,13 @@ ExecutionResult result = ExecutePsop.builder() .onFinish((r, history) -> { System.out.println("执行结果: " + r.isSuccess()); }) - .execute() - .get(10, TimeUnit.MINUTES); + .execute(); +try { + ExecutionResult result = execution.get(10, TimeUnit.MINUTES); + System.out.println(result.getStepOutputs()); +} finally { + if (!execution.isDone()) execution.cancel(true); +} ``` 必填项:`psop`、`controlPoint`。其余配置项都有默认值。 @@ -138,13 +156,11 @@ ExecutionResult result = ExecutePsop.builder() ### 5.1 .env 文件 -引擎不读取 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,不覆盖模板,也不是生产失败兜底。 +引擎不读取 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 凭证配置文件 @@ -195,15 +211,21 @@ openssl rand -hex 32 A2AT_CRED_KEY=4f8a2b1c3d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b ``` +实例隔离加密可调用 `CredentialCrypto.encrypt(plaintext, keyHex)`;显式 key 优先且不会修改 JVM 全局属性。不要记录明文或密钥。 + **加密密码** +先执行 `mvn -pl workflow-engine -am package`,以下命令在仓库根目录运行,仅需 SDK jar 和 JDK。 +`set` 是 Windows cmd 语法,PowerShell 应使用 `$env:A2AT_CRED_KEY='...'`。 +以下参数仅用于演示;命令行口令/密钥可能进入终端历史和进程参数。生产应由集成方安全读取密钥并调用 Java 加密 API。 + ```bash # 方式一:先设置环境变量 set A2AT_CRED_KEY=4f8a2b1c3d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b -java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" +java -cp workflow-engine/target/workflow-engine-1.0.0.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" # 方式二:密钥作为第二个参数 -java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" 4f8a2b1c3d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b +java -cp workflow-engine/target/workflow-engine-1.0.0.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" 4f8a2b1c3d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b ``` 输出: @@ -217,14 +239,15 @@ enc:uHQcTeKZMVNRM9Ga:o5vm4weRozBXBs04phrLq7j7+/yRVyDsrw== **更换密钥** 1. 生成新密钥:`openssl rand -hex 32` -2. 更新 `.env` 中的 `A2AT_CRED_KEY` -3. 用新密钥重新加密所有密码:`java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "明文密码" 新密钥` +2. 更新集成方密钥存储及显式 `credentialEncryptionKey`,或 OS/JVM 的 `A2AT_CRED_KEY`;引擎不自动加载 `.env` +3. 用新密钥重新加密所有密码:`java -cp workflow-engine/target/workflow-engine-1.0.0.jar dev.openan.workflow.engine.client.CredentialCrypto "明文密码" 新密钥` 4. 将新的 `enc:...` 结果更新到凭证 JSON 文件 > `.env` 文件不应提交到版本库,建议加入 `.gitignore`。 + ### 5.3 自定义认证(AuthProvider) -当令牌由工作台或外部认证服务获取,或使用非标准认证方式时,实现 `AuthProvider` 接口。接口只有一个方法: +当令牌由集成方或外部认证服务获取,或使用非标准认证方式时,实现 `AuthProvider` 接口。接口只有一个方法: ```java public interface AuthProvider { @@ -283,10 +306,12 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() **注意事项:** - `applyAuth` 每次发消息都会调用,内部可自行实现 token 缓存和刷新逻辑 -- `securitySchemes` 表示智能体支持的认证方式;`securityRequirements` 表示当前对接强制要求的认证方式。`securityRequirements` 为空时不启用内置凭证认证,但 `AuthProvider` 仍会被调用 +- `securitySchemes` 表示智能体支持的认证方式;`securityRequirements` 表示当前对接强制要求的认证方式。 + `securityRequirements` 为空时不启用内置凭证认证,但 `AuthProvider` 仍会被调用 - 只配置 `AuthProvider` 时,它可以作为唯一认证来源,即使 `securityRequirements` 非空 - 如果同时配了凭证文件和 `AuthProvider`,两者分别生成 Header 后合并;同名不同值会 fail-fast - 认证失败时(如 token 获取异常),抛出的异常会传播到 `send()` 方法,请求会被拦截,不会发出 + ## 6. AgentCard 定义 AgentCard 通过 `capabilities.extensions` 声明扩展点: @@ -344,23 +369,21 @@ AgentCard 通过 `capabilities.extensions` 声明扩展点: 扩展 URI 必须与 A2A-T 定义完全一致。 -`securitySchemes` 与 `securityRequirements` 都是可选字段。前者表示智能体支持的认证方式,后者表示当前对接强制要求的认证方式;`securityRequirements: []` 表示不启用内置凭证认证。 +`securitySchemes` 与 `securityRequirements` 都是可选字段。前者表示智能体支持的认证方式,后者表示当前对接强制要求的认证方式; +`securityRequirements: []` 表示不启用内置凭证认证。 ## 7. A2A-T 扩展能力 -只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 -终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 -宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 -通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; -结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 +只有远端 `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 确认,也不能判为诊断成功。 +返回 `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 CompletableFuture sendAuthorization(String agentName, MessageContent content); @@ -368,7 +391,9 @@ NotificationSubscription openNotification(String agentName, MessageContent conte BiConsumer listener); ``` -宿主生成最终 Authorization-T/Notification-T 内容后调用上述接口;使用三类独立 transport/runtime/context。订阅监听器收到 handle 与完整 ReceivedMessage,按业务收到抢通结果后关闭。handle.acknowledgement() 和 completion() 分别表示 ACK 和真实流退出,不用它们的成功作为工作流前提。 +宿主生成最终 Authorization-T/Notification-T 内容后调用上述接口;使用三类独立 transport/runtime/context。订阅监听器收到 +handle 与完整 ReceivedMessage,按业务收到抢通结果后关闭。handle.acknowledgement () 和 completion () 分别表示 ACK +和真实流退出,不用它们的成功作为工作流前提。 ## 8. HTTPS 配置 @@ -387,22 +412,17 @@ NotificationSubscription openNotification(String agentName, MessageContent conte .crlPath("/path/to/revocations.crl") ``` -HTTP/JSON-RPC 的 TLS 策略只作用于当前客户端,不修改 JVM 全局主机名校验设置;关闭证书链校验时仍会加载 mTLS -客户端身份。生产环境应保持 `sslVerify(true)`;自签证书通过 `caCertsPath` 建立信任。默认 gRPC runtime 在 +HTTP/JSON-RPC 的 TLS 策略只作用于当前客户端,不修改 JVM 全局主机名校验设置;关闭证书链校验时仍会加载 mTLS 客户端身份。生产环境应保持 +`sslVerify(true)`;自签证书通过 `caCertsPath` 建立信任。默认 gRPC runtime 在 `sslVerify(false)` 时使用 plaintext,因此不能同时配置 mTLS 或 `crlPath`,这些组合会 fail-fast。 ## 9. 日志 -将 `PROTOCOL` logger 设为 DEBUG,可查看实际传输边界的观测记录。 -HTTP/JSON-RPC 记录 A2A SDK 处理后的实际序列化正文和应用头; -A2A-Version 只在真实请求有该头时出现,不为日志展示补造 Header。 -gRPC 记录实际 metadata 和 protobuf 的 JSON 展示,不伪装成 HTTP JSON 报文。 -JDK 自动网络头、HTTP/2 帧、TLS 密文及服务端字节不在此观测范围。 +将 `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` 只是高层预览,默认关闭,不是协议证据。 +`MODEL_PREVIEW` 只是高层预览,默认关闭,不是协议证据。 ```properties logger.protocol.name=PROTOCOL @@ -412,43 +432,37 @@ 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。 +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** 增加: +直接运行本地 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,两种模式使用同一业务回调。 +可演示 City2 或两城市同时缺参。宿主仍保留各城市的正确输入,原投诉上下文不变。预期链路是 `DEMO_NEGOTIATION` → +`INPUT_REQUIRED / PROPOSE` +→ 集成方 onNegotiation → `ACCEPT` → 两城市完成 → 一次汇总。 非内嵌 OMC 模式默认不注入缺参,显式设置 +`-Da2at.samples.negotiation=true` 也会被拒绝。 Demo 将开关传入当前 Spring 应用实例,不设置或修改 JVM 全局开关,不影响其他宿主。 +协议日志在控制台及以运行目录为基准的 `logs/spn-demo.log`。 本分支使用直连模式。 -离线重复验证(两个命令顺序执行,避免端口冲突): +离线重复验证: ```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、平台或真实模型语义的验证。 +每个测试类覆盖无 VM 参数的默认单城市协商、显式关闭、显式开启及两城市同时缺参。测试使用离线 LLM provider,但实际运行当前 SDK +的 模板、校验和 HTTP/SSE;不是现网 OMC、平台或真实模型语义的验证。 ## 10. 事件回调 @@ -491,7 +505,9 @@ Workflow workflow = LoadPsop.load( ## 12. 自定义扩展 -直接构造最终 MessageContent(parts, metadata, extensions),由宿主管理扩展内容的生成/校验。无需注册引擎 handler 或 SDK 实例。A2atMessages.from 是 A2A-T metadata 复制辅助;非 A2A-T 扩展也可直接提供 metadata 和激活 URI。引擎不会仅因 AgentCard 声明而自动生成内容。 +直接构造最终 MessageContent (parts, metadata, extensions),由宿主管理扩展内容的生成/校验。无需注册引擎 handler 或 SDK +实例。A2atMessages.from 是 A2A-T metadata 复制辅助;非 A2A-T 扩展也可直接提供 metadata 和激活 URI。引擎不会仅因 AgentCard +声明而自动生成内容。 ## 13. 你需要使用的接口一览 @@ -508,4 +524,4 @@ Workflow workflow = LoadPsop.load( | `LoadPsop` / `RegistryClient` | 工作流加载 / AgentCard 获取 | | `Workflow` / `WorkflowStep` / `Task` / `JumpCondition` | 工作流定义 | | `ExecutionResult` | 执行结果 | -| `SendMessageResult` / `TaskResult` | 消息/任务响应 | +| `SendMessageResult` / `TaskResult` | 消息/任务响应 | 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 ec5f6fbc..f11f9806 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,67 +1,64 @@ # 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 不是抢通结果。 +## 集成方业务边界(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 + 不是抢通结果。 ### 表格一:专线业务投诉诊断接口(核心诊断) -| 项目 | 详细说明 | -|:--------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **接口名称** | 专线业务投诉诊断接口 | -| **协议/模式** | A2A-T Task-T | -| **调用方向** | 工作台智能体 -> SPN Domain Agent(OMC) | -| **功能描述** | 工作台智能体下发诊断请求,OMC完成网络侧故障根因诊断并返回结果。 | +| 项目 | 详细说明 | +|:--------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **接口名称** | 专线业务投诉诊断接口 | +| **协议/模式** | A2A-T Task-T | +| **调用方向** | 集成方智能体 -> SPN Domain Agent(OMC) | +| **功能描述** | 集成方智能体下发诊断请求,OMC完成网络侧故障根因诊断并返回结果。 | | **请求参数** | **必填:**
1. 任务对象:业务名称、业务标识或接入端口三者至少一个
2. 投诉分类(专线质差 / 专线中断)
3. OSS 侧事件流水号

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

**请求内容:** 明确指出缺失/错误的参数及修正建议。 | -| **协商响应(工作台→OMC)** | 工作台智能体理解协商请求,返回强类型 Accept / Reject / Abort。Information Reject 必须对每个无法提供的请求信息项逐项说明原因,不能只返回一个汇总拒绝原因。 | -| **协商完成(OMC)** | OMC确认协商信息完成后,继续执行诊断任务,并最终返回诊断结果(同表格一响应内容)。 | +| 项目 | 详细说明 | +|:---------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **接口名称** | 专线业务投诉诊断协商接口 | +| **协议/模式** | A2A-T Negotiation-T | +| **调用方向** | SPN Domain Agent(OMC) <--> 集成方智能体(双向) | +| **功能描述** | 当OMC发现任务参数有误或缺失时,反向发起协商,请求集成方智能体补全或修正信息。 | +| **协商请求(OMC→集成方)** | **触发条件:**
1. 必选参数缺失(如任务对象、投诉分类或 OSS 侧事件流水号)
2. 参数取值错误(如投诉分类不在枚举范围内)
3. 参数语义校验错误(如应为逻辑口却下发了物理口)

**请求内容:** 明确指出缺失/错误的参数及修正建议。 | +| **协商响应(集成方→OMC)** | 集成方智能体理解协商请求,返回强类型 Accept / Reject / Abort。Information Reject 必须对每个无法提供的请求信息项逐项说明原因,不能只返回一个汇总拒绝原因。 | +| **协商完成(OMC)** | OMC确认协商信息完成后,继续执行诊断任务,并最终返回诊断结果(同表格一响应内容)。 | ### 表格三:业务抢通消息订阅与上报接口(结果通知) -| 项目 | 详细说明 | -|:---------------------------|:------------------------------------------------------------------------------------------------------------------------------| -| **接口名称** | 业务抢通消息订阅接口 / 消息上报 | -| **协议/模式** | A2A-T Notification-T | -| **功能描述** | 用于工作台订阅抢通结果,以及OMC通过SSE流主动上报抢通结果消息。 | -| **订阅请求(工作台→OMC)** | **包含内容:**
1. 消息主题
2. 上报消息数据格式(需包括:投诉事件流水号、分段业务标识、抢通方案执行结果、抢通方案详情) | -| **订阅响应(OMC→工作台)** | **成功:** Notification-T metadata 返回“订阅结果:成功”,任务进入 `WORKING`。
**失败:** 返回 `FAILED` 及失败原因。 | -| **消息上报(OMC→工作台)** | 基于订阅时指定的数据格式,通过 **SSE** 先推送 `recovery-plan`(未启动),白名单命中并执行后再推送 `recovery-result`(已结束);结果、取消或宿主关闭才结束通道。 | +| 项目 | 详细说明 | +|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **接口名称** | 业务抢通消息订阅接口 / 消息上报 | +| **协议/模式** | A2A-T Notification-T | +| **功能描述** | 用于集成方订阅抢通结果,以及OMC通过SSE流主动上报抢通结果消息。 | +| **订阅请求(集成方→OMC)** | **包含内容:**
1. 消息主题
2. 上报消息数据格式(需包括:投诉事件流水号、分段业务标识、抢通方案执行结果、抢通方案详情) | +| **订阅响应(OMC→集成方)** | **成功:** Notification-T metadata 返回“订阅结果:成功”,任务进入 `WORKING`。
**失败:** 返回 `FAILED` 及失败原因。 | +| **消息上报(OMC→集成方)** | 基于订阅时指定的数据格式,通过 **SSE** 先推送 `recovery-plan`(未启动),白名单命中并执行后再推送 `recovery-result`(已结束);结果、取消或宿主关闭才结束通道。 | ### 表格四:专线业务抢通授权接口(自动执行授权) -| 项目 | 详细说明 | -|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------| -| **接口名称** | 专线业务抢通授权接口 | -| **协议/模式** | A2A-T Authorization-T | -| **功能描述** | 工作台向 OMC 新增、修改、删除或查询抢通授权,OMC 据有效白名单决定是否自动执行抢通。 | -| **授权请求(工作台→OMC)** | **包含内容:**
1. 操作类型:新增/修改/删除/查询授权策略
2. SDK 模板所定义的策略列表或查询条件 | -| **授权响应(OMC→工作台)** | artifact 的 Authorization-T metadata 返回“授权操作执行结果”;新增/查询成功时还返回策略标识和策略列表,删除必须精确匹配策略标识。失败返回 `FAILED` 且保留原策略。 | -| **后置操作(OMC内部)** | 授权成功后,当OMC在执行诊断后的业务抢通时,若匹配到已授权项,则**自动执行**业务抢通,并依据**表格三**的订阅通道上报抢通结果消息。 | - +| 项目 | 详细说明 | +|:---------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **接口名称** | 专线业务抢通授权接口 | +| **协议/模式** | A2A-T Authorization-T | +| **功能描述** | 集成方向 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;引擎只管理会话关联和等待。 +多地市、多任务的输出按步骤分组保留于 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" deleted file mode 100644 index 09562b52..00000000 --- "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" +++ /dev/null @@ -1,762 +0,0 @@ -# 工作流执行引擎 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 节点。 - ---- - -## 1. 场景全景 - -### 1.1 业务拓扑 - -![工作流执行引擎与周边系统依赖架构](../images/workflow-engine-surrounding-systems.png) - -工作台是运行时编排宿主,执行引擎 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 交互 - -SDK 在该场景中承载四类 A2A-T 交互,对应业务流文档的四个接口: - -| 序号 | A2A-T 扩展 | 业务含义 | 发起方 | 时机 | -|------|-----------|---------|--------|------| -| 1 | Task-T | 下发诊断任务 | 工作台 -> OMC | 工作流执行中 | -| 2 | Negotiation-T | 参数修正协商 | OMC <-> 工作台 | OMC 发现参数缺失时反向发起 | -| 3 | Authorization-T | 抢通授权 | 工作台 -> OMC | 工作流启动前,前置预定位 | -| 4 | Notification-T | 抢通结果订阅/上报 | 工作台 -> OMC (订阅) / OMC -> 工作台 (SSE 上报) | 工作流启动前订阅,执行中上报 | - -序号 1、2 在工作流执行过程中发生,由 SDK 的 `WorkflowEngineClient` 承载。序号 3、4 是流程外的独立操作,Demo 只是在工作流启动前尝试,由 SDK 的 `ExtensionSender` 承载;这个时间顺序不构成因果依赖,授权或订阅失败均不得阻止后续 Task-T 工作流。各路径使用同一类底层传输组件 `A2ATransport`,但 runtime、context 和生命周期互相隔离:工作流发送使用任务级 transport,Notification-T 订阅使用工作台级 transport,避免工作流结束时关闭长连接。 - -### 1.3 SDK 自动处理 vs 工作台实现 - -| 引擎承担 | 工作台承担 | -|---|---| -| DAG、并行、任务关联、信封与传输 | onTask 返回最终 MessageContent | -| 上游来源选择、完整响应组装 | 根据业务需要映射上下游输入 | -| 协商关联、去重、等待 | SDK 生成/校验回复,返回 Send 或 Stop | -| 认证机制与 Header 注入 | 凭据来源或自定义 AuthProvider | -| 独立授权发送与订阅生命周期 | SDK 生成授权/订阅内容、处理业务结果 | - -A2A-T client/server、模板、schema、LLM 初始化属于宿主; -workflow-engine 仅依赖 a2a-t-core,不管理内容生成。 - -## 2. 集成全景图 - -工作台集成 SDK 共需完成六步。先总后分,每步后续均有详细说明和代码示例。 - -```mermaid -graph LR - S1["步骤 1
引入 Maven 依赖"] - S2["步骤 2
准备配置文件
AgentCard + 认证 + .env"] - S3["步骤 3
实现 AgentExecutor
A2A 服务端入口"] - S4["步骤 4
实现 ControlPoint
工作流决策回调"] - S5["步骤 5
组装运行管线
发现输入 -> 执行工作流 / 独立扩展"] - - S1 --> S2 --> S3 --> S4 --> S5 -``` - -职责分布: - -| 组件 | 工作台实现 | SDK 提供 | 说明 | -|------|-----------|---------|------| -| `AgentExecutor` | 是 | 否 | 工作台的 A2A 服务端入口,接收 上层系统 下发的任务 | -| `ControlPoint` | 是 (4 个方法) | 接口 + 默认实现 | 工作流决策: 任务下发、自处理、路由、协商 | -| `AuthProvider` | 是 (可选) | 接口 | 自定义认证头注入 (企业 SSO 等),与 credentials.json 二选一或组合 | -| `WorkbenchOrchestrator` | 是 | 否 | 组装编排管线的业务类 | -| `WorkflowEngineClient` | 否 | 是 | 工作流发送门面 (Task-T + 协商循环) | -| `ExtensionSender` | 否 | 是 | 前置预定位门面 (授权 + 订阅) | -| `A2ATransport` | 否 | 是 | 共享传输层 (HTTP + 认证 + SSE) | -| `ExecutePsop` | 否 | 是 | 高层运行器 (生命周期 + 事件 + 持久化) | - ---- - -## 3. 逐步集成 - -### 步骤 1: 引入 Maven 依赖 - -工作台项目 `pom.xml` 中添加: - -```xml - - dev.openan.workflow.sdk - workflow-engine - 1.0.0 - -``` - -如果工作台基于 Spring Boot 且需要 SDK 自动装配 A2A 服务端 (AgentCard 加载、REST 控制器、事件总线): - -```xml - - dev.openan.workflow.sdk - spring-boot-starter - 1.0.0 - -``` - -Spring Boot starter 会自动注册 `AgentCard`、`RequestHandler`、`RestHandler`、`A2AController` 等 Bean。工作台只需提供一个 `AgentExecutor` 实现类标注 `@Component` 即可。 - -### 步骤 2: 准备配置文件 - -集成需要三类配置文件。 - -#### 2.1 AgentCard (工作台自身的) - -工作台作为 A2A 服务端,需要声明自己的 AgentCard。该文件告知 上层故障系统如何调用工作台: - -```json -{ - "name": "Transport Workbench Agent", - "description": "传输工作台智能体: 接收上层诊断任务,编排多地市 OMC 执行诊断并汇总", - "version": "1.0.0", - "capabilities": { - "streaming": true, - "extensions": [ - {"uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1", "required": false}, - {"uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T", "required": false}, - {"uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1", "required": false}, - {"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": "跨城故障协同诊断", - "description": "协同多地市 OMC 执行专线故障诊断并汇总结果", - "tags": ["SPN", "fault-diagnosis"] - } - ], - "supportedInterfaces": [ - {"protocolBinding": "HTTP+JSON", "url": "https://工作台地址/a2a/json"} - ] -} -``` - -下游 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 或自定义认证头 | 提供 credentials.json 文件 | -| 自定义 AuthProvider | 工作台自管 Token、企业 SSO、非标准认证头,或经自定义网关/协议转发获取 OMC Token | 实现 `AuthProvider` 接口 | - -**合并规则:** 两种认证来源分别生成 Header 后合并。`AuthProvider` 可以作为唯一认证来源;如果两者对同名 Header 生成不同值,引擎会抛出 `SecurityException`。 - -##### 方式一: Credentials 认证 (credentials.json) - -SPN Domain Agent (OMC) 使用 Bearer 认证。SDK 的 `AgentAuthManager` 自动调用登录接口获取令牌并缓存刷新,工作台只需提供凭证文件: - -```json -{ - "SPN Domain Agent City1": { - "bearerAuth": { - "login_url": "https://omc-city1地址/rest/plat/smapp/v1/oauth/token", - "method": "PUT", - "request_fields": { - "grantType": "password", - "userName": "admin", - "value": "enc:加密后的密码", - "ipaddr": "*" - }, - "token_field": "accessSession", - "token_ttl": 3600 - } - }, - "SPN Domain Agent City2": { - "bearerAuth": { - "login_url": "https://omc-city2地址/rest/plat/smapp/v1/oauth/token", - "method": "PUT", - "request_fields": { - "grantType": "password", - "userName": "admin", - "value": "enc:加密后的密码", - "ipaddr": "*" - }, - "token_field": "accessSession", - "token_ttl": 3600 - } - } -} -``` - -键名必须与 AgentCard 的 `name` 字段一致。SDK 依据此键名匹配 AgentCard 与凭证。密码字段 `value` 使用 `enc:` 前缀标识加密内容,SDK 的 `CredentialCrypto` 自动解密 (密钥通过 `A2AT_CRED_KEY` 环境变量注入,32 字节 hex)。 - -**密码加密方法:** - -加密格式为 `enc::`,算法为 AES-256-GCM。SDK 提供两种加密方式: - -**方式一: 命令行工具 (推荐)** - -```bash -# 1. 生成 32 字节密钥 (仅首次,妥善保存) -openssl rand -hex 32 -# 输出示例: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2 - -# 2. 设置密钥环境变量 -set A2AT_CRED_KEY=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2 - -# 3. 加密密码 (输出 enc:... 格式,直接复制到 credentials.json) -java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" -# 输出: enc:xxxxxxxxxxxx:yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy - -# 也可以将密钥作为第二个参数传入 -java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" a1b2c3d4... -``` - -**方式二: 代码调用** - -```java -// 设置密钥后调用 -System.setProperty("A2AT_CRED_KEY", "a1b2c3d4e5f6..."); -String encrypted = CredentialCrypto.encrypt("Admin@123"); -// encrypted = "enc:xxxxxxxxxxxx:yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" -``` - -将输出的 `enc:...` 值直接填入 credentials.json 的 `value` 字段: - -```json -{ - "request_fields": { - "value": "enc:xxxxxxxxxxxx:yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" - } -} -``` - -运行时 SDK 从 `A2AT_CRED_KEY` 环境变量读取密钥自动解密。密钥来源优先级:显式 credentialEncryptionKey > 操作系统 A2AT_CRED_KEY > 同名 JVM 属性。引擎不自动读取宿主 LLM .env。 - -凭证配置支持的完整字段: - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `login_url` | String | 必填 | 登录接口地址 | -| `method` | String | `POST` | HTTP 方法 | -| `content_type` | String | `application/json` | 请求 Content-Type | -| `request_fields` | Map | - | 请求体字段 (替代 legacy 的 username/password) | -| `token_field` | String | `accessSession` | 从响应 JSON 提取令牌的路径 (支持点号嵌套,如 `data.access_token`) | -| `token_ttl` | int | 3600 | 令牌缓存有效期 (秒),到期前 60 秒自动刷新 | -| `auth_header` | String | `Authorization` | 自定义认证头名称 (设置后引擎改用该 Header 注入凭证) | -| `auth_header_prefix` | String | `Bearer ` | 自定义认证头前缀 (与 `auth_header` 配合使用) | - -##### 方式二: 自定义 AuthProvider - -当认证机制不属于标准 Bearer/API Key,或令牌来自外部身份提供商 (如企业 SSO) 时,实现 `AuthProvider` 接口: - -```java -public interface AuthProvider { - /** - * 为发送消息注入认证头。 - * - * @param agentName 目标 Agent 名称 (匹配 AgentCard.name) - * @param agentCard Agent 卡片 (securitySchemes 可能为空) - * @param headers 可变的请求头 Map,直接写入认证信息 - */ - void applyAuth(String agentName, AgentCard agentCard, Map headers); -} -``` - -通过 `WorkflowEngineClientConfig` 注册: - -```java -WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() - .authProvider((agentName, agentCard, headers) -> { - // 从企业 SSO 获取令牌 - String token = ssoClient.getToken(agentName); - headers.put("Authorization", "Bearer " + token); - }) - .sslVerify(sslVerify) - .build(); -``` - -`AuthProvider` 在**每次消息发送时**被调用,无论 AgentCard 是否声明了 `securitySchemes`。典型用途: - -- AgentCard 无 `securitySchemes` 但服务端仍要求认证 -- 认证机制非 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` 中。 - -##### 两种方式组合使用 - -如果部分 Agent 使用 credentials 认证、部分使用自定义认证,或需要在 credentials 基础上追加额外头: - -```java -WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() - .credentialsConfigPath(credentialsPath) // credentials 认证 - .authProvider((agentName, agentCard, headers) -> { - // 追加企业级追踪头 - headers.put("X-Trace-Id", TraceContext.current().getTraceId()); - }) - .build(); -``` - -`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 -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 Java AgentExecutor.execute(RequestContext, AgentEmitter) 接收 WAIMO, -并在自己的入口用 A2ATServer.validateTaskPromptAndDataFilling 校验 Task-T。 -metadata 中的 Task-T 正文与 templateUri 是内容入口,不用 parts 长短决定读取哪个正文。 -然后搜索工作流、获取 AgentCards、调用执行引擎,并通过 A2A emitter 返回 artifact/终态。 - -可编译的完整实现见 samples 的 SpringWorkbenchExecutor、WorkbenchTaskInputParser、 -WorkbenchOrchestrator;Spring starter 只提供协议入口与生命周期,不替宿主决定业务或模板。 -入口取消应停止对应本地执行并清理宿主资源;远端已提交任务需按业务策略显式取消。 -不要把错误或空结果伪装成成功诊断。 - -### 步骤 4: 实现 ControlPoint - -```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 生成及协商示例见 WorkbenchControlPoint / examples.negotiation.NegotiationStrategy;上述仅是普通 A2A 内容示例。 - -### 步骤 5: 组装编排管线 - -```java -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(); -} -``` - -workflow、cards、callbacks、intent 由宿主取得或构造。 -runtime 为 null 时使用默认直连实现;dev 的东信 runtime 只替换传输,不替换业务回调。 -上面 transport 仅属于这次工作流;授权和通知使用另外的实例。 -宿主自己的 A2ATClient 可在回调中复用,但要确保业务状态按 execution/task 隔离。 - -#### 5.1 前置预定位详解 - -```java -// 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)); -``` - -sdk、业务数据、schema、recordIndependent* 均由宿主提供,不是引擎隐式服务。 -内容生成失败也只记录本次独立操作失败,不能阻止任务 DAG。 -close() 幂等请求关闭;completion() 等待真实流退出。工作流完成不能关闭独立订阅。 -实际生命周期与异常处理见 samples 的 ExtensionPrePositioner / WorkbenchExtensionLifecycle。 - -#### 5.2 PSOP 工作流搜索与加载 - -工作流 (PSOP) 存储在编排中心。SDK 提供搜索和加载方法: - -```java -// 语义搜索: 根据原始意图匹配最相关的工作流 -List results = LoadPsop.search(orchUrl, messageText, 3, null, sslVerify); -String psopId = results.get(0).getWorkflowId(); - -// 加载完整工作流定义 -Workflow workflow = LoadPsop.load(orchUrl, psopId, null, sslVerify); -``` - -生产环境应明确编排中心不可用时的业务失败策略,不能默认执行可能过期的本地流程。 -`SpringSpnDemo` 为离线演示提供本地 PSOP fallback;接入方只有在完成版本一致性、审计和变更治理后, -才应自行设计受控的本地兜底。 - -#### 5.3 EventCallback -- 事件追踪 - -`ExecutePsop` 在执行过程中产出全量事件。工作台可实现 `EventCallback` 追踪执行进度、记录日志、向前端推送: - -```java -private EventCallback createLogCallback() { - return new EventCallback() { - @Override - public void onEvent(String type, Map data) { - switch (type) { - case EventType.START -> log.info("[开始] 工作流: {}", data.get("workflow")); - case EventType.STEP_START -> log.info("[步骤开始] {}", data.get("step")); - 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("exchange")); - case EventType.NEGOTIATION_RESOLVED -> - log.info("[协商完成] agent={}", data.get("agent")); - // 以下事件类型为预留,当前 SDK 版本不会触发 - // (独立操作不进入工作流事件流) - case EventType.AUTHORIZATION_REQUEST -> - log.info("[授权请求] agent={}", data.get("agent")); - case EventType.NOTIFICATION -> - log.info("[通知] agent={}", data.get("agent")); - case EventType.ROUTE_DECISION -> - log.info("[路由] {} -> {}", data.get("step"), data.get("next")); - case EventType.STEP_COMPLETE -> log.info("[步骤完成] {}", data.get("step")); - case EventType.COMPLETE -> log.info("[完成]"); - case EventType.ERROR -> log.error("[错误] {}", data.get("error")); - case EventType.CLOSE -> log.info("[关闭]"); - } - } - }; -} -``` - -事件类型完整列表见 SDK 的 `EventType` 常量类。关键事件: `start` -> `step_start` -> `agent_request` / `agent_response` (含 `negotiation_*`) -> `step_complete` -> `complete` / `error` -> `close`。 - -> **注意:** `authorization_request` 和 `notification` 事件类型在当前 SDK 版本中不会被触发 (独立操作不进入工作流事件流)。这些事件类型为未来扩展预留。独立授权通过 SendMessageResult,订阅通过 handle 的 ACK 和监听器取得响应,不经过事件流。 - ---- - -## 4. 完整调用时序 - -```mermaid -sequenceDiagram - 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 -``` - -```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 -``` - -只有远端 `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 确认,也不能判为诊断成功。 - -### 查看真实协商与协议日志 - -直接运行本地 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、平台或真实模型语义的验证。 - -## 5. 工作台需交付物清单 - -| 交付物 | 类型 | 说明 | -|--------|------|------| -| `TransportWorkbenchExecutor` | Java 类 | 实现 `AgentExecutor`,A2A 服务端入口 | -| `WorkbenchControlPoint` | Java 类 | 继承 `DefaultControlPoint`,实现 onTask/onSelfTask/onRoute/onNegotiation | -| `WorkbenchOrchestrator` | Java 类 | 组装编排管线 (加载卡片 -> 预定位 -> 执行) | -| `WorkbenchAuthProvider` (可选) | Java 类 | 实现 `AuthProvider`,自定义认证逻辑 (如企业 SSO) | -| `agentcard/transport_workbench_agent.json` | JSON | 工作台自身的 AgentCard | -| `spn_agent_credentials.json` (可选) | JSON | 下游 OMC 的认证凭证 (使用 Credentials 认证时需要) | -| `.env` | INI | A2A-T SDK 的 LLM 配置 | -| `application.yml` (Spring Boot) | YAML | `a2at.server.agent-card` 等配置项 | - ---- - -## 6. 生产环境注意事项 - -### 6.1 TLS 与证书 - -生产环境 OMC 通常使用自签名证书。`WorkflowEngineClientConfig` 中: -- `sslVerify(true)`: 校验证书 (需配置 CA 信任库 `caCertsPath`) -- `sslVerify(false)`: 仅跳过证书链校验,主机名校验仍启用;只用于受控的本地诊断 - (HTTP/JSON-RPC 会继续加载 mTLS 客户端证书;默认 gRPC 使用 plaintext,不能同时配置 mTLS/CRL) - -```java -WorkflowEngineClientConfig.builder() - .sslVerify(true) - .caCertsPath("/path/to/ca-certs.pem") - // OMC 要求双向 TLS 时配置: - .clientCertPath("/path/to/client-cert.pem") - .clientKeyPath("/path/to/client-key.pem") - .credentialsConfigPath(credentialsPath) - .build() -``` - -### 6.2 凭证加密与自定义认证 - -**Credentials 认证:** `spn_agent_credentials.json` 中的密码字段使用 `enc:` 前缀标识加密内容。SDK 的 `CredentialCrypto` 使用 AES-GCM 对称加密自动解密。加密密钥通过环境变量 `A2AT_CRED_KEY` 注入 (32 字节 hex)。生产环境不应明文存储密码。 - -**自定义 AuthProvider:** 如果工作台已有统一的认证体系 (如企业 SSO、OAuth2 网关),可直接实现 `AuthProvider` 接口,无需维护 credentials.json 文件。`AuthProvider` 在每次消息发送时调用,适合令牌生命周期由外部管理的场景: - -```java -public class SsoAuthProvider implements AuthProvider { - private final SsoClient ssoClient; - - @Override - public void applyAuth(String agentName, AgentCard agentCard, Map headers) { - String token = ssoClient.getAccessToken(agentName); - headers.put("Authorization", "Bearer " + token); - } -} -``` - -### 6.3 协商轮次限制 - -WorkflowEngineClientConfig.builder().maxNegotiationExchanges(3) 配置本地交互预算, -与收到的 SDK context.maxRounds 分开。耗尽/超时只本地失败,不自动生成 Abort。 -结束回复保留收到的 id/round/maxRounds,最后一轮仍可回答。 - -### 6.4 SSE 长连接管理 - -`sendNotification` 建立的 SSE 订阅通道是长连接。SDK 的 `A2ATransport` 管理连接池和超时。工作台应在服务关闭时调用 `extensionTransport.close()` 释放连接。`ExecutePsop` 在工作流结束后自动关闭 `engineClient` 及其任务级 transport;不要把这个 transport 与 `ExtensionSender` 共用,否则 Notification-T 长连接会在工作流结束时被关闭。 - -### 6.5 并发与并行 - -SDK 的 `WorkflowExecutor` 自动并行调度同一层的步骤 (无依赖关系的步骤并发执行)。步骤内的子任务也并行执行。工作台无需自行管理并发。如果 OMC 有并发限制,通过 DAG 依赖边或宿主限流控制;layer 不是执行顺序约束。 - -### 6.6 错误处理 - -引擎处理远端协议状态和结果解包;onTask 只返回 MessageContent。onSelfTask 返回 TaskResult,失败原因与 outputs 分离。异常和失败 future 由引擎记录;不要把未发送成功包装成成功输出。详见 [业务回调契约](BUSINESS_CALLBACKS.md)。 - -## 7. 接口速查表 - -```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 -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 的关键区别 - -本次集成是生产环境接入,与 Demo 示例代码的关键区别: - -| 维度 | Demo (samples) | 生产环境 | -|------|----------------|---------| -| AgentExecutor | 模拟实现,返回固定文本 | 对接真实 上层故障系统,需完整 A2A 消息时序 | -| OMC Agent | 模拟 Agent,预置响应 | 真实 SPN Domain Agent,Bearer 认证、真实诊断逻辑 | -| AgentCard | 从 classpath 加载 JSON | 从注册中心获取或厂商提供 | -| 认证 | credentials.json 明文密码 | 加密存储 (enc: 前缀) 或自定义 AuthProvider (企业 SSO) | -| TLS | sslVerify=false | 生产证书校验 | -| PSOP | fallback 固定 ID | 编排中心语义搜索 + 动态加载 | -| 事件 | 日志打印 | 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/\351\233\206\346\210\220\346\226\271\345\234\272\346\231\257\346\216\245\345\205\245\346\214\207\345\215\227.md" "b/docs/zh/\351\233\206\346\210\220\346\226\271\345\234\272\346\231\257\346\216\245\345\205\245\346\214\207\345\215\227.md" new file mode 100644 index 00000000..9e20cd08 --- /dev/null +++ "b/docs/zh/\351\233\206\346\210\220\346\226\271\345\234\272\346\231\257\346\216\245\345\205\245\346\214\207\345\215\227.md" @@ -0,0 +1,798 @@ +# 工作流执行引擎 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。 + +> 本文中的集成方是宿主智能体。代码示例保留 Workbench* 类名与 AgentCard 标识,便于对应真实源码;这些名称不构成 SDK 的业务绑定。 +> +> 面向集成方研发团队。本文档以 SPN 专线故障跨城协同诊断联创场景为例,从集成方视角说明接入 Workflow 执行引擎 SDK 的完整路径。 +> +> 业务基线:上游为 WAIMO;集成方接收 Task-T 后加载工作流,并行调度两地市 OMC, +> 两路完成后汇总一次并返回 WAIMO。Authorization-T 和 Notification-T 是集成方在 +> 独立时机触发的协议操作,不是工作流 DAG 节点。 + +--- + +## 1. 场景全景 + +### 1.1 业务拓扑 + +周边系统与 SDK 的可编辑架构图见 [架构设计](DESIGN.md)。 + +集成方是运行时编排宿主,执行引擎 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 交互 + +SDK 在该场景中承载四类 A2A-T 交互,对应业务流文档的四个接口: + +| 序号 | A2A-T 扩展 | 业务含义 | 发起方 | 时机 | +|------|-----------------|-------------------|-------------------------------------------------|------------------------------| +| 1 | Task-T | 下发诊断任务 | 集成方 -> OMC | 工作流执行中 | +| 2 | Negotiation-T | 参数修正协商 | OMC <-> 集成方 | OMC 发现参数缺失时反向发起 | +| 3 | Authorization-T | 抢通授权 | 集成方 -> OMC | 工作流启动前,前置预定位 | +| 4 | Notification-T | 抢通结果订阅/上报 | 集成方 -> OMC (订阅) / OMC -> 集成方 (SSE 上报) | 工作流启动前订阅,执行中上报 | + +序号 1、2 在工作流执行过程中发生,由 SDK 的 `WorkflowEngineClient` 承载。序号 3、4 是流程外的独立操作,Demo 只是在工作流启动前尝试,由 +SDK 的 `ExtensionSender` 承载;这个时间顺序不构成因果依赖,授权或订阅失败均不得阻止后续 Task-T 工作流。各路径使用同一类底层传输组件 +`A2ATransport`,但 runtime、context 和生命周期互相隔离:工作流发送使用任务级 transport,Notification-T 订阅使用集成方级 +transport,避免工作流结束时关闭长连接。 + +### 1.3 SDK 自动处理 vs 集成方实现 + +| 引擎承担 | 集成方承担 | +|---------------------------------|---------------------------------------| +| DAG、并行、任务关联、信封与传输 | onTask 返回最终 MessageContent | +| 上游来源选择、完整响应组装 | 根据业务需要映射上下游输入 | +| 协商关联、去重、等待 | SDK 生成/校验回复,返回 Send 或 Stop | +| 认证机制与 Header 注入 | 凭据来源或自定义 AuthProvider | +| 独立授权发送与订阅生命周期 | SDK 生成授权/订阅内容、处理业务结果 | + +A2A-T client/server、模板、schema、LLM 初始化属于宿主; workflow-engine 仅依赖 a2a-t-core,不管理内容生成。 + +## 2. 集成全景图 + +集成方集成 SDK 共需完成六步。先总后分,每步后续均有详细说明和代码示例。 + +```mermaid +graph LR + S1["步骤 1
引入 Maven 依赖"] + S2["步骤 2
准备配置文件
AgentCard + 认证 + .env"] + S3["步骤 3
实现 AgentExecutor
A2A 服务端入口"] + S4["步骤 4
实现 ControlPoint
工作流决策回调"] + S5["步骤 5
组装运行管线
发现输入 -> 执行工作流 / 独立扩展"] + + S1 --> S2 --> S3 --> S4 --> S5 +``` + +职责分布: + +| 组件 | 集成方实现 | SDK 提供 | 说明 | +|-------------------------|---------------|-----------------|------------------------------------------------------------------| +| `AgentExecutor` | 是 | 否 | 集成方的 A2A 服务端入口,接收 上层系统 下发的任务 | +| `ControlPoint` | 是 (4 个方法) | 接口 + 默认实现 | 工作流决策: 任务下发、自处理、路由、协商 | +| `AuthProvider` | 是 (可选) | 接口 | 自定义认证头注入 (企业 SSO 等),与 credentials.json 二选一或组合 | +| `WorkbenchOrchestrator` | 是 | 否 | 组装编排管线的业务类 | +| `WorkflowEngineClient` | 否 | 是 | 工作流发送门面 (Task-T + 协商循环) | +| `ExtensionSender` | 否 | 是 | 前置预定位门面 (授权 + 订阅) | +| `A2ATransport` | 否 | 是 | 共享传输层 (HTTP + 认证 + SSE) | +| `ExecutePsop` | 否 | 是 | 高层运行器 (生命周期 + 事件 + 持久化) | + +--- + +## 3. 逐步集成 + +### 步骤 1: 引入 Maven 依赖 + +集成方项目 `pom.xml` 中添加: + +```xml + + dev.openan.workflow.sdk + workflow-engine + 1.0.0 + +``` + +如果集成方基于 Spring Boot 且需要 SDK 自动装配 A2A 服务端 (AgentCard 加载、REST 控制器、事件总线): + +```xml + + dev.openan.workflow.sdk + spring-boot-starter + 1.0.0 + +``` + +Spring Boot starter 会自动注册 `AgentCard`、`RequestHandler`、`RestHandler`、`A2AController` 等 Bean。集成方只需提供一个 +`AgentExecutor` 实现类标注 `@Component` 即可。 + +### 步骤 2: 准备配置文件 + +集成需要三类配置文件。 + +#### 2.1 AgentCard (集成方自身的) + +集成方作为 A2A 服务端,需要声明自己的 AgentCard。该文件告知 上层故障系统如何调用集成方: + +```json +{ + "name": "Transport Workbench Agent", + "description": "传输工作台智能体: 接收上层诊断任务,编排多地市 OMC 执行诊断并汇总", + "version": "1.0.0", + "capabilities": { + "streaming": true, + "extensions": [ + {"uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1", "required": false}, + {"uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T", "required": false}, + {"uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1", "required": false}, + {"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": "跨城故障协同诊断", + "description": "协同多地市 OMC 执行专线故障诊断并汇总结果", + "tags": ["SPN", "fault-diagnosis"] + } + ], + "supportedInterfaces": [ + {"protocolBinding": "HTTP+JSON", "url": "https://工作台地址/a2a/json"} + ] +} +``` + +下游 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 或自定义认证头 | 提供 credentials.json 文件 | +| 自定义 AuthProvider | 集成方自管 Token、企业 SSO、非标准认证头,或经自定义网关/协议转发获取 OMC Token | 实现 `AuthProvider` 接口 | + +**合并规则:** 两种认证来源分别生成 Header 后合并。`AuthProvider` 可以作为唯一认证来源;如果两者对同名 Header 生成不同值,引擎会抛出 +`SecurityException`。 + +##### 方式一: Credentials 认证 (credentials.json) + +SPN Domain Agent (OMC) 使用 Bearer 认证。SDK 的 `AgentAuthManager` +自动调用登录接口获取令牌并缓存刷新,集成方只需提供凭证文件: + +```json +{ + "SPN Domain Agent City1": { + "bearerAuth": { + "login_url": "https://omc-city1地址/rest/plat/smapp/v1/oauth/token", + "method": "PUT", + "request_fields": { + "grantType": "password", + "userName": "admin", + "value": "enc:加密后的密码", + "ipaddr": "*" + }, + "token_field": "accessSession", + "token_ttl": 3600 + } + }, + "SPN Domain Agent City2": { + "bearerAuth": { + "login_url": "https://omc-city2地址/rest/plat/smapp/v1/oauth/token", + "method": "PUT", + "request_fields": { + "grantType": "password", + "userName": "admin", + "value": "enc:加密后的密码", + "ipaddr": "*" + }, + "token_field": "accessSession", + "token_ttl": 3600 + } + } +} +``` + +键名必须与 AgentCard 的 `name` 字段一致。SDK 依据此键名匹配 AgentCard 与凭证。密码字段 `value` 使用 `enc:` 前缀标识加密内容,SDK +的 `CredentialCrypto` 自动解密 (密钥通过 `A2AT_CRED_KEY` 环境变量注入,32 字节 hex)。 + +**密码加密方法:** + +加密格式为 `enc::`,算法为 AES-256-GCM。SDK 提供两种加密方式: + +**方式一: 命令行工具 (推荐)** + +```bash +# 1. 生成 32 字节密钥 (仅首次,妥善保存) +openssl rand -hex 32 +# 输出示例: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2 + +# 2. 设置密钥环境变量 +set A2AT_CRED_KEY=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2 + +# 3. 加密密码 (输出 enc:... 格式,直接复制到 credentials.json) +java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" +# 输出: enc:xxxxxxxxxxxx:yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy + +# 也可以将密钥作为第二个参数传入 +java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" a1b2c3d4... +``` + +**方式二: 代码调用** + +```java +// 设置密钥后调用 +System.setProperty("A2AT_CRED_KEY", "a1b2c3d4e5f6..."); +String encrypted = CredentialCrypto.encrypt("Admin@123"); +// encrypted = "enc:xxxxxxxxxxxx:yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" +``` + +将输出的 `enc:...` 值直接填入 credentials.json 的 `value` 字段: + +```json +{ + "request_fields": { + "value": "enc:xxxxxxxxxxxx:yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" + } +} +``` + +运行时 SDK 从 `A2AT_CRED_KEY` 环境变量读取密钥自动解密。密钥来源优先级:显式 credentialEncryptionKey > 操作系统 +A2AT_CRED_KEY > 同名 JVM 属性。引擎不自动读取宿主 LLM .env。 + +凭证配置支持的完整字段: + +| 字段 | 类型 | 默认值 | 说明 | +|----------------------|--------|--------------------|-------------------------------------------------------------------| +| `login_url` | String | 必填 | 登录接口地址 | +| `method` | String | `POST` | HTTP 方法 | +| `content_type` | String | `application/json` | 请求 Content-Type | +| `request_fields` | Map | - | 请求体字段 (替代 legacy 的 username/password) | +| `token_field` | String | `accessSession` | 从响应 JSON 提取令牌的路径 (支持点号嵌套,如 `data.access_token`) | +| `token_ttl` | int | 3600 | 令牌缓存有效期 (秒),到期前 60 秒自动刷新 | +| `auth_header` | String | `Authorization` | 自定义认证头名称 (设置后引擎改用该 Header 注入凭证) | +| `auth_header_prefix` | String | `Bearer ` | 自定义认证头前缀 (与 `auth_header` 配合使用) | + +##### 方式二: 自定义 AuthProvider + +当认证机制不属于标准 Bearer/API Key,或令牌来自外部身份提供商 (如企业 SSO) 时,实现 `AuthProvider` 接口: + +```java +public interface AuthProvider { + /** + * 为发送消息注入认证头。 + * + * @param agentName 目标 Agent 名称 (匹配 AgentCard.name) + * @param agentCard Agent 卡片 (securitySchemes 可能为空) + * @param headers 可变的请求头 Map,直接写入认证信息 + */ + void applyAuth(String agentName, AgentCard agentCard, Map headers); +} +``` + +通过 `WorkflowEngineClientConfig` 注册: + +```java +WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() + .authProvider((agentName, agentCard, headers) -> { + // 从企业 SSO 获取令牌 + String token = ssoClient.getToken(agentName); + headers.put("Authorization", "Bearer " + token); + }) + .sslVerify(sslVerify) + .build(); +``` + +`AuthProvider` 在 **每次消息发送时**被调用,无论 AgentCard 是否声明了 `securitySchemes`。典型用途: + +- AgentCard 无 `securitySchemes` 但服务端仍要求认证 +- 认证机制非 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` 中。 + +##### 两种方式组合使用 + +如果部分 Agent 使用 credentials 认证、部分使用自定义认证,或需要在 credentials 基础上追加额外头: + +```java +WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() + .credentialsConfigPath(credentialsPath) // credentials 认证 + .authProvider((agentName, agentCard, headers) -> { + // 追加企业级追踪头 + headers.put("X-Trace-Id", TraceContext.current().getTraceId()); + }) + .build(); +``` + +`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 +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 +``` + +集成方业务代码通过此配置初始化 A2A-T SDK,生成和校验协议内容。执行引擎不读取 LLM 配置,也不生成提示词。示例在配置缺失或生成失败时明确报错,不把原始输入降级为 Task-T 正文。 + +### 步骤 3: 实现 AgentExecutor + +集成方实现 A2A Java AgentExecutor.execute (RequestContext, AgentEmitter) 接收 WAIMO, 并在自己的入口用 +A2ATServer.validateTaskPromptAndDataFilling 校验 Task-T。 metadata 中的 Task-T 正文与 templateUri 是内容入口,不用 parts +长短决定读取哪个正文。 然后搜索工作流、获取 AgentCards、调用执行引擎,并通过 A2A emitter 返回 artifact/终态。 + +可编译的完整实现见 samples 的 SpringWorkbenchExecutor、WorkbenchTaskInputParser、 WorkbenchOrchestrator;Spring starter +只提供协议入口与生命周期,不替宿主决定业务或模板。 入口取消应停止对应本地执行并清理宿主资源;远端已提交任务需按业务策略显式取消。 +不要把错误或空结果伪装成成功诊断。 + +### 步骤 4: 实现 ControlPoint + +```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 生成及协商示例见 WorkbenchControlPoint / examples.negotiation.NegotiationStrategy;上述仅是普通 A2A 内容示例。 + +### 步骤 5: 组装编排管线 + +```java +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(); +} +``` + +workflow、cards、callbacks、intent 由宿主取得或构造。 runtime 为 null 时使用默认直连实现;自定义 runtime 只替换传输,不替换业务回调。 +上面 transport 仅属于这次工作流;授权和通知使用另外的实例。 宿主自己的 A2ATClient 可在回调中复用,但要确保业务状态按 +execution/task 隔离。 + +#### 5.1 前置预定位详解 + +```java +// 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)); +``` + +sdk、业务数据、schema、recordIndependent* 均由宿主提供,不是引擎隐式服务。 内容生成失败也只记录本次独立操作失败,不能阻止任务 +DAG。 close () 幂等请求关闭;completion () 等待真实流退出。工作流完成不能关闭独立订阅。 实际生命周期与异常处理见 samples 的 +ExtensionPrePositioner / WorkbenchExtensionLifecycle。 + +#### 5.2 PSOP 工作流搜索与加载 + +工作流 (PSOP) 存储在编排中心。SDK 提供搜索和加载方法: + +```java +// 语义搜索: 根据原始意图匹配最相关的工作流 +List results = LoadPsop.search(orchUrl, messageText, 3, null, sslVerify); +String psopId = results.get(0).getWorkflowId(); + +// 加载完整工作流定义 +Workflow workflow = LoadPsop.load(orchUrl, psopId, null, sslVerify); +``` + +生产环境应明确编排中心不可用时的业务失败策略,不能默认执行可能过期的本地流程。 +`SpringSpnDemo` 为离线演示提供本地 PSOP fallback;接入方只有在完成版本一致性、审计和变更治理后, 才应自行设计受控的本地兜底。 + +#### 5.3 EventCallback -- 事件追踪 + +`ExecutePsop` 在执行过程中产出全量事件。集成方可实现 `EventCallback` 追踪执行进度、记录日志、向前端推送: + +```java +private EventCallback createLogCallback() { + return new EventCallback() { + @Override + public void onEvent(String type, Map data) { + switch (type) { + case EventType.START -> log.info("[开始] 工作流: {}", data.get("workflow")); + case EventType.STEP_START -> log.info("[步骤开始] {}", data.get("step")); + 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("exchange")); + case EventType.NEGOTIATION_RESOLVED -> + log.info("[协商完成] agent={}", data.get("agent")); + // 以下事件类型为预留,当前 SDK 版本不会触发 + // (独立操作不进入工作流事件流) + case EventType.AUTHORIZATION_REQUEST -> + log.info("[授权请求] agent={}", data.get("agent")); + case EventType.NOTIFICATION -> + log.info("[通知] agent={}", data.get("agent")); + case EventType.ROUTE_DECISION -> + log.info("[路由] {} -> {}", data.get("step"), data.get("next")); + case EventType.STEP_COMPLETE -> log.info("[步骤完成] {}", data.get("step")); + case EventType.COMPLETE -> log.info("[完成]"); + case EventType.ERROR -> log.error("[错误] {}", data.get("error")); + case EventType.CLOSE -> log.info("[关闭]"); + } + } + }; +} +``` + +事件类型完整列表见 SDK 的 `EventType` 常量类。关键事件: `start` -> `step_start` -> `agent_request` / `agent_response` (含 +`negotiation_*`) -> `step_complete` -> `complete` / `error` -> `close`。 + +> **注意:** `authorization_request` 和 `notification` 事件类型在当前 SDK 版本中不会被触发 (独立操作不进入工作流事件流) +> 。这些事件类型为未来扩展预留。独立授权通过 SendMessageResult,订阅通过 handle 的 ACK 和监听器取得响应,不经过事件流。 + +--- + +## 4. 完整调用时序 + +```mermaid +sequenceDiagram + 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 and generate reply + H-->>E: Send(MessageContent) or local Stop + E->>A: Same task/context with final reply content + end + A-->>E: Task result / artifacts + E->>H: onSelfTask(selected complete upstream results) + H-->>E: TaskResult +``` + +```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 +``` + +只有远端 `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 确认,也不能判为诊断成功。 + +### 查看真实协商与协议日志 + +直接运行本地 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`。 本分支使用直连模式。 + +离线重复验证: + +```powershell +mvn -q -pl samples -am "-Dtest=SpringSpnDemoE2ETest" "-Dsurefire.failIfNoSpecifiedTests=false" test +``` + +每个测试类覆盖无 VM 参数的默认单城市协商、显式关闭、显式开启及两城市同时缺参。测试使用离线 LLM provider,但实际运行当前 SDK +的 模板、校验和 HTTP/SSE;不是现网 OMC、平台或真实模型语义的验证。 + +### Demo 端到端 A2A-T SDK 调用清单 + +`SpringSpnDemo` 完整业务链路(两地市并行诊断、City1 缺参协商、City2 直接诊断)按业务时序对 A2A-T SDK 的全部调用如下。授权与订阅是独立于工作流 +DAG 的操作,使用各自的独立通道 (授权为一次性通道,订阅为长连接通道),与 Task-T 报文无关;Demo 的调用时机是在工作流任务 下发 +**之前**先行完成授权与订阅,再启动两地市诊断。样例覆盖全部 8 个 validate 接口和全部 7 个 fromData 生成接口,均为真实调用;fromText +生成系列未在样例中使用(见文末说明)。 + +| # | 业务时机 | 调用方(角色) | A2A-T SDK 接口 | 样例实现 | +|----|-------------------------------------------------------------------------------------------|------------------|------------------------------------------------------------------------------|---------------------------------| +| 1 | WAIMO 下发投诉任务,集成方入口校验 Task-T 并提取任务参数 | 集成方(服务端) | `A2ATServer.validateTaskPromptAndDataFilling` | `WorkbenchTaskInputParser` | +| 2 | 集成方生成抢通白名单授权请求,经独立一次性通道发送 OMC(任务下发前) | 集成方(客户端) | `A2ATClient.generateAuthPromptFromDataWithSchema` | `ExtensionPrePositioner` | +| 3 | OMC 收到授权报文,校验并按白名单执行,返回 ACK | OMC(服务端) | `A2ATServer.validateAuthPromptAndDataFilling` | `PrePositionedExtensionHandler` | +| 4 | 集成方生成业务抢通事件订阅请求,经独立长连接通道向 OMC 订阅(任务下发前) | 集成方(客户端) | `A2ATClient.generateNotificationPromptFromDataWithSchema` | `ExtensionPrePositioner` | +| 5 | OMC 校验订阅报文,建立 SSE 上报;后续抢通结果经该长连接推回集成方 | OMC(服务端) | `A2ATServer.validateNotificationPromptAndDataFilling` | `PrePositionedExtensionHandler` | +| 6 | 集成方按地市生成并行 Task-T;不依赖独立授权或订阅成功 | 集成方(客户端) | `A2ATClient.generateTaskPromptFromDataWithSchema` | `SpringSpnDemo` | +| 7 | OMC 校验 Task-T,提取任务对象/上下文;参数完整则直接诊断(City2) | OMC(服务端) | `A2ATServer.validateTaskPromptAndDataFilling` | `NegotiationBaseAgentExecutor` | +| 8 | OMC 发现必选参数缺失,生成协商 Propose,随 INPUT_REQUIRED 返回(City1) | OMC(服务端) | `A2ATServer.generateNegotiationProposePromptFromData` | `NegotiationBaseAgentExecutor` | +| 9 | 集成方 `onNegotiation` 回调:校验入站 Propose,提取所需补充字段 | 集成方(客户端) | `A2ATClient.validateProposePromptAndDataFilling` | `NegotiationStrategy` | +| 10 | 集成方从当前地市业务输入补参,生成协商结尾报文(Accept;拒绝/终止对应 Reject/Abort 变体) | 集成方(客户端) | `A2ATClient.generateNegotiationAcceptPromptFromData`(及 Reject/Abort 变体) | `NegotiationStrategy` | +| 11 | OMC 校验集成方回传的协商结尾报文,提取补参后重新执行诊断 | OMC(客户端) | `A2ATClient.validateAcceptPromptAndDataFilling`(及 Reject/Abort 变体) | `NegotiationBaseAgentExecutor` | + +两个组装辅助贯穿上述调用(属执行引擎的通用工具,非 SDK 接口): + +- `A2atMessages.from(MetadataContent, parts)`:把 SDK 生成的 `MetadataContent`(模板 URI、扩展 URI、 协商上下文 + metadata)封装为发送用的 `MessageContent`,不生成、不解释业务内容; +- `A2atMessages.contextOf(received)`:从收到的协商报文中取回 `NegotiationContext`,协商结尾回复 保持相同 + id/round/maxRounds(步骤 6)。 + +说明: + +- 全部生成都走 **fromData(结构化输入、确定性渲染)**;`generateTaskPromptFromText` 等 fromText 系列 (自然语言入口,含一步 + LLM 抽取)接口在样例中未使用,接口契约见 + [A2A-T SDK 依赖说明](A2AT-SDK-DEPENDENCY.md)。 +- 协商由 **OMC 侧触发**(缺参时返回 INPUT_REQUIRED + Propose),集成方无法选择哪个地市协商; 样例通过删除 City1 + 的任务对象字段构造该输入条件,两城市的补参互相隔离。 +- 输入内容(任务参数、补参值、授权策略、订阅条件)全部来自样例构造的业务数据(`SpnCasePrompts`), SDK 接口输出不做任何打桩;运行 + `SpringSpnDemo` 使用真实 LLM 配置。 + +## 5. 集成方需交付物清单 + +| 交付物 | 类型 | 说明 | +|--------------------------------------------|---------|--------------------------------------------------------------------------| +| `TransportWorkbenchExecutor` | Java 类 | 实现 `AgentExecutor`,A2A 服务端入口 | +| `WorkbenchControlPoint` | Java 类 | 继承 `DefaultControlPoint`,实现 onTask/onSelfTask/onRoute/onNegotiation | +| `WorkbenchOrchestrator` | Java 类 | 组装编排管线 (加载卡片 -> 检索工作流 -> 执行) | +| `WorkbenchAuthProvider` (可选) | Java 类 | 实现 `AuthProvider`,自定义认证逻辑 (如企业 SSO) | +| `agentcard/transport_workbench_agent.json` | JSON | 集成方自身的 AgentCard | +| `spn_agent_credentials.json` (可选) | JSON | 下游 OMC 的认证凭证 (使用 Credentials 认证时需要) | +| `.env` | INI | A2A-T SDK 的 LLM 配置 | +| `application.yml` (Spring Boot) | YAML | `a2at.server.agent-card` 等配置项 | + +--- + +## 6. 生产环境注意事项 + +### 6.1 TLS 与证书 + +生产环境 OMC 通常使用自签名证书。`WorkflowEngineClientConfig` 中: + +- `sslVerify(true)`: 校验证书 (需配置 CA 信任库 `caCertsPath`) +- `sslVerify(false)`: 仅跳过证书链校验,主机名校验仍启用;只用于受控的本地诊断 (HTTP/JSON-RPC 会继续加载 mTLS 客户端证书;默认 + gRPC 使用 plaintext,不能同时配置 mTLS/CRL) + +```java +WorkflowEngineClientConfig.builder() + .sslVerify(true) + .caCertsPath("/path/to/ca-certs.pem") + // OMC 要求双向 TLS 时配置: + .clientCertPath("/path/to/client-cert.pem") + .clientKeyPath("/path/to/client-key.pem") + .credentialsConfigPath(credentialsPath) + .build() +``` + +### 6.2 凭证加密与自定义认证 + +**Credentials 认证:** `spn_agent_credentials.json` 中的密码字段使用 `enc:` 前缀标识加密内容。SDK 的 `CredentialCrypto` 使用 +AES-GCM 对称加密自动解密。加密密钥通过环境变量 `A2AT_CRED_KEY` 注入 (32 字节 hex)。生产环境不应明文存储密码。 + +**自定义 AuthProvider:** 如果集成方已有统一的认证体系 (如企业 SSO、OAuth2 网关),可直接实现 `AuthProvider` 接口,无需维护 +credentials.json 文件。`AuthProvider` 在每次消息发送时调用,适合令牌生命周期由外部管理的场景: + +```java +public class SsoAuthProvider implements AuthProvider { + private final SsoClient ssoClient; + + @Override + public void applyAuth(String agentName, AgentCard agentCard, Map headers) { + String token = ssoClient.getAccessToken(agentName); + headers.put("Authorization", "Bearer " + token); + } +} +``` + +### 6.3 协商轮次限制 + +WorkflowEngineClientConfig.builder ().maxNegotiationExchanges (3) 配置本地交互预算, 与收到的 SDK context.maxRounds +分开。耗尽/超时只本地失败,不自动生成 Abort。 结束回复保留收到的 id/round/maxRounds,最后一轮仍可回答。 + +### 6.4 SSE 长连接管理 + +`openNotification` 建立的 SSE 订阅通道是长连接。SDK 的 `A2ATransport` 管理连接池和超时。集成方应在服务关闭时调用 +`extensionTransport.close()` 释放连接。`ExecutePsop` 仅自动关闭它自行创建的 client/transport;传入已有 engineClient 时由集成方负责关闭。不要把任务级 +transport 与 `ExtensionSender` 共用,否则 Notification-T 长连接会在工作流结束时被关闭。 + +### 6.5 并发与并行 + +SDK 的 `WorkflowExecutor` 按 DAG 已激活前驱的完成情况调度就绪步骤;无依赖关系的就绪步骤及步骤内子任务可并行执行。如果 +OMC 有并发限制,通过 DAG 依赖边或宿主限流控制;layer 不是执行顺序约束。 + +### 6.6 错误处理 + +引擎处理远端协议状态和结果解包;onTask 只返回 MessageContent。onSelfTask 返回 TaskResult,失败原因与 outputs 分离。异常和失败 +future 由引擎记录;不要把未发送成功包装成成功输出。详见 [业务回调契约](BUSINESS_CALLBACKS.md)。 + +## 7. 接口速查表 + +```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 +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 的关键区别 + +本次集成是生产环境接入,与 Demo 示例代码的关键区别: + +| 维度 | Demo (samples) | 生产环境 | +|---------------|--------------------------------|-------------------------------------------------------| +| AgentExecutor | 模拟实现,返回固定文本 | 对接真实 上层故障系统,需完整 A2A 消息时序 | +| OMC Agent | 模拟 Agent,预置响应 | 真实 SPN Domain Agent,Bearer 认证、真实诊断逻辑 | +| AgentCard | 从 classpath 加载 JSON | 从注册中心获取或厂商提供 | +| 认证 | credentials.json 明文密码 | 加密存储 (enc: 前缀) 或自定义 AuthProvider (企业 SSO) | +| TLS | sslVerify=false | 生产证书校验 | +| PSOP | fallback 固定 ID | 编排中心语义搜索 + 动态加载 | +| 事件 | 日志打印 | 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 是否明确返回业务成功或失败 +- `openNotification` 是否传入回调,并在服务关闭时释放前置预定位 transport + +## 协议日志 + +将 `PROTOCOL` logger 设为 DEBUG,可查看实际传输边界的观测记录。 HTTP/JSON-RPC 记录 A2A SDK 处理后的实际序列化正文和应用头; +A2A-Version 只在真实请求有该头时出现,不为日志展示补造 Header。 gRPC 记录实际 metadata 和 protobuf 的 JSON 展示,不伪装成 +HTTP JSON 报文。 JDK 自动网络头、HTTP/2 帧、TLS 密文及服务端字节不在此观测范围。 + +`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/pom.xml b/pom.xml index 0f5bb3cb..d71558f0 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ workflow-engine-parent A2A-T workflow execution engine for multi-agent orchestration (Java) - parent - https://github.com/Zhoujie628/a2at-engine-java + https://github.com/project-openan/workflow-engine-sdk-java diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/SpringWorkbenchApplication.java b/samples/src/main/java/dev/openan/workflow/engine/examples/SpringWorkbenchApplication.java index 6a664658..3c40164e 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/SpringWorkbenchApplication.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/SpringWorkbenchApplication.java @@ -20,22 +20,21 @@ 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 java.nio.file.Path; 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 dev.openan.workflow.engine.examples.workbench.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 dev.openan.workflow.engine.examples.demo.SpringSpnDemo}. @@ -45,19 +44,19 @@ @SpringBootApplication public class SpringWorkbenchApplication { - private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchApplication.class); + private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchApplication.class); - public static void main(String[] args) { - loadDotEnv(); - SpringApplication.run(SpringWorkbenchApplication.class, args); - log.info("[SpringWorkbench] A2A server started -- ready to receive A2A-T messages"); - } + public static void main(String[] args) { + 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)); - } + /** 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 e8874248..0ecb03b4 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 @@ -19,13 +19,12 @@ package dev.openan.workflow.engine.examples.agents; +import java.util.UUID; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.Part; import org.a2aproject.sdk.spec.TextPart; -import java.util.UUID; - /** * Shared base class for sample agent executors. * @@ -34,24 +33,24 @@ */ public abstract class BaseAgentExecutor implements AgentExecutor { - protected static String extractText(Message message) { - if (message == null) return ""; - StringBuilder sb = new StringBuilder(); - for (Part part : message.parts()) { - if (part instanceof TextPart tp) { - sb.append(tp.text()); - } - } - return sb.toString(); + protected static String extractText(Message message) { + if (message == null) return ""; + StringBuilder sb = new StringBuilder(); + for (Part part : message.parts()) { + if (part instanceof TextPart tp) { + sb.append(tp.text()); + } } + return sb.toString(); + } - public 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(); - } + public 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(); + } } 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 b9f0f002..5b3f0ec9 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 @@ -19,13 +19,24 @@ package dev.openan.workflow.engine.examples.agents; +import dev.openan.workflow.engine.client.A2ATExtension; +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.examples.negotiation.NegotiationUtils; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; 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; @@ -38,557 +49,603 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -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 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 + * 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 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. + *

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; - private final BlockingQueue notificationQueue = new LinkedBlockingQueue<>(); - private final java.util.concurrent.ConcurrentMap pendingNegotiationTasks = - new java.util.concurrent.ConcurrentHashMap<>(); - private volatile A2ATClient a2atClient; - - 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 active Notification-T subscription, or null. */ - protected final NotificationPolicy getNotificationSubscription() { - return prePositionedHandler.getNotificationSubscription(); - } - - /** - * 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 means Negotiation-T cannot be served. - */ - protected abstract String resolveEnvPath(); - - private synchronized A2ATClient a2at() { - if (a2atClient != null) { - return a2atClient; - } - String env = resolveEnvPath(); - if (env == null || env.isBlank()) { - throw new IllegalStateException("A2A-T SDK configuration is required for OMC content validation"); - } - try { - 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) { - 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.isBlank()) { - log.info("[{}] Using Task-T prompt from message metadata", getClass().getSimpleName()); - input = taskTText; - } - } - log.info( - "[{}] TASK_START taskId={}, contextId={}, inputChars={}, followUp={}, " - + "prePositionedExtension={}", - getClass().getSimpleName(), - taskId, - contextId, - input.length(), - isNegotiationReply(ctx), - PrePositionedExtensionHandler.detect(ctx)); - try { - String prePositionedExt = PrePositionedExtensionHandler.detect(ctx); - if (prePositionedExt != null) { - if (A2ATExtension.NOTIFICATION_T.uri().equals(prePositionedExt)) { - handleNotificationSubscription(ctx, emitter); - } else { - prePositionedHandler.handle( - ctx, emitter, prePositionedExt, getClass().getSimpleName()); - } - } else if (isNegotiationReply(ctx)) { - handleFollowUp(ctx, emitter); - } else { - handleNewTask(ctx, emitter); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.info( - "[{}] TASK_INTERRUPTED taskId={}, contextId={}, elapsedMs={}, reason=shutdown", - getClass().getSimpleName(), - taskId, - contextId, - elapsedMillis(started)); - } catch (Exception 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())); - } - } - - /** - * Handle a Notification-T subscription: send "subscribed" ack, then block on a queue to keep - * the SSE stream open. Recovery results pushed via pushNotificationResult are forwarded through - * this stream with the Notification-T URI in artifact metadata. The stream stays open until the - * client disconnects or the agent is shut down. - */ - private void handleNotificationSubscription(RequestContext ctx, AgentEmitter emitter) - throws InterruptedException { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - String agentTag = getClass().getSimpleName(); - 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", - subscriptionMetadata, - false, - true); - emitStatus( - emitter, - TaskState.TASK_STATE_WORKING, - contextId, - taskId, - "订阅成功,启动业务抢通事件上报任务", - subscriptionMetadata); - while (!Thread.currentThread().isInterrupted()) { - 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( - "[{}] NOTIFICATION_PUSH taskId={}, contextId={}, artifactName={}, contentChars={}", - agentTag, - taskId, - contextId, - event.artifactName(), - event.content().length()); - Map notifMeta = new LinkedHashMap<>(); - notifMeta.put(notifUri, event.content()); - List> resultParts = List.of(new TextPart("业务抢通事件")); - emitter.addArtifact( - resultParts, - event.artifactName(), - event.artifactName(), - notifMeta, - false, - true); - } - } - - /** 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); + 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; + private final BlockingQueue notificationQueue = new LinkedBlockingQueue<>(); + private final java.util.concurrent.ConcurrentMap + pendingNegotiationTasks = new java.util.concurrent.ConcurrentHashMap<>(); + private volatile A2ATClient a2atClient; + private volatile net.openan.a2at.sdk.server.A2ATServer a2atServer; + + protected NegotiationBaseAgentExecutor() { + this(new PrePositionedExtensionHandler()); + } + + protected NegotiationBaseAgentExecutor(PrePositionedExtensionHandler prePositionedHandler) { + this.prePositionedHandler = java.util.Objects.requireNonNull(prePositionedHandler); + } + + 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 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); + } + + /** + * 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; + } + + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } + + /** The active SDK-validated Authorization-T whitelist, or null. */ + protected final AuthorizationPolicy getAuthorizationPolicy() { + return prePositionedHandler.getAuthorizationPolicy(); + } + + /** The active Notification-T subscription, or null. */ + protected final NotificationPolicy getNotificationSubscription() { + return prePositionedHandler.getNotificationSubscription(); + } + + /** + * 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); + } + + /** Resolve the A2A-T .env path; null means Negotiation-T cannot be served. */ + protected abstract String resolveEnvPath(); + + private synchronized A2ATClient a2at() { + if (a2atClient != null) { + return a2atClient; + } + String env = resolveEnvPath(); + if (env == null || env.isBlank()) { + throw new IllegalStateException( + "A2A-T SDK configuration is required for OMC content validation"); + } + try { + 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) { + throw new IllegalStateException( + "A2A-T client initialization failed for " + getClass().getSimpleName(), e); + } + } + + /** 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.isBlank()) { + log.info("[{}] Using Task-T prompt from message metadata", getClass().getSimpleName()); + input = taskTText; + } + } + log.info( + "[{}] TASK_START taskId={}, contextId={}, inputChars={}, followUp={}, " + + "prePositionedExtension={}", + getClass().getSimpleName(), + taskId, + contextId, + input.length(), + isNegotiationReply(ctx), + PrePositionedExtensionHandler.detect(ctx)); + try { + String prePositionedExt = PrePositionedExtensionHandler.detect(ctx); + if (prePositionedExt != null) { + if (A2ATExtension.NOTIFICATION_T.uri().equals(prePositionedExt)) { + handleNotificationSubscription(ctx, emitter); } else { - log.info("[{}] Parameters sufficient, skipping negotiation", getClass().getSimpleName()); - runBusinessAndComplete(ctx, emitter, new SpnTaskInput(data)); + prePositionedHandler.handle(ctx, emitter, prePositionedExt, getClass().getSimpleName()); } - } - - private boolean isNegotiationReply(RequestContext ctx) { - Map meta = ctx.getMessage() != null ? ctx.getMessage().metadata() : null; - return NegotiationUtils.hasNegotiationMetadata(meta) && !NegotiationUtils.hasTaskMetadata(meta); - } - - /** 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, 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)); - emitter.addArtifact(parts, "result", buildArtifactName(), metadata, false, true); + } else if (isNegotiationReply(ctx)) { + handleFollowUp(ctx, emitter); + } else { + handleNewTask(ctx, emitter); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.info( + "[{}] TASK_INTERRUPTED taskId={}, contextId={}, elapsedMs={}, reason=shutdown", + getClass().getSimpleName(), + taskId, + contextId, + elapsedMillis(started)); + } catch (Exception 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())); + } + } + + /** + * Handle a Notification-T subscription: send "subscribed" ack, then block on a queue to keep the + * SSE stream open. Recovery results pushed via pushNotificationResult are forwarded through this + * stream with the Notification-T URI in artifact metadata. The stream stays open until the client + * disconnects or the agent is shut down. + */ + private void handleNotificationSubscription(RequestContext ctx, AgentEmitter emitter) + throws InterruptedException { + String taskId = ctx.getTaskId(); + String contextId = ctx.getContextId(); + String agentTag = getClass().getSimpleName(); + 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", subscriptionMetadata, false, true); + emitStatus( + emitter, + TaskState.TASK_STATE_WORKING, + contextId, + taskId, + "订阅成功,启动业务抢通事件上报任务", + subscriptionMetadata); + while (!Thread.currentThread().isInterrupted()) { + 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_COMPLETED, contextId, taskId, "Completed", metadata); - emitter.complete(buildStatusMessage(contextId, taskId, "Completed")); - log.info( - "[{}] BUSINESS_DONE taskId={}, contextId={}, responseChars={}, elapsedMs={}", - getClass().getSimpleName(), - taskId, - contextId, - response != null ? response.length() : 0, - elapsedMillis(started)); - } - - private void emitStatus( - AgentEmitter emitter, - TaskState state, - String contextId, - String taskId, - String text, - Map metadata) { - TaskStatus status = - new TaskStatus(state, buildStatusMessage(contextId, taskId, text), null); - TaskStatusUpdateEvent event = - TaskStatusUpdateEvent.builder() - .taskId(taskId) - .contextId(contextId) - .status(status) - .metadata(metadata) - .build(); - emitter.emitEvent(event); - } - - /** - * Run the agent's actual business logic; return the response text. May emit intermediate - * events. - */ - protected abstract String executeBusiness( - 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) { - return new LinkedHashMap<>(); - } - - /** Short human-readable summary for the artifact parts. */ - protected String buildResultSummary() { - return "专线业务投诉诊断任务诊断结果消息"; - } - - /** Artifact display name. Default: subclass simple name + " result". */ - protected String buildArtifactName() { - return getClass().getSimpleName() + " result"; - } - - /** - * 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; - } - - 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(); - } + emitter, + TaskState.TASK_STATE_WORKING, + contextId, + taskId, + "Notification-T heartbeat", + Map.of("notificationHeartbeat", true, "timestamp", java.time.Instant.now().toString())); + continue; + } + log.info( + "[{}] NOTIFICATION_PUSH taskId={}, contextId={}, artifactName={}, contentChars={}", + agentTag, + taskId, + contextId, + event.artifactName(), + event.content().length()); + Map notifMeta = new LinkedHashMap<>(); + notifMeta.put(notifUri, event.content()); + List> resultParts = List.of(new TextPart("业务抢通事件")); + emitter.addArtifact( + resultParts, event.artifactName(), event.artifactName(), notifMeta, false, true); + } + } + + /** 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 (!ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode().equals(error.getCode()) + && !ErrorCatalog.SLOT_RULE_VIOLATION.getCode().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, new SpnTaskInput(data)); + } + } + + private boolean isNegotiationReply(RequestContext ctx) { + Map meta = ctx.getMessage() != null ? ctx.getMessage().metadata() : null; + return NegotiationUtils.hasNegotiationMetadata(meta) && !NegotiationUtils.hasTaskMetadata(meta); + } + + /** 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 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); + } + + /** 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); + } + + /** 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, 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)); + emitter.addArtifact(parts, "result", buildArtifactName(), metadata, false, true); + emitStatus(emitter, TaskState.TASK_STATE_COMPLETED, contextId, taskId, "Completed", metadata); + emitter.complete(buildStatusMessage(contextId, taskId, "Completed")); + log.info( + "[{}] BUSINESS_DONE taskId={}, contextId={}, responseChars={}, elapsedMs={}", + getClass().getSimpleName(), + taskId, + contextId, + response != null ? response.length() : 0, + elapsedMillis(started)); + } + + private void emitStatus( + AgentEmitter emitter, + TaskState state, + String contextId, + String taskId, + String text, + Map metadata) { + TaskStatus status = new TaskStatus(state, buildStatusMessage(contextId, taskId, text), null); + TaskStatusUpdateEvent event = + TaskStatusUpdateEvent.builder() + .taskId(taskId) + .contextId(contextId) + .status(status) + .metadata(metadata) + .build(); + emitter.emitEvent(event); + } + + /** + * Run the agent's actual business logic; return the response text. May emit intermediate events. + */ + protected abstract String executeBusiness( + 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) { + return new LinkedHashMap<>(); + } + + /** Short human-readable summary for the artifact parts. */ + protected String buildResultSummary() { + return "专线业务投诉诊断任务诊断结果消息"; + } + + /** Artifact display name. Default: subclass simple name + " result". */ + protected String buildArtifactName() { + return getClass().getSimpleName() + " result"; + } + + @Override + public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { + pendingNegotiationTasks.remove(ctx.getTaskId()); + emitter.cancel(); + } + + private record NotificationEvent(String artifactName, String content) { + private NotificationEvent { + java.util.Objects.requireNonNull(artifactName, "artifactName"); + java.util.Objects.requireNonNull(content, "content"); + } + } + + private record PendingNegotiation( + String a2aContextId, + net.openan.a2at.sdk.core.model.NegotiationContext context, + Map validatedData, + List requestedFields) {} } 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 62cdf79b..e60758fd 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 @@ -21,154 +21,156 @@ import dev.openan.workflow.engine.examples.extension.AuthorizationPolicy; import dev.openan.workflow.engine.examples.extension.PrePositionedExtensionHandler; +import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; +import dev.openan.workflow.engine.examples.util.EnvResolver; 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 org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -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 an incomplete task - * it replies INPUT_REQUIRED to start a Negotiation-T round, and on the follow-up it runs the - * 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. + *

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 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. 诊断结果:成功\n" - + "2. 诊断结果详情:P33206-YWHJ-业务汇聚机房1(990)的12-TPJ1EM8F-4端口出现无收光问题," - + "判断原因为对端设备掉电或端口关闭\n" - + "3. 修复建议:恢复供电后重新启动网元,或者重新开启端口"; - - private static final String ACCESS_PORT = "P781-珠江新城-PTN7900-23-TPA1EG24-17"; - private static final String RECOVERY_DETAIL = - "将时延越限和丢包越限的受影响隧道调优到新的可用路径,保留原路径用于回退。"; - - public SpnDomainAgentCity1Executor() { - super(); - } - - public SpnDomainAgentCity1Executor(PrePositionedExtensionHandler extensionHandler) { - super(extensionHandler); + 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. 诊断结果:成功\n" + + "2. 诊断结果详情:P33206-YWHJ-业务汇聚机房1(990)的12-TPJ1EM8F-4端口出现无收光问题," + + "判断原因为对端设备掉电或端口关闭\n" + + "3. 修复建议:恢复供电后重新启动网元,或者重新开启端口"; + + private static final String ACCESS_PORT = "P781-珠江新城-PTN7900-23-TPA1EG24-17"; + private static final String RECOVERY_DETAIL = "将时延越限和丢包越限的受影响隧道调优到新的可用路径,保留原路径用于回退。"; + + public SpnDomainAgentCity1Executor() { + super(); + } + + public SpnDomainAgentCity1Executor(PrePositionedExtensionHandler extensionHandler) { + super(extensionHandler); + } + + 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); + } + + 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); } - - 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); + if (executionResult != null) { + event.append('\n').append("9. 业务抢通方案执行结果:").append(executionResult); } - - @Override - protected String resolveEnvPath() { - return EnvResolver.resolveEnvPath(); - } - - @Override - 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, 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 void publishRecoveryLifecycle(RequestContext ctx, SpnTaskInput input) { - AuthorizationPolicy policy = getAuthorizationPolicy(); - boolean inWhitelist = - policy != null - && 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 = - formatRecoveryEvent( - "已结束", - taskId, - true, - "成功", - java.time.Instant.now().toString(), input); - log.info( - "[SPN-Domain-Agent] Recovery result reported via Notification-T: {}", - 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); - } - return event.toString(); - } - - @Override - 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 - protected Map buildResponseMetadata(RequestContext ctx, String response) { - Map metadata = new LinkedHashMap<>(); - metadata.put(NegotiationUtils.TASK_PROMPT_KEY, response); - return metadata; - } - - @Override - protected String buildResultSummary() { - return "专线业务投诉诊断任务诊断结果消息"; - } - - @Override - protected String buildArtifactName() { - return "spn-fault-diagnosis"; + return event.toString(); + } + + @Override + protected String resolveEnvPath() { + return EnvResolver.resolveEnvPath(); + } + + @Override + 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, 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 void publishRecoveryLifecycle(RequestContext ctx, SpnTaskInput input) { + AuthorizationPolicy policy = getAuthorizationPolicy(); + boolean inWhitelist = + policy != null + && 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 = + formatRecoveryEvent("已结束", taskId, true, "成功", java.time.Instant.now().toString(), input); + log.info( + "[SPN-Domain-Agent] Recovery result reported via Notification-T: {}", 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"); + } + + @Override + 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 + protected Map buildResponseMetadata(RequestContext ctx, String response) { + Map metadata = new LinkedHashMap<>(); + metadata.put(NegotiationUtils.TASK_PROMPT_KEY, response); + return metadata; + } + + @Override + protected String buildResultSummary() { + return "专线业务投诉诊断任务诊断结果消息"; + } + + @Override + protected String buildArtifactName() { + return "spn-fault-diagnosis"; + } } 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 b9e4c7fd..00355786 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 @@ -20,17 +20,16 @@ package dev.openan.workflow.engine.examples.agents; import dev.openan.workflow.engine.examples.extension.PrePositionedExtensionHandler; +import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; +import dev.openan.workflow.engine.examples.util.EnvResolver; import dev.openan.workflow.engine.examples.util.LlmHelper; +import java.util.LinkedHashMap; +import java.util.Map; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -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). * @@ -39,79 +38,79 @@ * Authorization-T and Notification-T are initiated on independent channels. */ public class SpnDomainAgentCity2Executor extends NegotiationBaseAgentExecutor { - private static final Logger log = LoggerFactory.getLogger(SpnDomainAgentCity2Executor.class); + 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 = - "1. 诊断结果:成功\n" - + "2. 诊断结果详情:城市2OMC端口状态正常,光功率-17dBm(正常范围),无异常告警,故障不在此地市\n" - + "3. 修复建议:城市2无需修复"; + // Diagnosis result placed in artifact.metadata[Task-T URI], mirroring spec case 7.1 (normal). + private static final String NORMAL_DIAGNOSIS_RESULT = + "1. 诊断结果:成功\n" + "2. 诊断结果详情:城市2OMC端口状态正常,光功率-17dBm(正常范围),无异常告警,故障不在此地市\n" + "3. 修复建议:城市2无需修复"; - public SpnDomainAgentCity2Executor() { - super(); - } + public SpnDomainAgentCity2Executor() { + super(); + } - public SpnDomainAgentCity2Executor(PrePositionedExtensionHandler extensionHandler) { - super(extensionHandler); - } + public SpnDomainAgentCity2Executor(PrePositionedExtensionHandler extensionHandler) { + super(extensionHandler); + } - 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; + 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 - protected String resolveEnvPath() { - return EnvResolver.resolveEnvPath(); - } + @Override + protected String resolveEnvPath() { + return EnvResolver.resolveEnvPath(); + } - @Override - 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 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 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 + 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 - protected Map buildResponseMetadata(RequestContext ctx, String response) { - Map metadata = new LinkedHashMap<>(); - metadata.put(NegotiationUtils.TASK_PROMPT_KEY, response); - return metadata; - } + @Override + protected Map buildResponseMetadata(RequestContext ctx, String response) { + Map metadata = new LinkedHashMap<>(); + metadata.put(NegotiationUtils.TASK_PROMPT_KEY, response); + return metadata; + } - @Override - protected String buildResultSummary() { - return "专线业务投诉诊断任务诊断结果消息"; - } + @Override + protected String buildResultSummary() { + return "专线业务投诉诊断任务诊断结果消息"; + } - @Override - protected String buildArtifactName() { - return "spn-fault-diagnosis"; - } + @Override + protected String buildArtifactName() { + return "spn-fault-diagnosis"; + } } 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 index 304eb39b..034b1a42 100644 --- 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 @@ -27,47 +27,52 @@ /** 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; - } + public SpnTaskInput { + parameters = Map.copyOf(parameters); + List invalid = invalidFields(parameters); + if (!invalid.isEmpty()) + throw new IllegalArgumentException("Incomplete complaint parameters: " + invalid); + } - /** The access port belonging to this validated city task. */ - public String accessPort() { return field(parameters, "任务对象", "接入端口名称"); } + /** 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 original business incident identifier, not the A2A task identifier. */ - public String incidentId() { return field(parameters, "任务上下文", "OSS侧事件流水号"); } + 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; + } - /** Business-only diagnostic input; never concatenate raw protocol or unvalidated replies here. */ - public String diagnosisInput() { - return "已校验投诉参数\n任务对象:" + parameters.get("任务对象") - + "\n任务上下文:" + parameters.get("任务上下文"); + 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); + } - 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; - } + /** The access port belonging to this validated city task. */ + public String accessPort() { + return field(parameters, "任务对象", "接入端口名称"); + } - 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); - } + /** 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("任务上下文"); + } } 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 index a9764df8..a57790cd 100644 --- 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 @@ -11,47 +11,54 @@ @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; + 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; } + /** + * 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; } + /** 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 String getOrchUrl() { + return orchUrl; + } - public void setOrchUrl(String orchUrl) { - this.orchUrl = orchUrl; - } + public void setOrchUrl(String orchUrl) { + this.orchUrl = orchUrl; + } - public String getCredentialsPath() { - return credentialsPath; - } + public String getCredentialsPath() { + return credentialsPath; + } - public void setCredentialsPath(String credentialsPath) { - this.credentialsPath = credentialsPath; - } + public void setCredentialsPath(String credentialsPath) { + this.credentialsPath = credentialsPath; + } - public boolean isSslVerify() { - return sslVerify; - } + public boolean isSslVerify() { + return sslVerify; + } - public void setSslVerify(boolean sslVerify) { - this.sslVerify = sslVerify; - } + public void setSslVerify(boolean sslVerify) { + this.sslVerify = sslVerify; + } - public String getA2atEnvPath() { - return a2atEnvPath; - } + public String getA2atEnvPath() { + return a2atEnvPath; + } - public void setA2atEnvPath(String a2atEnvPath) { - this.a2atEnvPath = 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 index a8227cac..3efa8552 100644 --- 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 @@ -14,10 +14,6 @@ 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; @@ -27,399 +23,475 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** - * 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. + * 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 Logger log = LoggerFactory.getLogger(A2ATProtocolCases.class); - private static final String NEGOTIATION_T_URI = A2ATExtension.NEGOTIATION_T.uri(); + 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 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; + private final OmcAgentLauncher omc = new OmcAgentLauncher(); + 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 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 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 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 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 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); + 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"); + } - List cards = - List.of(OmcAgentLauncher.cardFromResource( - "agentcard/spn_domain_agent_city1.json")); - WorkflowEngineClientConfig config = - WorkflowEngineClientConfig.builder() - .sslVerify(false) + 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); - .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); - } + 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(); - } + 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()); + /** 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); } - - 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()); } + 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 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_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 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 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_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_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_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 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 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_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 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 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_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_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_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_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_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 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 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_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 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_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_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"); + 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/HostQuickStart.java b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/HostQuickStart.java new file mode 100644 index 00000000..782cf4d4 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/HostQuickStart.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +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.WorkflowEngineClient; +import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; +import dev.openan.workflow.engine.control.ControlPoint; +import dev.openan.workflow.engine.model.*; +import dev.openan.workflow.engine.registry.RegistryClient; +import dev.openan.workflow.engine.runner.ExecutePsop; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.a2aproject.sdk.spec.AgentCard; + +/** Minimal plain-A2A host. SpringSpnDemo demonstrates host-owned A2A-T content generation. */ +public final class HostQuickStart { + private HostQuickStart() {} + + /** Arguments: registry URL, target agent name, credentials JSON path. */ + public static void main(String[] args) throws Exception { + if (args.length != 3) { + throw new IllegalArgumentException("Expected: registry-url agent-name credentials-json"); + } + List cards = loadCards(new RegistryClient(args[0], true)); + try (A2ATransport transport = new A2ATransport(cards, null, + WorkflowEngineClientConfig.builder().sslVerify(true).credentialsConfigPath(args[2]).build())) { + ExecutionResult result = execute(workflow(args[1]), cards, new DefaultWorkflowEngineClient(transport)); + if (!result.isSuccess()) throw new IllegalStateException(result.getError()); + System.out.println(result.getStepOutputs()); + } + } + + /** Converts the registry's JSON maps to the A2A SDK's typed cards. */ + public static List loadCards(RegistryClient registry) throws Exception { + ObjectMapper mapper = new ObjectMapper().registerModule(new AgentCardJacksonModule()); + return registry.fetchAgentCards().stream() + .map(card -> mapper.convertValue(card, AgentCard.class)).toList(); + } + + /** A remote task followed by local aggregation, with unconditional routing. */ + public static Workflow workflow(String agentName) { + return Workflow.builder().name("host-quick-start").steps(List.of( + WorkflowStep.builder().name("diagnose").layer(0) + .subtasks(List.of(Task.builder().agent(agentName).description("Diagnose the supplied issue").build())) + .next(List.of(JumpCondition.builder().step("aggregate").condition("").build())).build(), + WorkflowStep.builder().name("aggregate").layer(1).stepType(StepType.SELF_LOOP) + .subtasks(List.of(Task.builder().agent("host").description("Aggregate results").build())) + .contextFrom(List.of("diagnose")).build())).build(); + } + + /** The caller owns client/transport; timeout and interruption stop further local dispatch. */ + public static ExecutionResult execute(Workflow workflow, List cards, WorkflowEngineClient client) + throws Exception { + 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()))))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Host review required"))) + .build(); + CompletableFuture execution = ExecutePsop.builder().psop(workflow).agentCards(cards) + .engineClient(client).controlPoint(callbacks).runtimeIntent("Diagnose the supplied issue").execute(); + try { + return execution.get(10, TimeUnit.MINUTES); + } finally { + if (!execution.isDone()) execution.cancel(true); + } + } +} 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 index 5c2ee393..3291f54a 100644 --- 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 @@ -21,210 +21,213 @@ 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; +import net.openan.a2at.sdk.core.model.StandardTemplates; /** * 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. + *

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. + *

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; - } + /** 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<>(); + // The condition is optional in the SDK slot schema, but an empty value renders a bare section + // header that real-LLM semantic validation rejects as a missing parameter; carry an explicit + // constraint instead. + data.put("订阅条件", "子网名称:SPN承载子网"); + 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 index f652d5d8..4b564284 100644 --- 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 @@ -20,7 +20,6 @@ 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; @@ -33,16 +32,14 @@ import dev.openan.workflow.engine.examples.workbench.SpringWorkbenchExtensionLifecycle; import dev.openan.workflow.engine.model.SendMessageResult; import dev.openan.workflow.engine.spring.A2AController; - +import java.util.List; +import java.util.concurrent.TimeUnit; 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). * @@ -59,208 +56,213 @@ * 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 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 static final String WB_AGENT_NAME = "Transport Workbench Agent"; - private static final long STARTUP_WAIT = 3; + private final OmcAgentLauncher omc = new OmcAgentLauncher(); - private final OmcAgentLauncher omc = new OmcAgentLauncher(); + public static void main(String[] args) throws Exception { + SpringWorkbenchApplication.loadDotEnv(); + new SpringSpnDemo().run(args); + } - public static void main(String[] args) throws Exception { - SpringWorkbenchApplication.loadDotEnv(); - new SpringSpnDemo().run(args); + 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")); } + } - 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)); + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } - 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)); + 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=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); - } + 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.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)); + 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); } - } - - /** - * 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(); + // 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)); } + } - 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")); - } - } + /** + * 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(); - private static long elapsedMillis(long startedNanos) { - return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + 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(); } + } } 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 index 9c3ec2a3..971d26b2 100644 --- 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 @@ -21,177 +21,162 @@ */ 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"); - } + 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); } - - /** 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); + rules = List.copyOf(Objects.requireNonNull(rules, "Authorization rules are required")); + if (rules.isEmpty()) { + throw new IllegalArgumentException("Authorization policy must contain at least one rule"); } - - /** 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; + } + + /** 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"); } - - /** 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); + 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)); } - - /** 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), "策略标识"); + 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 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); + } - 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); + 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; + } + + /** 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)); + } + + /** 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"); + } } - /** 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 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 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; + private static LocalDate parseDate(String value) { + return LocalDate.parse(value.strip()); } - /** 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); - } + 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 index 533b97b5..a851f4e2 100644 --- 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 @@ -36,7 +36,8 @@ * 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. + * subscription for each non-workbench agent on separate channels. Individual workflow tasks do not + * own these operations. */ public class ExtensionPrePositioner { @@ -54,8 +55,9 @@ public ExtensionPrePositioner() { } 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))); + 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). @@ -66,6 +68,30 @@ public ExtensionPrePositioner(String envPath) { this.notifSchema = SdkSlotSchemaLoader.loadConfigured(StandardTemplates.SERVICE_RECOVERY); } + 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")); + } + } + /** * Pre-position Authorization-T + Notification-T to every non-workbench agent. * @@ -77,7 +103,9 @@ public List prePosition( ExtensionSender authorizationSender, ExtensionSender notificationSender, List agentCards, - java.util.function.BiConsumer notificationCallback) { + java.util.function.BiConsumer< + NotificationSubscription, dev.openan.workflow.engine.model.ReceivedMessage> + notificationCallback) { return prePosition( authorizationSender, notificationSender, agentCards, notificationCallback, null); } @@ -90,7 +118,9 @@ public List prePosition( ExtensionSender authorizationSender, ExtensionSender notificationSender, List agentCards, - java.util.function.BiConsumer notificationCallback, + java.util.function.BiConsumer< + NotificationSubscription, dev.openan.workflow.engine.model.ReceivedMessage> + notificationCallback, Consumer subscriptionOpened) { long allStarted = System.nanoTime(); int targetCount = 0; @@ -139,12 +169,15 @@ public List prePosition( 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); + 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); } @@ -193,11 +226,17 @@ public List prePosition( 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(); + 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()); @@ -221,28 +260,4 @@ public List prePosition( 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 index c22549fb..a1b6c887 100644 --- 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 @@ -11,30 +11,30 @@ /** 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 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 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)); + 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; + 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 index c748e717..2247b957 100644 --- 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 @@ -18,6 +18,21 @@ */ package dev.openan.workflow.engine.examples.extension; +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.agents.BaseAgentExecutor; +import dev.openan.workflow.engine.examples.util.EnvResolver; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.model.A2ATConfig; +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.core.validation.ContentValidationException; +import net.openan.a2at.sdk.server.A2ATServer; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.a2aproject.sdk.spec.Message; @@ -25,34 +40,17 @@ 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: + * 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 @@ -64,466 +62,443 @@ * 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"; + 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); - } + public PrePositionedExtensionHandler() { + this(null, null); + } - /** Validation seam for deterministic Notification-T integration tests. */ - @FunctionalInterface - public interface NotificationPromptValidator { - FilledParamData validate(String prompt, Map schema, TemplateUri templateUri); - } + public PrePositionedExtensionHandler(AuthorizationPromptValidator authorizationValidator) { + this(authorizationValidator, null); + } - public PrePositionedExtensionHandler() { - this(null, null); - } + public PrePositionedExtensionHandler( + AuthorizationPromptValidator authorizationValidator, + NotificationPromptValidator notificationValidator) { + this.authorizationValidator = authorizationValidator; + this.notificationValidator = notificationValidator; + } - public PrePositionedExtensionHandler(AuthorizationPromptValidator authorizationValidator) { - this(authorizationValidator, 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); + } - public PrePositionedExtensionHandler( - AuthorizationPromptValidator authorizationValidator, - NotificationPromptValidator notificationValidator) { - this.authorizationValidator = authorizationValidator; - this.notificationValidator = notificationValidator; + 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(); + } - 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; - } + 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); + } } + } - /** - * 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(); + 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 (msg.metadata().containsKey(A2ATExtension.NOTIFICATION_T.uri())) { - return A2ATExtension.NOTIFICATION_T.uri(); + if (!line.isBlank()) { + if (!value.isEmpty()) { + value.append('\n'); + } + value.append(line.strip()); } - return null; + } + 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 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); - } + private static String normalizeSlotValue(String value) { + return value == null ? "" : value.strip().replaceAll("\\s+", " "); + } - /** The SDK-validated Authorization-T whitelist, or null when none has been accepted. */ - public AuthorizationPolicy getAuthorizationPolicy() { - return authorizationPolicy; + 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); } + } - /** The SDK-validated Notification-T subscription, or null. */ - public NotificationPolicy getNotificationSubscription() { - return notificationSubscription; + 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 Map validationSchema(TemplateUri templateUri) { - return SdkSlotSchemaLoader.load(templateUri, validationLanguage); - } + 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)); + } - /** - * 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); + 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; } + } - 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); - } - } + /** The SDK-validated Authorization-T whitelist, or null when none has been accepted. */ + public AuthorizationPolicy getAuthorizationPolicy() { + return authorizationPolicy; + } - 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()); - } + /** The SDK-validated Notification-T subscription, or null. */ + public NotificationPolicy getNotificationSubscription() { + return notificationSubscription; + } - 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(); - } + private Map validationSchema(TemplateUri templateUri) { + return SdkSlotSchemaLoader.load(templateUri, validationLanguage); + } - record AuthorizationOperationResult(String text, String metadataText) { - AuthorizationOperationResult { - Objects.requireNonNull(text, "Authorization response text"); - Objects.requireNonNull(metadataText, "Authorization response metadata"); - } + /** + * 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; } - - 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; + // 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); } - - private FilledParamData validateAuthorizationWithSdk(A2ATServer server, String prompt) { - return server.validateAuthPromptAndDataFilling( - prompt, - validationSchema(StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT), - StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT.uri()); + 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); + } - /** 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; - } + 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"); } - requireExtractedSectionsMatch( - prompt, - filled.data(), - NotificationPolicy.CONDITION_FIELD, - NotificationPolicy.REPORT_FORMAT_FIELD); - NotificationPolicy candidate = NotificationPolicy.fromValidated(filled.data()); - notificationSubscription = candidate; + authorizationPolicy = null; + authorizationPolicyId = null; + authorizationCreatedAt = null; + authorizationModifiedAt = null; 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); - } + "[{}] 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 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 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 normalizeSlotValue(String value) { - return value == null ? "" : value.strip().replaceAll("\\s+", " "); + 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 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 FilledParamData validateAuthorizationWithSdk(A2ATServer server, String prompt) { + return server.validateAuthPromptAndDataFilling( + prompt, + validationSchema(StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT), + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT.uri()); + } - 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(); + /** 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; + } + + /** 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); + } - 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)); + record AuthorizationOperationResult(String text, String metadataText) { + AuthorizationOperationResult { + Objects.requireNonNull(text, "Authorization response text"); + Objects.requireNonNull(metadataText, "Authorization response metadata"); } + } } 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 index ac53a6d8..6be8b519 100644 --- 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 @@ -20,37 +20,47 @@ import dev.openan.workflow.engine.client.A2ATExtension; import dev.openan.workflow.engine.model.ReceivedMessage; +import java.util.LinkedHashMap; 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. + * 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() {} + 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())); - } + /** 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("业务抢通方案执行结果")); + 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 index 6e0e0180..74e7c3c6 100644 --- 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 @@ -8,6 +8,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openan.workflow.engine.examples.util.EnvResolver; import java.io.IOException; import java.io.InputStream; import java.nio.file.Path; @@ -15,41 +16,42 @@ 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 static final ObjectMapper MAPPER = new ObjectMapper(); - private SdkSlotSchemaLoader() {} + 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 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); - } + /** 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 index e31a8f24..72a79ea7 100644 --- 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 @@ -23,125 +23,164 @@ import dev.openan.workflow.engine.client.A2atMessages; import dev.openan.workflow.engine.examples.util.A2ATInitialization; import dev.openan.workflow.engine.model.*; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Pattern; 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. */ +/** + * 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; + 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))); - } + /** 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 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 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 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 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 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; + } - 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("终止本次协商")))); - } + @Override + public CompletableFuture resolve(NegotiationRequest request) { + return CompletableFuture.supplyAsync( + () -> + dev.openan.workflow.engine.examples.util.BusinessSdkErrors.call( + "negotiation-reply", () -> answer(request))); + } - /** - * 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 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, "当前示例策略需要人工确认目标或可行性"); } - - 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("拒绝本次信息请求")))); + 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, "协商未包含可识别的请求字段"); } - - 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")); + Map data; + try { + data = dev.openan.workflow.engine.examples.demo.SpnCasePrompts.complaintData(request.task()); + } catch (IllegalArgumentException error) { + return abort(context, "当前任务没有可核实的结构化补充信息"); } - - 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; + // 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 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("拒绝本次信息请求")))); + } } 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 index df7ed634..0e9c8fd7 100644 --- 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 @@ -20,51 +20,48 @@ 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. + * 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() {} + /** Standard Negotiation-T extension URI (spec §2). */ + public static final String NEGOTIATION_T_URI = A2ATExtension.NEGOTIATION_T.uri(); - /** - * 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); - } + /** Standard Task-T extension URI (spec §2). */ + public static final String TASK_PROMPT_KEY = A2ATExtension.TASK_T.uri(); + /** + * 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; - /** - * 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); - } + private NegotiationUtils() {} - /** - * 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; + /** + * 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); + } } 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 b7638b10..a672451a 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 @@ -21,12 +21,34 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.protobuf.util.JsonFormat; -import dev.openan.workflow.engine.client.AgentCardJacksonModule; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpsConfigurator; import com.sun.net.httpserver.HttpsParameters; import com.sun.net.httpserver.HttpsServer; - +import dev.openan.workflow.engine.client.AgentCardJacksonModule; +import java.io.IOException; +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; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Flow; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; import org.a2aproject.sdk.grpc.SendMessageRequest; import org.a2aproject.sdk.grpc.StreamResponse; import org.a2aproject.sdk.grpc.utils.ProtoUtils; @@ -54,533 +76,523 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; -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; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Flow; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; - -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLParameters; - public class JdkHttpA2AServer implements AutoCloseable { - private static final Logger log = LoggerFactory.getLogger(JdkHttpA2AServer.class); - - private static final ObjectMapper mapper = new ObjectMapper(); - - private static final ObjectMapper cardMapper = - new ObjectMapper().registerModule(new AgentCardJacksonModule()); - - private static final int THREAD_COUNT = 8; - - private static final String DEFAULT_KEYSTORE_RESOURCE = "a2a-server-keystore.p12"; - - private static final String DEFAULT_KEYSTORE_PASSWORD = "changeit"; - - private final String keystoreResource; - - private final String keystorePassword; - - private final HttpsServer server; - - private final ExecutorService executorService; - - private final ExecutorService agentExecutorService; - - private final Map agentCardMap; - - private final String agentName; - - private final String pathPrefix; - - @SuppressWarnings("unchecked") - public JdkHttpA2AServer( - String host, int port, Map agentCard, AgentExecutor agentExecutor) - throws IOException { - this(host, port, agentCard, agentExecutor, DEFAULT_KEYSTORE_RESOURCE, DEFAULT_KEYSTORE_PASSWORD); + private static final Logger log = LoggerFactory.getLogger(JdkHttpA2AServer.class); + + private static final ObjectMapper mapper = new ObjectMapper(); + + private static final ObjectMapper cardMapper = + new ObjectMapper().registerModule(new AgentCardJacksonModule()); + + private static final int THREAD_COUNT = 8; + + private static final String DEFAULT_KEYSTORE_RESOURCE = "a2a-server-keystore.p12"; + + private static final String DEFAULT_KEYSTORE_PASSWORD = "changeit"; + + private final String keystoreResource; + + private final String keystorePassword; + + private final HttpsServer server; + + private final ExecutorService executorService; + + private final ExecutorService agentExecutorService; + + private final Map agentCardMap; + + private final String agentName; + + private final String pathPrefix; + + @SuppressWarnings("unchecked") + public JdkHttpA2AServer( + String host, int port, Map agentCard, AgentExecutor agentExecutor) + throws IOException { + this( + host, port, agentCard, agentExecutor, DEFAULT_KEYSTORE_RESOURCE, DEFAULT_KEYSTORE_PASSWORD); + } + + public JdkHttpA2AServer( + String host, + int port, + Map agentCard, + AgentExecutor agentExecutor, + String keystoreResource, + String keystorePassword) + throws IOException { + this.keystoreResource = keystoreResource; + this.keystorePassword = keystorePassword; + this.agentCardMap = agentCard; + this.agentName = String.valueOf(agentCard.getOrDefault("name", "unknown")); + this.pathPrefix = extractPathPrefix(agentCard); + AgentCard typedCard = toTypedAgentCard(agentCard); + this.agentExecutorService = + new ThreadPoolExecutor( + THREAD_COUNT, + THREAD_COUNT, + 0L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(), + r -> { + Thread t = new Thread(r, "a2a-server-" + agentName); + t.setDaemon(true); + return t; + }); + RestHandler restHandler = initServerComponents(agentCard, agentExecutor, typedCard); + this.executorService = + new ThreadPoolExecutor( + THREAD_COUNT, + THREAD_COUNT, + 0L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(), + r -> { + Thread t = new Thread(r, "a2a-http-" + agentName); + t.setDaemon(true); + return t; + }); + this.server = HttpsServer.create(new InetSocketAddress(host, port), 0); + SSLContext sslContext = createSslContext(); + this.server.setHttpsConfigurator( + new HttpsConfigurator(sslContext) { + @Override + public void configure(HttpsParameters params) { + SSLParameters sslParams = sslContext.getDefaultSSLParameters(); + params.setSSLParameters(sslParams); + } + }); + this.server.setExecutor(executorService); + this.server.createContext( + pathPrefix.isEmpty() ? "/" : pathPrefix, exchange -> handleExchange(exchange, restHandler)); + if (hasSecuritySchemes(agentCard)) { + this.server.createContext("/rest/plat/smapp/v1/oauth/token", this::handleLogin); + log.info("[{}] Auth login endpoint enabled", agentName); } - public JdkHttpA2AServer( - String host, - int port, - Map agentCard, - AgentExecutor agentExecutor, - String keystoreResource, - String keystorePassword) - throws IOException { - this.keystoreResource = keystoreResource; - this.keystorePassword = keystorePassword; - this.agentCardMap = agentCard; - this.agentName = String.valueOf(agentCard.getOrDefault("name", "unknown")); - this.pathPrefix = extractPathPrefix(agentCard); - AgentCard typedCard = toTypedAgentCard(agentCard); - this.agentExecutorService = - new ThreadPoolExecutor( - THREAD_COUNT, - THREAD_COUNT, - 0L, - TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>(), - r -> { - Thread t = new Thread(r, "a2a-server-" + agentName); - t.setDaemon(true); - return t; - }); - RestHandler restHandler = initServerComponents(agentCard, agentExecutor, typedCard); - this.executorService = - new ThreadPoolExecutor( - THREAD_COUNT, - THREAD_COUNT, - 0L, - TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>(), - r -> { - Thread t = new Thread(r, "a2a-http-" + agentName); - t.setDaemon(true); - return t; - }); - this.server = HttpsServer.create(new InetSocketAddress(host, port), 0); - SSLContext sslContext = createSslContext(); - this.server.setHttpsConfigurator( - new HttpsConfigurator(sslContext) { - @Override - public void configure(HttpsParameters params) { - SSLParameters sslParams = sslContext.getDefaultSSLParameters(); - params.setSSLParameters(sslParams); - } - }); - this.server.setExecutor(executorService); - this.server.createContext( - pathPrefix.isEmpty() ? "/" : pathPrefix, - exchange -> handleExchange(exchange, restHandler)); - if (hasSecuritySchemes(agentCard)) { - this.server.createContext("/rest/plat/smapp/v1/oauth/token", this::handleLogin); - log.info("[{}] Auth login endpoint enabled", agentName); - } - - log.info("[{}] A2A server started on https://{}:{}/", agentName, host, port); + log.info("[{}] A2A server started on https://{}:{}/", agentName, host, port); + } + + private static String formatSse(long seq, String payload) { + String compact = + payload == null + ? "" + : payload + .replace("\r\n", "\n") + .replace('\r', '\n') + .lines() + .map(String::trim) + .reduce("", String::concat); + return String.format(Locale.ROOT, "id:%d%n", seq) + "data:" + compact + "\n\n"; + } + + private static StreamResponse toStreamResponse(StreamingEventKind event) { + StreamResponse.Builder b = StreamResponse.newBuilder(); + if (event instanceof Message m) { + b.setMessage(ProtoUtils.ToProto.message(m)); + return b.build(); } - - private SSLContext createSslContext() throws IOException { - try (InputStream is = - JdkHttpA2AServer.class.getClassLoader().getResourceAsStream(keystoreResource)) { - if (is == null) { - throw new IOException("Keystore not found on classpath: " + keystoreResource); - } - KeyStore ks = KeyStore.getInstance("PKCS12"); - ks.load(is, keystorePassword.toCharArray()); - KeyManagerFactory kmf = - KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(ks, keystorePassword.toCharArray()); - SSLContext ctx = SSLContext.getInstance("TLS"); - ctx.init(kmf.getKeyManagers(), null, null); - return ctx; - } catch (Exception e) { - throw new IOException("Failed to init SSL context: " + e.getMessage(), e); - } + if (event instanceof Task t) { + b.setTask(ProtoUtils.ToProto.task(t)); + return b.build(); } - - private static String formatSse(long seq, String payload) { - String compact = - payload == null - ? "" - : payload.replace("\r\n", "\n") - .replace('\r', '\n') - .lines() - .map(String::trim) - .reduce("", String::concat); - return String.format(Locale.ROOT, "id:%d%n", seq) + "data:" + compact + "\n\n"; + if (event instanceof TaskStatusUpdateEvent e) { + b.setStatusUpdate(ProtoUtils.ToProto.taskStatusUpdateEvent(e)); + return b.build(); } - - private static StreamResponse toStreamResponse(StreamingEventKind event) { - StreamResponse.Builder b = StreamResponse.newBuilder(); - if (event instanceof Message m) { - b.setMessage(ProtoUtils.ToProto.message(m)); - return b.build(); - } - if (event instanceof Task t) { - b.setTask(ProtoUtils.ToProto.task(t)); - return b.build(); - } - if (event instanceof TaskStatusUpdateEvent e) { - b.setStatusUpdate(ProtoUtils.ToProto.taskStatusUpdateEvent(e)); - return b.build(); - } - if (event instanceof TaskArtifactUpdateEvent e) { - b.setArtifactUpdate(ProtoUtils.ToProto.taskArtifactUpdateEvent(e)); - return b.build(); - } - throw new IllegalArgumentException("Unsupported event: " + event); + if (event instanceof TaskArtifactUpdateEvent e) { + b.setArtifactUpdate(ProtoUtils.ToProto.taskArtifactUpdateEvent(e)); + return b.build(); } - - private static RequestHandler extractRequestHandler(RestHandler restHandler) { + throw new IllegalArgumentException("Unsupported event: " + event); + } + + private static RequestHandler extractRequestHandler(RestHandler restHandler) { + try { + var f = RestHandler.class.getDeclaredField("requestHandler"); + f.setAccessible(true); + return (RequestHandler) f.get(restHandler); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to access request handler", e); + } + } + + private static ServerCallContext buildCallContext(HttpExchange exchange) { + Map headers = new LinkedHashMap<>(); + exchange + .getRequestHeaders() + .forEach((k, v) -> headers.put(k.toLowerCase(Locale.ROOT), String.join(",", v))); + String ext = firstHeader(headers, "A2A-Extensions", "X-A2A-Extensions"); + Set exts = ext.isBlank() ? Set.of() : Set.of(ext.split(",")); + return new ServerCallContext( + null, + Map.of("headers", headers), + exts, + firstNullableHeader(headers, "A2A-Protocol-Version", "A2A-Version")); + } + + private static String firstHeader(Map h, String a, String b) { + String v = firstNullableHeader(h, a, b); + return v == null ? "" : v; + } + + private static String firstNullableHeader(Map h, String a, String b) { + String v = h.get(a.toLowerCase(Locale.ROOT)); + return v != null ? v : h.get(b.toLowerCase(Locale.ROOT)); + } + + private static String readBody(HttpExchange exchange) throws IOException { + return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + + private static void sendJson(HttpExchange exchange, int status, Object data) throws IOException { + String json = data instanceof String ? (String) data : mapper.writeValueAsString(data); + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + private static AgentCard toTypedAgentCard(Map card) { + return cardMapper.convertValue(card, AgentCard.class); + } + + private static boolean hasSecuritySchemes(Map card) { + return card.containsKey("securitySchemes") + && card.get("securitySchemes") instanceof Map + && !((Map) card.get("securitySchemes")).isEmpty() + && card.containsKey("securityRequirements"); + } + + @SuppressWarnings("unchecked") + private static String extractPathPrefix(Map agentCard) { + List> interfaces = + (List>) agentCard.getOrDefault("supportedInterfaces", List.of()); + for (Map iface : interfaces) { + if ("HTTP+JSON".equalsIgnoreCase(String.valueOf(iface.get("protocolBinding")))) { + String url = String.valueOf(iface.getOrDefault("url", "")); try { - var f = RestHandler.class.getDeclaredField("requestHandler"); - f.setAccessible(true); - return (RequestHandler) f.get(restHandler); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Failed to access request handler", e); + String path = java.net.URI.create(url).getPath(); + if (path != null && !path.isEmpty() && !"/".equals(path)) { + return path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + } + } catch (Exception ignored) { } + break; + } } - - private static ServerCallContext buildCallContext(HttpExchange exchange) { - Map headers = new LinkedHashMap<>(); - exchange.getRequestHeaders() - .forEach((k, v) -> headers.put(k.toLowerCase(Locale.ROOT), String.join(",", v))); - String ext = firstHeader(headers, "A2A-Extensions", "X-A2A-Extensions"); - Set exts = ext.isBlank() ? Set.of() : Set.of(ext.split(",")); - return new ServerCallContext( - null, - Map.of("headers", headers), - exts, - firstNullableHeader(headers, "A2A-Protocol-Version", "A2A-Version")); + return ""; + } + + private static void startEventBus(MainEventBusProcessor proc) { + try { + var m = MainEventBusProcessor.class.getDeclaredMethod("start"); + m.setAccessible(true); + m.invoke(proc); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to start event bus", e); } + } - private static String firstHeader(Map h, String a, String b) { - String v = firstNullableHeader(h, a, b); - return v == null ? "" : v; + /** 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; } - - private static String firstNullableHeader(Map h, String a, String b) { - String v = h.get(a.toLowerCase(Locale.ROOT)); - return v != null ? v : h.get(b.toLowerCase(Locale.ROOT)); + 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; } - - private static String readBody(HttpExchange exchange) throws IOException { - return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + if (encoded.isEmpty() || encoded.contains("/")) { + return null; } - - private static void sendJson(HttpExchange exchange, int status, Object data) - throws IOException { - String json = data instanceof String ? (String) data : mapper.writeValueAsString(data); - byte[] bytes = json.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "application/json"); - exchange.sendResponseHeaders(status, bytes.length); - try (OutputStream os = exchange.getResponseBody()) { - os.write(bytes); - } + 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); } - - private static AgentCard toTypedAgentCard(Map card) { - return cardMapper.convertValue(card, AgentCard.class); + } + + private SSLContext createSslContext() throws IOException { + try (InputStream is = + JdkHttpA2AServer.class.getClassLoader().getResourceAsStream(keystoreResource)) { + if (is == null) { + throw new IOException("Keystore not found on classpath: " + keystoreResource); + } + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(is, keystorePassword.toCharArray()); + KeyManagerFactory kmf = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, keystorePassword.toCharArray()); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(kmf.getKeyManagers(), null, null); + return ctx; + } catch (Exception e) { + throw new IOException("Failed to init SSL context: " + e.getMessage(), e); } - - private static boolean hasSecuritySchemes(Map card) { - return card.containsKey("securitySchemes") - && card.get("securitySchemes") instanceof Map - && !((Map) card.get("securitySchemes")).isEmpty() - && card.containsKey("securityRequirements"); + } + + public void start() { + server.start(); + } + + @Override + public void close() { + server.stop(0); + agentExecutorService.shutdownNow(); + executorService.shutdownNow(); + try { + agentExecutorService.awaitTermination(1, TimeUnit.SECONDS); + executorService.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } - - @SuppressWarnings("unchecked") - private static String extractPathPrefix(Map agentCard) { - List> interfaces = - (List>) - agentCard.getOrDefault("supportedInterfaces", List.of()); - for (Map iface : interfaces) { - if ("HTTP+JSON".equalsIgnoreCase(String.valueOf(iface.get("protocolBinding")))) { - String url = String.valueOf(iface.getOrDefault("url", "")); - try { - String path = java.net.URI.create(url).getPath(); - if (path != null && !path.isEmpty() && !"/".equals(path)) { - return path.endsWith("/") ? path.substring(0, path.length() - 1) : path; - } - } catch (Exception ignored) { - } - break; - } - } - return ""; + log.info("[{}] A2A server stopped", agentName); + } + + public Map getAgentCard() { + return agentCardMap; + } + + public String getAgentName() { + return agentName; + } + + private RestHandler initServerComponents( + Map agentCard, AgentExecutor agentExecutor, AgentCard typedCard) { + InMemoryTaskStore taskStore = new InMemoryTaskStore(); + MainEventBus mainEventBus = new MainEventBus(); + InMemoryQueueManager queueManager = new InMemoryQueueManager(taskStore, mainEventBus); + PushNotificationConfigStore pushStore = new InMemoryPushNotificationConfigStore(); + PushNotificationSender pushSender = new BasePushNotificationSender(pushStore); + MainEventBusProcessor eventBusProc = + new MainEventBusProcessor(mainEventBus, taskStore, pushSender, queueManager); + startEventBus(eventBusProc); + RequestHandler requestHandler = + 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), requestHandler, Runnable::run); + } + + private void handleExchange(HttpExchange exchange, RestHandler restHandler) throws IOException { + String fullPath = exchange.getRequestURI().getRawPath(); + String method = exchange.getRequestMethod(); + String path = + pathPrefix.isEmpty() || !fullPath.startsWith(pathPrefix) + ? fullPath + : fullPath.substring(pathPrefix.length()); + if (path.isEmpty()) path = "/"; + try { + if ("GET".equalsIgnoreCase(method) && "/".equals(path)) { + sendJson(exchange, 200, restHandler.getAgentCard().getBody()); + return; + } + + if ("POST".equalsIgnoreCase(method) && "/message:send".equals(path)) { + var resp = restHandler.sendMessage(buildCallContext(exchange), "", readBody(exchange)); + sendJson(exchange, resp.getStatusCode(), resp.getBody()); + return; + } + + if ("POST".equalsIgnoreCase(method) && "/message:stream".equals(path)) { + + 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(exchange, 500, Map.of("error", Map.of("code", 500, "message", e.getMessage()))); + } finally { + exchange.close(); } - - private static void startEventBus(MainEventBusProcessor proc) { - try { - var m = MainEventBusProcessor.class.getDeclaredMethod("start"); - m.setAccessible(true); - m.invoke(proc); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Failed to start event bus", e); - } + } + + @SuppressWarnings("unchecked") + private void handleLogin(HttpExchange exchange) throws IOException { + if (!"PUT".equalsIgnoreCase(exchange.getRequestMethod()) + && !"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + sendJson(exchange, 405, Map.of("error", "Method not allowed")); + return; } - public void start() { - server.start(); + String body = readBody(exchange); + String userName = ""; + String password = ""; + try { + + Map data = mapper.readValue(body, Map.class); + userName = String.valueOf(data.getOrDefault("userName", data.getOrDefault("username", ""))); + password = String.valueOf(data.getOrDefault("value", data.getOrDefault("password", ""))); + } catch (Exception e) { + sendJson(exchange, 400, Map.of("error", "Invalid request body")); + return; } - - @Override - public void close() { - server.stop(0); - agentExecutorService.shutdownNow(); - executorService.shutdownNow(); - try { - agentExecutorService.awaitTermination(1, TimeUnit.SECONDS); - executorService.awaitTermination(1, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - log.info("[{}] A2A server stopped", agentName); - } - - public Map getAgentCard() { - return agentCardMap; + if ("admin".equals(userName) && "Admin@123".equals(password)) { + log.info("[{}] Login succeeded, token issued", agentName); + sendJson(exchange, 200, Map.of("accessSession", UUID.randomUUID().toString())); + } else { + log.warn("[{}] Login failed: bad credentials", agentName); + sendJson(exchange, 401, Map.of("error", "Invalid credentials")); } + } + + @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 String getAgentName() { - return agentName; - } + public void onError(Throwable t) { + done.countDown(); + } - private RestHandler initServerComponents( - Map agentCard, AgentExecutor agentExecutor, AgentCard typedCard) { - InMemoryTaskStore taskStore = new InMemoryTaskStore(); - MainEventBus mainEventBus = new MainEventBus(); - InMemoryQueueManager queueManager = new InMemoryQueueManager(taskStore, mainEventBus); - PushNotificationConfigStore pushStore = new InMemoryPushNotificationConfigStore(); - PushNotificationSender pushSender = new BasePushNotificationSender(pushStore); - MainEventBusProcessor eventBusProc = - new MainEventBusProcessor(mainEventBus, taskStore, pushSender, queueManager); - startEventBus(eventBusProc); - RequestHandler requestHandler = - 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), - requestHandler, - Runnable::run); - } - - private void handleExchange(HttpExchange exchange, RestHandler restHandler) throws IOException { - String fullPath = exchange.getRequestURI().getRawPath(); - String method = exchange.getRequestMethod(); - String path = - pathPrefix.isEmpty() || !fullPath.startsWith(pathPrefix) - ? fullPath - : fullPath.substring(pathPrefix.length()); - if (path.isEmpty()) path = "/"; + public void onComplete() { + done.countDown(); + } + }); try { - if ("GET".equalsIgnoreCase(method) && "/".equals(path)) { - sendJson(exchange, 200, restHandler.getAgentCard().getBody()); - return; - } - - if ("POST".equalsIgnoreCase(method) && "/message:send".equals(path)) { - var resp = - restHandler.sendMessage(buildCallContext(exchange), "", readBody(exchange)); - sendJson(exchange, resp.getStatusCode(), resp.getBody()); - return; - } - - if ("POST".equalsIgnoreCase(method) && "/message:stream".equals(path)) { - - 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( - exchange, 500, Map.of("error", Map.of("code", 500, "message", e.getMessage()))); + done.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } finally { - exchange.close(); + os.close(); } + } else { + sendJson(exchange, resp.getStatusCode(), resp.getBody()); + } + } catch (A2AError e) { + sendJson(exchange, 500, Map.of("error", e.getMessage())); } - - /** 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; + } + + @SuppressWarnings("unchecked") + private void handleStream(HttpExchange exchange, RestHandler restHandler, String requestBody) + throws IOException { + try { + RequestHandler inner = extractRequestHandler(restHandler); + SendMessageRequest.Builder builder = SendMessageRequest.newBuilder(); + JsonFormat.parser().merge(requestBody, builder); + var request = ProtoUtils.FromProto.messageSendParams(builder.build()); + Flow.Publisher publisher = + inner.onMessageSendStream(request, buildCallContext(exchange)); + 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(); + publisher.subscribe( + new Flow.Subscriber<>() { + private Flow.Subscription sub; + + public void onSubscribe(Flow.Subscription s) { + sub = s; + s.request(1); } - 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()) - && !"POST".equalsIgnoreCase(exchange.getRequestMethod())) { - sendJson(exchange, 405, Map.of("error", "Method not allowed")); - return; - } - - String body = readBody(exchange); - String userName = ""; - String password = ""; - try { - - Map data = mapper.readValue(body, Map.class); - userName = - String.valueOf( - data.getOrDefault("userName", data.getOrDefault("username", ""))); - password = - String.valueOf(data.getOrDefault("value", data.getOrDefault("password", ""))); - } catch (Exception e) { - sendJson(exchange, 400, Map.of("error", "Invalid request body")); - return; - } - if ("admin".equals(userName) && "Admin@123".equals(password)) { - log.info("[{}] Login succeeded, token issued", agentName); - sendJson(exchange, 200, Map.of("accessSession", UUID.randomUUID().toString())); - } else { - log.warn("[{}] Login failed: bad credentials", agentName); - sendJson(exchange, 401, Map.of("error", "Invalid credentials")); - } - } + public void onNext(StreamingEventKind item) { + try { + String payload = JsonFormat.printer().print(toStreamResponse(item)); + os.write( + formatSse(seq.incrementAndGet(), payload).getBytes(StandardCharsets.UTF_8)); + os.flush(); + sub.request(1); + } catch (IOException e) { + sub.cancel(); + done.countDown(); + } + } - @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()); + public void onError(Throwable t) { + done.countDown(); } - } catch (A2AError e) { - sendJson(exchange, 500, Map.of("error", e.getMessage())); - } - } - @SuppressWarnings("unchecked") - private void handleStream(HttpExchange exchange, RestHandler restHandler, String requestBody) - throws IOException { - try { - RequestHandler inner = extractRequestHandler(restHandler); - SendMessageRequest.Builder builder = SendMessageRequest.newBuilder(); - JsonFormat.parser().merge(requestBody, builder); - var request = ProtoUtils.FromProto.messageSendParams(builder.build()); - Flow.Publisher publisher = - inner.onMessageSendStream(request, buildCallContext(exchange)); - 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(); - publisher.subscribe( - new Flow.Subscriber<>() { - private Flow.Subscription sub; - - public void onSubscribe(Flow.Subscription s) { - sub = s; - s.request(1); - } - - public void onNext(StreamingEventKind item) { - try { - String payload = JsonFormat.printer().print(toStreamResponse(item)); - os.write( - formatSse(seq.incrementAndGet(), payload) - .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(); + public void onComplete() { + done.countDown(); } - } catch (A2AError e) { - sendJson(exchange, 500, Map.of("error", e.getMessage())); - } + }); + try { + done.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + os.close(); + } + } catch (A2AError e) { + sendJson(exchange, 500, Map.of("error", e.getMessage())); } + } } 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 index ea0a8535..cedca55f 100644 --- 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 @@ -20,16 +20,13 @@ 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; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.spec.AgentCard; /** * Starts and tracks {@link JdkHttpA2AServer} instances from agentcard resources. Centralizes the @@ -37,61 +34,61 @@ */ public final class OmcAgentLauncher implements AutoCloseable { - private static final ObjectMapper MAPPER = - new ObjectMapper().registerModule(new AgentCardJacksonModule()); + private static final ObjectMapper MAPPER = + new ObjectMapper().registerModule(new AgentCardJacksonModule()); - private final List servers = new ArrayList<>(); + 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); - } + /** 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); + } - /** 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; - } + /** 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(); + } - /** 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); - } + /** 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); + } - /** 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(); - } + /** 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; + } - public List servers() { - return servers; - } + 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(); + @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/util/A2ATInitialization.java b/samples/src/main/java/dev/openan/workflow/engine/examples/util/A2ATInitialization.java index 23e7f9f6..c2ee36c5 100644 --- 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 @@ -30,45 +30,44 @@ * closing another facade's shared handle. No global URL cache setting is changed. */ public final class A2ATInitialization { - private 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); - } + /** + * 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; - } - }); - } + 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 index 86c6ffeb..321a0c9c 100644 --- 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 @@ -17,22 +17,28 @@ * 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; +import net.openan.a2at.sdk.core.exception.A2ATError; -/** Host-owned SDK error translation. Raw provider errors/input/configuration never cross the callback boundary. */ +/** + * 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)); - } + 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/util/EnvResolver.java b/samples/src/main/java/dev/openan/workflow/engine/examples/util/EnvResolver.java index 3e064207..5c5216d9 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/util/EnvResolver.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/util/EnvResolver.java @@ -27,37 +27,37 @@ * Shared utility for resolving the A2A-T {@code .env} file path. * *

    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 + * 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() {} + 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(); - } - File cwd = new File(System.getProperty("user.dir")); - for (File dir = cwd; dir != null; dir = dir.getParentFile()) { - File candidate = new File(dir, ".env"); - if (candidate.exists()) { - return candidate.getAbsolutePath(); - } - } - return null; + 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(); + } + File cwd = new File(System.getProperty("user.dir")); + for (File dir = cwd; dir != null; dir = dir.getParentFile()) { + File candidate = new File(dir, ".env"); + if (candidate.exists()) { + return candidate.getAbsolutePath(); + } + } + return null; + } } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/util/LlmHelper.java b/samples/src/main/java/dev/openan/workflow/engine/examples/util/LlmHelper.java index a21ae860..def77f4e 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/util/LlmHelper.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/util/LlmHelper.java @@ -21,10 +21,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; @@ -36,271 +32,273 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Sample LLM helper that calls an OpenAI-compatible chat completions endpoint. * - *

    This helper intentionally lives in samples: the workflow engine itself - * does not call an LLM directly. Callers must provide a deterministic fallback - * for missing configuration and transient model failures. + *

    This helper intentionally lives in samples: the workflow engine itself does not call an LLM + * directly. Callers must provide a deterministic fallback for missing configuration and transient + * model failures. */ public final class LlmHelper { - private static final Logger log = LoggerFactory.getLogger(LlmHelper.class); - private static final ObjectMapper MAPPER = new ObjectMapper(); - private static volatile Config cached; + private static final Logger log = LoggerFactory.getLogger(LlmHelper.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static volatile Config cached; - private LlmHelper() {} + 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); - 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; - } - ModelReply reply = extractReply(response.body); - String content = reply != null ? reply.content() : null; - if (content == null || content.isBlank()) { - 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_FALLBACK reason=call_failed, errorType={}, elapsedMs={}, message={}", - e.getClass().getSimpleName(), - elapsedMillis(started), - e.getMessage()); - return fallback; - } + 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; } - - private static long elapsedMillis(long startedNanos) { - return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis( - System.nanoTime() - startedNanos); + 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); + 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; + } + ModelReply reply = extractReply(response.body); + String content = reply != null ? reply.content() : null; + if (content == null || content.isBlank()) { + 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_FALLBACK reason=call_failed, errorType={}, elapsedMs={}, message={}", + e.getClass().getSimpleName(), + elapsedMillis(started), + e.getMessage()); + return fallback; } + } - 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 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"); - return null; - } - if (envPath == null || envPath.isBlank()) { - return null; - } - if (cached != null && envPath.equals(cached.envPath)) { - return cached; - } - synchronized (LlmHelper.class) { - if (cached != null && envPath.equals(cached.envPath)) { - return cached; - } - Config config = loadFromEnv(Path.of(envPath)); - if (config == null) { - return null; - } - cached = config; - log.info("[LlmHelper] LLM client ready: model={}, baseUrl={}", config.model, config.baseUrl); - return cached; - } + 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"); + return null; + } + if (envPath == null || envPath.isBlank()) { + return null; } + if (cached != null && envPath.equals(cached.envPath)) { + return cached; + } + synchronized (LlmHelper.class) { + if (cached != null && envPath.equals(cached.envPath)) { + return cached; + } + Config config = loadFromEnv(Path.of(envPath)); + if (config == null) { + return null; + } + cached = config; + log.info("[LlmHelper] LLM client ready: model={}, baseUrl={}", config.model, config.baseUrl); + return cached; + } + } - private static Config loadFromEnv(Path envFile) { - if (!Files.exists(envFile)) { - log.debug("[LlmHelper] .env not found: {}", envFile); - return null; - } - Map properties; - try { - properties = parseEnvLines(Files.readAllLines(envFile)); - } catch (IOException e) { - log.warn("[LlmHelper] Failed to read {}: {}", envFile, e.getMessage()); - return null; - } - String apiKey = properties.get("A2AT_LLM_API_KEY"); - String baseUrl = properties.get("A2AT_LLM_BASE_URL"); - String model = properties.get("A2AT_LLM_MODEL"); - if (apiKey == null || apiKey.isBlank() || baseUrl == null || model == null) { - log.warn("[LlmHelper] Missing LLM config in .env (need API_KEY, BASE_URL, MODEL)"); - return null; - } - int maxTokens = getIntProperty(properties, "A2AT_LLM_MAX_TOKENS", 2000); - double temperature = getDoubleProperty(properties, "A2AT_LLM_TEMPERATURE", 0.0); - int timeout = getIntProperty(properties, "A2AT_LLM_TIMEOUT_SECONDS", 60); - return new Config(envFile.toString(), apiKey, baseUrl, model, maxTokens, temperature, timeout); + private static Config loadFromEnv(Path envFile) { + if (!Files.exists(envFile)) { + log.debug("[LlmHelper] .env not found: {}", envFile); + return null; + } + Map properties; + try { + properties = parseEnvLines(Files.readAllLines(envFile)); + } catch (IOException e) { + log.warn("[LlmHelper] Failed to read {}: {}", envFile, e.getMessage()); + return null; } + String apiKey = properties.get("A2AT_LLM_API_KEY"); + String baseUrl = properties.get("A2AT_LLM_BASE_URL"); + String model = properties.get("A2AT_LLM_MODEL"); + if (apiKey == null || apiKey.isBlank() || baseUrl == null || model == null) { + log.warn("[LlmHelper] Missing LLM config in .env (need API_KEY, BASE_URL, MODEL)"); + return null; + } + int maxTokens = getIntProperty(properties, "A2AT_LLM_MAX_TOKENS", 2000); + double temperature = getDoubleProperty(properties, "A2AT_LLM_TEMPERATURE", 0.0); + int timeout = getIntProperty(properties, "A2AT_LLM_TIMEOUT_SECONDS", 60); + return new Config(envFile.toString(), apiKey, baseUrl, model, maxTokens, temperature, timeout); + } - private static Map parseEnvLines(List lines) { - Map properties = new java.util.HashMap<>(); - for (String line : lines) { - String trimmed = line.trim(); - if (trimmed.isEmpty() || trimmed.startsWith("#")) { - continue; - } - int equals = trimmed.indexOf('='); - if (equals <= 0) { - continue; - } - String key = trimmed.substring(0, equals).trim(); - String value = trimmed.substring(equals + 1).trim(); - if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { - value = value.substring(1, value.length() - 1); - } - String environmentValue = System.getenv(key); - properties.put(key, environmentValue != null ? environmentValue : value); - } - return properties; + private static Map parseEnvLines(List lines) { + Map properties = new java.util.HashMap<>(); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int equals = trimmed.indexOf('='); + if (equals <= 0) { + continue; + } + String key = trimmed.substring(0, equals).trim(); + String value = trimmed.substring(equals + 1).trim(); + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + String environmentValue = System.getenv(key); + properties.put(key, environmentValue != null ? environmentValue : value); } + return properties; + } - private static int getIntProperty(Map properties, String key, int defaultValue) { - String value = properties.get(key); - if (value == null || value.isBlank()) { - return defaultValue; - } - try { - return Integer.parseInt(value.trim()); - } catch (NumberFormatException e) { - return defaultValue; - } + private static int getIntProperty(Map properties, String key, int defaultValue) { + String value = properties.get(key); + if (value == null || value.isBlank()) { + return defaultValue; } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return defaultValue; + } + } - private static double getDoubleProperty(Map properties, String key, - double defaultValue) { - String value = properties.get(key); - if (value == null || value.isBlank()) { - return defaultValue; - } - try { - return Double.parseDouble(value.trim()); - } catch (NumberFormatException e) { - return defaultValue; - } + private static double getDoubleProperty( + Map properties, String key, double defaultValue) { + String value = properties.get(key); + if (value == null || value.isBlank()) { + return defaultValue; + } + try { + return Double.parseDouble(value.trim()); + } catch (NumberFormatException e) { + return defaultValue; } + } - private static String buildRequestBody(Config config, String system, String user) { - try { - List> messages = new ArrayList<>(); - messages.add(Map.of("role", "system", "content", system)); - messages.add(Map.of("role", "user", "content", user)); - Map body = new java.util.LinkedHashMap<>(); - body.put("model", config.model); - body.put("messages", messages); - body.put("temperature", config.temperature); - body.put("max_tokens", config.maxTokens); - return MAPPER.writeValueAsString(body); - } catch (Exception e) { - throw new IllegalStateException("Failed to build request body", e); - } + private static String buildRequestBody(Config config, String system, String user) { + try { + List> messages = new ArrayList<>(); + messages.add(Map.of("role", "system", "content", system)); + messages.add(Map.of("role", "user", "content", user)); + Map body = new java.util.LinkedHashMap<>(); + body.put("model", config.model); + body.put("messages", messages); + body.put("temperature", config.temperature); + body.put("max_tokens", config.maxTokens); + return MAPPER.writeValueAsString(body); + } catch (Exception e) { + throw new IllegalStateException("Failed to build request body", e); } + } - private static ModelReply extractReply(String responseBody) { - try { - JsonNode choices = MAPPER.readTree(responseBody).path("choices"); - if (choices.isArray() && !choices.isEmpty()) { - JsonNode choice = choices.get(0); - JsonNode message = choice.path("message").path("content"); - if (!message.isMissingNode()) { - JsonNode finishReason = choice.path("finish_reason"); - return new ModelReply( - message.asText(), - finishReason.isMissingNode() || finishReason.isNull() - ? null - : finishReason.asText()); - } - } - return null; - } catch (Exception e) { - log.warn("[LlmHelper] Failed to parse response: {}", e.getMessage()); - return null; + private static ModelReply extractReply(String responseBody) { + try { + JsonNode choices = MAPPER.readTree(responseBody).path("choices"); + if (choices.isArray() && !choices.isEmpty()) { + JsonNode choice = choices.get(0); + JsonNode message = choice.path("message").path("content"); + if (!message.isMissingNode()) { + JsonNode finishReason = choice.path("finish_reason"); + return new ModelReply( + message.asText(), + finishReason.isMissingNode() || finishReason.isNull() ? null : finishReason.asText()); } + } + return null; + } catch (Exception e) { + log.warn("[LlmHelper] Failed to parse response: {}", e.getMessage()); + return null; } + } - private static final class Config { - private final String envPath; - private final String apiKey; - private final String baseUrl; - private final String model; - private final int maxTokens; - private final double temperature; - private final int timeoutSeconds; + private static final class Config { + private final String envPath; + private final String apiKey; + private final String baseUrl; + private final String model; + private final int maxTokens; + private final double temperature; + private final int timeoutSeconds; - private Config(String envPath, String apiKey, String baseUrl, String model, - int maxTokens, double temperature, int timeoutSeconds) { - this.envPath = envPath; - this.apiKey = apiKey; - this.baseUrl = baseUrl; - this.model = model; - this.maxTokens = maxTokens; - this.temperature = temperature; - this.timeoutSeconds = timeoutSeconds; - } + private Config( + String envPath, + String apiKey, + String baseUrl, + String model, + int maxTokens, + double temperature, + int timeoutSeconds) { + this.envPath = envPath; + this.apiKey = apiKey; + this.baseUrl = baseUrl; + this.model = model; + this.maxTokens = maxTokens; + this.temperature = temperature; + this.timeoutSeconds = timeoutSeconds; } + } - private record HttpResponse(int statusCode, String body) {} + private record HttpResponse(int statusCode, String body) {} - private record ModelReply(String content, String finishReason) {} + private record ModelReply(String content, String finishReason) {} } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/ExecutionCancellation.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/ExecutionCancellation.java new file mode 100644 index 00000000..302de05d --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/ExecutionCancellation.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.workbench; + +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; + +/** Request-local cancellation; independent extension subscriptions are deliberately not bound. */ +final class ExecutionCancellation { + private final java.util.concurrent.atomic.AtomicBoolean cancelled = new java.util.concurrent.atomic.AtomicBoolean(); + private final java.util.concurrent.atomic.AtomicReference> execution = new java.util.concurrent.atomic.AtomicReference<>(); + + void bind(CompletableFuture execution) { + this.execution.set(execution); + if (cancelled.get()) execution.cancel(true); + } + + void cancel() { + cancelled.set(true); + CompletableFuture active = execution.get(); + if (active != null) active.cancel(true); + } + + void check() { + if (cancelled.get()) throw new CancellationException("Workflow execution cancelled"); + } + + boolean isCancelled() { + return cancelled.get(); + } +} 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 index 8725db27..4e0bd6c5 100644 --- 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 @@ -23,7 +23,10 @@ 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 java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.a2aproject.sdk.spec.A2AError; @@ -33,11 +36,6 @@ 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. * @@ -47,118 +45,122 @@ */ @Component public class SpringWorkbenchExecutor extends BaseAgentExecutor { - private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchExecutor.class); + private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchExecutor.class); - private final WorkbenchClientProperties properties; - private volatile WorkbenchTaskInputParser taskInputParser; + private final WorkbenchClientProperties properties; + private volatile WorkbenchTaskInputParser taskInputParser; + private final java.util.concurrent.ConcurrentMap executions = + new java.util.concurrent.ConcurrentHashMap<>(); - public SpringWorkbenchExecutor(WorkbenchClientProperties properties) { - this.properties = properties; - } + public SpringWorkbenchExecutor(WorkbenchClientProperties properties) { + this.properties = properties; + } - private String resolveEnvPath() { - if (properties.getA2atEnvPath() != null && !properties.getA2atEnvPath().isBlank()) { - return properties.getA2atEnvPath(); - } - return EnvResolver.resolveEnvPath(); + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } + + 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; + 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")); + @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(); + ExecutionCancellation cancellation = new ExecutionCancellation(); + executions.put(taskId, cancellation); + 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())); - } + 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(), cancellation); + cancellation.check(); + 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) { + if (cancellation.isCancelled()) return; + 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())); + } finally { + executions.remove(taskId, cancellation); } + } - private WorkbenchTaskInputParser taskInputParser() { - WorkbenchTaskInputParser current = taskInputParser; - if (current != null) { - return current; - } - synchronized (this) { - if (taskInputParser == null) { - taskInputParser = WorkbenchTaskInputParser.fromEnv(resolveEnvPath()); - } - return taskInputParser; - } + private WorkbenchTaskInputParser taskInputParser() { + WorkbenchTaskInputParser current = taskInputParser; + if (current != null) { + return current; } - - @Override - public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { - log.warn( - "[SpringWorkbench] TASK_CANCEL taskId={}, contextId={}", - ctx.getTaskId(), - ctx.getContextId()); - emitter.cancel(); + synchronized (this) { + if (taskInputParser == null) { + taskInputParser = WorkbenchTaskInputParser.fromEnv(resolveEnvPath()); + } + return taskInputParser; } + } - private static long elapsedMillis(long startedNanos) { - return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); - } + @Override + public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { + ExecutionCancellation execution = executions.get(ctx.getTaskId()); + if (execution != null) execution.cancel(); + log.warn( + "[SpringWorkbench] TASK_CANCEL taskId={}, contextId={}", + ctx.getTaskId(), + ctx.getContextId()); + emitter.cancel(); + } } 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 index fbb62e32..2ef07898 100644 --- 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 @@ -7,7 +7,6 @@ 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; @@ -17,100 +16,100 @@ /** 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 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<>(); + 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; - } + 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); - } + @EventListener(ApplicationReadyEvent.class) + public synchronized void start() { + if (lifecycle != null && lifecycle.isActive()) { + return; } - - @PreDestroy - public synchronized void close() { - if (lifecycle == null) { - return; - } - lifecycle.close(); - lifecycle = null; + 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); } + } - /** - * 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; - } + @PreDestroy + public synchronized void close() { + if (lifecycle == null) { + return; } + lifecycle.close(); + lifecycle = null; + } - 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); + /** + * 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; } - - private String resolveEnvPath() { - return properties.getA2atEnvPath() != null && !properties.getA2atEnvPath().isBlank() - ? properties.getA2atEnvPath() - : EnvResolver.resolveEnvPath(); + 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; + 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 index 0601c7e1..d01535db 100644 --- 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 @@ -53,6 +53,14 @@ public WorkbenchAgentCatalog() { this.explicitlyConfigured = true; } + 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(); + } + public List load() { Map byName = new LinkedHashMap<>(); for (String location : locations) { @@ -100,12 +108,4 @@ private InputStream open(String location) throws Exception { : 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 index b1ed88b0..e05dd50c 100644 --- 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 @@ -19,27 +19,24 @@ package dev.openan.workflow.engine.examples.workbench; -import dev.openan.workflow.engine.examples.util.LlmHelper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import dev.openan.workflow.engine.control.DefaultControlPoint; +import dev.openan.workflow.engine.examples.negotiation.NegotiationStrategy; +import dev.openan.workflow.engine.examples.util.EnvResolver; +import dev.openan.workflow.engine.examples.util.LlmHelper; +import dev.openan.workflow.engine.model.MessageContent; 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.TaskRequest; 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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * ControlPoint for the SPN cross-city diagnosis workflow. @@ -52,106 +49,127 @@ * 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); + 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); + } + + private static String serialize(Object value) { + try { + return JSON.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize upstream agent results", e); } - - 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(), + } + + @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", + 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); - } + 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()); + } + + @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 index 6f1f53fe..27e4ed9c 100644 --- 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 @@ -14,7 +14,6 @@ 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; @@ -23,122 +22,150 @@ /** 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 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; + private final dev.openan.workflow.engine.client.AuthProvider authProvider; + private final String credentialsPath; + private final boolean sslVerify; + private final String a2atEnvPath; + private final Supplier runtimeSupplier; + private final java.util.function.BiConsumer< + NotificationSubscription, dev.openan.workflow.engine.model.ReceivedMessage> + 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 WorkbenchExtensionLifecycle( + String credentialsPath, + boolean sslVerify, + String a2atEnvPath, + Supplier runtimeSupplier, + java.util.function.BiConsumer< + NotificationSubscription, dev.openan.workflow.engine.model.ReceivedMessage> + notificationCallback) { + this(credentialsPath, sslVerify, a2atEnvPath, runtimeSupplier, notificationCallback, null); + } - 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) + public WorkbenchExtensionLifecycle( + String credentialsPath, boolean sslVerify, String a2atEnvPath, + Supplier runtimeSupplier, + java.util.function.BiConsumer notificationCallback, + dev.openan.workflow.engine.client.AuthProvider authProvider) { + this.authProvider = authProvider; + this.credentialsPath = credentialsPath; + this.sslVerify = sslVerify; + this.a2atEnvPath = a2atEnvPath; + this.runtimeSupplier = runtimeSupplier; + this.notificationCallback = notificationCallback; + } - .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; - } - } + private static long elapsedMillis(long startedNanos) { + return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } - public synchronized boolean isActive() { - return notificationTransport != null; + 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 = null; + A2AJavaClientRuntime notificationRuntime = null; + A2ATransport authorizationTransport = null; + A2ATransport notificationCandidate = null; + try { + authorizationRuntime = runtimeSupplier != null ? runtimeSupplier.get() : null; + 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) + .authProvider(authProvider) + .build(); + authorizationTransport = new A2ATransport(agentCards, authorizationRuntime, config); + notificationCandidate = new A2ATransport(agentCards, notificationRuntime, config); + long started = System.nanoTime(); + log.info( + "[ExtensionLifecycle] START authorizationContextId={}, notificationContextId={}, scope=workbench", + authorizationTransport.getContextId(), + notificationCandidate.getContextId()); + 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())); + 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(); + closeResource(notificationCandidate, + notificationRuntime == authorizationRuntime ? null : notificationRuntime); + throw e; + } finally { + closeResource(authorizationTransport, authorizationRuntime); } + } - @Override - public synchronized void close() { - A2ATransport active = notificationTransport; - notificationTransport = null; - if (active == null) { - return; - } - active.close(); - subscriptions.clear(); + private static void closeResource(A2ATransport transport, A2AJavaClientRuntime runtime) { + try { + if (transport != null) transport.close(); + else if (runtime != null) runtime.close(); + } catch (RuntimeException error) { + log.warn("Extension resource cleanup failed", error); } + } - 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); } - } + 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 static long elapsedMillis(long startedNanos) { - return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + 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); + } } + } } 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 index 0ec95d21..5592b7c8 100644 --- 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 @@ -19,13 +19,14 @@ 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.A2ATransport; 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.examples.negotiation.NegotiationStrategy; import dev.openan.workflow.engine.model.ExecutionResult; import dev.openan.workflow.engine.model.JumpCondition; import dev.openan.workflow.engine.model.StepType; @@ -35,357 +36,369 @@ 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 org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -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: + * 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) + *
    • {@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. + *

    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 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; + 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) { + 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; - } + 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()); + static String buildResultText(ExecutionResult result) { + if (result.isSuccess()) { + if (result.getHistory() != null) { + for (int i = result.getHistory().size() - 1; i >= 0; i--) { + Map last = result.getHistory().get(i); + if (last.get("outputs") instanceof List outputs && !outputs.isEmpty()) { + Object step = last.get("step"); + return result.getHistory().stream() + .filter(entry -> java.util.Objects.equals(step, entry.get("step"))) + .filter(entry -> entry.get("outputs") instanceof List) + .flatMap(entry -> ((List) entry.get("outputs")).stream()) + .map(WorkbenchOrchestrator::renderOutput) + .collect(java.util.stream.Collectors.joining("\n\n")); + } } - return sb.toString(); + } + } + 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")); + private static String outputText(Map output) { + if (output == null || output.isEmpty()) { + return ""; } + return output.values().stream() + .filter(value -> value != null && !String.valueOf(value).isBlank()) + .flatMap(value -> value instanceof List values ? values.stream() : java.util.stream.Stream.of(value)) + .map(WorkbenchOrchestrator::renderOutput) + .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")); + private static String renderOutput(Object value) { + if (value instanceof String text) return text; + try { + return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(value); + } catch (com.fasterxml.jackson.core.JsonProcessingException error) { + throw new IllegalArgumentException("Cannot serialize business output", error); } + } - /** 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); + static String buildResultText(ExecutionResult result, Workflow workflow) { + if (result.isSuccess() && result.getStepOutputs() != null) { + String terminalOutputs = workflow.getSteps().stream() + .filter(step -> result.getStepOutputs().containsKey(step.getName())) + .filter(step -> step.getNext() == null || step.getNext().stream() + .noneMatch(next -> result.getStepOutputs().containsKey(next.getStep()))) + .map(step -> outputText(result.getStepOutputs().get(step.getName()))) + .filter(output -> !output.isBlank()) + .collect(java.util.stream.Collectors.joining("\n\n")); + if (!terminalOutputs.isBlank()) return terminalOutputs; + } + return buildResultText(result); + } - 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)); + 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(); + } - 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)); + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } - stageStarted = System.nanoTime(); - log.info( - "[Orchestrator] STAGE_START stage=create-engine-client, runtime={}", - runtimeName); - A2ATransport transport = - new A2ATransport( - agentCards, - clientRuntime, - WorkflowEngineClientConfig.builder() - .sslVerify(sslVerify) + /** 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")); + } - .credentialsConfigPath(credentialsPath) - .build()); - log.info( - "[Orchestrator] STAGE_DONE stage=create-engine-client, contextId={}, elapsedMs={}", - transport.getContextId(), - elapsedMillis(stageStarted)); - try { - WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport); + /** Runs with an explicit sample-host negotiation setting, without changing global properties. */ + public String run(String messageText, boolean demoNegotiationEnabled) throws Exception { + return run(messageText, demoNegotiationEnabled, new ExecutionCancellation()); + } - 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) + String run(String messageText, boolean demoNegotiationEnabled, ExecutionCancellation cancellation) + throws Exception { + cancellation.check(); + 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); - .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(); - } - } + long stageStarted = System.nanoTime(); + log.info("[Orchestrator] STAGE_START stage=load-agent-cards"); + List agentCards = loadAgentCards(); + cancellation.check(); + log.info( + "[Orchestrator] STAGE_DONE stage=load-agent-cards, count={}, agents={}, elapsedMs={}", + agentCards.size(), + agentCards.stream().map(AgentCard::name).toList(), + elapsedMillis(stageStarted)); - private List loadAgentCards() { - return new WorkbenchAgentCatalog().load(); - } + stageStarted = System.nanoTime(); + log.info("[Orchestrator] STAGE_START stage=search-load-psop"); + ResolvedWorkflow resolvedWorkflow = resolveWorkflow(messageText); + cancellation.check(); + 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)); - 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()); - } + 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); - 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(); + 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); + cancellation.check(); + CompletableFuture execution = + 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(); + cancellation.bind(execution); + ExecutionResult result = execution.join(); + cancellation.check(); + 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, workflow); + 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 record ResolvedWorkflow(String psopId, Workflow workflow) {} + private List loadAgentCards() { + return new WorkbenchAgentCatalog().load(); + } - 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 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()); + } - private static long elapsedMillis(long startedNanos) { - return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); - } + 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 record ResolvedWorkflow(String psopId, Workflow workflow) {} } 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 index 81f78fed..0b697fa0 100644 --- 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 @@ -6,126 +6,116 @@ import dev.openan.workflow.engine.client.A2ATExtension; import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; - +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; 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); - } + private final TaskPromptValidator validator; - record ParsedTask( - String userText, - String prompt, - TemplateUri templateUri, - Map parameters) { + WorkbenchTaskInputParser(TaskPromptValidator validator) { + this.validator = validator; + } - 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(); - } + 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())); + } - private final TaskPromptValidator validator; - - WorkbenchTaskInputParser(TaskPromptValidator validator) { - this.validator = validator; + 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"); + } - 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())); + 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); + } - 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 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; + } - private static String extractTaskPrompt(Map metadata) { - Object exact = metadata.get(A2ATExtension.TASK_T.uri()); - if (exact instanceof String prompt && !prompt.isBlank()) { - return prompt; - } + 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 request has no canonical Task-T prompt payload"); + "Inbound Workbench Task-T is missing required parameter: " + required); + } } + return new ParsedTask(userText, prompt, templateUri, Map.copyOf(parameters)); + } - 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); - } + @FunctionalInterface + interface TaskPromptValidator { + FilledParamData validate(String prompt, Map schema, TemplateUri templateUri); + } + + record ParsedTask( + String userText, String prompt, TemplateUri templateUri, Map parameters) { - 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; + 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(); } + } } 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 6e761e99..7d786e73 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 @@ -23,9 +23,9 @@ import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity2Executor; import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; import dev.openan.workflow.engine.examples.extension.PrePositionedExtensionHandler; +import dev.openan.workflow.engine.examples.server.OmcAgentLauncher; 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; @@ -33,21 +33,19 @@ import dev.openan.workflow.engine.model.Workflow; import dev.openan.workflow.engine.model.WorkflowStep; import dev.openan.workflow.engine.runner.ExecutePsop; - -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.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; 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; +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; /** * End-to-end business integration test for the SPN cross-city diagnosis workflow. Starts two real @@ -63,323 +61,310 @@ */ class SpnCrossCityE2ETest { - private static final ObjectMapper mapper = - new ObjectMapper().registerModule(new AgentCardJacksonModule()); + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(new AgentCardJacksonModule()); - private final OmcAgentLauncher omc = new OmcAgentLauncher(); - private DefaultWorkflowEngineClient client; - 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; + private final OmcAgentLauncher omc = new OmcAgentLauncher(); + private final List subscriptions = new CopyOnWriteArrayList<>(); + private DefaultWorkflowEngineClient client; + private ExtensionSender authorizationSender; + private ExtensionSender notificationSender; + private A2ATransport taskTransport; + private A2ATransport authorizationTransport; + private A2ATransport notificationTransport; + private int port1; + private int port2; + private String sdkEnvPath; + private net.openan.a2at.sdk.client.A2ATClient contentClient; - @BeforeAll - static void registerMockProvider() { - OfflineA2ATLlmClient.install(); - } + @BeforeAll + static void registerMockProvider() { + OfflineA2ATLlmClient.install(); + } - private static AgentCard cardFor(String name, int port) { - Map card = + private static AgentCard cardFor(String name, int port) { + Map card = + Map.of( + "name", name, + "description", "test", + "provider", Map.of("organization", "test", "url", ""), + "version", "1.0.0", + "capabilities", Map.of( - "name", 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/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", - 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", - ""))); - return mapper.convertValue(card, AgentCard.class); - } - - @BeforeEach - void setUp() throws Exception { - 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); - 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); - WorkflowEngineClientConfig config = - WorkflowEngineClientConfig.builder() - .sslVerify(false) + "streaming", + true, + "pushNotifications", + false, + "extendedAgentCard", + false, + "extensions", + 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", + 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", + ""))); + return mapper.convertValue(card, AgentCard.class); + } - .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); + private static boolean await(CountDownLatch latch, long seconds) { + try { + return latch.await(seconds, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; } + } - @AfterEach - void tearDown() { - subscriptions.forEach(NotificationSubscription::close); - subscriptions.clear(); - if (client != null) client.close(); - 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() { - Task t1 = Task.builder().agent("SPN Domain Agent City1").description("SPN专线故障诊断").build(); - Task t2 = Task.builder().agent("SPN Domain Agent City2").description("SPN专线故障诊断").build(); - Task merge = Task.builder().agent("Workbench").description("汇总两地市OMC诊断结论").build(); - WorkflowStep s1 = - WorkflowStep.builder() - .name("diagnosis_city1") - .layer(0) - .subtasks(List.of(t1)) - .next( - List.of( - JumpCondition.builder() - .step("merge_analysis") - .condition("") - .build())) - .build(); - WorkflowStep s2 = - WorkflowStep.builder() - .name("diagnosis_city2") - .layer(0) - .subtasks(List.of(t2)) - .next( - List.of( - JumpCondition.builder() - .step("merge_analysis") - .condition("") - .build())) - .build(); - WorkflowStep s3 = - WorkflowStep.builder() - .name("merge_analysis") - .layer(1) - .stepType(StepType.SELF_LOOP) - .contextFrom(List.of("diagnosis_city1", "diagnosis_city2")) - .subtasks(List.of(merge)) - .next( - List.of( - JumpCondition.builder() - .step("endNode") - .condition("") - .build())) - .build(); - return Workflow.builder().name("spn-e2e").steps(List.of(s1, s2, s3)).build(); - } + @BeforeEach + void setUp() throws Exception { + 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); + 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); + WorkflowEngineClientConfig config = + WorkflowEngineClientConfig.builder().sslVerify(false).build(); - @Test - 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 - 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(); - } + 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); + } - 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); - } + @AfterEach + void tearDown() { + subscriptions.forEach(NotificationSubscription::close); + subscriptions.clear(); + if (client != null) client.close(); + if (taskTransport != null) taskTransport.close(); + if (authorizationTransport != null) authorizationTransport.close(); + if (notificationTransport != null) notificationTransport.close(); + omc.close(); + System.clearProperty("a2at.env.path"); + } - Map allOutputs = new ConcurrentHashMap<>(); - AtomicBoolean workflowTaskContainedRecovery = new AtomicBoolean(false); - AtomicBoolean sawSelfLoop = new AtomicBoolean(false); - AtomicBoolean sawNegotiation = new AtomicBoolean(false); - EventCallback cb = - new EventCallback() { - @Override - public void onEvent(String type, Map data) { - if (EventType.AGENT_ARTIFACT_UPDATE.equals(type)) { - String text = - data.get("text") != null - ? String.valueOf(data.get("text")) - : ""; - 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); - } - }; + private Workflow crossCityWorkflow() { + Task t1 = Task.builder().agent("SPN Domain Agent City1").description("SPN专线故障诊断").build(); + Task t2 = Task.builder().agent("SPN Domain Agent City2").description("SPN专线故障诊断").build(); + Task merge = Task.builder().agent("Workbench").description("汇总两地市OMC诊断结论").build(); + WorkflowStep s1 = + WorkflowStep.builder() + .name("diagnosis_city1") + .layer(0) + .subtasks(List.of(t1)) + .next(List.of(JumpCondition.builder().step("merge_analysis").condition("").build())) + .build(); + WorkflowStep s2 = + WorkflowStep.builder() + .name("diagnosis_city2") + .layer(0) + .subtasks(List.of(t2)) + .next(List.of(JumpCondition.builder().step("merge_analysis").condition("").build())) + .build(); + WorkflowStep s3 = + WorkflowStep.builder() + .name("merge_analysis") + .layer(1) + .stepType(StepType.SELF_LOOP) + .contextFrom(List.of("diagnosis_city1", "diagnosis_city2")) + .subtasks(List.of(merge)) + .next(List.of(JumpCondition.builder().step("endNode").condition("").build())) + .build(); + return Workflow.builder().name("spn-e2e").steps(List.of(s1, s2, s3)).build(); + } - WorkbenchControlPoint cp = new WorkbenchControlPoint(sdkEnvPath); - ExecutionResult result = - ExecutePsop.builder() - .psop(crossCityWorkflow()) - .controlPoint(cp) - .engineClient(client) - .runtimeIntent("SPN跨城专线故障诊断与抢通:客户A上海-广州间SPN专线中断") - .lang("zh") - .sslVerify(false) - .eventCallback(cb) - .execute() - .join(); - - 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"); - // 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( - 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("SPN Domain Agent City1") && mergeText.contains("无收光"), - "Offline merge must preserve actual City1 identity and diagnosis: " + mergeText); + @Test + 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 + 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(); } - private static boolean await(CountDownLatch latch, long seconds) { - try { - return latch.await(seconds, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } + CountDownLatch recoveryPlanNotification = new CountDownLatch(1); + CountDownLatch recoveryResultNotification = new CountDownLatch(1); + AtomicReference recoveryPlanNotificationText = new AtomicReference<>(); + AtomicReference recoveryNotificationText = new AtomicReference<>(); + java.util.function.BiConsumer< + NotificationSubscription, dev.openan.workflow.engine.model.ReceivedMessage> + 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 workflowTaskContainedRecovery = new AtomicBoolean(false); + AtomicBoolean sawSelfLoop = new AtomicBoolean(false); + AtomicBoolean sawNegotiation = new AtomicBoolean(false); + EventCallback cb = + new EventCallback() { + @Override + public void onEvent(String type, Map data) { + if (EventType.AGENT_ARTIFACT_UPDATE.equals(type)) { + String text = data.get("text") != null ? String.valueOf(data.get("text")) : ""; + 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(sdkEnvPath); + ExecutionResult result = + ExecutePsop.builder() + .psop(crossCityWorkflow()) + .controlPoint(cp) + .engineClient(client) + .runtimeIntent("SPN跨城专线故障诊断与抢通:客户A上海-广州间SPN专线中断") + .lang("zh") + .sslVerify(false) + .eventCallback(cb) + .execute() + .join(); + + 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"); + // 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( + 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("SPN Domain Agent City1") && mergeText.contains("无收光"), + "Offline merge must preserve actual City1 identity and diagnosis: " + mergeText); + } } 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 index 13aec24a..9be1134d 100644 --- 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 @@ -18,54 +18,62 @@ */ 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.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + class DemoNegotiationSettingsTest { - private String enabled; - private String city; + 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"); - } + @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); - } + @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 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 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 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)); - } + @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 index 01002090..ef7a75da 100644 --- 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 @@ -4,22 +4,33 @@ */ package dev.openan.workflow.engine.examples.demo; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + import java.net.URI; import java.nio.file.*; import java.util.*; import javax.tools.*; -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; 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 = """ + 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); + } + + @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 callbackSource = + """ import java.util.*; import java.util.concurrent.*; import dev.openan.workflow.engine.control.*; @@ -30,40 +41,71 @@ class DocumentedCallbackExampleTest { 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()); + """ + + callbacks + + "\nreturn callbacks;\n}\n" + + "MessageContent generate(A2ATClient sdk, Map data, Map schema) {\n" + + generated + + "\nreturn outgoing;\n}\n}"; + String integration = Files.readString(root.resolve("docs/" + language + "/INTEGRATION_GUIDE.md")); + String quickStartSource = """ + import java.util.*; + import java.util.concurrent.*; + import com.fasterxml.jackson.databind.ObjectMapper; + import org.a2aproject.sdk.spec.AgentCard; + import dev.openan.workflow.engine.control.*; + import dev.openan.workflow.engine.model.*; + import dev.openan.workflow.engine.client.*; + import dev.openan.workflow.engine.registry.*; + import dev.openan.workflow.engine.runner.ExecutePsop; + class DocumentedIntegration { + void run() throws Exception { + """ + + block(integration, "Workflow workflow =") + "\n" + + block(integration, "RegistryClient registry =") + "\n" + + block(integration, "ControlPoint callbacks =") + "\n" + + block(integration, "CompletableFuture execution =") + "\n}}"; + for (String source : List.of(callbackSource, quickStartSource)) { + 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; } - } - } - - 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); + }; + 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()); + } + } } + } } diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/HostQuickStartTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/HostQuickStartTest.java new file mode 100644 index 00000000..ef7e54de --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/HostQuickStartTest.java @@ -0,0 +1,35 @@ +/* + * 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.*; +import dev.openan.workflow.engine.client.WorkflowEngineClient; +import dev.openan.workflow.engine.model.*; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +class HostQuickStartTest { + @Test + void publishedHostExampleReachesLocalAggregation() throws Exception { + var sent = new java.util.concurrent.atomic.AtomicInteger(); + var client = (WorkflowEngineClient) java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[] {WorkflowEngineClient.class}, (proxy, method, args) -> { + if (method.getName().equals("callbackTimeoutSeconds")) return 10L; + if (method.getName().equals("dispatch")) { + sent.incrementAndGet(); + return CompletableFuture.completedFuture(SendMessageResult.builder().taskState("TASK_STATE_COMPLETED") + .receivedMessages(List.of(new ReceivedMessage(MessageContent.text("diagnosis"), Map.of(), List.of()))) + .build()); + } + return null; + }); + var result = HostQuickStart.execute(HostQuickStart.workflow("test-agent"), List.of(), client); + assertTrue(result.isSuccess(), result.getError()); + assertEquals(1, sent.get()); + assertTrue(result.getStepOutputs().get("aggregate").toString().contains("diagnosis")); + } +} 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 index b52469aa..daad54d5 100644 --- 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 @@ -20,164 +20,190 @@ 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 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 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); - } + @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"; } + 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") + 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"); - } + && 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() - }; - } + 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); - } + @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); - } + 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 index 4a261c2f..fadcea00 100644 --- 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 @@ -12,13 +12,16 @@ class SpringSpnDemoTest { - @Test - void demoOnlyReportsSuccessForACompletedWorkbenchTask() { - assertDoesNotThrow(() -> SpringSpnDemo.requireCompleted( + @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())); - } + 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 index 0d793979..23e1dcaa 100644 --- 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 @@ -12,141 +12,149 @@ 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; +import org.junit.jupiter.api.Test; 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( + @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.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.ADD, AuthorizationPolicy.POLICY_LIST_FIELD, "1. 业务场景是载波调度,处置类型是业务抢通,操作名称是载波调度,有效期是永久生效")); - AuthorizationPolicy limited = AuthorizationPolicy.fromValidated(Map.of( - AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, + 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,有效期是永久生效"))); + 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 index d16ffcd1..e1c18019 100644 --- 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 @@ -25,10 +25,45 @@ import org.junit.jupiter.api.Test; class ExtensionPrePositionerTest { + 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)); + } + @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()); + System.setProperty( + "a2at.env.path", + java.nio.file.Path.of(getClass().getResource("/a2at-e2e.env").toURI()).toString()); } @org.junit.jupiter.api.AfterEach @@ -121,39 +156,6 @@ void authorizationFailureDoesNotBlockSubscriptionOrWorkflowStartup() throws Exce 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 index a3e78de6..4f65dcac 100644 --- 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 @@ -7,39 +7,32 @@ 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; +import org.junit.jupiter.api.Test; class NotificationPolicyTest { - @Test - void acceptsSdkValidatedRecoverySubscription() { - NotificationPolicy policy = - NotificationPolicy.fromValidated( - Map.of( - NotificationPolicy.CONDITION_FIELD, - "业务抢通完成", - NotificationPolicy.REPORT_FORMAT_FIELD, - "投诉诊断任务流水号、执行状态、执行结果")); + @Test + void acceptsSdkValidatedRecoverySubscription() { + NotificationPolicy policy = + NotificationPolicy.fromValidated( + Map.of( + NotificationPolicy.CONDITION_FIELD, + "业务抢通完成", + NotificationPolicy.REPORT_FORMAT_FIELD, + "投诉诊断任务流水号、执行状态、执行结果")); - assertEquals("业务抢通完成", policy.condition()); - assertEquals("投诉诊断任务流水号、执行状态、执行结果", policy.reportFormat()); - } + assertEquals("业务抢通完成", policy.condition()); + assertEquals("投诉诊断任务流水号、执行状态、执行结果", policy.reportFormat()); + } - @Test - void missingReportSchemaOrWrongConditionTypeFailsClosed() { - assertThrows( - IllegalArgumentException.class, - () -> NotificationPolicy.fromValidated(Map.of("订阅条件", "业务抢通完成"))); - assertThrows( - IllegalArgumentException.class, - () -> - NotificationPolicy.fromValidated( - Map.of( - "订阅条件", - 1, - "上报通知数据格式", - "执行结果"))); - } + @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 index 7856e94a..1e5b67c3 100644 --- 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 @@ -13,235 +13,248 @@ 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; +import org.junit.jupiter.api.Test; 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 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 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(); - @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); - PrePositionedExtensionHandler.requireExtractedSectionsMatch( + Map wrongOperation = new java.util.LinkedHashMap<>(exact); + wrongOperation.put(AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.QUERY); + assertThrows( + IllegalArgumentException.class, + () -> + PrePositionedExtensionHandler.requireExtractedSectionsMatch( prompt, - exact, + wrongOperation, 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)); - } + 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 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("动网操作的授权策略列表")); + @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.DELETE, - AuthorizationPolicy.POLICY_LIST_FIELD, - "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"), + 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"); - assertNull(handler.getAuthorizationPolicy()); - } + "test")); + assertSame(existing, 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(); - @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()); - } + 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 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 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 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()); - } + @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 index aa65a11c..76a6aba4 100644 --- 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 @@ -18,43 +18,68 @@ */ package dev.openan.workflow.engine.examples.extension; +import static org.junit.jupiter.api.Assertions.*; + import dev.openan.workflow.engine.client.A2ATExtension; import dev.openan.workflow.engine.model.ReceivedMessage; +import java.util.List; +import java.util.Map; 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 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())); - } + 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 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 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"))); - } + @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 index a2591646..ac823cc1 100644 --- 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 @@ -19,109 +19,170 @@ package dev.openan.workflow.engine.examples.negotiation; +import static org.junit.jupiter.api.Assertions.*; + +import dev.openan.workflow.engine.client.*; 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 java.util.*; 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(); - } + private String env; - @Test void twoCitiesUseTheirOwnInputs() { - String first = answer(SpnCasePrompts.privateLineComplaintData()); - String second = answer(SpnCasePrompts.privateLineComplaintDataCity2()); - assertTrue(first.contains("P781-")); - assertTrue(second.contains("P882-")); - assertNotEquals(first, second); - } + @BeforeEach + void configure() throws Exception { + OfflineA2ATLlmClient.install(); + env = java.nio.file.Path.of(getClass().getResource("/a2at-e2e.env").toURI()).toString(); + } - @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 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; } + @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("业务策略不允许提供")); - } + 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 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()); - } + @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 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) { + 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), + 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)); - } + 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-")); - } + @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 index 8405bbaf..300466ba 100644 --- 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 @@ -22,406 +22,524 @@ import static org.junit.jupiter.api.Assertions.*; import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openan.workflow.engine.client.A2ATExtension; import dev.openan.workflow.engine.client.A2ATransport; +import dev.openan.workflow.engine.client.A2atMessages; 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.agents.SpnTaskInput; +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; 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 dev.openan.workflow.engine.model.SendMessageResult; +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; 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.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 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 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; + + @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); + } } - - 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]; } - } - 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 = + } + }, + 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( - "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, + "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("拒绝原因", "当前调用方无权提供该端口资料")))), + 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("业务取消本次诊断")), + 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")); + 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")); + } + + 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); } + } } 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 index 9db0d822..8697995a 100644 --- 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 @@ -7,7 +7,6 @@ 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; @@ -19,238 +18,259 @@ /** 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(); + public static final String PROVIDER = "workflow-engine-e2e-test"; + private static final ObjectMapper MAPPER = new ObjectMapper(); - private final LLMClientConfig config; + private final LLMClientConfig config; - public OfflineA2ATLlmClient(LLMClientConfig config) { - this.config = config; - } + public OfflineA2ATLlmClient(LLMClientConfig config) { + this.config = config; + } - public static void install() { - if (!LLMClientFactory.availableProviders().contains(PROVIDER)) { - LLMClientFactory.register(PROVIDER, OfflineA2ATLlmClient.class); - } + 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); - } + 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; } - - 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; + 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 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. } - - 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; + if (source.contains("## 订阅条件")) { + // Mirrors the real provider: a rendered section whose value is blank reads as an + // unfilled parameter and must be rejected, not silently accepted. + if (section(source, "订阅条件").isBlank()) return false; } - - 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(); - } + 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 ""; + } } + return true; + } - 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 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() : ""; + } - 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; + } - /** 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() : ""; + } + + @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); } - 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() : ""; + 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 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); - } + 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 index de848e34..f86cf460 100644 --- 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 @@ -24,24 +24,24 @@ 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()); - } + @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 index 9832670f..115be61b 100644 --- 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 @@ -17,23 +17,31 @@ * under the License. */ - package dev.openan.workflow.engine.examples.util; +import static org.junit.jupiter.api.Assertions.*; + import dev.openan.workflow.engine.model.BusinessFailure; +import java.util.Map; 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")); - } + @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/WorkbenchTaskFlowTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskFlowTest.java index 538db3ce..1dc293cd 100644 --- 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 @@ -12,139 +12,171 @@ import dev.openan.workflow.engine.model.ExecutionResult; import dev.openan.workflow.engine.model.StepType; import dev.openan.workflow.engine.model.Workflow; - +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; 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 = + @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( - "创建专线业务投诉诊断任务", - 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)); + "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", + "outputs", List.of("city1")), + Map.of( + "step", "diagnosis_city2", + "outputs", List.of("city2")), + Map.of( + "step", "merge_analysis", + "outputs", List.of("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)); + } + + @Test + void arbitraryTerminalNamesAndMultipleOutputsArePreserved() { + var workflow = Workflow.builder().name("custom").steps(List.of( + dev.openan.workflow.engine.model.WorkflowStep.builder().name("custom-final").build())).build(); + var result = ExecutionResult.builder().success(true).history(List.of()) + .stepOutputs(Map.of("custom-final", Map.of("business", List.of("text", Map.of("count", 2), List.of("nested"))))) + .build(); + assertEquals("text\n\n{\"count\":2}\n\n[\"nested\"]", WorkbenchOrchestrator.buildResultText(result, workflow)); + } + + @Test + void cancellationIsRequestLocalAndSurvivesLateBinding() { + var first = new ExecutionCancellation(); + var second = new ExecutionCancellation(); + first.cancel(); + var future = new java.util.concurrent.CompletableFuture(); + first.bind(future); + assertTrue(future.isCancelled()); + assertThrows(java.util.concurrent.CancellationException.class, first::check); + second.check(); + } + + @Test + void secondRuntimeCreationFailureClosesFirstRuntime() { + var creates = new java.util.concurrent.atomic.AtomicInteger(); + var closes = new java.util.concurrent.atomic.AtomicInteger(); + var runtime = (dev.openan.workflow.engine.client.A2AJavaClientRuntime) + java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[] {dev.openan.workflow.engine.client.A2AJavaClientRuntime.class}, + (proxy, method, args) -> { + if (method.getName().equals("close")) closes.incrementAndGet(); + return null; + }); + try (var lifecycle = new WorkbenchExtensionLifecycle(null, true, null, () -> { + if (creates.incrementAndGet() == 2) throw new IllegalStateException("second initialization failed"); + return runtime; + }, null)) { + assertThrows(IllegalStateException.class, lifecycle::start); + assertEquals(1, closes.get()); } + assertEquals(1, closes.get()); + } } 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 a0d705f5..49b96598 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 @@ -21,7 +21,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.openan.workflow.engine.client.AgentCardJacksonModule; - +import java.io.InputStream; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import org.a2aproject.sdk.server.AgentCardCacheMetadata; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.config.A2AConfigProvider; @@ -49,13 +54,6 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; -import java.io.InputStream; -import java.util.Optional; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - /** * Spring Boot autoconfiguration for the A2A-T server side. * @@ -80,140 +78,138 @@ @EnableConfigurationProperties(A2AProperties.class) public class A2AAutoConfiguration { - private static final Logger log = LoggerFactory.getLogger(A2AAutoConfiguration.class); - - @Bean - @ConditionalOnMissingBean - public AgentCard agentCard(A2AProperties props, ResourceLoader resourceLoader) - throws Exception { - ObjectMapper mapper = new ObjectMapper().registerModule(new AgentCardJacksonModule()); - Resource resource = resourceLoader.getResource(props.getAgentCard()); - if (!resource.exists()) { - throw new IllegalStateException("AgentCard not found: " + props.getAgentCard()); - } - try (InputStream is = resource.getInputStream()) { - AgentCard card = mapper.readValue(is, AgentCard.class); - log.info("[A2A] Loaded AgentCard: name={}, version={}", card.name(), card.version()); - return card; - } - } + private static final Logger log = LoggerFactory.getLogger(A2AAutoConfiguration.class); - @Bean - @ConditionalOnMissingBean - public A2AConfigProvider a2aConfigProvider(A2AProperties properties) { - return new A2AConfigProvider() { - @Override - public String getValue(String key) { - return switch (key) { - case "a2a.blocking.agent.timeout.seconds" -> - Integer.toString(properties.getAgentTimeoutSeconds()); - case "a2a.blocking.consumption.timeout.seconds" -> - Integer.toString(properties.getConsumptionTimeoutSeconds()); - case "a2a.blocking.reconciliation.timeout.seconds" -> - Integer.toString(properties.getReconciliationTimeoutSeconds()); - default -> null; - }; - } - - @Override - public Optional getOptionalValue(String key) { - String v = getValue(key); - return v != null ? Optional.of(v) : Optional.empty(); - } - }; + @Bean + @ConditionalOnMissingBean + public AgentCard agentCard(A2AProperties props, ResourceLoader resourceLoader) throws Exception { + ObjectMapper mapper = new ObjectMapper().registerModule(new AgentCardJacksonModule()); + Resource resource = resourceLoader.getResource(props.getAgentCard()); + if (!resource.exists()) { + throw new IllegalStateException("AgentCard not found: " + props.getAgentCard()); } - - @Bean - @ConditionalOnMissingBean - public InMemoryTaskStore taskStore() { - return new InMemoryTaskStore(); + try (InputStream is = resource.getInputStream()) { + AgentCard card = mapper.readValue(is, AgentCard.class); + log.info("[A2A] Loaded AgentCard: name={}, version={}", card.name(), card.version()); + return card; } - - @Bean - @ConditionalOnMissingBean - public MainEventBus eventBus() { - return new MainEventBus(); - } - - @Bean - @ConditionalOnMissingBean - public InMemoryQueueManager queueManager(InMemoryTaskStore store, MainEventBus bus) { - return new InMemoryQueueManager(store, bus); - } - - @Bean - @ConditionalOnMissingBean - public PushNotificationConfigStore pushStore() { - return new InMemoryPushNotificationConfigStore(); - } - - @Bean(destroyMethod = "shutdownNow") - @ConditionalOnMissingBean - public ExecutorService agentExecutorPool(A2AProperties properties) { - if (properties.getExecutorMaxSize() < properties.getExecutorCoreSize()) { - throw new IllegalArgumentException( - "a2at.server.executor-max-size must be >= executor-core-size"); - } - return new ThreadPoolExecutor( - properties.getExecutorCoreSize(), - properties.getExecutorMaxSize(), - properties.getExecutorKeepAliveSeconds(), - TimeUnit.SECONDS, - new LinkedBlockingQueue<>(properties.getExecutorQueueCapacity()), - r -> { - Thread t = new Thread(r, "a2a-agent-executor"); - t.setDaemon(true); - return t; - }, - new ThreadPoolExecutor.CallerRunsPolicy()); - } - - @Bean - @ConditionalOnMissingBean - public MainEventBusProcessor eventBusProcessor( - MainEventBus bus, - InMemoryTaskStore store, - InMemoryQueueManager qm, - PushNotificationConfigStore pushStore) { - PushNotificationSender sender = new BasePushNotificationSender(pushStore); - MainEventBusProcessor proc = new MainEventBusProcessor(bus, store, sender, qm); - proc.ensureStarted(); - log.info("[A2A] MainEventBusProcessor started"); - return proc; - } - - @Bean(initMethod = "") - @ConditionalOnMissingBean - public RequestHandler requestHandler( - AgentExecutor executor, - InMemoryTaskStore store, - InMemoryQueueManager qm, - PushNotificationConfigStore pushStore, - MainEventBusProcessor proc, - ExecutorService 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 - @ConditionalOnMissingBean - public RestHandler restHandler(AgentCard card, RequestHandler handler, ExecutorService pool) { - return new RestHandler(card, new AgentCardCacheMetadata(card, null), handler, pool); - } - - @Bean - @ConditionalOnMissingBean - public A2AController a2aController( - RestHandler restHandler, RequestHandler requestHandler) { - return new A2AController(restHandler, requestHandler); + } + + @Bean + @ConditionalOnMissingBean + public A2AConfigProvider a2aConfigProvider(A2AProperties properties) { + return new A2AConfigProvider() { + @Override + public String getValue(String key) { + return switch (key) { + case "a2a.blocking.agent.timeout.seconds" -> + Integer.toString(properties.getAgentTimeoutSeconds()); + case "a2a.blocking.consumption.timeout.seconds" -> + Integer.toString(properties.getConsumptionTimeoutSeconds()); + case "a2a.blocking.reconciliation.timeout.seconds" -> + Integer.toString(properties.getReconciliationTimeoutSeconds()); + default -> null; + }; + } + + @Override + public Optional getOptionalValue(String key) { + String v = getValue(key); + return v != null ? Optional.of(v) : Optional.empty(); + } + }; + } + + @Bean + @ConditionalOnMissingBean + public InMemoryTaskStore taskStore() { + return new InMemoryTaskStore(); + } + + @Bean + @ConditionalOnMissingBean + public MainEventBus eventBus() { + return new MainEventBus(); + } + + @Bean + @ConditionalOnMissingBean + public InMemoryQueueManager queueManager(InMemoryTaskStore store, MainEventBus bus) { + return new InMemoryQueueManager(store, bus); + } + + @Bean + @ConditionalOnMissingBean + public PushNotificationConfigStore pushStore() { + return new InMemoryPushNotificationConfigStore(); + } + + @Bean(destroyMethod = "shutdownNow") + @ConditionalOnMissingBean + public ExecutorService agentExecutorPool(A2AProperties properties) { + if (properties.getExecutorMaxSize() < properties.getExecutorCoreSize()) { + throw new IllegalArgumentException( + "a2at.server.executor-max-size must be >= executor-core-size"); } + return new ThreadPoolExecutor( + properties.getExecutorCoreSize(), + properties.getExecutorMaxSize(), + properties.getExecutorKeepAliveSeconds(), + TimeUnit.SECONDS, + new LinkedBlockingQueue<>(properties.getExecutorQueueCapacity()), + r -> { + Thread t = new Thread(r, "a2a-agent-executor"); + t.setDaemon(true); + return t; + }, + new ThreadPoolExecutor.CallerRunsPolicy()); + } + + @Bean + @ConditionalOnMissingBean + public MainEventBusProcessor eventBusProcessor( + MainEventBus bus, + InMemoryTaskStore store, + InMemoryQueueManager qm, + PushNotificationConfigStore pushStore) { + PushNotificationSender sender = new BasePushNotificationSender(pushStore); + MainEventBusProcessor proc = new MainEventBusProcessor(bus, store, sender, qm); + proc.ensureStarted(); + log.info("[A2A] MainEventBusProcessor started"); + return proc; + } + + @Bean(initMethod = "") + @ConditionalOnMissingBean + public RequestHandler requestHandler( + AgentExecutor executor, + InMemoryTaskStore store, + InMemoryQueueManager qm, + PushNotificationConfigStore pushStore, + MainEventBusProcessor proc, + ExecutorService 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 + @ConditionalOnMissingBean + public RestHandler restHandler(AgentCard card, RequestHandler handler, ExecutorService pool) { + return new RestHandler(card, new AgentCardCacheMetadata(card, null), handler, pool); + } + + @Bean + @ConditionalOnMissingBean + public A2AController a2aController(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 3efb4b99..271e9b84 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 @@ -20,9 +20,17 @@ package dev.openan.workflow.engine.spring; import com.google.protobuf.util.JsonFormat; - import jakarta.servlet.http.HttpServletRequest; - +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +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; import org.a2aproject.sdk.grpc.SendMessageRequest; import org.a2aproject.sdk.grpc.StreamResponse; import org.a2aproject.sdk.grpc.utils.ProtoUtils; @@ -43,17 +51,6 @@ 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; - /** * Spring MVC controller exposing A2A message endpoints. * @@ -72,260 +69,256 @@ @RestController public class A2AController { - private static final Logger log = LoggerFactory.getLogger(A2AController.class); + private static final Logger log = LoggerFactory.getLogger(A2AController.class); - private final RestHandler restHandler; - private final RequestHandler requestHandler; - private final AtomicInteger activeStreams = new AtomicInteger(); - private final Object streamCompletionMonitor = new Object(); + private final RestHandler restHandler; + private final RequestHandler requestHandler; + private final AtomicInteger activeStreams = new AtomicInteger(); + private final Object streamCompletionMonitor = new Object(); - public A2AController(RestHandler restHandler, RequestHandler requestHandler) { - this.restHandler = restHandler; - this.requestHandler = requestHandler; - } + public A2AController(RestHandler restHandler, RequestHandler requestHandler) { + this.restHandler = restHandler; + this.requestHandler = requestHandler; + } - @PostMapping("${a2at.server.path-prefix}/message:send") - public ResponseEntity sendMessage(HttpServletRequest req, @RequestBody String body) { - var ctx = buildContext(req); - var resp = restHandler.sendMessage(ctx, "", body); - 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}/message:send") + public ResponseEntity sendMessage(HttpServletRequest req, @RequestBody String body) { + var ctx = buildContext(req); + var resp = restHandler.sendMessage(ctx, "", body); + 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}/message:stream", - produces = MediaType.TEXT_EVENT_STREAM_VALUE) - 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()); + @PostMapping( + value = "${a2at.server.path-prefix}/message:stream", + produces = MediaType.TEXT_EVENT_STREAM_VALUE) + 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()); - var ctx = buildContext(req); - Flow.Publisher publisher = - requestHandler.onMessageSendStream(params, ctx); + var ctx = buildContext(req); + Flow.Publisher publisher = + requestHandler.onMessageSendStream(params, ctx); - final AtomicLong seq = new AtomicLong(0); - publisher.subscribe( - new Flow.Subscriber<>() { - private Flow.Subscription sub; + final AtomicLong seq = new AtomicLong(0); + publisher.subscribe( + new Flow.Subscriber<>() { + private Flow.Subscription sub; - @Override - public void onSubscribe(Flow.Subscription s) { - sub = s; - s.request(1); - } + @Override + public void onSubscribe(Flow.Subscription s) { + sub = s; + s.request(1); + } - @Override - public void onNext(StreamingEventKind item) { - try { - StreamResponse sr = ProtoUtils.ToProto.streamResponse(item); - 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(); - emitter.completeWithError(e); - return; - } - sub.request(1); - } + @Override + public void onNext(StreamingEventKind item) { + try { + StreamResponse sr = ProtoUtils.ToProto.streamResponse(item); + 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(); + emitter.completeWithError(e); + return; + } + sub.request(1); + } - @Override - public void onError(Throwable t) { - log.error("[SSE] Stream error: {}", t.getMessage()); - emitter.completeWithError(t); - } + @Override + public void onError(Throwable t) { + log.error("[SSE] Stream error: {}", t.getMessage()); + emitter.completeWithError(t); + } - @Override - public void onComplete() { - emitter.complete(); - } - }); - } catch (Exception e) { - log.error("[SSE] Setup failed: {}", e.getMessage(), e); - emitter.completeWithError(e); - } - return emitter; + @Override + public void onComplete() { + emitter.complete(); + } + }); + } catch (Exception e) { + log.error("[SSE] Setup failed: {}", e.getMessage(), e); + emitter.completeWithError(e); } + 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()); - } + @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("${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; + } - @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)); + /** + * 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 { - 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); + TimeUnit.NANOSECONDS.timedWait(streamCompletionMonitor, remaining); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; } - return emitter; + } } + return true; + } - /** - * 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; - } + 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(); } - } - return true; - } + } + }; + emitter.onCompletion(finish); + emitter.onTimeout(finish); + emitter.onError(ignored -> finish.run()); + return emitter; + } - public int activeStreamCount() { - return activeStreams.get(); + private ServerCallContext buildContext(HttpServletRequest req) { + Map state = new LinkedHashMap<>(); + Map headers = new LinkedHashMap<>(); + if (req.getHeaderNames() != null) { + java.util.Collections.list(req.getHeaderNames()) + .forEach(h -> headers.put(h.toLowerCase(Locale.ROOT), req.getHeader(h))); } - - 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; + state.put("headers", headers); + String ext = req.getHeader("A2A-Extensions"); + 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); } - - private ServerCallContext buildContext(HttpServletRequest req) { - Map state = new LinkedHashMap<>(); - Map headers = new LinkedHashMap<>(); - 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; - 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); + // 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/A2AProperties.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AProperties.java index 6c69e9ac..58abeb86 100644 --- a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AProperties.java +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AProperties.java @@ -30,121 +30,120 @@ @ConfigurationProperties(prefix = "a2at.server") public class A2AProperties { - /** Whether A2A server autoconfiguration is enabled. Set false to disable all A2A beans. */ - private boolean enabled = true; + /** Whether A2A server autoconfiguration is enabled. Set false to disable all A2A beans. */ + private boolean enabled = true; - /** Path to the AgentCard JSON file (classpath: or file: prefix supported). */ - private String agentCard = "classpath:agentcard.json"; + /** Path to the AgentCard JSON file (classpath: or file: prefix supported). */ + private String agentCard = "classpath:agentcard.json"; - /** URL path prefix for A2A endpoints (extracted from AgentCard by default). */ - private String pathPrefix = "/a2a/json"; + /** URL path prefix for A2A endpoints (extracted from AgentCard by default). */ + private String pathPrefix = "/a2a/json"; - /** Timeout for blocking agent execution. */ - private int agentTimeoutSeconds = 30; + /** Timeout for blocking agent execution. */ + private int agentTimeoutSeconds = 30; - /** Timeout for consuming and persisting events in blocking calls. */ - private int consumptionTimeoutSeconds = 5; + /** Timeout for consuming and persisting events in blocking calls. */ + private int consumptionTimeoutSeconds = 5; - /** Timeout for TaskStore reconciliation polling in blocking calls. */ - private int reconciliationTimeoutSeconds = 1; + /** Timeout for TaskStore reconciliation polling in blocking calls. */ + private int reconciliationTimeoutSeconds = 1; - /** Core server executor thread count. */ - private int executorCoreSize = 8; + /** Core server executor thread count. */ + private int executorCoreSize = 8; - /** Maximum server executor thread count. */ - private int executorMaxSize = 8; + /** Maximum server executor thread count. */ + private int executorMaxSize = 8; - /** Maximum number of queued server tasks. */ - private int executorQueueCapacity = 100; + /** Maximum number of queued server tasks. */ + private int executorQueueCapacity = 100; - /** Idle timeout for server executor threads above the core size. */ - private int executorKeepAliveSeconds = 60; + /** Idle timeout for server executor threads above the core size. */ + private int executorKeepAliveSeconds = 60; - public boolean isEnabled() { - return enabled; - } + private static int positive(int value, String name) { + if (value <= 0) throw new IllegalArgumentException(name + " must be positive"); + return value; + } - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } + public boolean isEnabled() { + return enabled; + } - public String getAgentCard() { - return agentCard; - } + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } - public void setAgentCard(String agentCard) { - this.agentCard = agentCard; - } + public String getAgentCard() { + return agentCard; + } - public String getPathPrefix() { - return pathPrefix; - } + public void setAgentCard(String agentCard) { + this.agentCard = agentCard; + } - public void setPathPrefix(String pathPrefix) { - this.pathPrefix = pathPrefix; - } + public String getPathPrefix() { + return pathPrefix; + } - public int getAgentTimeoutSeconds() { - return agentTimeoutSeconds; - } + public void setPathPrefix(String pathPrefix) { + this.pathPrefix = pathPrefix; + } - public void setAgentTimeoutSeconds(int agentTimeoutSeconds) { - this.agentTimeoutSeconds = positive(agentTimeoutSeconds, "agentTimeoutSeconds"); - } + public int getAgentTimeoutSeconds() { + return agentTimeoutSeconds; + } - public int getConsumptionTimeoutSeconds() { - return consumptionTimeoutSeconds; - } + public void setAgentTimeoutSeconds(int agentTimeoutSeconds) { + this.agentTimeoutSeconds = positive(agentTimeoutSeconds, "agentTimeoutSeconds"); + } - public void setConsumptionTimeoutSeconds(int consumptionTimeoutSeconds) { - this.consumptionTimeoutSeconds = - positive(consumptionTimeoutSeconds, "consumptionTimeoutSeconds"); - } + public int getConsumptionTimeoutSeconds() { + return consumptionTimeoutSeconds; + } - public int getReconciliationTimeoutSeconds() { - return reconciliationTimeoutSeconds; - } + public void setConsumptionTimeoutSeconds(int consumptionTimeoutSeconds) { + this.consumptionTimeoutSeconds = + positive(consumptionTimeoutSeconds, "consumptionTimeoutSeconds"); + } - public void setReconciliationTimeoutSeconds(int reconciliationTimeoutSeconds) { - this.reconciliationTimeoutSeconds = - positive(reconciliationTimeoutSeconds, "reconciliationTimeoutSeconds"); - } + public int getReconciliationTimeoutSeconds() { + return reconciliationTimeoutSeconds; + } - public int getExecutorCoreSize() { - return executorCoreSize; - } + public void setReconciliationTimeoutSeconds(int reconciliationTimeoutSeconds) { + this.reconciliationTimeoutSeconds = + positive(reconciliationTimeoutSeconds, "reconciliationTimeoutSeconds"); + } - public void setExecutorCoreSize(int executorCoreSize) { - this.executorCoreSize = positive(executorCoreSize, "executorCoreSize"); - } + public int getExecutorCoreSize() { + return executorCoreSize; + } - public int getExecutorMaxSize() { - return executorMaxSize; - } + public void setExecutorCoreSize(int executorCoreSize) { + this.executorCoreSize = positive(executorCoreSize, "executorCoreSize"); + } - public void setExecutorMaxSize(int executorMaxSize) { - this.executorMaxSize = positive(executorMaxSize, "executorMaxSize"); - } + public int getExecutorMaxSize() { + return executorMaxSize; + } - public int getExecutorQueueCapacity() { - return executorQueueCapacity; - } + public void setExecutorMaxSize(int executorMaxSize) { + this.executorMaxSize = positive(executorMaxSize, "executorMaxSize"); + } - public void setExecutorQueueCapacity(int executorQueueCapacity) { - this.executorQueueCapacity = positive(executorQueueCapacity, "executorQueueCapacity"); - } + public int getExecutorQueueCapacity() { + return executorQueueCapacity; + } - public int getExecutorKeepAliveSeconds() { - return executorKeepAliveSeconds; - } + public void setExecutorQueueCapacity(int executorQueueCapacity) { + this.executorQueueCapacity = positive(executorQueueCapacity, "executorQueueCapacity"); + } - public void setExecutorKeepAliveSeconds(int executorKeepAliveSeconds) { - this.executorKeepAliveSeconds = - positive(executorKeepAliveSeconds, "executorKeepAliveSeconds"); - } + public int getExecutorKeepAliveSeconds() { + return executorKeepAliveSeconds; + } - private static int positive(int value, String name) { - if (value <= 0) throw new IllegalArgumentException(name + " must be positive"); - return value; - } + public void setExecutorKeepAliveSeconds(int executorKeepAliveSeconds) { + this.executorKeepAliveSeconds = positive(executorKeepAliveSeconds, "executorKeepAliveSeconds"); + } } 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 index d7219486..cb22d224 100644 --- 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 @@ -4,6 +4,8 @@ */ package dev.openan.workflow.engine.spring; +import java.util.Objects; +import java.util.concurrent.Flow; import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.TaskOperation; @@ -23,9 +25,6 @@ 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. * @@ -36,75 +35,73 @@ * the builder-created delegate remains framework-neutral. */ final class SpringCompatibleRequestHandler implements RequestHandler { - private final RequestHandler delegate; + private final RequestHandler delegate; - SpringCompatibleRequestHandler(RequestHandler delegate) { - this.delegate = Objects.requireNonNull(delegate, "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 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 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 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 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 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 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 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 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 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 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); - } + @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 8ae00dc1..f87a1da5 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 @@ -5,139 +5,128 @@ package dev.openan.workflow.engine.spring; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadPoolExecutor; import org.a2aproject.sdk.server.config.A2AConfigProvider; -import org.a2aproject.sdk.server.agentexecution.AgentExecutor; -import org.a2aproject.sdk.server.requesthandlers.RequestHandler; -import org.a2aproject.sdk.spec.AgentCard; -import org.a2aproject.sdk.transport.rest.handler.RestHandler; import org.junit.jupiter.api.Test; 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}"; + @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()); + 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()); - } + 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(); - properties.setAgentTimeoutSeconds(90); - properties.setConsumptionTimeoutSeconds(12); - properties.setReconciliationTimeoutSeconds(3); + @Test + void blockingTimeoutsComeFromProperties() { + A2AProperties properties = new A2AProperties(); + properties.setAgentTimeoutSeconds(90); + properties.setConsumptionTimeoutSeconds(12); + properties.setReconciliationTimeoutSeconds(3); - A2AConfigProvider provider = - new A2AAutoConfiguration().a2aConfigProvider(properties); + A2AConfigProvider provider = new A2AAutoConfiguration().a2aConfigProvider(properties); - assertEquals("90", provider.getValue("a2a.blocking.agent.timeout.seconds")); - assertEquals("12", provider.getValue("a2a.blocking.consumption.timeout.seconds")); - assertEquals("3", provider.getValue("a2a.blocking.reconciliation.timeout.seconds")); - } + assertEquals("90", provider.getValue("a2a.blocking.agent.timeout.seconds")); + assertEquals("12", provider.getValue("a2a.blocking.consumption.timeout.seconds")); + assertEquals("3", provider.getValue("a2a.blocking.reconciliation.timeout.seconds")); + } - @Test - void executorUsesConfiguredBounds() { - A2AProperties properties = new A2AProperties(); - properties.setExecutorCoreSize(2); - properties.setExecutorMaxSize(4); - properties.setExecutorQueueCapacity(7); - properties.setExecutorKeepAliveSeconds(15); + @Test + void executorUsesConfiguredBounds() { + A2AProperties properties = new A2AProperties(); + properties.setExecutorCoreSize(2); + properties.setExecutorMaxSize(4); + properties.setExecutorQueueCapacity(7); + properties.setExecutorKeepAliveSeconds(15); - ExecutorService executor = new A2AAutoConfiguration().agentExecutorPool(properties); - try { - ThreadPoolExecutor pool = (ThreadPoolExecutor) executor; - assertEquals(2, pool.getCorePoolSize()); - assertEquals(4, pool.getMaximumPoolSize()); - assertEquals(7, pool.getQueue().remainingCapacity()); - } finally { - executor.shutdownNow(); - } + ExecutorService executor = new A2AAutoConfiguration().agentExecutorPool(properties); + try { + ThreadPoolExecutor pool = (ThreadPoolExecutor) executor; + assertEquals(2, pool.getCorePoolSize()); + assertEquals(4, pool.getMaximumPoolSize()); + assertEquals(7, pool.getQueue().remainingCapacity()); + } finally { + executor.shutdownNow(); } + } - @Test - void executorRejectsMaxBelowCore() { - A2AProperties properties = new A2AProperties(); - properties.setExecutorCoreSize(4); - properties.setExecutorMaxSize(2); - assertThrows( - IllegalArgumentException.class, - () -> new A2AAutoConfiguration().agentExecutorPool(properties)); - } + @Test + void executorRejectsMaxBelowCore() { + A2AProperties properties = new A2AProperties(); + properties.setExecutorCoreSize(4); + properties.setExecutorMaxSize(2); + assertThrows( + IllegalArgumentException.class, + () -> new A2AAutoConfiguration().agentExecutorPool(properties)); + } - @Test - void whenEnabledFalseThenNoA2ABeansCreated() { - new WebApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(A2AAutoConfiguration.class)) - .withPropertyValues("a2at.server.enabled=false") - .run(context -> { - assertFalse(context.containsBean("agentCard")); - assertFalse(context.containsBean("requestHandler")); - assertFalse(context.containsBean("restHandler")); - assertFalse(context.containsBean("a2aController")); - assertFalse(context.containsBean("agentExecutorPool")); - assertFalse(context.containsBean("eventBus")); - assertFalse(context.containsBean("taskStore")); - }); - } + @Test + void whenEnabledFalseThenNoA2ABeansCreated() { + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(A2AAutoConfiguration.class)) + .withPropertyValues("a2at.server.enabled=false") + .run( + context -> { + assertFalse(context.containsBean("agentCard")); + assertFalse(context.containsBean("requestHandler")); + assertFalse(context.containsBean("restHandler")); + assertFalse(context.containsBean("a2aController")); + assertFalse(context.containsBean("agentExecutorPool")); + assertFalse(context.containsBean("eventBus")); + assertFalse(context.containsBean("taskStore")); + }); + } - @Test - void whenEnabledTrueThenA2ABeansCreated() { - new WebApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(A2AAutoConfiguration.class)) - .withPropertyValues("a2at.server.enabled=true") - .run(context -> { - // agentCard will fail to load without a real classpath resource, - // but the bean definition should be present (condition matched) - assertFalse(context.getStartupFailure() != null - && context.getStartupFailure().getMessage() != null - && context.getStartupFailure().getMessage().contains("did not match")); - }); - } + @Test + void whenEnabledTrueThenA2ABeansCreated() { + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(A2AAutoConfiguration.class)) + .withPropertyValues("a2at.server.enabled=true") + .run( + context -> { + // agentCard will fail to load without a real classpath resource, + // but the bean definition should be present (condition matched) + assertFalse( + context.getStartupFailure() != null + && context.getStartupFailure().getMessage() != null + && context.getStartupFailure().getMessage().contains("did not match")); + }); + } } 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 f5de5959..f7ca6a27 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 @@ -19,17 +19,13 @@ package dev.openan.workflow.engine.client; +import dev.openan.workflow.engine.model.SendMessageResult; +import java.util.function.Consumer; 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; +import org.a2aproject.sdk.spec.Task; /** * Runtime seam for sending A2A messages via the a2a-java SDK's {@link @@ -44,50 +40,43 @@ */ public interface A2AJavaClientRuntime { - /** - * Send a message to an agent and collect the streaming events. - * - * @param agentCard the target agent's card as a map (from config or registry) - * @param params the message send parameters (message, context, metadata) - * @param callContext client call context with auth/extension headers - * @param eventSink optional callback invoked for each intermediate event (status updates, - * artifact updates, messages). Null = no real-time forwarding. - * @param logSink optional log consumer for SDK diagnostics - * @return an iterable of {@link ClientEvent} produced by the agent - */ - Iterable sendMessage( - AgentCard agentCard, - MessageSendParams params, - ClientCallContext callContext, - Consumer eventSink, - Consumer logSink); - + /** + * Send a message to an agent and collect the streaming events. + * + * @param agentCard the target agent's card as a map (from config or registry) + * @param params the message send parameters (message, context, metadata) + * @param callContext client call context with auth/extension headers + * @param eventSink optional callback invoked for each intermediate event (status updates, + * artifact updates, messages). Null = no real-time forwarding. + * @param logSink optional log consumer for SDK diagnostics + * @return an iterable of {@link ClientEvent} produced by the agent + */ + Iterable sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + 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"); - } + /** 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"); - } + /** 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"); - } + /** 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(); + /** 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 723482cd..df866f96 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 @@ -35,43 +35,42 @@ *

  2. {@link #AUTHORIZATION_T} - independent whitelist authorization operation *
  3. {@link #NOTIFICATION_T} - independent result subscription * - * */ public enum A2ATExtension { - /** Host-generated task content. The engine does not generate it. */ - TASK_T(ExtensionUriConstants.TASK_T_EXTENSION_URI), + /** 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(ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI), + /** Negotiation text exchange. Handled automatically via auto-loop. */ + NEGOTIATION_T(ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI), - /** Authorization whitelist. Pre-positioned before workflow starts. */ - AUTHORIZATION_T(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI), + /** Authorization whitelist. Pre-positioned before workflow starts. */ + AUTHORIZATION_T(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI), - /** Result notification subscription. Pre-positioned before workflow starts. */ - NOTIFICATION_T(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); + /** Result notification subscription. Pre-positioned before workflow starts. */ + 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; + /** 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; + private final String uri; - A2ATExtension(String uri) { - this.uri = uri; - } + A2ATExtension(String uri) { + this.uri = uri; + } - /** - * @return the full extension URI used as metadata key and A2A-Extensions header value - */ - public String uri() { - return uri; - } + /** + * @return the full extension URI used as metadata key and A2A-Extensions header value + */ + public String uri() { + return uri; + } - /** - * @return short display name (e.g. {@code "Authorization-T"}) - */ - public String displayName() { - return name().replace('_', '-'); - } + /** + * @return short display name (e.g. {@code "Authorization-T"}) + */ + public String displayName() { + return name().replace('_', '-'); + } } 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 5be94324..aa858134 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 @@ -19,38 +19,18 @@ package dev.openan.workflow.engine.client; -import dev.openan.workflow.engine.model.SendMessageResult; - 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.client.transport.spi.interceptors.ClientCallContext; -import org.a2aproject.sdk.spec.AgentCard; -import org.a2aproject.sdk.spec.Artifact; -import org.a2aproject.sdk.spec.Message; -import org.a2aproject.sdk.spec.MessageSendParams; -import org.a2aproject.sdk.spec.Part; -import org.a2aproject.sdk.spec.Task; -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 dev.openan.workflow.engine.model.SendMessageResult; +import java.time.Duration; import java.util.ArrayList; +import java.util.HashMap; 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.ConcurrentHashMap; @@ -60,6 +40,22 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +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.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.a2aproject.sdk.spec.TextPart; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Shared A2A communication base (client runtime + auth + card map + SSE event extraction). This is @@ -76,780 +72,800 @@ */ public class A2ATransport implements AutoCloseable { - private static final Logger log = LoggerFactory.getLogger(A2ATransport.class); - - private volatile Map cardMap = Map.of(); - private final A2AJavaClientRuntime a2aClientRuntime; - private final AgentAuthManager authManager; - private final AuthProvider authProvider; - 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 - : new DefaultA2AJavaClientRuntime( - config.isSslVerify(), - config.getCaCertsPath(), - config.getClientCertPath(), - config.getClientKeyPath(), - config.getClientKeyPassword(), - config.getCrlPath(), - config.getSendTimeoutSeconds(), - config.getPreferredProtocol(), - config.getSendExecutorCoreSize(), - config.getSendExecutorMaxSize(), - config.getSendExecutorQueueCapacity()); - this.contextId = UUID.randomUUID().toString(); - this.notificationAckTimeoutSeconds = config.getNotificationAckTimeoutSeconds(); - this.sendTimeoutSeconds = config.getSendTimeoutSeconds(); - this.asyncExecutor = - new ThreadPoolExecutor( - config.getSendExecutorCoreSize(), - config.getSendExecutorMaxSize(), - 60L, - TimeUnit.SECONDS, - new ArrayBlockingQueue<>(config.getSendExecutorQueueCapacity()), - r -> { - Thread t = new Thread(r, "engine-send"); - t.setDaemon(true); - return t; - }, - new ThreadPoolExecutor.CallerRunsPolicy()); - CredentialHttpTransport credentialHttpTransport = null; - String credentialEncryptionKey = config.getCredentialEncryptionKey(); - if (config.getCredentialsConfigPath() != null - || config.getCredentialsConfig() != null) { - credentialHttpTransport = - CredentialHttpTransport.create( - config.isSslVerify(), - config.getCaCertsPath(), - config.getClientCertPath(), - config.getClientKeyPath(), - config.getClientKeyPassword(), - config.getCrlPath(), - Duration.ofSeconds(30)); - } - if (config.getCredentialsConfigPath() != null) { - this.authManager = - new AgentAuthManager( - config.getCredentialsConfigPath(), - credentialHttpTransport, - credentialEncryptionKey); - } else if (config.getCredentialsConfig() != null) { - this.authManager = - new AgentAuthManager( - config.getCredentialsConfig(), - credentialHttpTransport, - credentialEncryptionKey); - } else { - this.authManager = new AgentAuthManager(); - } - this.authProvider = config.getAuthProvider(); - this.clientCallContextFactory = - new ClientCallContextFactory( - new AuthProviderHeaderContributor(authProvider), - 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) { - // 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) { - 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); - } + private static final Logger log = LoggerFactory.getLogger(A2ATransport.class); + private final A2AJavaClientRuntime a2aClientRuntime; + private final AgentAuthManager authManager; + private final AuthProvider authProvider; + 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(); + private volatile Map cardMap = Map.of(); + + public A2ATransport( + List agentCards, + A2AJavaClientRuntime a2aClientRuntime, + WorkflowEngineClientConfig config) { + config = java.util.Objects.requireNonNull(config, "config"); + Map validatedCards = validateAgentCards(agentCards); + this.a2aClientRuntime = + a2aClientRuntime != null + ? a2aClientRuntime + : new DefaultA2AJavaClientRuntime( + config.isSslVerify(), + config.getCaCertsPath(), + config.getClientCertPath(), + config.getClientKeyPath(), + config.getClientKeyPassword(), + config.getCrlPath(), + config.getSendTimeoutSeconds(), + config.getPreferredProtocol(), + config.getSendExecutorCoreSize(), + config.getSendExecutorMaxSize(), + config.getSendExecutorQueueCapacity()); + this.contextId = UUID.randomUUID().toString(); + this.notificationAckTimeoutSeconds = config.getNotificationAckTimeoutSeconds(); + this.sendTimeoutSeconds = config.getSendTimeoutSeconds(); + this.asyncExecutor = + new ThreadPoolExecutor( + config.getSendExecutorCoreSize(), + config.getSendExecutorMaxSize(), + 60L, + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(config.getSendExecutorQueueCapacity()), + r -> { + Thread t = new Thread(r, "engine-send"); + t.setDaemon(true); + return t; + }, + new ThreadPoolExecutor.CallerRunsPolicy()); + CredentialHttpTransport credentialHttpTransport = null; + String credentialEncryptionKey = config.getCredentialEncryptionKey(); + if (config.getCredentialsConfigPath() != null || config.getCredentialsConfig() != null) { + credentialHttpTransport = + CredentialHttpTransport.create( + config.isSslVerify(), + config.getCaCertsPath(), + config.getClientCertPath(), + config.getClientKeyPath(), + config.getClientKeyPassword(), + config.getCrlPath(), + Duration.ofSeconds(30)); + } + if (config.getCredentialsConfigPath() != null) { + this.authManager = + new AgentAuthManager( + config.getCredentialsConfigPath(), credentialHttpTransport, credentialEncryptionKey); + } else if (config.getCredentialsConfig() != null) { + this.authManager = + new AgentAuthManager( + config.getCredentialsConfig(), credentialHttpTransport, credentialEncryptionKey); + } else { + this.authManager = new AgentAuthManager(); + } + this.authProvider = config.getAuthProvider(); + this.clientCallContextFactory = + new ClientCallContextFactory( + new AuthProviderHeaderContributor(authProvider), + 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) { + // 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) { + 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 (!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(); - } - - // ------------------------------------------------------------------ - // Accessors - // ------------------------------------------------------------------ - - public static void extractTextFromTask(Task task, StringBuilder sb) { - if (task.artifacts() != null) - for (Artifact a : task.artifacts()) extractTextFromArtifact(a, sb); - } - - public static void extractTextFromArtifact(Artifact artifact, StringBuilder sb) { - for (Part part : artifact.parts()) if (part instanceof TextPart tp) sb.append(tp.text()); - } - - public static void extractTextFromMessage(Message message, StringBuilder sb) { - if (message == null) return; - for (Part part : message.parts()) if (part instanceof TextPart tp) sb.append(tp.text()); - } - - public static String extractResponseTaskState(Iterable events) { - String state = ""; - for (ClientEvent event : events) { - if (event instanceof TaskEvent te) state = te.getTask().status().state().name(); - else if (event instanceof TaskUpdateEvent tue) { - if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) - state = sue.status().state().name(); - } - } - 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); - // 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; - } - - // ------------------------------------------------------------------ - // Core send via SDK runtime - // ------------------------------------------------------------------ - - public static void mergeTaskMetadata(Task task, Map metadata) { - if (task == null) return; - Map m = task.metadata(); - if (m != null && !m.isEmpty()) metadata.putAll(m); - if (task.artifacts() != null) { - for (Artifact a : task.artifacts()) { - Map am = a.metadata(); - if (am != null && !am.isEmpty()) metadata.putAll(am); - } - } - } - - public static Task extractResponseTask(Iterable events) { - Task lastTask = null; - for (ClientEvent event : events) { - if (event instanceof TaskEvent te) lastTask = te.getTask(); - else if (event instanceof TaskUpdateEvent tue) lastTask = tue.getTask(); - } - return lastTask; - } - - // ------------------------------------------------------------------ - // Build helpers - // ------------------------------------------------------------------ - - 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 != null && !uri.isBlank()) uris.add(uri); - } - return uris; - } - - public AgentCard getCard(String agentName) { - return cardMap.get(agentName); - } - - public List getAgentNames() { - return new ArrayList<>(cardMap.keySet()); - } - - /** Time budget shared by sending and business interaction callbacks. */ - public long sendTimeoutSeconds() { return sendTimeoutSeconds; } - - public String getContextId() { - return contextId; - } - - public void updateAgentCards(List agentCards) { - 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) { - 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()); - } + if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue + && sue.status().message() != null) { + addFallbackMessage(sue.status().message(), fallbackMessages); } - return validated; - } - - /** - * Send a message and collect the streaming events. The optional {@code eventSink} is invoked - * for each intermediate event (status updates, artifact updates, messages); the workflow facade - * wires it to its event callback, the one-shot sender passes {@code null}. - */ - public CompletableFuture send( - AgentCard agentCard, String agentName, MessageContent content, String contextId, - String taskId, Consumer eventSink) { - return send(agentCard, agentName, content, contextId, taskId, eventSink, () -> true); - } - - 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")); + } 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(); + } + + // ------------------------------------------------------------------ + // Accessors + // ------------------------------------------------------------------ + + public static void extractTextFromTask(Task task, StringBuilder sb) { + if (task.artifacts() != null) + for (Artifact a : task.artifacts()) extractTextFromArtifact(a, sb); + } + + public static void extractTextFromArtifact(Artifact artifact, StringBuilder sb) { + for (Part part : artifact.parts()) if (part instanceof TextPart tp) sb.append(tp.text()); + } + + public static void extractTextFromMessage(Message message, StringBuilder sb) { + if (message == null) return; + for (Part part : message.parts()) if (part instanceof TextPart tp) sb.append(tp.text()); + } + + public static String extractResponseTaskState(Iterable events) { + String state = ""; + for (ClientEvent event : events) { + if (event instanceof TaskEvent te) state = te.getTask().status().state().name(); + else if (event instanceof TaskUpdateEvent tue) { + if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) + state = sue.status().state().name(); + } + } + 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); + // 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); + } } - 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 { - 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"); + } else if (event instanceof MessageEvent me) { + Map mm = me.getMessage().metadata(); + if (mm != null && !mm.isEmpty()) metadata.putAll(mm); + } + } + return metadata; + } + + // ------------------------------------------------------------------ + // Core send via SDK runtime + // ------------------------------------------------------------------ + + public static void mergeTaskMetadata(Task task, Map metadata) { + if (task == null) return; + Map m = task.metadata(); + if (m != null && !m.isEmpty()) metadata.putAll(m); + if (task.artifacts() != null) { + for (Artifact a : task.artifacts()) { + Map am = a.metadata(); + if (am != null && !am.isEmpty()) metadata.putAll(am); + } + } + } + + public static Task extractResponseTask(Iterable events) { + Task lastTask = null; + for (ClientEvent event : events) { + if (event instanceof TaskEvent te) lastTask = te.getTask(); + else if (event instanceof TaskUpdateEvent tue) lastTask = tue.getTask(); + } + return lastTask; + } + + // ------------------------------------------------------------------ + // Build helpers + // ------------------------------------------------------------------ + + 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 != null && !uri.isBlank()) uris.add(uri); + } + return uris; + } + + private static Map validateAgentCards(List agentCards) { + java.util.Objects.requireNonNull(agentCards, "agentCards"); + Map validated = new java.util.LinkedHashMap<>(); + for (AgentCard card : agentCards) { + 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()); + } + } + return validated; + } + + 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 != null) { + if (current instanceof InterruptedException) { + return true; + } + current = current.getCause(); + } + return false; + } + + 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; + } + + /** + * 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 carrying a concrete + * task state; an artifact alone is application data, not a protocol acknowledgement. + */ + public AgentCard getCard(String agentName) { + return cardMap.get(agentName); + } + + /** Time budget shared by sending and business interaction callbacks. */ + public long sendTimeoutSeconds() { + return sendTimeoutSeconds; + } + + public String getContextId() { + return contextId; + } + + /** + * Send a message and collect the streaming events. The optional {@code eventSink} is invoked for + * each intermediate event (status updates, artifact updates, messages); the workflow facade wires + * it to its event callback, the one-shot sender passes {@code null}. + */ + public CompletableFuture send( + AgentCard agentCard, + String agentName, + MessageContent content, + String contextId, + String taskId, + Consumer eventSink) { + return send(agentCard, agentName, content, contextId, taskId, eventSink, () -> true); + } + + 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 { + 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() + ? "?" + : agentCard.supportedInterfaces().get(0).url(); + ProtocolLogger.logRequest( + agentName, endpoint, params, callContext.getHeaders()); + log.info("[Transport] Sending via A2A SDK to {}", agentName); + Iterable events = + a2aClientRuntime.sendMessage( + agentCard, + params, + callContext, + eventSink, + s -> log.info("[A2A] {}", s)); + String responseText = extractResponseText(events); + String taskState = extractResponseTaskState(events); + Map respMetadata = extractResponseMetadata(events); + Task task = extractResponseTask(events); + log.info( + "[Transport] Response from {}: {} chars, state={}", + agentName, + responseText.length(), + taskState); + return SendMessageResult.builder() + .text(responseText) + .task(task) + .metadata(respMetadata) + .taskState(taskState) + .receivedMessages(ProtocolResponses.assemble(events)) + .build(); + } catch (Exception e) { + log.error( + "[Transport] Failed to send message to {}: {}", + agentName, + e.getMessage(), + e); + if (e instanceof SecurityException securityException) { + throw securityException; + } + throw new RuntimeException("Agent call failed: " + e.getMessage(), e); + } + }), + asyncExecutor); + } + + /** Release resources retained by a conversation-scoped runtime. */ + public void closeConversation(AgentCard agentCard, String contextId) { + if (a2aClientRuntime instanceof ConversationScopedA2AJavaClientRuntime scopedRuntime) { + try { + scopedRuntime.closeConversation(agentCard, contextId); + } catch (RuntimeException e) { + log.warn( + "[Transport] CONVERSATION_CLOSE_FAILED contextId={}, agent={}, message={}", + contextId, + agentCard != null ? agentCard.name() : "?", + e.getMessage(), + e); + } + } + } + + /** Opens a long-lived Notification-T stream and returns its explicit lifecycle handle. */ + public NotificationSubscription openNotificationStream( + AgentCard agentCard, + String agentName, + MessageContent content, + String contextId, + java.util.function.BiConsumer eventSink) { + if (closed.get()) { + throw new IllegalStateException("A2A transport is closed"); + } + 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(content, contextId, null); + ClientCallContext callContext = + buildContentCallContext(agentCard, agentName, content); String endpoint = - agentCard.supportedInterfaces().isEmpty() - ? "?" - : agentCard.supportedInterfaces().get(0).url(); + agentCard.supportedInterfaces().isEmpty() + ? "?" + : agentCard.supportedInterfaces().get(0).url(); ProtocolLogger.logRequest( - agentName, endpoint, params, callContext.getHeaders()); - log.info("[Transport] Sending via A2A SDK to {}", agentName); - Iterable events = - a2aClientRuntime.sendMessage( - agentCard, - params, - callContext, - eventSink, - s -> log.info("[A2A] {}", s)); - String responseText = extractResponseText(events); - String taskState = extractResponseTaskState(events); - Map respMetadata = extractResponseMetadata(events); - Task task = extractResponseTask(events); + agentName, endpoint, params, callContext.getHeaders()); log.info( - "[Transport] Response from {}: {} chars, state={}", - agentName, - responseText.length(), - taskState); - return SendMessageResult.builder() - .text(responseText) - .task(task) - .metadata(respMetadata) - .taskState(taskState) - .receivedMessages(ProtocolResponses.assemble(events)) - .build(); - } catch (Exception e) { - log.error( - "[Transport] Failed to send message to {}: {}", - agentName, - e.getMessage(), - e); - if (e instanceof SecurityException securityException) { - throw securityException; + "[Transport] Opening Notification-T long-lived stream to {}", + agentName); + a2aClientRuntime.sendMessage( + agentCard, + params, + callContext, + event -> { + log.info( + "[Transport] Notification-T event from {}: {}", + agentName, + event.getClass().getSimpleName()); + subscription.recordEvent(); + if (eventSink != null) { + responses.accept(event); + for (ReceivedMessage received : responses.snapshots()) { + eventSink.accept(subscription, received); + } + } + 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 (!subscription.acknowledgement().isDone()) { + subscription.failAcknowledgement( + new IllegalStateException("Stream closed before acknowledgement")); } - throw new RuntimeException("Agent call failed: " + e.getMessage(), e); - } - }), - asyncExecutor); - } - - /** Release resources retained by a conversation-scoped runtime. */ - public void closeConversation(AgentCard agentCard, String contextId) { - if (a2aClientRuntime instanceof ConversationScopedA2AJavaClientRuntime scopedRuntime) { - try { - scopedRuntime.closeConversation(agentCard, contextId); - } catch (RuntimeException e) { - log.warn( - "[Transport] CONVERSATION_CLOSE_FAILED contextId={}, agent={}, message={}", - contextId, - agentCard != null ? agentCard.name() : "?", - e.getMessage(), - e); - } - } - } - - /** - * 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 carrying a - * concrete task state; an artifact alone is application data, not a protocol acknowledgement. - */ - /** Opens a long-lived Notification-T stream and returns its explicit lifecycle handle. */ - public NotificationSubscription openNotificationStream( - AgentCard agentCard, - String agentName, - MessageContent content, - String contextId, - java.util.function.BiConsumer eventSink) { - if (closed.get()) { - throw new IllegalStateException("A2A transport is closed"); + subscription.completeStream(); + } catch (Exception e) { + String msg = e.getMessage() != null ? e.getMessage() : ""; + boolean connectionClosed = + !subscription.isActive() + || causedByInterruption(e) + || msg.contains("connection closed locally") + || msg.contains("chunked transfer encoding, state: READING_LENGTH"); + if (connectionClosed) { + log.info("[Transport] Notification-T stream closed for {}", agentName); + } else { + log.error( + "[Transport] Notification-T stream error for {}: {}", + agentName, + e.getMessage(), + 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(); + 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 MessageSendParams buildMessageSendParams( + 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(); + } + + // ------------------------------------------------------------------ + // Lifecycle + // ------------------------------------------------------------------ + + 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 (extension.required() && !extensions.contains(extension.uri())) { + throw new IllegalArgumentException( + "Required extension not activated: " + extension.uri()); } - 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(content, contextId, null); - ClientCallContext callContext = - buildContentCallContext(agentCard, agentName, content); - String endpoint = - agentCard.supportedInterfaces().isEmpty() - ? "?" - : agentCard.supportedInterfaces().get(0).url(); - ProtocolLogger.logRequest( - agentName, endpoint, params, callContext.getHeaders()); - log.info( - "[Transport] Opening Notification-T long-lived stream to {}", - agentName); - a2aClientRuntime.sendMessage( - agentCard, - params, - callContext, - event -> { - log.info( - "[Transport] Notification-T event from {}: {}", - agentName, - event.getClass().getSimpleName()); - subscription.recordEvent(); - if (eventSink != null) { - responses.accept(event); - for (ReceivedMessage received : responses.snapshots()) { - eventSink.accept(subscription, received); - } - } - 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 (!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 = - !subscription.isActive() - || causedByInterruption(e) - || msg.contains("connection closed locally") - || msg.contains( - "chunked transfer encoding, state: READING_LENGTH"); - if (connectionClosed) { - log.info( - "[Transport] Notification-T stream closed for {}", - agentName); - } else { - log.error( - "[Transport] Notification-T stream error for {}: {}", - agentName, - e.getMessage(), - 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(); - CompletableFuture.runAsync( + } + } + if (!extensions.isEmpty()) headers.put("A2A-Extensions", String.join(",", extensions)); + return new ClientCallContext(new HashMap<>(), headers); + } + + private ClientCallContext buildClientCallContext( + AgentCard agentCard, String agentName, Map messageMetadata) { + return clientCallContextFactory.create(agentCard, agentName, messageMetadata); + } + + 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, () -> { - 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 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 != null) { - if (current instanceof InterruptedException) { - return true; - } - current = current.getCause(); - } - return false; - } - - private MessageSendParams buildMessageSendParams( - 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 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( - AgentCard agentCard, String agentName, Map messageMetadata) { - return clientCallContextFactory.create(agentCard, agentName, messageMetadata); - } - - // ------------------------------------------------------------------ - // 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() + 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() + .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)))) + .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, + } 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); - } + 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)) { - asyncExecutor.shutdownNow(); - } - } catch (InterruptedException e) { - asyncExecutor.shutdownNow(); - Thread.currentThread().interrupt(); - } - try { - a2aClientRuntime.close(); - } catch (Exception e) { - 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()); - } - } + } + + @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)) { + asyncExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + asyncExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + try { + a2aClientRuntime.close(); + } catch (Exception e) { + 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 index 2c83f780..4000723f 100644 --- 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 @@ -21,67 +21,76 @@ 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; +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; /** Thin A2A-T core conversion. Does not generate, validate or interpret business content. */ public final class A2atMessages { - private 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())); - } + /** 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 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"))); + /** 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); - } + 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 3fd03335..f5de0275 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 @@ -22,10 +22,8 @@ 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; @@ -120,19 +118,15 @@ private static Map>> loadFromFile(String return loaded; } - 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); + 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); } } - return uris; } /** Get or create a credential service for the given agent. */ @@ -158,16 +152,4 @@ public AgentCredentialService getService(String agentName) { 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); - } - } - } - } 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 44123816..79474328 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 @@ -24,7 +24,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import com.fasterxml.jackson.databind.module.SimpleModule; - +import java.io.IOException; +import java.util.Map; import org.a2aproject.sdk.spec.APIKeySecurityScheme; import org.a2aproject.sdk.spec.HTTPAuthSecurityScheme; import org.a2aproject.sdk.spec.MutualTLSSecurityScheme; @@ -32,9 +33,6 @@ import org.a2aproject.sdk.spec.OpenIdConnectSecurityScheme; import org.a2aproject.sdk.spec.SecurityScheme; -import java.io.IOException; -import java.util.Map; - /** * Jackson module that teaches Jackson how to deserialize the {@link SecurityScheme} interface by * inspecting the oneOf key (e.g. "httpAuthSecurityScheme", "apiKeySecurityScheme"). @@ -55,113 +53,106 @@ */ public final class AgentCardJacksonModule extends SimpleModule { - public AgentCardJacksonModule() { - addDeserializer(SecurityScheme.class, new SecuritySchemeDeserializer()); - addDeserializer( - org.a2aproject.sdk.spec.SecurityRequirement.class, - new SecurityRequirementDeserializer()); - } + public AgentCardJacksonModule() { + addDeserializer(SecurityScheme.class, new SecuritySchemeDeserializer()); + addDeserializer( + org.a2aproject.sdk.spec.SecurityRequirement.class, new SecurityRequirementDeserializer()); + } - public static final class SecuritySchemeDeserializer extends StdDeserializer { + public static final class SecuritySchemeDeserializer extends StdDeserializer { - private static final Map TYPE_KEYS = - Map.of( - "httpAuthSecurityScheme", "httpAuthSecurityScheme", - "apiKeySecurityScheme", "apiKeySecurityScheme", - "oauth2SecurityScheme", "oauth2SecurityScheme", - "openIdConnectSecurityScheme", "openIdConnectSecurityScheme", - "mtlsSecurityScheme", "mtlsSecurityScheme"); + private static final Map TYPE_KEYS = + Map.of( + "httpAuthSecurityScheme", "httpAuthSecurityScheme", + "apiKeySecurityScheme", "apiKeySecurityScheme", + "oauth2SecurityScheme", "oauth2SecurityScheme", + "openIdConnectSecurityScheme", "openIdConnectSecurityScheme", + "mtlsSecurityScheme", "mtlsSecurityScheme"); - public SecuritySchemeDeserializer() { - super(SecurityScheme.class); - } + public SecuritySchemeDeserializer() { + super(SecurityScheme.class); + } - private static String textOrNull(JsonNode node, String field) { - return node.has(field) ? node.get(field).asText() : null; - } + private static String textOrNull(JsonNode node, String field) { + return node.has(field) ? node.get(field).asText() : null; + } - @Override - public SecurityScheme deserialize(JsonParser p, DeserializationContext ctxt) - throws IOException { - JsonNode node = p.getCodec().readTree(p); - for (var entry : TYPE_KEYS.entrySet()) { - if (node.has(entry.getKey())) { - JsonNode inner = node.get(entry.getKey()); - return switch (entry.getKey()) { - case "httpAuthSecurityScheme" -> { - String scheme = textOrNull(inner, "scheme"); - if (scheme == null) { - throw ctxt.instantiationException( - HTTPAuthSecurityScheme.class, - "httpAuthSecurityScheme: 'scheme' field is required"); - } - yield new HTTPAuthSecurityScheme( - textOrNull(inner, "bearerFormat"), - scheme, - textOrNull(inner, "description")); - } - case "apiKeySecurityScheme" -> { - String name = textOrNull(inner, "name"); - if (name == null) { - throw ctxt.instantiationException( - APIKeySecurityScheme.class, - "apiKeySecurityScheme: 'name' field is required"); - } - yield new APIKeySecurityScheme( - inner.has("in") && inner.get("in").isTextual() - ? APIKeySecurityScheme.Location.valueOf( - inner.get("in").asText().toUpperCase()) - : null, - name, - textOrNull(inner, "description")); - } - case "mtlsSecurityScheme" -> - new MutualTLSSecurityScheme(textOrNull(inner, "description")); - case "oauth2SecurityScheme" -> - new OAuth2SecurityScheme( - null, - textOrNull(inner, "description"), - textOrNull(inner, "oauth2MetadataUrl")); - case "openIdConnectSecurityScheme" -> { - String url = textOrNull(inner, "openIdConnectUrl"); - if (url == null) { - throw ctxt.instantiationException( - OpenIdConnectSecurityScheme.class, - "openIdConnectSecurityScheme: 'openIdConnectUrl' field is required"); - } - yield new OpenIdConnectSecurityScheme( - url, textOrNull(inner, "description")); - } - default -> null; - }; - } + @Override + public SecurityScheme deserialize(JsonParser p, DeserializationContext ctxt) + throws IOException { + JsonNode node = p.getCodec().readTree(p); + for (var entry : TYPE_KEYS.entrySet()) { + if (node.has(entry.getKey())) { + JsonNode inner = node.get(entry.getKey()); + return switch (entry.getKey()) { + case "httpAuthSecurityScheme" -> { + String scheme = textOrNull(inner, "scheme"); + if (scheme == null) { + throw ctxt.instantiationException( + HTTPAuthSecurityScheme.class, + "httpAuthSecurityScheme: 'scheme' field is required"); + } + yield new HTTPAuthSecurityScheme( + textOrNull(inner, "bearerFormat"), scheme, textOrNull(inner, "description")); + } + case "apiKeySecurityScheme" -> { + String name = textOrNull(inner, "name"); + if (name == null) { + throw ctxt.instantiationException( + APIKeySecurityScheme.class, "apiKeySecurityScheme: 'name' field is required"); + } + yield new APIKeySecurityScheme( + inner.has("in") && inner.get("in").isTextual() + ? APIKeySecurityScheme.Location.valueOf( + inner.get("in").asText().toUpperCase()) + : null, + name, + textOrNull(inner, "description")); } - return null; + case "mtlsSecurityScheme" -> + new MutualTLSSecurityScheme(textOrNull(inner, "description")); + case "oauth2SecurityScheme" -> + new OAuth2SecurityScheme( + null, textOrNull(inner, "description"), textOrNull(inner, "oauth2MetadataUrl")); + case "openIdConnectSecurityScheme" -> { + String url = textOrNull(inner, "openIdConnectUrl"); + if (url == null) { + throw ctxt.instantiationException( + OpenIdConnectSecurityScheme.class, + "openIdConnectSecurityScheme: 'openIdConnectUrl' field is required"); + } + yield new OpenIdConnectSecurityScheme(url, textOrNull(inner, "description")); + } + default -> null; + }; } + } + return null; } + } - /** - * Deserializes SecurityRequirement from JSON like: {"schemes": {"bearerAuth": {}}} The SDK type - * is {@code Map>}, so empty objects become empty lists. - */ - public static final class SecurityRequirementDeserializer - extends StdDeserializer { + /** + * Deserializes SecurityRequirement from JSON like: {"schemes": {"bearerAuth": {}}} The SDK type + * is {@code Map>}, so empty objects become empty lists. + */ + public static final class SecurityRequirementDeserializer + extends StdDeserializer { - public SecurityRequirementDeserializer() { - super(org.a2aproject.sdk.spec.SecurityRequirement.class); - } + public SecurityRequirementDeserializer() { + super(org.a2aproject.sdk.spec.SecurityRequirement.class); + } - @Override - public org.a2aproject.sdk.spec.SecurityRequirement deserialize( - JsonParser p, DeserializationContext ctxt) throws IOException { - JsonNode node = p.getCodec().readTree(p); - var schemes = new java.util.LinkedHashMap>(); - if (node.has("schemes") && node.get("schemes").isObject()) { - for (var entry : node.get("schemes").properties()) { - schemes.put(entry.getKey(), java.util.List.of()); - } - } - return new org.a2aproject.sdk.spec.SecurityRequirement(schemes); + @Override + public org.a2aproject.sdk.spec.SecurityRequirement deserialize( + JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.getCodec().readTree(p); + var schemes = new java.util.LinkedHashMap>(); + if (node.has("schemes") && node.get("schemes").isObject()) { + for (var entry : node.get("schemes").properties()) { + schemes.put(entry.getKey(), java.util.List.of()); } + } + return new org.a2aproject.sdk.spec.SecurityRequirement(schemes); } + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardNormalizer.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardNormalizer.java index b63acc37..516ef0dd 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardNormalizer.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardNormalizer.java @@ -20,14 +20,12 @@ package dev.openan.workflow.engine.client; import com.fasterxml.jackson.databind.ObjectMapper; - -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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * AgentCard normalization -- converts OpenAPI-style security scheme notation to a format compatible @@ -44,122 +42,122 @@ */ public final class AgentCardNormalizer { - private static final Logger log = LoggerFactory.getLogger(AgentCardNormalizer.class); - private static final ObjectMapper mapper = new ObjectMapper(); + private static final Logger log = LoggerFactory.getLogger(AgentCardNormalizer.class); + private static final ObjectMapper mapper = new ObjectMapper(); - private static final List PROTO_ONEOF_KEYS = - List.of( - "httpAuthSecurityScheme", - "apiKeySecurityScheme", - "oauth2SecurityScheme", - "openIdConnectSecurityScheme", - "mtlsSecurityScheme"); + private static final List PROTO_ONEOF_KEYS = + List.of( + "httpAuthSecurityScheme", + "apiKeySecurityScheme", + "oauth2SecurityScheme", + "openIdConnectSecurityScheme", + "mtlsSecurityScheme"); - private AgentCardNormalizer() {} + private AgentCardNormalizer() {} - /** - * Normalize an AgentCard map to a compatible format. - * - * @param agentDict raw agent card as a map - * @return normalized map - */ - @SuppressWarnings("unchecked") - public static Map normalize(Map agentDict) { - if (agentDict == null) { - return agentDict; - } - Map result = new LinkedHashMap<>(agentDict); + /** + * Normalize an AgentCard map to a compatible format. + * + * @param agentDict raw agent card as a map + * @return normalized map + */ + @SuppressWarnings("unchecked") + public static Map normalize(Map agentDict) { + if (agentDict == null) { + return agentDict; + } + Map result = new LinkedHashMap<>(agentDict); - if (result.containsKey("securitySchemes")) { - result.put("securitySchemes", normalizeSecuritySchemes(result.get("securitySchemes"))); - } - if (result.containsKey("securityRequirements")) { - result.put( - "securityRequirements", - normalizeSecurityRequirements(result.get("securityRequirements"))); - } - return result; + if (result.containsKey("securitySchemes")) { + result.put("securitySchemes", normalizeSecuritySchemes(result.get("securitySchemes"))); + } + if (result.containsKey("securityRequirements")) { + result.put( + "securityRequirements", + normalizeSecurityRequirements(result.get("securityRequirements"))); } + return result; + } - @SuppressWarnings("unchecked") - private static Map normalizeSecuritySchemes(Object secSchemes) { - if (!(secSchemes instanceof Map)) { - return secSchemes != null ? (Map) secSchemes : Map.of(); - } - Map input = (Map) secSchemes; - Map result = new LinkedHashMap<>(); - for (Map.Entry entry : input.entrySet()) { - String name = entry.getKey(); - Object schemeObj = entry.getValue(); - if (!(schemeObj instanceof Map)) { - result.put(name, schemeObj); - continue; - } - Map scheme = (Map) schemeObj; - // Already in structured format - boolean alreadyStructured = PROTO_ONEOF_KEYS.stream().anyMatch(scheme::containsKey); - if (alreadyStructured) { - result.put(name, scheme); - continue; - } - // OpenAPI-style: flat "scheme": "bearer" - Object schemeField = scheme.get("scheme"); - if (schemeField instanceof String) { - Map httpAuth = new LinkedHashMap<>(); - httpAuth.put("scheme", schemeField); - copyIfPresent(scheme, httpAuth, "description"); - copyIfPresent(scheme, httpAuth, "bearerFormat"); - result.put(name, Map.of("httpAuthSecurityScheme", httpAuth)); - continue; - } - // OpenAPI-style: apiKey - if ("apiKey".equals(scheme.get("type"))) { - Map apiKey = new LinkedHashMap<>(); - copyIfPresent(scheme, apiKey, "in", "location"); - copyIfPresent(scheme, apiKey, "name"); - copyIfPresent(scheme, apiKey, "description"); - result.put(name, Map.of("apiKeySecurityScheme", apiKey)); - continue; - } - result.put(name, scheme); - } - return result; + @SuppressWarnings("unchecked") + private static Map normalizeSecuritySchemes(Object secSchemes) { + if (!(secSchemes instanceof Map)) { + return secSchemes != null ? (Map) secSchemes : Map.of(); } + Map input = (Map) secSchemes; + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : input.entrySet()) { + String name = entry.getKey(); + Object schemeObj = entry.getValue(); + if (!(schemeObj instanceof Map)) { + result.put(name, schemeObj); + continue; + } + Map scheme = (Map) schemeObj; + // Already in structured format + boolean alreadyStructured = PROTO_ONEOF_KEYS.stream().anyMatch(scheme::containsKey); + if (alreadyStructured) { + result.put(name, scheme); + continue; + } + // OpenAPI-style: flat "scheme": "bearer" + Object schemeField = scheme.get("scheme"); + if (schemeField instanceof String) { + Map httpAuth = new LinkedHashMap<>(); + httpAuth.put("scheme", schemeField); + copyIfPresent(scheme, httpAuth, "description"); + copyIfPresent(scheme, httpAuth, "bearerFormat"); + result.put(name, Map.of("httpAuthSecurityScheme", httpAuth)); + continue; + } + // OpenAPI-style: apiKey + if ("apiKey".equals(scheme.get("type"))) { + Map apiKey = new LinkedHashMap<>(); + copyIfPresent(scheme, apiKey, "in", "location"); + copyIfPresent(scheme, apiKey, "name"); + copyIfPresent(scheme, apiKey, "description"); + result.put(name, Map.of("apiKeySecurityScheme", apiKey)); + continue; + } + result.put(name, scheme); + } + return result; + } - @SuppressWarnings("unchecked") - private static List> normalizeSecurityRequirements(Object secReqs) { - if (!(secReqs instanceof List)) { - return List.of(); - } - List> result = new ArrayList<>(); - for (Object item : (List) secReqs) { - if (!(item instanceof Map)) { - continue; - } - Map req = (Map) item; - Object schemes = req.get("schemes"); - if (schemes instanceof List) { - Map schemesMap = new LinkedHashMap<>(); - for (Object s : (List) schemes) { - schemesMap.put(s.toString(), Map.of()); - } - result.add(Map.of("schemes", schemesMap)); - } else if (schemes instanceof Map) { - result.add(req); - } else { - result.add(req); - } + @SuppressWarnings("unchecked") + private static List> normalizeSecurityRequirements(Object secReqs) { + if (!(secReqs instanceof List)) { + return List.of(); + } + List> result = new ArrayList<>(); + for (Object item : (List) secReqs) { + if (!(item instanceof Map)) { + continue; + } + Map req = (Map) item; + Object schemes = req.get("schemes"); + if (schemes instanceof List) { + Map schemesMap = new LinkedHashMap<>(); + for (Object s : (List) schemes) { + schemesMap.put(s.toString(), Map.of()); } - return result; + result.add(Map.of("schemes", schemesMap)); + } else if (schemes instanceof Map) { + result.add(req); + } else { + result.add(req); + } } + return result; + } - private static void copyIfPresent( - Map src, Map dst, String... keys) { - for (String key : keys) { - if (src.containsKey(key)) { - String dstKey = keys.length > 1 ? keys[1] : key; - dst.put(dstKey, src.get(key)); - } - } + private static void copyIfPresent( + Map src, Map dst, String... keys) { + for (String key : keys) { + if (src.containsKey(key)) { + String dstKey = keys.length > 1 ? keys[1] : key; + dst.put(dstKey, src.get(key)); + } } + } } 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 5dd629b0..e11c33a6 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 @@ -21,13 +21,6 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; - -import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; -import org.a2aproject.sdk.client.transport.spi.interceptors.auth.CredentialService; -import org.a2aproject.sdk.util.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; @@ -38,6 +31,11 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.CredentialService; +import org.a2aproject.sdk.util.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Obtains Bearer tokens via login endpoints for agents requiring authentication. @@ -47,245 +45,234 @@ */ class AgentCredentialService implements CredentialService { - private static final Logger log = LoggerFactory.getLogger(AgentCredentialService.class); + private static final Logger log = LoggerFactory.getLogger(AgentCredentialService.class); - private static final ObjectMapper mapper = new ObjectMapper(); + private static final ObjectMapper mapper = new ObjectMapper(); - private final String agentName; + private final String agentName; - private final Map> schemeConfigs; + private final Map> schemeConfigs; - private final HttpClient httpClient; + private final HttpClient httpClient; - private final CredentialHttpTransport httpTransport; + private final CredentialHttpTransport httpTransport; - private final String credentialEncryptionKey; + private final String credentialEncryptionKey; - private final Map tokenCache = new ConcurrentHashMap<>(); + private final Map tokenCache = new ConcurrentHashMap<>(); - public AgentCredentialService( - String agentName, Map> schemeConfigs) { - this(agentName, schemeConfigs, null, null); - } + public AgentCredentialService(String agentName, Map> schemeConfigs) { + this(agentName, schemeConfigs, null, null); + } - public AgentCredentialService( - String agentName, - Map> schemeConfigs, - HttpClient httpClient) { - this(agentName, schemeConfigs, httpClient, 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, + 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( - true, null, null, null, null, null, Duration.ofSeconds(30), null); - } + 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( + true, null, null, null, null, null, Duration.ofSeconds(30), null); } + } - @SuppressWarnings("unchecked") - private static String extractNestedValue(Map data, String path) { - if (path == null || path.isEmpty()) { - return null; - } - Object current = data; - for (String part : path.split("\\.")) { - if (!(current instanceof Map)) { - return null; - } - current = ((Map) current).get(part); - if (current == null) { - return null; - } - } - return current != null ? current.toString() : null; + @SuppressWarnings("unchecked") + private static String extractNestedValue(Map data, String path) { + if (path == null || path.isEmpty()) { + return null; } - - @SuppressWarnings("unchecked") - 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, credentialEncryptionKey)); - } - } else { - String username = (String) schemeCfg.get("username"); - 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); - } - return body; + Object current = data; + for (String part : path.split("\\.")) { + if (!(current instanceof Map)) { + return null; + } + current = ((Map) current).get(part); + if (current == null) { + return null; + } } + return current != null ? current.toString() : null; + } - private static long toLong(Object value) { - if (value instanceof Number) { - return ((Number) value).longValue(); - } - if (value instanceof String) { - try { - return Long.parseLong((String) value); - } catch (NumberFormatException e) { - return 3600; - } - } + private static long toLong(Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + if (value instanceof String) { + try { + return Long.parseLong((String) value); + } catch (NumberFormatException e) { return 3600; + } } + return 3600; + } - /** - * Mask sensitive fields (password, value, accessSession) for safe logging. Mirrors Python SDK's - * {@code _sanitize_body()}. - */ - private static Map sanitizeBody(Map body) { - Map sanitized = new java.util.LinkedHashMap<>(); - for (Map.Entry e : body.entrySet()) { - String key = e.getKey().toLowerCase(); - if (key.equals("password") || key.equals("value") || key.equals("accesssession")) { - sanitized.put(e.getKey(), "***"); - } else { - sanitized.put(e.getKey(), e.getValue()); - } - } - return sanitized; + /** + * Mask sensitive fields (password, value, accessSession) for safe logging. Mirrors Python SDK's + * {@code _sanitize_body()}. + */ + private static Map sanitizeBody(Map body) { + Map sanitized = new java.util.LinkedHashMap<>(); + for (Map.Entry e : body.entrySet()) { + String key = e.getKey().toLowerCase(); + if (key.equals("password") || key.equals("value") || key.equals("accesssession")) { + sanitized.put(e.getKey(), "***"); + } else { + sanitized.put(e.getKey(), e.getValue()); + } } + return sanitized; + } - @Override - public String getCredential(@NotNull String securitySchemeName, ClientCallContext context) { - Map schemeCfg = schemeConfigs.get(securitySchemeName); - if (schemeCfg == null) { - return null; - } + private static String encodeForm(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } - // Check cache - TokenEntry cached = tokenCache.get(securitySchemeName); - if (cached != null && !cached.isExpired()) { - log.info("[Auth] Cache hit for agent {} scheme {}", agentName, securitySchemeName); - return cached.token; - } + @SuppressWarnings("unchecked") + 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, credentialEncryptionKey)); + } + } else { + String username = (String) schemeCfg.get("username"); + 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); + } + return body; + } - // Login and cache - String token = login(schemeCfg); - if (token != null) { - long ttl = toLong(schemeCfg.get("token_ttl")); - tokenCache.put( - securitySchemeName, - new TokenEntry(token, System.currentTimeMillis() / 1000 + ttl)); - log.info("[Auth] Login succeeded: agent={}, scheme={}", agentName, securitySchemeName); - } + @Override + public String getCredential(@NotNull String securitySchemeName, ClientCallContext context) { + Map schemeCfg = schemeConfigs.get(securitySchemeName); + if (schemeCfg == null) { + return null; + } - return token; + // Check cache + TokenEntry cached = tokenCache.get(securitySchemeName); + if (cached != null && !cached.isExpired()) { + log.info("[Auth] Cache hit for agent {} scheme {}", agentName, securitySchemeName); + return cached.token; } - private String login(Map schemeCfg) { - String loginUrl = (String) schemeCfg.get("login_url"); - if (loginUrl == null || loginUrl.isEmpty()) { - return null; - } + // Login and cache + String token = login(schemeCfg); + if (token != null) { + long ttl = toLong(schemeCfg.get("token_ttl")); + tokenCache.put( + securitySchemeName, new TokenEntry(token, System.currentTimeMillis() / 1000 + ttl)); + log.info("[Auth] Login succeeded: agent={}, scheme={}", agentName, securitySchemeName); + } - String method = schemeCfg.getOrDefault("method", "POST").toString().toUpperCase(); - String contentType = schemeCfg.getOrDefault("content_type", "application/json").toString(); - String tokenField = schemeCfg.getOrDefault("token_field", "accessSession").toString(); - Map body = buildLoginBody(schemeCfg); - try { - log.info( - "[Auth] Login attempt: agent={}, method={}, url={}, params={}", - agentName, - method, - loginUrl, - sanitizeBody(body)); - String requestBody; - if ("application/x-www-form-urlencoded".equals(contentType)) { - StringBuilder form = new StringBuilder(); - for (Map.Entry e : body.entrySet()) { - if (!form.isEmpty()) { - form.append("&"); - } - form.append(encodeForm(e.getKey())) - .append("=") - .append(encodeForm(String.valueOf(e.getValue()))); - } - requestBody = form.toString(); - } else { - 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, HttpRequest.BodyPublishers.ofString(requestBody)) - .timeout(Duration.ofSeconds(30)) - .build(); - HttpResponse resp = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - return parseTokenResponse(tokenField, resp.statusCode(), resp.body()); - } catch (Exception e) { - log.error( - "[Auth] Login failed: agent={}, url={}, error={}", - agentName, - loginUrl, - e.getMessage()); - return null; - } + return token; + } + + private String login(Map schemeCfg) { + String loginUrl = (String) schemeCfg.get("login_url"); + if (loginUrl == null || loginUrl.isEmpty()) { + return null; } - 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; + String method = schemeCfg.getOrDefault("method", "POST").toString().toUpperCase(); + String contentType = schemeCfg.getOrDefault("content_type", "application/json").toString(); + String tokenField = schemeCfg.getOrDefault("token_field", "accessSession").toString(); + Map body = buildLoginBody(schemeCfg); + try { + log.info( + "[Auth] Login attempt: agent={}, method={}, url={}, params={}", + agentName, + method, + loginUrl, + sanitizeBody(body)); + String requestBody; + if ("application/x-www-form-urlencoded".equals(contentType)) { + StringBuilder form = new StringBuilder(); + for (Map.Entry e : body.entrySet()) { + if (!form.isEmpty()) { + form.append("&"); + } + form.append(encodeForm(e.getKey())) + .append("=") + .append(encodeForm(String.valueOf(e.getValue()))); } - 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; + requestBody = form.toString(); + } else { + 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, HttpRequest.BodyPublishers.ofString(requestBody)) + .timeout(Duration.ofSeconds(30)) + .build(); + HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return parseTokenResponse(tokenField, resp.statusCode(), resp.body()); + } catch (Exception e) { + log.error( + "[Auth] Login failed: agent={}, url={}, error={}", agentName, loginUrl, e.getMessage()); + return null; } + } - private static String encodeForm(String value) { - return URLEncoder.encode(value, StandardCharsets.UTF_8); + 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 record TokenEntry(String token, long expiresAt) { - boolean isExpired() { - return System.currentTimeMillis() / 1000 >= expiresAt - 60; - } + private record TokenEntry(String token, long expiresAt) { + boolean isExpired() { + return System.currentTimeMillis() / 1000 >= expiresAt - 60; } + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProvider.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProvider.java index 9d622612..21f06c69 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProvider.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProvider.java @@ -19,9 +19,8 @@ package dev.openan.workflow.engine.client; -import org.a2aproject.sdk.spec.AgentCard; - import java.util.Map; +import org.a2aproject.sdk.spec.AgentCard; /** * Custom authentication provider for injecting auth headers into outgoing A2A messages. @@ -54,12 +53,12 @@ */ public interface AuthProvider { - /** - * Apply authentication headers for sending a message to the given agent. - * - * @param agentName the target agent name (matches AgentCard.name) - * @param agentCard the agent's card (securitySchemes may be null or empty) - * @param headers mutable header map to add auth headers to - */ - void applyAuth(String agentName, AgentCard agentCard, Map headers); + /** + * Apply authentication headers for sending a message to the given agent. + * + * @param agentName the target agent name (matches AgentCard.name) + * @param agentCard the agent's card (securitySchemes may be null or empty) + * @param headers mutable header map to add auth headers to + */ + void applyAuth(String agentName, AgentCard agentCard, Map headers); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProviderHeaderContributor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProviderHeaderContributor.java index 68527258..2662bc45 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProviderHeaderContributor.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProviderHeaderContributor.java @@ -6,29 +6,28 @@ package dev.openan.workflow.engine.client; -import org.a2aproject.sdk.spec.AgentCard; - import java.util.HashMap; import java.util.Map; +import org.a2aproject.sdk.spec.AgentCard; /** Supplies headers from the host-provided {@link AuthProvider}. */ final class AuthProviderHeaderContributor implements HeaderContributor { - private final AuthProvider authProvider; + private final AuthProvider authProvider; - AuthProviderHeaderContributor(AuthProvider authProvider) { - this.authProvider = authProvider; - } + AuthProviderHeaderContributor(AuthProvider authProvider) { + this.authProvider = authProvider; + } - @Override - public Map contribute( - AgentCard agentCard, - String agentName, - Map messageMetadata, - Map currentHeaders) { - Map headers = new HashMap<>(); - if (authProvider != null) { - authProvider.applyAuth(agentName, agentCard, headers); - } - return headers; + @Override + public Map contribute( + AgentCard agentCard, + String agentName, + Map messageMetadata, + Map currentHeaders) { + Map headers = new HashMap<>(); + if (authProvider != null) { + authProvider.applyAuth(agentName, agentCard, headers); } + return headers; + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientCallContextFactory.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientCallContextFactory.java index 3682df13..ed82cd0f 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientCallContextFactory.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientCallContextFactory.java @@ -6,53 +6,47 @@ package dev.openan.workflow.engine.client; -import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; -import org.a2aproject.sdk.spec.AgentCard; - import java.util.HashMap; import java.util.List; import java.util.Map; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.AgentCard; /** Builds the final outbound call context from independent header contributors. */ final class ClientCallContextFactory { - private final List contributors; + private final List contributors; - ClientCallContextFactory(HeaderContributor... contributors) { - this.contributors = List.of(contributors); - } + ClientCallContextFactory(HeaderContributor... contributors) { + this.contributors = List.of(contributors); + } - ClientCallContext create( - AgentCard agentCard, String agentName, Map messageMetadata) { - Map headers = new HashMap<>(); - for (HeaderContributor contributor : contributors) { - Map contributed = - contributor.contribute(agentCard, agentName, messageMetadata, headers); - mergeHeaders(agentName, headers, contributed); - } - return new ClientCallContext(new HashMap<>(), headers); + static void mergeHeaders( + String agentName, Map destination, Map additionalHeaders) { + for (Map.Entry entry : additionalHeaders.entrySet()) { + String existingKey = + destination.keySet().stream() + .filter(key -> key.equalsIgnoreCase(entry.getKey())) + .findFirst() + .orElse(null); + String existing = existingKey != null ? destination.get(existingKey) : null; + if (existing != null && !existing.equals(entry.getValue())) { + throw new SecurityException( + "Authentication header conflict for agent " + agentName + ": " + entry.getKey()); + } + if (existingKey == null) { + destination.put(entry.getKey(), entry.getValue()); + } } + } - static void mergeHeaders( - String agentName, - Map destination, - Map additionalHeaders) { - for (Map.Entry entry : additionalHeaders.entrySet()) { - String existingKey = - destination.keySet().stream() - .filter(key -> key.equalsIgnoreCase(entry.getKey())) - .findFirst() - .orElse(null); - String existing = existingKey != null ? destination.get(existingKey) : null; - if (existing != null && !existing.equals(entry.getValue())) { - throw new SecurityException( - "Authentication header conflict for agent " - + agentName - + ": " - + entry.getKey()); - } - if (existingKey == null) { - destination.put(entry.getKey(), entry.getValue()); - } - } + ClientCallContext create( + AgentCard agentCard, String agentName, Map messageMetadata) { + Map headers = new HashMap<>(); + for (HeaderContributor contributor : contributors) { + Map contributed = + contributor.contribute(agentCard, agentName, messageMetadata, headers); + mergeHeaders(agentName, headers, contributed); } + return new ClientCallContext(new HashMap<>(), headers); + } } 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 index 39997073..31cd2fdd 100644 --- 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 @@ -4,6 +4,8 @@ */ package dev.openan.workflow.engine.client; +import java.util.HashMap; +import java.util.Map; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; import org.a2aproject.sdk.client.TaskEvent; @@ -11,65 +13,62 @@ 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() {} + 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()); - } + 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()); } - return data; + } + } 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/ConversationScopedA2AJavaClientRuntime.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ConversationScopedA2AJavaClientRuntime.java index eb7ef9c3..e0d77896 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ConversationScopedA2AJavaClientRuntime.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ConversationScopedA2AJavaClientRuntime.java @@ -15,6 +15,6 @@ */ public interface ConversationScopedA2AJavaClientRuntime { - /** Release resources retained for the completed logical conversation. */ - void closeConversation(AgentCard agentCard, String contextId); + /** Release resources retained for the completed logical conversation. */ + void closeConversation(AgentCard agentCard, String contextId); } 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 6715f126..63a3161e 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 @@ -19,13 +19,9 @@ package dev.openan.workflow.engine.client; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; - import javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; @@ -49,172 +45,169 @@ */ public final class CredentialCrypto { - private static final Logger log = LoggerFactory.getLogger(CredentialCrypto.class); - private static final String ENV_KEY = "A2AT_CRED_KEY"; - private static final String PREFIX = "enc:"; - private static final String ALGORITHM = "AES/GCM/NoPadding"; - private static final int GCM_TAG_LENGTH = 128; - private static final int IV_LENGTH = 12; + private static final String ENV_KEY = "A2AT_CRED_KEY"; + private static final String PREFIX = "enc:"; + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_TAG_LENGTH = 128; + private static final int IV_LENGTH = 12; - private CredentialCrypto() {} + private CredentialCrypto() {} - /** - * CLI entry point for encrypting a plaintext password. - * - *

    Usage: - *

    -     *   # Option 1: set env var, then encrypt
    -     *   set A2AT_CRED_KEY=0123456789abcdef...
    -     *   java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123"
    -     *
    -     *   # Option 2: pass key as second argument
    -     *   java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" 0123456789abcdef...
    -     * 
    - * - *

    Output: the encrypted string (e.g. {@code enc::}) to stdout. - * Copy this value into the credentials JSON file's {@code value} field. - * - * @param args {@code args[0]} = plaintext, {@code args[1]} = optional key-hex - */ - public static void main(String[] args) { - if (args.length == 0) { - System.err.println("Usage: java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto

    [key-hex]"); - System.err.println(" Set A2AT_CRED_KEY env var (32-byte hex) or pass as second argument."); - System.exit(1); - } - String plaintext = args[0]; - if (args.length >= 2) { - System.setProperty(ENV_KEY, args[1]); - } - try { - String encrypted = encrypt(plaintext); - System.out.println(encrypted); - } catch (IllegalStateException e) { - System.err.println("Error: " + e.getMessage()); - System.exit(1); - } + /** + * CLI entry point for encrypting a plaintext password. + * + * <p>Usage: + * + * <pre> + * # Option 1: set env var, then encrypt + * set A2AT_CRED_KEY=0123456789abcdef... + * java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" + * + * # Option 2: pass key as second argument + * java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto "Admin@123" 0123456789abcdef... + * </pre> + * + * <p>Output: the encrypted string (e.g. {@code enc:<iv>:<ciphertext>}) to stdout. Copy this value + * into the credentials JSON file's {@code value} field. + * + * @param args {@code args[0]} = plaintext, {@code args[1]} = optional key-hex + */ + public static void main(String[] args) { + if (args.length == 0) { + System.err.println( + "Usage: java -cp workflow-engine.jar dev.openan.workflow.engine.client.CredentialCrypto <plaintext> [key-hex]"); + System.err.println(" Set A2AT_CRED_KEY env var (32-byte hex) or pass as second argument."); + System.exit(1); } - - /** - * Decrypt a credential value if it has the {@code enc:} prefix. Values without the prefix are - * returned as-is (plaintext fallback). - * - * @param value the raw value from config (may be {@code enc:...} or plaintext) - * @return decrypted plaintext, or the original value if not encrypted - * @throws IllegalStateException when an encrypted value cannot be decrypted - */ - public static String decryptIfNeeded(String value) { - return decryptIfNeeded(value, null); + String plaintext = args[0]; + try { + String encrypted = encrypt(plaintext, args.length >= 2 ? args[1] : null); + System.out.println(encrypted); + } catch (IllegalStateException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(1); } + } - /** 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(configuredKeyHex); - if (keyHex == null || keyHex.isBlank()) { - throw new IllegalStateException( - "Encrypted credential found but " - + ENV_KEY - + " is not set (environment variable or system property)"); - } - try { - String encoded = value.substring(PREFIX.length()); - String[] parts = encoded.split(":", 2); - if (parts.length != 2) { - throw new IllegalArgumentException( - "Invalid encrypted credential format; expected enc:<iv>:<ciphertext>"); - } - byte[] iv = Base64.getDecoder().decode(parts[0]); - byte[] cipherText = Base64.getDecoder().decode(parts[1]); - byte[] keyBytes = hexToBytes(keyHex); - Cipher cipher = Cipher.getInstance(ALGORITHM); - cipher.init( - Cipher.DECRYPT_MODE, - new SecretKeySpec(keyBytes, "AES"), - new GCMParameterSpec(GCM_TAG_LENGTH, iv)); - byte[] plainBytes = cipher.doFinal(cipherText); - return new String(plainBytes, StandardCharsets.UTF_8); - } catch (Exception e) { - throw new IllegalStateException( - "Credential decryption failed; verify " + ENV_KEY + " and encrypted value", e); - } - } + /** + * Decrypt a credential value if it has the {@code enc:} prefix. Values without the prefix are + * returned as-is (plaintext fallback). + * + * @param value the raw value from config (may be {@code enc:...} or plaintext) + * @return decrypted plaintext, or the original value if not encrypted + * @throws IllegalStateException when an encrypted value cannot be decrypted + */ + public static String decryptIfNeeded(String value) { + return decryptIfNeeded(value, null); + } - /** - * Encrypt a plaintext value using AES-GCM with the key from the {@code A2AT_CRED_KEY} - * environment variable. - * - * @param plaintext the value to encrypt - * @return encrypted string in format {@code enc:<base64-iv>:<base64-ciphertext>} - * @throws IllegalStateException if the key env var is not set - */ - public static String encrypt(String plaintext) { - String keyHex = resolveKey(); - if (keyHex == null || keyHex.isBlank()) { - throw new IllegalStateException(ENV_KEY + " environment variable not set"); - } - try { - byte[] keyBytes = hexToBytes(keyHex); - byte[] iv = new byte[IV_LENGTH]; - new SecureRandom().nextBytes(iv); - Cipher cipher = Cipher.getInstance(ALGORITHM); - cipher.init( - Cipher.ENCRYPT_MODE, - new SecretKeySpec(keyBytes, "AES"), - new GCMParameterSpec(GCM_TAG_LENGTH, iv)); - byte[] cipherText = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); - return PREFIX - + Base64.getEncoder().encodeToString(iv) - + ":" - + Base64.getEncoder().encodeToString(cipherText); - } catch (Exception e) { - throw new RuntimeException("Encryption failed: " + e.getMessage(), e); - } + /** Decrypts with an optional instance-scoped key supplied explicitly by the host. */ + static String decryptIfNeeded(String value, String configuredKeyHex) { + if (value == null || !value.startsWith(PREFIX)) { + return value; + } + String keyHex = resolveKey(configuredKeyHex); + if (keyHex == null || keyHex.isBlank()) { + throw new IllegalStateException( + "Encrypted credential found but " + + ENV_KEY + + " is not set (environment variable or system property)"); + } + try { + String encoded = value.substring(PREFIX.length()); + String[] parts = encoded.split(":", 2); + if (parts.length != 2) { + throw new IllegalArgumentException( + "Invalid encrypted credential format; expected enc:<iv>:<ciphertext>"); + } + byte[] iv = Base64.getDecoder().decode(parts[0]); + byte[] cipherText = Base64.getDecoder().decode(parts[1]); + byte[] keyBytes = hexToBytes(keyHex); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init( + Cipher.DECRYPT_MODE, + new SecretKeySpec(keyBytes, "AES"), + new GCMParameterSpec(GCM_TAG_LENGTH, iv)); + byte[] plainBytes = cipher.doFinal(cipherText); + return new String(plainBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalStateException( + "Credential decryption failed; verify " + ENV_KEY + " and encrypted value", e); } + } - /** - * Resolve the encryption key from OS environment variable first, then from system property (set - * by {@code .env} file loader). - * - * @return the hex key string, or null if not found - */ - private static String resolveKey() { - return resolveKey(null); + /** + * Encrypt a plaintext value using AES-GCM with the key from the {@code A2AT_CRED_KEY} environment + * variable. + * + * @param plaintext the value to encrypt + * @return encrypted string in format {@code enc:<base64-iv>:<base64-ciphertext>} + * @throws IllegalStateException if the key env var is not set + */ + public static String encrypt(String plaintext) { + return encrypt(plaintext, null); + } + + /** Encrypts using an explicit instance key, or OS/JVM configuration when the key is null. */ + public static String encrypt(String plaintext, String configuredKeyHex) { + String keyHex = resolveKey(configuredKeyHex); + if (keyHex == null || keyHex.isBlank()) { + throw new IllegalStateException(ENV_KEY + " environment variable not set"); + } + try { + byte[] keyBytes = hexToBytes(keyHex); + byte[] iv = new byte[IV_LENGTH]; + new SecureRandom().nextBytes(iv); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init( + Cipher.ENCRYPT_MODE, + new SecretKeySpec(keyBytes, "AES"), + new GCMParameterSpec(GCM_TAG_LENGTH, iv)); + byte[] cipherText = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + return PREFIX + + Base64.getEncoder().encodeToString(iv) + + ":" + + Base64.getEncoder().encodeToString(cipherText); + } catch (Exception e) { + throw new RuntimeException("Encryption failed: " + e.getMessage(), e); } + } - 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; - } - key = System.getProperty(ENV_KEY); - if (key != null && !key.isBlank()) { - return key; - } - return null; + /** + * Resolve an explicit key first, then OS environment, then a host-supplied system property. + * + * @return the hex key string, or null if not found + */ + 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; } + 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"); - } - byte[] data = new byte[len / 2]; - for (int i = 0; i < len; i += 2) { - int high = Character.digit(hex.charAt(i), 16); - int low = Character.digit(hex.charAt(i + 1), 16); - if (high < 0 || low < 0) { - throw new IllegalArgumentException( - "A2AT_CRED_KEY must contain hexadecimal characters only"); - } - data[i / 2] = (byte) ((high << 4) + low); - } - return data; + private static byte[] hexToBytes(String hex) { + int len = hex.length(); + if (len != 64) { + 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) { + int high = Character.digit(hex.charAt(i), 16); + int low = Character.digit(hex.charAt(i + 1), 16); + if (high < 0 || low < 0) { + throw new IllegalArgumentException( + "A2AT_CRED_KEY must contain hexadecimal characters only"); + } + data[i / 2] = (byte) ((high << 4) + low); } + return data; + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHeaderContributor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHeaderContributor.java index b066b69d..760b3751 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHeaderContributor.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHeaderContributor.java @@ -6,109 +6,108 @@ package dev.openan.workflow.engine.client; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.SecurityRequirement; import org.a2aproject.sdk.spec.SecurityScheme; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** Supplies credentials declared by an AgentCard and loaded from engine credential configuration. */ +/** + * Supplies credentials declared by an AgentCard and loaded from engine credential configuration. + */ final class CredentialHeaderContributor implements HeaderContributor { - private static final Logger log = LoggerFactory.getLogger(CredentialHeaderContributor.class); + private static final Logger log = LoggerFactory.getLogger(CredentialHeaderContributor.class); - private final AgentAuthManager authManager; - private final AuthProvider authProvider; + private final AgentAuthManager authManager; + private final AuthProvider authProvider; - CredentialHeaderContributor(AgentAuthManager authManager, AuthProvider authProvider) { - this.authManager = authManager; - this.authProvider = authProvider; - } + CredentialHeaderContributor(AgentAuthManager authManager, AuthProvider authProvider) { + this.authManager = authManager; + this.authProvider = authProvider; + } - @Override - public Map<String, String> contribute( - AgentCard agentCard, - String agentName, - Map<String, Object> messageMetadata, - Map<String, String> currentHeaders) { - Map<String, String> headers = new HashMap<>(); - Map<String, SecurityScheme> securitySchemes = agentCard.securitySchemes(); - List<SecurityRequirement> securityRequirements = agentCard.securityRequirements(); - if (securitySchemes == null - || securitySchemes.isEmpty() - || securityRequirements == null - || securityRequirements.isEmpty()) { - return headers; - } + private static void addCredentialHeader( + String agentName, + Map<String, String> headers, + Map<String, Object> schemeConfig, + String credential) { + String authHeader = (String) schemeConfig.get("auth_header"); + if (authHeader != null && !authHeader.isEmpty()) { + String prefix = (String) schemeConfig.getOrDefault("auth_header_prefix", ""); + headers.put(authHeader, prefix + credential); + log.info("[Auth] Set header {} for agent {}", authHeader, agentName); + } else { + headers.put("Authorization", "Bearer " + credential); + log.info("[Auth] Set Bearer header for agent {}", agentName); + } + String acceptHeader = (String) schemeConfig.get("accept_header"); + if (acceptHeader != null && !acceptHeader.isEmpty()) { + headers.put("Accept", acceptHeader); + } + } - AgentCredentialService credentialService = authManager.getService(agentName); - if (credentialService == null) { - if (authProvider != null) { - return headers; - } - throw new SecurityException( - "Authentication failed for agent " - + agentName - + ": AgentCard requires credentials but none are configured"); - } - Map<String, Map<String, Object>> schemeConfigs = authManager.getConfig(agentName); - if (schemeConfigs == null || schemeConfigs.isEmpty()) { - if (authProvider != null) { - return headers; - } - throw new SecurityException( - "Authentication failed for agent " - + agentName - + ": credential configuration is empty"); - } + @Override + public Map<String, String> contribute( + AgentCard agentCard, + String agentName, + Map<String, Object> messageMetadata, + Map<String, String> currentHeaders) { + Map<String, String> headers = new HashMap<>(); + Map<String, SecurityScheme> securitySchemes = agentCard.securitySchemes(); + List<SecurityRequirement> securityRequirements = agentCard.securityRequirements(); + if (securitySchemes == null + || securitySchemes.isEmpty() + || securityRequirements == null + || securityRequirements.isEmpty()) { + return headers; + } - for (SecurityRequirement requirement : securityRequirements) { - Map<String, List<String>> schemes = requirement.schemes(); - for (String schemeName : schemes.keySet()) { - Map<String, Object> schemeConfig = schemeConfigs.get(schemeName); - if (schemeConfig == null) { - throw new SecurityException( - "Authentication failed for agent " - + agentName - + ": no configuration for scheme " - + schemeName); - } - String credential = credentialService.getCredential(schemeName, null); - if (credential == null) { - throw new SecurityException( - "Authentication failed for agent " - + agentName - + " (scheme=" - + schemeName - + "), request blocked"); - } - addCredentialHeader(agentName, headers, schemeConfig, credential); - return headers; - } - } + AgentCredentialService credentialService = authManager.getService(agentName); + if (credentialService == null) { + if (authProvider != null) { + return headers; + } + throw new SecurityException( + "Authentication failed for agent " + + agentName + + ": AgentCard requires credentials but none are configured"); + } + Map<String, Map<String, Object>> schemeConfigs = authManager.getConfig(agentName); + if (schemeConfigs == null || schemeConfigs.isEmpty()) { + if (authProvider != null) { return headers; + } + throw new SecurityException( + "Authentication failed for agent " + agentName + ": credential configuration is empty"); } - private static void addCredentialHeader( - String agentName, - Map<String, String> headers, - Map<String, Object> schemeConfig, - String credential) { - String authHeader = (String) schemeConfig.get("auth_header"); - if (authHeader != null && !authHeader.isEmpty()) { - String prefix = (String) schemeConfig.getOrDefault("auth_header_prefix", ""); - headers.put(authHeader, prefix + credential); - log.info("[Auth] Set header {} for agent {}", authHeader, agentName); - } else { - headers.put("Authorization", "Bearer " + credential); - log.info("[Auth] Set Bearer header for agent {}", agentName); + for (SecurityRequirement requirement : securityRequirements) { + Map<String, List<String>> schemes = requirement.schemes(); + for (String schemeName : schemes.keySet()) { + Map<String, Object> schemeConfig = schemeConfigs.get(schemeName); + if (schemeConfig == null) { + throw new SecurityException( + "Authentication failed for agent " + + agentName + + ": no configuration for scheme " + + schemeName); } - String acceptHeader = (String) schemeConfig.get("accept_header"); - if (acceptHeader != null && !acceptHeader.isEmpty()) { - headers.put("Accept", acceptHeader); + String credential = credentialService.getCredential(schemeName, null); + if (credential == null) { + throw new SecurityException( + "Authentication failed for agent " + + agentName + + " (scheme=" + + schemeName + + "), request blocked"); } + addCredentialHeader(agentName, headers, schemeConfig, credential); + return headers; + } } + return headers; + } } 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 index 1e5f4616..6e9d9e2f 100644 --- 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 @@ -4,77 +4,70 @@ */ 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; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; /** Synchronous, conversation-free HTTP boundary used by credential login requests. */ final class CredentialHttpTransport { - private final SSLContext sslContext; - private final int timeoutMillis; + private final SSLContext sslContext; + private final int timeoutMillis; - private CredentialHttpTransport(SSLContext sslContext, Duration timeout) { - this.sslContext = sslContext; - this.timeoutMillis = Math.toIntExact(timeout.toMillis()); - } + 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); - } + 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(); + 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) {} + 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 635d0e66..f8216fe7 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 @@ -19,14 +19,24 @@ package dev.openan.workflow.engine.client; +import dev.openan.workflow.engine.model.SendMessageResult; import io.grpc.ManagedChannelBuilder; - +import java.io.File; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; 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; @@ -43,29 +53,17 @@ import org.a2aproject.sdk.spec.A2AClientException; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.Task; import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.TaskQueryParams; import org.a2aproject.sdk.spec.TaskState; import org.a2aproject.sdk.spec.TaskStatus; import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.File; -import java.net.http.HttpClient; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; - /** * Default implementation of {@link A2AJavaClientRuntime} that uses the a2a-java SDK's {@link * Client} with {@link RestTransport}. @@ -79,629 +77,614 @@ * property is changed. */ public class DefaultA2AJavaClientRuntime - implements A2AJavaClientRuntime, ConversationScopedA2AJavaClientRuntime { - - private static final Logger log = LoggerFactory.getLogger(DefaultA2AJavaClientRuntime.class); - - private final boolean sslVerify; - private final String caCertsPath; - private final String clientCertPath; - private final String clientKeyPath; - private final String clientKeyPassword; - private final String crlPath; - private final long sendTimeoutSeconds; - private final String preferredProtocol; - private final java.util.concurrent.ExecutorService httpClientExecutor; - private final Map<ClientCacheKey, Client> clientCache = new ConcurrentHashMap<>(); - /** Notification-T streams need a client that can be closed without disrupting task traffic. */ - private final Map<StreamClientKey, Client> streamClients = new ConcurrentHashMap<>(); - private final AtomicBoolean closed = new AtomicBoolean(); - - /** - * Create a runtime with the given SSL configuration. - * - * @param sslVerify whether to verify server TLS certificates - * @param caCertsPath optional path to a PEM CA trust store (null = use default) - * @param sendTimeoutSeconds SSE response wait timeout in seconds - */ - public DefaultA2AJavaClientRuntime( - boolean sslVerify, - String caCertsPath, - long sendTimeoutSeconds, - String preferredProtocol) { - this( - sslVerify, - caCertsPath, - null, - null, - null, - null, - sendTimeoutSeconds, - preferredProtocol, - 4, - 16, - 256); + implements A2AJavaClientRuntime, ConversationScopedA2AJavaClientRuntime { + + private static final Logger log = LoggerFactory.getLogger(DefaultA2AJavaClientRuntime.class); + + private final boolean sslVerify; + private final String caCertsPath; + private final String clientCertPath; + private final String clientKeyPath; + private final String clientKeyPassword; + private final String crlPath; + private final long sendTimeoutSeconds; + private final String preferredProtocol; + private final java.util.concurrent.ExecutorService httpClientExecutor; + private final Map<ClientCacheKey, Client> clientCache = new ConcurrentHashMap<>(); + + /** Notification-T streams need a client that can be closed without disrupting task traffic. */ + private final Map<StreamClientKey, Client> streamClients = new ConcurrentHashMap<>(); + + private final AtomicBoolean closed = new AtomicBoolean(); + + /** + * Create a runtime with the given SSL configuration. + * + * @param sslVerify whether to verify server TLS certificates + * @param caCertsPath optional path to a PEM CA trust store (null = use default) + * @param sendTimeoutSeconds SSE response wait timeout in seconds + */ + public DefaultA2AJavaClientRuntime( + boolean sslVerify, String caCertsPath, long sendTimeoutSeconds, String preferredProtocol) { + this( + sslVerify, + caCertsPath, + null, + null, + null, + null, + sendTimeoutSeconds, + preferredProtocol, + 4, + 16, + 256); + } + + public DefaultA2AJavaClientRuntime( + boolean sslVerify, + String caCertsPath, + String clientCertPath, + String clientKeyPath, + String clientKeyPassword, + String crlPath, + long sendTimeoutSeconds, + String preferredProtocol, + int executorCoreSize, + int executorMaxSize, + int executorQueueCapacity) { + this.sslVerify = sslVerify; + this.caCertsPath = caCertsPath; + this.clientCertPath = clientCertPath; + this.clientKeyPath = clientKeyPath; + this.clientKeyPassword = clientKeyPassword; + this.crlPath = crlPath; + this.sendTimeoutSeconds = sendTimeoutSeconds; + this.preferredProtocol = preferredProtocol; + this.httpClientExecutor = + new ThreadPoolExecutor( + executorCoreSize, + executorMaxSize, + 60L, + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(executorQueueCapacity), + r -> { + Thread t = new Thread(r, "a2a-client"); + t.setDaemon(true); + return t; + }, + new ThreadPoolExecutor.CallerRunsPolicy()); + if (!sslVerify) { + log.warn( + "[A2ARuntime] TLS certificate-chain verification is disabled; hostname verification remains enabled"); } - - public DefaultA2AJavaClientRuntime( - boolean sslVerify, - String caCertsPath, - String clientCertPath, - String clientKeyPath, - String clientKeyPassword, - String crlPath, - long sendTimeoutSeconds, - String preferredProtocol, - int executorCoreSize, - int executorMaxSize, - int executorQueueCapacity) { - this.sslVerify = sslVerify; - this.caCertsPath = caCertsPath; - this.clientCertPath = clientCertPath; - this.clientKeyPath = clientKeyPath; - this.clientKeyPassword = clientKeyPassword; - this.crlPath = crlPath; - this.sendTimeoutSeconds = sendTimeoutSeconds; - this.preferredProtocol = preferredProtocol; - this.httpClientExecutor = - new ThreadPoolExecutor( - executorCoreSize, - executorMaxSize, - 60L, - TimeUnit.SECONDS, - new ArrayBlockingQueue<>(executorQueueCapacity), - r -> { - Thread t = new Thread(r, "a2a-client"); - t.setDaemon(true); - return t; - }, - new ThreadPoolExecutor.CallerRunsPolicy()); - if (!sslVerify) { - log.warn( - "[A2ARuntime] TLS certificate-chain verification is disabled; hostname verification remains enabled"); - } - log.info( - "[A2ARuntime] Initialized: sslVerify={}, caCerts={}, timeout={}s", - sslVerify, - caCertsPath, - sendTimeoutSeconds); + log.info( + "[A2ARuntime] Initialized: sslVerify={}, caCerts={}, timeout={}s", + sslVerify, + caCertsPath, + sendTimeoutSeconds); + } + + /** Simplified constructor using strict TLS verification and the JVM default trust store. */ + public DefaultA2AJavaClientRuntime() { + this(true, null, 600L, null); + } + + private static String extractAgentUrl(AgentCard agentCard) { + if (!agentCard.supportedInterfaces().isEmpty()) { + return agentCard.supportedInterfaces().get(0).url(); } - - /** Simplified constructor using strict TLS verification and the JVM default trust store. */ - public DefaultA2AJavaClientRuntime() { - this(true, null, 600L, null); + return "?"; + } + + private static void onEvent( + String agentName, + ClientEvent event, + List<ClientEvent> events, + AtomicReference<ClientEvent> lastEventRef, + Consumer<ClientEvent> eventSink, + CountDownLatch done) { + events.add(event); + lastEventRef.set(event); + logEvent(agentName, event); + ProtocolLogger.logResponseEvent(agentName, event); + if (eventSink != null) { + try { + eventSink.accept(event); + } catch (Exception e) { + log.warn( + "[A2ARuntime] eventSink callback failed for {} (event_class={}): {}", + agentName, + event.getClass().getSimpleName(), + e.getMessage(), + e); + } } - - private static String extractAgentUrl(AgentCard agentCard) { - if (!agentCard.supportedInterfaces().isEmpty()) { - return agentCard.supportedInterfaces().get(0).url(); - } - return "?"; + if (isTerminal(event)) { + log.info("[A2ARuntime] Terminal event for '{}': {}", agentName, describeTerminalEvent(event)); + done.countDown(); } - - private static void onEvent( - String agentName, - ClientEvent event, - List<ClientEvent> events, - AtomicReference<ClientEvent> lastEventRef, - Consumer<ClientEvent> eventSink, - CountDownLatch done) { - events.add(event); - lastEventRef.set(event); - logEvent(agentName, event); - ProtocolLogger.logResponseEvent(agentName, event); - if (eventSink != null) { - try { - eventSink.accept(event); - } catch (Exception e) { - log.warn( - "[A2ARuntime] eventSink callback failed for {} (event_class={}): {}", - agentName, - event.getClass().getSimpleName(), - e.getMessage(), - e); - } - } - if (isTerminal(event)) { - log.info( - "[A2ARuntime] Terminal event for '{}': {}", - agentName, - describeTerminalEvent(event)); - done.countDown(); - } + } + + private static void onError( + String agentName, Throwable error, CountDownLatch done, AtomicReference<Throwable> errorRef) { + if (done.getCount() == 0) { + log.debug( + "[A2ARuntime] Connection closed after terminal event for '{}': {}", + agentName, + error.getMessage()); + } else { + String msg = error.getMessage() != null ? error.getMessage() : ""; + boolean connectionClosed = + msg.contains("connection closed locally") + || msg.contains("chunked transfer encoding, state: READING_LENGTH"); + if (connectionClosed) { + log.debug("[A2ARuntime] Connection closed for '{}': {}", agentName, msg); + } else { + errorRef.set(error); + log.error("[A2ARuntime] Error callback for '{}': {}", agentName, error.getMessage(), error); + } + done.countDown(); } + } - private static void onError( - String agentName, - Throwable error, - CountDownLatch done, - AtomicReference<Throwable> errorRef) { - if (done.getCount() == 0) { - log.debug( - "[A2ARuntime] Connection closed after terminal event for '{}': {}", - agentName, - error.getMessage()); - } else { - String msg = error.getMessage() != null ? error.getMessage() : ""; - boolean connectionClosed = - msg.contains("connection closed locally") - || msg.contains("chunked transfer encoding, state: READING_LENGTH"); - if (connectionClosed) { - log.debug("[A2ARuntime] Connection closed for '{}': {}", agentName, msg); - } else { - errorRef.set(error); - log.error( - "[A2ARuntime] Error callback for '{}': {}", - agentName, - error.getMessage(), - error); - } - done.countDown(); - } + private static boolean isTerminal(ClientEvent event) { + if (event instanceof TaskEvent taskEvent) { + return isTerminal(taskEvent.getTask().status().state()); } - - private static boolean isTerminal(ClientEvent event) { - if (event instanceof TaskEvent taskEvent) { - return isTerminal(taskEvent.getTask().status().state()); - } - if (event instanceof TaskUpdateEvent taskUpdateEvent) { - if (taskUpdateEvent.getUpdateEvent() instanceof TaskStatusUpdateEvent statusUpdate) { - return isTerminal(statusUpdate.status().state()); - } - // Artifact updates are not terminal - if (taskUpdateEvent.getUpdateEvent() instanceof TaskArtifactUpdateEvent) { - return false; - } - } + if (event instanceof TaskUpdateEvent taskUpdateEvent) { + if (taskUpdateEvent.getUpdateEvent() instanceof TaskStatusUpdateEvent statusUpdate) { + return isTerminal(statusUpdate.status().state()); + } + // Artifact updates are not terminal + if (taskUpdateEvent.getUpdateEvent() instanceof TaskArtifactUpdateEvent) { return false; + } } - - private static boolean isTerminal(TaskState state) { - return state == TaskState.TASK_STATE_COMPLETED - || state == TaskState.TASK_STATE_FAILED - || state == TaskState.TASK_STATE_CANCELED - || state == TaskState.TASK_STATE_REJECTED - || state == TaskState.TASK_STATE_INPUT_REQUIRED - || state == TaskState.TASK_STATE_AUTH_REQUIRED; + return false; + } + + private static boolean isTerminal(TaskState state) { + return state == TaskState.TASK_STATE_COMPLETED + || state == TaskState.TASK_STATE_FAILED + || state == TaskState.TASK_STATE_CANCELED + || state == TaskState.TASK_STATE_REJECTED + || state == TaskState.TASK_STATE_INPUT_REQUIRED + || state == TaskState.TASK_STATE_AUTH_REQUIRED; + } + + private static void logEvent(String agentName, ClientEvent event) { + if (event instanceof TaskEvent te) { + TaskStatus st = te.getTask().status(); + log.info( + "[A2ARuntime] Event[Task] agent='{}', state={}, final={}", + agentName, + st.state(), + isTerminal(st.state())); + } else if (event instanceof TaskUpdateEvent tue) { + if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { + TaskStatus st = sue.status(); + log.info( + "[A2ARuntime] Event[StatusUpdate] agent='{}', state={}, final={}", + agentName, + st.state(), + sue.isFinal()); + } else if (tue.getUpdateEvent() instanceof TaskArtifactUpdateEvent ae) { + log.info( + "[A2ARuntime] Event[ArtifactUpdate] agent='{}', name={}, append={}, lastChunk={}", + agentName, + ae.artifact().name(), + ae.append(), + ae.lastChunk()); + } + } else if (event instanceof MessageEvent me) { + log.info( + "[A2ARuntime] Event[Message] agent='{}', role={}, parts={}", + agentName, + me.getMessage().role(), + me.getMessage().parts().size()); + } else { + log.debug("[A2ARuntime] Event[{}] agent='{}'", event.getClass().getSimpleName(), agentName); } + } - private static void logEvent(String agentName, ClientEvent event) { - if (event instanceof TaskEvent te) { - TaskStatus st = te.getTask().status(); - log.info( - "[A2ARuntime] Event[Task] agent='{}', state={}, final={}", - agentName, - st.state(), - isTerminal(st.state())); - } else if (event instanceof TaskUpdateEvent tue) { - if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { - TaskStatus st = sue.status(); - log.info( - "[A2ARuntime] Event[StatusUpdate] agent='{}', state={}, final={}", - agentName, - st.state(), - sue.isFinal()); - } else if (tue.getUpdateEvent() instanceof TaskArtifactUpdateEvent ae) { - log.info( - "[A2ARuntime] Event[ArtifactUpdate] agent='{}', name={}, append={}, lastChunk={}", - agentName, - ae.artifact().name(), - ae.append(), - ae.lastChunk()); - } - } else if (event instanceof MessageEvent me) { - log.info( - "[A2ARuntime] Event[Message] agent='{}', role={}, parts={}", - agentName, - me.getMessage().role(), - me.getMessage().parts().size()); - } else { - log.debug( - "[A2ARuntime] Event[{}] agent='{}'", - event.getClass().getSimpleName(), - agentName); - } + private static String describeTerminalEvent(ClientEvent event) { + if (event instanceof TaskEvent te) { + return te.getTask().status().state().name(); } - - private static String describeTerminalEvent(ClientEvent event) { - if (event instanceof TaskEvent te) { - return te.getTask().status().state().name(); - } - if (event instanceof TaskUpdateEvent tue - && tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { - return sue.status().state().name(); - } - return event.getClass().getSimpleName(); + if (event instanceof TaskUpdateEvent tue + && tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { + return sue.status().state().name(); } - - @Override - public Iterable<ClientEvent> sendMessage( - AgentCard agentCard, - org.a2aproject.sdk.spec.MessageSendParams params, - ClientCallContext callContext, - Consumer<ClientEvent> eventSink, - Consumer<String> logSink) { - if (closed.get()) throw new IllegalStateException("A2A client runtime is closed"); - String agentUrl = extractAgentUrl(agentCard); - Client client = - isNotificationStream(params) - ? getOrCreateStreamClient( - agentCard, - params.message() != null ? params.message().contextId() : null) - : getOrCreateClient(agentCard, agentUrl); - List<ClientEvent> events = Collections.synchronizedList(new ArrayList<>()); - CountDownLatch done = new CountDownLatch(1); - AtomicReference<Throwable> errorRef = new AtomicReference<>(); - AtomicReference<ClientEvent> lastEventRef = new AtomicReference<>(); - - if (logSink != null) logSink.accept("[A2A] Sending message to " + agentCard.name()); - - try { - client.sendMessage( - params, - List.of( - (event, card) -> - onEvent( - agentCard.name(), - event, - events, - lastEventRef, - eventSink, - done)), - error -> onError(agentCard.name(), error, done, errorRef), - callContext); - } catch (A2AClientException e) { - throw new RuntimeException( - "A2A message:send failed for " + agentCard.name() + ": " + e.getMessage(), e); - } - - awaitCompletion(agentCard.name(), done, events, lastEventRef); - - if (errorRef.get() != null) { - throw new RuntimeException( - "A2A message:send failed for " - + agentCard.name() - + ": " - + errorRef.get().getMessage(), - errorRef.get()); - } - log.info("[A2ARuntime] Completed for '{}': {} event(s)", agentCard.name(), events.size()); - return events; + return event.getClass().getSimpleName(); + } + + 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 Iterable<ClientEvent> sendMessage( + AgentCard agentCard, + org.a2aproject.sdk.spec.MessageSendParams params, + ClientCallContext callContext, + Consumer<ClientEvent> eventSink, + Consumer<String> logSink) { + if (closed.get()) throw new IllegalStateException("A2A client runtime is closed"); + String agentUrl = extractAgentUrl(agentCard); + Client client = + isNotificationStream(params) + ? getOrCreateStreamClient( + agentCard, params.message() != null ? params.message().contextId() : null) + : getOrCreateClient(agentCard, agentUrl); + List<ClientEvent> events = Collections.synchronizedList(new ArrayList<>()); + CountDownLatch done = new CountDownLatch(1); + AtomicReference<Throwable> errorRef = new AtomicReference<>(); + AtomicReference<ClientEvent> lastEventRef = new AtomicReference<>(); + + if (logSink != null) logSink.accept("[A2A] Sending message to " + agentCard.name()); + + try { + client.sendMessage( + params, + List.of( + (event, card) -> + onEvent(agentCard.name(), event, events, lastEventRef, eventSink, done)), + error -> onError(agentCard.name(), error, done, errorRef), + callContext); + } catch (A2AClientException e) { + throw new RuntimeException( + "A2A message:send failed for " + agentCard.name() + ": " + e.getMessage(), e); } - private Client getOrCreateClient(AgentCard agentCard, String agentUrl) { - AgentInterface selected = selectInterface(agentCard); - String protocolBinding = selected.protocolBinding(); - ClientCacheKey key = - new ClientCacheKey( - agentCard.name(), - agentCard.version(), - selected.url(), - protocolBinding, - sslVerify, - caCertsPath, - clientCertPath, - clientKeyPath, - crlPath); - return clientCache.computeIfAbsent( - key, - ignored -> { - A2AHttpClient httpClient = createHttpClient(); - try { - Client client = - buildClientWithTransport(agentCard, protocolBinding, httpClient); - log.info( - "[A2ARuntime] Created cached transport: {} for '{}' ({})", - protocolBinding, - agentCard.name(), - selected.url()); - return client; - } catch (A2AClientException e) { - throw new IllegalStateException( - "Failed to create a2a-java client for " - + agentCard.name() - + " at " - + agentUrl, - e); - } - }); - } + awaitCompletion(agentCard.name(), done, events, lastEventRef); - 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); - } - }); + if (errorRef.get() != null) { + throw new RuntimeException( + "A2A message:send failed for " + agentCard.name() + ": " + errorRef.get().getMessage(), + errorRef.get()); } - - 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()); + log.info("[A2ARuntime] Completed for '{}': {} event(s)", agentCard.name(), events.size()); + return events; + } + + private Client getOrCreateClient(AgentCard agentCard, String agentUrl) { + AgentInterface selected = selectInterface(agentCard); + String protocolBinding = selected.protocolBinding(); + ClientCacheKey key = + new ClientCacheKey( + agentCard.name(), + agentCard.version(), + selected.url(), + protocolBinding, + sslVerify, + caCertsPath, + clientCertPath, + clientKeyPath, + crlPath); + return clientCache.computeIfAbsent( + key, + ignored -> { + A2AHttpClient httpClient = createHttpClient(); + try { + Client client = buildClientWithTransport(agentCard, protocolBinding, httpClient); + log.info( + "[A2ARuntime] Created cached transport: {} for '{}' ({})", + protocolBinding, + agentCard.name(), + selected.url()); + return client; + } catch (A2AClientException e) { + throw new IllegalStateException( + "Failed to create a2a-java client for " + agentCard.name() + " at " + agentUrl, e); + } + }); + } + + private Client getOrCreateStreamClient(AgentCard agentCard, String contextId) { + if (contextId == null || contextId.isBlank()) { + throw new IllegalArgumentException("Notification-T contextId must not be blank"); } - - @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(); + 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] Closed conversation-scoped Notification-T transport for '{}' (contextId={})", - agentCard.name(), - contextId); - } + "[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); + } + }); + } + + @Override + public void closeConversation(AgentCard agentCard, String contextId) { + if (agentCard == null || contextId == null || contextId.isBlank()) { + return; } - - /** Select the best AgentInterface based on preferredProtocol or first available. */ - private AgentInterface selectInterface(AgentCard agentCard) { - List<AgentInterface> interfaces = agentCard.supportedInterfaces(); - if (interfaces == null || interfaces.isEmpty()) { - throw new RuntimeException("AgentCard has no supportedInterfaces: " + agentCard.name()); - } - if (preferredProtocol != null && !preferredProtocol.isBlank()) { - for (AgentInterface iface : interfaces) { - if (preferredProtocol.equalsIgnoreCase(iface.protocolBinding())) { - log.info( - "[A2ARuntime] Selected preferred protocol {} for '{}'", - preferredProtocol, - agentCard.name()); - return iface; - } - } - log.warn( - "[A2ARuntime] Preferred protocol '{}' not in supportedInterfaces for '{}', using first available: {}", - preferredProtocol, - agentCard.name(), - interfaces.get(0).protocolBinding()); - } - return interfaces.get(0); + 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); } + } - /** - * Build the client with the transport matching the protocol binding. - * - * <p>HTTP+JSON and JSONRPC use A2AHttpClient for SSL configuration. GRPC uses - * GrpcTransportConfig with a custom Channel factory. Insecure gRPC is plaintext and cannot - * carry mTLS or CRL options; such combinations fail fast instead of being ignored. - */ - private Client buildClientWithTransport( - AgentCard agentCard, String protocolBinding, A2AHttpClient httpClient) - throws A2AClientException { - if ("JSONRPC".equalsIgnoreCase(protocolBinding)) { - return Client.builder(agentCard) - .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfig(httpClient)) - .build(); - } - if ("GRPC".equalsIgnoreCase(protocolBinding)) { - GrpcTransportConfig grpcConfig = new GrpcTransportConfig(url -> createGrpcChannel(url)); - return Client.builder(agentCard).withTransport(GrpcTransport.class, grpcConfig).build(); - } - return Client.builder(agentCard) - .withTransport(RestTransport.class, new RestTransportConfig(httpClient)) - .build(); + /** Select the best AgentInterface based on preferredProtocol or first available. */ + private AgentInterface selectInterface(AgentCard agentCard) { + List<AgentInterface> interfaces = agentCard.supportedInterfaces(); + if (interfaces == null || interfaces.isEmpty()) { + throw new RuntimeException("AgentCard has no supportedInterfaces: " + agentCard.name()); } - - private void awaitCompletion( - String agentName, - CountDownLatch done, - List<ClientEvent> events, - AtomicReference<ClientEvent> lastEventRef) { - try { - if (!done.await(sendTimeoutSeconds, TimeUnit.SECONDS)) { - ClientEvent last = lastEventRef.get(); - log.error( - "[A2ARuntime] TIMEOUT for '{}' after {}s: received {} event(s), last event_class={}", - agentName, - sendTimeoutSeconds, - events.size(), - last != null ? last.getClass().getSimpleName() : "none"); - throw new RuntimeException("A2A message:send timed out for " + agentName); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("A2A message:send interrupted for " + agentName, e); + if (preferredProtocol != null && !preferredProtocol.isBlank()) { + for (AgentInterface iface : interfaces) { + if (preferredProtocol.equalsIgnoreCase(iface.protocolBinding())) { + log.info( + "[A2ARuntime] Selected preferred protocol {} for '{}'", + preferredProtocol, + agentCard.name()); + return iface; } + } + log.warn( + "[A2ARuntime] Preferred protocol '{}' not in supportedInterfaces for '{}', using first available: {}", + preferredProtocol, + agentCard.name(), + interfaces.get(0).protocolBinding()); } - - @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); - } + return interfaces.get(0); + } + + /** + * Build the client with the transport matching the protocol binding. + * + * <p>HTTP+JSON and JSONRPC use A2AHttpClient for SSL configuration. GRPC uses GrpcTransportConfig + * with a custom Channel factory. Insecure gRPC is plaintext and cannot carry mTLS or CRL options; + * such combinations fail fast instead of being ignored. + */ + private Client buildClientWithTransport( + AgentCard agentCard, String protocolBinding, A2AHttpClient httpClient) + throws A2AClientException { + if ("JSONRPC".equalsIgnoreCase(protocolBinding)) { + return Client.builder(agentCard) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfig(httpClient)) + .build(); } - - @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); - } + if ("GRPC".equalsIgnoreCase(protocolBinding)) { + GrpcTransportConfig grpcConfig = new GrpcTransportConfig(url -> createGrpcChannel(url)); + return Client.builder(agentCard).withTransport(GrpcTransport.class, grpcConfig).build(); } - - @Override - public java.util.concurrent.CompletableFuture<SendMessageResult> subscribeToTask( - AgentCard agentCard, - String taskId, - ClientCallContext callContext, - java.util.function.Consumer<ClientEvent> eventSink) { - if (closed.get()) { - return java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("A2A client runtime is closed")); - } - java.util.concurrent.CompletableFuture<SendMessageResult> future = new java.util.concurrent.CompletableFuture<>(); - Client client = getOrCreateClient(agentCard, extractAgentUrl(agentCard)); - List<ClientEvent> 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<ClientEvent> 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; + return Client.builder(agentCard) + .withTransport(RestTransport.class, new RestTransportConfig(httpClient)) + .build(); + } + + private void awaitCompletion( + String agentName, + CountDownLatch done, + List<ClientEvent> events, + AtomicReference<ClientEvent> lastEventRef) { + try { + if (!done.await(sendTimeoutSeconds, TimeUnit.SECONDS)) { + ClientEvent last = lastEventRef.get(); + log.error( + "[A2ARuntime] TIMEOUT for '{}' after {}s: received {} event(s), last event_class={}", + agentName, + sendTimeoutSeconds, + events.size(), + last != null ? last.getClass().getSimpleName() : "none"); + throw new RuntimeException("A2A message:send timed out for " + agentName); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("A2A message:send interrupted for " + agentName, e); } - - @Override - public void close() { - if (!closed.compareAndSet(false, true)) return; - clientCache.values().forEach( - client -> { - try { - client.close(); - } catch (RuntimeException e) { - log.warn("[A2ARuntime] Failed to close cached client: {}", e.getMessage()); - } - }); - 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"); + } + + @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); } - - private record StreamClientKey(String agentName, String contextId) {} - - /** - * Create a gRPC channel with SSL settings matching the engine config. - * - * <p>When sslVerify=false, uses plaintext (HTTP/2 without TLS). mTLS and CRL configuration - * require TLS and therefore cannot be combined with that mode in the default gRPC runtime. - */ - private io.grpc.Channel createGrpcChannel(String url) { - if (!sslVerify) { - if ((clientCertPath != null && !clientCertPath.isBlank()) - || (clientKeyPath != null && !clientKeyPath.isBlank())) { - throw new IllegalArgumentException( - "sslVerify=false uses plaintext gRPC and cannot carry mTLS configuration"); - } - if (crlPath != null && !crlPath.isBlank()) { - throw new IllegalArgumentException( - "sslVerify=false uses plaintext gRPC and cannot check a CRL"); - } - return ManagedChannelBuilder.forTarget(url).usePlaintext().intercept(new WireGrpcInterceptor()).build(); - } - if (crlPath != null && !crlPath.isBlank()) { - throw new IllegalArgumentException( - "CRL configuration is not supported by the default gRPC transport"); - } - try { - io.grpc.TlsChannelCredentials.Builder credentials = - io.grpc.TlsChannelCredentials.newBuilder(); - if (caCertsPath != null && !caCertsPath.isBlank()) { - credentials.trustManager(new File(caCertsPath)); - } - boolean hasCert = clientCertPath != null && !clientCertPath.isBlank(); - boolean hasKey = clientKeyPath != null && !clientKeyPath.isBlank(); - if (hasCert != hasKey) { - throw new IllegalArgumentException( - "Both client certificate and private key are required for gRPC mTLS"); - } - if (hasCert) { - if (clientKeyPassword == null || clientKeyPassword.isEmpty()) { - credentials.keyManager(new File(clientCertPath), new File(clientKeyPath)); - } else { - credentials.keyManager( - new File(clientCertPath), - new File(clientKeyPath), - clientKeyPassword); + } + + @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<SendMessageResult> subscribeToTask( + AgentCard agentCard, + String taskId, + ClientCallContext callContext, + java.util.function.Consumer<ClientEvent> eventSink) { + if (closed.get()) { + return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("A2A client runtime is closed")); + } + java.util.concurrent.CompletableFuture<SendMessageResult> future = + new java.util.concurrent.CompletableFuture<>(); + Client client = getOrCreateClient(agentCard, extractAgentUrl(agentCard)); + List<ClientEvent> 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) { + } } - } - 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); - } + if (isTerminal(event) && !future.isDone()) { + List<ClientEvent> 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)); } - - private A2AHttpClient createHttpClient() { - HttpClient httpClient = - JdkHttpClientFactory.create( - sslVerify, - caCertsPath, - clientCertPath, - clientKeyPath, - clientKeyPassword, - crlPath, - Duration.ofSeconds(60), - httpClientExecutor); - return new JdkA2AHttpClient(new ObservedHttpClient(httpClient)); + return future; + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + clientCache + .values() + .forEach( + client -> { + try { + client.close(); + } catch (RuntimeException e) { + log.warn("[A2ARuntime] Failed to close cached client: {}", e.getMessage()); + } + }); + 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"); + } + + /** + * Create a gRPC channel with SSL settings matching the engine config. + * + * <p>When sslVerify=false, uses plaintext (HTTP/2 without TLS). mTLS and CRL configuration + * require TLS and therefore cannot be combined with that mode in the default gRPC runtime. + */ + private io.grpc.Channel createGrpcChannel(String url) { + if (!sslVerify) { + if ((clientCertPath != null && !clientCertPath.isBlank()) + || (clientKeyPath != null && !clientKeyPath.isBlank())) { + throw new IllegalArgumentException( + "sslVerify=false uses plaintext gRPC and cannot carry mTLS configuration"); + } + if (crlPath != null && !crlPath.isBlank()) { + throw new IllegalArgumentException( + "sslVerify=false uses plaintext gRPC and cannot check a CRL"); + } + return ManagedChannelBuilder.forTarget(url) + .usePlaintext() + .intercept(new WireGrpcInterceptor()) + .build(); } - - private record ClientCacheKey( - String agentName, - String agentVersion, - String endpoint, - String protocolBinding, - boolean sslVerify, - String caCertsPath, - String clientCertPath, - String clientKeyPath, - String crlPath) {} + if (crlPath != null && !crlPath.isBlank()) { + throw new IllegalArgumentException( + "CRL configuration is not supported by the default gRPC transport"); + } + try { + io.grpc.TlsChannelCredentials.Builder credentials = + io.grpc.TlsChannelCredentials.newBuilder(); + if (caCertsPath != null && !caCertsPath.isBlank()) { + credentials.trustManager(new File(caCertsPath)); + } + boolean hasCert = clientCertPath != null && !clientCertPath.isBlank(); + boolean hasKey = clientKeyPath != null && !clientKeyPath.isBlank(); + if (hasCert != hasKey) { + throw new IllegalArgumentException( + "Both client certificate and private key are required for gRPC mTLS"); + } + if (hasCert) { + if (clientKeyPassword == null || clientKeyPassword.isEmpty()) { + credentials.keyManager(new File(clientCertPath), new File(clientKeyPath)); + } else { + credentials.keyManager( + new File(clientCertPath), new File(clientKeyPath), clientKeyPassword); + } + } + 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); + } + } + + private A2AHttpClient createHttpClient() { + HttpClient httpClient = + JdkHttpClientFactory.create( + sslVerify, + caCertsPath, + clientCertPath, + clientKeyPath, + clientKeyPassword, + crlPath, + Duration.ofSeconds(60), + httpClientExecutor); + return new JdkA2AHttpClient(new ObservedHttpClient(httpClient)); + } + + private record StreamClientKey(String agentName, String contextId) {} + + private record ClientCacheKey( + String agentName, + String agentVersion, + String endpoint, + String protocolBinding, + boolean sslVerify, + String caCertsPath, + String clientCertPath, + String clientKeyPath, + String crlPath) {} } 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 f676f3af..a3ac05c7 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 @@ -22,52 +22,63 @@ import dev.openan.workflow.engine.model.MessageContent; import dev.openan.workflow.engine.model.ReceivedMessage; import dev.openan.workflow.engine.model.SendMessageResult; -import org.a2aproject.sdk.spec.AgentCard; import java.util.Objects; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.function.BiConsumer; +import org.a2aproject.sdk.spec.AgentCard; /** 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"); - } +public record DefaultExtensionSender(A2ATransport transport) + implements ExtensionSender, AutoCloseable { + public DefaultExtensionSender { + Objects.requireNonNull(transport, "transport"); + } - @Override - public CompletableFuture<SendMessageResult> 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); - } + @Override + public CompletableFuture<SendMessageResult> 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); } + } - @Override - public NotificationSubscription openNotification(String agentName, MessageContent content, - BiConsumer<NotificationSubscription, ReceivedMessage> listener) { - AgentCard card = requireExtension(agentName, content, A2ATExtension.NOTIFICATION_T); - return transport.openNotificationStream(card, agentName, content, UUID.randomUUID().toString(), - Objects.requireNonNull(listener, "listener")); - } + @Override + public NotificationSubscription openNotification( + String agentName, + MessageContent content, + BiConsumer<NotificationSubscription, ReceivedMessage> listener) { + AgentCard card = requireExtension(agentName, content, A2ATExtension.NOTIFICATION_T); + return transport.openNotificationStream( + card, + agentName, + content, + UUID.randomUUID().toString(), + Objects.requireNonNull(listener, "listener")); + } - 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 card; + 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 card; + } - @Override - public void close() { - // Caller owns the independent transport and its subscriptions. - } + @Override + public void close() { + // 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 ab1a1d92..771778d9 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 @@ -23,6 +23,11 @@ import dev.openan.workflow.engine.control.EventCallback; import dev.openan.workflow.engine.control.EventType; import dev.openan.workflow.engine.model.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import net.openan.a2at.sdk.core.model.NegotiationContext; import net.openan.a2at.sdk.core.model.NegotiationPerformative; import org.a2aproject.sdk.client.ClientEvent; @@ -34,385 +39,484 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.time.Duration; -import java.util.*; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -/** Coordinates A2A task interaction; all content generation and semantic validation belong to the host. */ +/** + * 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 int maxNegotiationExchanges; - private final boolean closeTransportOnClose; - private final AtomicBoolean closed = new AtomicBoolean(); - private final Set<Invocation> invocations = java.util.concurrent.ConcurrentHashMap.newKeySet(); - private EventCallback eventCallback = new EventCallback(); - private ControlPoint controlPoint; + private static final Logger log = LoggerFactory.getLogger(DefaultWorkflowEngineClient.class); + private final A2ATransport transport; + private final int maxNegotiationExchanges; + private final boolean closeTransportOnClose; + private final AtomicBoolean closed = new AtomicBoolean(); + private final Set<Invocation> invocations = java.util.concurrent.ConcurrentHashMap.newKeySet(); + private EventCallback eventCallback = new EventCallback(); + private ControlPoint controlPoint; - /** Creates a caller-owned transport facade. */ - public DefaultWorkflowEngineClient(A2ATransport transport) { - this(transport, 3, false); - } - - /** Uses the configured resource exchange budget, not an SDK protocol round counter. */ - public DefaultWorkflowEngineClient(A2ATransport transport, WorkflowEngineClientConfig config) { - this(transport, config.getMaxNegotiationExchanges(), false); - } + /** Creates a caller-owned transport facade. */ + public DefaultWorkflowEngineClient(A2ATransport transport) { + this(transport, 3, false); + } - 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; - } + /** Uses the configured resource exchange budget, not an SDK protocol round counter. */ + public DefaultWorkflowEngineClient(A2ATransport transport, WorkflowEngineClientConfig config) { + this(transport, config.getMaxNegotiationExchanges(), false); + } - /** Creates a facade owning the supplied transport. */ - public static DefaultWorkflowEngineClient owning(A2ATransport transport) { - return new DefaultWorkflowEngineClient(transport, 3, true); - } + 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; + } - /** Creates an owning facade with explicit resource settings. */ - public static DefaultWorkflowEngineClient owning(A2ATransport transport, WorkflowEngineClientConfig config) { - return new DefaultWorkflowEngineClient(transport, config.getMaxNegotiationExchanges(), true); - } + /** Creates a facade owning the supplied transport. */ + public static DefaultWorkflowEngineClient owning(A2ATransport transport) { + return new DefaultWorkflowEngineClient(transport, 3, true); + } - @Override - public void setControlPoint(ControlPoint callbacks) { this.controlPoint = callbacks; } + /** 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 setEventCallback(EventCallback callback) { - this.eventCallback = callback == null ? new EventCallback() : callback; + 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; } + throw new IllegalArgumentException( + "Unsupported INPUT_REQUIRED interaction: no Negotiation-T Propose"); + } - @Override - public long callbackTimeoutSeconds() { return transport.sendTimeoutSeconds(); } - - @Override - public CompletableFuture<SendMessageResult> 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 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"); } + 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; + } - 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<SendMessageResult> completion = new CompletableFuture<>(); - final Map<String, List<NegotiationRequest.Exchange>> history = new HashMap<>(); - final Set<String> answered = new HashSet<>(); - final Map<String, NegotiationContext> contexts = new HashMap<>(); - String remoteTaskId; - int exchanges; + @Override + public void setControlPoint(ControlPoint callbacks) { + this.controlPoint = callbacks; + } - Invocation(TaskRequest task, MessageContent content, ControlPoint callbacks) { - this.task = task; - this.original = content; - this.callbacks = callbacks; - } + @Override + public void setEventCallback(EventCallback callback) { + this.eventCallback = callback == null ? new EventCallback() : callback; + } - Duration remaining() { - return Duration.ofNanos(Math.max(0, deadline - System.nanoTime())); - } + @Override + public long callbackTimeoutSeconds() { + return transport.sendTimeoutSeconds(); + } - boolean active() { return !closed.get() && !completion.isDone() && !remaining().isZero(); } - } + @Override + public CompletableFuture<SendMessageResult> 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); + } - @Override - public CompletableFuture<SendMessageResult> 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())); + @Override + public CompletableFuture<SendMessageResult> 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; - } + 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<SendMessageResult> 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<String, String> trace = invocationTrace(invocation); - return WireLog.call(trace, () -> transport.send(card, invocation.task.getAgentName(), content, - invocation.contextId, taskId, event -> forwardIntermediateEvent(event, invocation.task.getAgentName()), + private CompletableFuture<SendMessageResult> 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<String, String> 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<String, String> invocationTrace(Invocation invocation) { - Map<String, String> 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 Map<String, String> invocationTrace(Invocation invocation) { + Map<String, String> 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<SendMessageResult> 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(); - } - 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<NegotiationRequest.Exchange> 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<NegotiationReply> 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 CompletableFuture<SendMessageResult> 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(); } - - /** A send ACK is not a task result. Observe without resending the business command. */ - private CompletableFuture<SendMessageResult> 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)); - }); + if ("TASK_STATE_SUBMITTED".equals(result.getTaskState()) + || "TASK_STATE_WORKING".equals(result.getTaskState())) { + return observeTask(card, invocation).thenCompose(next -> advance(card, invocation, next)); } - - 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; - } - throw new IllegalArgumentException("Unsupported INPUT_REQUIRED interaction: no Negotiation-T Propose"); + if (!"TASK_STATE_INPUT_REQUIRED".equals(result.getTaskState())) { + emitAgentResponse(invocation.task.getAgentName(), result); + return CompletableFuture.completedFuture(result); } - - 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"); - } - 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; + 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<NegotiationRequest.Exchange> 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<NegotiationReply> 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 void emit(String type, Map<String, Object> data) { - try { eventCallback.onEvent(type, data); } - catch (RuntimeException error) { log.warn("Event callback failed for {}", type, error); } + /** A send ACK is not a task result. Observe without resending the business command. */ + private CompletableFuture<SendMessageResult> 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)); + }); + } + + private void emit(String type, Map<String, Object> 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<String, Object> data = new HashMap<>(); + data.put("agent", agentName); + data.put("response", result.getText()); + data.put("receivedMessages", result.getReceivedMessages()); + emit(EventType.AGENT_RESPONSE, data); + } - private void emitAgentResponse(String agentName, SendMessageResult result) { + private void forwardIntermediateEvent(ClientEvent event, String agentName) { + if (event instanceof TaskUpdateEvent tue) { + if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { + String state = sue.status().state().name(); + StringBuilder statusText = new StringBuilder(); + A2ATransport.extractTextFromMessage(sue.status().message(), statusText); Map<String, Object> data = new HashMap<>(); data.put("agent", agentName); - data.put("response", result.getText()); - data.put("receivedMessages", result.getReceivedMessages()); - emit(EventType.AGENT_RESPONSE, data); + data.put("state", state); + data.put("is_final", sue.isFinal()); + if (!statusText.isEmpty()) data.put("text", statusText.toString()); + if (sue.metadata() != null && !sue.metadata().isEmpty()) + data.put("metadata", sue.metadata()); + log.info( + "[EngineClient] Agent {} status update: {} (final={})", + agentName, + state, + sue.isFinal()); + emit(EventType.AGENT_STATUS_UPDATE, data); + } else if (tue.getUpdateEvent() + instanceof org.a2aproject.sdk.spec.TaskArtifactUpdateEvent ae) { + StringBuilder text = new StringBuilder(); + A2ATransport.extractTextFromArtifact(ae.artifact(), text); + Map<String, Object> data = new HashMap<>(); + data.put("agent", agentName); + 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()); + if (!text.isEmpty()) data.put("text", text.toString()); + // Business metadata belongs to the Artifact. Event metadata describes delivery + // (chunking, tracing, etc.) and must not hide the protocol payload. + Map<String, Object> artifactMetadata = ae.artifact().metadata(); + if (artifactMetadata != null && !artifactMetadata.isEmpty()) + data.put("metadata", artifactMetadata); + log.info( + "[EngineClient] Agent {} artifact update: {} ({})", + agentName, + ae.artifact().name(), + ae.artifact().artifactId()); + emit(EventType.AGENT_ARTIFACT_UPDATE, data); + } + } else if (event instanceof MessageEvent me) { + Message msg = me.getMessage(); + StringBuilder text = new StringBuilder(); + A2ATransport.extractTextFromMessage(msg, text); + Map<String, Object> data = new HashMap<>(); + data.put("agent", agentName); + 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()); + } + log.info("[EngineClient] Agent {} message event: {} chars", agentName, text.length()); + emit(EventType.AGENT_MESSAGE_EVENT, data); } + } - private void forwardIntermediateEvent(ClientEvent event, String agentName) { - if (event instanceof TaskUpdateEvent tue) { - if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { - String state = sue.status().state().name(); - StringBuilder statusText = new StringBuilder(); - A2ATransport.extractTextFromMessage(sue.status().message(), statusText); - Map<String, Object> data = new HashMap<>(); - data.put("agent", agentName); - data.put("state", state); - data.put("is_final", sue.isFinal()); - if (!statusText.isEmpty()) data.put("text", statusText.toString()); - if (sue.metadata() != null && !sue.metadata().isEmpty()) - data.put("metadata", sue.metadata()); - log.info( - "[EngineClient] Agent {} status update: {} (final={})", - agentName, - state, - sue.isFinal()); - emit(EventType.AGENT_STATUS_UPDATE, data); - } else if (tue.getUpdateEvent() - instanceof org.a2aproject.sdk.spec.TaskArtifactUpdateEvent ae) { - StringBuilder text = new StringBuilder(); - A2ATransport.extractTextFromArtifact(ae.artifact(), text); - Map<String, Object> data = new HashMap<>(); - data.put("agent", agentName); - 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()); - if (!text.isEmpty()) data.put("text", text.toString()); - // Business metadata belongs to the Artifact. Event metadata describes delivery - // (chunking, tracing, etc.) and must not hide the protocol payload. - Map<String, Object> artifactMetadata = ae.artifact().metadata(); - if (artifactMetadata != null && !artifactMetadata.isEmpty()) - data.put("metadata", artifactMetadata); - log.info( - "[EngineClient] Agent {} artifact update: {} ({})", - agentName, - ae.artifact().name(), - ae.artifact().artifactId()); - emit(EventType.AGENT_ARTIFACT_UPDATE, data); - } - } else if (event instanceof MessageEvent me) { - Message msg = me.getMessage(); - StringBuilder text = new StringBuilder(); - A2ATransport.extractTextFromMessage(msg, text); - Map<String, Object> data = new HashMap<>(); - data.put("agent", agentName); - 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()); - } - log.info("[EngineClient] Agent {} message event: {} chars", agentName, text.length()); - emit(EventType.AGENT_MESSAGE_EVENT, data); - } + @Override + public CompletableFuture<SendMessageResult> 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<SendMessageResult> 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<SendMessageResult> 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<SendMessageResult> 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<SendMessageResult> subscribeToTask( + String agentName, + String taskId, + java.util.function.Consumer<java.util.Map<String, Object>> 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 CompletableFuture<SendMessageResult> subscribeToTask( - String agentName, String taskId, - java.util.function.Consumer<java.util.Map<String, Object>> 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() { + if (!closed.compareAndSet(false, true)) return; + invocations.forEach(invocation -> invocation.completion.cancel(true)); + if (closeTransportOnClose) transport.close(); + } + + 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<SendMessageResult> completion = new CompletableFuture<>(); + final Map<String, List<NegotiationRequest.Exchange>> history = new HashMap<>(); + final Set<String> answered = new HashSet<>(); + final Map<String, NegotiationContext> contexts = new HashMap<>(); + String remoteTaskId; + int exchanges; + + Invocation(TaskRequest task, MessageContent content, ControlPoint callbacks) { + this.task = task; + this.original = content; + this.callbacks = callbacks; + } + + Duration remaining() { + return Duration.ofNanos(Math.max(0, deadline - System.nanoTime())); } - @Override - public void close() { - if (!closed.compareAndSet(false, true)) return; - invocations.forEach(invocation -> invocation.completion.cancel(true)); - if (closeTransportOnClose) transport.close(); + boolean active() { + return !closed.get() && !completion.isDone() && !remaining().isZero(); } + } } 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 6b0438ce..f11f508c 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 @@ -19,15 +19,14 @@ package dev.openan.workflow.engine.client; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - 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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Explicit application-bootstrap utility that copies key-value pairs from a {@code .env} file to @@ -35,73 +34,71 @@ * * <p>{@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}. + * 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 { - private static final Logger log = LoggerFactory.getLogger(EnvFileLoader.class); + private static final Logger log = LoggerFactory.getLogger(EnvFileLoader.class); - private EnvFileLoader() {} + private EnvFileLoader() {} - /** - * Parse a {@code .env} file and set each key as a system property, unless the key already - * exists as an OS environment variable. - * - * @param envFilePath path to the {@code .env} file - */ - public static void loadToSystemProperties(Path envFilePath) { - Map<String, String> values = read(envFilePath); - int count = 0; - for (Map.Entry<String, String> 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); - } + /** + * Parse a {@code .env} file and set each key as a system property, unless the key already exists + * as an OS environment variable. + * + * @param envFilePath path to the {@code .env} file + */ + public static void loadToSystemProperties(Path envFilePath) { + Map<String, String> values = read(envFilePath); + int count = 0; + for (Map.Entry<String, String> 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<String, String> read(Path envFilePath) { - if (envFilePath == null) { - return Map.of(); - } - if (!Files.exists(envFilePath)) { - log.debug("[EnvLoader] File not found: {}", envFilePath); - return Map.of(); - } - List<String> lines; - try { - lines = Files.readAllLines(envFilePath); - } catch (IOException e) { - log.warn("[EnvLoader] Failed to read {}: {}", envFilePath, e.getMessage()); - return Map.of(); - } - Map<String, String> values = new LinkedHashMap<>(); - for (String line : lines) { - String trimmed = line.trim(); - if (trimmed.isEmpty() || trimmed.startsWith("#")) { - continue; - } - int eq = trimmed.indexOf('='); - if (eq <= 0) { - continue; - } - String key = trimmed.substring(0, eq).trim(); - String value = trimmed.substring(eq + 1).trim(); - if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { - value = value.substring(1, value.length() - 1); - } - values.putIfAbsent(key, value); - } - return java.util.Collections.unmodifiableMap(values); + /** Reads an env file without mutating process-global state. */ + static Map<String, String> read(Path envFilePath) { + if (envFilePath == null) { + return Map.of(); + } + if (!Files.exists(envFilePath)) { + log.debug("[EnvLoader] File not found: {}", envFilePath); + return Map.of(); + } + List<String> lines; + try { + lines = Files.readAllLines(envFilePath); + } catch (IOException e) { + log.warn("[EnvLoader] Failed to read {}: {}", envFilePath, e.getMessage()); + return Map.of(); + } + Map<String, String> values = new LinkedHashMap<>(); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int eq = trimmed.indexOf('='); + if (eq <= 0) { + continue; + } + String key = trimmed.substring(0, eq).trim(); + String value = trimmed.substring(eq + 1).trim(); + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + values.putIfAbsent(key, value); } + return java.util.Collections.unmodifiableMap(values); + } } 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 989fcde0..097c741e 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 @@ -27,10 +27,12 @@ /** 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<SendMessageResult> sendAuthorization(String agentName, MessageContent content); + /** Sends final authorization content on this sender's independent transport. */ + CompletableFuture<SendMessageResult> sendAuthorization(String agentName, MessageContent content); - /** Registers a handle before starting I/O; early callbacks may close it directly. */ - NotificationSubscription openNotification(String agentName, MessageContent content, - BiConsumer<NotificationSubscription, ReceivedMessage> listener); + /** Registers a handle before starting I/O; early callbacks may close it directly. */ + NotificationSubscription openNotification( + String agentName, + MessageContent content, + BiConsumer<NotificationSubscription, ReceivedMessage> listener); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/HeaderContributor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/HeaderContributor.java index 30263b26..5875679c 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/HeaderContributor.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/HeaderContributor.java @@ -6,9 +6,8 @@ package dev.openan.workflow.engine.client; -import org.a2aproject.sdk.spec.AgentCard; - import java.util.Map; +import org.a2aproject.sdk.spec.AgentCard; /** * Supplies one isolated group of outbound A2A headers. Contributors return their own map so the @@ -16,9 +15,9 @@ */ interface HeaderContributor { - Map<String, String> contribute( - AgentCard agentCard, - String agentName, - Map<String, Object> messageMetadata, - Map<String, String> currentHeaders); + Map<String, String> contribute( + AgentCard agentCard, + String agentName, + Map<String, Object> messageMetadata, + Map<String, String> currentHeaders); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/JdkHttpClientFactory.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/JdkHttpClientFactory.java index 7348cb74..1ad93bdc 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/JdkHttpClientFactory.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/JdkHttpClientFactory.java @@ -14,31 +14,26 @@ /** Builds JDK HTTP clients from the engine's outbound TLS policy. */ final class JdkHttpClientFactory { - private JdkHttpClientFactory() {} + private JdkHttpClientFactory() {} - static HttpClient create( - boolean sslVerify, - String caCertsPath, - String clientCertPath, - String clientKeyPath, - String clientKeyPassword, - String crlPath, - Duration connectTimeout, - Executor executor) { - HttpClient.Builder builder = - HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .connectTimeout(connectTimeout) - .followRedirects(HttpClient.Redirect.ALWAYS); - if (executor != null) builder.executor(executor); - SslContextFactory.create( - sslVerify, - caCertsPath, - clientCertPath, - clientKeyPath, - clientKeyPassword, - crlPath) - .ifPresent(builder::sslContext); - return builder.build(); - } + static HttpClient create( + boolean sslVerify, + String caCertsPath, + String clientCertPath, + String clientKeyPath, + String clientKeyPassword, + String crlPath, + Duration connectTimeout, + Executor executor) { + HttpClient.Builder builder = + HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(connectTimeout) + .followRedirects(HttpClient.Redirect.ALWAYS); + if (executor != null) builder.executor(executor); + SslContextFactory.create( + sslVerify, caCertsPath, clientCertPath, clientKeyPath, clientKeyPassword, crlPath) + .ifPresent(builder::sslContext); + return builder.build(); + } } 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 index c8b61fb3..d4955099 100644 --- 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 @@ -7,12 +7,11 @@ 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.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; @@ -20,114 +19,111 @@ /** * Explicit lifecycle handle for one long-lived Notification-T subscription. * - * <p>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. + * <p>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<SendMessageResult> acknowledgement = new CompletableFuture<>(); - private final CompletableFuture<Void> completion = new CompletableFuture<>(); - private final CompletableFuture<Void> streamTermination = new CompletableFuture<>(); - private final Runnable closeAction; - private final AtomicBoolean closed = new AtomicBoolean(); - private final AtomicLong eventCount = new AtomicLong(); - private final AtomicReference<Instant> 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<SendMessageResult> acknowledgement() { - return acknowledgement; - } - - public CompletableFuture<Void> 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<Void> 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) {} + private final String agentName; + private final String contextId; + private final Instant openedAt = Instant.now(); + private final CompletableFuture<SendMessageResult> acknowledgement = new CompletableFuture<>(); + private final CompletableFuture<Void> completion = new CompletableFuture<>(); + private final CompletableFuture<Void> streamTermination = new CompletableFuture<>(); + private final Runnable closeAction; + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicLong eventCount = new AtomicLong(); + private final AtomicReference<Instant> 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<SendMessageResult> acknowledgement() { + return acknowledgement; + } + + public CompletableFuture<Void> 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<Void> 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 index ec387a52..f8aaf3ad 100644 --- 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 @@ -17,11 +17,8 @@ * 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.*; @@ -30,128 +27,313 @@ import java.util.*; import java.util.concurrent.*; import java.util.function.Consumer; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; /** 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<WireLog.Entry> observer; + private final HttpClient delegate; + private final Consumer<WireLog.Entry> observer; + + ObservedHttpClient(HttpClient delegate) { + this(delegate, null); + } + + ObservedHttpClient(HttpClient delegate, Consumer<WireLog.Entry> observer) { + this.delegate = Objects.requireNonNull(delegate); + this.observer = observer; + } + + @Override + public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> 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 <T> CompletableFuture<HttpResponse<T>> sendAsync( + HttpRequest request, HttpResponse.BodyHandler<T> handler) { + Exchange exchange = new Exchange(request); + try { + CompletableFuture<HttpResponse<T>> 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 <T> CompletableFuture<HttpResponse<T>> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler<T> handler, + HttpResponse.PushPromiseHandler<T> pushHandler) { + Exchange exchange = new Exchange(request); + try { + CompletableFuture<HttpResponse<T>> 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> cookieHandler() { + return delegate.cookieHandler(); + } + + @Override + public Optional<Duration> connectTimeout() { + return delegate.connectTimeout(); + } + + @Override + public Redirect followRedirects() { + return delegate.followRedirects(); + } + + @Override + public Optional<ProxySelector> proxy() { + return delegate.proxy(); + } + + @Override + public SSLContext sslContext() { + return delegate.sslContext(); + } + + @Override + public SSLParameters sslParameters() { + return delegate.sslParameters(); + } + + @Override + public Optional<Authenticator> authenticator() { + return delegate.authenticator(); + } + + @Override + public Version version() { + return delegate.version(); + } + + @Override + public Optional<Executor> executor() { + return delegate.executor(); + } + + private final class Exchange { + final String id = UUID.randomUUID().toString(); + final HttpRequest request; + final Map<String, String> context = WireLog.context(); + + Exchange(HttpRequest request) { + this.request = request; + emit("REQUEST_HEADERS", null, request.headers().map(), "headers", "", ""); + } + + void emit( + String direction, + Integer status, + Map<String, List<String>> 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); + } - ObservedHttpClient(HttpClient delegate) { this(delegate, null); } - ObservedHttpClient(HttpClient delegate, Consumer<WireLog.Entry> observer) { - this.delegate = Objects.requireNonNull(delegate); - this.observer = observer; + void failure(Throwable error) { + emit( + "FAILURE", + null, + Map.of(), + "transport-error", + "", + "errorType=" + error.getClass().getSimpleName()); } - private final class Exchange { - final String id = UUID.randomUUID().toString(); - final HttpRequest request; - final Map<String, String> context = WireLog.context(); - Exchange(HttpRequest request) { - this.request = request; - emit("REQUEST_HEADERS", null, request.headers().map(), "headers", "", ""); + HttpRequest observedRequest() { + return new HttpRequest() { + @Override + public Optional<BodyPublisher> bodyPublisher() { + return request + .bodyPublisher() + .map( + original -> + new BodyPublisher() { + @Override + public long contentLength() { + return original.contentLength(); + } + + @Override + public void subscribe(Flow.Subscriber<? super ByteBuffer> 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<ByteBuffer>() { + @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(); + } + }); + } + }); } - void emit(String direction, Integer status, Map<String, List<String>> 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); + + @Override + public String method() { + return request.method(); } - void failure(Throwable error) { - emit("FAILURE", null, Map.of(), "transport-error", "", - "errorType=" + error.getClass().getSimpleName()); + + @Override + public Optional<Duration> timeout() { + return request.timeout(); } - HttpRequest observedRequest() { - return new HttpRequest() { - @Override public Optional<BodyPublisher> bodyPublisher() { - return request.bodyPublisher().map(original -> new BodyPublisher() { - @Override public long contentLength() { return original.contentLength(); } - @Override public void subscribe(Flow.Subscriber<? super ByteBuffer> 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<ByteBuffer>() { - @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<Duration> timeout() { return request.timeout(); } - @Override public boolean expectContinue() { return request.expectContinue(); } - @Override public URI uri() { return request.uri(); } - @Override public Optional<Version> version() { return request.version(); } - @Override public HttpHeaders headers() { return request.headers(); } - }; + + @Override + public boolean expectContinue() { + return request.expectContinue(); } - <T> HttpResponse.BodyHandler<T> handler(HttpResponse.BodyHandler<T> 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<T> target = handler.apply(info); - return new HttpResponse.BodySubscriber<T>() { - @Override public CompletionStage<T> 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<ByteBuffer> 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 URI uri() { + return request.uri(); } - } - @Override public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> 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 Optional<Version> version() { + return request.version(); + } - @Override public <T> CompletableFuture<HttpResponse<T>> sendAsync( - HttpRequest request, HttpResponse.BodyHandler<T> handler) { - Exchange exchange = new Exchange(request); - try { - CompletableFuture<HttpResponse<T>> 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 HttpHeaders headers() { + return request.headers(); + } + }; } - @Override public <T> CompletableFuture<HttpResponse<T>> sendAsync( - HttpRequest request, HttpResponse.BodyHandler<T> handler, - HttpResponse.PushPromiseHandler<T> pushHandler) { - Exchange exchange = new Exchange(request); - try { - CompletableFuture<HttpResponse<T>> 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; } - } + <T> HttpResponse.BodyHandler<T> handler(HttpResponse.BodyHandler<T> 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<T> target = handler.apply(info); + return new HttpResponse.BodySubscriber<T>() { + @Override + public CompletionStage<T> 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 Optional<CookieHandler> cookieHandler() { return delegate.cookieHandler(); } - @Override public Optional<Duration> connectTimeout() { return delegate.connectTimeout(); } - @Override public Redirect followRedirects() { return delegate.followRedirects(); } - @Override public Optional<ProxySelector> proxy() { return delegate.proxy(); } - @Override public SSLContext sslContext() { return delegate.sslContext(); } - @Override public SSLParameters sslParameters() { return delegate.sslParameters(); } - @Override public Optional<Authenticator> authenticator() { return delegate.authenticator(); } - @Override public Version version() { return delegate.version(); } - @Override public Optional<Executor> executor() { return delegate.executor(); } + @Override + public void onNext(List<ByteBuffer> 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(); + } + }; + }; + } + } } 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 762757ad..7ca1f28b 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 @@ -24,7 +24,8 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; - +import java.time.OffsetDateTime; +import java.util.Map; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; import org.a2aproject.sdk.client.TaskEvent; @@ -35,149 +36,137 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.time.OffsetDateTime; -import java.util.Map; - /** 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_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 Logger log = LoggerFactory.getLogger("PROTOCOL"); + 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 ObjectMapper mapper = - new ObjectMapper() - .enable(SerializationFeature.INDENT_OUTPUT) - .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL) - .registerModule( - new SimpleModule() - .addSerializer( - OffsetDateTime.class, ToStringSerializer.instance)); + private static final ObjectMapper mapper = + new ObjectMapper() + .enable(SerializationFeature.INDENT_OUTPUT) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL) + .registerModule( + new SimpleModule().addSerializer(OffsetDateTime.class, ToStringSerializer.instance)); - private ProtocolLogger() {} + private ProtocolLogger() {} - /** - * Log the full request (headers + body) before sending to an agent. - * - * @param agentName target agent display name - * @param endpoint agent URL - * @param params the message send parameters (protocol body) - * @param headers HTTP headers from ClientCallContext - */ - static void logRequest( - String agentName, - String endpoint, - MessageSendParams params, - Map<String, String> headers) { - if (!log.isDebugEnabled() || !booleanSetting("WORKFLOW_ENGINE_PROTOCOL_MODEL_PREVIEW", false)) { - return; - } - try { - String bodyJson = formatBody(mapper.writeValueAsString(params)); - log.debug( - "MODEL_PREVIEW >>> [{}] REQUEST to {}\n=== Headers ===\n{}\n=== Body ===\n{}", - agentName, - endpoint, - formatHeaders(headers), - bodyJson); - } catch (Exception e) { - log.warn(">>> [{}] Failed to serialize request: {}", agentName, e.getMessage()); - } + /** + * Log the full request (headers + body) before sending to an agent. + * + * @param agentName target agent display name + * @param endpoint agent URL + * @param params the message send parameters (protocol body) + * @param headers HTTP headers from ClientCallContext + */ + static void logRequest( + String agentName, String endpoint, MessageSendParams params, Map<String, String> headers) { + if (!log.isDebugEnabled() || !booleanSetting("WORKFLOW_ENGINE_PROTOCOL_MODEL_PREVIEW", false)) { + return; } - - /** - * Log each response event (full payload) received from an agent. - * - * @param agentName source agent display name - * @param event the received client event - */ - static void logResponseEvent(String agentName, ClientEvent event) { - 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.debug( - "MODEL_PREVIEW <<< [{}] RESPONSE [{}]: (no serializable payload)", agentName, eventType); - return; - } - String json = formatBody(mapper.writeValueAsString(payload)); - log.debug("MODEL_PREVIEW <<< [{}] RESPONSE [{}]\n{}", agentName, eventType, json); - } catch (Exception e) { - log.warn("<<< [{}] Failed to serialize response event: {}", agentName, e.getMessage()); - } + try { + String bodyJson = formatBody(mapper.writeValueAsString(params)); + log.debug( + "MODEL_PREVIEW >>> [{}] REQUEST to {}\n=== Headers ===\n{}\n=== Body ===\n{}", + agentName, + endpoint, + formatHeaders(headers), + bodyJson); + } catch (Exception e) { + log.warn(">>> [{}] Failed to serialize request: {}", agentName, e.getMessage()); } + } - /** - * Extract the serializable protocol payload from a ClientEvent. Returns the inner SDK spec - * object (Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, or Message) rather than the - * event wrapper. - */ - private static Object extractPayload(ClientEvent event) { - if (event instanceof TaskEvent te) { - return te.getTask(); - } - if (event instanceof TaskUpdateEvent tue) { - if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { - return sue; - } - if (tue.getUpdateEvent() instanceof TaskArtifactUpdateEvent ae) { - return ae; - } - return tue.getTask(); - } - if (event instanceof MessageEvent me) { - return me.getMessage(); - } - return null; + /** + * Log each response event (full payload) received from an agent. + * + * @param agentName source agent display name + * @param event the received client event + */ + static void logResponseEvent(String agentName, ClientEvent event) { + if (!log.isDebugEnabled() || !booleanSetting("WORKFLOW_ENGINE_PROTOCOL_MODEL_PREVIEW", false)) { + return; } - - /** Format headers map as "Key: Value" lines for readable logging. */ - static String formatHeaders(Map<String, String> headers) { - if (headers == null || headers.isEmpty()) { - return "(none)"; - } - StringBuilder sb = new StringBuilder(); - headers.forEach( - (k, v) -> { - sb.append(k) - .append(": ") - .append(WireLog.sensitive(k) ? "***" : v) - .append("\n"); - }); - return sb.toString().trim(); + try { + Object payload = extractPayload(event); + String eventType = event.getClass().getSimpleName(); + if (payload == null) { + log.debug( + "MODEL_PREVIEW <<< [{}] RESPONSE [{}]: (no serializable payload)", + agentName, + eventType); + return; + } + String json = formatBody(mapper.writeValueAsString(payload)); + log.debug("MODEL_PREVIEW <<< [{}] RESPONSE [{}]\n{}", agentName, eventType, json); + } catch (Exception e) { + log.warn("<<< [{}] Failed to serialize response event: {}", agentName, e.getMessage()); } + } - 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) - + "\n... (truncated, originalChars=" - + body.length() - + ")"; + /** + * Extract the serializable protocol payload from a ClientEvent. Returns the inner SDK spec object + * (Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, or Message) rather than the event + * wrapper. + */ + private static Object extractPayload(ClientEvent event) { + if (event instanceof TaskEvent te) { + return te.getTask(); + } + if (event instanceof TaskUpdateEvent tue) { + if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { + return sue; + } + if (tue.getUpdateEvent() instanceof TaskArtifactUpdateEvent ae) { + return ae; + } + return tue.getTask(); } + if (event instanceof MessageEvent me) { + return me.getMessage(); + } + return null; + } - private static boolean booleanSetting(String name, boolean defaultValue) { - String value = System.getProperty(name); - if (value == null || value.isBlank()) value = System.getenv(name); - return value == null || value.isBlank() ? defaultValue : Boolean.parseBoolean(value); + /** Format headers map as "Key: Value" lines for readable logging. */ + static String formatHeaders(Map<String, String> headers) { + if (headers == null || headers.isEmpty()) { + return "(none)"; } + StringBuilder sb = new StringBuilder(); + headers.forEach( + (k, v) -> { + sb.append(k).append(": ").append(WireLog.sensitive(k) ? "***" : v).append("\n"); + }); + return sb.toString().trim(); + } + + 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) + "\n... (truncated, originalChars=" + body.length() + ")"; + } + + private static boolean booleanSetting(String name, boolean defaultValue) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) value = System.getenv(name); + return value == null || value.isBlank() ? defaultValue : Boolean.parseBoolean(value); + } - private static int intSetting(String name, int defaultValue) { - String value = System.getProperty(name); - if (value == null || value.isBlank()) value = System.getenv(name); - if (value == null || value.isBlank()) return defaultValue; - try { - int parsed = Integer.parseInt(value); - return parsed > 0 ? parsed : defaultValue; - } catch (NumberFormatException ignored) { - return defaultValue; - } + private static int intSetting(String name, int defaultValue) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) value = System.getenv(name); + if (value == null || value.isBlank()) return defaultValue; + try { + int parsed = Integer.parseInt(value); + return parsed > 0 ? parsed : defaultValue; + } catch (NumberFormatException ignored) { + return defaultValue; } + } } 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 index 0e034a4c..b679853c 100644 --- 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 @@ -21,100 +21,110 @@ import dev.openan.workflow.engine.model.MessageContent; import dev.openan.workflow.engine.model.ReceivedMessage; +import java.util.*; 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() {} + private ProtocolResponses() {} - /** Reduces ordered SDK events to final response snapshots. */ - public static List<ReceivedMessage> assemble(Iterable<ClientEvent> events) { - Accumulator accumulator = new Accumulator(); - events.forEach(accumulator::accept); - return accumulator.snapshots(); - } + /** Reduces ordered SDK events to final response snapshots. */ + public static List<ReceivedMessage> assemble(Iterable<ClientEvent> 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<String, Artifact> artifacts = new LinkedHashMap<>(); - private final Map<String, MessageContent> messages = new LinkedHashMap<>(); - private Map<String, Object> taskMetadata = Map.of(); - private MessageContent statusMessage; - private boolean hasTask; + static MessageContent content(Message message) { + return message == null + ? null + : new MessageContent( + message.parts(), + message.metadata(), + message.extensions() == null ? Set.of() : new LinkedHashSet<>(message.extensions())); + } - 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)); - } - } - } - } + 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; + } - 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); - } - } - } + /** Per-stream accumulator, never shared across tasks or subscriptions. */ + static final class Accumulator { + private final Map<String, Artifact> artifacts = new LinkedHashMap<>(); + private final Map<String, MessageContent> messages = new LinkedHashMap<>(); + private Map<String, Object> taskMetadata = Map.of(); + private MessageContent statusMessage; + private boolean hasTask; - private void append(TaskArtifactUpdateEvent event) { - Artifact value = event.artifact(); - Artifact previous = artifacts.get(value.artifactId()); - if (Boolean.TRUE.equals(event.append()) && previous != null) { - List<Part<?>> parts = new ArrayList<>(previous.parts()); - parts.addAll(value.parts()); - Map<String, Object> 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); + 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)); + } } + } + } - List<ReceivedMessage> snapshots() { - List<ReceivedMessage> 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); + 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); } + } } - static MessageContent content(Message message) { - return message == null ? null : new MessageContent( - message.parts(), message.metadata(), - message.extensions() == null ? Set.of() : new LinkedHashSet<>(message.extensions())); + private void append(TaskArtifactUpdateEvent event) { + Artifact value = event.artifact(); + Artifact previous = artifacts.get(value.artifactId()); + if (Boolean.TRUE.equals(event.append()) && previous != null) { + List<Part<?>> parts = new ArrayList<>(previous.parts()); + parts.addAll(value.parts()); + Map<String, Object> 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); } - 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; + List<ReceivedMessage> snapshots() { + List<ReceivedMessage> 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); } + } } 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 deleted file mode 100644 index 3fe4f7c9..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SseNormalization.java +++ /dev/null @@ -1,91 +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 com.fasterxml.jackson.databind.ObjectMapper; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; -import java.util.Set; - -/** - * SSE response normalization for non-standard agent responses. - * - * <p>Mirrors the Python SDK's {@code sse_normalization}. Some A2A agents return bare Task or - * Message objects instead of properly wrapped SSE envelopes. This utility coerces such responses - * into the expected shape. - * - * <p>Unlike the Python version (which monkey-patches protobuf's Parse), the Java version provides a - * standalone static method that the DefaultWorkflowEngineClient calls after deserializing each - * response. - */ -final class SseNormalization { - - private static final Logger log = LoggerFactory.getLogger(SseNormalization.class); - private static final ObjectMapper mapper = new ObjectMapper(); - - private static final Set<String> STREAM_RESPONSE_KEYS = - Set.of("task", "message", "statusUpdate", "artifactUpdate"); - - private SseNormalization() {} - - /** - * Coerce a non-SSE response map into a StreamResponse-shaped map. - * - * <p>If the data already has StreamResponse keys, it is returned as-is. If it looks like a bare - * Task, it is wrapped in a {@code "task"} key. If it looks like an Artifact update, it is - * wrapped in {@code "artifactUpdate"}. - * - * @param data the raw response as a map - * @return normalized map - */ - public static Map<String, Object> normalize(Map<String, Object> 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); - } - // 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<String, Object> 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); - } - } - return data; - } -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SslContextFactory.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SslContextFactory.java index 33ccc8ac..c3ed979b 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SslContextFactory.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SslContextFactory.java @@ -19,9 +19,6 @@ package dev.openan.workflow.engine.client; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.FileInputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -39,7 +36,6 @@ import java.util.Collection; import java.util.List; import java.util.Optional; - import javax.crypto.Cipher; import javax.crypto.EncryptedPrivateKeyInfo; import javax.crypto.SecretKey; @@ -48,6 +44,8 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * SSL context factory for outbound HTTPS calls. @@ -58,237 +56,231 @@ */ public final class SslContextFactory { - private static final Logger log = LoggerFactory.getLogger(SslContextFactory.class); + private static final Logger log = LoggerFactory.getLogger(SslContextFactory.class); - private SslContextFactory() {} + private SslContextFactory() {} - /** - * Build an SSLContext for outbound HTTPS. - * - * @param verifyServer whether to verify remote server certificates - * @param caCertsPath optional path to a PEM CA trust store file - * @return the configured SSLContext - */ - public static Optional<SSLContext> create(boolean verifyServer, String caCertsPath) { - return create(verifyServer, caCertsPath, null, null, null, null); - } + /** + * Build an SSLContext for outbound HTTPS. + * + * @param verifyServer whether to verify remote server certificates + * @param caCertsPath optional path to a PEM CA trust store file + * @return the configured SSLContext + */ + public static Optional<SSLContext> create(boolean verifyServer, String caCertsPath) { + return create(verifyServer, caCertsPath, null, null, null, null); + } - /** - * Build an SSLContext for outbound HTTPS with full mTLS support. - * - * @param verifyServer whether to verify remote server certificates - * @param caCertsPath optional path to a PEM CA trust store file - * @param certPath optional path to client certificate (for mTLS) - * @param keyPath optional path to client private key (for mTLS) - * @param keyPassword optional password for the private key - * @param crlPath optional path to a CRL file - * @return the configured SSLContext - */ - public static Optional<SSLContext> create( - boolean verifyServer, - String caCertsPath, - String certPath, - String keyPath, - String keyPassword, - String crlPath) { - try { - javax.net.ssl.KeyManager[] keyManagers = - loadKeyManagers(certPath, keyPath, keyPassword); - X509TrustManager trustManager = - verifyServer - ? createTrustManager(caCertsPath) - : trustAllManager(); - trustManager = applyCrl(trustManager, crlPath); - SSLContext ctx = SSLContext.getInstance("TLS"); - ctx.init(keyManagers, new TrustManager[] {trustManager}, null); - log.info( - "Client SSL: context initialized (verifyServer={}, ca_certs={})", - verifyServer, - caCertsPath); - return Optional.of(ctx); - } catch (Exception e) { - throw new IllegalStateException("Failed to build outbound TLS context", e); - } + /** + * Build an SSLContext for outbound HTTPS with full mTLS support. + * + * @param verifyServer whether to verify remote server certificates + * @param caCertsPath optional path to a PEM CA trust store file + * @param certPath optional path to client certificate (for mTLS) + * @param keyPath optional path to client private key (for mTLS) + * @param keyPassword optional password for the private key + * @param crlPath optional path to a CRL file + * @return the configured SSLContext + */ + public static Optional<SSLContext> create( + boolean verifyServer, + String caCertsPath, + String certPath, + String keyPath, + String keyPassword, + String crlPath) { + try { + javax.net.ssl.KeyManager[] keyManagers = loadKeyManagers(certPath, keyPath, keyPassword); + X509TrustManager trustManager = + verifyServer ? createTrustManager(caCertsPath) : trustAllManager(); + trustManager = applyCrl(trustManager, crlPath); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(keyManagers, new TrustManager[] {trustManager}, null); + log.info( + "Client SSL: context initialized (verifyServer={}, ca_certs={})", + verifyServer, + caCertsPath); + return Optional.of(ctx); + } catch (Exception e) { + throw new IllegalStateException("Failed to build outbound TLS context", e); } + } - private static X509TrustManager createTrustManager(String caCertsPath) throws Exception { - if (caCertsPath != null && !caCertsPath.isEmpty()) { - try (FileInputStream fis = new FileInputStream(caCertsPath)) { - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(null, null); - int i = 0; - for (Certificate cert : cf.generateCertificates(fis).toArray(new Certificate[0])) { - ks.setCertificateEntry("ca-" + i, cert); - i++; - } - if (i == 0) throw new IllegalArgumentException("CA file contains no certificates"); - log.info("Client SSL: loaded CA trust store from {}", caCertsPath); - javax.net.ssl.TrustManagerFactory tmf = - javax.net.ssl.TrustManagerFactory.getInstance( - javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); - tmf.init(ks); - return (X509TrustManager) tmf.getTrustManagers()[0]; - } + private static X509TrustManager createTrustManager(String caCertsPath) throws Exception { + if (caCertsPath != null && !caCertsPath.isEmpty()) { + try (FileInputStream fis = new FileInputStream(caCertsPath)) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + int i = 0; + for (Certificate cert : cf.generateCertificates(fis).toArray(new Certificate[0])) { + ks.setCertificateEntry("ca-" + i, cert); + i++; } + if (i == 0) throw new IllegalArgumentException("CA file contains no certificates"); + log.info("Client SSL: loaded CA trust store from {}", caCertsPath); javax.net.ssl.TrustManagerFactory tmf = - javax.net.ssl.TrustManagerFactory.getInstance( - javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); - tmf.init((KeyStore) null); + javax.net.ssl.TrustManagerFactory.getInstance( + javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); return (X509TrustManager) tmf.getTrustManagers()[0]; + } } + javax.net.ssl.TrustManagerFactory tmf = + javax.net.ssl.TrustManagerFactory.getInstance( + javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + tmf.init((KeyStore) null); + return (X509TrustManager) tmf.getTrustManagers()[0]; + } - private static javax.net.ssl.KeyManager[] loadKeyManagers( - String certPath, String keyPath, String keyPassword) throws Exception { - boolean hasCert = certPath != null && !certPath.isBlank(); - boolean hasKey = keyPath != null && !keyPath.isBlank(); - if (!hasCert && !hasKey) return null; - if (!hasCert || !hasKey) { - throw new IllegalArgumentException( - "Both client certificate and private key are required for mTLS"); - } - Path certificateFile = Path.of(certPath); - Path privateKeyFile = Path.of(keyPath); - if (!Files.isRegularFile(certificateFile) || !Files.isRegularFile(privateKeyFile)) { - throw new IllegalArgumentException("mTLS certificate or private-key file not found"); - } - - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - List<Certificate> chain; - try (FileInputStream certFis = new FileInputStream(certificateFile.toFile())) { - chain = new ArrayList<>(cf.generateCertificates(certFis)); - } - if (chain.isEmpty()) throw new IllegalArgumentException("mTLS certificate chain is empty"); + private static javax.net.ssl.KeyManager[] loadKeyManagers( + String certPath, String keyPath, String keyPassword) throws Exception { + boolean hasCert = certPath != null && !certPath.isBlank(); + boolean hasKey = keyPath != null && !keyPath.isBlank(); + if (!hasCert && !hasKey) return null; + if (!hasCert || !hasKey) { + throw new IllegalArgumentException( + "Both client certificate and private key are required for mTLS"); + } + Path certificateFile = Path.of(certPath); + Path privateKeyFile = Path.of(keyPath); + if (!Files.isRegularFile(certificateFile) || !Files.isRegularFile(privateKeyFile)) { + throw new IllegalArgumentException("mTLS certificate or private-key file not found"); + } - PrivateKey privateKey = parsePkcs8PrivateKey(privateKeyFile, keyPassword); - char[] password = keyPassword != null ? keyPassword.toCharArray() : new char[0]; - KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - keyStore.load(null, null); - keyStore.setKeyEntry( - "client-key", privateKey, password, chain.toArray(new Certificate[0])); - javax.net.ssl.KeyManagerFactory kmf = - javax.net.ssl.KeyManagerFactory.getInstance( - javax.net.ssl.KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(keyStore, password); - log.info("Client SSL: loaded client identity certificate chain for mTLS"); - return kmf.getKeyManagers(); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + List<Certificate> chain; + try (FileInputStream certFis = new FileInputStream(certificateFile.toFile())) { + chain = new ArrayList<>(cf.generateCertificates(certFis)); } + if (chain.isEmpty()) throw new IllegalArgumentException("mTLS certificate chain is empty"); - private static PrivateKey parsePkcs8PrivateKey(Path keyPath, String keyPassword) - throws Exception { - byte[] raw = Files.readAllBytes(keyPath); - String text = new String(raw, StandardCharsets.US_ASCII); - byte[] der = raw; - if (text.contains("-----BEGIN")) { - boolean encrypted = text.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----"); - if (!encrypted && !text.contains("-----BEGIN PRIVATE KEY-----")) { - throw new IllegalArgumentException( - "Only PKCS#8 PEM private keys are supported"); - } - String base64 = - text.replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replace("-----BEGIN ENCRYPTED PRIVATE KEY-----", "") - .replace("-----END ENCRYPTED PRIVATE KEY-----", "") - .replaceAll("\\s", ""); - der = Base64.getDecoder().decode(base64); - if (encrypted) der = decryptPkcs8PrivateKey(der, keyPassword); - } - PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); - Exception last = null; - for (String algorithm : List.of("RSA", "EC", "Ed25519")) { - try { - return KeyFactory.getInstance(algorithm).generatePrivate(spec); - } catch (Exception e) { - last = e; - } - } - throw new IllegalArgumentException("Unsupported PKCS#8 private-key algorithm", last); + PrivateKey privateKey = parsePkcs8PrivateKey(privateKeyFile, keyPassword); + char[] password = keyPassword != null ? keyPassword.toCharArray() : new char[0]; + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + keyStore.setKeyEntry("client-key", privateKey, password, chain.toArray(new Certificate[0])); + javax.net.ssl.KeyManagerFactory kmf = + javax.net.ssl.KeyManagerFactory.getInstance( + javax.net.ssl.KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, password); + log.info("Client SSL: loaded client identity certificate chain for mTLS"); + return kmf.getKeyManagers(); + } + + private static PrivateKey parsePkcs8PrivateKey(Path keyPath, String keyPassword) + throws Exception { + byte[] raw = Files.readAllBytes(keyPath); + String text = new String(raw, StandardCharsets.US_ASCII); + byte[] der = raw; + if (text.contains("-----BEGIN")) { + boolean encrypted = text.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----"); + if (!encrypted && !text.contains("-----BEGIN PRIVATE KEY-----")) { + throw new IllegalArgumentException("Only PKCS#8 PEM private keys are supported"); + } + String base64 = + text.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN ENCRYPTED PRIVATE KEY-----", "") + .replace("-----END ENCRYPTED PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + der = Base64.getDecoder().decode(base64); + if (encrypted) der = decryptPkcs8PrivateKey(der, keyPassword); + } + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); + Exception last = null; + for (String algorithm : List.of("RSA", "EC", "Ed25519")) { + try { + return KeyFactory.getInstance(algorithm).generatePrivate(spec); + } catch (Exception e) { + last = e; + } } + throw new IllegalArgumentException("Unsupported PKCS#8 private-key algorithm", last); + } - private static byte[] decryptPkcs8PrivateKey(byte[] encrypted, String keyPassword) - throws Exception { - if (keyPassword == null || keyPassword.isEmpty()) { - throw new IllegalArgumentException("Encrypted private key requires a password"); - } - EncryptedPrivateKeyInfo encryptedInfo = new EncryptedPrivateKeyInfo(encrypted); - SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(encryptedInfo.getAlgName()); - SecretKey key = keyFactory.generateSecret(new PBEKeySpec(keyPassword.toCharArray())); - Cipher cipher = Cipher.getInstance(encryptedInfo.getAlgName()); - cipher.init(Cipher.DECRYPT_MODE, key, encryptedInfo.getAlgParameters()); - return encryptedInfo.getKeySpec(cipher).getEncoded(); + private static byte[] decryptPkcs8PrivateKey(byte[] encrypted, String keyPassword) + throws Exception { + if (keyPassword == null || keyPassword.isEmpty()) { + throw new IllegalArgumentException("Encrypted private key requires a password"); } + EncryptedPrivateKeyInfo encryptedInfo = new EncryptedPrivateKeyInfo(encrypted); + SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(encryptedInfo.getAlgName()); + SecretKey key = keyFactory.generateSecret(new PBEKeySpec(keyPassword.toCharArray())); + Cipher cipher = Cipher.getInstance(encryptedInfo.getAlgName()); + cipher.init(Cipher.DECRYPT_MODE, key, encryptedInfo.getAlgParameters()); + return encryptedInfo.getKeySpec(cipher).getEncoded(); + } - private static X509TrustManager applyCrl(X509TrustManager delegate, String crlPath) - throws Exception { - if (crlPath == null || crlPath.isBlank()) return delegate; - Path file = Path.of(crlPath); - if (!Files.isRegularFile(file)) { - throw new IllegalArgumentException("CRL file not found: " + crlPath); - } - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - Collection<? extends java.security.cert.CRL> parsed; - try (FileInputStream input = new FileInputStream(file.toFile())) { - parsed = cf.generateCRLs(input); - } - List<X509CRL> crls = - parsed.stream().filter(X509CRL.class::isInstance).map(X509CRL.class::cast).toList(); - if (crls.isEmpty()) throw new IllegalArgumentException("CRL file contains no X.509 CRL"); - log.info("Client SSL: loaded {} CRL(s) from {}", crls.size(), crlPath); - return new RevocationCheckingTrustManager(delegate, crls); + private static X509TrustManager applyCrl(X509TrustManager delegate, String crlPath) + throws Exception { + if (crlPath == null || crlPath.isBlank()) return delegate; + Path file = Path.of(crlPath); + if (!Files.isRegularFile(file)) { + throw new IllegalArgumentException("CRL file not found: " + crlPath); } + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Collection<? extends java.security.cert.CRL> parsed; + try (FileInputStream input = new FileInputStream(file.toFile())) { + parsed = cf.generateCRLs(input); + } + List<X509CRL> crls = + parsed.stream().filter(X509CRL.class::isInstance).map(X509CRL.class::cast).toList(); + if (crls.isEmpty()) throw new IllegalArgumentException("CRL file contains no X.509 CRL"); + log.info("Client SSL: loaded {} CRL(s) from {}", crls.size(), crlPath); + return new RevocationCheckingTrustManager(delegate, crls); + } - public static SSLContext createTrustAll() { - try { - SSLContext ctx = SSLContext.getInstance("TLS"); - ctx.init(null, new TrustManager[] {trustAllManager()}, null); - log.warn( - "Outbound TLS certificate-chain verification disabled; hostname verification remains enabled"); - return ctx; - } catch (Exception e) { - throw new IllegalStateException("Failed to create trust-all SSL context", e); - } + public static SSLContext createTrustAll() { + try { + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, new TrustManager[] {trustAllManager()}, null); + log.warn( + "Outbound TLS certificate-chain verification disabled; hostname verification remains enabled"); + return ctx; + } catch (Exception e) { + throw new IllegalStateException("Failed to create trust-all SSL context", e); } + } - private static X509TrustManager trustAllManager() { - return new X509TrustManager() { - public void checkClientTrusted(X509Certificate[] chain, String authType) {} + private static X509TrustManager trustAllManager() { + return new X509TrustManager() { + public void checkClientTrusted(X509Certificate[] chain, String authType) {} - public void checkServerTrusted(X509Certificate[] chain, String authType) {} + public void checkServerTrusted(X509Certificate[] chain, String authType) {} - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - }; - } + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + } - private record RevocationCheckingTrustManager( - X509TrustManager delegate, List<X509CRL> crls) implements X509TrustManager { + private record RevocationCheckingTrustManager(X509TrustManager delegate, List<X509CRL> crls) + implements X509TrustManager { - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) - throws java.security.cert.CertificateException { - delegate.checkClientTrusted(chain, authType); - } + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws java.security.cert.CertificateException { + delegate.checkClientTrusted(chain, authType); + } - @Override - public void checkServerTrusted(X509Certificate[] chain, String authType) - throws java.security.cert.CertificateException { - delegate.checkServerTrusted(chain, authType); - for (X509Certificate certificate : chain) { - for (X509CRL crl : crls) { - if (crl.isRevoked(certificate)) { - throw new java.security.cert.CertificateException( - "Server certificate has been revoked: " - + certificate.getSerialNumber()); - } - } - } + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) + throws java.security.cert.CertificateException { + delegate.checkServerTrusted(chain, authType); + for (X509Certificate certificate : chain) { + for (X509CRL crl : crls) { + if (crl.isRevoked(certificate)) { + throw new java.security.cert.CertificateException( + "Server certificate has been revoked: " + certificate.getSerialNumber()); + } } + } + } - @Override - public X509Certificate[] getAcceptedIssuers() { - return delegate.getAcceptedIssuers(); - } + @Override + public X509Certificate[] getAcceptedIssuers() { + return delegate.getAcceptedIssuers(); } + } } 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 index 5a93ee2a..6e3bcc4e 100644 --- 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 @@ -17,7 +17,6 @@ * under the License. */ - package dev.openan.workflow.engine.client; import com.google.protobuf.MessageOrBuilder; @@ -28,70 +27,103 @@ /** Captures actual gRPC metadata and protobuf values, never labels them HTTP JSON. */ final class WireGrpcInterceptor implements ClientInterceptor { - private final Consumer<WireLog.Entry> observer; - WireGrpcInterceptor() { this(null); } - WireGrpcInterceptor(Consumer<WireLog.Entry> observer) { this.observer = observer; } + private final Consumer<WireLog.Entry> observer; + + WireGrpcInterceptor() { + this(null); + } - @Override public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall( - MethodDescriptor<ReqT, RespT> method, CallOptions options, Channel channel) { - String requestId = UUID.randomUUID().toString(); - Map<String, String> context = WireLog.context(); - return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(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<RespT> listener, Metadata headers) { - emit("REQUEST_HEADERS", headers, null, ""); - super.start(new ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT>(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); - } - }; + WireGrpcInterceptor(Consumer<WireLog.Entry> observer) { + this.observer = observer; + } + + private static Map<String, List<String>> headers(Metadata metadata) { + Map<String, List<String>> result = new LinkedHashMap<>(); + if (metadata == null) return result; + for (String name : metadata.keys()) { + List<String> values = new ArrayList<>(); + if (name.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Iterable<byte[]> 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<String> raw = + metadata.getAll(Metadata.Key.of(name, Metadata.ASCII_STRING_MARSHALLER)); + if (raw != null) raw.forEach(values::add); + } + result.put(name, values); } + return result; + } - private static Map<String, List<String>> headers(Metadata metadata) { - Map<String, List<String>> result = new LinkedHashMap<>(); - if (metadata == null) return result; - for (String name : metadata.keys()) { - List<String> values = new ArrayList<>(); - if (name.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { - Iterable<byte[]> 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<String> raw = metadata.getAll(Metadata.Key.of(name, Metadata.ASCII_STRING_MARSHALLER)); - if (raw != null) raw.forEach(values::add); - } - result.put(name, values); + @Override + public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall( + MethodDescriptor<ReqT, RespT> method, CallOptions options, Channel channel) { + String requestId = UUID.randomUUID().toString(); + Map<String, String> context = WireLog.context(); + return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>( + 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(); } - return result; - } + 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<RespT> listener, Metadata headers) { + emit("REQUEST_HEADERS", headers, null, ""); + super.start( + new ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT>(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); + } + }; + } } 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 index 58008e35..ed0c786d 100644 --- 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 @@ -17,221 +17,320 @@ * 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; +import org.slf4j.LoggerFactory; /** 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<Map<String, String>> 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<String, List<String>> headers, String representation, - String body, String visibility, Map<String, String> correlation) {} - - public static Map<String, String> context() { return CONTEXT.get(); } - - public static <T> T call(Map<String, String> context, java.util.function.Supplier<T> action) { - Map<String, String> previous = CONTEXT.get(); - CONTEXT.set(Map.copyOf(context)); - try { return action.get(); } finally { CONTEXT.set(previous); } - } + private static final org.slf4j.Logger LOG = LoggerFactory.getLogger("PROTOCOL"); + private static final ThreadLocal<Map<String, String>> 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]*"); + + private WireLog() {} + + public static Map<String, String> context() { + return CONTEXT.get(); + } - /** Local-only correlation; never inserted into a protocol header or message metadata. */ - public static void inContext(Map<String, String> context, Runnable action) { - Map<String, String> previous = CONTEXT.get(); - CONTEXT.set(Map.copyOf(context)); - try { action.run(); } finally { CONTEXT.set(previous); } + public static <T> T call(Map<String, String> context, java.util.function.Supplier<T> action) { + Map<String, String> previous = CONTEXT.get(); + CONTEXT.set(Map.copyOf(context)); + try { + return action.get(); + } 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); + /** Local-only correlation; never inserted into a protocol header or message metadata. */ + public static void inContext(Map<String, String> context, Runnable action) { + Map<String, String> previous = CONTEXT.get(); + CONTEXT.set(Map.copyOf(context)); + try { + action.run(); + } finally { + CONTEXT.set(previous); } + } - 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; } + 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***"); + 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; + /** 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; } - // Incomplete secret values are redacted to the end, never logged as a raw prefix. - return text.length(); + } 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"); - } + static boolean sensitive(String name) { + String key = name.toLowerCase(Locale.ROOT); + // URI-shaped keys are protocol identifiers (e.g. the Authorization-T extension URI in message + // metadata), not credential fields; their values are business content and must stay visible. + if (key.contains("://")) return false; + 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<String, List<String>> safeHeaders(Map<String, List<String>> headers) { - Map<String, List<String>> result = new LinkedHashMap<>(); - headers.forEach((key, values) -> result.put(key, - sensitive(key) ? List.of("***") : List.copyOf(values))); - return Collections.unmodifiableMap(result); - } + public static Map<String, List<String>> safeHeaders(Map<String, List<String>> headers) { + Map<String, List<String>> 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<String, List<String>> headers, + String representation, + String body, + String visibility) { + emit( + null, + boundary, + direction, + requestId, + target, + method, + status, + headers, + representation, + body, + visibility, + context()); + } - /** 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<String, List<String>> headers, - String representation, String body, String visibility) { - emit(null, boundary, direction, requestId, target, method, status, headers, - representation, body, visibility, context()); + static void emit( + Consumer<Entry> observer, + String boundary, + String direction, + String requestId, + String target, + String method, + Integer status, + Map<String, List<String>> headers, + String representation, + String body, + String visibility, + Map<String, String> 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"); } + } - static void emit(Consumer<Entry> observer, String boundary, String direction, String requestId, - String target, String method, Integer status, Map<String, List<String>> headers, - String representation, String body, String visibility, Map<String, String> 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"); - } + /** 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<String, List<String>> headers, + String representation, + String body, + String visibility, + Map<String, String> correlation) {} + + /** Bounded raw-byte collector. SSE emits each frame as it arrives, not at stream termination. */ + public static final class Body { + private final Consumer<String[]> 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<String[]> sink) { + this.sse = sse; + this.textual = textual; + this.sink = sink; } - /** Bounded raw-byte collector. SSE emits each frame as it arrives, not at stream termination. */ - public static final class Body { - private final Consumer<String[]> 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<String[]> 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; } - 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++; - } - } + size++; + if (bytes.size() < capacity && !dropped) bytes.write(value); + else { + dropped = true; + bytes.reset(); } - 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; + 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)" : bytes.toString(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 index 9024970e..b47afdef 100644 --- 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 @@ -22,128 +22,231 @@ /** Display only: transport bytes and observer entries are never reformatted. */ final class WireLogFormatter { - private static final JsonFactory JSON = new JsonFactory(); + private static final JsonFactory JSON = new JsonFactory(); - private WireLogFormatter() {} + 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) -> + static String format(WireLog.Entry entry, boolean pretty) { + if (entry.direction().endsWith("_BODY")) return formatFrame(entry, 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(); - } + 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)"; - } + /** + * Compact format for body-frame continuations of an already-logged envelope entry: the envelope's + * target/status/headers/correlation are not repeated. The first line still ends at the requestId + * so that tooling parsing {@code requestId=<id>} keeps working. + */ + private static String formatFrame(WireLog.Entry entry, boolean pretty) { + return new StringBuilder() + .append("[") + .append(entry.boundary()) + .append("] ") + .append(entry.direction()) + .append(" requestId=") + .append(entry.requestId()) + .append( + "\n(envelope logged in the preceding entry; target/status/headers/correlation not repeated)") + .append("\nVisibility: ") + .append(entry.visibility()) + .append("\n=== Body ===\n") + .append(pretty ? prettyBody(entry.body(), entry.representation()) : entry.body()) + .toString(); + } - 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)"; + 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 ==="); } - return out.toString(); + 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; - } + 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); + /** Insert whitespace only; preserve duplicate keys, numbers and escaped string tokens exactly. */ + /** + * Parses the four hex digits of a unicode escape whose backslash-u sits before index {@code i}; + * -1 when malformed. + */ + private static int decodeUnicodeEscape(String text, int i) { + if (i + 4 >= text.length()) return -1; + try { + return Integer.parseInt(text.substring(i + 1, i + 5), 16); + } catch (NumberFormatException malformed) { + return -1; + } + } + + /** + * Characters that may be decoded for the display: printable and not structural. Quotes and + * backslashes stay escaped so the displayed JSON stays readable; control characters stay escaped. + * This decodes both non-ASCII escapes (for example protobuf-JSON escaping Chinese) and the + * HTML-safe ASCII escapes protobuf-JSON emits for angle brackets and ampersands. + */ + private static boolean isDisplayDecodable(int codePoint) { + if (codePoint < 0x20) return false; + if (codePoint == 0x22 || codePoint == 0x5c) return false; + if (codePoint < 0x80) return true; + return !Character.isISOControl(codePoint); + } + + 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) { + if (escaped) { + if (c == 'u') { + int cp = decodeUnicodeEscape(trimmed, i); + if (isDisplayDecodable(cp)) { + // display-only decoding; quotes, backslashes and control escapes stay verbatim + int advance = 4; + if (Character.isHighSurrogate((char) cp) + && trimmed.length() > i + 10 + && trimmed.charAt(i + 5) == '\\' + && trimmed.charAt(i + 6) == 'u') { + int low = decodeUnicodeEscape(trimmed, i + 6); + if (Character.isLowSurrogate((char) low)) { + cp = Character.toCodePoint((char) cp, (char) low); + advance = 10; } + } + out.appendCodePoint(cp); + i += advance; + escaped = false; + continue; } - if (!Character.isWhitespace(c) || quoted) previous = c; - if (out.length() > WireLog.limit()) return text + "\n(pretty skipped: display capacity exceeded)"; + } + out.append('\\').append(c); + escaped = false; + } else if (c == '\\') { + escaped = true; + } else { + out.append(c); + if (c == '"') quoted = false; } - return out.toString(); + } 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)); - } + 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 a600ace4..6934cf85 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 @@ -24,35 +24,37 @@ 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; /** 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<SendMessageResult> dispatch( - TaskRequest request, MessageContent content, ControlPoint callbacks); + /** Dispatches a prepared workflow activation; target and protocol association remain internal. */ + CompletableFuture<SendMessageResult> dispatch( + TaskRequest request, MessageContent content, ControlPoint callbacks); + + /** Sends final content outside the DAG using explicitly configured interaction callbacks. */ + CompletableFuture<SendMessageResult> sendMessage(String agentName, MessageContent content); - /** Sends final content outside the DAG using explicitly configured interaction callbacks. */ - CompletableFuture<SendMessageResult> sendMessage(String agentName, MessageContent content); + /** Maximum wait for each business callback, including initial content preparation. */ + default long callbackTimeoutSeconds() { + return 600; + } - /** Maximum wait for each business callback, including initial content preparation. */ - default long callbackTimeoutSeconds() { return 600; } + void setControlPoint(ControlPoint controlPoint); - void setControlPoint(ControlPoint controlPoint); - void setEventCallback(EventCallback callback); + void setEventCallback(EventCallback callback); - /** Queries an existing remote task. */ - CompletableFuture<SendMessageResult> getTask(String agentName, String taskId); + /** Queries an existing remote task. */ + CompletableFuture<SendMessageResult> getTask(String agentName, String taskId); - /** Cancels an existing remote task; this is not a Negotiation-T Abort. */ - CompletableFuture<SendMessageResult> cancelTask(String agentName, String taskId); + /** Cancels an existing remote task; this is not a Negotiation-T Abort. */ + CompletableFuture<SendMessageResult> cancelTask(String agentName, String taskId); - /** Subscribes to an existing task, preserving its identity. */ - CompletableFuture<SendMessageResult> subscribeToTask( - String agentName, String taskId, Consumer<Map<String, Object>> eventCallback); + /** Subscribes to an existing task, preserving its identity. */ + CompletableFuture<SendMessageResult> subscribeToTask( + String agentName, String taskId, Consumer<Map<String, Object>> eventCallback); - void close(); + 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 ad84a81f..e10a3693 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 @@ -19,13 +19,12 @@ package dev.openan.workflow.engine.client; -import lombok.Getter; - import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import lombok.Getter; /** * Configuration for DefaultWorkflowEngineClient. @@ -36,207 +35,203 @@ @Getter public class WorkflowEngineClientConfig { - private final boolean sslVerify; - private final String caCertsPath; - private final String clientCertPath; - private final String clientKeyPath; - private final String clientKeyPassword; - private final String crlPath; - private final long sendTimeoutSeconds; - private final long notificationAckTimeoutSeconds; - private final int sendExecutorCoreSize; - private final int sendExecutorMaxSize; - private final int sendExecutorQueueCapacity; - private final AuthProvider authProvider; - private final String credentialsConfigPath; - private final String credentialEncryptionKey; - private final Map<String, Map<String, Map<String, Object>>> credentialsConfig; - private final int maxNegotiationExchanges; - private final String preferredProtocol; - - private WorkflowEngineClientConfig(Builder b) { - this.sslVerify = b.sslVerify; - this.caCertsPath = b.caCertsPath; - this.clientCertPath = b.clientCertPath; - this.clientKeyPath = b.clientKeyPath; - this.clientKeyPassword = b.clientKeyPassword; - this.crlPath = b.crlPath; - this.sendTimeoutSeconds = b.sendTimeoutSeconds; - this.notificationAckTimeoutSeconds = b.notificationAckTimeoutSeconds; - this.sendExecutorCoreSize = b.sendExecutorCoreSize; - this.sendExecutorMaxSize = b.sendExecutorMaxSize; - this.sendExecutorQueueCapacity = b.sendExecutorQueueCapacity; - this.authProvider = b.authProvider; - this.credentialsConfigPath = b.credentialsConfigPath; - this.credentialEncryptionKey = b.credentialEncryptionKey; - this.credentialsConfig = - b.credentialsConfig != null ? copyCredentials(b.credentialsConfig) : null; - this.maxNegotiationExchanges = b.maxNegotiationExchanges; - this.preferredProtocol = b.preferredProtocol; - } - - private static Map<String, Map<String, Map<String, Object>>> copyCredentials( - Map<String, Map<String, Map<String, Object>>> source) { - Map<String, Map<String, Map<String, Object>>> top = new LinkedHashMap<>(); - source.forEach( - (agent, schemes) -> { - Map<String, Map<String, Object>> 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<String, Object> immutableObjectMap(Map<String, Object> source) { - Map<String, Object> 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<Object, Object> copy = new LinkedHashMap<>(); - map.forEach((key, nested) -> copy.put(key, immutableValue(nested))); - return Collections.unmodifiableMap(copy); - } - if (value instanceof List<?> list) { - List<Object> 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(); - } - - public static class Builder { - private boolean sslVerify = true; - private String caCertsPath = null; - private String clientCertPath = null; - private String clientKeyPath = null; - private String clientKeyPassword = null; - private String crlPath = null; - private long sendTimeoutSeconds = 600; - private long notificationAckTimeoutSeconds = 5; - private int sendExecutorCoreSize = 4; - private int sendExecutorMaxSize = 16; - private int sendExecutorQueueCapacity = 256; - private AuthProvider authProvider; - private String credentialsConfigPath = null; - private String credentialEncryptionKey; - private Map<String, Map<String, Map<String, Object>>> credentialsConfig = null; - private int maxNegotiationExchanges = 3; - private String preferredProtocol = null; - - public Builder sslVerify(boolean v) { - this.sslVerify = v; - return this; - } - - public Builder sendTimeoutSeconds(long v) { - this.sendTimeoutSeconds = v; - return this; - } - - public Builder authProvider(AuthProvider v) { - this.authProvider = v; - return this; - } - - public Builder caCertsPath(String v) { - this.caCertsPath = v; - return this; - } - - public Builder clientCertPath(String v) { - this.clientCertPath = v; - return this; - } - - public Builder clientKeyPath(String v) { - this.clientKeyPath = v; - return this; - } - - public Builder clientKeyPassword(String v) { - this.clientKeyPassword = v; - return this; - } - - public Builder crlPath(String v) { - this.crlPath = v; - return this; - } - - public Builder notificationAckTimeoutSeconds(long v) { - this.notificationAckTimeoutSeconds = v; - return this; - } - - public Builder sendExecutorCoreSize(int v) { - this.sendExecutorCoreSize = v; - return this; - } - - public Builder sendExecutorMaxSize(int v) { - this.sendExecutorMaxSize = v; - return this; - } - - public Builder sendExecutorQueueCapacity(int v) { - this.sendExecutorQueueCapacity = 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; - } - - public Builder credentialsConfig(Map<String, Map<String, Map<String, Object>>> v) { - this.credentialsConfig = v; - return this; - } - - public Builder maxNegotiationExchanges(int v) { - this.maxNegotiationExchanges = v; - return this; - } - - public Builder preferredProtocol(String v) { - this.preferredProtocol = v; - return this; - } - - public WorkflowEngineClientConfig build() { - if (sendTimeoutSeconds <= 0 || notificationAckTimeoutSeconds <= 0) { - throw new IllegalArgumentException("Timeouts must be positive"); - } - if (sendExecutorCoreSize <= 0 - || sendExecutorMaxSize < sendExecutorCoreSize - || sendExecutorQueueCapacity <= 0) { - throw new IllegalArgumentException("Invalid send executor configuration"); - } - if (maxNegotiationExchanges <= 0) { - throw new IllegalArgumentException("maxNegotiationExchanges must be positive"); - } - return new WorkflowEngineClientConfig(this); - } + private final boolean sslVerify; + private final String caCertsPath; + private final String clientCertPath; + private final String clientKeyPath; + private final String clientKeyPassword; + private final String crlPath; + private final long sendTimeoutSeconds; + private final long notificationAckTimeoutSeconds; + private final int sendExecutorCoreSize; + private final int sendExecutorMaxSize; + private final int sendExecutorQueueCapacity; + private final AuthProvider authProvider; + private final String credentialsConfigPath; + private final String credentialEncryptionKey; + private final Map<String, Map<String, Map<String, Object>>> credentialsConfig; + private final int maxNegotiationExchanges; + private final String preferredProtocol; + + private WorkflowEngineClientConfig(Builder b) { + this.sslVerify = b.sslVerify; + this.caCertsPath = b.caCertsPath; + this.clientCertPath = b.clientCertPath; + this.clientKeyPath = b.clientKeyPath; + this.clientKeyPassword = b.clientKeyPassword; + this.crlPath = b.crlPath; + this.sendTimeoutSeconds = b.sendTimeoutSeconds; + this.notificationAckTimeoutSeconds = b.notificationAckTimeoutSeconds; + this.sendExecutorCoreSize = b.sendExecutorCoreSize; + this.sendExecutorMaxSize = b.sendExecutorMaxSize; + this.sendExecutorQueueCapacity = b.sendExecutorQueueCapacity; + this.authProvider = b.authProvider; + this.credentialsConfigPath = b.credentialsConfigPath; + this.credentialEncryptionKey = b.credentialEncryptionKey; + this.credentialsConfig = + b.credentialsConfig != null ? copyCredentials(b.credentialsConfig) : null; + this.maxNegotiationExchanges = b.maxNegotiationExchanges; + this.preferredProtocol = b.preferredProtocol; + } + + private static Map<String, Map<String, Map<String, Object>>> copyCredentials( + Map<String, Map<String, Map<String, Object>>> source) { + Map<String, Map<String, Map<String, Object>>> top = new LinkedHashMap<>(); + source.forEach( + (agent, schemes) -> { + Map<String, Map<String, Object>> 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<String, Object> immutableObjectMap(Map<String, Object> source) { + Map<String, Object> 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<Object, Object> copy = new LinkedHashMap<>(); + map.forEach((key, nested) -> copy.put(key, immutableValue(nested))); + return Collections.unmodifiableMap(copy); + } + if (value instanceof List<?> list) { + List<Object> 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(); + } + + public static class Builder { + private boolean sslVerify = true; + private String caCertsPath = null; + private String clientCertPath = null; + private String clientKeyPath = null; + private String clientKeyPassword = null; + private String crlPath = null; + private long sendTimeoutSeconds = 600; + private long notificationAckTimeoutSeconds = 5; + private int sendExecutorCoreSize = 4; + private int sendExecutorMaxSize = 16; + private int sendExecutorQueueCapacity = 256; + private AuthProvider authProvider; + private String credentialsConfigPath = null; + private String credentialEncryptionKey; + private Map<String, Map<String, Map<String, Object>>> credentialsConfig = null; + private int maxNegotiationExchanges = 3; + private String preferredProtocol = null; + + public Builder sslVerify(boolean v) { + this.sslVerify = v; + return this; + } + + public Builder sendTimeoutSeconds(long v) { + this.sendTimeoutSeconds = v; + return this; + } + + public Builder authProvider(AuthProvider v) { + this.authProvider = v; + return this; + } + + public Builder caCertsPath(String v) { + this.caCertsPath = v; + return this; + } + + public Builder clientCertPath(String v) { + this.clientCertPath = v; + return this; + } + + public Builder clientKeyPath(String v) { + this.clientKeyPath = v; + return this; + } + + public Builder clientKeyPassword(String v) { + this.clientKeyPassword = v; + return this; + } + + public Builder crlPath(String v) { + this.crlPath = v; + return this; + } + + public Builder notificationAckTimeoutSeconds(long v) { + this.notificationAckTimeoutSeconds = v; + return this; + } + + public Builder sendExecutorCoreSize(int v) { + this.sendExecutorCoreSize = v; + return this; + } + + public Builder sendExecutorMaxSize(int v) { + this.sendExecutorMaxSize = v; + return this; + } + + public Builder sendExecutorQueueCapacity(int v) { + this.sendExecutorQueueCapacity = 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; + } + + public Builder credentialsConfig(Map<String, Map<String, Map<String, Object>>> v) { + this.credentialsConfig = v; + return this; + } + + public Builder maxNegotiationExchanges(int v) { + this.maxNegotiationExchanges = v; + return this; + } + + public Builder preferredProtocol(String v) { + this.preferredProtocol = v; + return this; + } + + public WorkflowEngineClientConfig build() { + if (sendTimeoutSeconds <= 0 || notificationAckTimeoutSeconds <= 0) { + throw new IllegalArgumentException("Timeouts must be positive"); + } + if (sendExecutorCoreSize <= 0 + || sendExecutorMaxSize < sendExecutorCoreSize + || 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 b6401c1d..69c2dd35 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 @@ -25,87 +25,85 @@ /** 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<MessageContent> onTask( - TaskRequest request) { - return CompletableFuture.failedFuture( - new IllegalStateException("onTask handler is required for " + request.getStepName())); + /** Registers only the capabilities the host needs. */ + static Builder builder() { + return new Builder(); + } + + /** Prepares content; the engine sends after completion. No transport is exposed. */ + default CompletableFuture<MessageContent> 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<TaskResult> 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<RouteDecision> 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<NegotiationReply> onNegotiation(NegotiationRequest request) { + return CompletableFuture.failedFuture( + new IllegalStateException("onNegotiation handler is required")); + } + + /** Independent registration; omitted callbacks retain the documented defaults. */ + final class Builder { + private Function<TaskRequest, CompletableFuture<MessageContent>> task; + private Function<TaskRequest, CompletableFuture<TaskResult>> self; + private Function<RouteRequest, CompletableFuture<RouteDecision>> route; + private Function<NegotiationRequest, CompletableFuture<NegotiationReply>> negotiation; + + public Builder onTask(Function<TaskRequest, CompletableFuture<MessageContent>> handler) { + task = handler; + return this; } - /** Runs a local task. Missing implementation is an error, never an echo-success. */ - default CompletableFuture<TaskResult> onSelfTask(TaskRequest request) { - return CompletableFuture.failedFuture( - new IllegalStateException( - "onSelfTask handler is required for " + request.getStepName())); + public Builder onSelfTask(Function<TaskRequest, CompletableFuture<TaskResult>> handler) { + self = handler; + return this; } - /** Selects a permitted branch. Unconditional edges bypass this callback. */ - default CompletableFuture<RouteDecision> onRoute(RouteRequest request) { - return CompletableFuture.failedFuture( - new IllegalStateException("onRoute handler is required for " + request.stepName())); + public Builder onRoute(Function<RouteRequest, CompletableFuture<RouteDecision>> handler) { + route = handler; + return this; } - /** Answers the proposal. Missing handlers never implicitly consent. */ - default CompletableFuture<NegotiationReply> onNegotiation(NegotiationRequest request) { - return CompletableFuture.failedFuture( - new IllegalStateException("onNegotiation handler is required")); + public Builder onNegotiation( + Function<NegotiationRequest, CompletableFuture<NegotiationReply>> handler) { + negotiation = handler; + return this; } - /** Registers only the capabilities the host needs. */ - static Builder builder() { - return new Builder(); - } - - /** Independent registration; omitted callbacks retain the documented defaults. */ - final class Builder { - private Function<TaskRequest, CompletableFuture<MessageContent>> task; - private Function<TaskRequest, CompletableFuture<TaskResult>> self; - private Function<RouteRequest, CompletableFuture<RouteDecision>> route; - private Function<NegotiationRequest, CompletableFuture<NegotiationReply>> negotiation; - - public Builder onTask(Function<TaskRequest, CompletableFuture<MessageContent>> handler) { - task = handler; - return this; - } - - public Builder onSelfTask(Function<TaskRequest, CompletableFuture<TaskResult>> handler) { - self = handler; - return this; + public ControlPoint build() { + var t = task; + var s = self; + var r = route; + var n = negotiation; + return new ControlPoint() { + public CompletableFuture<MessageContent> onTask(TaskRequest q) { + return t == null ? ControlPoint.super.onTask(q) : t.apply(q); } - public Builder onRoute(Function<RouteRequest, CompletableFuture<RouteDecision>> handler) { - route = handler; - return this; + public CompletableFuture<TaskResult> onSelfTask(TaskRequest q) { + return s == null ? ControlPoint.super.onSelfTask(q) : s.apply(q); } - public Builder onNegotiation( - Function<NegotiationRequest, CompletableFuture<NegotiationReply>> handler) { - negotiation = handler; - return this; + public CompletableFuture<RouteDecision> onRoute(RouteRequest q) { + return r == null ? ControlPoint.super.onRoute(q) : r.apply(q); } - public ControlPoint build() { - var t = task; - var s = self; - var r = route; - var n = negotiation; - return new ControlPoint() { - public CompletableFuture<MessageContent> onTask(TaskRequest q) { - return t == null ? ControlPoint.super.onTask(q) : t.apply(q); - } - - public CompletableFuture<TaskResult> onSelfTask(TaskRequest q) { - return s == null ? ControlPoint.super.onSelfTask(q) : s.apply(q); - } - - public CompletableFuture<RouteDecision> onRoute(RouteRequest q) { - return r == null ? ControlPoint.super.onRoute(q) : r.apply(q); - } - - public CompletableFuture<NegotiationReply> onNegotiation(NegotiationRequest q) { - return n == null ? ControlPoint.super.onNegotiation(q) : n.apply(q); - } - }; + public CompletableFuture<NegotiationReply> 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 e534f908..7cda1c69 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 @@ -24,20 +24,18 @@ /** Explicit business callbacks, with no implicit local success, branch selection or consent. */ public class DefaultControlPoint implements ControlPoint { - private final NegotiationStrategy strategy; + private final NegotiationStrategy strategy; - public DefaultControlPoint() { - this(null); - } + public DefaultControlPoint() { + this(null); + } - public DefaultControlPoint(NegotiationStrategy strategy) { - this.strategy = strategy; - } + public DefaultControlPoint(NegotiationStrategy strategy) { + this.strategy = strategy; + } - @Override - public CompletableFuture<NegotiationReply> onNegotiation(NegotiationRequest request) { - return strategy == null - ? ControlPoint.super.onNegotiation(request) - : strategy.resolve(request); - } + @Override + public CompletableFuture<NegotiationReply> 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/EventCallback.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/EventCallback.java index 3e0e842a..52a5e6cf 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/EventCallback.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/EventCallback.java @@ -23,5 +23,5 @@ /** Optional callback for execution events. Instantiate directly as no-op sink. */ public class EventCallback { - public void onEvent(String eventType, Map<String, Object> data) {} + public void onEvent(String eventType, Map<String, Object> data) {} } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/EventType.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/EventType.java index 7cba3354..e074f160 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/EventType.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/EventType.java @@ -28,33 +28,33 @@ * "step_start".equals(eventType)}) works alongside the constants. */ public final class EventType { - // Runner lifecycle (ExecutePsop) - public static final String START = "start"; - public static final String COMPLETE = "complete"; - public static final String CLOSE = "close"; - // Step / task execution (WorkflowExecutor) - public static final String STEP_START = "step_start"; - public static final String STEP_COMPLETE = "step_complete"; - public static final String TASK_REQUEST = "task_request"; - public static final String TASK_RESPONSE = "task_response"; - public static final String TASK_STATUS_CHANGED = "task_status_changed"; - public static final String ROUTE_DECISION = "route_decision"; - public static final String WORKFLOW_COMPLETE = "workflow_complete"; - // Agent traffic (DefaultWorkflowEngineClient) - public static final String AGENT_REQUEST = "agent_request"; - public static final String AGENT_RESPONSE = "agent_response"; - public static final String AGENT_STATUS_UPDATE = "agent_status_update"; - public static final String AGENT_ARTIFACT_UPDATE = "agent_artifact_update"; - public static final String AGENT_MESSAGE_EVENT = "agent_message_event"; - // A2A-T extensions (negotiation / authorization / notification) - public static final String NEGOTIATION_REQUEST = "negotiation_request"; - public static final String NEGOTIATION_RESOLVED = "negotiation_resolved"; - public static final String NEGOTIATION_FAILED = "negotiation_failed"; - public static final String AUTHORIZATION_REQUEST = "authorization_request"; - public static final String AUTHORIZATION_RESOLVED = "authorization_resolved"; - public static final String NOTIFICATION = "notification"; - // Emitted on failure by the executor (step) and the runner (final). - public static final String ERROR = "error"; + // Runner lifecycle (ExecutePsop) + public static final String START = "start"; + public static final String COMPLETE = "complete"; + public static final String CLOSE = "close"; + // Step / task execution (WorkflowExecutor) + public static final String STEP_START = "step_start"; + public static final String STEP_COMPLETE = "step_complete"; + public static final String TASK_REQUEST = "task_request"; + public static final String TASK_RESPONSE = "task_response"; + public static final String TASK_STATUS_CHANGED = "task_status_changed"; + public static final String ROUTE_DECISION = "route_decision"; + public static final String WORKFLOW_COMPLETE = "workflow_complete"; + // Agent traffic (DefaultWorkflowEngineClient) + public static final String AGENT_REQUEST = "agent_request"; + public static final String AGENT_RESPONSE = "agent_response"; + public static final String AGENT_STATUS_UPDATE = "agent_status_update"; + public static final String AGENT_ARTIFACT_UPDATE = "agent_artifact_update"; + public static final String AGENT_MESSAGE_EVENT = "agent_message_event"; + // A2A-T extensions (negotiation / authorization / notification) + public static final String NEGOTIATION_REQUEST = "negotiation_request"; + public static final String NEGOTIATION_RESOLVED = "negotiation_resolved"; + public static final String NEGOTIATION_FAILED = "negotiation_failed"; + public static final String AUTHORIZATION_REQUEST = "authorization_request"; + public static final String AUTHORIZATION_RESOLVED = "authorization_resolved"; + public static final String NOTIFICATION = "notification"; + // Emitted on failure by the executor (step) and the runner (final). + public static final String ERROR = "error"; - private EventType() {} + private EventType() {} } 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 1c1e48c9..4ea382e5 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 @@ -21,17 +21,16 @@ import dev.openan.workflow.engine.model.NegotiationReply; import dev.openan.workflow.engine.model.NegotiationRequest; - import java.util.concurrent.CompletableFuture; /** * Strategy for making typed negotiation decisions. * - * <p>Single responsibility: when an agent returns INPUT_REQUIRED (Negotiation-T), produce the - * typed decision to send back. This is a <b>separate concern</b> 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. + * <p>Single responsibility: when an agent returns INPUT_REQUIRED (Negotiation-T), produce the typed + * decision to send back. This is a <b>separate concern</b> 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. * * <p>The SDK's {@link ControlPoint#onNegotiation} remains the entry point for the auto-negotiation * loop; {@link DefaultControlPoint} delegates to an injected strategy by default. Users may still @@ -39,11 +38,11 @@ */ public interface NegotiationStrategy { - /** - * Make a business decision for the given negotiation request. - * - * @param request typed business view of the received negotiation request - * @return future completing with final content to send, or a local stop - */ - CompletableFuture<NegotiationReply> resolve(NegotiationRequest request); + /** + * Make a business decision for the given negotiation request. + * + * @param request typed business view of the received negotiation request + * @return future completing with final content to send, or a local stop + */ + CompletableFuture<NegotiationReply> 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 2b6c6396..cb189428 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 @@ -19,15 +19,11 @@ package dev.openan.workflow.engine.core; -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.Workflow; import dev.openan.workflow.engine.model.WorkflowInput; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - +import dev.openan.workflow.engine.model.WorkflowStep; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; @@ -36,96 +32,100 @@ import java.util.List; import java.util.Map; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** 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; - private final String runtimeIntent; - private final Map<String, Integer> stepIndex; + private static final Logger log = LoggerFactory.getLogger(ContextBuilder.class); + private final Workflow workflow; + private final String runtimeIntent; + private final Map<String, Integer> stepIndex; - public ContextBuilder(Workflow workflow, String runtimeIntent) { - this.workflow = workflow; - this.runtimeIntent = runtimeIntent == null ? "" : runtimeIntent; - this.stepIndex = new HashMap<>(); - for (int i = 0; i < workflow.getSteps().size(); i++) { - stepIndex.put(workflow.getSteps().get(i).getName(), i); - } + public ContextBuilder(Workflow workflow, String runtimeIntent) { + this.workflow = workflow; + this.runtimeIntent = runtimeIntent == null ? "" : runtimeIntent; + this.stepIndex = new HashMap<>(); + for (int i = 0; i < workflow.getSteps().size(); i++) { + stepIndex.put(workflow.getSteps().get(i).getName(), i); } + } - public List<String> getStepPredecessors(String stepName) { - List<String> preds = new ArrayList<>(); - for (WorkflowStep s : workflow.getSteps()) { - if (s.getNext() != null) { - for (var jc : s.getNext()) { - if (jc.getStep().equals(stepName) && !s.getName().equals(stepName)) { - preds.add(s.getName()); - break; - } - } - } + public List<String> getStepPredecessors(String stepName) { + List<String> preds = new ArrayList<>(); + for (WorkflowStep s : workflow.getSteps()) { + if (s.getNext() != null) { + for (var jc : s.getNext()) { + if (jc.getStep().equals(stepName) && !s.getName().equals(stepName)) { + preds.add(s.getName()); + break; + } } - return preds; + } } + return preds; + } - public List<String> getAllPredecessors(String stepName) { - Set<String> ancestors = new LinkedHashSet<>(); - Deque<String> queue = new ArrayDeque<>(); - queue.add(stepName); - while (!queue.isEmpty()) { - String current = queue.poll(); - for (WorkflowStep s : workflow.getSteps()) { - if (s.getNext() != null) { - for (var jc : s.getNext()) { - if (jc.getStep().equals(current) - && !s.getName().equals(current) - && !ancestors.contains(s.getName())) { - ancestors.add(s.getName()); - queue.add(s.getName()); - break; - } - } - } + public List<String> getAllPredecessors(String stepName) { + Set<String> ancestors = new LinkedHashSet<>(); + Deque<String> queue = new ArrayDeque<>(); + queue.add(stepName); + while (!queue.isEmpty()) { + String current = queue.poll(); + for (WorkflowStep s : workflow.getSteps()) { + if (s.getNext() != null) { + for (var jc : s.getNext()) { + if (jc.getStep().equals(current) + && !s.getName().equals(current) + && !ancestors.contains(s.getName())) { + ancestors.add(s.getName()); + queue.add(s.getName()); + break; } + } } - return new ArrayList<>(ancestors); + } } + return new ArrayList<>(ancestors); + } - public WorkflowInput buildWorkflowInput(WorkflowStep step, Map<String, List<TaskExecutionResult>> stepResults) { - List<String> selectedSteps = new ArrayList<>(); - if (step.getContextFrom() != null && step.getContextFrom().contains("*")) { - for (String name : getAllPredecessors(step.getName())) { - if (stepResults.containsKey(name)) { - selectedSteps.add(name); - } - } - } else if (step.getContextFrom() != null && !step.getContextFrom().isEmpty()) { - for (String name : step.getContextFrom()) { - if (stepResults.containsKey(name)) { - selectedSteps.add(name); - } - } - } else if (step.getContextFrom() == null) { - for (String name : getStepPredecessors(step.getName())) { - if (stepResults.containsKey(name)) { - selectedSteps.add(name); - } - } + public WorkflowInput buildWorkflowInput( + WorkflowStep step, Map<String, List<TaskExecutionResult>> stepResults) { + List<String> selectedSteps = new ArrayList<>(); + if (step.getContextFrom() != null && step.getContextFrom().contains("*")) { + for (String name : getAllPredecessors(step.getName())) { + if (stepResults.containsKey(name)) { + selectedSteps.add(name); + } + } + } else if (step.getContextFrom() != null && !step.getContextFrom().isEmpty()) { + for (String name : step.getContextFrom()) { + if (stepResults.containsKey(name)) { + selectedSteps.add(name); } - List<UpstreamStepResult> upstreamResults = new ArrayList<>(); - for (String name : selectedSteps) { - upstreamResults.add(new UpstreamStepResult(name, stepResults.get(name))); + } + } else if (step.getContextFrom() == null) { + for (String name : getStepPredecessors(step.getName())) { + if (stepResults.containsKey(name)) { + selectedSteps.add(name); } - 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) { - return stepIndex.get(stepName); + List<UpstreamStepResult> upstreamResults = new ArrayList<>(); + for (String name : selectedSteps) { + upstreamResults.add(new UpstreamStepResult(name, stepResults.get(name))); } + 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) { + return stepIndex.get(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 index 212c35a7..4d44fd15 100644 --- 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 @@ -25,20 +25,27 @@ /** Maps only generic failures or explicitly safe host-selected business facts. */ final class FailureMapping { - static TaskResult from(Throwable error) { - Throwable root = error; - Set<Throwable> 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()); + static TaskResult from(Throwable error) { + Throwable root = error; + Set<Throwable> 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 index 0408883b..632f3231 100644 --- 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 @@ -24,20 +24,31 @@ /** Maps transport state independently from the presence or absence of business content. */ final class ProtocolResultAdapter { - private 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(); - } + 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 41a5ab58..052d6a73 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 @@ -27,17 +27,13 @@ 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.TaskExecutionResult; import dev.openan.workflow.engine.model.TaskRequest; +import dev.openan.workflow.engine.model.TaskResult; 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; - +import dev.openan.workflow.engine.model.WorkflowStep; import java.util.ArrayList; import java.util.Collections; import java.util.Deque; @@ -51,771 +47,764 @@ import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Main entry point. Traverses DAG, calls ControlPoint at decision points. Mirrors Python * WorkflowExecutor. */ 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<CompletableFuture<?>> 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<String, Map<String, Object>> stepOutputs = new ConcurrentHashMap<>(); - private final Map<String, List<TaskExecutionResult>> stepExecutionResults = new ConcurrentHashMap<>(); - private final List<Map<String, Object>> executionHistory = - Collections.synchronizedList(new ArrayList<>()); + private static final Logger log = LoggerFactory.getLogger(WorkflowExecutor.class); - public WorkflowExecutor( - Workflow workflow, - ControlPoint controlPoint, - WorkflowEngineClient engineClient, - EventCallback eventCallback, - String runtimeIntent, - String lang) { - 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(this.workflow, runtimeIntent); - this.lang = lang != null ? lang : "zh"; - try { - this.engineClient.setControlPoint(this.controlPoint); - } catch (Exception ignored) { - // Engine client may not support control point injection - } - try { - this.engineClient.setEventCallback(this.eventCallback); - } catch (Exception ignored) { - // Engine client may not support event callback injection - } - log.info( - "[Executor] Workflow: {}, steps={}, intent={}, lang={}", - workflow.getName(), - workflow.getSteps().size(), - runtimeIntent, - lang); - } + private final Workflow workflow; + private final AtomicBoolean started = new AtomicBoolean(); + private final AtomicBoolean stopped = new AtomicBoolean(); + private final Set<CompletableFuture<?>> 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<String, Map<String, Object>> stepOutputs = new ConcurrentHashMap<>(); + private final Map<String, List<TaskExecutionResult>> stepExecutionResults = + new ConcurrentHashMap<>(); + private final List<Map<String, Object>> executionHistory = + Collections.synchronizedList(new ArrayList<>()); - /** - * Current step outputs (mutable, updated during execution). Mirrors Python SDK's {@code - * current_step_outputs} property. - */ - public Map<String, Map<String, Object>> getCurrentStepOutputs() { - return new HashMap<>(stepOutputs); + public WorkflowExecutor( + Workflow workflow, + ControlPoint controlPoint, + WorkflowEngineClient engineClient, + EventCallback eventCallback, + String runtimeIntent, + String lang) { + 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(this.workflow, runtimeIntent); + this.lang = lang != null ? lang : "zh"; + try { + this.engineClient.setControlPoint(this.controlPoint); + } catch (Exception ignored) { + // Engine client may not support control point injection } - - /** - * Execution history (mutable, updated during execution). Mirrors Python SDK's {@code history} - * property. - */ - public List<Map<String, Object>> getHistory() { - return new ArrayList<>(executionHistory); + try { + this.engineClient.setEventCallback(this.eventCallback); + } catch (Exception ignored) { + // Engine client may not support event callback injection } + log.info( + "[Executor] Workflow: {}, steps={}, intent={}, lang={}", + workflow.getName(), + workflow.getSteps().size(), + runtimeIntent, + lang); + } - private void emit(String type, Map<String, Object> data) { - try { - eventCallback.onEvent(type, data); - } catch (Exception e) { - log.warn("Event callback error: {}", e.getMessage()); - } - } + private static boolean isTerminalRoute(String stepName) { + return "end".equals(stepName) || "retry".equals(stepName) || "endNode".equals(stepName); + } - public CompletableFuture<ExecutionResult> 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. - log.info( - "[Executor] Starting workflow: {} ({} steps)", - workflow.getName(), - workflow.getSteps().size()); - try { - validateWorkflowGraph(); - } catch (IllegalArgumentException e) { - log.error("[Executor] Invalid workflow graph: {}", e.getMessage()); - emit(EventType.ERROR, Map.of("error", e.getMessage())); - return CompletableFuture.completedFuture( - ExecutionResult.builder() - .success(false) - .history(new ArrayList<>(executionHistory)) - .stepOutputs(new HashMap<>(stepOutputs)) - .error(e.getMessage()) - .build()); - } - Deque<Integer> pending = new ConcurrentLinkedDeque<>(); - Set<Integer> scheduled = ConcurrentHashMap.newKeySet(); - Set<Integer> 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<Integer> executed = ConcurrentHashMap.newKeySet(); - AtomicBoolean failed = new AtomicBoolean(); - CompletableFuture<ExecutionResult> execution = executeSteps(pending, scheduled, executed, activated, failed) - .thenApply( - v -> { - emit(EventType.WORKFLOW_COMPLETE, Map.of()); - log.info( - "[Executor] Workflow completed: {}, {} task(s) executed", - workflow.getName(), - executionHistory.size()); - return ExecutionResult.builder() - .success(!failed.get()) - .history(new ArrayList<>(executionHistory)) - .stepOutputs(new HashMap<>(stepOutputs)) - .error(failed.get() ? "Step execution failed" : null) - .build(); - }) - .exceptionally( - e -> { - log.error("[Executor] DAG traversal error: {}", e.getMessage(), e); - emit(EventType.ERROR, Map.of("error", e.getMessage())); - return ExecutionResult.builder() - .success(false) - .history(new ArrayList<>(executionHistory)) - .stepOutputs(new HashMap<>(stepOutputs)) - .error(e.getMessage()) - .build(); - }); - execution.whenComplete((value, error) -> { - stopped.set(true); - activeTasks.forEach(task -> task.cancel(true)); + private static <T> void completeFrom( + CompletableFuture<T> destination, CompletableFuture<T> 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); }); - return execution; - } + } - private CompletableFuture<Void> executeSteps( - Deque<Integer> pending, - Set<Integer> scheduled, - Set<Integer> executed, - Set<Integer> activated, - AtomicBoolean failed) { - if (pending.isEmpty() || failed.get() || stopped.get()) { - return CompletableFuture.completedFuture(null); - } + private static List<TaskExecutionResult> collectExecutionResults( + List<StepResult> completedResults) { + return completedResults.stream().flatMap(result -> result.taskResults().stream()).toList(); + } - // Collect all ready steps (predecessors complete) and deferred steps - List<Integer> readySteps = new ArrayList<>(); - List<Integer> deferredSteps = new ArrayList<>(); - while (!pending.isEmpty()) { - int idx = pending.pollFirst(); - if (idx >= workflow.getSteps().size() || executed.contains(idx)) { - continue; - } - var step = workflow.getSteps().get(idx); - var preds = contextBuilder.getStepPredecessors(step.getName()); - boolean activePredecessorsComplete = - preds.stream() - .filter( - predecessor -> { - Integer predecessorIndex = - contextBuilder.findStepIndex(predecessor); - return predecessorIndex != null - && activated.contains(predecessorIndex); - }) - .allMatch(stepOutputs::containsKey); - if (activePredecessorsComplete) { - readySteps.add(idx); - } else { - deferredSteps.add(idx); - } - } - // Add deferred steps back - for (int idx : deferredSteps) { - pending.addLast(idx); - } - if (readySteps.isEmpty()) { - if (!deferredSteps.isEmpty()) { - String details = - deferredSteps.stream() - .map( - idx -> { - WorkflowStep step = workflow.getSteps().get(idx); - List<String> missing = - contextBuilder.getStepPredecessors(step.getName()) - .stream() - .filter( - predecessor -> { - Integer predecessorIndex = - contextBuilder - .findStepIndex( - predecessor); - return predecessorIndex - != null - && activated.contains( - predecessorIndex) - && !stepOutputs.containsKey( - predecessor); - }) - .toList(); - return step.getName() + " <- " + missing; - }) - .collect(Collectors.joining(", ")); - return CompletableFuture.failedFuture( - new IllegalStateException( - "Workflow dependency deadlock; unresolved active predecessors: " - + details)); - } - return CompletableFuture.completedFuture(null); - } - // Execute all ready steps in parallel - List<CompletableFuture<Void>> stepFutures = new ArrayList<>(); - for (int idx : readySteps) { - executed.add(idx); - var step = workflow.getSteps().get(idx); - stepFutures.add(executeStep(step, scheduled, activated, pending, failed)); - } - return CompletableFuture.allOf(stepFutures.toArray(new CompletableFuture[0])) - .thenCompose(v -> executeSteps(pending, scheduled, executed, activated, failed)); + /** Keeps every subtask result even when descriptions repeat within one parallel step. */ + private static Map<String, Object> collectTaskResults(List<StepResult> completedResults) { + Map<String, Long> descriptionCounts = + completedResults.stream() + .filter(result -> result.taskDesc() != null) + .collect( + Collectors.groupingBy( + StepResult::taskDesc, LinkedHashMap::new, Collectors.counting())); + Map<String, Object> 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 CompletableFuture<Void> executeStep( - WorkflowStep step, - Set<Integer> scheduled, - Set<Integer> activated, - Deque<Integer> pending, - 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( - EventType.ERROR, - Map.of( - "step", - step.getName(), - "results", - result.results())); - failed.set(true); - return CompletableFuture.completedFuture(null); - } - emit( - EventType.STEP_COMPLETE, - Map.of("step", step.getName(), "results", result.results())); - return determineNextSteps(step, result.results()) - .thenAccept( - nextIndices -> { - for (int i = nextIndices.size() - 1; i >= 0; i--) { - int nxt = nextIndices.get(i); - activated.add(nxt); - if (scheduled.add(nxt)) { - pending.addFirst(nxt); - } - } - }); - }); - } + /** + * Current step outputs (mutable, updated during execution). Mirrors Python SDK's {@code + * current_step_outputs} property. + */ + public Map<String, Map<String, Object>> getCurrentStepOutputs() { + return new HashMap<>(stepOutputs); + } - private void validateWorkflowGraph() { - Map<String, Integer> indices = new HashMap<>(); - for (int i = 0; i < workflow.getSteps().size(); i++) { - String name = workflow.getSteps().get(i).getName(); - if (name == null || name.isBlank()) { - throw new IllegalArgumentException("Workflow step name must not be blank"); - } - if (indices.put(name, i) != null) { - throw new IllegalArgumentException("Duplicate workflow step name: " + name); - } - } - List<List<Integer>> graph = new ArrayList<>(); - for (int i = 0; i < workflow.getSteps().size(); i++) graph.add(new ArrayList<>()); - for (int i = 0; i < workflow.getSteps().size(); i++) { - WorkflowStep step = workflow.getSteps().get(i); - if (step.getNext() == null) continue; - for (JumpCondition jump : step.getNext()) { - if (isTerminalRoute(jump.getStep())) continue; - Integer target = indices.get(jump.getStep()); - if (target == null) { - throw new IllegalArgumentException( - "Step " + step.getName() + " references missing step " + jump.getStep()); - } - graph.get(i).add(target); - } - } - for (WorkflowStep step : workflow.getSteps()) { - List<String> 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<String> 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); + /** + * Execution history (mutable, updated during execution). Mirrors Python SDK's {@code history} + * property. + */ + public List<Map<String, Object>> getHistory() { + return new ArrayList<>(executionHistory); + } + + private void emit(String type, Map<String, Object> data) { + try { + eventCallback.onEvent(type, data); + } catch (Exception e) { + log.warn("Event callback error: {}", e.getMessage()); } + } - private void detectCycle(int node, List<List<Integer>> graph, int[] state) { - if (state[node] == 2) return; - if (state[node] == 1) { - throw new IllegalArgumentException( - "Workflow graph contains a cycle at step " - + workflow.getSteps().get(node).getName()); - } - state[node] = 1; - for (int next : graph.get(node)) detectCycle(next, graph, state); - state[node] = 2; + public CompletableFuture<ExecutionResult> 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. + log.info( + "[Executor] Starting workflow: {} ({} steps)", + workflow.getName(), + workflow.getSteps().size()); + try { + validateWorkflowGraph(); + } catch (IllegalArgumentException e) { + log.error("[Executor] Invalid workflow graph: {}", e.getMessage()); + emit(EventType.ERROR, Map.of("error", e.getMessage())); + return CompletableFuture.completedFuture( + ExecutionResult.builder() + .success(false) + .history(new ArrayList<>(executionHistory)) + .stepOutputs(new HashMap<>(stepOutputs)) + .error(e.getMessage()) + .build()); + } + Deque<Integer> pending = new ConcurrentLinkedDeque<>(); + Set<Integer> scheduled = ConcurrentHashMap.newKeySet(); + Set<Integer> 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<Integer> executed = ConcurrentHashMap.newKeySet(); + AtomicBoolean failed = new AtomicBoolean(); + CompletableFuture<ExecutionResult> execution = + executeSteps(pending, scheduled, executed, activated, failed) + .thenApply( + v -> { + emit(EventType.WORKFLOW_COMPLETE, Map.of()); + log.info( + "[Executor] Workflow completed: {}, {} task(s) executed", + workflow.getName(), + executionHistory.size()); + return ExecutionResult.builder() + .success(!failed.get()) + .history(new ArrayList<>(executionHistory)) + .stepOutputs(new HashMap<>(stepOutputs)) + .error(failed.get() ? "Step execution failed" : null) + .build(); + }) + .exceptionally( + e -> { + log.error("[Executor] DAG traversal error: {}", e.getMessage(), e); + emit(EventType.ERROR, Map.of("error", e.getMessage())); + return ExecutionResult.builder() + .success(false) + .history(new ArrayList<>(executionHistory)) + .stepOutputs(new HashMap<>(stepOutputs)) + .error(e.getMessage()) + .build(); + }); + execution.whenComplete( + (value, error) -> { + stopped.set(true); + activeTasks.forEach(task -> task.cancel(true)); + }); + return execution; + } - private static boolean isTerminalRoute(String stepName) { - return "end".equals(stepName) - || "retry".equals(stepName) - || "endNode".equals(stepName); + private CompletableFuture<Void> executeSteps( + Deque<Integer> pending, + Set<Integer> scheduled, + Set<Integer> executed, + Set<Integer> activated, + AtomicBoolean failed) { + if (pending.isEmpty() || failed.get() || stopped.get()) { + return CompletableFuture.completedFuture(null); } - private record StepResult( - String taskDesc, - String agentName, - int subtaskIndex, - List<Object> outputs, - boolean success, - Map<String, Object> results, - List<TaskExecutionResult> taskResults) {} + // Collect all ready steps (predecessors complete) and deferred steps + List<Integer> readySteps = new ArrayList<>(); + List<Integer> deferredSteps = new ArrayList<>(); + while (!pending.isEmpty()) { + int idx = pending.pollFirst(); + if (idx >= workflow.getSteps().size() || executed.contains(idx)) { + continue; + } + var step = workflow.getSteps().get(idx); + var preds = contextBuilder.getStepPredecessors(step.getName()); + boolean activePredecessorsComplete = + preds.stream() + .filter( + predecessor -> { + Integer predecessorIndex = contextBuilder.findStepIndex(predecessor); + return predecessorIndex != null && activated.contains(predecessorIndex); + }) + .allMatch(stepOutputs::containsKey); + if (activePredecessorsComplete) { + readySteps.add(idx); + } else { + deferredSteps.add(idx); + } + } + // Add deferred steps back + for (int idx : deferredSteps) { + pending.addLast(idx); + } + if (readySteps.isEmpty()) { + if (!deferredSteps.isEmpty()) { + String details = + deferredSteps.stream() + .map( + idx -> { + WorkflowStep step = workflow.getSteps().get(idx); + List<String> missing = + contextBuilder.getStepPredecessors(step.getName()).stream() + .filter( + predecessor -> { + Integer predecessorIndex = + contextBuilder.findStepIndex(predecessor); + return predecessorIndex != null + && activated.contains(predecessorIndex) + && !stepOutputs.containsKey(predecessor); + }) + .toList(); + return step.getName() + " <- " + missing; + }) + .collect(Collectors.joining(", ")); + return CompletableFuture.failedFuture( + new IllegalStateException( + "Workflow dependency deadlock; unresolved active predecessors: " + details)); + } + return CompletableFuture.completedFuture(null); + } + // Execute all ready steps in parallel + List<CompletableFuture<Void>> stepFutures = new ArrayList<>(); + for (int idx : readySteps) { + executed.add(idx); + var step = workflow.getSteps().get(idx); + stepFutures.add(executeStep(step, scheduled, activated, pending, failed)); + } + return CompletableFuture.allOf(stepFutures.toArray(new CompletableFuture[0])) + .thenCompose(v -> executeSteps(pending, scheduled, executed, activated, failed)); + } - private CompletableFuture<StepResult> executeSubtasks(WorkflowStep step) { - var workflowInput = contextBuilder.buildWorkflowInput(step, stepExecutionResults); - List<CompletableFuture<StepResult>> 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, workflowInput); - emit( - EventType.TASK_REQUEST, - Map.of( - "step", - step.getName(), - "agent", - task.getAgent(), - "task", - task.getDescription())); - CompletableFuture<TaskResult> dispatch = dispatchTask(step, request); - CompletableFuture<StepResult> 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); + private CompletableFuture<Void> executeStep( + WorkflowStep step, + Set<Integer> scheduled, + Set<Integer> activated, + Deque<Integer> pending, + 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(EventType.ERROR, Map.of("step", step.getName(), "results", result.results())); + failed.set(true); + return CompletableFuture.completedFuture(null); + } + emit( + EventType.STEP_COMPLETE, + Map.of("step", step.getName(), "results", result.results())); + return determineNextSteps(step, result.results()) + .thenAccept( + nextIndices -> { + for (int i = nextIndices.size() - 1; i >= 0; i--) { + int nxt = nextIndices.get(i); + activated.add(nxt); + if (scheduled.add(nxt)) { + pending.addFirst(nxt); + } + } + }); }); - futures.add(processed); + } + + private void validateWorkflowGraph() { + Map<String, Integer> indices = new HashMap<>(); + for (int i = 0; i < workflow.getSteps().size(); i++) { + String name = workflow.getSteps().get(i).getName(); + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Workflow step name must not be blank"); + } + if (indices.put(name, i) != null) { + throw new IllegalArgumentException("Duplicate workflow step name: " + name); + } + } + List<List<Integer>> graph = new ArrayList<>(); + for (int i = 0; i < workflow.getSteps().size(); i++) graph.add(new ArrayList<>()); + for (int i = 0; i < workflow.getSteps().size(); i++) { + WorkflowStep step = workflow.getSteps().get(i); + if (step.getNext() == null) continue; + for (JumpCondition jump : step.getNext()) { + if (isTerminalRoute(jump.getStep())) continue; + Integer target = indices.get(jump.getStep()); + if (target == null) { + throw new IllegalArgumentException( + "Step " + step.getName() + " references missing step " + jump.getStep()); } - if (step.getStepType() == StepType.ANY_SUCCESS) { - return anySuccess(futures); + graph.get(i).add(target); + } + } + for (WorkflowStep step : workflow.getSteps()) { + List<String> 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<String> 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); } - return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) - .thenApply(v -> collectAllSuccess(futures)); + 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); + } - private String taskId(String stepName, int index) { - return java.util - .UUID - .nameUUIDFromBytes( - (executionId + ":" + stepName + ":" + index) - .getBytes(java.nio.charset.StandardCharsets.UTF_8)) - .toString(); + private void detectCycle(int node, List<List<Integer>> graph, int[] state) { + if (state[node] == 2) return; + if (state[node] == 1) { + throw new IllegalArgumentException( + "Workflow graph contains a cycle at step " + workflow.getSteps().get(node).getName()); } + state[node] = 1; + for (int next : graph.get(node)) detectCycle(next, graph, state); + state[node] = 2; + } - private TaskRequest buildTaskRequest( - 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()); - return TaskRequest.builder() - .agentName(task.getAgent()) - .skill(task.getSkill()) - .instruction(task.getDescription()) - .language(lang) - .stepName(step.getName()) - .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<StepResult> executeSubtasks(WorkflowStep step) { + var workflowInput = contextBuilder.buildWorkflowInput(step, stepExecutionResults); + List<CompletableFuture<StepResult>> 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, workflowInput); + emit( + EventType.TASK_REQUEST, + Map.of("step", step.getName(), "agent", task.getAgent(), "task", task.getDescription())); + CompletableFuture<TaskResult> dispatch = dispatchTask(step, request); + CompletableFuture<StepResult> 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); } - - private CompletableFuture<TaskResult> dispatchTask(WorkflowStep step, TaskRequest request) { - CompletableFuture<TaskResult> 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; + if (step.getStepType() == StepType.ANY_SUCCESS) { + return anySuccess(futures); } + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply(v -> collectAllSuccess(futures)); + } - private static <T> void completeFrom(CompletableFuture<T> destination, CompletableFuture<T> 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 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, WorkflowInput workflowInput) { + log.info( + "[Executor] Dispatching task: step={}, agent={}, subtask_index={}, desc={}", + step.getName(), + task.getAgent(), + subtaskIndex, + task.getDescription()); + return TaskRequest.builder() + .agentName(task.getAgent()) + .skill(task.getSkill()) + .instruction(task.getDescription()) + .language(lang) + .stepName(step.getName()) + .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 StepResult collectAllSuccess(List<CompletableFuture<StepResult>> futures) { - List<StepResult> completedResults = new ArrayList<>(); - boolean anyFailed = false; - for (var f : futures) { - var r = f.join(); - completedResults.add(r); - if (!r.success()) { - anyFailed = true; + private CompletableFuture<TaskResult> dispatchTask(WorkflowStep step, TaskRequest request) { + CompletableFuture<TaskResult> 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; } - } - return new StepResult( - null, null, -1, - List.of(), !anyFailed, collectTaskResults(completedResults), - collectExecutionResults(completedResults)); - } + 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 List<TaskExecutionResult> collectExecutionResults( - List<StepResult> completedResults) { - return completedResults.stream().flatMap(result -> result.taskResults().stream()).toList(); + private StepResult collectAllSuccess(List<CompletableFuture<StepResult>> futures) { + List<StepResult> completedResults = new ArrayList<>(); + boolean anyFailed = false; + for (var f : futures) { + var r = f.join(); + completedResults.add(r); + if (!r.success()) { + anyFailed = true; + } } + return new StepResult( + null, + null, + -1, + List.of(), + !anyFailed, + collectTaskResults(completedResults), + collectExecutionResults(completedResults)); + } - /** Keeps every subtask result even when descriptions repeat within one parallel step. */ - private static Map<String, Object> collectTaskResults(List<StepResult> completedResults) { - Map<String, Long> descriptionCounts = - completedResults.stream() - .filter(result -> result.taskDesc() != null) - .collect( - Collectors.groupingBy( - StepResult::taskDesc, - LinkedHashMap::new, - Collectors.counting())); - Map<String, Object> 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, + Map.of( + "step", + step.getName(), + "subtask_index", + subtaskIndex, + "agent", + task.getAgent(), + "status", + task.getStatus().getValue())); + String status = response.isSuccess() ? "success" : "failed"; + log.info("[Executor] Task {} -> {}: {}", task.getDescription(), task.getAgent(), status); + if (response.isSuccess() && !response.getOutputs().isEmpty()) { + log.debug("[Executor] Task outputs from {}: [{}]", task.getAgent(), response.getOutputs()); } - - private StepResult processTaskResult( - WorkflowStep step, Task task, int subtaskIndex, TaskResult response) { - task.setStatus(response.isSuccess() ? TaskStatus.SUCCESS : TaskStatus.FAILED); - emit( - EventType.TASK_STATUS_CHANGED, - Map.of( - "step", - step.getName(), - "subtask_index", - subtaskIndex, - "agent", - task.getAgent(), - "status", - task.getStatus().getValue())); - String status = response.isSuccess() ? "success" : "failed"; - log.info("[Executor] Task {} -> {}: {}", task.getDescription(), task.getAgent(), status); - if (response.isSuccess() && !response.getOutputs().isEmpty()) { - log.debug( - "[Executor] Task outputs from {}: [{}]", task.getAgent(), response.getOutputs()); - } - List<Object> outputs = - response.getOutputs(); - executionHistory.add( - Map.of( - "step", - step.getName(), - "subtask_index", - subtaskIndex, - "task", - task.getDescription(), - "agent", - task.getAgent(), - "status", - status, - "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(), - "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(), + List<Object> outputs = response.getOutputs(); + executionHistory.add( + Map.of( + "step", + step.getName(), + "subtask_index", + subtaskIndex, + "task", + task.getDescription(), + "agent", + task.getAgent(), + "status", + status, + "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(), + "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(), - subtaskIndex, + task.getSkill(), + taskId(step.getName(), subtaskIndex), + task.getDescription(), + task.getStatus(), 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()))); - } + response.getReceivedMessages(), + response.getError(), + response.getErrorCode(), + response.getErrorDetails()))); + } - private StepResult processTaskError( - WorkflowStep step, Task task, int subtaskIndex, Throwable e) { - return processTaskResult(step, task, - subtaskIndex, FailureMapping.from(e)); - } - - /** - * ANY_SUCCESS logic: iterate futures as they complete; on the first success, cancel the rest - * and return success=true. If all fail, return success=false. Mirrors Python's - * asyncio.as_completed loop. - */ - private CompletableFuture<StepResult> anySuccess(List<CompletableFuture<StepResult>> futures) { - if (futures.isEmpty()) { - return CompletableFuture.completedFuture( - new StepResult(null, null, -1, List.of(), true, Map.of(), List.of())); - } - CompletableFuture<StepResult> result = new CompletableFuture<>(); - int total = futures.size(); - int[] completed = {0}; - int[] failedCount = {0}; - boolean[] winnerChosen = {false}; + private StepResult processTaskError(WorkflowStep step, Task task, int subtaskIndex, Throwable e) { + return processTaskResult(step, task, subtaskIndex, FailureMapping.from(e)); + } - for (CompletableFuture<StepResult> f : futures) { - f.handle( - (sr, ex) -> { - synchronized (completed) { - completed[0]++; - boolean success = (ex == null && sr != null && sr.success()); - if (success && !result.isDone()) { - winnerChosen[0] = true; - // Cancel all remaining - for (CompletableFuture<StepResult> other : futures) { - if (!other.isDone()) { - other.cancel(true); - } - } - // Collect results from completed futures - List<StepResult> completedResults = new ArrayList<>(); - for (CompletableFuture<StepResult> cf : futures) { - if (cf.isDone() && !cf.isCompletedExceptionally()) { - try { - var r = cf.join(); - if (r != null && r.taskDesc() != null) { - completedResults.add(r); - } - } catch (Exception ignored) { - // Cancellation race, skip - } - } - } - 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 - List<StepResult> completedResults = new ArrayList<>(); - for (CompletableFuture<StepResult> cf : futures) { - if (cf.isDone() && !cf.isCompletedExceptionally()) { - try { - var r = cf.join(); - if (r != null && r.taskDesc() != null) { - completedResults.add(r); - } - } catch (Exception ignored) { - // Cancellation race, skip - } - } - } - result.complete( - new StepResult( - null, - null, - -1, - List.of(), - false, - collectTaskResults(completedResults), - collectExecutionResults(completedResults))); - } - } - } - return null; - }); - } - return result; + /** + * ANY_SUCCESS logic: iterate futures as they complete; on the first success, cancel the rest and + * return success=true. If all fail, return success=false. Mirrors Python's asyncio.as_completed + * loop. + */ + private CompletableFuture<StepResult> anySuccess(List<CompletableFuture<StepResult>> futures) { + if (futures.isEmpty()) { + return CompletableFuture.completedFuture( + new StepResult(null, null, -1, List.of(), true, Map.of(), List.of())); } + CompletableFuture<StepResult> result = new CompletableFuture<>(); + int total = futures.size(); + int[] completed = {0}; + int[] failedCount = {0}; + boolean[] winnerChosen = {false}; - private CompletableFuture<List<Integer>> determineNextSteps( - WorkflowStep step, Map<String, Object> stepResult) { - if (step.getNext() == null || step.getNext().isEmpty()) { - return CompletableFuture.completedFuture(List.of()); - } - if (step.getNext().stream() - .allMatch(jc -> jc.getCondition() == null || jc.getCondition().isEmpty())) { - List<Integer> indices = new ArrayList<>(); - for (var jc : step.getNext()) { - if (isTerminalRoute(jc.getStep())) { - continue; + for (CompletableFuture<StepResult> f : futures) { + f.handle( + (sr, ex) -> { + synchronized (completed) { + completed[0]++; + boolean success = (ex == null && sr != null && sr.success()); + if (success && !result.isDone()) { + winnerChosen[0] = true; + // Cancel all remaining + for (CompletableFuture<StepResult> other : futures) { + if (!other.isDone()) { + other.cancel(true); + } } - Integer idx = contextBuilder.findStepIndex(jc.getStep()); - if (idx != null) { - indices.add(idx); + // Collect results from completed futures + List<StepResult> completedResults = new ArrayList<>(); + for (CompletableFuture<StepResult> cf : futures) { + if (cf.isDone() && !cf.isCompletedExceptionally()) { + try { + var r = cf.join(); + if (r != null && r.taskDesc() != null) { + completedResults.add(r); + } + } catch (Exception ignored) { + // Cancellation race, skip + } + } + } + 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 + List<StepResult> completedResults = new ArrayList<>(); + for (CompletableFuture<StepResult> cf : futures) { + if (cf.isDone() && !cf.isCompletedExceptionally()) { + try { + var r = cf.join(); + if (r != null && r.taskDesc() != null) { + completedResults.add(r); + } + } catch (Exception ignored) { + // Cancellation race, skip + } + } + } + result.complete( + new StepResult( + null, + null, + -1, + List.of(), + false, + collectTaskResults(completedResults), + collectExecutionResults(completedResults))); } + } } - return CompletableFuture.completedFuture(indices); + return null; + }); + } + return result; + } + + private CompletableFuture<List<Integer>> determineNextSteps( + WorkflowStep step, Map<String, Object> stepResult) { + if (step.getNext() == null || step.getNext().isEmpty()) { + return CompletableFuture.completedFuture(List.of()); + } + if (step.getNext().stream() + .allMatch(jc -> jc.getCondition() == null || jc.getCondition().isEmpty())) { + List<Integer> indices = new ArrayList<>(); + for (var jc : step.getNext()) { + if (isTerminalRoute(jc.getStep())) { + continue; } - 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(routeRequest) - .orTimeout(engineClient.callbackTimeoutSeconds(), java.util.concurrent.TimeUnit.SECONDS) - .thenApply( - decision -> { - log.info( - "Route for '{}': {} ({})", - step.getName(), - decision.getNextStep(), - decision.getReason()); - emit( - EventType.ROUTE_DECISION, - Map.of( - "step", - step.getName(), - "next", - decision.getNextStep(), - "reason", - decision.getReason())); - List<String> allowed = - step.getNext().stream() - .map(JumpCondition::getStep) - .collect(Collectors.toList()); - if (!allowed.contains(decision.getNextStep())) { - throw new IllegalStateException( - "onRoute returned '" - + decision.getNextStep() - + "' for step '" - + step.getName() - + "', allowed next steps are " - + allowed); - } - if (isTerminalRoute(decision.getNextStep())) return List.of(); - Integer idx = contextBuilder.findStepIndex(decision.getNextStep()); - if (idx == null) - throw new IllegalStateException( - "Route target does not exist: " + decision.getNextStep()); - return List.of(idx); - }); + Integer idx = contextBuilder.findStepIndex(jc.getStep()); + if (idx != null) { + indices.add(idx); + } + } + return CompletableFuture.completedFuture(indices); } + 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(routeRequest) + .orTimeout(engineClient.callbackTimeoutSeconds(), java.util.concurrent.TimeUnit.SECONDS) + .thenApply( + decision -> { + log.info( + "Route for '{}': {} ({})", + step.getName(), + decision.getNextStep(), + decision.getReason()); + emit( + EventType.ROUTE_DECISION, + Map.of( + "step", + step.getName(), + "next", + decision.getNextStep(), + "reason", + decision.getReason())); + List<String> allowed = + step.getNext().stream().map(JumpCondition::getStep).collect(Collectors.toList()); + if (!allowed.contains(decision.getNextStep())) { + throw new IllegalStateException( + "onRoute returned '" + + decision.getNextStep() + + "' for step '" + + step.getName() + + "', allowed next steps are " + + allowed); + } + if (isTerminalRoute(decision.getNextStep())) return List.of(); + Integer idx = contextBuilder.findStepIndex(decision.getNextStep()); + if (idx == null) + throw new IllegalStateException( + "Route target does not exist: " + decision.getNextStep()); + return List.of(idx); + }); + } + + private record StepResult( + String taskDesc, + String agentName, + int subtaskIndex, + List<Object> outputs, + boolean success, + Map<String, Object> results, + List<TaskExecutionResult> taskResults) {} } 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 index 76b3995b..f7f94aeb 100644 --- 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 @@ -23,20 +23,24 @@ /** 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<String, Object> details; + private final String code; + private final Map<String, Object> details; - /** Constructs a failure with an application code and explicitly safe diagnostic fields. */ - public BusinessFailure(String code, String message, Map<String, Object> 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); - } + /** Constructs a failure with an application code and explicitly safe diagnostic fields. */ + public BusinessFailure(String code, String message, Map<String, Object> 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; } + /** Machine-readable business failure code. */ + public String code() { + return code; + } - /** Safe business facts selected by the host. */ - public Map<String, Object> details() { return details; } + /** Safe business facts selected by the host. */ + public Map<String, Object> 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 index 216b11ea..1750964a 100644 --- 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 @@ -19,25 +19,24 @@ 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); - } + 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); - } + /** 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); - } + /** 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 index 5a6cb7b4..e5d95b18 100644 --- 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 @@ -20,45 +20,42 @@ 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<String, Object> map(Map<String, ?> value) { - return (Map<String, Object>) freeze(JSON.convertValue(value, Map.class)); - } - - /** Snapshots ordered outputs without flattening nested arrays. */ - @SuppressWarnings("unchecked") - public static List<Object> list(List<?> value) { - return value == null - ? List.of() - : (List<Object>) freeze(JSON.convertValue(value, List.class)); + 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<String, Object> map(Map<String, ?> value) { + return (Map<String, Object>) freeze(JSON.convertValue(value, Map.class)); + } + + /** Snapshots ordered outputs without flattening nested arrays. */ + @SuppressWarnings("unchecked") + public static List<Object> list(List<?> value) { + return value == null ? List.of() : (List<Object>) freeze(JSON.convertValue(value, List.class)); + } + + private static Object freeze(Object value) { + if (value instanceof Map<?, ?> map) { + Map<String, Object> copy = new LinkedHashMap<>(); + map.forEach((k, v) -> copy.put(String.valueOf(k), freeze(v))); + return Collections.unmodifiableMap(copy); } - - private static Object freeze(Object value) { - if (value instanceof Map<?, ?> map) { - Map<String, Object> copy = new LinkedHashMap<>(); - map.forEach((k, v) -> copy.put(String.valueOf(k), freeze(v))); - return Collections.unmodifiableMap(copy); - } - if (value instanceof List<?> list) { - List<Object> copy = new ArrayList<>(); - list.forEach(v -> copy.add(freeze(v))); - return Collections.unmodifiableList(copy); - } - return value; + if (value instanceof List<?> list) { + List<Object> 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/ExecutionResult.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/ExecutionResult.java index 62ac9500..b01abe6a 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/ExecutionResult.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/ExecutionResult.java @@ -19,21 +19,20 @@ package dev.openan.workflow.engine.model; +import java.util.List; +import java.util.Map; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import java.util.List; -import java.util.Map; - @Data @NoArgsConstructor @AllArgsConstructor @Builder public class ExecutionResult { - private boolean success; - private List<Map<String, Object>> history; - private Map<String, Map<String, Object>> stepOutputs; - private String error; + private boolean success; + private List<Map<String, Object>> history; + private Map<String, Map<String, Object>> stepOutputs; + private String error; } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/JumpCondition.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/JumpCondition.java index 57d8bc4e..ee9f6822 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/JumpCondition.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/JumpCondition.java @@ -29,6 +29,6 @@ @AllArgsConstructor @Builder public class JumpCondition { - private String step; - @Builder.Default private String condition = ""; + private String step; + @Builder.Default private String condition = ""; } 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 index dc6adcf0..dcd03115 100644 --- 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 @@ -19,70 +19,74 @@ 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.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +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; /** Final business content. The engine owns the A2A envelope and transport headers. */ public record MessageContent( - List<Part<?>> parts, Map<String, Object> metadata, Set<String> extensions) { + List<Part<?>> parts, Map<String, Object> metadata, Set<String> extensions) { - public MessageContent { - parts = snapshotParts(Objects.requireNonNull(parts, "parts")); - metadata = metadata == null ? Map.of() : BusinessValues.map(metadata); - LinkedHashSet<String> 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); - } + public MessageContent { + parts = snapshotParts(Objects.requireNonNull(parts, "parts")); + metadata = metadata == null ? Map.of() : BusinessValues.map(metadata); + LinkedHashSet<String> active = new LinkedHashSet<>(); + if (extensions != null) { + for (String uri : extensions) { + if (uri == null || uri.isBlank()) { + throw new IllegalArgumentException("Extension URI must not be blank"); } - extensions = Collections.unmodifiableSet(active); + 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")))); - } + /** 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<Part<?>> parts) { - return new MessageContent(parts, Map.of(), Set.of()); - } + /** Ordered standard A2A parts without message-level metadata or extensions. */ + public static MessageContent parts(List<Part<?>> parts) { + return new MessageContent(parts, Map.of(), Set.of()); + } - static List<Part<?>> snapshotParts(List<Part<?>> parts) { - return parts.stream().map(MessageContent::snapshotPart).toList(); + static List<Part<?>> snapshotParts(List<Part<?>> parts) { + List<Part<?>> snapshot = new ArrayList<>(parts.size()); + for (Part<?> part : parts) { + snapshot.add(snapshotPart(part)); } + return List.copyOf(snapshot); + } - 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()); + private static Part<?> snapshotPart(Part<?> part) { + Objects.requireNonNull(part, "part"); + if (part instanceof TextPart text) { + return new TextPart(text.text(), snapshotMetadata(text.metadata())); } - - static Map<String, Object> snapshotMetadata(Map<String, Object> metadata) { - return metadata == null ? null : BusinessValues.map(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<String, Object> snapshotMetadata(Map<String, Object> 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 index cf20e27c..369fc10d 100644 --- 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 @@ -23,19 +23,19 @@ /** 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"); - } + /** 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"); - } - } + /** 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 index dddacdbd..f2b32b9a 100644 --- 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 @@ -25,31 +25,31 @@ /** Uninterpreted received content and history for one task's current negotiation session. */ public record NegotiationRequest( - TaskRequest task, - MessageContent originalSubmission, - ReceivedMessage received, - List<Exchange> previousExchanges, - Duration remainingWait) { + TaskRequest task, + MessageContent originalSubmission, + ReceivedMessage received, + List<Exchange> 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"); + } - 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(); + } - /** Counterpart identity, not a remote protocol task identifier. */ - public String agentName() { - return task.getAgentName(); + /** 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"); } + } } 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 index 86334bd7..e6f489ff 100644 --- 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 @@ -19,53 +19,62 @@ package dev.openan.workflow.engine.model; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; 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<String, Object> taskMetadata, List<Artifact> artifacts) { + MessageContent message, Map<String, Object> taskMetadata, List<Artifact> 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()))) + 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(); + } + + private static void project(List<Part<?>> parts, List<Object> values) { + for (Part<?> part : parts) { + if (part instanceof TextPart text) { + values.add(text.text()); + } else if (part instanceof DataPart data) { + values.add(data.data()); + } } + } - /** Projects deliverables, or the message when there are no artifacts; never parses text. */ - public List<Object> outputs() { return outputs(true); } + /** Projects deliverables, or the message when there are no artifacts; never parses text. */ + public List<Object> outputs() { + return outputs(true); + } - /** Failed/non-final task status messages remain evidence, never business outputs. */ - public List<Object> outputs(boolean includeMessage) { - List<Object> 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); + /** Failed/non-final task status messages remain evidence, never business outputs. */ + public List<Object> outputs(boolean includeMessage) { + List<Object> 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); } - - private static void project(List<Part<?>> parts, List<Object> values) { - for (Part<?> part : parts) { - if (part instanceof TextPart text) { - values.add(text.text()); - } else if (part instanceof DataPart data) { - values.add(data.data()); - } - } + for (Artifact artifact : artifacts) { + project(artifact.parts(), values); } + return BusinessValues.list(values); + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/RouteDecision.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/RouteDecision.java index d2d85a91..8992a86a 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/RouteDecision.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/RouteDecision.java @@ -29,6 +29,6 @@ @AllArgsConstructor @Builder public class RouteDecision { - private String nextStep; - @Builder.Default private String reason = ""; + private String nextStep; + @Builder.Default private String reason = ""; } 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 index 4496161d..67449aca 100644 --- 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 @@ -23,16 +23,16 @@ /** Conditional route input; upstream selection is identical to task callbacks. */ public record RouteRequest( - String executionId, - String stepName, - WorkflowInput workflowInput, - List<TaskExecutionResult> currentResults, - List<RouteOption> candidates) { - public RouteRequest { - currentResults = List.copyOf(currentResults); - candidates = List.copyOf(candidates); - } + String executionId, + String stepName, + WorkflowInput workflowInput, + List<TaskExecutionResult> currentResults, + List<RouteOption> 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) {} + /** 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 9f215d21..0b035ec4 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,50 +19,58 @@ package dev.openan.workflow.engine.model; +import java.util.List; +import java.util.Map; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; - import org.a2aproject.sdk.spec.Task; -import java.util.Map; -import java.util.List; - @Data @NoArgsConstructor public class SendMessageResult { - private String text = ""; - private Task task; - private Map<String, Object> metadata; - private String taskState = ""; - /** Local interaction outcome, separate from the actual remote task state. */ - private String failureCode; - private String failureMessage; + private String text = ""; + private Task task; + private Map<String, Object> metadata; + 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<ReceivedMessage> receivedMessages = List.of(); + /** Lossless source of all convenience outputs. */ + private List<ReceivedMessage> receivedMessages = List.of(); - @Builder - public SendMessageResult(String text, Task task, Map<String, Object> metadata, String taskState, - String failureCode, String failureMessage, List<ReceivedMessage> 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); - } + @Builder + public SendMessageResult( + String text, + Task task, + Map<String, Object> metadata, + String taskState, + String failureCode, + String failureMessage, + List<ReceivedMessage> 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<Object> getOutputs() { - boolean includeMessage = failureCode == null - && (task == null || "TASK_STATE_COMPLETED".equals(taskState)); - return receivedMessages.stream().flatMap(message -> message.outputs(includeMessage).stream()).toList(); - } + /** Projects text/data values from the retained response, without fallback or semantic parsing. */ + public List<Object> 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<ReceivedMessage> messages) { - this.receivedMessages = List.copyOf(messages); - } + /** Snapshots the list supplied by transport adapters. */ + public void setReceivedMessages(List<ReceivedMessage> messages) { + this.receivedMessages = List.copyOf(messages); + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/StepType.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/StepType.java index 18465565..0b60e682 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/StepType.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/StepType.java @@ -20,29 +20,29 @@ package dev.openan.workflow.engine.model; public enum StepType { - ALL_SUCCESS("AllSuccess"), - ANY_SUCCESS("AnySuccess"), - SELF_LOOP("SelfLoop"); + ALL_SUCCESS("AllSuccess"), + ANY_SUCCESS("AnySuccess"), + SELF_LOOP("SelfLoop"); - private final String value; + private final String value; - StepType(String value) { - this.value = value; - } + StepType(String value) { + this.value = value; + } - public static StepType fromValue(String v) { - if (v == null) { - return ALL_SUCCESS; - } - for (StepType t : values()) { - if (t.value.equalsIgnoreCase(v) || t.name().equalsIgnoreCase(v)) { - return t; - } - } - return ALL_SUCCESS; + public static StepType fromValue(String v) { + if (v == null) { + return ALL_SUCCESS; } - - public String getValue() { - return value; + for (StepType t : values()) { + if (t.value.equalsIgnoreCase(v) || t.name().equalsIgnoreCase(v)) { + return t; + } } + return ALL_SUCCESS; + } + + public String getValue() { + return value; + } } 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 e96a5a25..7026474f 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 @@ -29,9 +29,9 @@ @AllArgsConstructor @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; + 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 index 30390cfa..5daa5bfb 100644 --- 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 @@ -24,28 +24,30 @@ /** Immutable result of one upstream workflow subtask. */ public record TaskExecutionResult( - String agentName, - String skill, - String taskId, - String taskDescription, - TaskStatus status, - List<Object> outputs, - List<ReceivedMessage> receivedMessages, - String error, - String errorCode, - java.util.Map<String, Object> errorDetails) { + String agentName, + String skill, + String taskId, + String taskDescription, + TaskStatus status, + List<Object> outputs, + List<ReceivedMessage> receivedMessages, + String error, + String errorCode, + java.util.Map<String, Object> 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); + 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 e970e627..ece8bbe3 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 @@ -26,23 +26,24 @@ @Value @Builder public class TaskRequest { - /** Unique execution identity, separate from remote A2A context IDs. */ - private String executionId; + /** Unique execution identity, separate from remote A2A context IDs. */ + String executionId; - /** Logical task identity, stable across self-loop re-entry within this execution. */ - private String taskId; + /** Logical task identity, stable across self-loop re-entry within this execution. */ + String taskId; - /** Business text or structured data, independent of upstream evidence. */ - private BusinessInput input; + /** Business text or structured data, independent of upstream evidence. */ + BusinessInput input; - private String agentName; - private String skill; + String agentName; + String skill; - /** Current task instruction only; never contains rendered upstream results. */ - private String instruction; - @Builder.Default private String language = "zh"; - private String stepName; + /** Current task instruction only; never contains rendered upstream results. */ + String instruction; - /** Engine-assembled, protocol-neutral input selected by the workflow definition. */ - @Builder.Default private WorkflowInput workflowInput = WorkflowInput.empty(); + @Builder.Default String language = "zh"; + String stepName; + + /** Engine-assembled, protocol-neutral input selected by the workflow definition. */ + @Builder.Default WorkflowInput workflowInput = WorkflowInput.empty(); } 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 index e5407feb..fb9fef30 100644 --- 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 @@ -19,49 +19,54 @@ package dev.openan.workflow.engine.model; +import java.util.List; +import java.util.Map; import lombok.Builder; import lombok.Value; -import java.util.List; - @Value @Builder public class TaskResult { - private boolean success; + boolean success; + + /** Ordered business outputs; values may be text or JSON-serializable structured data. */ + @Builder.Default List<Object> outputs = List.of(); - /** Ordered business outputs; values may be text or JSON-serializable structured data. */ - @Builder.Default private List<Object> outputs = List.of(); - /** Remote evidence; local onSelfTask outputs leave this empty. */ - @Builder.Default private List<ReceivedMessage> receivedMessages = List.of(); + /** Remote evidence; local onSelfTask outputs leave this empty. */ + @Builder.Default List<ReceivedMessage> receivedMessages = List.of(); - private String error; - private String errorCode; - @Builder.Default private java.util.Map<String, Object> errorDetails = java.util.Map.of(); + String error; + String errorCode; + @Builder.Default Map<String, Object> errorDetails = java.util.Map.of(); - /** Successful local business output, including an intentionally empty output list. */ - public static TaskResult success(List<Object> outputs) { - return builder().success(true).outputs(outputs).build(); - } + public TaskResult( + boolean success, + List<Object> outputs, + List<ReceivedMessage> receivedMessages, + String error, + String errorCode, + java.util.Map<String, Object> 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); + } - /** 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(); - } + /** Successful local business output, including an intentionally empty output list. */ + public static TaskResult success(List<Object> outputs) { + return builder().success(true).outputs(outputs).build(); + } - public TaskResult( - boolean success, - List<Object> outputs, - List<ReceivedMessage> receivedMessages, - String error, - String errorCode, - java.util.Map<String, Object> 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); - } + /** 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(); + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskStatus.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskStatus.java index 48f175f6..f9f91813 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskStatus.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskStatus.java @@ -20,22 +20,22 @@ package dev.openan.workflow.engine.model; public enum TaskStatus { - PENDING("pending"), - RUNNING("running"), - SUCCESS("success"), - FAILED("failed"); + PENDING("pending"), + RUNNING("running"), + SUCCESS("success"), + FAILED("failed"); - private final String value; + private final String value; - TaskStatus(String value) { - this.value = value; - } + TaskStatus(String value) { + this.value = value; + } - /** - * Returns the lowercase string value, matching the Python SDK. Used in {@code - * task_status_changed} events for cross-SDK consistency. - */ - public String getValue() { - return value; - } + /** + * Returns the lowercase string value, matching the Python SDK. Used in {@code + * task_status_changed} events for cross-SDK consistency. + */ + public String getValue() { + return value; + } } 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 index 7c8df2f1..630ddfa6 100644 --- 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 @@ -24,10 +24,10 @@ /** Immutable, step-scoped group of upstream task results selected by {@code contextFrom}. */ public record UpstreamStepResult(String stepName, List<TaskExecutionResult> 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); + 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/Workflow.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/Workflow.java index c5e727d6..bd7e01b4 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/Workflow.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/Workflow.java @@ -19,94 +19,91 @@ package dev.openan.workflow.engine.model; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - @Data @NoArgsConstructor @AllArgsConstructor @Builder public class Workflow { - @Builder.Default private String id = ""; - @Builder.Default private String name = ""; - @Builder.Default private String description = ""; - @Builder.Default private List<WorkflowStep> steps = List.of(); + @Builder.Default private String id = ""; + @Builder.Default private String name = ""; + @Builder.Default private String description = ""; + @Builder.Default private List<WorkflowStep> steps = List.of(); - @SuppressWarnings("unchecked") - public static Workflow fromMap(Map<String, Object> data) { - Workflow wf = new Workflow(); - wf.setId((String) data.getOrDefault("id", "")); - wf.setName((String) data.getOrDefault("name", "")); - wf.setDescription((String) data.getOrDefault("description", "")); - List<WorkflowStep> steps = - parseSteps((List<Map<String, Object>>) data.getOrDefault("steps", List.of())); - wf.setSteps(steps); - return wf; - } + @SuppressWarnings("unchecked") + public static Workflow fromMap(Map<String, Object> data) { + Workflow wf = new Workflow(); + wf.setId((String) data.getOrDefault("id", "")); + wf.setName((String) data.getOrDefault("name", "")); + wf.setDescription((String) data.getOrDefault("description", "")); + List<WorkflowStep> steps = + parseSteps((List<Map<String, Object>>) data.getOrDefault("steps", List.of())); + wf.setSteps(steps); + return wf; + } - @SuppressWarnings("unchecked") - private static List<WorkflowStep> parseSteps(List<Map<String, Object>> stepList) { - List<WorkflowStep> steps = new ArrayList<>(); - for (Map<String, Object> s : stepList) { - List<Task> subtasks = - parseSubtasks( - (List<Map<String, Object>>) s.getOrDefault("subtasks", List.of())); - List<JumpCondition> nextList = - parseNextSteps((List<Map<String, Object>>) s.getOrDefault("next", List.of())); - List<String> contextFrom = parseContextFrom(s.get("context_from")); - int layer = s.get("layer") instanceof Number num ? num.intValue() : 0; - String stValue = - (String) s.getOrDefault("step_type", s.getOrDefault("type", "AllSuccess")); - steps.add( - WorkflowStep.builder() - .name((String) s.getOrDefault("name", "")) - .subtasks(subtasks) - .next(nextList) - .layer(layer) - .contextFrom(contextFrom) - .stepType(StepType.fromValue(stValue)) - .build()); - } - return steps; + @SuppressWarnings("unchecked") + private static List<WorkflowStep> parseSteps(List<Map<String, Object>> stepList) { + List<WorkflowStep> steps = new ArrayList<>(); + for (Map<String, Object> s : stepList) { + List<Task> subtasks = + parseSubtasks((List<Map<String, Object>>) s.getOrDefault("subtasks", List.of())); + List<JumpCondition> nextList = + parseNextSteps((List<Map<String, Object>>) s.getOrDefault("next", List.of())); + List<String> contextFrom = parseContextFrom(s.get("context_from")); + int layer = s.get("layer") instanceof Number num ? num.intValue() : 0; + String stValue = (String) s.getOrDefault("step_type", s.getOrDefault("type", "AllSuccess")); + steps.add( + WorkflowStep.builder() + .name((String) s.getOrDefault("name", "")) + .subtasks(subtasks) + .next(nextList) + .layer(layer) + .contextFrom(contextFrom) + .stepType(StepType.fromValue(stValue)) + .build()); } + return steps; + } - @SuppressWarnings("unchecked") - private static List<Task> parseSubtasks(List<Map<String, Object>> stList) { - List<Task> subtasks = new ArrayList<>(); - for (Map<String, Object> t : stList) { - subtasks.add( - Task.builder() - .agent((String) t.getOrDefault("agent", "")) - .skill((String) t.getOrDefault("skill", "")) - .description((String) t.getOrDefault("description", "")) - .build()); - } - return subtasks; + @SuppressWarnings("unchecked") + private static List<Task> parseSubtasks(List<Map<String, Object>> stList) { + List<Task> subtasks = new ArrayList<>(); + for (Map<String, Object> t : stList) { + subtasks.add( + Task.builder() + .agent((String) t.getOrDefault("agent", "")) + .skill((String) t.getOrDefault("skill", "")) + .description((String) t.getOrDefault("description", "")) + .build()); } + return subtasks; + } - @SuppressWarnings("unchecked") - private static List<JumpCondition> parseNextSteps(List<Map<String, Object>> jcList) { - List<JumpCondition> nextList = new ArrayList<>(); - for (Map<String, Object> jc : jcList) { - nextList.add( - JumpCondition.builder() - .step((String) jc.getOrDefault("step", "")) - .condition((String) jc.getOrDefault("condition", "")) - .build()); - } - return nextList; + @SuppressWarnings("unchecked") + private static List<JumpCondition> parseNextSteps(List<Map<String, Object>> jcList) { + List<JumpCondition> nextList = new ArrayList<>(); + for (Map<String, Object> jc : jcList) { + nextList.add( + JumpCondition.builder() + .step((String) jc.getOrDefault("step", "")) + .condition((String) jc.getOrDefault("condition", "")) + .build()); } + return nextList; + } - @SuppressWarnings("unchecked") - private static List<String> parseContextFrom(Object cfRaw) { - if (cfRaw instanceof List) return (List<String>) cfRaw; - if (cfRaw instanceof String cfStr && !cfStr.isEmpty()) return List.of(cfStr); - return null; - } + @SuppressWarnings("unchecked") + private static List<String> parseContextFrom(Object cfRaw) { + if (cfRaw instanceof List) return (List<String>) cfRaw; + if (cfRaw instanceof String cfStr && !cfStr.isEmpty()) return List.of(cfStr); + return null; + } } 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 index ce6f269c..5dc29e9f 100644 --- 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 @@ -30,12 +30,12 @@ */ public record WorkflowInput(String runtimeIntent, List<UpstreamStepResult> upstreamResults) { - public WorkflowInput { - runtimeIntent = runtimeIntent == null ? "" : runtimeIntent; - upstreamResults = upstreamResults == null ? List.of() : List.copyOf(upstreamResults); - } + public WorkflowInput { + runtimeIntent = runtimeIntent == null ? "" : runtimeIntent; + upstreamResults = upstreamResults == null ? List.of() : List.copyOf(upstreamResults); + } - public static WorkflowInput empty() { - return new WorkflowInput("", List.of()); - } + public static WorkflowInput empty() { + return new WorkflowInput("", List.of()); + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowSearchResult.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowSearchResult.java index 71909202..48cc71f7 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowSearchResult.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowSearchResult.java @@ -20,33 +20,32 @@ package dev.openan.workflow.engine.model; import dev.openan.workflow.engine.registry.LoadPsop; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import java.util.List; - /** * Summary of a PSOP workflow returned by the search endpoint. * * <p>Mirrors the Python orchestration center's {@code WorkflowSearchResult}. Returned by {@link - * LoadPsop#search}. To get the full workflow with steps, take - * {@code getWorkflowId()} and call {@link LoadPsop#load}. + * LoadPsop#search}. To get the full workflow with steps, take {@code getWorkflowId()} and call + * {@link LoadPsop#load}. */ @Data @NoArgsConstructor @AllArgsConstructor @Builder public class WorkflowSearchResult { - private String workflowId; - private String workflowType; - private String name; - private String description; - @Builder.Default private List<String> tags = List.of(); - private String createdAt; - @Builder.Default private double score = 1.0; - private String userIntent; - private String relatedPreflow; - private String tasksSummary; + private String workflowId; + private String workflowType; + private String name; + private String description; + @Builder.Default private List<String> tags = List.of(); + private String createdAt; + @Builder.Default private double score = 1.0; + private String userIntent; + private String relatedPreflow; + private String tasksSummary; } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowStep.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowStep.java index dea26b5c..c109c67a 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowStep.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowStep.java @@ -19,22 +19,21 @@ package dev.openan.workflow.engine.model; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import java.util.List; - @Data @NoArgsConstructor @AllArgsConstructor @Builder public class WorkflowStep { - private String name; - @Builder.Default private List<Task> subtasks = List.of(); - @Builder.Default private List<JumpCondition> next = List.of(); - @Builder.Default private int layer = 0; - private List<String> contextFrom; - @Builder.Default private StepType stepType = StepType.ALL_SUCCESS; + private String name; + @Builder.Default private List<Task> subtasks = List.of(); + @Builder.Default private List<JumpCondition> next = List.of(); + @Builder.Default private int layer = 0; + private List<String> contextFrom; + @Builder.Default private StepType stepType = StepType.ALL_SUCCESS; } 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 371f2e96..c701426b 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 @@ -23,12 +23,6 @@ import dev.openan.workflow.engine.client.SslContextFactory; import dev.openan.workflow.engine.model.Workflow; import dev.openan.workflow.engine.model.WorkflowSearchResult; - -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; @@ -37,6 +31,9 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import javax.net.ssl.HttpsURLConnection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Load and search PSOP workflows from the orchestration center's external API. @@ -47,122 +44,120 @@ * </ul> */ public class LoadPsop { - private static final Logger log = LoggerFactory.getLogger(LoadPsop.class); - private static final ObjectMapper mapper = new ObjectMapper(); + private static final Logger log = LoggerFactory.getLogger(LoadPsop.class); + private static final ObjectMapper mapper = new ObjectMapper(); - public static Workflow load( - String baseUrl, String psopId, String accessToken, boolean sslVerify) throws Exception { - StringBuilder urlBuilder = - new StringBuilder(baseUrl).append("/api/v1/orchestrate/psop/").append(psopId); - if (accessToken != null && !accessToken.isEmpty()) { - urlBuilder - .append("?access_token=") - .append(URLEncoder.encode(accessToken, StandardCharsets.UTF_8)); - } - String url = urlBuilder.toString(); - log.info("[Registry] Loading PSOP from {} (ssl_verify={})", url, sslVerify); - HttpResult resp = execute("GET", url, null, sslVerify); - if (resp.statusCode() != 200) { - throw new RuntimeException("Orchestration center returned " + resp.statusCode()); - } - @SuppressWarnings("unchecked") - Map<String, Object> data = mapper.readValue(resp.body(), Map.class); - @SuppressWarnings("unchecked") - Map<String, Object> psopData = (Map<String, Object>) data.getOrDefault("data", data); - Workflow wf = Workflow.fromMap(psopData); - log.info("[Registry] Loaded workflow: {}, {} steps", wf.getName(), wf.getSteps().size()); - return wf; + public static Workflow load(String baseUrl, String psopId, String accessToken, boolean sslVerify) + throws Exception { + StringBuilder urlBuilder = + new StringBuilder(baseUrl).append("/api/v1/orchestrate/psop/").append(psopId); + if (accessToken != null && !accessToken.isEmpty()) { + urlBuilder + .append("?access_token=") + .append(URLEncoder.encode(accessToken, StandardCharsets.UTF_8)); } - - public static Workflow load(String baseUrl, String psopId) throws Exception { - return load(baseUrl, psopId, null, true); + String url = urlBuilder.toString(); + log.info("[Registry] Loading PSOP from {} (ssl_verify={})", anonymousUrl(url, accessToken), sslVerify); + HttpResult resp = execute("GET", url, null, sslVerify); + if (resp.statusCode() != 200) { + throw new RuntimeException("Orchestration center returned " + resp.statusCode()); } + @SuppressWarnings("unchecked") + Map<String, Object> data = mapper.readValue(resp.body(), Map.class); + @SuppressWarnings("unchecked") + Map<String, Object> psopData = (Map<String, Object>) data.getOrDefault("data", data); + Workflow wf = Workflow.fromMap(psopData); + log.info("[Registry] Loaded workflow: {}, {} steps", wf.getName(), wf.getSteps().size()); + return wf; + } - public static List<WorkflowSearchResult> search( - String baseUrl, String intent, int topN, String accessToken, boolean sslVerify) - throws Exception { - StringBuilder urlBuilder = new StringBuilder(baseUrl).append("/api/v1/orchestrate/search"); - if (accessToken != null && !accessToken.isEmpty()) { - urlBuilder - .append("?access_token=") - .append(URLEncoder.encode(accessToken, StandardCharsets.UTF_8)); - } - 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)); - HttpResult resp = execute("POST", url, jsonBody, sslVerify); - if (resp.statusCode() != 200) { - throw new RuntimeException("Orchestration center returned " + resp.statusCode()); - } - @SuppressWarnings("unchecked") - Map<String, Object> data = mapper.readValue(resp.body(), Map.class); - @SuppressWarnings("unchecked") - List<Map<String, Object>> rawResults = - (List<Map<String, Object>>) data.getOrDefault("data", List.of()); - List<WorkflowSearchResult> results = new java.util.ArrayList<>(); - for (Map<String, Object> raw : rawResults) { - results.add( - WorkflowSearchResult.builder() - .workflowId( - (String) - raw.getOrDefault( - "workflow_id", raw.getOrDefault("id", ""))) - .workflowType((String) raw.getOrDefault("workflow_type", "")) - .name((String) raw.getOrDefault("name", "")) - .description((String) raw.getOrDefault("description", null)) - .createdAt( - raw.get("created_at") != null - ? raw.get("created_at").toString() - : "") - .score(raw.get("score") instanceof Number n ? n.doubleValue() : 1.0) - .userIntent((String) raw.getOrDefault("user_intent", null)) - .relatedPreflow((String) raw.getOrDefault("related_preflow", null)) - .tasksSummary((String) raw.getOrDefault("tasks_summary", null)) - .build()); - } - log.info("[Registry] Search returned {} workflow(s)", results.size()); - return results; + public static Workflow load(String baseUrl, String psopId) throws Exception { + return load(baseUrl, psopId, null, true); + } + + public static List<WorkflowSearchResult> search( + String baseUrl, String intent, int topN, String accessToken, boolean sslVerify) + throws Exception { + StringBuilder urlBuilder = new StringBuilder(baseUrl).append("/api/v1/orchestrate/search"); + if (accessToken != null && !accessToken.isEmpty()) { + urlBuilder + .append("?access_token=") + .append(URLEncoder.encode(accessToken, StandardCharsets.UTF_8)); + } + String url = urlBuilder.toString(); + log.info("[Registry] Searching PSOP at {} (intent={}, top_n={})", anonymousUrl(url, accessToken), intent, topN); + String jsonBody = mapper.writeValueAsString(Map.of("intent", intent, "top_n", topN)); + HttpResult resp = execute("POST", url, jsonBody, sslVerify); + if (resp.statusCode() != 200) { + throw new RuntimeException("Orchestration center returned " + resp.statusCode()); } + @SuppressWarnings("unchecked") + Map<String, Object> data = mapper.readValue(resp.body(), Map.class); + @SuppressWarnings("unchecked") + List<Map<String, Object>> rawResults = + (List<Map<String, Object>>) data.getOrDefault("data", List.of()); + List<WorkflowSearchResult> results = new java.util.ArrayList<>(); + for (Map<String, Object> raw : rawResults) { + results.add( + WorkflowSearchResult.builder() + .workflowId((String) raw.getOrDefault("workflow_id", raw.getOrDefault("id", ""))) + .workflowType((String) raw.getOrDefault("workflow_type", "")) + .name((String) raw.getOrDefault("name", "")) + .description((String) raw.getOrDefault("description", null)) + .createdAt(raw.get("created_at") != null ? raw.get("created_at").toString() : "") + .score(raw.get("score") instanceof Number n ? n.doubleValue() : 1.0) + .userIntent((String) raw.getOrDefault("user_intent", null)) + .relatedPreflow((String) raw.getOrDefault("related_preflow", null)) + .tasksSummary((String) raw.getOrDefault("tasks_summary", null)) + .build()); + } + log.info("[Registry] Search returned {} workflow(s)", results.size()); + 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(); + /** Preserve token presence for diagnostics without exposing any token characters or length. */ + static String anonymousUrl(String url, String token) { + return token == null || token.isEmpty() + ? url + : url.substring(0, url.indexOf("?access_token=")) + "?access_token=<anonymous>"; + } + + 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<WorkflowSearchResult> search(String baseUrl, String intent) throws Exception { + return search(baseUrl, intent, 5, null, true); + } - /** Convenience: search with defaults (top_n=5, no token, ssl_verify=true). */ - public static List<WorkflowSearchResult> search(String baseUrl, String intent) - throws Exception { - return search(baseUrl, intent, 5, null, true); - } + private record HttpResult(int statusCode, String body) {} } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/RegistryClient.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/RegistryClient.java index 7ce47ddb..fe29caeb 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/RegistryClient.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/RegistryClient.java @@ -22,128 +22,150 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.openan.workflow.engine.client.AgentCardNormalizer; import dev.openan.workflow.engine.client.SslContextFactory; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - 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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Optional helper for fetching AgentCards from the Registry Center. Users can use this, or fetch * AgentCards from any other source. */ public class RegistryClient { - private static final Logger log = LoggerFactory.getLogger(RegistryClient.class); - private static final ObjectMapper mapper = new ObjectMapper(); + private static final Logger log = LoggerFactory.getLogger(RegistryClient.class); + private static final ObjectMapper mapper = new ObjectMapper(); - private final String baseUrl; - private final HttpClient httpClient; + private final String baseUrl; + private final HttpClient httpClient; + private final java.time.Duration requestTimeout; - public RegistryClient(String url, boolean sslVerify) { - this.baseUrl = url.endsWith("/") ? url.substring(0, url.length() - 1) : url; - HttpClient.Builder clientBuilder = - HttpClient.newBuilder() - .connectTimeout(java.time.Duration.ofSeconds(30)) - .followRedirects(HttpClient.Redirect.ALWAYS); - if (!sslVerify) { - clientBuilder.sslContext(SslContextFactory.createTrustAll()); - } - this.httpClient = clientBuilder.build(); - } + public RegistryClient(String url, boolean sslVerify) { + this(url, sslVerify, java.time.Duration.ofSeconds(30)); + } - @SuppressWarnings("unchecked") - public List<Map<String, Object>> fetchAgentCards() throws Exception { - String url = baseUrl + "/rest/v1/registry-center/agent-cards"; - log.info("[Registry] Fetching all agent cards from {}", baseUrl); - HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build(); - HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (resp.statusCode() != 200) { - throw new RuntimeException("Registry returned " + resp.statusCode()); - } - Map<String, Object> data = mapper.readValue(resp.body(), Map.class); - List<Map<String, Object>> cards = - (List<Map<String, Object>>) - data.getOrDefault("agentCards", data.getOrDefault("data", List.of())); - // Normalize each card's security schemes to a compatible format - cards = cards.stream().map(AgentCardNormalizer::normalize).toList(); - log.info("[Registry] Received {} agent card(s)", cards.size()); - return cards; + /** Creates a discovery client with a positive deadline for each complete HTTP response. */ + public RegistryClient(String url, boolean sslVerify, java.time.Duration requestTimeout) { + if (requestTimeout == null || requestTimeout.isZero() || requestTimeout.isNegative()) { + throw new IllegalArgumentException("requestTimeout must be positive"); + } + this.requestTimeout = requestTimeout; + this.baseUrl = url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + HttpClient.Builder clientBuilder = + HttpClient.newBuilder() + .connectTimeout(java.time.Duration.ofSeconds(30)) + .followRedirects(HttpClient.Redirect.ALWAYS); + if (!sslVerify) { + clientBuilder.sslContext(SslContextFactory.createTrustAll()); } + this.httpClient = clientBuilder.build(); + } - @SuppressWarnings("unchecked") - public Map<String, Object> fetchAgentCard(String name) throws Exception { - return fetchAgentCard(name, null); + @SuppressWarnings("unchecked") + public List<Map<String, Object>> fetchAgentCards() throws Exception { + String url = baseUrl + "/rest/v1/registry-center/agent-cards"; + log.info("[Registry] Fetching all agent cards from {}", baseUrl); + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).timeout(requestTimeout).GET().build(); + HttpResponse<String> resp = send(request); + if (resp.statusCode() != 200) { + throw new RuntimeException("Registry returned " + resp.statusCode()); } + Map<String, Object> data = mapper.readValue(resp.body(), Map.class); + List<Map<String, Object>> cards = + (List<Map<String, Object>>) + data.getOrDefault("agentCards", data.getOrDefault("data", List.of())); + // Normalize each card's security schemes to a compatible format + cards = cards.stream().map(AgentCardNormalizer::normalize).toList(); + log.info("[Registry] Received {} agent card(s)", cards.size()); + return cards; + } + + @SuppressWarnings("unchecked") + public Map<String, Object> fetchAgentCard(String name) throws Exception { + return fetchAgentCard(name, null); + } - @SuppressWarnings("unchecked") - public Map<String, Object> fetchAgentCard(String name, String organization) throws Exception { - StringBuilder urlBuilder = - new StringBuilder(baseUrl) - .append("/rest/v1/registry-center/agent-cards?name=") - .append( - java.net.URLEncoder.encode( - name != null ? name : "", - java.nio.charset.StandardCharsets.UTF_8)); - if (organization != null && !organization.isEmpty()) { - urlBuilder - .append("&organization=") - .append( - java.net.URLEncoder.encode( - organization, java.nio.charset.StandardCharsets.UTF_8)); - } - String url = urlBuilder.toString(); - log.info("[Registry] Fetching agent card: name={}, org={}", name, organization); - HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build(); - HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (resp.statusCode() != 200) { - throw new RuntimeException("Registry returned " + resp.statusCode()); - } - Map<String, Object> data = mapper.readValue(resp.body(), Map.class); - List<Map<String, Object>> cards = - (List<Map<String, Object>>) - data.getOrDefault("agentCards", data.getOrDefault("data", List.of())); - if (cards.isEmpty()) { - log.warn("[Registry] Agent card not found: name={}", name); - return null; - } - log.info("[Registry] Agent card found: name={}", name); - return AgentCardNormalizer.normalize(cards.get(0)); + @SuppressWarnings("unchecked") + public Map<String, Object> fetchAgentCard(String name, String organization) throws Exception { + StringBuilder urlBuilder = + new StringBuilder(baseUrl) + .append("/rest/v1/registry-center/agent-cards?name=") + .append( + java.net.URLEncoder.encode( + name != null ? name : "", java.nio.charset.StandardCharsets.UTF_8)); + if (organization != null && !organization.isEmpty()) { + urlBuilder + .append("&organization=") + .append( + java.net.URLEncoder.encode(organization, java.nio.charset.StandardCharsets.UTF_8)); } + String url = urlBuilder.toString(); + log.info("[Registry] Fetching agent card: name={}, org={}", name, organization); + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).timeout(requestTimeout).GET().build(); + HttpResponse<String> resp = send(request); + if (resp.statusCode() != 200) { + throw new RuntimeException("Registry returned " + resp.statusCode()); + } + Map<String, Object> data = mapper.readValue(resp.body(), Map.class); + List<Map<String, Object>> cards = + (List<Map<String, Object>>) + data.getOrDefault("agentCards", data.getOrDefault("data", List.of())); + if (cards.isEmpty()) { + log.warn("[Registry] Agent card not found: name={}", name); + return null; + } + log.info("[Registry] Agent card found: name={}", name); + return AgentCardNormalizer.normalize(cards.get(0)); + } + + public String getBaseUrl() { + return baseUrl; + } - public String getBaseUrl() { - return baseUrl; + /** + * Register or update an AgentCard in the registry. + * + * @param agentCard the agent card to register (must include name, supportedInterfaces, etc.) + * @return the registry response as a Map + */ + @SuppressWarnings("unchecked") + public Map<String, Object> registerAgentCard(Map<String, Object> agentCard) throws Exception { + String url = baseUrl + "/rest/v1/registry-center/agent-cards"; + Map<String, Object> payload = Map.of("agentCards", List.of(agentCard)); + String json = mapper.writeValueAsString(payload); + log.info("[Registry] Registering agent card: name={}", agentCard.get("name")); + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(requestTimeout) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + HttpResponse<String> resp = send(request); + Map<String, Object> result = mapper.readValue(resp.body(), Map.class); + if (resp.statusCode() == 200 || resp.statusCode() == 201) { + log.info("[Registry] Agent card registered: name={}", agentCard.get("name")); + } else { + log.warn("[Registry] Registration returned {}: {}", resp.statusCode(), resp.body()); } + return result; + } - /** - * Register or update an AgentCard in the registry. - * - * @param agentCard the agent card to register (must include name, supportedInterfaces, etc.) - * @return the registry response as a Map - */ - @SuppressWarnings("unchecked") - public Map<String, Object> registerAgentCard(Map<String, Object> agentCard) throws Exception { - String url = baseUrl + "/rest/v1/registry-center/agent-cards"; - Map<String, Object> payload = Map.of("agentCards", List.of(agentCard)); - String json = mapper.writeValueAsString(payload); - log.info("[Registry] Registering agent card: name={}", agentCard.get("name")); - HttpRequest request = - HttpRequest.newBuilder() - .uri(URI.create(url)) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(json)) - .build(); - HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - Map<String, Object> result = mapper.readValue(resp.body(), Map.class); - if (resp.statusCode() == 200 || resp.statusCode() == 201) { - log.info("[Registry] Agent card registered: name={}", agentCard.get("name")); - } else { - log.warn("[Registry] Registration returned {}: {}", resp.statusCode(), resp.body()); - } - return result; + private HttpResponse<String> send(HttpRequest request) throws Exception { + var response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()); + try { + // Also bound body consumption: a peer may send headers and then stall its body. + return response.get(Math.max(1, requestTimeout.toMillis()), java.util.concurrent.TimeUnit.MILLISECONDS); + } catch (java.util.concurrent.TimeoutException error) { + response.cancel(true); + throw new java.net.http.HttpTimeoutException("Registry response deadline exceeded"); + } catch (InterruptedException error) { + response.cancel(true); + Thread.currentThread().interrupt(); + throw error; } + } } 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 a6f367f0..df96eec5 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 @@ -30,11 +30,6 @@ import dev.openan.workflow.engine.core.WorkflowExecutor; import dev.openan.workflow.engine.model.ExecutionResult; import dev.openan.workflow.engine.model.Workflow; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -46,6 +41,9 @@ import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Function; +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * High-level PSOP runner -- execute + emit events + persistence hook. @@ -55,438 +53,462 @@ * called with (result, collectedEvents). */ public class ExecutePsop { - private static final Logger log = LoggerFactory.getLogger(ExecutePsop.class); - - /** - * Execute a PSOP workflow end-to-end. - * - * @param psop workflow object - * @param agentCards list of AgentCard objects - * @param controlPoint user's decision callbacks - * @param engineClient optional pre-created client (null = auto-create) - * @param runtimeIntent original user intent - * @param lang language hint (zh/en) - * @param credentialsConfigPath path to credentials JSON - * @param sslVerify whether to verify TLS certificates - * @param caCertsPath path to CA trust store - * @param a2aClientRuntime the A2A client runtime - * @param eventCallback optional event callback (null = no-op) - * @param onFinish optional persistence hook - * @param onEvent optional event transformer - * @return CompletableFuture<ExecutionResult> - */ - public static CompletableFuture<ExecutionResult> execute( - Workflow psop, - List<AgentCard> agentCards, - ControlPoint controlPoint, - WorkflowEngineClient engineClient, - String runtimeIntent, - String lang, - String credentialsConfigPath, - boolean sslVerify, - String caCertsPath, - A2AJavaClientRuntime a2aClientRuntime, - EventCallback eventCallback, - BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> - onFinish, - Function<Map<String, Object>, Object> onEvent) { - - Objects.requireNonNull(psop, "psop"); - Objects.requireNonNull(controlPoint, "controlPoint"); - EventCallback emitter = eventCallback != null ? eventCallback : new EventCallback(); - List<Map<String, Object>> collected = Collections.synchronizedList(new ArrayList<>()); - EventCallback collectingCallback = createCollectingCallback(emitter, collected, onEvent); - WorkflowEngineClient client = - engineClient != null - ? engineClient - : createEngineClient( - agentCards, - a2aClientRuntime, - sslVerify, - caCertsPath, - credentialsConfigPath, - collectingCallback); - boolean closeClientOnFinish = engineClient == null; - WorkflowExecutor executor = - new WorkflowExecutor( - psop, - controlPoint, - client, - collectingCallback, - runtimeIntent != null ? runtimeIntent : "", - lang != null ? lang : "zh"); - log.info( - "[execute_psop] Starting: workflow={}, {} steps, intent={}", - psop.getName(), - psop.getSteps().size(), - runtimeIntent); - collectingCallback.onEvent( - EventType.START, - Map.of("workflow", psop.getName(), "steps", psop.getSteps().size())); - return executor.run() - .exceptionally(ExecutePsop::handleExecutionError) - .thenCompose( - result -> - finalizeResult( - result, - client, - closeClientOnFinish, - collectingCallback, - collected, - onFinish)); - } - - /** Convenience overload using default transport/authentication configuration. */ - public static CompletableFuture<ExecutionResult> execute( - Workflow psop, - List<AgentCard> agentCards, - ControlPoint controlPoint, - WorkflowEngineClient engineClient, - String runtimeIntent, - String lang, - A2AJavaClientRuntime a2aClientRuntime, - EventCallback eventCallback, - BiConsumer<ExecutionResult, List<Map<String, Object>>> onFinish, - Function<Map<String, Object>, Object> onEvent) { - BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> - asyncOnFinish = - onFinish != null - ? (r, e) -> { - onFinish.accept(r, e); - return CompletableFuture.completedFuture(null); - } - : null; - return execute( - psop, + private static final Logger log = LoggerFactory.getLogger(ExecutePsop.class); + + /** + * Execute a PSOP workflow end-to-end. + * + * @param psop workflow object + * @param agentCards list of AgentCard objects + * @param controlPoint user's decision callbacks + * @param engineClient optional pre-created client (null = auto-create) + * @param runtimeIntent original user intent + * @param lang language hint (zh/en) + * @param credentialsConfigPath path to credentials JSON + * @param sslVerify whether to verify TLS certificates + * @param caCertsPath path to CA trust store + * @param a2aClientRuntime the A2A client runtime + * @param eventCallback optional event callback (null = no-op) + * @param onFinish optional persistence hook + * @param onEvent optional event transformer + * @return CompletableFuture<ExecutionResult> + */ + public static CompletableFuture<ExecutionResult> execute( + Workflow psop, + List<AgentCard> agentCards, + ControlPoint controlPoint, + WorkflowEngineClient engineClient, + String runtimeIntent, + String lang, + String credentialsConfigPath, + boolean sslVerify, + String caCertsPath, + A2AJavaClientRuntime a2aClientRuntime, + EventCallback eventCallback, + BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> onFinish, + Function<Map<String, Object>, Object> onEvent) { + + Objects.requireNonNull(psop, "psop"); + Objects.requireNonNull(controlPoint, "controlPoint"); + EventCallback emitter = eventCallback != null ? eventCallback : new EventCallback(); + List<Map<String, Object>> collected = Collections.synchronizedList(new ArrayList<>()); + EventCallback collectingCallback = createCollectingCallback(emitter, collected, onEvent); + WorkflowEngineClient client = + engineClient != null + ? engineClient + : createEngineClient( agentCards, - controlPoint, - engineClient, - runtimeIntent, - lang, - null, - true, - null, a2aClientRuntime, - eventCallback, - asyncOnFinish, - onEvent); - } - - public static Builder builder() { - return new Builder(); + sslVerify, + caCertsPath, + credentialsConfigPath, + collectingCallback); + boolean closeClientOnFinish = engineClient == null; + CompletableFuture<ExecutionResult> execution; + try { + WorkflowExecutor executor = + new WorkflowExecutor( + psop, + controlPoint, + client, + collectingCallback, + runtimeIntent != null ? runtimeIntent : "", + lang != null ? lang : "zh"); + log.info( + "[execute_psop] Starting: workflow={}, {} steps, intent={}", + psop.getName(), + psop.getSteps().size(), + runtimeIntent); + collectingCallback.onEvent( + EventType.START, Map.of("workflow", psop.getName(), "steps", psop.getSteps().size())); + execution = executor.run(); + } catch (RuntimeException error) { + closeExecution(client, closeClientOnFinish, collectingCallback); + return CompletableFuture.failedFuture(error); } - - @SuppressWarnings("unchecked") - private static EventCallback createCollectingCallback( - EventCallback emitter, - List<Map<String, Object>> collected, - Function<Map<String, Object>, Object> onEvent) { - return new EventCallback() { - @Override - public void onEvent(String eventType, Map<String, Object> data) { - Map<String, Object> event = new HashMap<>(); - event.put("type", eventType); - event.put("data", serialize(data)); - event.put("timestamp", System.currentTimeMillis() / 1000.0); - Object transformed = transformEvent(event, onEvent); - if (transformed == null) return; - dispatchTransformed(transformed, eventType, emitter, collected); + CompletableFuture<ExecutionResult> exposed = new CompletableFuture<>(); + var closed = new java.util.concurrent.atomic.AtomicBoolean(); + Runnable cleanup = () -> { + if (closed.compareAndSet(false, true)) { + closeExecution(client, closeClientOnFinish, collectingCallback); + } + }; + var finishing = new java.util.concurrent.atomic.AtomicReference<CompletableFuture<ExecutionResult>>(); + exposed.whenComplete((result, error) -> { + if (exposed.isCancelled()) { + execution.cancel(true); + CompletableFuture<ExecutionResult> pending = finishing.get(); + if (pending != null) pending.cancel(true); + cleanup.run(); + } + }); + // Observe the source independently: cancelling the public future must not skip cleanup. + execution.whenComplete((result, error) -> { + ExecutionResult outcome = error == null ? result : handleExecutionError(error); + CompletableFuture<ExecutionResult> completion = + finalizeResult(outcome, collectingCallback, collected, onFinish, cleanup); + finishing.set(completion); + if (exposed.isCancelled()) completion.cancel(true); + completion.whenComplete((value, failure) -> { + if (failure == null) exposed.complete(value); + else exposed.completeExceptionally(failure); + }); + }); + return exposed; + } + + /** Convenience overload using default transport/authentication configuration. */ + public static CompletableFuture<ExecutionResult> execute( + Workflow psop, + List<AgentCard> agentCards, + ControlPoint controlPoint, + WorkflowEngineClient engineClient, + String runtimeIntent, + String lang, + A2AJavaClientRuntime a2aClientRuntime, + EventCallback eventCallback, + BiConsumer<ExecutionResult, List<Map<String, Object>>> onFinish, + Function<Map<String, Object>, Object> onEvent) { + BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> asyncOnFinish = + onFinish != null + ? (r, e) -> { + onFinish.accept(r, e); + return CompletableFuture.completedFuture(null); } - }; + : null; + return execute( + psop, + agentCards, + controlPoint, + engineClient, + runtimeIntent, + lang, + null, + true, + null, + a2aClientRuntime, + eventCallback, + asyncOnFinish, + onEvent); + } + + public static Builder builder() { + return new Builder(); + } + + @SuppressWarnings("unchecked") + private static EventCallback createCollectingCallback( + EventCallback emitter, + List<Map<String, Object>> collected, + Function<Map<String, Object>, Object> onEvent) { + return new EventCallback() { + @Override + public void onEvent(String eventType, Map<String, Object> data) { + Map<String, Object> event = new HashMap<>(); + event.put("type", eventType); + event.put("data", serialize(data)); + event.put("timestamp", System.currentTimeMillis() / 1000.0); + Object transformed = transformEvent(event, onEvent); + if (transformed == null) return; + dispatchTransformed(transformed, eventType, emitter, collected); + } + }; + } + + private static Object transformEvent( + Map<String, Object> event, Function<Map<String, Object>, Object> onEvent) { + if (onEvent == null) return event; + try { + return onEvent.apply(event); + } catch (Exception e) { + log.warn("[execute_psop] on_event raised: {}", e.getMessage()); + return event; } - - private static Object transformEvent( - Map<String, Object> event, Function<Map<String, Object>, Object> onEvent) { - if (onEvent == null) return event; - try { - return onEvent.apply(event); - } catch (Exception e) { - log.warn("[execute_psop] on_event raised: {}", e.getMessage()); - return event; + } + + @SuppressWarnings("unchecked") + private static void dispatchTransformed( + Object transformed, + String defaultType, + EventCallback emitter, + List<Map<String, Object>> collected) { + if (transformed instanceof List<?> list) { + for (Object e : list) { + if (e instanceof Map<?, ?> m) { + emitEventMap((Map<String, Object>) m, defaultType, emitter, collected); } + } + } else if (transformed instanceof Map<?, ?> m) { + emitEventMap((Map<String, Object>) m, defaultType, emitter, collected); } - - @SuppressWarnings("unchecked") - private static void dispatchTransformed( - Object transformed, - String defaultType, - EventCallback emitter, - List<Map<String, Object>> collected) { - if (transformed instanceof List<?> list) { - for (Object e : list) { - if (e instanceof Map<?, ?> m) { - emitEventMap((Map<String, Object>) m, defaultType, emitter, collected); - } - } - } else if (transformed instanceof Map<?, ?> m) { - emitEventMap((Map<String, Object>) m, defaultType, emitter, collected); - } + } + + @SuppressWarnings("unchecked") + private static void emitEventMap( + Map<String, Object> m, + String defaultType, + EventCallback emitter, + List<Map<String, Object>> collected) { + collected.add(m); + Object typeObj = m.get("type"); + Object dataObj = m.get("data"); + try { + emitter.onEvent( + typeObj != null ? typeObj.toString() : defaultType, + dataObj instanceof Map ? (Map<String, Object>) dataObj : Map.of()); + } catch (Exception e) { + log.warn("[execute_psop] Event callback failed: {}", e.getMessage()); } - - @SuppressWarnings("unchecked") - private static void emitEventMap( - Map<String, Object> m, - String defaultType, - EventCallback emitter, - List<Map<String, Object>> collected) { - collected.add(m); - Object typeObj = m.get("type"); - Object dataObj = m.get("data"); + } + + private static WorkflowEngineClient createEngineClient( + List<AgentCard> agentCards, + A2AJavaClientRuntime a2aClientRuntime, + boolean sslVerify, + String caCertsPath, + String credentialsConfigPath, + EventCallback callback) { + WorkflowEngineClientConfig config = + WorkflowEngineClientConfig.builder() + .sslVerify(sslVerify) + .caCertsPath(caCertsPath) + .credentialsConfigPath(credentialsConfigPath) + .build(); + A2ATransport transport = new A2ATransport(agentCards, a2aClientRuntime, config); + WorkflowEngineClient client = DefaultWorkflowEngineClient.owning(transport, config); + client.setEventCallback(callback); + return client; + } + + private static ExecutionResult handleExecutionError(Throwable error) { + boolean cancelled = error instanceof java.util.concurrent.CancellationException; + String message = cancelled ? "Workflow execution cancelled" + : error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName(); + if (cancelled) log.info("[execute_psop] {}", message); + else log.error("[execute_psop] Execution failed: {}", message); + return ExecutionResult.builder() + .success(false) + .error(message) + .history(List.of()) + .stepOutputs(Map.of()) + .build(); + } + + private static CompletableFuture<ExecutionResult> finalizeResult( + ExecutionResult result, + EventCallback callback, + List<Map<String, Object>> collected, + BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> onFinish, + Runnable cleanup) { + try { + emitResultEvent(result, callback); + } catch (Exception e) { + log.warn("[execute_psop] Result event callback failed: {}", e.getMessage()); + } + CompletableFuture<Void> finishFuture = invokeOnFinish(result, collected, onFinish); + return finishFuture.thenApply( + v -> { + try { + log.info( + "[execute_psop] Finished: workflow success={}, history={}", + result.isSuccess(), + result.getHistory() != null ? result.getHistory().size() : 0); + } finally { + cleanup.run(); + } + return result; + }); + } + + private static void closeExecution( + WorkflowEngineClient client, boolean owned, EventCallback callback) { + try { + callback.onEvent(EventType.CLOSE, Map.of()); + } catch (RuntimeException error) { + log.warn("[execute_psop] Close observer failed", error); + } finally { + if (owned) { try { - emitter.onEvent( - typeObj != null ? typeObj.toString() : defaultType, - dataObj instanceof Map ? (Map<String, Object>) dataObj : Map.of()); - } catch (Exception e) { - log.warn("[execute_psop] Event callback failed: {}", e.getMessage()); + client.close(); + } catch (Exception error) { + log.warn("[execute_psop] Owned client close failed", error); } + } } - - private static WorkflowEngineClient createEngineClient( - List<AgentCard> agentCards, - A2AJavaClientRuntime a2aClientRuntime, - boolean sslVerify, - String caCertsPath, - String credentialsConfigPath, - EventCallback callback) { - WorkflowEngineClientConfig config = - WorkflowEngineClientConfig.builder() - .sslVerify(sslVerify) - .caCertsPath(caCertsPath) - .credentialsConfigPath(credentialsConfigPath) - .build(); - A2ATransport transport = - new A2ATransport(agentCards, a2aClientRuntime, config); - WorkflowEngineClient client = DefaultWorkflowEngineClient.owning(transport, config); - client.setEventCallback(callback); - return client; + } + + private static void emitResultEvent(ExecutionResult result, EventCallback callback) { + if (result.isSuccess()) { + callback.onEvent( + EventType.COMPLETE, + Map.of( + "history", result.getHistory(), + "step_outputs", result.getStepOutputs())); + } else { + callback.onEvent( + EventType.ERROR, + Map.of( + "error", result.getError() != null ? result.getError() : "Execution failed", + "history", result.getHistory(), + "step_outputs", result.getStepOutputs())); } - - private static ExecutionResult handleExecutionError(Throwable error) { - log.error("[execute_psop] Execution failed: {}", error.getMessage()); - return ExecutionResult.builder() - .success(false) - .error(error.getMessage()) - .history(List.of()) - .stepOutputs(Map.of()) - .build(); + } + + private static CompletableFuture<Void> invokeOnFinish( + ExecutionResult result, + List<Map<String, Object>> collected, + BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> onFinish) { + if (onFinish == null) { + return CompletableFuture.completedFuture(null); } - - private static CompletableFuture<ExecutionResult> finalizeResult( - ExecutionResult result, - WorkflowEngineClient client, - boolean closeClientOnFinish, - EventCallback callback, - List<Map<String, Object>> collected, - BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> - onFinish) { - try { - emitResultEvent(result, callback); - } catch (Exception e) { - log.warn("[execute_psop] Result event callback failed: {}", e.getMessage()); - } - CompletableFuture<Void> finishFuture = invokeOnFinish(result, collected, onFinish); - return finishFuture.thenApply( - v -> { - try { - 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; - }); + try { + List<Map<String, Object>> eventSnapshot; + synchronized (collected) { + eventSnapshot = new ArrayList<>(collected); + } + CompletableFuture<Void> 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); } - - private static void emitResultEvent(ExecutionResult result, EventCallback callback) { - if (result.isSuccess()) { - callback.onEvent( - EventType.COMPLETE, - Map.of( - "history", result.getHistory(), - "step_outputs", result.getStepOutputs())); - } else { - callback.onEvent( - EventType.ERROR, - Map.of( - "error", - result.getError() != null - ? result.getError() - : "Execution failed", - "history", result.getHistory(), - "step_outputs", result.getStepOutputs())); - } + } + + @SuppressWarnings("unchecked") + private static Object serialize(Object data) { + if (data == null) return null; + if (data instanceof String || data instanceof Number || data instanceof Boolean) return data; + if (data instanceof Map) { + Map<String, Object> result = new LinkedHashMap<>(); + for (var entry : ((Map<String, Object>) data).entrySet()) { + result.put(entry.getKey(), serialize(entry.getValue())); + } + return result; } - - private static CompletableFuture<Void> invokeOnFinish( - ExecutionResult result, - List<Map<String, Object>> collected, - BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> - onFinish) { - if (onFinish == null) { - return CompletableFuture.completedFuture(null); - } - try { - List<Map<String, Object>> eventSnapshot; - synchronized (collected) { - eventSnapshot = new ArrayList<>(collected); - } - CompletableFuture<Void> 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); - } + if (data instanceof List) { + return ((List<?>) data).stream().map(ExecutePsop::serialize).toList(); } - - @SuppressWarnings("unchecked") - private static Object serialize(Object data) { - if (data == null) return null; - if (data instanceof String || data instanceof Number || data instanceof Boolean) - return data; - if (data instanceof Map) { - Map<String, Object> result = new LinkedHashMap<>(); - for (var entry : ((Map<String, Object>) data).entrySet()) { - result.put(entry.getKey(), serialize(entry.getValue())); - } - return result; - } - if (data instanceof List) { - return ((List<?>) data).stream().map(ExecutePsop::serialize).toList(); - } - return data.toString(); + return data.toString(); + } + + public static final class Builder { + private Workflow psop; + private List<AgentCard> agentCards = List.of(); + private ControlPoint controlPoint; + private WorkflowEngineClient engineClient; + private String runtimeIntent = ""; + private String lang = "zh"; + private String credentialsConfigPath; + private boolean sslVerify = true; + private String caCertsPath; + private A2AJavaClientRuntime a2aClientRuntime; + private EventCallback eventCallback; + private BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> + onFinish; + private Function<Map<String, Object>, Object> onEvent; + + public Builder psop(Workflow psop) { + this.psop = psop; + return this; } - public static final class Builder { - private Workflow psop; - private List<AgentCard> agentCards = List.of(); - private ControlPoint controlPoint; - private WorkflowEngineClient engineClient; - private String runtimeIntent = ""; - private String lang = "zh"; - private String credentialsConfigPath; - private boolean sslVerify = true; - private String caCertsPath; - private A2AJavaClientRuntime a2aClientRuntime; - private EventCallback eventCallback; - private BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> - onFinish; - private Function<Map<String, Object>, Object> onEvent; - - public Builder psop(Workflow psop) { - this.psop = psop; - return this; - } - - public Builder agentCards(List<AgentCard> v) { - this.agentCards = v; - return this; - } + public Builder agentCards(List<AgentCard> v) { + this.agentCards = v; + return this; + } - public Builder controlPoint(ControlPoint v) { - this.controlPoint = v; - return this; - } + public Builder controlPoint(ControlPoint v) { + this.controlPoint = v; + return this; + } - public Builder engineClient(WorkflowEngineClient v) { - this.engineClient = v; - return this; - } + public Builder engineClient(WorkflowEngineClient v) { + this.engineClient = v; + return this; + } - public Builder runtimeIntent(String v) { - this.runtimeIntent = v; - return this; - } + public Builder runtimeIntent(String v) { + this.runtimeIntent = v; + return this; + } - public Builder lang(String v) { - this.lang = v; - return this; - } + public Builder lang(String v) { + this.lang = v; + return this; + } - public Builder credentialsConfigPath(String v) { - this.credentialsConfigPath = v; - return this; - } + public Builder credentialsConfigPath(String v) { + this.credentialsConfigPath = v; + return this; + } - public Builder sslVerify(boolean v) { - this.sslVerify = v; - return this; - } + public Builder sslVerify(boolean v) { + this.sslVerify = v; + return this; + } - public Builder caCertsPath(String v) { - this.caCertsPath = v; - return this; - } + public Builder caCertsPath(String v) { + this.caCertsPath = v; + return this; + } - public Builder a2aClientRuntime(A2AJavaClientRuntime v) { - this.a2aClientRuntime = v; - return this; - } + public Builder a2aClientRuntime(A2AJavaClientRuntime v) { + this.a2aClientRuntime = v; + return this; + } - public Builder eventCallback(EventCallback v) { - this.eventCallback = v; - return this; - } + public Builder eventCallback(EventCallback v) { + this.eventCallback = v; + return this; + } - public Builder onFinish( - BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> v) { - this.onFinish = v; - return this; - } + public Builder onFinish( + BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> v) { + this.onFinish = v; + return this; + } - public Builder onFinish(BiConsumer<ExecutionResult, List<Map<String, Object>>> v) { - this.onFinish = - v != null - ? (r, e) -> { - v.accept(r, e); - return CompletableFuture.completedFuture(null); - } - : null; - return this; - } + public Builder onFinish(BiConsumer<ExecutionResult, List<Map<String, Object>>> v) { + this.onFinish = + v != null + ? (r, e) -> { + v.accept(r, e); + return CompletableFuture.completedFuture(null); + } + : null; + return this; + } - public Builder onEvent(Function<Map<String, Object>, Object> v) { - this.onEvent = v; - return this; - } + public Builder onEvent(Function<Map<String, Object>, Object> v) { + this.onEvent = v; + return this; + } - public CompletableFuture<ExecutionResult> execute() { - if (psop == null) throw new IllegalArgumentException("psop is required"); - if (controlPoint == null) - throw new IllegalArgumentException("controlPoint is required"); - return ExecutePsop.execute( - psop, - agentCards, - controlPoint, - engineClient, - runtimeIntent, - lang, - credentialsConfigPath, - sslVerify, - caCertsPath, - a2aClientRuntime, - eventCallback, - onFinish, - onEvent); - } + public CompletableFuture<ExecutionResult> execute() { + if (psop == null) throw new IllegalArgumentException("psop is required"); + if (controlPoint == null) throw new IllegalArgumentException("controlPoint is required"); + return ExecutePsop.execute( + psop, + agentCards, + controlPoint, + engineClient, + runtimeIntent, + lang, + credentialsConfigPath, + sslVerify, + caCertsPath, + a2aClientRuntime, + eventCallback, + onFinish, + onEvent); } + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/EventTypeTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/EventTypeTest.java index c998c2ed..7c6ca1f1 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/EventTypeTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/EventTypeTest.java @@ -22,51 +22,50 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import dev.openan.workflow.engine.control.EventType; - import org.junit.jupiter.api.Test; /** Verifies EventType constants match the Python SDK values. */ class EventTypeTest { - @Test - void lifecycleEventsMatchPython() { - assertEquals("start", EventType.START); - assertEquals("complete", EventType.COMPLETE); - assertEquals("close", EventType.CLOSE); - assertEquals("error", EventType.ERROR); - } + @Test + void lifecycleEventsMatchPython() { + assertEquals("start", EventType.START); + assertEquals("complete", EventType.COMPLETE); + assertEquals("close", EventType.CLOSE); + assertEquals("error", EventType.ERROR); + } - @Test - void stepTaskEventsMatchPython() { - assertEquals("step_start", EventType.STEP_START); - assertEquals("step_complete", EventType.STEP_COMPLETE); - assertEquals("task_request", EventType.TASK_REQUEST); - assertEquals("task_response", EventType.TASK_RESPONSE); - assertEquals("task_status_changed", EventType.TASK_STATUS_CHANGED); - assertEquals("route_decision", EventType.ROUTE_DECISION); - assertEquals("workflow_complete", EventType.WORKFLOW_COMPLETE); - } + @Test + void stepTaskEventsMatchPython() { + assertEquals("step_start", EventType.STEP_START); + assertEquals("step_complete", EventType.STEP_COMPLETE); + assertEquals("task_request", EventType.TASK_REQUEST); + assertEquals("task_response", EventType.TASK_RESPONSE); + assertEquals("task_status_changed", EventType.TASK_STATUS_CHANGED); + assertEquals("route_decision", EventType.ROUTE_DECISION); + assertEquals("workflow_complete", EventType.WORKFLOW_COMPLETE); + } - @Test - void agentTrafficEventsMatchPython() { - assertEquals("agent_request", EventType.AGENT_REQUEST); - assertEquals("agent_response", EventType.AGENT_RESPONSE); - } + @Test + void agentTrafficEventsMatchPython() { + assertEquals("agent_request", EventType.AGENT_REQUEST); + assertEquals("agent_response", EventType.AGENT_RESPONSE); + } - @Test - void intermediateAgentEventsExist() { - assertEquals("agent_status_update", EventType.AGENT_STATUS_UPDATE); - assertEquals("agent_artifact_update", EventType.AGENT_ARTIFACT_UPDATE); - assertEquals("agent_message_event", EventType.AGENT_MESSAGE_EVENT); - } + @Test + void intermediateAgentEventsExist() { + assertEquals("agent_status_update", EventType.AGENT_STATUS_UPDATE); + assertEquals("agent_artifact_update", EventType.AGENT_ARTIFACT_UPDATE); + assertEquals("agent_message_event", EventType.AGENT_MESSAGE_EVENT); + } - @Test - void extensionEventsMatchPython() { - assertEquals("negotiation_request", EventType.NEGOTIATION_REQUEST); - assertEquals("negotiation_resolved", EventType.NEGOTIATION_RESOLVED); - assertEquals("negotiation_failed", EventType.NEGOTIATION_FAILED); - assertEquals("authorization_request", EventType.AUTHORIZATION_REQUEST); - assertEquals("authorization_resolved", EventType.AUTHORIZATION_RESOLVED); - assertEquals("notification", EventType.NOTIFICATION); - } + @Test + void extensionEventsMatchPython() { + assertEquals("negotiation_request", EventType.NEGOTIATION_REQUEST); + assertEquals("negotiation_resolved", EventType.NEGOTIATION_RESOLVED); + assertEquals("negotiation_failed", EventType.NEGOTIATION_FAILED); + assertEquals("authorization_request", EventType.AUTHORIZATION_REQUEST); + assertEquals("authorization_resolved", EventType.AUTHORIZATION_RESOLVED); + assertEquals("notification", EventType.NOTIFICATION); + } } 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 c6a8331b..c065e871 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 @@ -23,7 +23,6 @@ import dev.openan.workflow.engine.control.ControlPoint; import dev.openan.workflow.engine.control.EventCallback; import dev.openan.workflow.engine.model.SendMessageResult; - import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -37,144 +36,157 @@ */ public class StubWorkflowEngineClient implements WorkflowEngineClient { - private final List<SentMessage> sent = Collections.synchronizedList(new ArrayList<>()); - private final Map<String, String> cannedResponses = new HashMap<>(); - private final List<String> agentNames = new ArrayList<>(); - private EventCallback eventCallback = new EventCallback(); - private ControlPoint controlPoint; - private String defaultResponse = "stub-response"; - private String defaultTaskState = "TASK_STATE_COMPLETED"; - private List<Object> defaultOutputs = List.of(); - - public StubWorkflowEngineClient withDefaultOutputs(List<Object> outputs) { - defaultOutputs = outputs; - return this; - } - - public StubWorkflowEngineClient(String... agentNames) { - this.agentNames.addAll(List.of(agentNames)); - } - - public StubWorkflowEngineClient withResponse(String agentName, String text) { - cannedResponses.put(agentName, text); - return this; - } - - public StubWorkflowEngineClient withDefaultResponse(String text) { - this.defaultResponse = text; - return this; - } - - public StubWorkflowEngineClient withDefaultTaskState(String state) { - this.defaultTaskState = state; - return this; - } - - @Override - public CompletableFuture<SendMessageResult> 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<SendMessageResult> sendMessage( - 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<String, Object> metadata = content.metadata(); - sent.add(new SentMessage(agentName, message, contextId, metadata)); - String text = cannedResponses.getOrDefault(agentName, defaultResponse); - if (eventCallback != null) { - eventCallback.onEvent( - "agent_request", - Map.of( - "agent", - agentName, - "request", - message, - "metadata", - metadata != null ? metadata : Map.of())); - } - 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().<org.a2aproject.sdk.spec.Part<?>>map(value -> - new org.a2aproject.sdk.spec.DataPart(value)).toList()), Map.of(), List.of()))) - .text(text) - .taskState(defaultTaskState) - .metadata(new HashMap<>()) - .build(); - if (eventCallback != null) { - eventCallback.onEvent("agent_response", Map.of("agent", agentName, "response", text)); - } - return CompletableFuture.completedFuture(result); - } - - @Override - public void setControlPoint(ControlPoint controlPoint) { - this.controlPoint = controlPoint; - } - - @Override - public void setEventCallback(EventCallback callback) { - this.eventCallback = callback != null ? callback : new EventCallback(); - } - - @Override - public CompletableFuture<SendMessageResult> getTask(String agentName, String taskId) { - return cannedResult(agentName, defaultTaskState); - } - - @Override - public CompletableFuture<SendMessageResult> cancelTask(String agentName, String taskId) { - return cannedResult(agentName, "TASK_STATE_CANCELED"); - } - - @Override - public CompletableFuture<SendMessageResult> subscribeToTask( - String agentName, - String taskId, - java.util.function.Consumer<Map<String, Object>> eventCallback) { - return cannedResult(agentName, defaultTaskState); - } - - private CompletableFuture<SendMessageResult> 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() {} - - public List<SentMessage> getSentMessages() { - return new ArrayList<>(sent); - } - - public int getSentCount() { - return sent.size(); - } - - public static final class SentMessage { - public final String agentName; - public final String message; - public final String contextId; - public final Map<String, Object> metadata; - - public SentMessage( - String agentName, String message, String contextId, Map<String, Object> metadata) { - this.agentName = agentName; - this.message = message; - this.contextId = contextId; - this.metadata = metadata; - } - } + private final List<SentMessage> sent = Collections.synchronizedList(new ArrayList<>()); + private final Map<String, String> cannedResponses = new HashMap<>(); + private final List<String> agentNames = new ArrayList<>(); + private EventCallback eventCallback = new EventCallback(); + private ControlPoint controlPoint; + private String defaultResponse = "stub-response"; + private String defaultTaskState = "TASK_STATE_COMPLETED"; + private List<Object> defaultOutputs = List.of(); + + public StubWorkflowEngineClient(String... agentNames) { + this.agentNames.addAll(List.of(agentNames)); + } + + public StubWorkflowEngineClient withDefaultOutputs(List<Object> outputs) { + defaultOutputs = outputs; + return this; + } + + public StubWorkflowEngineClient withResponse(String agentName, String text) { + cannedResponses.put(agentName, text); + return this; + } + + public StubWorkflowEngineClient withDefaultResponse(String text) { + this.defaultResponse = text; + return this; + } + + public StubWorkflowEngineClient withDefaultTaskState(String state) { + this.defaultTaskState = state; + return this; + } + + @Override + public CompletableFuture<SendMessageResult> 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<SendMessageResult> sendMessage( + 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<String, Object> metadata = content.metadata(); + sent.add(new SentMessage(agentName, message, contextId, metadata)); + String text = cannedResponses.getOrDefault(agentName, defaultResponse); + if (eventCallback != null) { + eventCallback.onEvent( + "agent_request", + Map.of( + "agent", + agentName, + "request", + message, + "metadata", + metadata != null ? metadata : Map.of())); + } + 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() + .<org.a2aproject.sdk.spec.Part<?>>map( + value -> new org.a2aproject.sdk.spec.DataPart(value)) + .toList()), + Map.of(), + List.of()))) + .text(text) + .taskState(defaultTaskState) + .metadata(new HashMap<>()) + .build(); + if (eventCallback != null) { + eventCallback.onEvent("agent_response", Map.of("agent", agentName, "response", text)); + } + return CompletableFuture.completedFuture(result); + } + + @Override + public void setControlPoint(ControlPoint controlPoint) { + this.controlPoint = controlPoint; + } + + @Override + public void setEventCallback(EventCallback callback) { + this.eventCallback = callback != null ? callback : new EventCallback(); + } + + @Override + public CompletableFuture<SendMessageResult> getTask(String agentName, String taskId) { + return cannedResult(agentName, defaultTaskState); + } + + @Override + public CompletableFuture<SendMessageResult> cancelTask(String agentName, String taskId) { + return cannedResult(agentName, "TASK_STATE_CANCELED"); + } + + @Override + public CompletableFuture<SendMessageResult> subscribeToTask( + String agentName, + String taskId, + java.util.function.Consumer<Map<String, Object>> eventCallback) { + return cannedResult(agentName, defaultTaskState); + } + + private CompletableFuture<SendMessageResult> 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() {} + + public List<SentMessage> getSentMessages() { + return new ArrayList<>(sent); + } + + public int getSentCount() { + return sent.size(); + } + + public static final class SentMessage { + public final String agentName; + public final String message; + public final String contextId; + public final Map<String, Object> metadata; + + public SentMessage( + String agentName, String message, String contextId, Map<String, Object> metadata) { + this.agentName = agentName; + this.message = message; + this.contextId = contextId; + this.metadata = metadata; + } + } } 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 08f204de..6ec28836 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 @@ -7,257 +7,41 @@ package dev.openan.workflow.engine.client; -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 static org.junit.jupiter.api.Assertions.*; 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; -import org.a2aproject.sdk.spec.AgentCard; -import org.a2aproject.sdk.spec.MessageSendParams; -import org.junit.jupiter.api.Test; - import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletionException; 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.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.junit.jupiter.api.Test; class A2ATransportHeaderTest { - private static final String TASK_T_URI = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; - - @Test - void forwardsAuthorizationAndActiveExtensionHeadersToRuntime() throws Exception { - AtomicReference<Map<String, String>> capturedHeaders = new AtomicReference<>(); - A2AJavaClientRuntime runtime = new CapturingRuntime(capturedHeaders); - AgentCard card = agentCard(); - WorkflowEngineClientConfig config = - WorkflowEngineClientConfig.builder() - .authProvider( - (agentName, ignored, headers) -> - headers.put("Authorization", "Bearer test-token")) - .build(); - - try (A2ATransport transport = new A2ATransport(List.of(card), runtime, config)) { - transport.send(card, card.name(), content("diagnose", Map.of(TASK_T_URI, "structured task")), "context-1", null, null) - .join(); - } - - assertEquals("Bearer test-token", capturedHeaders.get().get("Authorization")); - assertEquals(TASK_T_URI, capturedHeaders.get().get("A2A-Extensions")); - } - - @Test - void notificationTransportFailureIsNotReportedAsSubscriptionSuccess() throws Exception { - AgentCard card = agentCard(); - A2AJavaClientRuntime runtime = - new A2AJavaClientRuntime() { - @Override - public Iterable<ClientEvent> sendMessage( - AgentCard agentCard, - MessageSendParams params, - ClientCallContext callContext, - Consumer<ClientEvent> eventSink, - Consumer<String> logSink) { - throw new IllegalStateException("gateway unavailable"); - } - - @Override - public void close() {} - }; - WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder().build(); - - try (A2ATransport transport = new A2ATransport(List.of(card), runtime, config)) { - CompletionException error = - assertThrows( - CompletionException.class, - () -> - transport.openNotificationStream(card, card.name(), content("subscribe", Map.of(TASK_T_URI, "notification")), "context-1", null).acknowledgement() - .join()); - assertEquals("gateway unavailable", error.getCause().getMessage()); - } - } - - @Test - void agentCardSecurityRequirementWithoutCredentialsFailsClosed() throws Exception { - AgentCard card = securedAgentCard(); - A2AJavaClientRuntime runtime = - new A2AJavaClientRuntime() { - @Override - public Iterable<ClientEvent> sendMessage( - AgentCard agentCard, - MessageSendParams params, - ClientCallContext callContext, - Consumer<ClientEvent> eventSink, - Consumer<String> logSink) { - throw new AssertionError("unauthenticated request must not reach runtime"); - } - - @Override - public void close() {} - }; - - try (A2ATransport transport = - new A2ATransport(List.of(card), runtime, WorkflowEngineClientConfig.builder().build())) { - CompletionException error = - assertThrows( - CompletionException.class, - () -> - 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")); - } - } - - @Test - void authProviderCanSatisfySecurityRequirementWithoutCredentials() throws Exception { - AtomicReference<Map<String, String>> capturedHeaders = new AtomicReference<>(); - A2AJavaClientRuntime runtime = new CapturingRuntime(capturedHeaders); - AgentCard card = securedAgentCard(); - WorkflowEngineClientConfig config = - WorkflowEngineClientConfig.builder() - .authProvider( - (agentName, ignored, headers) -> - headers.put("Authorization", "Bearer gateway-token")) - .build(); - - try (A2ATransport transport = new A2ATransport(List.of(card), runtime, config)) { - transport.send(card, card.name(), content("diagnose", null), "context-1", null, null).join(); - } - - assertEquals("Bearer gateway-token", capturedHeaders.get().get("Authorization")); - } - - @Test - void authenticationHeaderConflictsAreCaseInsensitive() { - Map<String, String> headers = new HashMap<>(); - headers.put("authorization", "Bearer provider-token"); - - SecurityException error = - assertThrows( - SecurityException.class, - () -> - ClientCallContextFactory.mergeHeaders( - "Test Agent", - headers, - Map.of("Authorization", "Bearer credential-token"))); - - assertEquals( - "Authentication header conflict for agent Test Agent: Authorization", - error.getMessage()); - 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<ClientEvent> sendMessage( - AgentCard agentCard, - MessageSendParams params, - ClientCallContext callContext, - Consumer<ClientEvent> eventSink, - Consumer<String> logSink) { - entered.countDown(); - try { - new CountDownLatch(1).await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - exited.set(true); - } - return List.of(); - } + private static final String TASK_T_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; - @Override - public void close() {} - }; - A2ATransport transport = - new A2ATransport( - List.of(card), runtime, WorkflowEngineClientConfig.builder().build()); + private static dev.openan.workflow.engine.model.MessageContent content( + String text, Map<String, Object> 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()); + } - 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<MessageSendParams> 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<String, Object> 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 = - """ + private static AgentCard agentCard() throws Exception { + String json = + """ { "name": "Test Agent", "description": "test", @@ -281,15 +65,15 @@ private static AgentCard agentCard() throws Exception { ] } """ - .formatted(TASK_T_URI); - return new ObjectMapper() - .registerModule(new AgentCardJacksonModule()) - .readValue(json, AgentCard.class); - } - - private static AgentCard securedAgentCard() throws Exception { - String json = - """ + .formatted(TASK_T_URI); + return new ObjectMapper() + .registerModule(new AgentCardJacksonModule()) + .readValue(json, AgentCard.class); + } + + private static AgentCard securedAgentCard() throws Exception { + String json = + """ { "name": "Test Agent", "description": "test", @@ -316,59 +100,289 @@ private static AgentCard securedAgentCard() throws Exception { ] } """; - return new ObjectMapper() - .registerModule(new AgentCardJacksonModule()) - .readValue(json, AgentCard.class); + return new ObjectMapper() + .registerModule(new AgentCardJacksonModule()) + .readValue(json, AgentCard.class); + } + + @Test + void forwardsAuthorizationAndActiveExtensionHeadersToRuntime() throws Exception { + AtomicReference<Map<String, String>> capturedHeaders = new AtomicReference<>(); + A2AJavaClientRuntime runtime = new CapturingRuntime(capturedHeaders); + AgentCard card = agentCard(); + WorkflowEngineClientConfig config = + WorkflowEngineClientConfig.builder() + .authProvider( + (agentName, ignored, headers) -> headers.put("Authorization", "Bearer test-token")) + .build(); + + try (A2ATransport transport = new A2ATransport(List.of(card), runtime, config)) { + transport + .send( + card, + card.name(), + content("diagnose", Map.of(TASK_T_URI, "structured task")), + "context-1", + null, + null) + .join(); } - private record CapturingRuntime(AtomicReference<Map<String, String>> capturedHeaders) - implements A2AJavaClientRuntime { - - @Override - public Iterable<ClientEvent> sendMessage( - AgentCard agentCard, - MessageSendParams params, - ClientCallContext callContext, - Consumer<ClientEvent> eventSink, - Consumer<String> logSink) { - capturedHeaders.set(Map.copyOf(callContext.getHeaders())); - return List.of(); - } - - @Override - public void close() {} + assertEquals("Bearer test-token", capturedHeaders.get().get("Authorization")); + assertEquals(TASK_T_URI, capturedHeaders.get().get("A2A-Extensions")); + } + + @Test + void notificationTransportFailureIsNotReportedAsSubscriptionSuccess() throws Exception { + AgentCard card = agentCard(); + A2AJavaClientRuntime runtime = + new A2AJavaClientRuntime() { + @Override + public Iterable<ClientEvent> sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer<ClientEvent> eventSink, + Consumer<String> logSink) { + throw new IllegalStateException("gateway unavailable"); + } + + @Override + public void close() {} + }; + WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder().build(); + + try (A2ATransport transport = new A2ATransport(List.of(card), runtime, config)) { + CompletionException error = + assertThrows( + CompletionException.class, + () -> + transport + .openNotificationStream( + card, + card.name(), + content("subscribe", Map.of(TASK_T_URI, "notification")), + "context-1", + null) + .acknowledgement() + .join()); + assertEquals("gateway unavailable", error.getCause().getMessage()); + } + } + + @Test + void agentCardSecurityRequirementWithoutCredentialsFailsClosed() throws Exception { + AgentCard card = securedAgentCard(); + A2AJavaClientRuntime runtime = + new A2AJavaClientRuntime() { + @Override + public Iterable<ClientEvent> sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer<ClientEvent> eventSink, + Consumer<String> logSink) { + throw new AssertionError("unauthenticated request must not reach runtime"); + } + + @Override + public void close() {} + }; + + try (A2ATransport transport = + new A2ATransport(List.of(card), runtime, WorkflowEngineClientConfig.builder().build())) { + CompletionException error = + assertThrows( + CompletionException.class, + () -> + transport + .send(card, card.name(), content("diagnose", null), "context-1", null, null) + .join()); + assertInstanceOf(SecurityException.class, error.getCause()); + assertTrue(error.getCause().getMessage().contains("none are configured")); + } + } + + @Test + void authProviderCanSatisfySecurityRequirementWithoutCredentials() throws Exception { + AtomicReference<Map<String, String>> capturedHeaders = new AtomicReference<>(); + A2AJavaClientRuntime runtime = new CapturingRuntime(capturedHeaders); + AgentCard card = securedAgentCard(); + WorkflowEngineClientConfig config = + WorkflowEngineClientConfig.builder() + .authProvider( + (agentName, ignored, headers) -> + headers.put("Authorization", "Bearer gateway-token")) + .build(); + + try (A2ATransport transport = new A2ATransport(List.of(card), runtime, config)) { + transport.send(card, card.name(), content("diagnose", null), "context-1", null, null).join(); } - private record CountingRuntime(AtomicInteger closeCalls) implements A2AJavaClientRuntime { - @Override - public Iterable<ClientEvent> sendMessage( - AgentCard agentCard, - MessageSendParams params, - ClientCallContext callContext, - Consumer<ClientEvent> eventSink, - Consumer<String> logSink) { + assertEquals("Bearer gateway-token", capturedHeaders.get().get("Authorization")); + } + + @Test + void authenticationHeaderConflictsAreCaseInsensitive() { + Map<String, String> headers = new HashMap<>(); + headers.put("authorization", "Bearer provider-token"); + + SecurityException error = + assertThrows( + SecurityException.class, + () -> + ClientCallContextFactory.mergeHeaders( + "Test Agent", headers, Map.of("Authorization", "Bearer credential-token"))); + + assertEquals( + "Authentication header conflict for agent Test Agent: Authorization", error.getMessage()); + 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<ClientEvent> sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer<ClientEvent> eventSink, + Consumer<String> 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() { - closeCalls.incrementAndGet(); - } + } + + @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<MessageSendParams> 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()); + assertFalse("business-only".equals(captured.get().message().contextId())); + } + } + + private record CapturingRuntime(AtomicReference<Map<String, String>> capturedHeaders) + implements A2AJavaClientRuntime { + + @Override + public Iterable<ClientEvent> sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer<ClientEvent> eventSink, + Consumer<String> logSink) { + capturedHeaders.set(Map.copyOf(callContext.getHeaders())); + return List.of(); } - private record SendCountingRuntime(AtomicInteger sendCalls) implements A2AJavaClientRuntime { - @Override - public Iterable<ClientEvent> sendMessage( - AgentCard agentCard, - MessageSendParams params, - ClientCallContext callContext, - Consumer<ClientEvent> eventSink, - Consumer<String> logSink) { - sendCalls.incrementAndGet(); - return List.of(); - } + @Override + public void close() {} + } + + private record CountingRuntime(AtomicInteger closeCalls) implements A2AJavaClientRuntime { + @Override + public Iterable<ClientEvent> sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer<ClientEvent> eventSink, + Consumer<String> logSink) { + return List.of(); + } - @Override - public void close() {} + @Override + public void close() { + closeCalls.incrementAndGet(); + } + } + + private record SendCountingRuntime(AtomicInteger sendCalls) implements A2AJavaClientRuntime { + @Override + public Iterable<ClientEvent> sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer<ClientEvent> eventSink, + Consumer<String> 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 index 52d5cc19..bd3700af 100644 --- 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 @@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; +import java.util.Map; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; import org.a2aproject.sdk.client.TaskEvent; @@ -22,138 +24,131 @@ 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<ClientEvent> 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<ClientEvent> events = + 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<Artifact> 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(); + } + + 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(); + } + + @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<ClientEvent> 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<ClientEvent> 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)))); + } + + @Test + void mixedStreamingTextAndStructuredDataAreBothRetained() { + Artifact mixed = + Artifact.builder() + .artifactId("mixed") + .parts( 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<Artifact> 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(); - } + 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()); + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCardNormalizerTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCardNormalizerTest.java index a435a3a4..9801e29b 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCardNormalizerTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCardNormalizerTest.java @@ -21,138 +21,121 @@ import static org.junit.jupiter.api.Assertions.*; -import org.junit.jupiter.api.Test; - import java.util.List; import java.util.Map; +import org.junit.jupiter.api.Test; /** Tests for AgentCardNormalizer: OpenAPI -> structured format conversion. */ class AgentCardNormalizerTest { - @Test - void normalizeNoOpWhenAlreadyStructured() { - Map<String, Object> card = - Map.of( - "name", - "Agent1", - "securitySchemes", - Map.of( - "bearerAuth", - Map.of("httpAuthSecurityScheme", Map.of("scheme", "bearer")))); - Map<String, Object> result = AgentCardNormalizer.normalize(card); - assertEquals("Agent1", result.get("name")); - @SuppressWarnings("unchecked") - Map<String, Object> schemes = (Map<String, Object>) result.get("securitySchemes"); - assertNotNull(schemes.get("bearerAuth")); - } - - @Test + @Test + void normalizeNoOpWhenAlreadyStructured() { + Map<String, Object> card = + Map.of( + "name", + "Agent1", + "securitySchemes", + Map.of("bearerAuth", Map.of("httpAuthSecurityScheme", Map.of("scheme", "bearer")))); + Map<String, Object> result = AgentCardNormalizer.normalize(card); + assertEquals("Agent1", result.get("name")); @SuppressWarnings("unchecked") - void convertsOpenApiBearerScheme() { - Map<String, Object> card = - Map.of( - "name", - "Agent1", - "securitySchemes", - Map.of( - "bearerAuth", - Map.of("scheme", "bearer", "description", "Bearer auth"))); - Map<String, Object> result = AgentCardNormalizer.normalize(card); - Map<String, Object> schemes = (Map<String, Object>) result.get("securitySchemes"); - Map<String, Object> bearer = (Map<String, Object>) schemes.get("bearerAuth"); - assertNotNull(bearer.get("httpAuthSecurityScheme")); - Map<String, Object> httpAuth = (Map<String, Object>) bearer.get("httpAuthSecurityScheme"); - assertEquals("bearer", httpAuth.get("scheme")); - assertEquals("Bearer auth", httpAuth.get("description")); - } + Map<String, Object> schemes = (Map<String, Object>) result.get("securitySchemes"); + assertNotNull(schemes.get("bearerAuth")); + } - @Test - @SuppressWarnings("unchecked") - void convertsOpenApiApiKeyScheme() { - Map<String, Object> card = - Map.of( - "name", - "Agent1", - "securitySchemes", - Map.of( - "apiKeyAuth", - Map.of("type", "apiKey", "in", "header", "name", "X-API-Key"))); - Map<String, Object> result = AgentCardNormalizer.normalize(card); - Map<String, Object> schemes = (Map<String, Object>) result.get("securitySchemes"); - Map<String, Object> apiKeyWrapper = (Map<String, Object>) schemes.get("apiKeyAuth"); - assertNotNull(apiKeyWrapper.get("apiKeySecurityScheme")); - Map<String, Object> apiKey = - (Map<String, Object>) apiKeyWrapper.get("apiKeySecurityScheme"); - assertEquals("header", apiKey.get("location")); - assertEquals("X-API-Key", apiKey.get("name")); - } + @Test + @SuppressWarnings("unchecked") + void convertsOpenApiBearerScheme() { + Map<String, Object> card = + Map.of( + "name", + "Agent1", + "securitySchemes", + Map.of("bearerAuth", Map.of("scheme", "bearer", "description", "Bearer auth"))); + Map<String, Object> result = AgentCardNormalizer.normalize(card); + Map<String, Object> schemes = (Map<String, Object>) result.get("securitySchemes"); + Map<String, Object> bearer = (Map<String, Object>) schemes.get("bearerAuth"); + assertNotNull(bearer.get("httpAuthSecurityScheme")); + Map<String, Object> httpAuth = (Map<String, Object>) bearer.get("httpAuthSecurityScheme"); + assertEquals("bearer", httpAuth.get("scheme")); + assertEquals("Bearer auth", httpAuth.get("description")); + } - @Test - void doesNotInventSecurityRequirementsFromSupportedSchemes() { - Map<String, Object> card = - Map.of( - "name", - "Agent1", - "securitySchemes", - Map.of( - "bearerAuth", - Map.of("httpAuthSecurityScheme", Map.of("scheme", "bearer")))); - Map<String, Object> result = AgentCardNormalizer.normalize(card); - assertFalse(result.containsKey("securityRequirements")); - } + @Test + @SuppressWarnings("unchecked") + void convertsOpenApiApiKeyScheme() { + Map<String, Object> card = + Map.of( + "name", + "Agent1", + "securitySchemes", + Map.of("apiKeyAuth", Map.of("type", "apiKey", "in", "header", "name", "X-API-Key"))); + Map<String, Object> result = AgentCardNormalizer.normalize(card); + Map<String, Object> schemes = (Map<String, Object>) result.get("securitySchemes"); + Map<String, Object> apiKeyWrapper = (Map<String, Object>) schemes.get("apiKeyAuth"); + assertNotNull(apiKeyWrapper.get("apiKeySecurityScheme")); + Map<String, Object> apiKey = (Map<String, Object>) apiKeyWrapper.get("apiKeySecurityScheme"); + assertEquals("header", apiKey.get("location")); + assertEquals("X-API-Key", apiKey.get("name")); + } - @Test - void preservesExplicitlyEmptySecurityRequirements() { - Map<String, Object> card = - Map.of( - "name", - "Agent1", - "securitySchemes", - Map.of( - "bearerAuth", - Map.of("httpAuthSecurityScheme", Map.of("scheme", "bearer"))), - "securityRequirements", - List.of()); - Map<String, Object> result = AgentCardNormalizer.normalize(card); + @Test + void doesNotInventSecurityRequirementsFromSupportedSchemes() { + Map<String, Object> card = + Map.of( + "name", + "Agent1", + "securitySchemes", + Map.of("bearerAuth", Map.of("httpAuthSecurityScheme", Map.of("scheme", "bearer")))); + Map<String, Object> result = AgentCardNormalizer.normalize(card); + assertFalse(result.containsKey("securityRequirements")); + } - assertEquals(List.of(), result.get("securityRequirements")); - } + @Test + void preservesExplicitlyEmptySecurityRequirements() { + Map<String, Object> card = + Map.of( + "name", + "Agent1", + "securitySchemes", + Map.of("bearerAuth", Map.of("httpAuthSecurityScheme", Map.of("scheme", "bearer"))), + "securityRequirements", + List.of()); + Map<String, Object> result = AgentCardNormalizer.normalize(card); - @Test - @SuppressWarnings("unchecked") - void normalizesListStyleSecurityRequirements() { - Map<String, Object> card = - Map.of( - "name", "Agent1", - "securitySchemes", - Map.of( - "bearerAuth", - Map.of( - "httpAuthSecurityScheme", - Map.of("scheme", "bearer"))), - "securityRequirements", List.of(Map.of("schemes", List.of("bearerAuth")))); - Map<String, Object> result = AgentCardNormalizer.normalize(card); - List<Map<String, Object>> reqs = - (List<Map<String, Object>>) result.get("securityRequirements"); - Map<String, Object> schemesMap = (Map<String, Object>) reqs.get(0).get("schemes"); - assertNotNull(schemesMap.get("bearerAuth")); - } + assertEquals(List.of(), result.get("securityRequirements")); + } + + @Test + @SuppressWarnings("unchecked") + void normalizesListStyleSecurityRequirements() { + Map<String, Object> card = + Map.of( + "name", "Agent1", + "securitySchemes", + Map.of("bearerAuth", Map.of("httpAuthSecurityScheme", Map.of("scheme", "bearer"))), + "securityRequirements", List.of(Map.of("schemes", List.of("bearerAuth")))); + Map<String, Object> result = AgentCardNormalizer.normalize(card); + List<Map<String, Object>> reqs = (List<Map<String, Object>>) result.get("securityRequirements"); + Map<String, Object> schemesMap = (Map<String, Object>) reqs.get(0).get("schemes"); + assertNotNull(schemesMap.get("bearerAuth")); + } - @Test - void normalizeReturnsInputWhenNotMap() { - Object result = AgentCardNormalizer.normalize(null); - assertNull(result); - } + @Test + void normalizeReturnsInputWhenNotMap() { + Object result = AgentCardNormalizer.normalize(null); + assertNull(result); + } - @Test - void preservesNameAndCapabilities() { - Map<String, Object> card = - Map.of( - "name", "MyAgent", - "description", "An agent", - "capabilities", Map.of("streaming", true)); - Map<String, Object> result = AgentCardNormalizer.normalize(card); - assertEquals("MyAgent", result.get("name")); - assertEquals("An agent", result.get("description")); - } + @Test + void preservesNameAndCapabilities() { + Map<String, Object> card = + Map.of( + "name", "MyAgent", + "description", "An agent", + "capabilities", Map.of("streaming", true)); + Map<String, Object> result = AgentCardNormalizer.normalize(card); + assertEquals("MyAgent", result.get("name")); + assertEquals("An agent", result.get("description")); + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCredentialServiceTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCredentialServiceTest.java index 590c85dd..ca4ff1d8 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCredentialServiceTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCredentialServiceTest.java @@ -8,53 +8,49 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.sun.net.httpserver.HttpServer; - -import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; -import org.junit.jupiter.api.Test; - import java.net.InetSocketAddress; import java.net.http.HttpClient; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.junit.jupiter.api.Test; class AgentCredentialServiceTest { - @Test - void formLoginEncodesKeysAndValuesAsUtf8() throws Exception { - AtomicReference<String> requestBody = new AtomicReference<>(); - HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - server.createContext( - "/token", - exchange -> { - requestBody.set( - new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); - byte[] response = "{\"access_token\":\"token-1\"}".getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(200, response.length); - exchange.getResponseBody().write(response); - exchange.close(); - }); - server.start(); - try { - Map<String, Object> scheme = new HashMap<>(); - scheme.put("login_url", "http://127.0.0.1:" + server.getAddress().getPort() + "/token"); - scheme.put("content_type", "application/x-www-form-urlencoded"); - scheme.put("token_field", "access_token"); - scheme.put("request_fields", Map.of("user name", "a+b", "password", "密&码=")); - AgentCredentialService service = - new AgentCredentialService( - "agent", Map.of("bearer", scheme), HttpClient.newHttpClient()); - - String token = - service.getCredential( - "bearer", new ClientCallContext(new HashMap<>(), new HashMap<>())); - - assertEquals("token-1", token); - assertTrue(requestBody.get().contains("user+name=a%2Bb")); - assertTrue(requestBody.get().contains("password=%E5%AF%86%26%E7%A0%81%3D")); - } finally { - server.stop(0); - } + @Test + void formLoginEncodesKeysAndValuesAsUtf8() throws Exception { + AtomicReference<String> requestBody = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/token", + exchange -> { + requestBody.set( + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] response = "{\"access_token\":\"token-1\"}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + try { + Map<String, Object> scheme = new HashMap<>(); + scheme.put("login_url", "http://127.0.0.1:" + server.getAddress().getPort() + "/token"); + scheme.put("content_type", "application/x-www-form-urlencoded"); + scheme.put("token_field", "access_token"); + scheme.put("request_fields", Map.of("user name", "a+b", "password", "密&码=")); + AgentCredentialService service = + new AgentCredentialService("agent", Map.of("bearer", scheme), HttpClient.newHttpClient()); + + String token = + service.getCredential("bearer", new ClientCallContext(new HashMap<>(), new HashMap<>())); + + assertEquals("token-1", token); + assertTrue(requestBody.get().contains("user+name=a%2Bb")); + assertTrue(requestBody.get().contains("password=%E5%AF%86%26%E7%A0%81%3D")); + } finally { + server.stop(0); } + } } 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 index c10270dc..ed149014 100644 --- 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 @@ -17,40 +17,57 @@ * under the License. */ - package dev.openan.workflow.engine.client; -import org.junit.jupiter.api.Test; -import java.nio.file.*; +import static org.junit.jupiter.api.Assertions.*; + import java.nio.charset.StandardCharsets; +import java.nio.file.*; import java.util.*; -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; 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 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); - } - } + @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 196508eb..a2dc9b41 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 @@ -11,43 +11,50 @@ import org.junit.jupiter.api.Test; class CredentialCryptoTest { - private static final String KEY = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - private static final String OTHER_KEY = - "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; - - @AfterEach - void clearKey() { - System.clearProperty("A2AT_CRED_KEY"); - } - - @Test - void encryptedCredentialRoundTrips() { - System.setProperty("A2AT_CRED_KEY", KEY); - String encrypted = CredentialCrypto.encrypt("p@ss&中文"); - assertEquals("p@ss&中文", CredentialCrypto.decryptIfNeeded(encrypted)); - } - - @Test - void corruptedEncryptedCredentialFailsClosed() { - System.setProperty("A2AT_CRED_KEY", KEY); - assertThrows( - IllegalStateException.class, - () -> CredentialCrypto.decryptIfNeeded("enc:not-base64:also-not-base64")); - } - - @Test - 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)); - } + private static final String KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private static final String OTHER_KEY = + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + + @AfterEach + void clearKey() { + System.clearProperty("A2AT_CRED_KEY"); + } + + @Test + void encryptedCredentialRoundTrips() { + System.setProperty("A2AT_CRED_KEY", KEY); + String encrypted = CredentialCrypto.encrypt("p@ss&中文"); + assertEquals("p@ss&中文", CredentialCrypto.decryptIfNeeded(encrypted)); + } + + @Test + void explicitEncryptionKeyDoesNotMutateGlobalConfiguration() { + System.setProperty("A2AT_CRED_KEY", OTHER_KEY); + String encrypted = CredentialCrypto.encrypt("instance-secret", KEY); + assertEquals("instance-secret", CredentialCrypto.decryptIfNeeded(encrypted, KEY)); + assertEquals(OTHER_KEY, System.getProperty("A2AT_CRED_KEY")); + } + + @Test + void corruptedEncryptedCredentialFailsClosed() { + System.setProperty("A2AT_CRED_KEY", KEY); + assertThrows( + IllegalStateException.class, + () -> CredentialCrypto.decryptIfNeeded("enc:not-base64:also-not-base64")); + } + + @Test + 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 index ca9f01b2..190ab874 100644 --- 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 @@ -19,214 +19,364 @@ 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.model.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.function.*; 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(""" + 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); - } + """, + 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<MessageSendParams, List<ClientEvent>> send) { + return new A2AJavaClientRuntime() { + public Iterable<ClientEvent> sendMessage( + AgentCard card, + MessageSendParams params, + ClientCallContext context, + Consumer<ClientEvent> sink, + Consumer<String> logs) { + List<ClientEvent> events = send.apply(params); + if (sink != null) events.forEach(sink); + return events; + } - 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"))); + public void close() {} + }; + } + + @Test + void answersFinalRoundAndKeepsOriginalRemoteAssociation() throws Exception { + List<MessageSendParams> 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<NegotiationRequest> 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()); + } - 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()); + @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()); + } - static A2AJavaClientRuntime runtime(Function<MessageSendParams, List<ClientEvent>> send) { - return new A2AJavaClientRuntime() { - public Iterable<ClientEvent> sendMessage(AgentCard card, MessageSendParams params, - ClientCallContext context, Consumer<ClientEvent> sink, Consumer<String> logs) { - List<ClientEvent> events = send.apply(params); - if (sink != null) events.forEach(sink); - return events; - } - public void close() {} - }; + @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 answersFinalRoundAndKeepsOriginalRemoteAssociation() throws Exception { - List<MessageSendParams> 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<NegotiationRequest> 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 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 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 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 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()); + @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<MessageSendParams> last = new AtomicReference<>(); + A2AJavaClientRuntime runtime = + new A2AJavaClientRuntime() { + public Iterable<ClientEvent> sendMessage( + AgentCard card, + MessageSendParams params, + ClientCallContext context, + Consumer<ClientEvent> sink, + Consumer<String> logs) { + last.set(params); + return List.of( + response( + params, + sends.incrementAndGet() == 1 + ? TaskState.TASK_STATE_INPUT_REQUIRED + : secondState, + negotiation("city1", 1, NegotiationPerformative.PROPOSE))); } - 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()); - } - } + 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(); + } - @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()); - } + 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 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<MessageSendParams> last = new AtomicReference<>(); - A2AJavaClientRuntime runtime = new A2AJavaClientRuntime() { - public Iterable<ClientEvent> sendMessage(AgentCard card, MessageSendParams params, - ClientCallContext context, Consumer<ClientEvent> sink, Consumer<String> logs) { - last.set(params); - return List.of(response(params, sends.incrementAndGet() == 1 - ? TaskState.TASK_STATE_INPUT_REQUIRED : secondState, + @Test + void canceledOrTimedOutNegotiationNeverSendsLateReply() throws Exception { + for (boolean timeout : List.of(false, true)) { + AtomicInteger sends = new AtomicInteger(); + CompletableFuture<NegotiationReply> 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))); - } - 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<NegotiationReply> 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()); - } - } + }), + 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()); - } + @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 index 058e4cc6..311b2a88 100644 --- 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 @@ -7,33 +7,32 @@ 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; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; 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<String, String> values = EnvFileLoader.read(envFile); - - assertEquals("city-one", values.get(ISOLATED_KEY)); - assertEquals("with spaces", values.get("QUOTED_VALUE")); - assertNull(System.getProperty(ISOLATED_KEY)); - } + 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<String, String> 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/ExtensionSenderTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java index b9a0ea51..96d4ffaf 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 @@ -18,79 +18,118 @@ */ package dev.openan.workflow.engine.client; + +import static org.junit.jupiter.api.Assertions.*; + 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.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; import java.util.function.*; -import static org.junit.jupiter.api.Assertions.*; +import net.openan.a2at.sdk.core.model.MetadataContent; +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; 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(); - } + 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(); + } - static MessageContent content(A2ATExtension extension) { - return A2atMessages.from(new MetadataContent("host-template", "final-content", extension.uri()), - List.of(new TextPart("instruction"))); - } + 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<String> 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()); - } + @Test + void sendsFinalAuthorizationWithoutGeneratingOrSharingContext() throws Exception { + List<String> 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()); } + } - @Test void earlyEventCanCloseHandleAndCompletionWaitsForActualExit() throws Exception { - CountDownLatch callbackRan = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - AtomicReference<NotificationSubscription> observed = new AtomicReference<>(); - var runtime = new A2AJavaClientRuntime() { - public Iterable<ClientEvent> sendMessage(AgentCard card, MessageSendParams params, - ClientCallContext context, Consumer<ClientEvent> sink, Consumer<String> 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(); + @Test + void earlyEventCanCloseHandleAndCompletionWaitsForActualExit() throws Exception { + CountDownLatch callbackRan = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference<NotificationSubscription> observed = new AtomicReference<>(); + var runtime = + new A2AJavaClientRuntime() { + public Iterable<ClientEvent> sendMessage( + AgentCard card, + MessageSendParams params, + ClientCallContext context, + Consumer<ClientEvent> sink, + Consumer<String> 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 */ + } } - public void close() {} + 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(); } + 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 index 609c798c..19456960 100644 --- 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 @@ -4,84 +4,118 @@ */ package dev.openan.workflow.engine.client; -import com.sun.net.httpserver.HttpServer; +import static org.junit.jupiter.api.Assertions.*; + 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 com.sun.net.httpserver.HttpServer; 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.*; +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; class JsonRpcWireObservationTest { - @Test void recordsSdkSerializedJsonRpcRequestAndRealErrorResponse() throws Exception { - var json = new ObjectMapper().registerModule(new AgentCardJacksonModule()); - List<WireLog.Entry> logs = new CopyOnWriteArrayList<>(); - AtomicReference<String> received = new AtomicReference<>(); - AtomicReference<List<String>> version = new AtomicReference<>(); - AtomicReference<String> 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(); + @Test + void recordsSdkSerializedJsonRpcRequestAndRealErrorResponse() throws Exception { + var json = new ObjectMapper().registerModule(new AgentCardJacksonModule()); + List<WireLog.Entry> logs = new CopyOnWriteArrayList<>(); + AtomicReference<String> received = new AtomicReference<>(); + AtomicReference<List<String>> version = new AtomicReference<>(); + AtomicReference<String> 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(""" + 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<Throwable> 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); - } + """ + .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<Throwable> 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 index 6b55b05e..e4ce2688 100644 --- 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 @@ -10,56 +10,53 @@ 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; +import org.junit.jupiter.api.Test; 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))); - } + @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 index cef21698..34311f20 100644 --- 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 @@ -17,106 +17,162 @@ * under the License. */ - package dev.openan.workflow.engine.client; +import static org.junit.jupiter.api.Assertions.*; + 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.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; class ObservedHttpClientTest { - @AfterEach void clearSettings() { - System.clearProperty("WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY"); - System.clearProperty("WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS"); - } + @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<WireLog.Entry> records = new CopyOnWriteArrayList<>(); - List<String> capturedBody = new CopyOnWriteArrayList<>(); - List<List<String>> 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(); + @Test + void logsActualRequestAndResponseWithoutReencodingOrExposingCredentials() throws Exception { + String body = "{ \"message\": \"任务\", \"password\": \"login-secret\" }"; + String response = "{\"ok\":true,\"accessSession\":\"omc-secret\"}"; + List<WireLog.Entry> records = new CopyOnWriteArrayList<>(); + List<String> capturedBody = new CopyOnWriteArrayList<>(); + List<List<String>> 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<String> 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); } + 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<String> 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<String[]> 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 streamingFramesKeepUtf8ControlLinesAndArriveBeforeCompletion() { + List<String[]> 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<String[]> 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 oversizedFramesAreExplicitlyDroppedWithoutLeakingPartialSecrets() { + System.setProperty("WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS", "256"); + List<String[]> 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<WireLog.Entry> 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())); - } + @Test + void loggingFailureCannotChangeBodyDeliveryAndBodySwitchIsExplicit() throws Exception { + List<WireLog.Entry> 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 78c2d640..93626380 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 @@ -7,39 +7,36 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import java.util.Map; - class ProtocolLoggerTest { - private static final String SETTING = - "WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS"; + private static final String SETTING = "WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS"; - @AfterEach - void clearSetting() { - System.clearProperty(SETTING); - } + @AfterEach + void clearSetting() { + System.clearProperty(SETTING); + } - @Test - void sensitiveHeadersAreRedactedByDefault() { - System.setProperty(SETTING, "false"); - String formatted = - ProtocolLogger.formatHeaders( - Map.of( - "Authorization", "Bearer secret-token", - "X-Request-Id", "request-1")); - assertTrue(formatted.contains("Authorization: ***")); - assertFalse(formatted.contains("secret-token")); - assertTrue(formatted.contains("X-Request-Id: request-1")); - } + @Test + void sensitiveHeadersAreRedactedByDefault() { + System.setProperty(SETTING, "false"); + String formatted = + ProtocolLogger.formatHeaders( + Map.of( + "Authorization", "Bearer secret-token", + "X-Request-Id", "request-1")); + assertTrue(formatted.contains("Authorization: ***")); + assertFalse(formatted.contains("secret-token")); + assertTrue(formatted.contains("X-Request-Id: request-1")); + } - @Test - void obsoleteSettingCannotDisableMandatoryRedaction() { - System.setProperty(SETTING, "true"); - String formatted = - ProtocolLogger.formatHeaders(Map.of("Authorization", "Bearer visible")); - assertFalse(formatted.contains("Bearer visible")); - assertTrue(formatted.contains("Authorization: ***")); - } + @Test + void obsoleteSettingCannotDisableMandatoryRedaction() { + System.setProperty(SETTING, "true"); + String formatted = ProtocolLogger.formatHeaders(Map.of("Authorization", "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 index aa20ae62..d5277595 100644 --- 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 @@ -17,69 +17,127 @@ * under the License. */ - package dev.openan.workflow.engine.client; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; 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<ClientEvent> 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()); - } + private static Task task(TaskState state, List<Artifact> 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<String, Object> metadata) { + return Artifact.builder() + .artifactId(id) + .name("report") + .description("description") + .parts(new TextPart(text)) + .metadata(metadata) + .extensions(List.of("urn:test")) + .build(); + } - @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")); - } + private static TaskArtifactUpdateEvent update(Artifact artifact, boolean append) { + return TaskArtifactUpdateEvent.builder() + .taskId("task") + .contextId("context") + .artifact(artifact) + .append(append) + .lastChunk(false) + .build(); + } + + @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<ClientEvent> 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 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()); - } + @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")); + } - private static Task task(TaskState state, List<Artifact> 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<String,Object> 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(); - } + @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()); + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/SseNormalizationTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/SseNormalizationTest.java deleted file mode 100644 index d2d9f50f..00000000 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/SseNormalizationTest.java +++ /dev/null @@ -1,77 +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 org.junit.jupiter.api.Test; - -import java.util.Map; - -/** - * Tests for SseNormalization: bare Task wrapping, artifactUpdate, statusUpdate, already-SSE - * pass-through. - */ -class SseNormalizationTest { - - @Test - @SuppressWarnings("unchecked") - void wrapsBareTaskIntoTaskKey() { - Map<String, Object> bare = Map.of("id", "task-1", "status", Map.of("state", "COMPLETED")); - Map<String, Object> result = SseNormalization.normalize(bare); - assertNotNull(result.get("task")); - Map<String, Object> task = (Map<String, Object>) result.get("task"); - assertEquals("task-1", task.get("id")); - } - - @Test - void wrapsArtifactUpdate() { - Map<String, Object> bare = Map.of("artifact", Map.of("name", "a1"), "taskId", "task-1"); - Map<String, Object> result = SseNormalization.normalize(bare); - assertNotNull(result.get("artifactUpdate")); - } - - @Test - void wrapsStatusUpdate() { - Map<String, Object> bare = Map.of("status", Map.of("state", "WORKING"), "taskId", "task-1"); - Map<String, Object> result = SseNormalization.normalize(bare); - // bare has both "status" and "taskId" -> wraps as statusUpdate - assertNotNull(result.get("statusUpdate")); - } - - @Test - void passThroughWhenAlreadySseShaped() { - Map<String, Object> already = Map.of("task", Map.of("id", "t1")); - Map<String, Object> result = SseNormalization.normalize(already); - assertSame(already, result); - } - - @Test - void passThroughWhenMessageKey() { - Map<String, Object> already = Map.of("message", Map.of("role", "user")); - Map<String, Object> result = SseNormalization.normalize(already); - assertSame(already, result); - } - - @Test - void returnsNullForNull() { - assertNull(SseNormalization.normalize(null)); - } -} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/SslContextFactoryTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/SslContextFactoryTest.java index ab3bb3e0..d40957df 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/SslContextFactoryTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/SslContextFactoryTest.java @@ -9,57 +9,50 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import org.junit.jupiter.api.Test; - import java.lang.reflect.Field; import java.util.concurrent.ExecutorService; +import org.junit.jupiter.api.Test; class SslContextFactoryTest { - private static final String HOSTNAME_PROPERTY = - "jdk.internal.httpclient.disableHostnameVerification"; - - @Test - void trustAllContextDoesNotMutateJvmHostnameVerification() { - String previous = System.getProperty(HOSTNAME_PROPERTY); - try { - System.clearProperty(HOSTNAME_PROPERTY); - SslContextFactory.createTrustAll(); - assertNull(System.getProperty(HOSTNAME_PROPERTY)); - } finally { - if (previous == null) System.clearProperty(HOSTNAME_PROPERTY); - else System.setProperty(HOSTNAME_PROPERTY, previous); - } - } - - @Test - void invalidConfiguredCaFailsClosed() { - assertThrows( - IllegalStateException.class, - () -> SslContextFactory.create(true, "missing-ca-file.pem")); - } - - @Test - void insecureServerVerificationStillLoadsClientIdentity() { - assertThrows( - IllegalStateException.class, - () -> - SslContextFactory.create( - false, - null, - "missing-client-cert.pem", - "missing-client-key.pem", - null, - null)); - } - - @Test - void runtimeClosesItsOwnedExecutor() throws Exception { - DefaultA2AJavaClientRuntime runtime = new DefaultA2AJavaClientRuntime(); - Field field = DefaultA2AJavaClientRuntime.class.getDeclaredField("httpClientExecutor"); - field.setAccessible(true); - ExecutorService executor = (ExecutorService) field.get(runtime); - assertFalse(executor.isShutdown()); - runtime.close(); - assertTrue(executor.isShutdown()); + private static final String HOSTNAME_PROPERTY = + "jdk.internal.httpclient.disableHostnameVerification"; + + @Test + void trustAllContextDoesNotMutateJvmHostnameVerification() { + String previous = System.getProperty(HOSTNAME_PROPERTY); + try { + System.clearProperty(HOSTNAME_PROPERTY); + SslContextFactory.createTrustAll(); + assertNull(System.getProperty(HOSTNAME_PROPERTY)); + } finally { + if (previous == null) System.clearProperty(HOSTNAME_PROPERTY); + else System.setProperty(HOSTNAME_PROPERTY, previous); } + } + + @Test + void invalidConfiguredCaFailsClosed() { + assertThrows( + IllegalStateException.class, () -> SslContextFactory.create(true, "missing-ca-file.pem")); + } + + @Test + void insecureServerVerificationStillLoadsClientIdentity() { + assertThrows( + IllegalStateException.class, + () -> + SslContextFactory.create( + false, null, "missing-client-cert.pem", "missing-client-key.pem", null, null)); + } + + @Test + void runtimeClosesItsOwnedExecutor() throws Exception { + DefaultA2AJavaClientRuntime runtime = new DefaultA2AJavaClientRuntime(); + Field field = DefaultA2AJavaClientRuntime.class.getDeclaredField("httpClientExecutor"); + field.setAccessible(true); + ExecutorService executor = (ExecutorService) field.get(runtime); + assertFalse(executor.isShutdown()); + runtime.close(); + assertTrue(executor.isShutdown()); + } } 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 index 6ad0e564..563886b2 100644 --- 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 @@ -4,83 +4,121 @@ */ package dev.openan.workflow.engine.client; +import static org.junit.jupiter.api.Assertions.*; + 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.*; +import org.junit.jupiter.api.Test; class WireGrpcInterceptorTest { - @Test void capturesRealGrpcMetadataAndProtobufWithoutInventingHttpBody() throws Exception { - var method = MethodDescriptor.<StringValue, StringValue>newBuilder() - .setType(MethodDescriptor.MethodType.UNARY).setFullMethodName("test.Protocol/Echo") - .setRequestMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())) - .setResponseMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())).build(); - AtomicReference<StringValue> captured = new AtomicReference<>(); - AtomicReference<Metadata> capturedHeaders = new AtomicReference<>(); - var service = ServerServiceDefinition.builder("test.Protocol").addMethod(method, - ServerCalls.asyncUnaryCall((StringValue request, io.grpc.stub.StreamObserver<StringValue> response) -> { - captured.set(request); - response.onNext(request); - response.onCompleted(); - })).build(); - ServerInterceptor receiver = new ServerInterceptor() { - public <Q, R> ServerCall.Listener<Q> interceptCall(ServerCall<Q, R> call, Metadata headers, - ServerCallHandler<Q, R> next) { - capturedHeaders.set(headers); - return next.startCall(new ForwardingServerCall.SimpleForwardingServerCall<Q, R>(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); - } + @Test + void capturesRealGrpcMetadataAndProtobufWithoutInventingHttpBody() throws Exception { + var method = + MethodDescriptor.<StringValue, StringValue>newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("test.Protocol/Echo") + .setRequestMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())) + .setResponseMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())) + .build(); + AtomicReference<StringValue> captured = new AtomicReference<>(); + AtomicReference<Metadata> capturedHeaders = new AtomicReference<>(); + var service = + ServerServiceDefinition.builder("test.Protocol") + .addMethod( + method, + ServerCalls.asyncUnaryCall( + (StringValue request, io.grpc.stub.StreamObserver<StringValue> response) -> { + captured.set(request); + response.onNext(request); + response.onCompleted(); + })) + .build(); + ServerInterceptor receiver = + new ServerInterceptor() { + public <Q, R> ServerCall.Listener<Q> interceptCall( + ServerCall<Q, R> call, Metadata headers, ServerCallHandler<Q, R> next) { + capturedHeaders.set(headers); + return next.startCall( + new ForwardingServerCall.SimpleForwardingServerCall<Q, R>(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<WireLog.Entry> 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> status = new AtomicReference<>(); - AtomicReference<StringValue> received = new AtomicReference<>(); - ClientCall<StringValue, StringValue> 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); - } + Server server = + ServerBuilder.forPort(0) + .addService(ServerInterceptors.intercept(service, receiver)) + .build() + .start(); + List<WireLog.Entry> 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> status = new AtomicReference<>(); + AtomicReference<StringValue> received = new AtomicReference<>(); + ClientCall<StringValue, StringValue> 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 index a1897774..7ff6940e 100644 --- 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 @@ -18,101 +18,214 @@ */ package dev.openan.workflow.engine.client; -import org.junit.jupiter.api.Test; -import java.util.*; import static org.junit.jupiter.api.Assertions.*; +import java.util.*; +import org.junit.jupiter.api.Test; + 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 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 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<WireLog.Entry> 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 sseDisplaySeparatesEventsAndKeepsRawObservationAvailable() { - String raw = "id: 1\ndata: {\n" + "data: \"n\": 1\n" + "data: }\n\n" - + "id: 2\ndata: {}\n\n"; - List<WireLog.Entry> 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 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<WireLog.Entry> 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); + // body frames are continuations: the envelope (headers/correlation) was logged by the preceding + // entry + assertTrue(pretty.startsWith("[DIRECT_HTTP] REQUEST_BODY requestId=req-1\n")); + assertTrue(pretty.contains("(envelope logged in the preceding entry")); + assertFalse(pretty.contains("=== Headers ===")); + assertFalse(pretty.contains("hidden")); + assertTrue(pretty.contains("\n \"performative\": \"ACCEPT\"")); + assertTrue(WireLogFormatter.format(entry, false).endsWith(entry.body())); + assertEquals(raw.replace("hidden", "***"), entry.body()); + } - @Test void logLayoutIsPrettyButObserversKeepExactRedactedWireText() { - List<WireLog.Entry> 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 envelopeEntriesKeepFullLayoutWhileBodyFramesStayCompact() { + WireLog.Entry envelope = + new WireLog.Entry( + "DIRECT_HTTP", + "RESPONSE_HEADERS", + "req-1", + "/message:stream", + "POST", + 200, + Map.of("Content-type", List.of("text/event-stream")), + "sdk-headers", + "", + "observed", + Map.of("agent", "SPN Domain Agent City1")); + String full = WireLogFormatter.format(envelope, true); + assertTrue(full.contains("Target: POST /message:stream")); + assertTrue(full.contains("Status: 200")); + assertTrue(full.contains("=== Headers ===\nContent-type: text/event-stream")); + assertTrue(full.contains("=== Correlation ===\nagent: SPN Domain Agent City1")); + + WireLog.Entry frame = + new WireLog.Entry( + "DIRECT_HTTP", + "RESPONSE_BODY", + "req-1", + "/message:stream", + "POST", + 200, + Map.of(), + "sdk-sse-text", + "id:1\ndata: {\"ok\":true}", + "observed", + Map.of("agent", "SPN Domain Agent City1")); + String compact = WireLogFormatter.format(frame, true); + assertTrue(compact.startsWith("[DIRECT_HTTP] RESPONSE_BODY requestId=req-1")); + assertFalse(compact.contains("=== Headers ===")); + assertFalse(compact.contains("=== Correlation ===")); + assertTrue(compact.contains("=== Body ===")); + } + + @Test + void prettyDisplayDecodesNonAsciiUnicodeEscapesButKeepsStructuralEscapes() { + String raw = + "{\"授权策略\":\"\\u65b0\\u589e\\u52a8\\u7f51\\u64cd\\u4f5c\"," + + "\"note\":\"line\\u000anext\",\"emoji\":\"\\ud83d\\ude00\"," + + "\"prompt\":\"请根据\\u003c授权策略的操作类型\\u003e和\\u003c动网操作的授权策略列表\\u003e完成相应的授权操作\"," + + "\"structural\":\"quote \\u0022 and backslash \\u005c stay\"}"; + String pretty = WireLogFormatter.prettyBody(raw, "serialized-utf8"); + assertTrue(pretty.contains("\"授权策略\": \"新增动网操作\"")); + assertTrue(pretty.contains("\"emoji\": \"😀\"")); + assertTrue(pretty.contains("请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作")); + // structural and control escapes stay verbatim; raw observation is untouched + assertTrue(pretty.contains("line\\u000anext")); + assertTrue(pretty.contains("\\u0022")); + assertTrue(pretty.contains("\\u005c")); + } - @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 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<WireLog.Entry> 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); - } + @Test + void disabledBodyIsNotReconstructedByPrettyDisplay() { + String key = "WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY"; + String previous = System.getProperty(key); + System.setProperty(key, "false"); + try { + List<WireLog.Entry> 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 index 0b8ffe84..c28586eb 100644 --- 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 @@ -4,35 +4,63 @@ */ package dev.openan.workflow.engine.client; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.*; -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; class WireLogRedactionTest { - @Test void largeEscapedContentDoesNotUseRecursiveRegexBacktracking() { - String payload = "{\"result\":\"" + "escaped \\\"text\\\" ".repeat(8000) - + "\",\"token\":\"hidden\"}"; - assertEquals(payload.replace("hidden", "***"), WireLog.redact(payload)); - } + @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 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<String> 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()); + @Test + void splitSdkTextChunksDoNotExposeFragmentsOfCredentials() { + List<String> 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()); + } + + @Test + void extensionUriKeysCarryingBusinessContentAreNotRedacted() { + String payload = + "{\"artifact\":{\"metadata\":{" + + "\"https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1\":" + + "\"## 动网操作授权策略\\n新增授权策略\"}}," + + "\"accessToken\":\"hidden\"}}"; + String redacted = WireLog.redact(payload); + assertTrue(redacted.contains("Authorization-T/v1\":\"## 动网操作授权策略")); + assertFalse(redacted.contains("hidden")); + assertTrue(redacted.contains("accessToken\":\"***\"")); + // header names still match: the HTTP Authorization header keeps its credential masked + assertTrue(WireLog.sensitive("Authorization")); + assertFalse( + WireLog.sensitive( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1")); + } } 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 index 96765f7c..c5a22562 100644 --- 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 @@ -18,21 +18,33 @@ */ package dev.openan.workflow.engine.client; -import org.junit.jupiter.api.Test; -import java.util.*; + import static org.junit.jupiter.api.Assertions.*; +import java.util.*; +import org.junit.jupiter.api.Test; + 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<String, Object> 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()); - } + @Test + void rejectsInvalidResourceLimits() { + assertThrows( + IllegalArgumentException.class, + () -> WorkflowEngineClientConfig.builder().maxNegotiationExchanges(0).build()); + assertThrows( + IllegalArgumentException.class, + () -> WorkflowEngineClientConfig.builder().sendTimeoutSeconds(0).build()); + } + + @Test + void credentialKeyIsExplicitAndConfigurationIsSnapshotted() { + Map<String, Object> 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 f95c4bee..2ff4ec3b 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 @@ -5,57 +5,52 @@ package dev.openan.workflow.engine.control; import static org.junit.jupiter.api.Assertions.*; -import dev.openan.workflow.engine.model.*; -import org.junit.jupiter.api.Test; +import dev.openan.workflow.engine.model.*; import java.util.List; +import org.junit.jupiter.api.Test; class DefaultControlPointTest { - 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 localExecutionRequiresBusinessHandler() { - assertThrows( - java.util.concurrent.CompletionException.class, - () -> new DefaultControlPoint().onSelfTask(request).join()); - } - - @Test - void conditionalRoutingRequiresBusinessHandler() { - assertThrows( - java.util.concurrent.CompletionException.class, - () -> - new DefaultControlPoint().onRoute( - new RouteRequest( - "run", - "step", - WorkflowInput.empty(), - List.of(), - List.of())) - .join()); - } - - @Test - 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()); - } + 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 localExecutionRequiresBusinessHandler() { + assertThrows( + java.util.concurrent.CompletionException.class, + () -> new DefaultControlPoint().onSelfTask(request).join()); + } + + @Test + void conditionalRoutingRequiresBusinessHandler() { + assertThrows( + java.util.concurrent.CompletionException.class, + () -> + new DefaultControlPoint() + .onRoute( + new RouteRequest("run", "step", WorkflowInput.empty(), List.of(), List.of())) + .join()); + } + + @Test + 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 index 32ad16a8..7f756aaa 100644 --- 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 @@ -24,104 +24,84 @@ 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.*; +import org.junit.jupiter.api.Test; class BusinessBoundaryTest { - private WorkflowStep step(String name, String next, List<String> 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(); - } + private WorkflowStep step(String name, String next, List<String> 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<TaskRequest> 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 identifiersAndWorkflowStateAreIsolatedAcrossExecutions() { + var workflow = + Workflow.builder().name("shared").steps(List.of(step("one", null, List.of()))).build(); + List<TaskRequest> 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<String> selection : - Arrays.asList(null, List.<String>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<RouteRequest> 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()); - } + @Test + void routingUsesSelectedAncestorsAndCurrentTypedOutputsSeparately() { + for (List<String> selection : + Arrays.asList(null, List.<String>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<RouteRequest> 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 7b5eba19..9f91c62d 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 @@ -24,135 +24,131 @@ 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; - +import dev.openan.workflow.engine.model.Workflow; +import dev.openan.workflow.engine.model.WorkflowStep; import java.util.HashMap; import java.util.List; import java.util.Map; +import org.junit.jupiter.api.Test; class ContextBuilderTest { - private WorkflowStep step(String name, int layer, List<String> next, List<String> contextFrom) { - return WorkflowStep.builder() - .name(name) - .layer(layer) - .next( - next == null - ? List.of() - : next.stream() - .map(target -> JumpCondition.builder().step(target).build()) - .toList()) - .contextFrom(contextFrom) - .subtasks(List.of(Task.builder().agent("A").description("t").build())) - .build(); - } - - 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 layerZeroKeepsRuntimeIntentSeparateFromTaskInstruction() { - Workflow workflow = - Workflow.builder() - .name("w") - .steps(List.of(step("s1", 0, List.of("s2"), null))) - .build(); - - 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 directPredecessorsProvideTypedResults() { - WorkflowStep s1 = step("s1", 0, List.of("s2"), null); - WorkflowStep s2 = step("s2", 1, List.of(), null); - Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); - Map<String, List<TaskExecutionResult>> 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 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 workflow = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); - Map<String, List<TaskExecutionResult>> 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() { - WorkflowStep s1 = step("s1", 0, List.of("s2"), null); - WorkflowStep s2 = step("s2", 1, List.of("s3"), null); - WorkflowStep s3 = step("s3", 2, List.of(), List.of("*")); - Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); - Map<String, List<TaskExecutionResult>> 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 explicitEmptyContextFromDisablesUpstreamAggregation() { - WorkflowStep s1 = step("s1", 0, List.of("s2"), null); - WorkflowStep s2 = step("s2", 1, List.of(), List.of()); - Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); - Map<String, List<TaskExecutionResult>> results = - Map.of("s1", List.of(result("OMC-1", "out1"))); - - var context = new ContextBuilder(workflow, "intent").buildWorkflowInput(s2, results); - assertTrue(context.upstreamResults().isEmpty()); - } - - @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 workflow = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); - - List<String> predecessors = new ContextBuilder(workflow, "").getStepPredecessors("s3"); - assertEquals(2, predecessors.size()); - assertTrue(predecessors.contains("s1")); - assertTrue(predecessors.contains("s2")); - } + private WorkflowStep step(String name, int layer, List<String> next, List<String> contextFrom) { + return WorkflowStep.builder() + .name(name) + .layer(layer) + .next( + next == null + ? List.of() + : next.stream() + .map(target -> JumpCondition.builder().step(target).build()) + .toList()) + .contextFrom(contextFrom) + .subtasks(List.of(Task.builder().agent("A").description("t").build())) + .build(); + } + + 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 layerZeroKeepsRuntimeIntentSeparateFromTaskInstruction() { + Workflow workflow = + Workflow.builder().name("w").steps(List.of(step("s1", 0, List.of("s2"), null))).build(); + + 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 directPredecessorsProvideTypedResults() { + WorkflowStep s1 = step("s1", 0, List.of("s2"), null); + WorkflowStep s2 = step("s2", 1, List.of(), null); + Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); + Map<String, List<TaskExecutionResult>> 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 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 workflow = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); + Map<String, List<TaskExecutionResult>> 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() { + WorkflowStep s1 = step("s1", 0, List.of("s2"), null); + WorkflowStep s2 = step("s2", 1, List.of("s3"), null); + WorkflowStep s3 = step("s3", 2, List.of(), List.of("*")); + Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); + Map<String, List<TaskExecutionResult>> 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 explicitEmptyContextFromDisablesUpstreamAggregation() { + WorkflowStep s1 = step("s1", 0, List.of("s2"), null); + WorkflowStep s2 = step("s2", 1, List.of(), List.of()); + Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); + Map<String, List<TaskExecutionResult>> results = Map.of("s1", List.of(result("OMC-1", "out1"))); + + var context = new ContextBuilder(workflow, "intent").buildWorkflowInput(s2, results); + assertTrue(context.upstreamResults().isEmpty()); + } + + @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 workflow = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); + + List<String> 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 index b09f54cf..b5afa91b 100644 --- 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 @@ -24,7 +24,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import dev.openan.workflow.engine.model.SendMessageResult; - +import java.util.List; +import java.util.Map; import org.a2aproject.sdk.spec.Artifact; import org.a2aproject.sdk.spec.DataPart; import org.a2aproject.sdk.spec.Task; @@ -33,90 +34,99 @@ 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(); + @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()); + 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()); - } + 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()); + @Test + void doesNotInventTextFallbackWhenTransportHasNoBusinessContent() { + var response = + ProtocolResultAdapter.toTaskResult( + SendMessageResult.builder() + .text("diagnosis") + .taskState("TASK_STATE_COMPLETED") + .build()); - assertEquals(List.of(), response.getOutputs()); - } + assertEquals(List.of(), response.getOutputs()); + } - @Test - void keepsFailedProtocolStateOutOfBusinessOutputs() { - var response = - ProtocolResultAdapter.toTaskResult( - SendMessageResult.builder() - .text("remote failure detail") - .taskState("TASK_STATE_FAILED") - .build()); + @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()); - } + 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()); + @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()); - } + 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 83f7e463..1dbe03f5 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 @@ -25,12 +25,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.*; -import dev.openan.workflow.engine.model.RouteRequest; import dev.openan.workflow.engine.model.MessageContent; -import org.junit.jupiter.api.Test; - +import dev.openan.workflow.engine.model.RouteRequest; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CyclicBarrier; @@ -38,6 +35,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; /** * Tests for WorkflowExecutor: DAG traversal, parallel subtasks, ANY_SUCCESS, conditional routing, @@ -45,992 +43,859 @@ */ class WorkflowExecutorTest { - private final List<String> events = Collections.synchronizedList(new ArrayList<>()); + private final List<String> events = Collections.synchronizedList(new ArrayList<>()); - private EventCallback recordingCallback() { - events.clear(); - return new EventCallback() { - @Override - public void onEvent(String type, Map<String, Object> data) { - events.add(type); - } - }; - } - - private Task task(String agent, String desc) { - return Task.builder().agent(agent).description(desc).build(); + 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); } - - private JumpCondition jump(String step, String cond) { - return JumpCondition.builder().step(step).condition(cond).build(); + } + + private EventCallback recordingCallback() { + events.clear(); + return new EventCallback() { + @Override + public void onEvent(String type, Map<String, Object> data) { + events.add(type); + } + }; + } + + private Task task(String agent, String desc) { + return Task.builder().agent(agent).description(desc).build(); + } + + private JumpCondition jump(String step, String cond) { + return JumpCondition.builder().step(step).condition(cond).build(); + } + + /** ControlPoint that auto-sends to the stub client and picks the first branch. */ + private ControlPoint autoCp() { + return new ControlPoint() { + @Override + public CompletableFuture<MessageContent> onTask(TaskRequest request) { + return CompletableFuture.completedFuture(MessageContent.text(request.getInstruction())); + } + + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map<String, Object> results = java.util.Map.of(); + List<JumpCondition> 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()); + } + }; + } + + @Test + void linearWorkflowTwoSteps() { + WorkflowStep s1 = + WorkflowStep.builder() + .name("s1") + .layer(0) + .subtasks(List.of(task("A", "do A"))) + .next(List.of(jump("s2", ""))) + .build(); + WorkflowStep s2 = + WorkflowStep.builder().name("s2").layer(1).subtasks(List.of(task("B", "do B"))).build(); + Workflow wf = Workflow.builder().name("linear").steps(List.of(s1, s2)).build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); + WorkflowExecutor exec = + new WorkflowExecutor(wf, autoCp(), stub, recordingCallback(), "intent", "zh"); + ExecutionResult result = exec.run().join(); + assertTrue(result.isSuccess()); + assertEquals(2, exec.getHistory().size()); + assertEquals(2, stub.getSentCount()); + assertTrue(events.contains(EventType.STEP_START)); + assertTrue(events.contains(EventType.STEP_COMPLETE)); + assertTrue(events.contains(EventType.WORKFLOW_COMPLETE)); + assertFalse(events.contains(EventType.START)); + assertFalse(events.contains(EventType.COMPLETE)); + } + + @Test + void parallelFanOutAllUnconditionalNext() { + // s1 -> s2 (unconditional) and s3 (unconditional) + WorkflowStep s1 = + WorkflowStep.builder() + .name("s1") + .layer(0) + .subtasks(List.of(task("A", "do A"))) + .next(List.of(jump("s2", ""), jump("s3", ""))) + .build(); + WorkflowStep s2 = + WorkflowStep.builder().name("s2").layer(1).subtasks(List.of(task("B", "do B"))).build(); + WorkflowStep s3 = + WorkflowStep.builder().name("s3").layer(1).subtasks(List.of(task("C", "do C"))).build(); + Workflow wf = Workflow.builder().name("fanout").steps(List.of(s1, s2, s3)).build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B", "C"); + WorkflowExecutor exec = new WorkflowExecutor(wf, autoCp(), stub, recordingCallback(), "", "zh"); + ExecutionResult result = exec.run().join(); + assertTrue(result.isSuccess()); + assertEquals(3, exec.getHistory().size()); + assertEquals(3, stub.getSentCount()); + Set<String> agents = new HashSet<>(); + for (var sm : stub.getSentMessages()) { + agents.add(sm.agentName); } - - /** ControlPoint that auto-sends to the stub client and picks the first branch. */ - private ControlPoint autoCp() { - return new ControlPoint() { - @Override - public CompletableFuture<MessageContent> onTask( - TaskRequest request) { - return CompletableFuture.completedFuture( - MessageContent.text( - request.getInstruction())); - } - - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String stepName = routeRequest.stepName(); - Map<String, Object> results = java.util.Map.of(); - List<JumpCondition> 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()); + 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<MessageContent> onTask(TaskRequest request) { + return CompletableFuture.supplyAsync( + () -> { + awaitBarrier(diagnosisBarrier); + return MessageContent.text(request.getInstruction()); + }, + taskExecutor); + } + + @Override + public CompletableFuture<TaskResult> onSelfTask(TaskRequest request) { + mergeCalls.incrementAndGet(); + return CompletableFuture.completedFuture( + TaskResult.builder().success(true).outputs(List.of("merged")).build()); + } + + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map<String, Object> results = java.util.Map.of(); + List<JumpCondition> 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<String, Object> data) { + if (EventType.STEP_COMPLETE.equals(type) + && String.valueOf(data.get("step")).startsWith("diagnosis_")) { + awaitBarrier(schedulingBarrier); } + } }; - } - - @Test - void linearWorkflowTwoSteps() { - WorkflowStep s1 = - WorkflowStep.builder() - .name("s1") - .layer(0) - .subtasks(List.of(task("A", "do A"))) - .next(List.of(jump("s2", ""))) - .build(); - WorkflowStep s2 = - WorkflowStep.builder() - .name("s2") - .layer(1) - .subtasks(List.of(task("B", "do B"))) - .build(); - Workflow wf = Workflow.builder().name("linear").steps(List.of(s1, s2)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); - WorkflowExecutor exec = - new WorkflowExecutor(wf, autoCp(), stub, recordingCallback(), "intent", "zh"); - ExecutionResult result = exec.run().join(); - assertTrue(result.isSuccess()); - assertEquals(2, exec.getHistory().size()); - assertEquals(2, stub.getSentCount()); - assertTrue(events.contains(EventType.STEP_START)); - assertTrue(events.contains(EventType.STEP_COMPLETE)); - assertTrue(events.contains(EventType.WORKFLOW_COMPLETE)); - assertFalse(events.contains(EventType.START)); - assertFalse(events.contains(EventType.COMPLETE)); - } - @Test - void parallelFanOutAllUnconditionalNext() { - // s1 -> s2 (unconditional) and s3 (unconditional) - WorkflowStep s1 = - WorkflowStep.builder() - .name("s1") - .layer(0) - .subtasks(List.of(task("A", "do A"))) - .next(List.of(jump("s2", ""), jump("s3", ""))) - .build(); - WorkflowStep s2 = - WorkflowStep.builder() - .name("s2") - .layer(1) - .subtasks(List.of(task("B", "do B"))) - .build(); - WorkflowStep s3 = - WorkflowStep.builder() - .name("s3") - .layer(1) - .subtasks(List.of(task("C", "do C"))) - .build(); - Workflow wf = Workflow.builder().name("fanout").steps(List.of(s1, s2, s3)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B", "C"); - WorkflowExecutor exec = - new WorkflowExecutor(wf, autoCp(), stub, recordingCallback(), "", "zh"); - ExecutionResult result = exec.run().join(); - assertTrue(result.isSuccess()); - assertEquals(3, exec.getHistory().size()); - assertEquals(3, stub.getSentCount()); - Set<String> agents = new HashSet<>(); - for (var sm : stub.getSentMessages()) { - agents.add(sm.agentName); - } - assertEquals(Set.of("A", "B", "C"), agents); + 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(); } - - @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 = + } + + @Test + void conditionalRouteOnRouteCalled() { + WorkflowStep s1 = + WorkflowStep.builder() + .name("s1") + .layer(0) + .subtasks(List.of(task("A", "do A"))) + .next(List.of(jump("s2", "A ok"), jump("s3", "A fail"))) + .build(); + WorkflowStep s2 = + WorkflowStep.builder().name("s2").layer(1).subtasks(List.of(task("B", "do B"))).build(); + WorkflowStep s3 = + WorkflowStep.builder().name("s3").layer(1).subtasks(List.of(task("C", "do C"))).build(); + Workflow wf = Workflow.builder().name("cond").steps(List.of(s1, s2, s3)).build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B", "C"); + // Route to s3 (the second branch) + ControlPoint cp = + new ControlPoint() { + @Override + public CompletableFuture<MessageContent> onTask(TaskRequest req) { + return CompletableFuture.completedFuture(MessageContent.text(req.getInstruction())); + } + + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map<String, Object> results = java.util.Map.of(); + List<JumpCondition> conditions = + routeRequest.candidates().stream() + .map(option -> new JumpCondition(option.nextStep(), option.condition())) + .toList(); + + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep("s3").reason("chose s3").build()); + } + }; + WorkflowExecutor exec = new WorkflowExecutor(wf, cp, stub, recordingCallback(), "", "zh"); + ExecutionResult result = exec.run().join(); + assertTrue(result.isSuccess()); + // s1 -> s3 only (s2 skipped) + assertEquals(2, stub.getSentCount()); + assertTrue(events.contains(EventType.ROUTE_DECISION)); + assertEquals("C", stub.getSentMessages().get(1).agentName); + } + + @Test + void onRouteInvalidStepFailsWorkflow() { + WorkflowStep s1 = + WorkflowStep.builder() + .name("s1") + .layer(0) + .subtasks(List.of(task("A", "do A"))) + .next(List.of(jump("s2", "cond"))) + .build(); + WorkflowStep s2 = + WorkflowStep.builder().name("s2").layer(1).subtasks(List.of(task("B", "do B"))).build(); + Workflow wf = Workflow.builder().name("invalid").steps(List.of(s1, s2)).build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); + ControlPoint cp = + new ControlPoint() { + @Override + public CompletableFuture<MessageContent> onTask(TaskRequest req) { + return CompletableFuture.completedFuture(MessageContent.text(req.getInstruction())); + } + + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map<String, Object> results = java.util.Map.of(); + List<JumpCondition> conditions = + routeRequest.candidates().stream() + .map(option -> new JumpCondition(option.nextStep(), option.condition())) + .toList(); + + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep("nonexistent").reason("bad").build()); + } + }; + WorkflowExecutor exec = new WorkflowExecutor(wf, cp, stub, recordingCallback(), "", "zh"); + ExecutionResult result = exec.run().join(); + + // s1 executes, then an invalid route is reported as a workflow error (s2 never runs). + assertFalse(result.isSuccess()); + assertTrue(result.getError().contains("allowed next steps")); + assertEquals(1, stub.getSentCount()); + } + + @Test + void taskFailurePropagatesAndStopsWorkflow() { + WorkflowStep s1 = + WorkflowStep.builder() + .name("s1") + .layer(0) + .subtasks(List.of(task("A", "do A"))) + .next(List.of(jump("s2", ""))) + .build(); + WorkflowStep s2 = + WorkflowStep.builder().name("s2").layer(1).subtasks(List.of(task("B", "do B"))).build(); + Workflow wf = Workflow.builder().name("fail").steps(List.of(s1, s2)).build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); + ControlPoint cp = + new ControlPoint() { + @Override + public CompletableFuture<MessageContent> onTask(TaskRequest req) { + if (req.getAgentName().equals("A")) { + return CompletableFuture.failedFuture( + new IllegalStateException("business preparation failed")); + } + return CompletableFuture.completedFuture(MessageContent.text(req.getInstruction())); + } + + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map<String, Object> results = java.util.Map.of(); + List<JumpCondition> conditions = + routeRequest.candidates().stream() + .map(option -> new JumpCondition(option.nextStep(), option.condition())) + .toList(); + + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep(conditions.get(0).getStep()).build()); + } + }; + WorkflowExecutor exec = new WorkflowExecutor(wf, cp, stub, recordingCallback(), "", "zh"); + ExecutionResult result = exec.run().join(); + assertFalse(result.isSuccess()); + assertNotNull(result.getError()); + // Agent A fails directly (no send), agent B must never be reached. + boolean bSent = stub.getSentMessages().stream().anyMatch(m -> m.agentName.equals("B")); + assertFalse(bSent, "Agent B must not be reached after A fails"); + assertTrue(events.contains(EventType.ERROR)); + } + + @Test + void anySuccessReturnsOnFirstSuccess() { + // s1 has 3 subtasks, ANY_SUCCESS: first success cancels the rest. + Task t1 = task("A", "do A"); + Task t2 = Task.builder().agent("B").description("do B").build(); + Task t3 = Task.builder().agent("C").description("do C").build(); + WorkflowStep s1 = + WorkflowStep.builder() + .name("s1") + .layer(0) + .stepType(StepType.ANY_SUCCESS) + .subtasks(List.of(t1, t2, t3)) + .next(List.of()) + .build(); + Workflow wf = Workflow.builder().name("any").steps(List.of(s1)).build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B", "C"); + // All succeed; the step should complete as soon as the first returns. + WorkflowExecutor exec = new WorkflowExecutor(wf, autoCp(), stub, recordingCallback(), "", "zh"); + ExecutionResult result = exec.run().join(); + assertTrue(result.isSuccess()); + // At least one task was sent (could be all 3 racing, but >= 1) + assertTrue(stub.getSentCount() >= 1); + } + + @Test + void anySuccessAllFailReturnsFailure() { + Task t1 = task("A", "do A"); + Task t2 = task("B", "do B"); + WorkflowStep s1 = + WorkflowStep.builder() + .name("s1") + .layer(0) + .stepType(StepType.ANY_SUCCESS) + .subtasks(List.of(t1, t2)) + .next(List.of()) + .build(); + Workflow wf = Workflow.builder().name("any-fail").steps(List.of(s1)).build(); + StubWorkflowEngineClient stub = + new StubWorkflowEngineClient("A", "B").withDefaultTaskState("TASK_STATE_FAILED"); + ControlPoint cp = + new ControlPoint() { + @Override + public CompletableFuture<MessageContent> onTask(TaskRequest req) { + return CompletableFuture.completedFuture(MessageContent.text(req.getInstruction())); + } + + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map<String, Object> results = java.util.Map.of(); + List<JumpCondition> conditions = + routeRequest.candidates().stream() + .map(option -> new JumpCondition(option.nextStep(), option.condition())) + .toList(); + + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep(conditions.get(0).getStep()).build()); + } + }; + WorkflowExecutor exec = new WorkflowExecutor(wf, cp, stub, recordingCallback(), "", "zh"); + ExecutionResult result = exec.run().join(); + assertFalse(result.isSuccess()); + } + + @Test + void eventSequenceForLinearWorkflow() { + WorkflowStep s1 = + WorkflowStep.builder() + .name("s1") + .layer(0) + .subtasks(List.of(task("A", "do A"))) + .next(List.of()) + .build(); + Workflow wf = Workflow.builder().name("seq").steps(List.of(s1)).build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A"); + WorkflowExecutor exec = new WorkflowExecutor(wf, autoCp(), stub, recordingCallback(), "", "zh"); + exec.run().join(); + + // Executor emits: step_start, task_request, task_status_changed, + // task_response, step_complete, workflow_complete (NO start/complete/close) + int startIdx = events.indexOf(EventType.STEP_START); + int taskReqIdx = events.indexOf(EventType.TASK_REQUEST); + int taskStatusIdx = events.indexOf(EventType.TASK_STATUS_CHANGED); + int taskRespIdx = events.indexOf(EventType.TASK_RESPONSE); + int stepCompleteIdx = events.indexOf(EventType.STEP_COMPLETE); + int wfCompleteIdx = events.indexOf(EventType.WORKFLOW_COMPLETE); + assertNotEquals(-1, startIdx); + assertTrue(startIdx < taskReqIdx); + assertTrue(taskReqIdx < taskStatusIdx); + assertTrue(taskStatusIdx < taskRespIdx); + assertTrue(taskRespIdx < stepCompleteIdx); + assertTrue(stepCompleteIdx < wfCompleteIdx); + assertFalse(events.contains(EventType.START), "Executor must not emit START (runner's job)"); + } + + @Test + void runtimeIntentPassedToContext() { + WorkflowStep s1 = + WorkflowStep.builder() + .name("s1") + .layer(0) + .subtasks(List.of(task("A", "do A"))) + .next(List.of()) + .build(); + Workflow wf = Workflow.builder().name("intent").steps(List.of(s1)).build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A"); + List<TaskRequest> requests = Collections.synchronizedList(new ArrayList<>()); + ControlPoint cp = + new ControlPoint() { + @Override + public CompletableFuture<MessageContent> onTask(TaskRequest req) { + requests.add(req); + return CompletableFuture.completedFuture(MessageContent.text(req.getInstruction())); + } + + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map<String, Object> results = java.util.Map.of(); + List<JumpCondition> conditions = + routeRequest.candidates().stream() + .map(option -> new JumpCondition(option.nextStep(), option.condition())) + .toList(); + + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep(conditions.get(0).getStep()).build()); + } + }; + WorkflowExecutor exec = + new WorkflowExecutor(wf, cp, stub, new EventCallback(), "my intent", "zh"); + exec.run().join(); + 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 + void noSubtasksStepSucceeds() { + WorkflowStep s1 = + WorkflowStep.builder().name("s1").layer(0).subtasks(List.of()).next(List.of()).build(); + Workflow wf = Workflow.builder().name("empty-step").steps(List.of(s1)).build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient(); + WorkflowExecutor exec = new WorkflowExecutor(wf, autoCp(), stub, recordingCallback(), "", "zh"); + ExecutionResult result = exec.run().join(); + assertTrue(result.isSuccess()); + assertEquals(0, stub.getSentCount()); + } + + @Test + void missingStaticRouteTargetFailsBeforeExecution() { + WorkflowStep step = + WorkflowStep.builder() + .name("start") + .layer(0) + .subtasks(List.of(task("A", "run"))) + .next(List.of(jump("missing", ""))) + .build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A"); + ExecutionResult result = + new WorkflowExecutor( + Workflow.builder().name("invalid").steps(List.of(step)).build(), + autoCp(), + stub, + recordingCallback(), + "", + "zh") + .run() + .join(); + assertFalse(result.isSuccess()); + assertTrue(result.getError().contains("missing step")); + assertEquals(0, stub.getSentCount()); + } + + @Test + void cyclicWorkflowFailsBeforeExecution() { + WorkflowStep first = + WorkflowStep.builder() + .name("first") + .subtasks(List.of(task("A", "run"))) + .next(List.of(jump("second", ""))) + .build(); + WorkflowStep second = + WorkflowStep.builder() + .name("second") + .subtasks(List.of(task("B", "run"))) + .next(List.of(jump("first", ""))) + .build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); + ExecutionResult result = + new WorkflowExecutor( + Workflow.builder().name("cycle").steps(List.of(first, second)).build(), + autoCp(), + stub, + recordingCallback(), + "", + "zh") + .run() + .join(); + assertFalse(result.isSuccess()); + assertTrue(result.getError().contains("cycle")); + assertEquals(0, stub.getSentCount()); + } + + @Test + void mergeDoesNotWaitForUnselectedConditionalBranch() { + WorkflowStep start = + WorkflowStep.builder() + .name("start") + .layer(0) + .subtasks(List.of(task("A", "start"))) + .next(List.of(jump("left", "left"), jump("right", "right"))) + .build(); + WorkflowStep left = + WorkflowStep.builder() + .name("left") + .layer(1) + .subtasks(List.of(task("B", "left"))) + .next(List.of(jump("merge", ""))) + .build(); + WorkflowStep right = + WorkflowStep.builder() + .name("right") + .layer(1) + .subtasks(List.of(task("C", "right"))) + .next(List.of(jump("merge", ""))) + .build(); + WorkflowStep merge = + WorkflowStep.builder().name("merge").layer(2).subtasks(List.of(task("M", "merge"))).build(); + ControlPoint chooseRight = + new ControlPoint() { + @Override + public CompletableFuture<MessageContent> onTask(TaskRequest request) { + return CompletableFuture.completedFuture(MessageContent.text(request.getInstruction())); + } + + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map<String, Object> results = java.util.Map.of(); + List<JumpCondition> conditions = + routeRequest.candidates().stream() + .map(option -> new JumpCondition(option.nextStep(), option.condition())) + .toList(); + + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep("right").reason("test").build()); + } + }; + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B", "C", "M"); + ExecutionResult result = + new WorkflowExecutor( 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<MessageContent> onTask( - TaskRequest request) { - return CompletableFuture.supplyAsync( - () -> { - awaitBarrier(diagnosisBarrier); - return MessageContent.text(request.getInstruction()); - }, - taskExecutor); - } - - @Override - public CompletableFuture<TaskResult> onSelfTask(TaskRequest request) { - mergeCalls.incrementAndGet(); - return CompletableFuture.completedFuture( - TaskResult.builder().success(true).outputs(List.of("merged")).build()); - } - - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String stepName = routeRequest.stepName(); - Map<String, Object> results = java.util.Map.of(); - List<JumpCondition> 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<String, Object> 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 = - WorkflowStep.builder() - .name("s1") - .layer(0) - .subtasks(List.of(task("A", "do A"))) - .next(List.of(jump("s2", "A ok"), jump("s3", "A fail"))) - .build(); - WorkflowStep s2 = - WorkflowStep.builder() - .name("s2") - .layer(1) - .subtasks(List.of(task("B", "do B"))) - .build(); - WorkflowStep s3 = - WorkflowStep.builder() - .name("s3") - .layer(1) - .subtasks(List.of(task("C", "do C"))) - .build(); - Workflow wf = Workflow.builder().name("cond").steps(List.of(s1, s2, s3)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B", "C"); - // Route to s3 (the second branch) - ControlPoint cp = - new ControlPoint() { - @Override - public CompletableFuture<MessageContent> onTask( - TaskRequest req) { - return CompletableFuture.completedFuture( - MessageContent.text( - req.getInstruction())); - } - - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String stepName = routeRequest.stepName(); - Map<String, Object> results = java.util.Map.of(); - List<JumpCondition> conditions = - routeRequest.candidates().stream() - .map( - option -> - new JumpCondition( - option.nextStep(), - option.condition())) - .toList(); - - return CompletableFuture.completedFuture( - RouteDecision.builder().nextStep("s3").reason("chose s3").build()); - } - }; - WorkflowExecutor exec = new WorkflowExecutor(wf, cp, stub, recordingCallback(), "", "zh"); - ExecutionResult result = exec.run().join(); - assertTrue(result.isSuccess()); - // s1 -> s3 only (s2 skipped) - assertEquals(2, stub.getSentCount()); - assertTrue(events.contains(EventType.ROUTE_DECISION)); - assertEquals("C", stub.getSentMessages().get(1).agentName); - } - - @Test - void onRouteInvalidStepFailsWorkflow() { - WorkflowStep s1 = - WorkflowStep.builder() - .name("s1") - .layer(0) - .subtasks(List.of(task("A", "do A"))) - .next(List.of(jump("s2", "cond"))) - .build(); - WorkflowStep s2 = - WorkflowStep.builder() - .name("s2") - .layer(1) - .subtasks(List.of(task("B", "do B"))) - .build(); - Workflow wf = Workflow.builder().name("invalid").steps(List.of(s1, s2)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); - ControlPoint cp = - new ControlPoint() { - @Override - public CompletableFuture<MessageContent> onTask( - TaskRequest req) { - return CompletableFuture.completedFuture( - MessageContent.text( - req.getInstruction())); - } - - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String stepName = routeRequest.stepName(); - Map<String, Object> results = java.util.Map.of(); - List<JumpCondition> conditions = - routeRequest.candidates().stream() - .map( - option -> - new JumpCondition( - option.nextStep(), - option.condition())) - .toList(); - - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep("nonexistent") - .reason("bad") - .build()); - } - }; - WorkflowExecutor exec = new WorkflowExecutor(wf, cp, stub, recordingCallback(), "", "zh"); - ExecutionResult result = exec.run().join(); - - // s1 executes, then an invalid route is reported as a workflow error (s2 never runs). - assertFalse(result.isSuccess()); - assertTrue(result.getError().contains("allowed next steps")); - assertEquals(1, stub.getSentCount()); - } - - @Test - void taskFailurePropagatesAndStopsWorkflow() { - WorkflowStep s1 = - WorkflowStep.builder() - .name("s1") - .layer(0) - .subtasks(List.of(task("A", "do A"))) - .next(List.of(jump("s2", ""))) - .build(); - WorkflowStep s2 = - WorkflowStep.builder() - .name("s2") - .layer(1) - .subtasks(List.of(task("B", "do B"))) - .build(); - Workflow wf = Workflow.builder().name("fail").steps(List.of(s1, s2)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); - ControlPoint cp = - new ControlPoint() { - @Override - public CompletableFuture<MessageContent> onTask( - TaskRequest req) { - if (req.getAgentName().equals("A")) { - return CompletableFuture.failedFuture( - new IllegalStateException("business preparation failed")); - } - return CompletableFuture.completedFuture( - MessageContent.text( - req.getInstruction())); - } - - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String stepName = routeRequest.stepName(); - Map<String, Object> results = java.util.Map.of(); - List<JumpCondition> conditions = - routeRequest.candidates().stream() - .map( - option -> - new JumpCondition( - option.nextStep(), - option.condition())) - .toList(); - - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(conditions.get(0).getStep()) - .build()); - } - }; - WorkflowExecutor exec = new WorkflowExecutor(wf, cp, stub, recordingCallback(), "", "zh"); - ExecutionResult result = exec.run().join(); - assertFalse(result.isSuccess()); - assertNotNull(result.getError()); - // Agent A fails directly (no send), agent B must never be reached. - boolean bSent = stub.getSentMessages().stream().anyMatch(m -> m.agentName.equals("B")); - assertFalse(bSent, "Agent B must not be reached after A fails"); - assertTrue(events.contains(EventType.ERROR)); - } - - @Test - void anySuccessReturnsOnFirstSuccess() { - // s1 has 3 subtasks, ANY_SUCCESS: first success cancels the rest. - Task t1 = task("A", "do A"); - Task t2 = Task.builder().agent("B").description("do B").build(); - Task t3 = Task.builder().agent("C").description("do C").build(); - WorkflowStep s1 = - WorkflowStep.builder() - .name("s1") - .layer(0) - .stepType(StepType.ANY_SUCCESS) - .subtasks(List.of(t1, t2, t3)) - .next(List.of()) - .build(); - Workflow wf = Workflow.builder().name("any").steps(List.of(s1)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B", "C"); - // All succeed; the step should complete as soon as the first returns. - WorkflowExecutor exec = - new WorkflowExecutor(wf, autoCp(), stub, recordingCallback(), "", "zh"); - ExecutionResult result = exec.run().join(); - assertTrue(result.isSuccess()); - // At least one task was sent (could be all 3 racing, but >= 1) - assertTrue(stub.getSentCount() >= 1); - } - - @Test - void anySuccessAllFailReturnsFailure() { - Task t1 = task("A", "do A"); - Task t2 = task("B", "do B"); - WorkflowStep s1 = - WorkflowStep.builder() - .name("s1") - .layer(0) - .stepType(StepType.ANY_SUCCESS) - .subtasks(List.of(t1, t2)) - .next(List.of()) - .build(); - Workflow wf = Workflow.builder().name("any-fail").steps(List.of(s1)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B").withDefaultTaskState("TASK_STATE_FAILED"); - ControlPoint cp = - new ControlPoint() { - @Override - public CompletableFuture<MessageContent> onTask( - TaskRequest req) { - return CompletableFuture.completedFuture( - MessageContent.text(req.getInstruction())); - } - - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String stepName = routeRequest.stepName(); - Map<String, Object> results = java.util.Map.of(); - List<JumpCondition> conditions = - routeRequest.candidates().stream() - .map( - option -> - new JumpCondition( - option.nextStep(), - option.condition())) - .toList(); - - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(conditions.get(0).getStep()) - .build()); - } - }; - WorkflowExecutor exec = new WorkflowExecutor(wf, cp, stub, recordingCallback(), "", "zh"); - ExecutionResult result = exec.run().join(); - assertFalse(result.isSuccess()); - } - - @Test - void eventSequenceForLinearWorkflow() { - WorkflowStep s1 = - WorkflowStep.builder() - .name("s1") - .layer(0) - .subtasks(List.of(task("A", "do A"))) - .next(List.of()) - .build(); - Workflow wf = Workflow.builder().name("seq").steps(List.of(s1)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A"); - WorkflowExecutor exec = - new WorkflowExecutor(wf, autoCp(), stub, recordingCallback(), "", "zh"); - exec.run().join(); - - // Executor emits: step_start, task_request, task_status_changed, - // task_response, step_complete, workflow_complete (NO start/complete/close) - int startIdx = events.indexOf(EventType.STEP_START); - int taskReqIdx = events.indexOf(EventType.TASK_REQUEST); - int taskStatusIdx = events.indexOf(EventType.TASK_STATUS_CHANGED); - int taskRespIdx = events.indexOf(EventType.TASK_RESPONSE); - int stepCompleteIdx = events.indexOf(EventType.STEP_COMPLETE); - int wfCompleteIdx = events.indexOf(EventType.WORKFLOW_COMPLETE); - assertNotEquals(-1, startIdx); - assertTrue(startIdx < taskReqIdx); - assertTrue(taskReqIdx < taskStatusIdx); - assertTrue(taskStatusIdx < taskRespIdx); - assertTrue(taskRespIdx < stepCompleteIdx); - assertTrue(stepCompleteIdx < wfCompleteIdx); - assertFalse( - events.contains(EventType.START), "Executor must not emit START (runner's job)"); - } - - @Test - void runtimeIntentPassedToContext() { - WorkflowStep s1 = - WorkflowStep.builder() - .name("s1") - .layer(0) - .subtasks(List.of(task("A", "do A"))) - .next(List.of()) - .build(); - Workflow wf = Workflow.builder().name("intent").steps(List.of(s1)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A"); - List<TaskRequest> requests = Collections.synchronizedList(new ArrayList<>()); - ControlPoint cp = - new ControlPoint() { - @Override - public CompletableFuture<MessageContent> onTask( - TaskRequest req) { - requests.add(req); - return CompletableFuture.completedFuture( - MessageContent.text(req.getInstruction())); - } - - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String stepName = routeRequest.stepName(); - Map<String, Object> results = java.util.Map.of(); - List<JumpCondition> conditions = - routeRequest.candidates().stream() - .map( - option -> - new JumpCondition( - option.nextStep(), - option.condition())) - .toList(); - - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(conditions.get(0).getStep()) - .build()); - } - }; - WorkflowExecutor exec = - new WorkflowExecutor(wf, cp, stub, new EventCallback(), "my intent", "zh"); - exec.run().join(); - 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 - void noSubtasksStepSucceeds() { - WorkflowStep s1 = - WorkflowStep.builder() - .name("s1") - .layer(0) - .subtasks(List.of()) - .next(List.of()) - .build(); - Workflow wf = Workflow.builder().name("empty-step").steps(List.of(s1)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient(); - WorkflowExecutor exec = - new WorkflowExecutor(wf, autoCp(), stub, recordingCallback(), "", "zh"); - ExecutionResult result = exec.run().join(); - assertTrue(result.isSuccess()); - assertEquals(0, stub.getSentCount()); - } - - @Test - void missingStaticRouteTargetFailsBeforeExecution() { - WorkflowStep step = - WorkflowStep.builder() - .name("start") - .layer(0) - .subtasks(List.of(task("A", "run"))) - .next(List.of(jump("missing", ""))) - .build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A"); - ExecutionResult result = - new WorkflowExecutor( - Workflow.builder().name("invalid").steps(List.of(step)).build(), - autoCp(), - stub, - recordingCallback(), - "", - "zh") - .run() - .join(); - assertFalse(result.isSuccess()); - assertTrue(result.getError().contains("missing step")); - assertEquals(0, stub.getSentCount()); - } - - @Test - void cyclicWorkflowFailsBeforeExecution() { - WorkflowStep first = - WorkflowStep.builder() - .name("first") - .subtasks(List.of(task("A", "run"))) - .next(List.of(jump("second", ""))) - .build(); - WorkflowStep second = - WorkflowStep.builder() - .name("second") - .subtasks(List.of(task("B", "run"))) - .next(List.of(jump("first", ""))) - .build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); - ExecutionResult result = - new WorkflowExecutor( - Workflow.builder() - .name("cycle") - .steps(List.of(first, second)) - .build(), - autoCp(), - stub, - recordingCallback(), - "", - "zh") - .run() - .join(); - assertFalse(result.isSuccess()); - assertTrue(result.getError().contains("cycle")); - assertEquals(0, stub.getSentCount()); - } - - @Test - void mergeDoesNotWaitForUnselectedConditionalBranch() { - WorkflowStep start = - WorkflowStep.builder() - .name("start") - .layer(0) - .subtasks(List.of(task("A", "start"))) - .next(List.of(jump("left", "left"), jump("right", "right"))) - .build(); - WorkflowStep left = - WorkflowStep.builder() - .name("left") - .layer(1) - .subtasks(List.of(task("B", "left"))) - .next(List.of(jump("merge", ""))) - .build(); - WorkflowStep right = - WorkflowStep.builder() - .name("right") - .layer(1) - .subtasks(List.of(task("C", "right"))) - .next(List.of(jump("merge", ""))) - .build(); - WorkflowStep merge = - WorkflowStep.builder() - .name("merge") - .layer(2) - .subtasks(List.of(task("M", "merge"))) - .build(); - ControlPoint chooseRight = - new ControlPoint() { - @Override - public CompletableFuture<MessageContent> onTask( - TaskRequest request) { - return CompletableFuture.completedFuture( - MessageContent.text( - request.getInstruction())); - } - - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String stepName = routeRequest.stepName(); - Map<String, Object> results = java.util.Map.of(); - List<JumpCondition> conditions = - routeRequest.candidates().stream() - .map( - option -> - new JumpCondition( - option.nextStep(), - option.condition())) - .toList(); - - return CompletableFuture.completedFuture( - RouteDecision.builder().nextStep("right").reason("test").build()); - } - }; - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B", "C", "M"); - ExecutionResult result = - new WorkflowExecutor( - Workflow.builder() - .name("conditional-merge") - .steps(List.of(start, left, right, merge)) - .build(), - chooseRight, - stub, - recordingCallback(), - "", - "zh") - .run() - .join(); - assertTrue(result.isSuccess()); - assertEquals(List.of("A", "C", "M"), - stub.getSentMessages().stream().map(message -> message.agentName).toList()); - } - - @Test - void selfLoopStepCallsOnSelfTaskNotOnTask() { - // A SELF_LOOP step must dispatch via onSelfTask (local callback), - // NOT onTask (A2A-T). The stub client records sends, so onSelfTask - // producing a result without a send proves no A2A-T message went out. - Task merge = Task.builder().agent("Workbench").description("merge results").build(); - WorkflowStep remote = - WorkflowStep.builder() - .name("diag") - .layer(0) - .subtasks(List.of(task("SPN", "diagnose"))) - .next(List.of(jump("merge", ""))) - .build(); - WorkflowStep selfStep = - WorkflowStep.builder() - .name("merge") - .layer(1) - .stepType(StepType.SELF_LOOP) - .subtasks(List.of(merge)) - .next(List.of()) - .build(); - Workflow wf = Workflow.builder().name("self-loop").steps(List.of(remote, selfStep)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("SPN") - .withDefaultOutputs( - List.of(Map.of("diagnosis", List.of("found"), "alarmCount", 1))); - List<TaskRequest> selfTaskRequests = Collections.synchronizedList(new ArrayList<>()); - ControlPoint cp = - new ControlPoint() { - @Override - public CompletableFuture<MessageContent> onTask( - TaskRequest req) { - return CompletableFuture.completedFuture( - MessageContent.text( - req.getInstruction())) - .thenApply( - r -> MessageContent.text(req.getInstruction())); - } - - @Override - public CompletableFuture<TaskResult> onSelfTask(TaskRequest req) { - selfTaskRequests.add(req); - return CompletableFuture.completedFuture( - TaskResult.builder() - .success(true) - .outputs(List.of("merged")).build()); - } - - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String s = routeRequest.stepName(); - Map<String, Object> r = java.util.Map.of(); - List<JumpCondition> c = - routeRequest.candidates().stream() - .map( - option -> - new JumpCondition( - option.nextStep(), - option.condition())) - .toList(); - - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(c.get(0).getStep()) - .reason("first") - .build()); - } - }; - WorkflowExecutor exec = - new WorkflowExecutor(wf, cp, stub, recordingCallback(), "intent", "zh"); - ExecutionResult result = exec.run().join(); - assertTrue(result.isSuccess()); - // 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, 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<Object> 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<MessageContent> onTask( - TaskRequest request) { - return CompletableFuture.completedFuture( - MessageContent.text(request.getInstruction())); - } - - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String stepName = routeRequest.stepName(); - Map<String, Object> results = java.util.Map.of(); - List<JumpCondition> 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<MessageContent> onTask( - TaskRequest request) { - return CompletableFuture.completedFuture( - MessageContent.text(request.getInstruction())); - } - - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String stepName = routeRequest.stepName(); - Map<String, Object> results = java.util.Map.of(); - List<JumpCondition> 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<MessageContent> onTask( - TaskRequest request) { - return CompletableFuture.failedFuture(new RuntimeException((String) null)); - } - - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String stepName = routeRequest.stepName(); - Map<String, Object> results = java.util.Map.of(); - List<JumpCondition> 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()); - } + .name("conditional-merge") + .steps(List.of(start, left, right, merge)) + .build(), + chooseRight, + stub, + recordingCallback(), + "", + "zh") + .run() + .join(); + assertTrue(result.isSuccess()); + assertEquals( + List.of("A", "C", "M"), + stub.getSentMessages().stream().map(message -> message.agentName).toList()); + } + + @Test + void selfLoopStepCallsOnSelfTaskNotOnTask() { + // A SELF_LOOP step must dispatch via onSelfTask (local callback), + // NOT onTask (A2A-T). The stub client records sends, so onSelfTask + // producing a result without a send proves no A2A-T message went out. + Task merge = Task.builder().agent("Workbench").description("merge results").build(); + WorkflowStep remote = + WorkflowStep.builder() + .name("diag") + .layer(0) + .subtasks(List.of(task("SPN", "diagnose"))) + .next(List.of(jump("merge", ""))) + .build(); + WorkflowStep selfStep = + WorkflowStep.builder() + .name("merge") + .layer(1) + .stepType(StepType.SELF_LOOP) + .subtasks(List.of(merge)) + .next(List.of()) + .build(); + Workflow wf = Workflow.builder().name("self-loop").steps(List.of(remote, selfStep)).build(); + StubWorkflowEngineClient stub = + new StubWorkflowEngineClient("SPN") + .withDefaultOutputs(List.of(Map.of("diagnosis", List.of("found"), "alarmCount", 1))); + List<TaskRequest> selfTaskRequests = Collections.synchronizedList(new ArrayList<>()); + ControlPoint cp = + new ControlPoint() { + @Override + public CompletableFuture<MessageContent> onTask(TaskRequest req) { + return CompletableFuture.completedFuture(MessageContent.text(req.getInstruction())) + .thenApply(r -> MessageContent.text(req.getInstruction())); + } + + @Override + public CompletableFuture<TaskResult> onSelfTask(TaskRequest req) { + selfTaskRequests.add(req); + return CompletableFuture.completedFuture( + TaskResult.builder().success(true).outputs(List.of("merged")).build()); + } + + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String s = routeRequest.stepName(); + Map<String, Object> r = java.util.Map.of(); + List<JumpCondition> c = + routeRequest.candidates().stream() + .map(option -> new JumpCondition(option.nextStep(), option.condition())) + .toList(); + + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep(c.get(0).getStep()).reason("first").build()); + } + }; + WorkflowExecutor exec = new WorkflowExecutor(wf, cp, stub, recordingCallback(), "intent", "zh"); + ExecutionResult result = exec.run().join(); + assertTrue(result.isSuccess()); + // 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, 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<Object> 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<MessageContent> onTask(TaskRequest request) { + return CompletableFuture.completedFuture(MessageContent.text(request.getInstruction())); + } + + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map<String, Object> results = java.util.Map.of(); + List<JumpCondition> conditions = + routeRequest.candidates().stream() + .map(option -> new JumpCondition(option.nextStep(), option.condition())) + .toList(); + + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep("end").build()); + } + }; - @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<MessageContent> 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()); - } + 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<MessageContent> onTask(TaskRequest request) { + return CompletableFuture.completedFuture(MessageContent.text(request.getInstruction())); + } + + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map<String, Object> results = java.util.Map.of(); + List<JumpCondition> 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<MessageContent> onTask(TaskRequest request) { + return CompletableFuture.failedFuture(new RuntimeException((String) null)); + } + + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map<String, Object> results = java.util.Map.of(); + List<JumpCondition> 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<MessageContent> 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 index 7937d240..5f5e2b62 100644 --- 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 @@ -19,67 +19,96 @@ package dev.openan.workflow.engine.model; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; 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<Object> nested = new ArrayList<>(List.of("city1")); - Map<String, Object> data = new LinkedHashMap<>(Map.of("cities", nested)); - Map<String, Object> metadata = new LinkedHashMap<>(Map.of("nested", data)); - Set<String> extensions = new LinkedHashSet<>(List.of("urn:example:task")); - List<Part<?>> 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 snapshotsPartsMetadataAndActivationWithoutChangingOrder() { + List<Object> nested = new ArrayList<>(List.of("city1")); + Map<String, Object> data = new LinkedHashMap<>(Map.of("cities", nested)); + Map<String, Object> metadata = new LinkedHashMap<>(Map.of("nested", data)); + Set<String> extensions = new LinkedHashSet<>(List.of("urn:example:task")); + List<Part<?>> 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<Object> 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 snapshotsPrimitiveAndArrayDataWithoutFlattening() { + List<Object> 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<Object> 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 keepsMetadataOnlyResponseAndAllArtifactFields() { + List<Object> 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<Object>() {}))); - assertThrows(NullPointerException.class, () -> MessageContent.parts(null)); - assertThrows(IllegalArgumentException.class, - () -> new MessageContent(List.of(), Map.of(), Set.of(""))); - } + @Test + void preservesFileReferenceAndRejectsUnknownParts() { + assertInstanceOf(TextPart.class, MessageContent.text("").parts().get(0)); + assertThrows( + IllegalArgumentException.class, () -> MessageContent.parts(List.of(new Part<Object>() {}))); + 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 index 2cf32649..9ab7c1b8 100644 --- 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 @@ -18,25 +18,32 @@ */ 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.*; +import java.time.Duration; +import java.util.*; +import org.junit.jupiter.api.Test; + 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<NegotiationRequest.Exchange> 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))); - } + @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<NegotiationRequest.Exchange> 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/model/WorkflowModelTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/WorkflowModelTest.java index f3501fb0..54c94ddb 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/model/WorkflowModelTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/WorkflowModelTest.java @@ -21,206 +21,168 @@ import static org.junit.jupiter.api.Assertions.*; -import org.junit.jupiter.api.Test; - import java.util.List; import java.util.Map; +import org.junit.jupiter.api.Test; /** Tests for Workflow/WorkflowStep/Task/JumpCondition/StepType model parsing. */ class WorkflowModelTest { - @Test - void parsesLinearWorkflow() { - Map<String, Object> data = - Map.of( - "id", "wf-1", - "name", "test", - "steps", - List.of( - Map.of( - "name", - "s1", - "layer", - 0, - "subtasks", - List.of( - Map.of( - "agent", - "A", - "skill", - "diag", - "description", - "do A")), - "next", - List.of(Map.of("step", "s2", "condition", ""))), - Map.of( - "name", - "s2", - "layer", - 1, - "subtasks", - List.of( - Map.of( - "agent", - "B", - "description", - "do B"))))); - Workflow wf = Workflow.fromMap(data); - assertEquals("wf-1", wf.getId()); - assertEquals("test", wf.getName()); - assertEquals(2, wf.getSteps().size()); - assertEquals("s1", wf.getSteps().get(0).getName()); - assertEquals(0, wf.getSteps().get(0).getLayer()); - assertEquals("A", wf.getSteps().get(0).getSubtasks().get(0).getAgent()); - assertEquals("diag", wf.getSteps().get(0).getSubtasks().get(0).getSkill()); - assertEquals("do A", wf.getSteps().get(0).getSubtasks().get(0).getDescription()); - assertEquals("s2", wf.getSteps().get(0).getNext().get(0).getStep()); - assertEquals(1, wf.getSteps().get(1).getLayer()); - } - - @Test - void stepTypeCaseInsensitiveAllSuccess() { - for (String v : - List.of("AllSuccess", "ALLSUCCESS", "allsuccess", "ALL_SUCCESS", "all_success")) { - Map<String, Object> data = - Map.of( - "name", - "t", - "steps", - List.of(Map.of("name", "s", "step_type", v, "subtasks", List.of()))); - Workflow wf = Workflow.fromMap(data); - assertEquals( - StepType.ALL_SUCCESS, - wf.getSteps().get(0).getStepType(), - "Failed for input: " + v); - } - } - - @Test - void stepTypeCaseInsensitiveAnySuccess() { - for (String v : - List.of("AnySuccess", "ANYSUCCESS", "anysuccess", "ANY_SUCCESS", "any_success")) { - Map<String, Object> data = + @Test + void parsesLinearWorkflow() { + Map<String, Object> data = + Map.of( + "id", "wf-1", + "name", "test", + "steps", + List.of( Map.of( - "name", - "t", - "steps", - List.of(Map.of("name", "s", "step_type", v, "subtasks", List.of()))); - Workflow wf = Workflow.fromMap(data); - assertEquals( - StepType.ANY_SUCCESS, - wf.getSteps().get(0).getStepType(), - "Failed for input: " + v); - } - } - - @Test - void stepTypeViaTypeFieldFallback() { - Map<String, Object> data = - Map.of( - "name", - "t", - "steps", - List.of(Map.of("name", "s", "type", "AnySuccess", "subtasks", List.of()))); - Workflow wf = Workflow.fromMap(data); - assertEquals(StepType.ANY_SUCCESS, wf.getSteps().get(0).getStepType()); - } - - @Test - void stepTypeDefaultsToAllSuccess() { - Map<String, Object> data = - Map.of("name", "t", "steps", List.of(Map.of("name", "s", "subtasks", List.of()))); - Workflow wf = Workflow.fromMap(data); - assertEquals(StepType.ALL_SUCCESS, wf.getSteps().get(0).getStepType()); - } - - @Test - void stepTypeBogusFallsBackToAllSuccess() { - Map<String, Object> data = - Map.of( "name", - "t", - "steps", - List.of(Map.of("name", "s", "step_type", "bogus", "subtasks", List.of()))); - Workflow wf = Workflow.fromMap(data); - assertEquals(StepType.ALL_SUCCESS, wf.getSteps().get(0).getStepType()); - } - - @Test - void contextFromSingleStringNormalizedToList() { - Map<String, Object> data = - Map.of( - "name", - "t", - "steps", - List.of( - Map.of( - "name", - "s", - "context_from", - "prev_step", - "subtasks", - List.of()))); - Workflow wf = Workflow.fromMap(data); - assertNotNull(wf.getSteps().get(0).getContextFrom()); - assertEquals(List.of("prev_step"), wf.getSteps().get(0).getContextFrom()); - } - - @Test - void contextFromListPreserved() { - Map<String, Object> data = - Map.of( - "name", - "t", - "steps", - List.of( - Map.of( - "name", - "s", - "context_from", - List.of("a", "b"), - "subtasks", - List.of()))); - Workflow wf = Workflow.fromMap(data); - assertEquals(List.of("a", "b"), wf.getSteps().get(0).getContextFrom()); - } - - @Test - void contextFromStarPreserved() { - Map<String, Object> data = - Map.of( + "s1", + "layer", + 0, + "subtasks", + List.of(Map.of("agent", "A", "skill", "diag", "description", "do A")), + "next", + List.of(Map.of("step", "s2", "condition", ""))), + Map.of( "name", - "t", - "steps", - List.of( - Map.of( - "name", - "s", - "context_from", - List.of("*"), - "subtasks", - List.of()))); - Workflow wf = Workflow.fromMap(data); - assertEquals(List.of("*"), wf.getSteps().get(0).getContextFrom()); - } - - @Test - void taskStatusDefaultsToPending() { - Task t = Task.builder().agent("A").description("do").build(); - assertEquals(TaskStatus.PENDING, t.getStatus()); + "s2", + "layer", + 1, + "subtasks", + List.of(Map.of("agent", "B", "description", "do B"))))); + Workflow wf = Workflow.fromMap(data); + assertEquals("wf-1", wf.getId()); + assertEquals("test", wf.getName()); + assertEquals(2, wf.getSteps().size()); + assertEquals("s1", wf.getSteps().get(0).getName()); + assertEquals(0, wf.getSteps().get(0).getLayer()); + assertEquals("A", wf.getSteps().get(0).getSubtasks().get(0).getAgent()); + assertEquals("diag", wf.getSteps().get(0).getSubtasks().get(0).getSkill()); + assertEquals("do A", wf.getSteps().get(0).getSubtasks().get(0).getDescription()); + assertEquals("s2", wf.getSteps().get(0).getNext().get(0).getStep()); + assertEquals(1, wf.getSteps().get(1).getLayer()); + } + + @Test + void stepTypeCaseInsensitiveAllSuccess() { + for (String v : + List.of("AllSuccess", "ALLSUCCESS", "allsuccess", "ALL_SUCCESS", "all_success")) { + Map<String, Object> data = + Map.of( + "name", + "t", + "steps", + List.of(Map.of("name", "s", "step_type", v, "subtasks", List.of()))); + Workflow wf = Workflow.fromMap(data); + assertEquals( + StepType.ALL_SUCCESS, wf.getSteps().get(0).getStepType(), "Failed for input: " + v); } - - @Test - void jumpConditionDefaults() { - JumpCondition jc = JumpCondition.builder().step("s2").build(); - assertEquals("s2", jc.getStep()); - assertEquals("", jc.getCondition()); - } - - @Test - void emptyStepsAllowed() { - Map<String, Object> data = Map.of("name", "empty", "steps", List.of()); - Workflow wf = Workflow.fromMap(data); - assertTrue(wf.getSteps().isEmpty()); + } + + @Test + void stepTypeCaseInsensitiveAnySuccess() { + for (String v : + List.of("AnySuccess", "ANYSUCCESS", "anysuccess", "ANY_SUCCESS", "any_success")) { + Map<String, Object> data = + Map.of( + "name", + "t", + "steps", + List.of(Map.of("name", "s", "step_type", v, "subtasks", List.of()))); + Workflow wf = Workflow.fromMap(data); + assertEquals( + StepType.ANY_SUCCESS, wf.getSteps().get(0).getStepType(), "Failed for input: " + v); } + } + + @Test + void stepTypeViaTypeFieldFallback() { + Map<String, Object> data = + Map.of( + "name", + "t", + "steps", + List.of(Map.of("name", "s", "type", "AnySuccess", "subtasks", List.of()))); + Workflow wf = Workflow.fromMap(data); + assertEquals(StepType.ANY_SUCCESS, wf.getSteps().get(0).getStepType()); + } + + @Test + void stepTypeDefaultsToAllSuccess() { + Map<String, Object> data = + Map.of("name", "t", "steps", List.of(Map.of("name", "s", "subtasks", List.of()))); + Workflow wf = Workflow.fromMap(data); + assertEquals(StepType.ALL_SUCCESS, wf.getSteps().get(0).getStepType()); + } + + @Test + void stepTypeBogusFallsBackToAllSuccess() { + Map<String, Object> data = + Map.of( + "name", + "t", + "steps", + List.of(Map.of("name", "s", "step_type", "bogus", "subtasks", List.of()))); + Workflow wf = Workflow.fromMap(data); + assertEquals(StepType.ALL_SUCCESS, wf.getSteps().get(0).getStepType()); + } + + @Test + void contextFromSingleStringNormalizedToList() { + Map<String, Object> data = + Map.of( + "name", + "t", + "steps", + List.of(Map.of("name", "s", "context_from", "prev_step", "subtasks", List.of()))); + Workflow wf = Workflow.fromMap(data); + assertNotNull(wf.getSteps().get(0).getContextFrom()); + assertEquals(List.of("prev_step"), wf.getSteps().get(0).getContextFrom()); + } + + @Test + void contextFromListPreserved() { + Map<String, Object> data = + Map.of( + "name", + "t", + "steps", + List.of(Map.of("name", "s", "context_from", List.of("a", "b"), "subtasks", List.of()))); + Workflow wf = Workflow.fromMap(data); + assertEquals(List.of("a", "b"), wf.getSteps().get(0).getContextFrom()); + } + + @Test + void contextFromStarPreserved() { + Map<String, Object> data = + Map.of( + "name", + "t", + "steps", + List.of(Map.of("name", "s", "context_from", List.of("*"), "subtasks", List.of()))); + Workflow wf = Workflow.fromMap(data); + assertEquals(List.of("*"), wf.getSteps().get(0).getContextFrom()); + } + + @Test + void taskStatusDefaultsToPending() { + Task t = Task.builder().agent("A").description("do").build(); + assertEquals(TaskStatus.PENDING, t.getStatus()); + } + + @Test + void jumpConditionDefaults() { + JumpCondition jc = JumpCondition.builder().step("s2").build(); + assertEquals("s2", jc.getStep()); + assertEquals("", jc.getCondition()); + } + + @Test + void emptyStepsAllowed() { + Map<String, Object> data = Map.of("name", "empty", "steps", List.of()); + Workflow wf = Workflow.fromMap(data); + assertTrue(wf.getSteps().isEmpty()); + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopSearchTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopSearchTest.java index a560ee36..e83c0ac3 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopSearchTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopSearchTest.java @@ -22,10 +22,8 @@ import static org.junit.jupiter.api.Assertions.*; import dev.openan.workflow.engine.model.WorkflowSearchResult; - -import org.junit.jupiter.api.Test; - import java.util.List; +import org.junit.jupiter.api.Test; /** * Tests LoadPsop.search parsing logic using a local mock HTTP server. No real orchestration center @@ -33,109 +31,113 @@ */ class LoadPsopSearchTest { - /** - * Verifies that the search method correctly parses the orchestration center's JSON response - * into WorkflowSearchResult objects. - * - * <p>Uses Java's built-in HttpServer to mock the endpoint, so no external dependency is needed. - */ - @Test - void searchParsesResultsFromMockServer() throws Exception { - // Start a mock HTTP server returning a canned search response - com.sun.net.httpserver.HttpServer server = - com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress(0), 0); - server.createContext( - "/api/v1/orchestrate/search", - exchange -> { - String json = - "{\"data\":[" - + "{\"workflow_id\":\"psop-1\",\"workflow_type\":\"psop\",\"name\":\"fault_diag\",\"description\":\"SPN fault diagnosis\",\"score\":0.95,\"user_intent\":\"diagnose SPN\",\"tasks_summary\":\"3 steps\"}," - + "{\"workflow_id\":\"psop-2\",\"workflow_type\":\"psop\",\"name\":\"energy_saving\",\"description\":\"Energy saving flow\",\"score\":0.80}" - + "]}"; - byte[] resp = json.getBytes(java.nio.charset.StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, resp.length); - try (var os = exchange.getResponseBody()) { - os.write(resp); - } - }); - server.start(); - int port = server.getAddress().getPort(); - String baseUrl = "http://127.0.0.1:" + port; + @Test + void tokenLogsRevealPresenceButNoTokenCharacters() { + assertEquals("https://example.test/search?access_token=<anonymous>", + LoadPsop.anonymousUrl("https://example.test/search?access_token=secret%2Bvalue", "secret+value")); + assertEquals("https://example.test/search", LoadPsop.anonymousUrl("https://example.test/search", null)); + } + + /** + * Verifies that the search method correctly parses the orchestration center's JSON response into + * WorkflowSearchResult objects. + * + * <p>Uses Java's built-in HttpServer to mock the endpoint, so no external dependency is needed. + */ + @Test + void searchParsesResultsFromMockServer() throws Exception { + // Start a mock HTTP server returning a canned search response + com.sun.net.httpserver.HttpServer server = + com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress(0), 0); + server.createContext( + "/api/v1/orchestrate/search", + exchange -> { + String json = + "{\"data\":[" + + "{\"workflow_id\":\"psop-1\",\"workflow_type\":\"psop\",\"name\":\"fault_diag\",\"description\":\"SPN fault diagnosis\",\"score\":0.95,\"user_intent\":\"diagnose SPN\",\"tasks_summary\":\"3 steps\"}," + + "{\"workflow_id\":\"psop-2\",\"workflow_type\":\"psop\",\"name\":\"energy_saving\",\"description\":\"Energy saving flow\",\"score\":0.80}" + + "]}"; + byte[] resp = json.getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, resp.length); + try (var os = exchange.getResponseBody()) { + os.write(resp); + } + }); + server.start(); + int port = server.getAddress().getPort(); + String baseUrl = "http://127.0.0.1:" + port; - try { - List<WorkflowSearchResult> results = - LoadPsop.search(baseUrl, "SPN fault", 5, null, false); - assertEquals(2, results.size()); - WorkflowSearchResult r1 = results.get(0); - assertEquals("psop-1", r1.getWorkflowId()); - assertEquals("psop", r1.getWorkflowType()); - assertEquals("fault_diag", r1.getName()); - assertEquals("SPN fault diagnosis", r1.getDescription()); - assertEquals(0.95, r1.getScore(), 0.001); - assertEquals("diagnose SPN", r1.getUserIntent()); - assertEquals("3 steps", r1.getTasksSummary()); - WorkflowSearchResult r2 = results.get(1); - assertEquals("psop-2", r2.getWorkflowId()); - assertEquals("energy_saving", r2.getName()); - assertEquals(0.80, r2.getScore(), 0.001); - } finally { - server.stop(0); - } + try { + List<WorkflowSearchResult> results = LoadPsop.search(baseUrl, "SPN fault", 5, null, false); + assertEquals(2, results.size()); + WorkflowSearchResult r1 = results.get(0); + assertEquals("psop-1", r1.getWorkflowId()); + assertEquals("psop", r1.getWorkflowType()); + assertEquals("fault_diag", r1.getName()); + assertEquals("SPN fault diagnosis", r1.getDescription()); + assertEquals(0.95, r1.getScore(), 0.001); + assertEquals("diagnose SPN", r1.getUserIntent()); + assertEquals("3 steps", r1.getTasksSummary()); + WorkflowSearchResult r2 = results.get(1); + assertEquals("psop-2", r2.getWorkflowId()); + assertEquals("energy_saving", r2.getName()); + assertEquals(0.80, r2.getScore(), 0.001); + } finally { + server.stop(0); } + } - @Test - void searchEmptyResultsFromMockServer() throws Exception { - com.sun.net.httpserver.HttpServer server = - com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress(0), 0); - server.createContext( - "/api/v1/orchestrate/search", - exchange -> { - String json = "{\"data\":[]}"; - byte[] resp = json.getBytes(java.nio.charset.StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, resp.length); - try (var os = exchange.getResponseBody()) { - os.write(resp); - } - }); - server.start(); - int port = server.getAddress().getPort(); - String baseUrl = "http://127.0.0.1:" + port; + @Test + void searchEmptyResultsFromMockServer() throws Exception { + com.sun.net.httpserver.HttpServer server = + com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress(0), 0); + server.createContext( + "/api/v1/orchestrate/search", + exchange -> { + String json = "{\"data\":[]}"; + byte[] resp = json.getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, resp.length); + try (var os = exchange.getResponseBody()) { + os.write(resp); + } + }); + server.start(); + int port = server.getAddress().getPort(); + String baseUrl = "http://127.0.0.1:" + port; - try { - List<WorkflowSearchResult> results = - LoadPsop.search(baseUrl, "nonexistent", 5, null, false); - assertTrue(results.isEmpty()); - } finally { - server.stop(0); - } + try { + List<WorkflowSearchResult> results = LoadPsop.search(baseUrl, "nonexistent", 5, null, false); + assertTrue(results.isEmpty()); + } finally { + server.stop(0); } + } - @Test - void searchThrowsOnServerError() { - com.sun.net.httpserver.HttpServer server; - try { - server = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress(0), 0); - } catch (java.io.IOException e) { - fail("Could not start mock server: " + e.getMessage()); - return; - } - server.createContext( - "/api/v1/orchestrate/search", - exchange -> { - exchange.sendResponseHeaders(500, 0); - exchange.close(); - }); - server.start(); - int port = server.getAddress().getPort(); - String baseUrl = "http://127.0.0.1:" + port; + @Test + void searchThrowsOnServerError() { + com.sun.net.httpserver.HttpServer server; + try { + server = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress(0), 0); + } catch (java.io.IOException e) { + fail("Could not start mock server: " + e.getMessage()); + return; + } + server.createContext( + "/api/v1/orchestrate/search", + exchange -> { + exchange.sendResponseHeaders(500, 0); + exchange.close(); + }); + server.start(); + int port = server.getAddress().getPort(); + String baseUrl = "http://127.0.0.1:" + port; - try { - assertThrows( - RuntimeException.class, () -> LoadPsop.search(baseUrl, "test", 5, null, false)); - } finally { - server.stop(0); - } + try { + assertThrows(RuntimeException.class, () -> LoadPsop.search(baseUrl, "test", 5, null, false)); + } finally { + server.stop(0); } + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/RegistryClientTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/RegistryClientTest.java new file mode 100644 index 00000000..0598f511 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/RegistryClientTest.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.registry; + +import static org.junit.jupiter.api.Assertions.*; +import java.net.InetSocketAddress; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RegistryClientTest { + @Test + void rejectsNonPositiveDeadline() { + assertThrows(IllegalArgumentException.class, () -> new RegistryClient("http://localhost", true, Duration.ZERO)); + } + + @Test + void deadlineIncludesStalledResponseBody() throws Exception { + var server = com.sun.net.httpserver.HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + var release = new java.util.concurrent.CountDownLatch(1); + server.createContext("/", exchange -> { + exchange.sendResponseHeaders(200, 100); + exchange.getResponseBody().write('{'); + exchange.getResponseBody().flush(); + try { release.await(3, java.util.concurrent.TimeUnit.SECONDS); } + catch (InterruptedException error) { Thread.currentThread().interrupt(); } + finally { exchange.close(); } + }); + server.start(); + try { + var client = new RegistryClient("http://127.0.0.1:" + server.getAddress().getPort(), true, Duration.ofMillis(200)); + assertTimeoutPreemptively(Duration.ofSeconds(2), () -> + assertThrows(java.net.http.HttpTimeoutException.class, client::fetchAgentCards)); + } finally { + release.countDown(); + server.stop(0); + } + } +} 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 7417f969..35884167 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 @@ -25,18 +25,16 @@ 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 dev.openan.workflow.engine.model.RouteRequest; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; import java.util.function.Function; +import org.junit.jupiter.api.Test; /** * Tests for ExecutePsop: end-to-end event flow, lifecycle, on_finish, on_event transformer, START @@ -44,271 +42,396 @@ */ class ExecutePsopTest { - private Task task(String agent, String desc) { - return Task.builder().agent(agent).description(desc).build(); - } + @Test + void cancellationStopsLateTaskPreparationAndClosesLifecycleOnce() throws Exception { + var prepared = new CompletableFuture<MessageContent>(); + var entered = new java.util.concurrent.CountDownLatch(1); + var finished = new java.util.concurrent.CountDownLatch(1); + var closes = new AtomicInteger(); + var stub = new StubWorkflowEngineClient("A", "B"); + var execution = ExecutePsop.builder().psop(linearWorkflow()).engineClient(stub) + .controlPoint(ControlPoint.builder().onTask(request -> { + entered.countDown(); + return prepared; + }).build()) + .eventCallback(new EventCallback() { + @Override public void onEvent(String type, Map<String, Object> data) { + if (EventType.CLOSE.equals(type)) closes.incrementAndGet(); + } + }) + .onFinish((result, events) -> { + assertFalse(result.isSuccess()); + assertEquals("Workflow execution cancelled", result.getError()); + finished.countDown(); + return CompletableFuture.completedFuture(null); + }).execute(); + assertTrue(entered.await(2, java.util.concurrent.TimeUnit.SECONDS)); + assertTrue(execution.cancel(true)); + prepared.complete(MessageContent.text("must not send")); + assertTrue(finished.await(2, java.util.concurrent.TimeUnit.SECONDS)); + assertTrue(execution.isCancelled()); + assertEquals(0, stub.getSentMessages().size()); + assertEquals(1, closes.get()); + } - private ControlPoint autoCp() { - return new ControlPoint() { - @Override - public CompletableFuture<MessageContent> onTask( - TaskRequest request) { - return CompletableFuture.completedFuture( - MessageContent.text( - request.getInstruction())); - } + @Test + void cancellationWhileOnFinishIsPendingStillClosesExactlyOnce() { + var finish = new CompletableFuture<Void>(); + var closes = new AtomicInteger(); + var execution = ExecutePsop.builder().psop(linearWorkflow()) + .engineClient(new StubWorkflowEngineClient("A", "B")).controlPoint(autoCp()) + .eventCallback(new EventCallback() { + @Override public void onEvent(String type, Map<String, Object> data) { + if (EventType.CLOSE.equals(type)) closes.incrementAndGet(); + } + }).onFinish((result, events) -> finish).execute(); + assertTrue(execution.cancel(true)); + assertEquals(1, closes.get()); + finish.complete(null); + assertEquals(1, closes.get()); + } - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String stepName = routeRequest.stepName(); - Map<String, Object> results = java.util.Map.of(); - List<JumpCondition> conditions = - routeRequest.candidates().stream() - .map(c -> new JumpCondition(c.nextStep(), c.condition())) - .toList(); + @Test + void cancelledRunnerClosesItsOwnedRuntime() { + var closes = new AtomicInteger(); + var runtime = (dev.openan.workflow.engine.client.A2AJavaClientRuntime) + java.lang.reflect.Proxy.newProxyInstance(getClass().getClassLoader(), + new Class<?>[] {dev.openan.workflow.engine.client.A2AJavaClientRuntime.class}, + (proxy, method, args) -> { + if (method.getName().equals("close")) closes.incrementAndGet(); + return null; + }); + var execution = ExecutePsop.builder().psop(linearWorkflow()).a2aClientRuntime(runtime) + .controlPoint(ControlPoint.builder().onTask(request -> new CompletableFuture<>()).build()) + .execute(); + assertTrue(execution.cancel(true)); + assertEquals(1, closes.get()); + } - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(conditions.get(0).getStep()) - .reason("first") - .build()); - } - }; - } + private Task task(String agent, String desc) { + return Task.builder().agent(agent).description(desc).build(); + } + + private ControlPoint autoCp() { + return new ControlPoint() { + @Override + public CompletableFuture<MessageContent> onTask(TaskRequest request) { + return CompletableFuture.completedFuture(MessageContent.text(request.getInstruction())); + } + + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map<String, Object> results = java.util.Map.of(); + List<JumpCondition> conditions = + routeRequest.candidates().stream() + .map(c -> new JumpCondition(c.nextStep(), c.condition())) + .toList(); - private Workflow linearWorkflow() { - WorkflowStep s1 = - WorkflowStep.builder() - .name("s1") - .layer(0) - .subtasks(List.of(task("A", "do A"))) - .next(List.of(JumpCondition.builder().step("s2").condition("").build())) - .build(); - WorkflowStep s2 = - WorkflowStep.builder() - .name("s2") - .layer(1) - .subtasks(List.of(task("B", "do B"))) - .build(); - return Workflow.builder().name("e2e").steps(List.of(s1, s2)).build(); - } + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep(conditions.get(0).getStep()).reason("first").build()); + } + }; + } - @Test - void fullLifecycleEventSequence() { - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); - List<String> events = Collections.synchronizedList(new ArrayList<>()); - EventCallback cb = - new EventCallback() { - @Override - public void onEvent(String type, Map<String, Object> data) { - events.add(type); - } - }; - ExecutionResult result = - ExecutePsop.execute(linearWorkflow(), List.of(), autoCp(), stub, "intent", "zh", null, false, null, null, cb, (BiFunction< - ExecutionResult, - List<Map<String, Object>>, - CompletableFuture<Void>>) - null, null) - .join(); - assertTrue(result.isSuccess()); - // Verify lifecycle bracket: start ... complete ... close - assertEquals(EventType.START, events.get(0)); - assertEquals(EventType.CLOSE, events.get(events.size() - 1)); - int startCount = Collections.frequency(events, EventType.START); - assertEquals(1, startCount, "START must be emitted exactly once (no duplicates)"); - assertTrue(events.contains(EventType.COMPLETE)); - assertTrue(events.contains(EventType.WORKFLOW_COMPLETE)); - int closeCount = Collections.frequency(events, EventType.CLOSE); - assertEquals(1, closeCount); - int completeIdx = events.indexOf(EventType.COMPLETE); - int closeIdx = events.indexOf(EventType.CLOSE); - assertTrue(completeIdx < closeIdx); - int startIdx = events.indexOf(EventType.START); - assertTrue(startIdx < completeIdx); - } + private Workflow linearWorkflow() { + WorkflowStep s1 = + WorkflowStep.builder() + .name("s1") + .layer(0) + .subtasks(List.of(task("A", "do A"))) + .next(List.of(JumpCondition.builder().step("s2").condition("").build())) + .build(); + WorkflowStep s2 = + WorkflowStep.builder().name("s2").layer(1).subtasks(List.of(task("B", "do B"))).build(); + return Workflow.builder().name("e2e").steps(List.of(s1, s2)).build(); + } - @Test - void onFinishCalledWithResultAndEvents() { - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); - AtomicInteger finishCalls = new AtomicInteger(0); - AtomicReference<ExecutionResult> capturedResult = new AtomicReference<>(); - BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> onFinish = - (r, e) -> { - finishCalls.incrementAndGet(); - capturedResult.set(r); - assertTrue(e.size() > 0, "events should be collected"); - return CompletableFuture.completedFuture(null); - }; - ExecutionResult result = - 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()); - } + @Test + void fullLifecycleEventSequence() { + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); + List<String> events = Collections.synchronizedList(new ArrayList<>()); + EventCallback cb = + new EventCallback() { + @Override + public void onEvent(String type, Map<String, Object> data) { + events.add(type); + } + }; + ExecutionResult result = + ExecutePsop.execute( + linearWorkflow(), + List.of(), + autoCp(), + stub, + "intent", + "zh", + null, + false, + null, + null, + cb, + null, + null) + .join(); + assertTrue(result.isSuccess()); + // Verify lifecycle bracket: start ... complete ... close + assertEquals(EventType.START, events.get(0)); + assertEquals(EventType.CLOSE, events.get(events.size() - 1)); + int startCount = Collections.frequency(events, EventType.START); + assertEquals(1, startCount, "START must be emitted exactly once (no duplicates)"); + assertTrue(events.contains(EventType.COMPLETE)); + assertTrue(events.contains(EventType.WORKFLOW_COMPLETE)); + int closeCount = Collections.frequency(events, EventType.CLOSE); + assertEquals(1, closeCount); + int completeIdx = events.indexOf(EventType.COMPLETE); + int closeIdx = events.indexOf(EventType.CLOSE); + assertTrue(completeIdx < closeIdx); + int startIdx = events.indexOf(EventType.START); + assertTrue(startIdx < completeIdx); + } - @Test - void onEventTransformerCanInjectEvents() { - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); - List<String> events = Collections.synchronizedList(new ArrayList<>()); - EventCallback cb = - new EventCallback() { - @Override - public void onEvent(String type, Map<String, Object> data) { - events.add(type); - } - }; - // Inject a "psop_update" before each step_start - Function<Map<String, Object>, Object> onEvent = - event -> { - if (EventType.STEP_START.equals(event.get("type"))) { - List<Map<String, Object>> injected = new ArrayList<>(); - injected.add(Map.of("type", "psop_update", "data", Map.of("custom", true))); - injected.add(event); - return injected; - } - return event; - }; - 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"); - int stepStartIdx = events.indexOf(EventType.STEP_START); - assertTrue(psopIdx >= 0); - // psop_update should come right before the first step_start - assertEquals(stepStartIdx - 1, psopIdx); - } + @Test + void onFinishCalledWithResultAndEvents() { + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); + AtomicInteger finishCalls = new AtomicInteger(0); + AtomicReference<ExecutionResult> capturedResult = new AtomicReference<>(); + BiFunction<ExecutionResult, List<Map<String, Object>>, CompletableFuture<Void>> onFinish = + (r, e) -> { + finishCalls.incrementAndGet(); + capturedResult.set(r); + assertTrue(e.size() > 0, "events should be collected"); + return CompletableFuture.completedFuture(null); + }; + ExecutionResult result = + 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()); + } - @Test - void onEventTransformerCanFilterEvents() { - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); - List<String> events = Collections.synchronizedList(new ArrayList<>()); - EventCallback cb = - new EventCallback() { - @Override - public void onEvent(String type, Map<String, Object> data) { - events.add(type); - } - }; - // Filter out all task_request events - Function<Map<String, Object>, Object> onEvent = - event -> { - if (EventType.TASK_REQUEST.equals(event.get("type"))) { - return null; - } - return event; - }; - ExecutePsop.execute(linearWorkflow(), List.of(), autoCp(), stub, "intent", "zh", null, false, null, null, cb, null, onEvent) - .join(); - assertFalse(events.contains(EventType.TASK_REQUEST)); - } + @Test + void onEventTransformerCanInjectEvents() { + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); + List<String> events = Collections.synchronizedList(new ArrayList<>()); + EventCallback cb = + new EventCallback() { + @Override + public void onEvent(String type, Map<String, Object> data) { + events.add(type); + } + }; + // Inject a "psop_update" before each step_start + Function<Map<String, Object>, Object> onEvent = + event -> { + if (EventType.STEP_START.equals(event.get("type"))) { + List<Map<String, Object>> injected = new ArrayList<>(); + injected.add(Map.of("type", "psop_update", "data", Map.of("custom", true))); + injected.add(event); + return injected; + } + return event; + }; + 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"); + int stepStartIdx = events.indexOf(EventType.STEP_START); + assertTrue(psopIdx >= 0); + // psop_update should come right before the first step_start + assertEquals(stepStartIdx - 1, psopIdx); + } - @Test - 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, false, null, null, new EventCallback(), null, null) - .join(); - ExecutionResult r2 = - ExecutePsop.builder() - .psop(linearWorkflow()) - .agentCards(List.of()) - .controlPoint(autoCp()) - .engineClient(stub2) - .runtimeIntent("intent") - .lang("zh") - .sslVerify(false) - .execute() - .join(); - assertEquals(r1.isSuccess(), r2.isSuccess()); - assertEquals(stub1.getSentCount(), stub2.getSentCount()); - } + @Test + void onEventTransformerCanFilterEvents() { + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); + List<String> events = Collections.synchronizedList(new ArrayList<>()); + EventCallback cb = + new EventCallback() { + @Override + public void onEvent(String type, Map<String, Object> data) { + events.add(type); + } + }; + // Filter out all task_request events + Function<Map<String, Object>, Object> onEvent = + event -> { + if (EventType.TASK_REQUEST.equals(event.get("type"))) { + return null; + } + return event; + }; + ExecutePsop.execute( + linearWorkflow(), + List.of(), + autoCp(), + stub, + "intent", + "zh", + null, + false, + null, + null, + cb, + null, + onEvent) + .join(); + assertFalse(events.contains(EventType.TASK_REQUEST)); + } - @Test - void builderRequiresPsop() { - assertThrows( - IllegalArgumentException.class, - () -> ExecutePsop.builder().controlPoint(autoCp()).execute()); - } + @Test + 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, + false, + null, + null, + new EventCallback(), + null, + null) + .join(); + ExecutionResult r2 = + ExecutePsop.builder() + .psop(linearWorkflow()) + .agentCards(List.of()) + .controlPoint(autoCp()) + .engineClient(stub2) + .runtimeIntent("intent") + .lang("zh") + .sslVerify(false) + .execute() + .join(); + assertEquals(r1.isSuccess(), r2.isSuccess()); + assertEquals(stub1.getSentCount(), stub2.getSentCount()); + } - @Test - void builderRequiresControlPoint() { - assertThrows( - IllegalArgumentException.class, - () -> ExecutePsop.builder().psop(linearWorkflow()).execute()); - } + @Test + void builderRequiresPsop() { + assertThrows( + IllegalArgumentException.class, + () -> ExecutePsop.builder().controlPoint(autoCp()).execute()); + } - @Test - void injectedClientRemainsCallerOwnedAfterExecution() { - AtomicInteger closeCalls = new AtomicInteger(); - StubWorkflowEngineClient client = - new StubWorkflowEngineClient("A", "B") { - @Override - public void close() { - closeCalls.incrementAndGet(); - } - }; + @Test + void builderRequiresControlPoint() { + assertThrows( + IllegalArgumentException.class, + () -> ExecutePsop.builder().psop(linearWorkflow()).execute()); + } - ExecutionResult result = - ExecutePsop.builder() - .psop(linearWorkflow()) - .controlPoint(autoCp()) - .engineClient(client) - .execute() - .join(); + @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()); - } + assertTrue(result.isSuccess()); + assertEquals(0, closeCalls.get()); + } - @Test - void errorLifecycleOnTaskFailure() { - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); - ControlPoint failCp = - new ControlPoint() { - @Override - public CompletableFuture<MessageContent> onTask( - TaskRequest request) { - if (request.getAgentName().equals("A")) { - return CompletableFuture.failedFuture( - new IllegalStateException("business preparation failed")); - } - return CompletableFuture.completedFuture( - MessageContent.text( - request.getInstruction())); - } + @Test + void errorLifecycleOnTaskFailure() { + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); + ControlPoint failCp = + new ControlPoint() { + @Override + public CompletableFuture<MessageContent> onTask(TaskRequest request) { + if (request.getAgentName().equals("A")) { + return CompletableFuture.failedFuture( + new IllegalStateException("business preparation failed")); + } + return CompletableFuture.completedFuture(MessageContent.text(request.getInstruction())); + } - @Override - public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { - String stepName = routeRequest.stepName(); - Map<String, Object> results = java.util.Map.of(); - List<JumpCondition> conditions = - routeRequest.candidates().stream() - .map(c -> new JumpCondition(c.nextStep(), c.condition())) - .toList(); + @Override + public CompletableFuture<RouteDecision> onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map<String, Object> results = java.util.Map.of(); + List<JumpCondition> conditions = + routeRequest.candidates().stream() + .map(c -> new JumpCondition(c.nextStep(), c.condition())) + .toList(); - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(conditions.get(0).getStep()) - .build()); - } - }; - List<String> events = Collections.synchronizedList(new ArrayList<>()); - EventCallback cb = - new EventCallback() { - @Override - public void onEvent(String type, Map<String, Object> data) { - events.add(type); - } - }; - ExecutionResult result = - 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)); - assertEquals(EventType.CLOSE, events.get(events.size() - 1)); - assertTrue(events.contains(EventType.ERROR)); - assertFalse(events.contains(EventType.COMPLETE)); - } + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep(conditions.get(0).getStep()).build()); + } + }; + List<String> events = Collections.synchronizedList(new ArrayList<>()); + EventCallback cb = + new EventCallback() { + @Override + public void onEvent(String type, Map<String, Object> data) { + events.add(type); + } + }; + ExecutionResult result = + 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)); + assertEquals(EventType.CLOSE, events.get(events.size() - 1)); + assertTrue(events.contains(EventType.ERROR)); + assertFalse(events.contains(EventType.COMPLETE)); + } }