From d1ca753f756af4e043d4067753e23805571467ea Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Sun, 23 Aug 2026 13:12:13 +0800 Subject: [PATCH 01/49] feat(engine): adapt to latest A2A-T SDK content APIs and negotiation contract - adopt TemplateUri/StandardTemplates value types on all prompt-generation calls - rename to validateProposePromptAndDataFilling with explicit NegotiationContext param - add A2ATContentFacade: full SDK content-layer surface (generation x8, state machine x3, validate x4, template queries x4) exposed on A2ATransport - expose template queries on WorkflowEngineClient (getPrompts/getNegotiationPrompts/getPrompt) - inject business negotiation param schema via config (engine core stays domain-agnostic) - negotiation auto-loop: Accept/Reject/Abort terminal rendering via SDK templates, round-exhaustion abort flow, rendered follow-up message body (was placeholder) - fix SSE hang on INPUT_REQUIRED: read propose text+metadata from status message, merge status-message metadata into response extraction - stop re-triggering negotiation on completed replies carrying business results - constant-ize engine-internal negotiation metadata keys - refactor autoNegotiate into sub-50-line methods, drop dead code Signed-off-by: Zhoujie628 --- .../workflow/engine/spring/A2AController.java | 94 +++- .../engine/client/A2AJavaClientRuntime.java | 31 ++ .../engine/client/A2ATContentFacade.java | 342 ++++++++++++ .../workflow/engine/client/A2ATExtension.java | 23 +- .../workflow/engine/client/A2ATransport.java | 126 ++++- .../client/DefaultA2AJavaClientRuntime.java | 65 +++ .../engine/client/DefaultExtensionSender.java | 102 ++-- .../client/DefaultWorkflowEngineClient.java | 511 ++++++++++++++---- .../engine/client/ExtensionHandler.java | 13 + .../engine/client/ExtensionRegistry.java | 13 + .../engine/client/NegotiationTHandler.java | 145 ++++- .../engine/client/SseNormalization.java | 28 +- .../workflow/engine/client/TaskTHandler.java | 12 +- .../engine/client/WorkflowEngineClient.java | 49 ++ .../client/WorkflowEngineClientConfig.java | 13 + .../engine/client/A2ATContentFacadeTest.java | 175 ++++++ 16 files changed, 1536 insertions(+), 206 deletions(-) create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java index 2e408adf..c7643f42 100644 --- a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java @@ -35,6 +35,8 @@ import org.slf4j.LoggerFactory; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -158,6 +160,92 @@ public void onComplete() { return emitter; } + @GetMapping("/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("/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 = "/tasks/{id}:subscribe", + produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseBodyEmitter subscribeToTask( + HttpServletRequest req, + @PathVariable("id") String taskId) { + ResponseBodyEmitter emitter = new ResponseBodyEmitter(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 { + String sse = + String.format(Locale.ROOT, "id:%d%n", seq.incrementAndGet()) + + "data:" + item + "\n\n"; + emitter.send(sse); + } 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(resp.getBody()); + emitter.complete(); + } + } catch (Exception e) { + log.error("[SSE] Subscribe setup failed: {}", e.getMessage(), e); + emitter.completeWithError(e); + } + return emitter; + } private ServerCallContext buildContext(HttpServletRequest req) { Map state = new LinkedHashMap<>(); Map headers = new LinkedHashMap<>(); @@ -166,7 +254,11 @@ private ServerCallContext buildContext(HttpServletRequest req) { state.put("headers", headers); String ext = req.getHeader("A2A-Extensions"); Set exts = (ext == null || ext.isBlank()) ? Set.of() : Set.of(ext.split(",")); - String ver = req.getHeader("A2A-Protocol-Version"); + // 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/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2AJavaClientRuntime.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2AJavaClientRuntime.java index 2fba1fc7..f5de5959 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2AJavaClientRuntime.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2AJavaClientRuntime.java @@ -21,7 +21,12 @@ import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import dev.openan.workflow.engine.model.SendMessageResult; import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.TaskQueryParams; import org.a2aproject.sdk.spec.MessageSendParams; import java.util.function.Consumer; @@ -57,6 +62,32 @@ Iterable sendMessage( Consumer eventSink, Consumer logSink); + + /** + * Get a task by ID (A2A GET tasks/{id}). + */ + default Task getTask(AgentCard agentCard, String taskId, ClientCallContext callContext) { + throw new UnsupportedOperationException("getTask not supported by this runtime"); + } + + /** + * Cancel a task by ID (A2A POST tasks/{id}:cancel). + */ + default Task cancelTask(AgentCard agentCard, String taskId, ClientCallContext callContext) { + throw new UnsupportedOperationException("cancelTask not supported by this runtime"); + } + + /** + * Subscribe to a task stream (A2A POST tasks/{id}:subscribe). + */ + default java.util.concurrent.CompletableFuture subscribeToTask( + AgentCard agentCard, + String taskId, + ClientCallContext callContext, + java.util.function.Consumer eventSink) { + throw new UnsupportedOperationException("subscribeToTask not supported by this runtime"); + } + /** Release any cached resources (e.g. HTTP clients). */ void close(); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java new file mode 100644 index 00000000..2b0124bc --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java @@ -0,0 +1,342 @@ +/* + * 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 net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Engine-facing facade over the A2A-T SDK's negotiation content layer. + * + *

Bridges the engine to the SDK's full content-generation and validation surface: propose / + * accept / reject / abort message rendering from typed data or free text, the validate-and-fill + * pipeline, and the template queries. Callers get compile-time {@link TemplateUri} constants from + * {@link StandardTemplates} instead of hand-written URI strings. + * + *

All methods throw {@link net.openan.a2at.sdk.core.exception.A2ATError} subtypes on failure; + * callers are expected to catch and degrade (see {@code buildFallbackMeta} in {@code + * DefaultWorkflowEngineClient}). + * + *

Instances are cheap views over the shared {@link A2ATClient}; create one per transport or + * reuse freely. + */ +public final class A2ATContentFacade { + + private final A2ATClient client; + + /** @param client the shared SDK client; must not be null */ + public A2ATContentFacade(A2ATClient client) { + this.client = Objects.requireNonNull(client, "client"); + } + + /** The underlying SDK client. */ + public A2ATClient client() { + return client; + } + + // ------------------------------------------------------------------ + // Message generation: propose phase (from typed data, no LLM) + // ------------------------------------------------------------------ + + /** + * Renders a propose-phase negotiation message from typed data. Deterministic, no LLM call. + * + * @param data typed propose input (context + content matching the template's type) + * @param templateUri propose template, e.g. {@link StandardTemplates#INFORMATION_NEGOTIATION_PROPOSE} + * @return rendered message with template URI and negotiation extension URI + */ + public MetadataContent generateProposeFromData(NegotiationProposeData data, TemplateUri templateUri) { + return client.generateNegotiationProposePromptFromData(data, templateUri); + } + + /** + * Renders an accept-phase negotiation message from typed data. Deterministic, no LLM call. + * + * @param data typed ending input whose content conclusion must be {@code ACCEPT} + * @param templateUri accept-reject template, e.g. {@link + * StandardTemplates#INFORMATION_NEGOTIATION_ACCEPT_REJECT} + * @return rendered message with template URI and negotiation extension URI + */ + public MetadataContent generateAcceptFromData(NegotiationEndingData data, TemplateUri templateUri) { + return client.generateNegotiationAcceptPromptFromData(data, templateUri); + } + + /** + * Renders a reject-phase negotiation message from typed data. Deterministic, no LLM call. + * + * @param data typed ending input whose content conclusion must be {@code REJECT} + * @param templateUri accept-reject template, e.g. {@link + * StandardTemplates#INFORMATION_NEGOTIATION_ACCEPT_REJECT} + * @return rendered message with template URI and negotiation extension URI + */ + public MetadataContent generateRejectFromData(NegotiationEndingData data, TemplateUri templateUri) { + return client.generateNegotiationRejectPromptFromData(data, templateUri); + } + + /** + * Renders an abort negotiation message from typed data. Deterministic, no LLM call. + * + * @param data typed abort input carrying the context and termination reason + * @return rendered abort message ({@link StandardTemplates#NEGOTIATION_ABORT} is implied) + */ + public MetadataContent generateAbortFromData(NegotiationAbortData data) { + return client.generateNegotiationAbortPromptFromData(data, StandardTemplates.NEGOTIATION_ABORT); + } + + // ------------------------------------------------------------------ + // Message generation: from free text (one LLM extraction step) + // ------------------------------------------------------------------ + + /** + * Renders a propose-phase negotiation message from free text. Runs one LLM content-extraction + * step constrained by the template URI, then renders deterministically. + * + * @param text free-text description of the message content + * @param context negotiation session context (id/round/maxRounds) + * @param templateUri propose template + * @return rendered message with template URI and negotiation extension URI + */ + public MetadataContent generateProposeFromText( + String text, + NegotiationContext context, + TemplateUri templateUri) { + return client.generateNegotiationProposePromptFromText(text, context, templateUri); + } + + /** + * Renders an accept-phase negotiation message from free text; extracted conclusion must be + * {@code Accept}. + * + * @param text free-text description of the accept content + * @param context negotiation session context + * @param templateUri accept-reject template + * @return rendered message + */ + public MetadataContent generateAcceptFromText( + String text, + NegotiationContext context, + TemplateUri templateUri) { + return client.generateNegotiationAcceptPromptFromText(text, context, templateUri); + } + + /** + * Renders a reject-phase negotiation message from free text; extracted conclusion must be + * {@code Reject}. + * + * @param text free-text description of the reject reason + * @param context negotiation session context + * @param templateUri accept-reject template + * @return rendered message + */ + public MetadataContent generateRejectFromText( + String text, + NegotiationContext context, + TemplateUri templateUri) { + return client.generateNegotiationRejectPromptFromText(text, context, templateUri); + } + + /** + * Renders an abort negotiation message from free text stating the termination reason. + * + * @param text free-text termination reason + * @param context negotiation session context + * @return rendered abort message + */ + public MetadataContent generateAbortFromText( + String text, NegotiationContext context) { + return client.generateNegotiationAbortPromptFromText(text, context, StandardTemplates.NEGOTIATION_ABORT); + } + + // ------------------------------------------------------------------ + // Runtime state machine + // ------------------------------------------------------------------ + + /** + * Starts a negotiation payload in the SDK's client role. + * + * @param type negotiation type (INFORMATION / TARGET / FEASIBILITY) + * @param contentText rendered negotiation message text + * @param facts structured facts attached to the payload + * @return transport payload for the initial turn (carries the negotiation context) + */ + public Map startNegotiation( + net.openan.a2at.sdk.negotiation.types.model.NegotiationType type, + String contentText, + Map facts) { + return client.startNegotiation(type, contentText, facts); + } + + /** + * Processes a received negotiation message using its transport context payload. + * + * @param message received negotiation message text + * @param context transport context payload associated with the message + * @return normalized payload ({@code needResponse}, {@code message}, {@code context}, ...) + */ + public Map receiveNegotiation(String message, Map context) { + return client.receiveNegotiation(message, context); + } + + /** + * Continues an existing negotiation with a locally stored context snapshot. + * + * @param context current negotiation context (types.model variant) + * @param status next status to emit (IN_PROGRESS / AGREED / REJECTED) + * @param contentText continuation message content + * @return transport payload for the next turn + */ + public Map continueNegotiation( + net.openan.a2at.sdk.negotiation.types.model.NegotiationContext context, + net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus status, + String contentText) { + return client.continueNegotiation(context, status, contentText); + } + + // ------------------------------------------------------------------ + // Validate and fill (rule gate + LLM semantic validation + param merge) + // ------------------------------------------------------------------ + + /** + * Validates a propose-phase negotiation message and extracts parameters. + * + * @param prompt rendered negotiation message text + * @param context negotiation context carried alongside the message; null is reported as + * not-a-negotiation-message + * @param schema caller-provided parameter JSON schema + * @param templateUri propose template declaring the expected type and phase + * @return filled parameter data (context params + extracted params) + */ + public FilledParamData validatePropose( + String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) { + return client.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates an accept-phase negotiation message and extracts parameters. + * + * @param prompt rendered negotiation message text + * @param context negotiation context carried alongside the message; null is reported as + * not-a-negotiation-message + * @param schema caller-provided parameter JSON schema + * @param templateUri accept-reject template + * @return filled parameter data + */ + public FilledParamData validateAccept( + String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) { + return client.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates a reject-phase negotiation message and extracts parameters. + * + * @param prompt rendered negotiation message text + * @param context negotiation context carried alongside the message; null is reported as + * not-a-negotiation-message + * @param schema caller-provided parameter JSON schema + * @param templateUri accept-reject template + * @return filled parameter data + */ + public FilledParamData validateReject( + String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) { + return client.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates an abort negotiation message and extracts parameters. + * + * @param prompt rendered abort message text + * @param context negotiation context carried alongside the message + * @param schema caller-provided parameter JSON schema + * @return filled parameter data + */ + public FilledParamData validateAbort( + String prompt, NegotiationContext context, Map schema) { + return client.validateAbortPromptAndDataFilling( + prompt, context, schema, StandardTemplates.NEGOTIATION_ABORT); + } + + // ------------------------------------------------------------------ + // Template queries + // ------------------------------------------------------------------ + + /** + * Lists every template of the configured language across all A2A-T extensions. + * + * @return loadable templates sorted by template URI; empty when none can be loaded + */ + public List getPrompts() { + return client.getPrompts(); + } + + /** + * Loads one template by URI regardless of extension. + * + * @param templateUri template URI such as {@link StandardTemplates#PRIVATE_LINE_COMPLAINT} + * @return the addressed template, or empty when it does not exist for the language + */ + public Optional getPrompt(TemplateUri templateUri) { + return client.getPrompt(templateUri); + } + + /** + * Lists every negotiation template of the configured language. + * + * @return loadable negotiation templates in fixed type and phase order + */ + public List getNegotiationPrompts() { + return client.getNegotiationPrompts(); + } + + /** + * Loads one negotiation template by URI. + * + * @param templateUri template URI such as {@link + * StandardTemplates#INFORMATION_NEGOTIATION_PROPOSE} + * @return the addressed template, or empty when it does not exist + */ + public Optional getNegotiationPrompt(TemplateUri templateUri) { + return client.getNegotiationPrompt(templateUri); + } + + /** + * Builds the metadata map for a generated message: the extension URI mapping to the rendered + * text plus the {@code templateUri} marker key. + * + * @param content generated message content + * @return metadata map ready to attach to an A2A message + */ + public static Map toMetadata(MetadataContent content) { + return new LinkedHashMap<>(content.buildMetadataContent()); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATExtension.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATExtension.java index 6e4b7649..ae450297 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATExtension.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATExtension.java @@ -19,6 +19,8 @@ package dev.openan.workflow.engine.client; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; + /** * A2A-T extension types supported by the workflow execution engine. * @@ -43,8 +45,7 @@ public enum A2ATExtension { TASK_T("https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"), /** Negotiation text exchange. Handled automatically via auto-loop. */ - NEGOTIATION_T( - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T"), + NEGOTIATION_T(ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI), /** Authorization whitelist. Pre-positioned before workflow starts. */ AUTHORIZATION_T( @@ -54,6 +55,24 @@ public enum A2ATExtension { NOTIFICATION_T( "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"); + /** + * Internal metadata key under which {@code NegotiationTHandler} stores the SDK receive + * payload (needResponse/message/context). Engine-internal, not an A2A-T protocol key. + */ + public static final String NEGOTIATION_CONTEXT_META_KEY = "negotiation_context"; + + /** + * Internal metadata key under which {@code NegotiationTHandler} stores the extracted + * negotiation message text. Engine-internal, not an A2A-T protocol key. + */ + public static final String NEGOTIATION_MESSAGE_META_KEY = "negotiation_message"; + + /** + * Internal metadata key under which {@code NegotiationTHandler} stores the parameters + * extracted by the validate-and-fill pipeline. Engine-internal, not an A2A-T protocol key. + */ + public static final String NEGOTIATION_PARAMS_META_KEY = "negotiation_params"; + private final String uri; A2ATExtension(String uri) { diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java index 2ee2f283..e7aff1b3 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 @@ -80,6 +80,7 @@ public class A2ATransport implements AutoCloseable { private final AuthProvider authProvider; private final A2ATClient a2atClient; private final String contextId; + private volatile A2ATContentFacade contentFacade; private final ClientCallContextFactory clientCallContextFactory; private final ExecutorService asyncExecutor; private final long notificationAckTimeoutSeconds; @@ -172,6 +173,9 @@ else if (event instanceof TaskUpdateEvent tue) { extractTextFromTask(tue.getTask(), text); if (tue.getUpdateEvent() instanceof TaskArtifactUpdateEvent ae) extractTextFromArtifact(ae.artifact(), text); + if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue + && sue.status().message() != null) + extractTextFromMessage(sue.status().message(), text); } else if (event instanceof MessageEvent me) extractTextFromMessage(me.getMessage(), text); } @@ -208,20 +212,29 @@ else if (event instanceof TaskUpdateEvent tue) { return state; } - public static Map extractResponseMetadata(Iterable events) { - Map metadata = new HashMap<>(); - for (ClientEvent event : events) { - if (event instanceof TaskEvent te) { - mergeTaskMetadata(te.getTask(), metadata); - } else if (event instanceof TaskUpdateEvent tue) { - mergeTaskMetadata(tue.getTask(), metadata); - if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { - Map em = sue.metadata(); - if (em != null && !em.isEmpty()) metadata.putAll(em); - } - } - } - return metadata; + public static Map extractResponseMetadata(Iterable events) { + Map metadata = new HashMap<>(); + for (ClientEvent event : events) { + if (event instanceof TaskEvent te) { + mergeTaskMetadata(te.getTask(), metadata); + } else if (event instanceof TaskUpdateEvent tue) { + mergeTaskMetadata(tue.getTask(), metadata); + if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { + Map em = sue.metadata(); + if (em != null && !em.isEmpty()) metadata.putAll(em); + // A status can carry its own Message (e.g. a Negotiation-T propose riding on + // an INPUT_REQUIRED status); merge that message's metadata too. + if (sue.status().message() != null) { + Map sm = sue.status().message().metadata(); + if (sm != null && !sm.isEmpty()) metadata.putAll(sm); + } + } + } else if (event instanceof MessageEvent me) { + Map mm = me.getMessage().metadata(); + if (mm != null && !mm.isEmpty()) metadata.putAll(mm); + } + } + return metadata; } // ------------------------------------------------------------------ @@ -294,6 +307,29 @@ public A2ATClient getA2atClient() { return a2atClient; } + /** + * Content-layer facade over the SDK client: negotiation message generation (propose / accept / + * reject / abort, from data or text), validate-and-fill, and template queries. Returns null + * when no A2A-T env path was configured. + */ + public A2ATContentFacade getContentFacade() { + A2ATClient local = a2atClient; + if (local == null) { + return null; + } + A2ATContentFacade facade = contentFacade; + if (facade == null) { + synchronized (this) { + facade = contentFacade; + if (facade == null) { + facade = new A2ATContentFacade(local); + contentFacade = facade; + } + } + } + return facade; + } + public String getContextId() { return contextId; } @@ -552,6 +588,68 @@ private ClientCallContext buildClientCallContext( // Lifecycle // ------------------------------------------------------------------ + public CompletableFuture getTask( + AgentCard agentCard, String agentName, String taskId) { + if (closed.get()) { + return CompletableFuture.failedFuture(new IllegalStateException("A2A transport is closed")); + } + return CompletableFuture.supplyAsync(() -> { + 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(task.status().state().name()) + .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")); + } + return CompletableFuture.supplyAsync(() -> { + try { + ClientCallContext ctx = buildClientCallContext(agentCard, agentName, Map.of()); + Task task = a2aClientRuntime.cancelTask(agentCard, taskId, ctx); + StringBuilder text = new StringBuilder(); + extractTextFromTask(task, text); + return SendMessageResult.builder() + .text(text.toString()) + .task(task) + .taskState(task.status().state().name()) + .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 a2aClientRuntime.subscribeToTask(agentCard, taskId, ctx, + event -> { + if (eventSink != null) { + try { eventSink.accept(event); } catch (Exception ignored) {} + } + }); + } + @Override public void close() { if (!closed.compareAndSet(false, true)) return; diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java index 513a83bb..d44e9b45 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java @@ -22,6 +22,11 @@ import io.grpc.ManagedChannelBuilder; import org.a2aproject.sdk.client.Client; +import dev.openan.workflow.engine.model.SendMessageResult; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.CancelTaskParams; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; import org.a2aproject.sdk.client.TaskEvent; @@ -465,6 +470,66 @@ private void awaitCompletion( } } + @Override + public Task getTask(AgentCard agentCard, String taskId, ClientCallContext callContext) { + if (closed.get()) throw new IllegalStateException("A2A client runtime is closed"); + Client client = getOrCreateClient(agentCard, extractAgentUrl(agentCard)); + try { + return client.getTask(new TaskQueryParams(taskId), callContext); + } catch (A2AClientException e) { + throw new RuntimeException("A2A getTask failed for " + agentCard.name() + ": " + e.getMessage(), e); + } + } + + @Override + public Task cancelTask(AgentCard agentCard, String taskId, ClientCallContext callContext) { + if (closed.get()) throw new IllegalStateException("A2A client runtime is closed"); + Client client = getOrCreateClient(agentCard, extractAgentUrl(agentCard)); + try { + return client.cancelTask(CancelTaskParams.builder().id(taskId).build(), callContext); + } catch (A2AClientException e) { + throw new RuntimeException("A2A cancelTask failed for " + agentCard.name() + ": " + e.getMessage(), e); + } + } + + @Override + public java.util.concurrent.CompletableFuture subscribeToTask( + AgentCard agentCard, + String taskId, + ClientCallContext callContext, + java.util.function.Consumer eventSink) { + if (closed.get()) { + return java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("A2A client runtime is closed")); + } + java.util.concurrent.CompletableFuture future = new java.util.concurrent.CompletableFuture<>(); + Client client = getOrCreateClient(agentCard, extractAgentUrl(agentCard)); + List events = Collections.synchronizedList(new ArrayList<>()); + try { + client.subscribeToTask( + new TaskIdParams(taskId), + List.of((event, card) -> { + events.add(event); + if (eventSink != null) { + try { eventSink.accept(event); } catch (Exception ignored) {} + } + if (!future.isDone()) { + String state = A2ATransport.extractResponseTaskState(events); + String text = A2ATransport.extractResponseText(events); + future.complete(SendMessageResult.builder() + .text(text) + .taskState(state) + .build()); + } + }), + null, + callContext); + } catch (A2AClientException e) { + return java.util.concurrent.CompletableFuture.failedFuture( + new RuntimeException("A2A subscribeToTask failed for " + agentCard.name() + ": " + e.getMessage(), e)); + } + return future; + } + @Override public void close() { if (!closed.compareAndSet(false, true)) return; diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultExtensionSender.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultExtensionSender.java index 1212f5ae..87fa5810 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,8 +22,9 @@ import dev.openan.workflow.engine.model.SendMessageResult; import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.model.PromptGenerationFailure; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import org.a2aproject.sdk.spec.AgentCard; import org.slf4j.Logger; @@ -70,10 +71,15 @@ public CompletableFuture sendExtensionMessage( return CompletableFuture.supplyAsync( () -> generateExtensionPrompt(extension, naturalLanguageInput)) .thenCompose( - metadataValue -> { - String value = metadataValue; - if (value == null || value.isEmpty()) { + mc -> { + String value; + Map metadata = new HashMap<>(); + if (mc != null && mc.promptText() != null && !mc.promptText().isEmpty()) { + value = mc.promptText(); + metadata.putAll(mc.buildMetadataContent()); + } else { value = naturalLanguageInput; + metadata.put(extension.uri(), value); log.info( "[ExtensionSender] SDK prompt generation unavailable for {} ({}), using input as metadata", agentName, @@ -84,7 +90,6 @@ public CompletableFuture sendExtensionMessage( agentName, extension.displayName(), value.length()); - Map metadata = Map.of(extension.uri(), value); if (extension == A2ATExtension.NOTIFICATION_T) { return transport.sendNotificationStream( agentCard, @@ -135,13 +140,16 @@ public CompletableFuture sendNotification( generateExtensionPrompt( A2ATExtension.NOTIFICATION_T, naturalLanguageInput)) .thenCompose( - metadataValue -> { - String value = metadataValue; - if (value == null || value.isEmpty()) { + mc -> { + String value; + Map metadata = new HashMap<>(); + if (mc != null && mc.promptText() != null && !mc.promptText().isEmpty()) { + value = mc.promptText(); + metadata.putAll(mc.buildMetadataContent()); + } else { value = naturalLanguageInput; + metadata.put(A2ATExtension.NOTIFICATION_T.uri(), value); } - Map metadata = - Map.of(A2ATExtension.NOTIFICATION_T.uri(), value); Consumer eventSink = eventCallback != null ? event -> @@ -206,63 +214,37 @@ private void forwardNotificationEvent( // Extension prompt generation dispatch // ------------------------------------------------------------------ - String generateExtensionPrompt(A2ATExtension extension, String naturalLanguageInput) { - if (extension == A2ATExtension.TASK_T) { - return generateTaskPromptText(naturalLanguageInput); - } - if (extension == A2ATExtension.NEGOTIATION_T) { - return generateNegotiationPrompt(naturalLanguageInput); - } - if (extension == A2ATExtension.AUTHORIZATION_T) { - return generateAuthorizationPrompt(naturalLanguageInput); - } - if (extension == A2ATExtension.NOTIFICATION_T) { - return generateNotificationPrompt(naturalLanguageInput); - } - return null; - } - - private String generateTaskPromptText(String naturalLanguageInput) { + /** + * Generates a structured extension prompt via the A2A-T SDK. + * + *

The template is addressed by a {@link TemplateUri}; the message language comes from the + * SDK's own {@code A2AT_LANGUAGE} configuration, no longer from a caller-supplied language + * string. + * + * @return {@link MetadataContent} with prompt text and template URI, or null if SDK unavailable + */ + MetadataContent generateExtensionPrompt(A2ATExtension extension, String naturalLanguageInput) { A2ATClient a2atClient = transport.getA2atClient(); if (a2atClient == null) { return null; } try { - PromptGenerationResult result = a2atClient.generateTaskPrompt(naturalLanguageInput); - if (result.success()) { - return result.promptText(); - } - PromptGenerationFailure f = result.failure(); - log.warn( - "[ExtensionSender] SDK Task-T prompt generation failed: code={}, stage={}, message={}", - f != null ? f.code() : "unknown", - f != null ? f.stage() : "unknown", - f != null ? f.message() : "unknown"); + return switch (extension) { + case TASK_T -> a2atClient.generateTaskPromptFromText( + naturalLanguageInput, StandardTemplates.PRIVATE_LINE_COMPLAINT); + case AUTHORIZATION_T -> a2atClient.generateAuthPromptFromText( + naturalLanguageInput, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + case NOTIFICATION_T -> a2atClient.generateNotificationPromptFromText( + naturalLanguageInput, StandardTemplates.SUBSCRIBE_INCIDENT); + case NEGOTIATION_T -> null; // Negotiation uses a dedicated flow in NegotiationTHandler + }; } catch (Exception e) { - log.warn("[ExtensionSender] SDK Task-T prompt generation error: {}", e.getMessage()); - } - return null; - } - // TODO - private String generateNegotiationPrompt(String naturalLanguageInput) { - if (transport.getA2atClient() == null) { - return null; - } - return null; - } - // TODO - private String generateAuthorizationPrompt(String naturalLanguageInput) { - if (transport.getA2atClient() == null) { - return null; - } - return null; - } - // TODO - private String generateNotificationPrompt(String naturalLanguageInput) { - if (transport.getA2atClient() == null) { + log.warn( + "[ExtensionSender] SDK {} prompt generation error: {}", + extension.displayName(), + e.getMessage()); return null; } - return null; } @Override diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java index f410b2b9..34558e7f 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 @@ -25,6 +25,10 @@ import dev.openan.workflow.engine.model.SendMessageResult; import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.runtime.helper.NegotiationPayloadMapper; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; @@ -41,6 +45,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.CompletableFuture; /** @@ -67,9 +72,10 @@ public class DefaultWorkflowEngineClient implements WorkflowEngineClient, AutoCl public DefaultWorkflowEngineClient( A2ATransport transport, int maxNegotiationRounds, - List customHandlers) { + List customHandlers, + Map negotiationParamSchema) { this.transport = transport; - this.extensionRegistry = new ExtensionRegistry(); + this.extensionRegistry = new ExtensionRegistry(negotiationParamSchema); if (customHandlers != null) { for (ExtensionHandler h : customHandlers) { extensionRegistry.register(h); @@ -82,16 +88,37 @@ public DefaultWorkflowEngineClient( maxNegotiationRounds); } + public DefaultWorkflowEngineClient( + A2ATransport transport, + int maxNegotiationRounds, + List customHandlers) { + this(transport, maxNegotiationRounds, customHandlers, null); + } + public DefaultWorkflowEngineClient(A2ATransport transport) { - this(transport, 3, null); + this(transport, 3, null, null); } // ------------------------------------------------------------------ // Wiring // ------------------------------------------------------------------ + /** + * Whether the agent's reply opens a negotiation: metadata carries the Negotiation-T key AND + * the task is not already completed with a business result. A completed reply carrying both + * keys (e.g. a follow-up round whose artifact includes the negotiation Accept) ends the + * loop — the business already ran. + */ private static boolean isNegotiationNeeded(SendMessageResult result) { - return result.getTaskState() != null && result.getTaskState().contains("INPUT_REQUIRED"); + Map meta = result.getMetadata(); + if (meta == null || !meta.containsKey(A2ATExtension.NEGOTIATION_T.uri())) { + return false; + } + boolean completed = + result.getTaskState() != null + && result.getTaskState().contains("TASK_STATE_COMPLETED"); + boolean hasBusinessResult = meta.containsKey(A2ATExtension.TASK_T.uri()); + return !(completed && hasBusinessResult); } @Override @@ -104,6 +131,28 @@ public void setEventCallback(EventCallback callback) { this.eventCallback = callback != null ? callback : new EventCallback(); } + // ------------------------------------------------------------------ + // A2A-T template queries + // ------------------------------------------------------------------ + + @Override + public List getPrompts() { + A2ATContentFacade content = transport.getContentFacade(); + return content != null ? content.getPrompts() : List.of(); + } + + @Override + public List getNegotiationPrompts() { + A2ATContentFacade content = transport.getContentFacade(); + return content != null ? content.getNegotiationPrompts() : List.of(); + } + + @Override + public Optional getPrompt(TemplateUri templateUri) { + A2ATContentFacade content = transport.getContentFacade(); + return content != null ? content.getPrompt(templateUri) : Optional.empty(); + } + // ------------------------------------------------------------------ // Workflow send path // ------------------------------------------------------------------ @@ -168,132 +217,215 @@ private CompletableFuture autoNegotiate( String contextId, SendMessageResult result, int round) { - if (!isNegotiationNeeded(result) || round > maxNegotiationRounds) { - emit( - EventType.AGENT_RESPONSE, - Map.of("agent", agentName, "response", result.getText(), "metadata", result.getMetadata() != null ? result.getMetadata() : Map.of())); + if (!isNegotiationNeeded(result)) { + emitAgentResponse(agentName, result); return CompletableFuture.completedFuture(result); } + if (round > maxNegotiationRounds) { + return abortNegotiation(agentCard, agentName, contextId, result, round); + } Map negMeta = result.getMetadata() != null ? result.getMetadata() : new HashMap<>(); - String negText = negMeta.getOrDefault("negotiation_message", - negMeta.getOrDefault("negotiationConcern", "")).toString(); + Object negVal = negMeta.get(A2ATExtension.NEGOTIATION_T.uri()); + String negText = negVal instanceof String s ? s : ""; log.info("[Negotiation] Round {} for '{}': {}", round, agentName, negText); emit( EventType.NEGOTIATION_REQUEST, Map.of("agent", agentName, "round", round, "concern", negText)); - CompletableFuture clarFuture; - if (controlPoint != null) { - clarFuture = controlPoint.onNegotiation(agentName, negText, negMeta); - } else { - clarFuture = - CompletableFuture.completedFuture( - "Please proceed with the original task using available information."); - } + CompletableFuture clarFuture = + controlPoint != null + ? controlPoint.onNegotiation(agentName, negText, negMeta) + : CompletableFuture.completedFuture( + "Please proceed with the original task using available information."); return clarFuture.thenCompose( - clarification -> { - if (clarification == null || clarification.isEmpty()) { - emit( - EventType.NEGOTIATION_FAILED, - Map.of( - "agent", + clarification -> + clarification == null || clarification.isEmpty() + ? failWithoutClarification(agentName, result, round) + : continueNegotiationRound( + agentCard, agentName, - "round", - round, - "reason", - "no clarification")); - emit( - EventType.AGENT_RESPONSE, - Map.of("agent", agentName, "response", result.getText(), "metadata", result.getMetadata() != null ? result.getMetadata() : Map.of())); - return CompletableFuture.completedFuture(result); - } - log.info( - "[Negotiation] Clarification for '{}' round {}: {}", - agentName, - round, - clarification); - emit( - EventType.NEGOTIATION_RESOLVED, - Map.of( - "agent", - agentName, - "round", - round, - "clarification", - clarification)); - String followUp = - "[NEGOTIATION_RESOLUTION]\nThe engine has reviewed your negotiation request and provides the following clarification:\n\n" - + clarification - + "\n\n---\nOriginal Task:\n" - + originalMessage - + "\n\nPlease re-execute the task based on the clarification above."; - String taskId = result.getTask() != null ? result.getTask().id() : null; - return buildNegotiationFollowUpMeta(agentName, negMeta, clarification) - .thenCompose( - followUpMeta -> - runBeforeSendHandlers(agentCard, followUp, followUpMeta) - .thenCompose( - meta -> { - String ctx = - contextId != null - ? contextId - : transport.getContextId(); - return transport - .send( + originalMessage, + contextId, + result, + negMeta, + clarification, + round)); + } + + /** Emits the terminal AGENT_RESPONSE event for an agent's reply. */ + private void emitAgentResponse(String agentName, SendMessageResult result) { + Map data = new HashMap<>(); + data.put("agent", agentName); + data.put("response", result.getText() != null ? result.getText() : ""); + data.put("metadata", result.getMetadata() != null ? result.getMetadata() : Map.of()); + emit(EventType.AGENT_RESPONSE, data); + } + + /** + * Terminal path when the control point produces no clarification: negotiation failed, the + * agent's reply stands as the final response. + */ + private CompletableFuture failWithoutClarification( + String agentName, SendMessageResult result, int round) { + emit( + EventType.NEGOTIATION_FAILED, + Map.of("agent", agentName, "round", round, "reason", "no clarification")); + emitAgentResponse(agentName, result); + return CompletableFuture.completedFuture(result); + } + + /** + * Round-budget-exhaustion path: terminates via the SDK abort flow. The abort message is sent + * to the agent (best effort \u2014 send failures are logged, not propagated, since the negotiation + * is over either way) and the last agent reply stands as the final response. The loop does + * NOT continue on the abort reply. + */ + private CompletableFuture abortNegotiation( + AgentCard agentCard, + String agentName, + String contextId, + SendMessageResult result, + int round) { + log.warn( + "[Negotiation] Round budget exhausted for '{}' ({} rounds); aborting", + agentName, + maxNegotiationRounds); + emit( + EventType.NEGOTIATION_FAILED, + Map.of("agent", agentName, "round", round, "reason", "round budget exhausted")); + return buildNegotiationFollowUpMeta( + agentName, + result.getMetadata() != null ? result.getMetadata() : new HashMap<>(), + "abort: negotiation round budget exhausted (" + maxNegotiationRounds + ")") + .thenCompose( + abortMeta -> { + Object rendered = abortMeta.get(A2ATExtension.NEGOTIATION_T.uri()); + String followUp = + rendered instanceof String s && !s.isEmpty() + ? s + : "\u534f\u5546\u5df2\u7ec8\u6b62"; + return runBeforeSendHandlers(agentCard, followUp, abortMeta) + .thenCompose( + meta -> + transport.send( + agentCard, + agentName, + followUp, + contextId, + meta, + event -> + forwardIntermediateEvent( + event, agentName))); + }) + .handle( + (ignored, error) -> { + if (error != null) { + log.warn( + "[Negotiation] Abort message delivery to '{}' failed: {}", + agentName, + error.getMessage()); + } + emitAgentResponse(agentName, result); + return result; + }); + } + + /** + * One negotiation round with a clarification: renders the follow-up metadata via the SDK + * content layer, sends it, and recurses into the next round. + */ + private CompletableFuture continueNegotiationRound( + AgentCard agentCard, + String agentName, + String originalMessage, + String contextId, + SendMessageResult result, + Map negMeta, + String clarification, + int round) { + log.info( + "[Negotiation] Clarification for '{}' round {}: {}", + agentName, + round, + clarification); + emit( + EventType.NEGOTIATION_RESOLVED, + Map.of("agent", agentName, "round", round, "clarification", clarification)); + String taskId = result.getTask() != null ? result.getTask().id() : null; + return buildNegotiationFollowUpMeta(agentName, negMeta, clarification) + .thenCompose( + followUpMeta -> { + // The message body is the rendered negotiation text itself: the SDK + // template output when generation succeeded, the raw clarification on + // fallback. The same text also travels in metadata under the + // Negotiation-T key; agents read either. + Object rendered = + followUpMeta.get(A2ATExtension.NEGOTIATION_T.uri()); + String followUp = + rendered instanceof String s && !s.isEmpty() + ? s + : clarification; + return runBeforeSendHandlers(agentCard, followUp, followUpMeta) + .thenCompose( + meta -> { + String ctx = + contextId != null + ? contextId + : transport.getContextId(); + return transport + .send( + agentCard, + agentName, + followUp, + ctx, + taskId, + meta, + event -> + forwardIntermediateEvent( + event, agentName)) + .thenCompose( + r -> + runAfterReceiveHandlers( + agentCard, r)) + .thenCompose( + r -> + autoNegotiate( agentCard, agentName, - followUp, - ctx, - taskId, - meta, - event -> - forwardIntermediateEvent( - event, agentName)) - .thenCompose( - r -> runAfterReceiveHandlers(agentCard, r)) - .thenCompose( - r -> - autoNegotiate( - agentCard, - agentName, - originalMessage, - contextId, - r, - round + 1)); - })); - }); + originalMessage, + contextId, + r, + round + 1)); + }); + }); } + /** + * Builds the follow-up metadata for one negotiation round using the SDK content layer. + * + *

The clarification text is classified: {@code reject:...} / {@code abort:...} prefixes + * select the Reject or Abort terminal templates, anything else renders an Accept message via + * {@code generateNegotiationAcceptPromptFromText} (one LLM extraction step). When the + * negotiation context is available, the SDK state machine is also advanced so the follow-up + * payload carries the next-round context. All failures degrade to the raw-clarification + * fallback. + */ private CompletableFuture> buildNegotiationFollowUpMeta( String agentName, Map negMeta, String clarification) { - A2ATClient a2atClient = transport.getA2atClient(); - if (a2atClient == null) { + A2ATContentFacade content = transport.getContentFacade(); + if (content == null) { return CompletableFuture.completedFuture(buildFallbackMeta(clarification)); } return CompletableFuture.supplyAsync( () -> { try { - Object ctxObj = negMeta.get("negotiation_context"); - if (ctxObj instanceof Map rr) { - Object innerCtx = rr.get("context"); - if (innerCtx instanceof Map contextMap) { - @SuppressWarnings("unchecked") - NegotiationContext context = - NegotiationPayloadMapper.contextFromMap( - (Map) contextMap); - Map payload = - a2atClient.continueNegotiation( - context, NegotiationStatus.AGREED, clarification); - log.info( - "[Negotiation] SDK continueNegotiation payload for '{}': round {} -> AGREED", - agentName, - context.round()); - return new HashMap<>(payload); - } + MetadataContent mc = renderFollowUpMessage(content, agentName, negMeta, clarification); + if (mc.promptText() != null && !mc.promptText().isEmpty()) { + return advanceStateMachine(content, agentName, negMeta, clarification, mc); } } catch (Exception e) { log.warn( - "[Negotiation] continueNegotiation failed for '{}': {}; using fallback", + "[Negotiation] SDK content generation failed for '{}': {}; using fallback", agentName, e.getMessage()); } @@ -301,11 +433,142 @@ private CompletableFuture> buildNegotiationFollowUpMeta( }); } + /** + * Renders the follow-up message matching the clarification's decision prefix: {@code + * reject:} / {@code abort:} select the terminal templates, anything else renders an Accept + * message. + */ + private MetadataContent renderFollowUpMessage( + A2ATContentFacade content, + String agentName, + Map negMeta, + String clarification) { + net.openan.a2at.sdk.core.model.NegotiationContext contentCtx = + extractContentContext(negMeta); + String decision = clarification.strip().toLowerCase(java.util.Locale.ROOT); + if (decision.startsWith("reject:")) { + log.info("[Negotiation] SDK generateNegotiationRejectPrompt for '{}'", agentName); + return content.generateRejectFromText( + clarification.substring("reject:".length()).strip(), + requireContext(contentCtx, agentName), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + } + if (decision.startsWith("abort:")) { + log.info("[Negotiation] SDK generateNegotiationAbortPrompt for '{}'", agentName); + return content.generateAbortFromText( + clarification.substring("abort:".length()).strip(), + requireContext(contentCtx, agentName)); + } + MetadataContent mc = + content.generateAcceptFromText( + clarification, + requireContext(contentCtx, agentName), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + log.info( + "[Negotiation] SDK generateNegotiationAcceptPrompt: templateUri={}", + mc.templateUri()); + return mc; + } + + /** + * Builds the follow-up metadata from the rendered message and, when the state-machine context + * is available, advances the SDK state machine so the follow-up carries the next-round (or + * terminal) context payload alongside the message. + */ + private Map advanceStateMachine( + A2ATContentFacade content, + String agentName, + Map negMeta, + String clarification, + MetadataContent mc) { + Map meta = A2ATContentFacade.toMetadata(mc); + NegotiationContext typesCtx = extractTypesContext(negMeta); + if (typesCtx == null) { + return meta; + } + String decision = clarification.strip().toLowerCase(java.util.Locale.ROOT); + try { + Map payload = + content.continueNegotiation( + typesCtx, + decision.startsWith("reject:") ? NegotiationStatus.REJECTED : NegotiationStatus.AGREED, + mc.promptText()); + if (payload != null && !payload.isEmpty()) { + meta.putAll(payload); + } + } catch (Exception ce) { + log.debug( + "[Negotiation] continueNegotiation unavailable for '{}': {}", + agentName, + ce.getMessage()); + } + return meta; + } + + /** Extracts the SDK state-machine context from the negotiation receive payload, or null. */ + private static NegotiationContext extractTypesContext(Map negMeta) { + Object ctxObj = negMeta.get(A2ATExtension.NEGOTIATION_CONTEXT_META_KEY); + if (!(ctxObj instanceof Map rr)) { + return null; + } + Object innerCtx = rr.get("context"); + if (!(innerCtx instanceof Map contextMap)) { + return null; + } + try { + @SuppressWarnings("unchecked") + NegotiationContext typesCtx = + NegotiationPayloadMapper.contextFromMap((Map) contextMap); + return typesCtx; + } catch (Exception e) { + return null; + } + } + + /** + * Extracts the SDK-carried negotiation context (id/round/maxRounds) from the received + * metadata's {@code negotiationContext} key, or null when absent. Falls back to synthesizing + * one from the state-machine context payload when only that is present. + */ + private static net.openan.a2at.sdk.core.model.NegotiationContext extractContentContext( + Map negMeta) { + Object raw = + negMeta.get(net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + if (raw instanceof Map contextMap) { + Object id = contextMap.get("id"); + Object round = contextMap.get("round"); + Object maxRounds = contextMap.get("maxRounds"); + if (id instanceof String s && round instanceof Number r && maxRounds instanceof Number m) { + try { + return new net.openan.a2at.sdk.core.model.NegotiationContext( + s, r.intValue(), m.intValue()); + } catch (IllegalArgumentException ignored) { + // fall through to the state-machine fallback + } + } + } + NegotiationContext typesCtx = extractTypesContext(negMeta); + if (typesCtx == null) { + return null; + } + return new net.openan.a2at.sdk.core.model.NegotiationContext( + typesCtx.negotiationId(), + typesCtx.round(), + net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); + } + + private static net.openan.a2at.sdk.core.model.NegotiationContext requireContext( + net.openan.a2at.sdk.core.model.NegotiationContext context, String agentName) { + if (context == null) { + throw new IllegalStateException( + "No negotiation context from agent '" + agentName + "'; cannot render SDK message"); + } + return context; + } + private static Map buildFallbackMeta(String clarification) { Map meta = new HashMap<>(); - meta.put( - A2ATExtension.NEGOTIATION_T.uri(), - "## Data Return Confirmation\n" + clarification + "\n"); + meta.put(A2ATExtension.NEGOTIATION_T.uri(), clarification); return meta; } // ------------------------------------------------------------------ @@ -415,6 +678,36 @@ private void forwardIntermediateEvent(ClientEvent event, String agentName) { // Lifecycle // ------------------------------------------------------------------ + @Override + public CompletableFuture getTask(String agentName, String taskId) { + AgentCard agentCard = transport.getCard(agentName); + if (agentCard == null) { + return CompletableFuture.failedFuture(new RuntimeException("Agent not found: " + agentName)); + } + return transport.getTask(agentCard, agentName, taskId); + } + + @Override + public CompletableFuture cancelTask(String agentName, String taskId) { + AgentCard agentCard = transport.getCard(agentName); + if (agentCard == null) { + return CompletableFuture.failedFuture(new RuntimeException("Agent not found: " + agentName)); + } + return transport.cancelTask(agentCard, agentName, taskId); + } + + @Override + public CompletableFuture subscribeToTask( + String agentName, String taskId, + java.util.function.Consumer> eventCallback) { + 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)); + } + @Override public void close() { // Transport is owned by the caller; do not close it here. diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java index 5d8ccb0f..209fbca3 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java @@ -80,4 +80,17 @@ CompletableFuture afterReceive( A2ATClient a2atClient, ControlPoint controlPoint, EventCallback eventCallback); + + /** + * Parameter JSON Schema for the SDK validate-and-fill pipeline on received negotiation + * messages: the business parameters this handler wants extracted. Declares the shape in + * terms of the caller's domain, so the engine core never hardcodes business fields. + * + *

Default: an empty object schema (context params id/round/maxRounds only). + * + * @return parameter JSON schema map, never null + */ + default Map negotiationParamSchema() { + return Map.of("type", "object"); + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java index ace13511..5b554d16 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java @@ -49,6 +49,19 @@ public ExtensionRegistry() { register(new NegotiationTHandler()); } + /** + * Creates a registry whose built-in Negotiation-T handler validates received propose messages + * against the given business parameter schema. Use this instead of the no-arg constructor when + * the caller's domain declares concrete negotiation parameters. + * + * @param negotiationParamSchema parameter JSON schema for the validate-and-fill pipeline; null + * keeps the default empty schema + */ + public ExtensionRegistry(Map negotiationParamSchema) { + register(new TaskTHandler()); + register(new NegotiationTHandler(negotiationParamSchema)); + } + public void register(ExtensionHandler handler) { handlers.put(handler.extensionKeyword(), handler); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java index 8659d628..e64552d5 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java @@ -24,6 +24,12 @@ import dev.openan.workflow.engine.model.SendMessageResult; import net.openan.a2at.sdk.client.A2ATClient; +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 java.util.Locale; +import java.util.Map; import org.a2aproject.sdk.spec.AgentCard; import org.slf4j.Logger; @@ -44,6 +50,34 @@ class NegotiationTHandler implements ExtensionHandler { private static final Logger log = LoggerFactory.getLogger(NegotiationTHandler.class); + /** Empty object schema: context params (id/round/maxRounds) only. */ + private static final Map DEFAULT_SCHEMA = Map.of("type", "object"); + + private final Map paramSchema; + + /** + * Creates the handler with the default empty parameter schema (context params only). + */ + NegotiationTHandler() { + this(null); + } + + /** + * Creates the handler with a caller-provided business parameter schema used by the + * validate-and-fill pipeline on received propose messages. + * + * @param paramSchema parameter JSON schema declaring the business fields to extract; null + * falls back to {@link #DEFAULT_SCHEMA} + */ + NegotiationTHandler(Map paramSchema) { + this.paramSchema = paramSchema != null ? paramSchema : DEFAULT_SCHEMA; + } + + @Override + public Map negotiationParamSchema() { + return paramSchema; + } + @SuppressWarnings("unchecked") private static Map extractNegotiationContext(Map metadata) { if (metadata == null) { @@ -62,8 +96,9 @@ private static String extractNegotiationText(Map metadata) { return null; } for (var entry : metadata.entrySet()) { - if (entry.getKey().contains("NEGOTIATION-T") - && !entry.getKey().contains("DATA-NEGOTIATION-T") + String upperKey = entry.getKey().toUpperCase(Locale.ROOT); + if (upperKey.contains("NEGOTIATION-T") + && !upperKey.contains("DATA-NEGOTIATION-T") && entry.getValue() instanceof String) { return (String) entry.getValue(); } @@ -78,7 +113,18 @@ private static boolean supportsNegotiation(AgentCard agentCard) { } for (var ext : extensions) { String uri = ext.uri(); - if (uri.contains("NEGOTIATION-T")) { + if (uri.toUpperCase(Locale.ROOT).contains("NEGOTIATION-T")) { + return true; + } + } + return false; + } + + private static boolean hasNegotiationMetadata(Map metadata) { + if (metadata == null) return false; + for (String key : metadata.keySet()) { + String upper = key.toUpperCase(Locale.ROOT); + if (upper.contains("NEGOTIATION-T") && !upper.contains("DATA-NEGOTIATION-T")) { return true; } } @@ -89,6 +135,47 @@ private static String getAgentName(AgentCard agentCard) { return agentCard.name(); } + /** + * Parses the SDK-carried negotiation context ({@code negotiationContext} metadata key with + * id/round/maxRounds) from the received message metadata. Returns null when absent or + * malformed; the SDK validate APIs treat a null context as not-a-negotiation-message. + */ + private static net.openan.a2at.sdk.core.model.NegotiationContext parseNegotiationContext( + Map metadata) { + Object raw = metadata.get(net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + if (!(raw instanceof Map contextMap)) { + return null; + } + try { + Object id = contextMap.get("id"); + Object round = contextMap.get("round"); + Object maxRounds = contextMap.get("maxRounds"); + if (id instanceof String s && round instanceof Number r && maxRounds instanceof Number m) { + return new net.openan.a2at.sdk.core.model.NegotiationContext( + s, r.intValue(), m.intValue()); + } + } catch (IllegalArgumentException e) { + log.debug("[Negotiation-T] Malformed negotiationContext metadata: {}", e.getMessage()); + } + return null; + } + + /** + * Resolves the propose template URI matching the negotiation type declared in the received + * context payload. Falls back to information negotiation when the type is missing or unknown. + */ + private static TemplateUri proposeTemplateFor(Map contextMap) { + Object type = contextMap != null ? contextMap.get("negotiationType") : null; + if (type instanceof String s) { + return switch (s.replace('-', '_').toUpperCase(Locale.ROOT)) { + case "TARGET" -> StandardTemplates.TARGET_NEGOTIATION_PROPOSE; + case "FEASIBILITY" -> StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; + default -> StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + }; + } + return StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + } + @Override public String extensionKeyword() { return "Negotiation-T"; @@ -101,6 +188,9 @@ public CompletableFuture> beforeSend( Map metadata, A2ATClient a2atClient, ControlPoint controlPoint) { + // Accept prompt generation is handled by buildNegotiationFollowUpMeta + // in DefaultWorkflowEngineClient, which sets the Negotiation-T URI key + // before this method is called. No additional generation needed here. return CompletableFuture.completedFuture(metadata); } @@ -112,11 +202,16 @@ public CompletableFuture afterReceive( ControlPoint controlPoint, EventCallback eventCallback) { if (a2atClient == null - || result.getTaskState() == null - || !result.getTaskState().contains("INPUT_REQUIRED") || !supportsNegotiation(agentCard)) { return CompletableFuture.completedFuture(result); } + boolean inputRequired = + result.getTaskState() != null + && result.getTaskState().contains("INPUT_REQUIRED"); + boolean hasNegMeta = hasNegotiationMetadata(result.getMetadata()); + if (!inputRequired && !hasNegMeta) { + return CompletableFuture.completedFuture(result); + } return CompletableFuture.supplyAsync( () -> { Map metadata = @@ -135,19 +230,51 @@ public CompletableFuture afterReceive( Boolean needResponse = (Boolean) rr.get("needResponse"); if (Boolean.TRUE.equals(needResponse)) { String negMsg = (String) rr.getOrDefault("message", ""); - metadata.put("negotiation_message", negMsg); - metadata.put("negotiation_context", rr); + metadata.put(A2ATExtension.NEGOTIATION_MESSAGE_META_KEY, negMsg); + metadata.put(A2ATExtension.NEGOTIATION_CONTEXT_META_KEY, rr); log.info( "[Negotiation-T] Agent '{}' requested negotiation: {}", getAgentName(agentCard), negMsg); + // Validate the received negotiation message and extract params + // against the propose template matching the declared type. The + // SDK contract carries the context in the negotiationContext + // metadata key; without it the SDK treats the text as a + // non-negotiation message. + TemplateUri proposeUri = proposeTemplateFor(contextMap); + net.openan.a2at.sdk.core.model.NegotiationContext context = + parseNegotiationContext(metadata); + try { + FilledParamData paramData = a2atClient.validateProposePromptAndDataFilling( + result.getText(), context, negotiationParamSchema(), proposeUri); + if (paramData != null && paramData.data() != null + && !paramData.data().isEmpty()) { + metadata.put( + A2ATExtension.NEGOTIATION_PARAMS_META_KEY, + paramData.data()); + log.info( + "[Negotiation-T] Validated negotiation message" + + " for '{}' ({}), extracted {} params", + getAgentName(agentCard), + proposeUri.uri(), + paramData.data().keySet()); + } + } catch (Exception ve) { + log.warn( + "[Negotiation-T] validateProposePromptAndDataFilling" + + " failed for '{}' ({}): {}", + getAgentName(agentCard), + proposeUri.uri(), + ve.getMessage()); + } } } } catch (Exception e) { if (e.getMessage() != null && e.getMessage().contains("Unsupported negotiation type")) { log.debug( - "[Negotiation-T] SDK receiveNegotiation has no handler for '{}' ({}); using direct extraction", + "[Negotiation-T] SDK receiveNegotiation has no handler for" + + " '{}' ({}); using direct extraction", getAgentName(agentCard), e.getMessage()); } else { @@ -158,7 +285,7 @@ public CompletableFuture afterReceive( } String fallbackText = extractNegotiationText(metadata); if (fallbackText != null && !fallbackText.isEmpty()) { - metadata.put("negotiation_message", fallbackText); + metadata.put(A2ATExtension.NEGOTIATION_MESSAGE_META_KEY, fallbackText); log.info( "[Negotiation-T] Agent '{}' requested negotiation (direct): {}", getAgentName(agentCard), diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SseNormalization.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SseNormalization.java index 7e0c5fcf..3fe4f7c9 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SseNormalization.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SseNormalization.java @@ -62,14 +62,26 @@ public static Map normalize(Map data) { if (data == null) { return data; } - if (STREAM_RESPONSE_KEYS.stream().noneMatch(data::containsKey)) { - if (data.containsKey("id") && data.containsKey("status")) { - log.info("[A2A] Non-SSE response detected: bare Task, wrapping"); - return Map.of("task", data); - } - if (data.containsKey("artifact") && data.containsKey("taskId")) { - return Map.of("artifactUpdate", data); - } + if (STREAM_RESPONSE_KEYS.stream().noneMatch(data::containsKey)) { + if (data.containsKey("id") && data.containsKey("status")) { + log.info("[A2A] Non-SSE response detected: bare Task, wrapping"); + return Map.of("task", data); + } + // Some agents return "artifacts" (array) instead of "artifact" (single object) + // in artifactUpdate. Coerce to the standard single-object form. + if (data.containsKey("artifacts") && data.containsKey("taskId")) { + Object artifacts = data.get("artifacts"); + if (artifacts instanceof java.util.List list && !list.isEmpty()) { + Map normalized = new java.util.LinkedHashMap<>(data); + normalized.put("artifact", list.get(0)); + normalized.remove("artifacts"); + log.info("[A2A] Non-standard 'artifacts' array coerced to single 'artifact'"); + return Map.of("artifactUpdate", normalized); + } + } + if (data.containsKey("artifact") && data.containsKey("taskId")) { + return Map.of("artifactUpdate", data); + } if (data.containsKey("status") && data.containsKey("taskId")) { return Map.of("statusUpdate", data); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java index 33677f4b..e15e37d9 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java @@ -79,9 +79,15 @@ public CompletableFuture> beforeSend( if (a2atClient == null) { return CompletableFuture.completedFuture(metadata); } - if (messageText != null && messageText.contains("[NEGOTIATION_RESOLUTION]")) { - log.info("[Task-T] Skipping prompt generation for negotiation follow-up"); - return CompletableFuture.completedFuture(metadata); + // Skip Task-T prompt generation when this is a Negotiation-T follow-up + // (metadata already carries the Negotiation-T key from the Accept prompt). + if (metadata != null) { + for (String key : metadata.keySet()) { + if (key.toUpperCase(java.util.Locale.ROOT).contains("NEGOTIATION-T")) { + log.info("[Task-T] Skipping prompt generation for negotiation follow-up"); + return CompletableFuture.completedFuture(metadata); + } + } } String taskTUri = findTaskTUri(agentCard); if (taskTUri == null) { diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClient.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClient.java index c0ca0930..b10335bb 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 @@ -71,5 +71,54 @@ default CompletableFuture sendMessage(String agentName, Strin void setEventCallback(EventCallback callback); + /** + * Lists every A2A-T prompt template of the configured language across all extensions + * (Task-T / Notification-T / Authorization-T / Negotiation-T). Never throws. + * + * @return template list sorted by URI; empty when the A2A-T SDK is not configured + */ + default java.util.List getPrompts() { + return java.util.List.of(); + } + + /** + * Lists every negotiation template of the configured language (propose / accept-reject for + * information, target, feasibility, plus the common abort template). Never throws. + * + * @return negotiation template list; empty when the A2A-T SDK is not configured + */ + default java.util.List getNegotiationPrompts() { + return java.util.List.of(); + } + + /** + * Loads one template by URI regardless of extension. Never throws. + * + * @param templateUri template URI such as {@code StandardTemplates.PRIVATE_LINE_COMPLAINT} + * @return the addressed template, or empty when missing / SDK not configured + */ + default java.util.Optional getPrompt( + net.openan.a2at.sdk.core.model.TemplateUri templateUri) { + return java.util.Optional.empty(); + } + + + /** Query a task by ID (A2A GET tasks/{id}). */ + default CompletableFuture getTask(String agentName, String taskId) { + throw new UnsupportedOperationException("getTask not implemented"); + } + + /** Cancel a task by ID (A2A POST tasks/{id}:cancel). */ + default CompletableFuture cancelTask(String agentName, String taskId) { + throw new UnsupportedOperationException("cancelTask not implemented"); + } + + /** Subscribe to a task stream (A2A POST tasks/{id}:subscribe). */ + default CompletableFuture subscribeToTask( + String agentName, String taskId, + java.util.function.Consumer> eventCallback) { + throw new UnsupportedOperationException("subscribeToTask not implemented"); + } + void close(); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java index 8d89e20a..54425455 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 @@ -51,6 +51,7 @@ public class WorkflowEngineClientConfig { private final int maxNegotiationRounds; private final List customHandlers; private final String preferredProtocol; + private final Map negotiationParamSchema; private WorkflowEngineClientConfig(Builder b) { this.sslVerify = b.sslVerify; @@ -71,6 +72,7 @@ private WorkflowEngineClientConfig(Builder b) { this.maxNegotiationRounds = b.maxNegotiationRounds; this.customHandlers = b.customHandlers; this.preferredProtocol = b.preferredProtocol; + this.negotiationParamSchema = b.negotiationParamSchema; } public static Builder builder() { @@ -96,6 +98,7 @@ public static class Builder { private int maxNegotiationRounds = 3; private List customHandlers = null; private String preferredProtocol = null; + private Map negotiationParamSchema = null; public Builder sslVerify(boolean v) { this.sslVerify = v; @@ -187,6 +190,16 @@ public Builder customHandlers(List v) { return this; } + /** + * Business parameter JSON schema for the Negotiation-T validate-and-fill pipeline. Declares + * the caller's domain fields so the engine core stays domain-agnostic; null keeps the + * default empty schema (negotiation context params only). + */ + public Builder negotiationParamSchema(Map v) { + this.negotiationParamSchema = v; + return this; + } + public WorkflowEngineClientConfig build() { if (sendTimeoutSeconds <= 0 || notificationAckTimeoutSeconds <= 0) { throw new IllegalArgumentException("Timeouts must be positive"); diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java new file mode 100644 index 00000000..1bb50707 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java @@ -0,0 +1,175 @@ +/* + * 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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +/** + * Tests for {@link A2ATContentFacade} against the real A2A-T SDK: deterministic from-data + * rendering (no LLM) for propose, accept, reject and abort, plus the metadata shape the engine + * attaches to A2A messages. + * + *

These tests exercise the bundled classpath templates of a2a-t-resources; the .env-driven + * LLM paths (from-text generation, validate-and-fill) are covered by the SDK's own suite and the + * engine's E2E sample test. + */ +class A2ATContentFacadeTest { + + private static final String ENV_PATH = resolveSampleEnv(); + + private static String resolveSampleEnv() { + // The facade needs a real A2ATClient; use the engine repo's .env when present so the + // template language resolves to zh-CN. Walk up from the module dir to the repo root. + java.nio.file.Path dir = java.nio.file.Path.of("").toAbsolutePath(); + while (dir != null) { + java.nio.file.Path candidate = dir.resolve(".env"); + if (java.nio.file.Files.exists(candidate)) { + return candidate.toString(); + } + dir = dir.getParent(); + } + return null; + } + + private A2ATContentFacade facade() { + if (ENV_PATH == null) { + return null; + } + return new A2ATContentFacade(new net.openan.a2at.sdk.client.A2ATClient(java.nio.file.Path.of(ENV_PATH))); + } + + private static NegotiationContext context() { + return new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5); + } + + @Test + void rendersInformationProposeFromData() { + A2ATContentFacade facade = facade(); + assumeTrue(facade != null, ".env not found; SDK-dependent assertions skipped"); + net.openan.a2at.sdk.core.model.MetadataContent mc = + facade.generateProposeFromData( + new NegotiationProposeData( + context(), + new InformationProposeContent( + List.of( + new NegotiationItem("接入端口名称", "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1"), + new NegotiationItem("投诉分类", "举例:专线质差")), + null)), + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); + assertNotNull(mc.promptText()); + // Since the SDK moved the context out of templates, the propose text carries only the + // business sections; the context travels in the negotiationContext metadata key. + assertTrue(mc.promptText().contains("信息协商"), "propose must render the info section"); + assertTrue(mc.promptText().contains("接入端口名称")); + assertFalse(mc.promptText().contains("协商上下文"), "context section must not render"); + assertEquals( + "Negotiation-T/information-negotiation/propose/v1", + mc.templateUri()); + Map meta = A2ATContentFacade.toMetadata(mc); + assertEquals(mc.promptText(), meta.get(net.openan.a2at.sdk.core.model.ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI)); + assertEquals( + "Negotiation-T/information-negotiation/propose/v1", + meta.get(net.openan.a2at.sdk.core.model.MetadataContent.TEMPLATE_URI_METADATA_KEY)); + Object ctx = meta.get(net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + assertTrue(ctx instanceof Map, "context must travel in metadata"); + assertEquals("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", ((Map) ctx).get("id")); + assertEquals(1, ((Map) ctx).get("round")); + } + + @Test + void rendersAcceptFromData() { + A2ATContentFacade facade = facade(); + assumeTrue(facade != null, ".env not found; SDK-dependent assertions skipped"); + net.openan.a2at.sdk.core.model.MetadataContent mc = + facade.generateAcceptFromData( + new NegotiationEndingData( + context(), + new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of(new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1")))), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + assertTrue(mc.promptText().contains("Accept"), "accept must carry the Accept conclusion"); + // The round travels in metadata, not in the rendered text. + assertFalse(mc.promptText().contains("round: 1")); + } + + @Test + void rendersRejectFromData() { + A2ATContentFacade facade = facade(); + assumeTrue(facade != null, ".env not found; SDK-dependent assertions skipped"); + net.openan.a2at.sdk.core.model.MetadataContent mc = + facade.generateRejectFromData( + new NegotiationEndingData( + context(), + new InformationEndingContent( + NegotiationConclusion.REJECT, + List.of(new NegotiationItem("投诉分类", "无法提供,原因:数据源不可用")))), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + assertTrue(mc.promptText().contains("Reject"), "reject must carry the Reject conclusion"); + } + + @Test + void rendersAbortFromData() { + A2ATContentFacade facade = facade(); + assumeTrue(facade != null, ".env not found; SDK-dependent assertions skipped"); + net.openan.a2at.sdk.core.model.MetadataContent mc = + facade.generateAbortFromData( + new NegotiationAbortData( + context(), new NegotiationAbortContent("轮次预算耗尽,协商终止"))); + assertEquals("Negotiation-T/common/abort/v1", mc.templateUri()); + assertTrue(mc.promptText().contains("轮次预算耗尽")); + } + + @Test + void negotiationTemplateQueriesResolve() { + A2ATContentFacade facade = facade(); + assumeTrue(facade != null, ".env not found; SDK-dependent assertions skipped"); + var prompts = facade.getNegotiationPrompts(); + // 3 types x 2 phases + common abort + assertEquals(7, prompts.size(), "bundled negotiation templates must be enumerable"); + assertTrue( + facade.getNegotiationPrompt(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE).isPresent()); + assertFalse( + facade.getPrompt( + net.openan.a2at.sdk.core.model.TemplateUri.of("Task-T", "nonexistent-scenario")) + .isPresent(), + "unknown scenario must yield empty"); + } +} From 4436fb6fc178fa2072e95859370d548ac86d38c8 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Sun, 23 Aug 2026 13:12:43 +0800 Subject: [PATCH 02/49] feat(samples): SDK negotiation content integration for SPN scenario - render Negotiation-T propose via SDK templates (negotiationType/buildProposeContent/ proposeTemplateUri subclass extension points, three negotiation types) - send propose as INPUT_REQUIRED status message (standalone agent Message closes the SSE stream per a2a-java SDK spec behavior) - validate-and-fill follow-ups with explicit NegotiationContext from metadata; render Accept and attach to the result artifact metadata - SDK-driven needsNegotiation via validateTaskPromptAndDataFilling (blank slot triggers) - server-side validate full set: validateAuth/validateNotification with compliance fallback - NegotiationStrategy: structured Accept payload per SDK scenario data (drop legacy in-text context extraction removed from templates) - A2ATProtocolCases demo reproducing the 11 protocol verification cases + template query case - negotiation E2E integration test (negotiation auto-loop completes over real SSE) - fix Negotiation-T extension URI casing in agent cards (NEGOTIATION-T -> Negotiation-T/v1) Signed-off-by: Zhoujie628 --- .../SpringWorkbenchApplication.java | 21 +- .../examples/agents/BaseAgentExecutor.java | 2 +- .../agents/NegotiationBaseAgentExecutor.java | 504 ++++++++++++++---- .../examples/agents/NegotiationStrategy.java | 69 --- .../examples/agents/NegotiationUtils.java | 108 ---- .../agents/PrePositionedExtensionHandler.java | 115 ++++ .../agents/SpnDomainAgentCity1Executor.java | 73 +-- .../agents/SpnDomainAgentCity2Executor.java | 32 +- .../agents/SpringWorkbenchExecutor.java | 160 ++++++ .../TransportWorkbenchAgentExecutor.java | 32 +- .../agents/WorkbenchControlPoint.java | 108 ++-- .../examples/demo/A2ATProtocolCases.java | 271 ++++++++++ .../extension/ExtensionPrePositioner.java | 174 ++++++ .../PrePositionedExtensionHandler.java | 265 +++++++++ .../negotiation/NegotiationStrategy.java | 83 +++ .../negotiation/NegotiationUtils.java | 68 +++ .../examples/server/JdkHttpA2AServer.java | 57 ++ .../SpringWorkbenchExtensionLifecycle.java | 114 ++++ .../workbench/WorkbenchAgentCatalog.java | 60 +++ .../workbench/WorkbenchControlPoint.java | 231 ++++++++ .../WorkbenchExtensionLifecycle.java | 123 +++++ .../workbench/WorkbenchOrchestrator.java | 295 ++++++++++ .../agentcard/spn_domain_agent_city1.json | 2 +- .../agentcard/spn_domain_agent_city2.json | 2 +- .../examples/EmbeddedA2AServerTest.java | 51 +- .../server/EmbeddedA2AServerTest.java | 227 ++++++++ 26 files changed, 2877 insertions(+), 370 deletions(-) rename samples/src/main/java/dev/openan/workflow/engine/examples/{spring => }/SpringWorkbenchApplication.java (69%) delete mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationStrategy.java delete mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationUtils.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/demo/A2ATProtocolCases.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositioner.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandler.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategy.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationUtils.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExtensionLifecycle.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalog.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchControlPoint.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchExtensionLifecycle.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/server/EmbeddedA2AServerTest.java diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringWorkbenchApplication.java b/samples/src/main/java/dev/openan/workflow/engine/examples/SpringWorkbenchApplication.java similarity index 69% rename from samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringWorkbenchApplication.java rename to samples/src/main/java/dev/openan/workflow/engine/examples/SpringWorkbenchApplication.java index a3796ce8..d1a534a3 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringWorkbenchApplication.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/SpringWorkbenchApplication.java @@ -17,22 +17,28 @@ * under the License. */ -package dev.openan.workflow.engine.examples.spring; +package dev.openan.workflow.engine.examples; + +import dev.openan.workflow.engine.client.EnvFileLoader; +import dev.openan.workflow.engine.examples.util.EnvResolver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import java.nio.file.Path; + +import dev.openan.workflow.engine.examples.demo.SpringSpnDemo; /** * Spring Boot Workbench Agent -- northbound A2A server. * *

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

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

Can also be run standalone as a Spring Boot service (no demo logic). */ @@ -43,7 +49,16 @@ public class SpringWorkbenchApplication { public static void main(String[] args) { System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); + loadDotEnv(); SpringApplication.run(SpringWorkbenchApplication.class, args); log.info("[SpringWorkbench] A2A server started -- ready to receive A2A-T messages"); } + + /** Loads local demo configuration before Spring resolves application properties. */ + public static void loadDotEnv() { + String envPath = EnvResolver.resolveEnvPath(); + if (envPath != null) { + EnvFileLoader.loadToSystemProperties(Path.of(envPath)); + } + } } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/BaseAgentExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/BaseAgentExecutor.java index 47293b95..e8874248 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/BaseAgentExecutor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/BaseAgentExecutor.java @@ -45,7 +45,7 @@ protected static String extractText(Message message) { return sb.toString(); } - protected static Message buildStatusMessage(String contextId, String taskId, String text) { + public static Message buildStatusMessage(String contextId, String taskId, String text) { return Message.builder() .messageId(UUID.randomUUID().toString()) .contextId(contextId) diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationBaseAgentExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationBaseAgentExecutor.java index dc3b35f7..ceb8205b 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationBaseAgentExecutor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationBaseAgentExecutor.java @@ -20,11 +20,16 @@ package dev.openan.workflow.engine.examples.agents; import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.server.A2ATServer; import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.Part; import org.a2aproject.sdk.spec.TaskState; import org.a2aproject.sdk.spec.TaskStatus; @@ -42,6 +47,8 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; +import dev.openan.workflow.engine.examples.extension.PrePositionedExtensionHandler; +import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; /** * Server-side negotiation base, mirroring the Python reference * (orchestration-center/samples/agents/negotiation_base_agent.py). @@ -49,7 +56,7 @@ *

Every agent that declares the Negotiation-T extension MUST be able to receive and reply to * negotiation messages. This base implements that capability: on a new task it starts a fulfillment * negotiation and replies with INPUT_REQUIRED carrying the negotiation context in task metadata; on - * a follow-up ([NEGOTIATION_RESOLUTION]) it re-executes the business and completes. + * a Negotiation-T reply (metadata has Negotiation-T key, no Task-T key) it re-executes the business and completes. * *

Negotiation triggers on every new task (deterministic), so the demo exercises the full * Negotiation-T round-trip each run. When the A2A-T .env is absent or LLM is disabled, the @@ -76,8 +83,19 @@ protected final String getNotificationSubscription() { return prePositionedHandler.getNotificationSubscription(); } + protected void reportRecoveryResult(AgentEmitter emitter, String result) { + String notifUri = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + Map notifMeta = new LinkedHashMap<>(); + notifMeta.put(notifUri, result); + List> rParts = List.of(new TextPart(result)); + emitter.addArtifact(rParts, "recovery-result", "recovery-result", notifMeta, false, true); + log.info("[{}] RECOVERY_REPORTED resultChars={}", getClass().getSimpleName(), result.length()); + } + protected void pushNotificationResult(String result) { - notificationQueue.offer(result); + if (!notificationQueue.offer(result)) { + log.warn("[{}] Notification queue rejected a result", getClass().getSimpleName()); + } } /** @@ -110,10 +128,60 @@ private A2ATClient a2at() { } } + private volatile net.openan.a2at.sdk.server.A2ATServer a2atServer; + + /** Server-side SDK facade for task-prompt validation; null when unavailable. */ + protected net.openan.a2at.sdk.server.A2ATServer a2atServer() { + if (a2atServer != null) { + return a2atServer; + } + if (Boolean.getBoolean("a2at.llm.disabled")) { + return null; + } + String env = resolveEnvPath(); + if (env == null || env.isBlank()) { + return null; + } + try { + a2atServer = new net.openan.a2at.sdk.server.A2ATServer(Path.of(env)); + return a2atServer; + } catch (Exception e) { + log.warn( + "[{}] A2ATServer init failed, task validation disabled: {}", + getClass().getSimpleName(), + e.getMessage()); + return null; + } + } + + /** + * 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() { + Map properties = new LinkedHashMap<>(); + properties.put("任务对象", Map.of("type", "string")); + properties.put("任务上下文", Map.of("type", "string")); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + schema.put("required", List.of("任务上下文")); + return schema; + } + @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) { @@ -125,12 +193,14 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { } } log.info( - "[{}] Received task: taskId={}, text={} chars, followUp={}, prePositioned={}", + "[{}] TASK_START taskId={}, contextId={}, inputChars={}, followUp={}, " + + "prePositionedExtension={}", getClass().getSimpleName(), taskId, + contextId, input.length(), - NegotiationUtils.isFollowUpTask(input), - PrePositionedExtensionHandler.detect(ctx) != null); + isNegotiationReply(ctx), + PrePositionedExtensionHandler.detect(ctx)); try { String prePositionedExt = PrePositionedExtensionHandler.detect(ctx); if (prePositionedExt != null) { @@ -140,7 +210,7 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { prePositionedHandler.handle( ctx, emitter, prePositionedExt, getClass().getSimpleName()); } - } else if (NegotiationUtils.isFollowUpTask(input)) { + } else if (isNegotiationReply(ctx)) { handleFollowUp(ctx, emitter, input); } else { handleNewTask(ctx, emitter, input); @@ -148,10 +218,21 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.info( - "[{}] Notification-T stream interrupted (shutdown)", - getClass().getSimpleName()); + "[{}] TASK_INTERRUPTED taskId={}, contextId={}, elapsedMs={}, reason=shutdown", + getClass().getSimpleName(), + taskId, + contextId, + elapsedMillis(started)); } catch (Exception e) { - log.error("[{}] execute failed: {}", getClass().getSimpleName(), e.getMessage(), e); + 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())); } } @@ -169,8 +250,12 @@ private void handleNotificationSubscription(RequestContext ctx, AgentEmitter emi String agentTag = getClass().getSimpleName(); String notifUri = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; - log.info("[{}] Notification-T subscription received, keeping stream open", agentTag); - List> ackParts = List.of(new TextPart("Subscribed to recovery results")); + log.info( + "[{}] NOTIFICATION_SUBSCRIBED taskId={}, contextId={}, action=keep-stream-open", + agentTag, + taskId, + contextId); + List> ackParts = List.of(new TextPart("订阅成功,启动业务抢通事件上报任务")); emitter.addArtifact( ackParts, "subscription", agentTag + " subscription", Map.of(), false, true); emitStatus( @@ -178,14 +263,19 @@ private void handleNotificationSubscription(RequestContext ctx, AgentEmitter emi TaskState.TASK_STATE_WORKING, contextId, taskId, - "Notification-T subscribed, waiting for recovery results", + "订阅成功,启动业务抢通事件上报任务", Map.of()); while (!Thread.currentThread().isInterrupted()) { String result = notificationQueue.poll(30, TimeUnit.SECONDS); if (result == null) { continue; } - log.info("[{}] Pushing recovery result via Notification-T stream", agentTag); + log.info( + "[{}] NOTIFICATION_PUSH taskId={}, contextId={}, resultChars={}", + agentTag, + taskId, + contextId, + result.length()); Map notifMeta = new LinkedHashMap<>(); notifMeta.put(notifUri, result); List> resultParts = List.of(new TextPart(result)); @@ -196,110 +286,208 @@ private void handleNotificationSubscription(RequestContext ctx, AgentEmitter emi /** New task: start negotiation and request input. Business runs on the follow-up. */ private void handleNewTask(RequestContext ctx, AgentEmitter emitter, String input) { - if (needsNegotiation(input)) { + if (isNegotiationRequested(ctx) || needsNegotiation(input)) { requestNegotiation(ctx, emitter, input); } else { log.info( "[{}] Parameters sufficient, skipping negotiation", getClass().getSimpleName()); - runBusinessAndComplete(ctx, emitter, input); + runBusinessAndComplete(ctx, emitter, input, null); } } - /** Follow-up: clean markers, run business, complete with extension metadata. */ + private boolean isNegotiationRequested(RequestContext ctx) { + ServerCallContext callContext = ctx.getCallContext(); + if (callContext == null) return false; + String negUri = NegotiationUtils.NEGOTIATION_T_URI; + if (callContext.isExtensionRequested(negUri)) return true; + for (String ext : callContext.getRequestedExtensions()) { + if (ext.toUpperCase(java.util.Locale.ROOT).contains("NEGOTIATION-T")) return true; + } + return false; + } + + private boolean isNegotiationReply(RequestContext ctx) { + Map meta = ctx.getMessage() != null ? ctx.getMessage().metadata() : null; + return NegotiationUtils.hasNegotiationMetadata(meta) && !NegotiationUtils.hasTaskMetadata(meta); + } + + /** + * Follow-up: validate the negotiation reply, then re-execute the business with the Accept + * text attached to the result artifact metadata (the a2a-java SDK closes the SSE stream + * right after delivering a standalone agent Message, so the Accept must not be sent as one + * before the final artifact). + */ private void handleFollowUp(RequestContext ctx, AgentEmitter emitter, String input) { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - String cleanInput = NegotiationUtils.cleanupResolutionMarker(input); + String cleanInput = input; + A2ATClient client = a2at(); + Map meta = + ctx.getMessage() != null ? ctx.getMessage().metadata() : null; + net.openan.a2at.sdk.core.model.NegotiationContext negotiationCtx = + parseNegotiationContext(meta); + String acceptText = null; + if (client != null) { + try { + FilledParamData filled = client.validateProposePromptAndDataFilling( + cleanInput, negotiationCtx, buildNegotiationParamSchema(), + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); + if (filled != null && filled.data() != null && !filled.data().isEmpty()) { + log.info("[{}] Negotiation params validated and filled: {} keys", + getClass().getSimpleName(), filled.data().keySet()); + } + } catch (Exception e) { + log.warn("[{}] validateProposePromptAndDataFilling failed: {}", + getClass().getSimpleName(), e.getMessage()); + } + acceptText = renderAcceptText(client, negotiationCtx); + } log.info("[{}] Follow-up received, re-executing business", getClass().getSimpleName()); - runBusinessAndComplete(ctx, emitter, cleanInput); + runBusinessAndComplete(ctx, emitter, cleanInput, acceptText); } - /** Send INPUT_REQUIRED to start a Negotiation-T round. */ - @SuppressWarnings("unchecked") - private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, String input) { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - Map neg = startNegotiation(input, taskId, contextId); - Map contextData = - neg != null && neg.get(NegotiationUtils.NEGOTIATION_CONTEXT_KEY) instanceof Map - ? (Map) neg.get(NegotiationUtils.NEGOTIATION_CONTEXT_KEY) - : fallbackContext(); - String negText = - neg != null && neg.get(NegotiationUtils.NEGOTIATION_TEXT_KEY) instanceof String - ? (String) neg.get(NegotiationUtils.NEGOTIATION_TEXT_KEY) - : defaultNegotiationText(); - String concern = defaultNegotiationConcern(); - Map metadata = - NegotiationUtils.negotiationResponseMetadata(contextData, negText, concern); - emitStatus( - emitter, - TaskState.TASK_STATE_INPUT_REQUIRED, - contextId, - taskId, - "[NEGOTIATION_REQUEST] " - + getClass().getSimpleName() - + " needs clarification before completing this task.", - metadata); - log.info("[{}] Requested negotiation (INPUT_REQUIRED)", getClass().getSimpleName()); + /** + * Renders the Negotiation-T Accept for the received propose. Rendering failures are logged + * only (null return); the business proceeds regardless. + */ + private String renderAcceptText( + A2ATClient client, net.openan.a2at.sdk.core.model.NegotiationContext negotiationCtx) { + try { + net.openan.a2at.sdk.core.model.NegotiationContext acceptCtx = + negotiationCtx != null + ? negotiationCtx + : 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.MetadataContent mc = + client.generateNegotiationAcceptPromptFromText( + "同意补充所缺参数,信息已完整", + acceptCtx, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + if (mc != null && mc.promptText() != null && !mc.promptText().isEmpty()) { + log.info( + "[{}] Negotiation Accept rendered via {}", + getClass().getSimpleName(), + mc.templateUri()); + return mc.promptText(); + } + } catch (Exception e) { + log.warn( + "[{}] Accept rendering failed: {}", + getClass().getSimpleName(), + e.getMessage()); + } + return null; } - /** Run business logic, emit artifact, and complete the task. */ - private void runBusinessAndComplete(RequestContext ctx, AgentEmitter emitter, String input) { + /** + * Sends the Negotiation-T propose as an INPUT_REQUIRED status with the propose text and + * metadata attached to the status message. + * + *

An earlier version sent a standalone Message(ROLE_AGENT) followed by {@code + * requiresInput()}; the a2a-java SDK closes the SSE stream right after delivering a Message + * (per spec §3.1.2 a Message is the complete response), so the queued INPUT_REQUIRED status + * was never delivered and the client hung waiting for a terminal event. Carrying the propose + * inside the status message keeps the stream semantics intact: INPUT_REQUIRED is an + * interrupted (non-final) state, the client's runtime treats it as terminal for the + * sendMessage call, and the engine's negotiation auto-loop picks the propose text up from + * the merged response metadata. + */ + private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, String input) { String taskId = ctx.getTaskId(); String contextId = ctx.getContextId(); - 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("[{}] Task completed", getClass().getSimpleName()); + String negId = java.util.UUID.randomUUID().toString(); + String negText = renderProposeText(negId, input); + Map metadata = NegotiationUtils.negotiationResponseMetadata(negText); + Message proposeMessage = + Message.builder() + .messageId(java.util.UUID.randomUUID().toString()) + .contextId(contextId) + .taskId(taskId) + .role(Message.Role.ROLE_AGENT) + .parts(List.of(new TextPart("存在信息缺失,请补充信息"))) + .metadata(metadata) + .build(); + emitter.requiresInput(proposeMessage); + log.info( + "[{}] Requested negotiation via INPUT_REQUIRED status message", + getClass().getSimpleName()); } - /** Start a fulfillment negotiation via A2ATClient, or return a fallback payload. */ - @SuppressWarnings("unchecked") - private Map startNegotiation(String input, String taskId, String contextId) { + /** + * Renders the propose text through the SDK content engine when available: the subclass's + * {@link #negotiationType()} picks the template, {@link #buildProposeContent(String)} supplies + * the typed content, and the SDK renders deterministically (no LLM). Falls back to + * {@link #defaultNegotiationText()} when the SDK client is unavailable or rendering fails. + */ + private String renderProposeText(String negotiationId, String input) { A2ATClient client = a2at(); - if (client == null) { - return fallbackNegotiationPayload(input, taskId, contextId); + net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent content = + buildProposeContent(input); + if (client == null || content == null) { + return defaultNegotiationText().replace("PLACEHOLDER_NEGOTIATION_ID", negotiationId); } try { - Map facts = new LinkedHashMap<>(); - facts.put("agent", getClass().getSimpleName()); - if (taskId != null) facts.put("task_id", taskId); - if (contextId != null) facts.put("context_id", contextId); - // contentText should be the short negotiation request text, not - // the full task input. The full input is passed as a fact so the - // negotiation context retains the original task context. - facts.put("input", input); - return client.startNegotiation( - NegotiationType.FULFILLMENT, defaultNegotiationText(), facts); + net.openan.a2at.sdk.core.model.NegotiationContext contentCtx = + new net.openan.a2at.sdk.core.model.NegotiationContext( + negotiationId, + 1, + net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); + net.openan.a2at.sdk.core.model.MetadataContent mc = + client.generateNegotiationProposePromptFromData( + new net.openan.a2at.sdk.negotiation.content.NegotiationProposeData( + contentCtx, content), + proposeTemplateUri()); + if (mc != null && mc.promptText() != null && !mc.promptText().isEmpty()) { + log.info( + "[{}] SDK-rendered propose via {}: {} chars", + getClass().getSimpleName(), + mc.templateUri(), + mc.promptText().length()); + return mc.promptText(); + } } catch (Exception e) { log.warn( - "[{}] startNegotiation failed, using fallback: {}", + "[{}] SDK propose rendering failed ({}); using default negotiation text", getClass().getSimpleName(), e.getMessage()); - return fallbackNegotiationPayload(input, taskId, contextId); } + return defaultNegotiationText().replace("PLACEHOLDER_NEGOTIATION_ID", negotiationId); } - private Map fallbackContext() { - Map ctx = new LinkedHashMap<>(); - ctx.put("negotiationType", "fulfillment"); - ctx.put("negotiationId", UUID.randomUUID().toString()); - ctx.put("round", 1); - ctx.put("status", "in-progress"); - ctx.put("extra", Map.of()); - return ctx; - } - - private Map fallbackNegotiationPayload( - String input, String taskId, String contextId) { - Map payload = new LinkedHashMap<>(); - payload.put(NegotiationUtils.NEGOTIATION_CONTEXT_KEY, fallbackContext()); - payload.put(NegotiationUtils.NEGOTIATION_TEXT_KEY, defaultNegotiationText()); - return payload; + /** Run business logic, emit artifact, and complete the task. */ + private void runBusinessAndComplete( + RequestContext ctx, AgentEmitter emitter, String input, String acceptText) { + String taskId = ctx.getTaskId(); + String contextId = ctx.getContextId(); + long started = System.nanoTime(); + log.info( + "[{}] BUSINESS_START taskId={}, contextId={}, inputChars={}", + getClass().getSimpleName(), + taskId, + contextId, + input != null ? input.length() : 0); + String response = executeBusiness(ctx, emitter, input); + Map metadata = buildResponseMetadata(ctx, response); + if (acceptText != null && !acceptText.isEmpty()) { + // Negotiation Accept rides with the result artifact (not as a standalone agent + // Message, which would close the SSE stream before the final artifact). A dedicated + // key — NOT the Negotiation-T extension URI, which the engine treats as the + // "negotiation needed" trigger signal and would loop on. + metadata.put("negotiation_accept", acceptText); + } + 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( @@ -323,13 +511,80 @@ private void emitStatus( // ---- subclass extension points ---- + /** + * The negotiation type this agent starts. Selects the SDK propose template and the typed + * content model expected from {@link #buildProposeContent(String)}. Default: information + * negotiation (missing parameters). Override for {@code TARGET} (intent alignment) or {@code + * FEASIBILITY} (whether the request can be fulfilled). + */ + protected NegotiationType negotiationType() { + return NegotiationType.INFORMATION; + } + + /** + * Typed propose content rendered by the SDK content engine. The default returns {@code null} + * to signal "no typed content"; subclasses override together with {@link + * #negotiationType()}: + * + *

    + *
  • INFORMATION - {@code InformationProposeContent(items, relationship)} + *
  • TARGET - {@code TargetProposeContent(description, intent, alignment, clarification)} + *
  • FEASIBILITY - {@code FeasibilityProposeContent(description, action, ...)} + *
+ * + * @param input the task input text + * @return typed content, or null to fall back to {@link #defaultNegotiationText()} + */ + protected net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent buildProposeContent( + String input) { + return null; + } + + /** Resolves the SDK propose template URI for {@link #negotiationType()}. */ + protected net.openan.a2at.sdk.core.model.TemplateUri proposeTemplateUri() { + return switch (negotiationType()) { + case TARGET -> StandardTemplates.TARGET_NEGOTIATION_PROPOSE; + case FEASIBILITY -> StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; + case INFORMATION -> StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + }; + } + /** * Whether the incoming task parameters are incomplete/wrong and require a Negotiation-T round - * before proceeding. Default: false (parameters are sufficient, skip negotiation and run - * business directly). Override to return true when the agent needs clarification. + * before proceeding. + * + *

Default: SDK-driven check — the task prompt runs through the server-side validate-and-fill + * pipeline ({@code A2ATServer.validateTaskPromptAndDataFilling}) against {@link + * #taskTemplateUri()} with {@link #buildTaskParamSchema()}; a validation failure or any blank + * required slot triggers negotiation. Override for a domain-specific heuristic. */ protected boolean needsNegotiation(String input) { - return false; + A2ATServer server = a2atServer(); + if (server == null) { + return false; + } + try { + FilledParamData filled = server.validateTaskPromptAndDataFilling( + input, buildTaskParamSchema(), taskTemplateUri()); + Map data = filled != null ? filled.data() : null; + if (data == null) { + return false; + } + for (Map.Entry entry : data.entrySet()) { + Object value = entry.getValue(); + if (value == null || (value instanceof String s && s.isBlank())) { + log.info("[{}] Task param '{}' is blank; negotiation needed", + getClass().getSimpleName(), entry.getKey()); + return true; + } + } + return false; + } catch (Exception e) { + // Validation rejected (semantic/rule failure) means the prompt is not usable as-is. + log.info("[{}] Task validation failed ({}); negotiation needed", + getClass().getSimpleName(), e.getMessage()); + return true; + } } /** @@ -346,7 +601,7 @@ protected Map buildResponseMetadata(RequestContext ctx, String r /** Short human-readable summary for the artifact parts. */ protected String buildResultSummary() { - return "业务处理结果"; + return "专线业务投诉诊断任务诊断结果消息"; } /** Artifact display name. Default: subclass simple name + " result". */ @@ -356,12 +611,69 @@ protected String buildArtifactName() { /** Default negotiation text shown when A2ATClient is unavailable. */ protected String defaultNegotiationText() { - return "Please confirm the task parameters so I can proceed."; + // Mirrors the SDK information-propose template without the context section (the SDK + // carries id/round/maxRounds in the negotiationContext metadata key since 2026-08). + return "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n" + + "## 所需信息项\n1. 接入端口名称,举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n" + + "2. 投诉分类,举例:专线质差\n" + + "缺失项之间的关系:以上参数均为必选,缺少无法启动诊断"; + } + + /** + * Parameter JSON Schema for the SDK validate-and-fill pipeline: the business parameters this + * agent expects the negotiation follow-up to carry. Mirrors the missing-info items of + * {@link #defaultNegotiationText()}. + */ + protected Map buildNegotiationParamSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("接入端口名称", Map.of("type", "string")); + properties.put("投诉分类", Map.of("type", "string")); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + return schema; } /** Default negotiation concern. */ protected String defaultNegotiationConcern() { - return "needs clarification"; + return "参数缺失,需协商补充"; + } + + /** + * Parses the SDK-carried negotiation context ({@code negotiationContext} metadata key) from + * the incoming message metadata. Returns null when absent or malformed; the SDK validate + * APIs treat a null context as not-a-negotiation-message. + */ + 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); + if (!(raw instanceof Map contextMap)) { + return null; + } + try { + Object id = contextMap.get("id"); + Object round = contextMap.get("round"); + Object maxRounds = contextMap.get("maxRounds"); + if (id instanceof String s && round instanceof Number r && maxRounds instanceof Number m) { + return new net.openan.a2at.sdk.core.model.NegotiationContext( + s, r.intValue(), m.intValue()); + } + } catch (IllegalArgumentException e) { + log.debug( + "[{}] Malformed negotiationContext metadata: {}", + NegotiationBaseAgentExecutor.class.getSimpleName(), + e.getMessage()); + } + return null; + } + + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); } @Override diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationStrategy.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationStrategy.java deleted file mode 100644 index 0cfa4f52..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationStrategy.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.agents; - -import dev.openan.workflow.engine.examples.LlmHelper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -/** - * Negotiation clarification strategy for the SPN cross-city workbench. - * - *

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

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

Markers and key names align with the a2a-t-sdk-java negotiation payload mapper - * (NEGOTIATION_CONTEXT_KEY / NEGOTIATION_TEXT_KEY) and the engine-side NegotiationTHandler / - * autoNegotiate follow-up format. - */ -final class NegotiationUtils { - - static final String NEGOTIATION_RESOLUTION_MARKER = "[NEGOTIATION_RESOLUTION]"; - static final String NEGOTIATION_REQUEST_MARKER = "[NEGOTIATION_REQUEST]"; - static final String NEGOTIATION_CONTEXT_MARKER = "[NEGOTIATION_CONTEXT]"; - static final String NEGOTIATION_CONCERN_KEY = "negotiationConcern"; - static final String NEGOTIATION_CONTEXT_KEY = NegotiationHandler.NEGOTIATION_CONTEXT_KEY; - static final String NEGOTIATION_TEXT_KEY = A2ATExtension.NEGOTIATION_T.uri(); - static final String TASK_PROMPT_KEY = A2ATExtension.TASK_T.uri(); - - private NegotiationUtils() {} - - static boolean isFollowUpTask(String text) { - return text != null && text.contains(NEGOTIATION_RESOLUTION_MARKER); - } - - /** Extract the original task text from a [NEGOTIATION_RESOLUTION] follow-up message. */ - static String extractOriginalTask(String text) { - if (text == null || !text.contains(NEGOTIATION_RESOLUTION_MARKER)) { - return text != null ? text : ""; - } - int idx = text.indexOf("Original Task:"); - if (idx < 0) { - return ""; - } - String after = text.substring(idx + "Original Task:".length()).trim(); - int end = after.indexOf("\n\nPlease re-execute the task based on the clarification above."); - if (end >= 0) { - after = after.substring(0, end).trim(); - } - return after; - } - - /** Strip negotiation markers to recover a clean task text for re-execution. */ - static String cleanupResolutionMarker(String text) { - if (text == null) { - return ""; - } - if (!text.contains(NEGOTIATION_RESOLUTION_MARKER)) { - return text; - } - String original = extractOriginalTask(text); - return original.isEmpty() ? text : original; - } - - /** Build the negotiation response metadata placed on an INPUT_REQUIRED task. */ - static Map negotiationResponseMetadata( - Map contextData, String negotiationText, String concern) { - Map metadata = new LinkedHashMap<>(); - if (contextData != null && !contextData.isEmpty()) { - metadata.put(NEGOTIATION_CONTEXT_KEY, contextData); - } - if (negotiationText != null && !negotiationText.isEmpty()) { - metadata.put(NEGOTIATION_TEXT_KEY, negotiationText); - } - if (concern != null && !concern.isEmpty()) { - metadata.put(NEGOTIATION_CONCERN_KEY, concern); - } - return metadata; - } - - /** Build the [NEGOTIATION_RESOLUTION] follow-up text the client resends. */ - static String buildResolutionMessage(String originalTask, String resolutionText) { - return NEGOTIATION_RESOLUTION_MARKER - + "\n" - + "The engine has reviewed your negotiation request and provides the following clarification:\n\n" - + resolutionText - + "\n\n---\nOriginal Task:\n" - + originalTask - + "\n\nPlease re-execute the task based on the clarification above."; - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/PrePositionedExtensionHandler.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/PrePositionedExtensionHandler.java index 9eaa7150..ab19c8dd 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/PrePositionedExtensionHandler.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/PrePositionedExtensionHandler.java @@ -25,10 +25,16 @@ 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.server.model.PromptComplianceResult; +import java.nio.file.Path; import org.a2aproject.sdk.spec.TextPart; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -53,6 +59,21 @@ public class PrePositionedExtensionHandler { private static final Logger log = LoggerFactory.getLogger(PrePositionedExtensionHandler.class); private volatile String authorizationPolicy; private volatile String notificationSubscription; + private volatile A2ATServer a2atServer; + + private A2ATServer a2atServer() { + if (a2atServer != null) return a2atServer; + if (Boolean.getBoolean("a2at.llm.disabled")) return null; + String env = System.getProperty("A2AT_ENV_PATH", System.getenv("A2AT_ENV_PATH")); + if (env == null || env.isBlank()) return null; + try { + a2atServer = new A2ATServer(Path.of(env)); + return a2atServer; + } catch (Exception e) { + log.warn("A2ATServer init failed: {}", e.getMessage()); + return null; + } + } /** * Detect whether the incoming message carries an Authorization-T or Notification-T extension in @@ -105,6 +126,35 @@ public String getNotificationSubscription() { return notificationSubscription; } + /** + * Parameter JSON Schema for the Authorization-T validate-and-fill pipeline: the whitelist + * policy fields the agent wants extracted from a rendered authorization prompt. + */ + private static Map buildAuthParamSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("授权策略的操作类型", Map.of("type", "string")); + properties.put("动网操作的授权策略列表", Map.of("type", "string")); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + return schema; + } + + /** + * Parameter JSON Schema for the Notification-T validate-and-fill pipeline: the subscription + * fields extracted from a rendered subscribe-incident prompt. + */ + private static Map buildNotificationParamSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("通知主题", Map.of("type", "string")); + properties.put("订阅条件", Map.of("type", "string")); + properties.put("上报通知数据格式", Map.of("type", "string")); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + return schema; + } + /** * Handle a pre-positioned message: store the payload, emit an ACK artifact, and complete the * task. @@ -122,10 +172,75 @@ public void handle( .findFirst() .map(v -> v instanceof String s ? s : String.valueOf(v)) .orElse(""); + // Extract templateUri from metadata if present (sent by SDK prompt generation) + String templateUri = null; + if (ctx.getMessage().metadata() != null) { + Object tu = ctx.getMessage().metadata().get(MetadataContent.TEMPLATE_URI_METADATA_KEY); + if (tu instanceof String s) templateUri = s; + } if (extKeyword.contains("Authorization")) { authorizationPolicy = payloadText; + // Full validate-and-fill pipeline: rule gate + LLM semantic validation + param + // extraction, falling back to the lightweight compliance check. + A2ATServer server = a2atServer(); + if (server != null) { + try { + net.openan.a2at.sdk.core.model.FilledParamData filled = + server.validateAuthPromptAndDataFilling( + payloadText, + buildAuthParamSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + log.info( + "[{}] Authorization-T validateAuthPromptAndDataFilling passed, templateUri={}, params={}", + agentTag, + templateUri, + filled.data() != null ? filled.data().keySet() : java.util.Set.of()); + } catch (Exception ve) { + log.warn( + "[{}] Authorization-T validate-and-fill rejected ({}); falling back to compliance check", + agentTag, + ve.getMessage()); + try { + PromptComplianceResult result = server.checkTaskPrompt(payloadText); + if (result.success()) { + log.info("[{}] Authorization-T prompt compliance check passed, templateUri={}", + agentTag, templateUri); + } else { + log.warn("[{}] Authorization-T prompt compliance check failed: code={}, message={}", + agentTag, + result.failure() != null ? result.failure().code() : "unknown", + result.failure() != null ? result.failure().message() : "unknown"); + } + } catch (Exception e) { + log.warn("[{}] Authorization-T compliance check error: {}", agentTag, e.getMessage()); + } + } + } } else if (extKeyword.contains("Notification")) { notificationSubscription = payloadText; + A2ATServer server = a2atServer(); + if (server != null) { + try { + net.openan.a2at.sdk.core.model.FilledParamData filled = + server.validateNotificationPromptAndDataFilling( + payloadText, + buildNotificationParamSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates.SUBSCRIBE_INCIDENT); + log.info( + "[{}] Notification-T validateNotificationPromptAndDataFilling" + + " passed, templateUri={}, params={}", + agentTag, + templateUri, + filled.data() != null ? filled.data().keySet() : java.util.Set.of()); + } catch (Exception ve) { + log.warn( + "[{}] Notification-T validate-and-fill rejected ({}); keeping payload as subscription", + agentTag, + ve.getMessage()); + } + } else { + log.info("[{}] Notification-T received, templateUri={}", agentTag, templateUri); + } } log.info( "[{}] Pre-positioned {} received, payload length={}", diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java index cebb38ff..c0befdf1 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java @@ -19,7 +19,7 @@ package dev.openan.workflow.engine.examples.agents; -import dev.openan.workflow.engine.examples.LlmHelper; +import dev.openan.workflow.engine.examples.util.LlmHelper; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.slf4j.Logger; @@ -28,6 +28,8 @@ import java.util.LinkedHashMap; import java.util.Map; +import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; +import dev.openan.workflow.engine.examples.util.EnvResolver; /** * SPN Domain Agent for City1 (City1 OMC). * @@ -41,40 +43,42 @@ public class SpnDomainAgentCity1Executor extends NegotiationBaseAgentExecutor { private static final Logger log = LoggerFactory.getLogger(SpnDomainAgentCity1Executor.class); + // Diagnosis result placed in artifact.metadata[Task-T URI], mirroring spec case 7.1. private static final String FAULT_DIAGNOSIS_RESULT = - "诊断结果:城市1城市OMC诊断结果 - 端口Down,光功率-28dBm(低于阈值),存在故障。\n" - + "修复方案:更换城市1OMC端口光模块,恢复端口Down状态。此修复方案需授权后执行(授权已预置)。\n" - + "故障根因:城市1OMC端口光模块故障。"; - - private static final String RECOVERY_RESULT = "城市1OMC端口光模块已更换,端口恢复Up,专线业务恢复正常。"; + "1. 诊断结果:成功\n" + + "2. 诊断结果详情:P33206-YWHJ-业务汇聚机房1(990)的12-TPJ1EM8F-4端口出现无收光问题," + + "判断原因为对端设备掉电或端口关闭\n" + + "3. 修复建议:恢复供电后重新启动网元,或者重新开启端口"; + + // Recovery (business-preemption) result reported via Notification-T, mirroring spec case 7.9. + private static final String RECOVERY_RESULT = + "### 业务抢通事件\n" + + "1. 业务抢通方案执行状态:已结束\n" + + "2. 投诉诊断任务流水号:9de168c0-6179-4778-8b72-4279582c0a3f\n" + + "3. OSS侧事件流水号:event-id-202606250128\n" + + "4. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17\n" + + "5. 是否已授权OMC自动抢通:是\n" + + "6. 业务抢通方案名称:隧道调优\n" + + "7. 业务抢通方案详情:将受影响的隧道列表调优到新的路径列表,业务恢复。\n" + + "8. 业务抢通方案执行结束时间:2026-05-11T08:31:46Z\n" + + "9. 业务抢通方案执行结果:成功"; private static String llmDiagnosisResult(String input, String fallback) { String env = EnvResolver.resolveEnvPath(); - String sys = "你是SPN领域城市1OMC故障诊断专家。根据输入诊断信息,输出诊断结果、修复方案和故障根因,提及城市1。简洁专业,中文。"; - String user = "输入:\n" + input + "\n\n已知:城市1OMC端口port-7=DOWN,光功率-28dBm(阈值-20dBm)。请输出诊断结论。"; + 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 llmRecoveryResult(String input, String fallback) { String env = EnvResolver.resolveEnvPath(); - String sys = "你是SPN领域城市1OMC抢通执行专家。用一句话报告抢通成功结果,提及城市1OMC端口恢复Up、专线业务恢复。中文。"; - String user = "输入:\n" + input + "\n\n已更换光模块,端口恢复Up。请输出抢通结果。"; + String sys = "你是SPN领域OMC业务抢通执行专家。按业务抢通事件数据格式输出结果," + + "包含执行状态、方案名称、详情、执行结果。中文。"; + String user = "输入:\n" + input + "\n\n已执行隧道调优,业务恢复。请输出业务抢通结果。"; return LlmHelper.text(env, sys, user, fallback); } - private static void sleepBriefly() { - try { - Thread.sleep(300); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - protected boolean needsNegotiation(String input) { - return true; - } - @Override protected String resolveEnvPath() { return EnvResolver.resolveEnvPath(); @@ -97,9 +101,9 @@ private String selfTriggerRecovery(RequestContext ctx, String diagnosisResult) { boolean inWhitelist = policy != null && !policy.isEmpty() - && (policy.contains("业务抢通") - || policy.contains("光模块") - || policy.contains("授权")); + && (policy.contains("业务抢通") + || policy.contains("光模块") + || policy.contains("授权")); if (inWhitelist) { log.info("[SPN-Domain-Agent] Fault in whitelist, self-triggering recovery"); String recoveryResult = llmRecoveryResult(diagnosisResult, RECOVERY_RESULT); @@ -110,14 +114,23 @@ private String selfTriggerRecovery(RequestContext ctx, String diagnosisResult) { return recoveryResult; } log.info("[SPN-Domain-Agent] Fault not in whitelist, refusing recovery"); - String refusalResult = "操作不在白名单内,拒绝执行抢通。"; + String refusalResult = "## 授权操作执行结果\n 授权操作执行结果:失败\n\n## 失败原因\n操作不在授权白名单内,拒绝执行业务抢通。"; pushNotificationResult(refusalResult); return refusalResult; } @Override - protected String defaultNegotiationText() { - return "城市1OMC诊断需要确认客户专线故障的详细端口信息后再执行,请补充。"; + protected net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent buildProposeContent( + String input) { + // Information negotiation: the two missing parameters required to start diagnosis. + return new net.openan.a2at.sdk.negotiation.content.InformationProposeContent( + java.util.List.of( + new net.openan.a2at.sdk.negotiation.content.NegotiationItem( + "接入端口名称", + "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1"), + new net.openan.a2at.sdk.negotiation.content.NegotiationItem( + "投诉分类", "举例:专线质差")), + "以上参数均为必选,缺少无法启动诊断"); } @Override @@ -129,7 +142,7 @@ protected Map buildResponseMetadata(RequestContext ctx, String r @Override protected String buildResultSummary() { - return "SPN专线故障诊断结果"; + return "专线业务投诉诊断任务诊断结果消息"; } @Override diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java index 5eecf2c8..8276ccd6 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java @@ -19,7 +19,7 @@ package dev.openan.workflow.engine.examples.agents; -import dev.openan.workflow.engine.examples.LlmHelper; +import dev.openan.workflow.engine.examples.util.LlmHelper; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.slf4j.Logger; @@ -28,6 +28,8 @@ 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). * @@ -38,29 +40,20 @@ public class SpnDomainAgentCity2Executor extends NegotiationBaseAgentExecutor { private static final Logger log = LoggerFactory.getLogger(SpnDomainAgentCity2Executor.class); + // Diagnosis result placed in artifact.metadata[Task-T URI], mirroring spec case 7.1 (normal). private static final String NORMAL_DIAGNOSIS_RESULT = - "诊断结果:城市2城市OMC诊断结果 - 端口状态正常,光功率-17dBm(正常范围),无异常告警。\n" - + "修复建议:城市2城市无需修复,故障不在此地市。\n" - + "故障根因:无根因(此地市正常)。"; - - private static final String RECOVERY_RESULT = "城市2侧OMC抢通完成,业务恢复正常。"; + "1. 诊断结果:成功\n" + + "2. 诊断结果详情:城市2OMC端口状态正常,光功率-17dBm(正常范围),无异常告警,故障不在此地市\n" + + "3. 修复建议:城市2无需修复"; private static String llmDiagnosisResult(String input, String fallback) { String env = EnvResolver.resolveEnvPath(); - String sys = - "你是SPN领域城市2OMC故障诊断专家。根据输入诊断信息,城市2侧端口正常、光功率-17dBm(正常范围)、无异常告警。输出诊断结果:城市2无需修复、故障不在此地市。简洁专业,中文。"; + String sys = "你是SPN领域OMC故障诊断专家。按如下结构输出:1. 诊断结果(成功/失败);" + + "2. 诊断结果详情;3. 修复建议。城市2侧端口正常、无故障。简洁专业,中文。"; String user = "输入:\n" + input + "\n\n城市2OMC端口port-3=UP,光功率-17dBm,无告警。请输出诊断结论。"; return LlmHelper.text(env, sys, user, fallback); } - private static void sleepBriefly() { - try { - Thread.sleep(300); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - @Override protected String resolveEnvPath() { return EnvResolver.resolveEnvPath(); @@ -74,11 +67,6 @@ protected String executeBusiness(RequestContext ctx, AgentEmitter emitter, Strin return result; } - @Override - protected String defaultNegotiationText() { - return "城市2OMC诊断需要确认客户专线故障是否涉及城市2侧端口,请补充。"; - } - @Override protected Map buildResponseMetadata(RequestContext ctx, String response) { Map metadata = new LinkedHashMap<>(); @@ -88,7 +76,7 @@ protected Map buildResponseMetadata(RequestContext ctx, String r @Override protected String buildResultSummary() { - return "SPN专线故障诊断结果"; + return "专线业务投诉诊断任务诊断结果消息"; } @Override diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java new file mode 100644 index 00000000..5cf6aa81 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.agents; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.client.A2AJavaClientRuntime; +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 dev.openan.workflow.engine.examples.agents.TransportWorkbenchAgentExecutor; +import dev.openan.workflow.engine.examples.workbench.WorkbenchOrchestrator; + +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.TextPart; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import dev.openan.workflow.engine.examples.gateway.ClientRuntimeFactory; +import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; +/** + * Spring-managed Workbench AgentExecutor. + * + *

Same business logic as {@link + * TransportWorkbenchAgentExecutor} -- receives a Task-T from + * the upper layer, delegates to {@link WorkbenchOrchestrator} for the full pipeline, and returns + * the result. The difference is purely the server container: this runs inside Spring Boot instead + * of the JDK HttpServer-based {@code JdkHttpA2AServer}. + */ +@Component +public class SpringWorkbenchExecutor extends BaseAgentExecutor { + private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchExecutor.class); + + private final ClientRuntimeFactory runtimeFactory; + private final WorkbenchClientProperties properties; + + public SpringWorkbenchExecutor( + ClientRuntimeFactory runtimeFactory, WorkbenchClientProperties properties) { + this.runtimeFactory = runtimeFactory; + this.properties = properties; + } + + private String resolveEnvPath() { + if (properties.getA2atEnvPath() != null && !properties.getA2atEnvPath().isBlank()) { + return properties.getA2atEnvPath(); + } + return EnvResolver.resolveEnvPath(); + } + + private String resolveCredentialsPath() { + if (properties.getCredentialsPath() != null + && !properties.getCredentialsPath().isBlank()) { + return properties.getCredentialsPath(); + } + return getClass().getClassLoader().getResource("spn_agent_credentials.json") != null + ? "classpath:spn_agent_credentials.json" + : null; + } + + private A2AJavaClientRuntime createClientRuntime() { + A2AJavaClientRuntime runtime = runtimeFactory.create(); + log.info( + "[SpringWorkbench] TRANSPORT_MODE mode={}, runtime={}", + runtimeFactory.mode(), + runtime != null ? runtime.getClass().getSimpleName() : "DefaultA2AJavaClientRuntime"); + return runtime; + } + + @Override + public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { + String taskId = ctx.getTaskId(); + String contextId = ctx.getContextId(); + String input = extractText(ctx.getMessage()); + long started = System.nanoTime(); + log.info( + "[SpringWorkbench] TASK_START taskId={}, contextId={}, inputChars={}, " + + "orchUrl={}, transportMode={}, sslVerify={}", + taskId, + contextId, + input.length(), + properties.getOrchUrl(), + runtimeFactory.mode(), + properties.isSslVerify()); + + emitter.submit(buildStatusMessage(contextId, taskId, "Task received")); + emitter.startWork(buildStatusMessage(contextId, taskId, "Processing")); + + try { + String result = + new WorkbenchOrchestrator( + properties.getOrchUrl(), + resolveCredentialsPath(), + properties.isSslVerify(), + resolveEnvPath(), + createClientRuntime()) + .run(input); + Map metadata = new LinkedHashMap<>(); + metadata.put(A2ATExtension.TASK_T.uri(), result); + List> parts = List.of(new TextPart("SPN cross-city fault diagnosis summary")); + emitter.addArtifact( + parts, "result", "cross-city-diagnosis-summary", metadata, false, true); + emitter.complete(buildStatusMessage(contextId, taskId, "Completed")); + log.info( + "[SpringWorkbench] TASK_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())); + } + } + + @Override + public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { + log.warn( + "[SpringWorkbench] TASK_CANCEL taskId={}, contextId={}", + ctx.getTaskId(), + ctx.getContextId()); + emitter.cancel(); + } + + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/TransportWorkbenchAgentExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/TransportWorkbenchAgentExecutor.java index 06f47d0a..3f12672a 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/TransportWorkbenchAgentExecutor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/TransportWorkbenchAgentExecutor.java @@ -31,6 +31,9 @@ import java.util.List; import java.util.Map; +import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; +import dev.openan.workflow.engine.examples.workbench.WorkbenchExtensionLifecycle; +import dev.openan.workflow.engine.examples.workbench.WorkbenchOrchestrator; /** * Transport Workbench Agent - the orchestrator. * @@ -39,7 +42,7 @@ * result. This class does NOT contain workflow logic, pre-positioning, or negotiation strategy -- * those live in dedicated classes. */ -public class TransportWorkbenchAgentExecutor extends BaseAgentExecutor { +public class TransportWorkbenchAgentExecutor extends BaseAgentExecutor implements AutoCloseable { private static final Logger log = LoggerFactory.getLogger(TransportWorkbenchAgentExecutor.class); @@ -47,6 +50,7 @@ public class TransportWorkbenchAgentExecutor extends BaseAgentExecutor { private final String credentialsPath; private final boolean sslVerify; private final String a2atEnvPath; + private final WorkbenchExtensionLifecycle extensionLifecycle; public TransportWorkbenchAgentExecutor( String registryUrl, String orchUrl, String credentialsPath, boolean sslVerify) { @@ -59,10 +63,30 @@ public TransportWorkbenchAgentExecutor( String credentialsPath, boolean sslVerify, String a2atEnvPath) { + // registryUrl is retained for source compatibility; orchUrl is the unified registry API. this.orchUrl = orchUrl; this.credentialsPath = credentialsPath; this.sslVerify = sslVerify; this.a2atEnvPath = a2atEnvPath; + this.extensionLifecycle = + new WorkbenchExtensionLifecycle( + credentialsPath, + sslVerify, + a2atEnvPath, + () -> null, + data -> + log.info( + "[Notification] EVENT scope=workbench, agent={}, state={}, textChars={}", + data.get("agent"), + data.get("state"), + data.get("text") != null + ? String.valueOf(data.get("text")).length() + : 0)); + } + + /** Establish persistent workbench-level pre-positioning after all agent servers are ready. */ + public void startExtensions() { + extensionLifecycle.start(); } @Override @@ -74,6 +98,7 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { emitter.submit(buildStatusMessage(contextId, taskId, "Task received")); emitter.startWork(buildStatusMessage(contextId, taskId, "Processing")); try { + startExtensions(); String result = new WorkbenchOrchestrator(orchUrl, credentialsPath, sslVerify, a2atEnvPath) .run(input); @@ -94,4 +119,9 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { emitter.cancel(); } + + @Override + public void close() { + extensionLifecycle.close(); + } } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchControlPoint.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchControlPoint.java index 49a5b5a4..8421ee2b 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchControlPoint.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchControlPoint.java @@ -22,6 +22,8 @@ import dev.openan.workflow.engine.examples.LlmHelper; import dev.openan.workflow.engine.client.WorkflowEngineClient; import dev.openan.workflow.engine.control.DefaultControlPoint; +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; import dev.openan.workflow.engine.model.JumpCondition; import dev.openan.workflow.engine.model.RouteDecision; import dev.openan.workflow.engine.model.TaskRequest; @@ -30,6 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -67,19 +70,33 @@ public WorkbenchControlPoint(String a2atEnvPath, NegotiationStrategy negotiation } private static String analyzeFaultLocation(String messageText) { + // Identify City1 fault by spec case 7.1 diagnostic-detail fault signatures, since the + // diagnosis text now follows the document format (no longer contains the "城市1" keyword). boolean hasCity1Fault = - messageText.contains("城市1") - && (messageText.contains("故障") || messageText.contains("Down")); + messageText.contains("无收光") + || messageText.contains("掉电") + || messageText.contains("端口关闭") + || messageText.contains("端口Down") + || (messageText.contains("城市1") + && (messageText.contains("故障") || messageText.contains("Down"))); boolean hasCity2Fault = messageText.contains("城市2") && (messageText.contains("故障") || messageText.contains("Down")); if (hasCity1Fault) { - return "汇总分析完成。故障定位:城市1地市OMC,端口Down,光功率-28dBm低于阈值。"; + // Merge result mirroring spec case 7.1 diagnosis summary in Task-T metadata. + return "1. 诊断结果:成功\n" + + "2. 诊断结果详情:汇总分析完成。故障定位:城市1地市OMC," + + "P33206-YWHJ-业务汇聚机房1(990)的12-TPJ1EM8F-4端口出现无收光问题," + + "判断原因为对端设备掉电或端口关闭\n" + + "3. 修复建议:恢复供电后重新启动网元,或者重新开启端口"; } if (hasCity2Fault) { - return "汇总分析完成。故障定位:城市2地市OMC,需排查。"; + return "1. 诊断结果:成功\n" + + "2. 诊断结果详情:汇总分析完成。城市2地市OMC端口状态正常," + + "光功率-17dBm(正常范围),无异常告警,故障不在此地市\n" + + "3. 修复建议:城市2无需修复"; } - return "汇总分析完成。两地市均未见异常。"; + return "1. 诊断结果:成功\n2. 诊断结果详情:汇总分析完成。两地市均未见异常。\n3. 修复建议:无需修复"; } /** @@ -89,41 +106,51 @@ private static String analyzeFaultLocation(String messageText) { */ private static String buildTargetedTaskMessage(String step) { return switch (step) { - case "diagnosis_city1" -> buildCity1Task(); - case "diagnosis_city2" -> buildCity2Task(); - default -> "请执行SPN专线故障诊断任务。"; + case "diagnosis_city1" -> buildCity1Task(); + case "diagnosis_city2" -> buildCity2Task(); + default -> "创建专线业务投诉诊断任务"; }; } private static String buildCity1Task() { - return -""" -## 任务 -SPN专线故障诊断 - 城市1OMC侧 - -## 故障参数 -- 端口状态:port-7 = DOWN -- 光功率:-28dBm(阈值-20dBm) -- 所属单板:line-card-03 -- 端口号:port-7 - -请针对城市1OMC侧端口故障进行诊断。请用中文回复。""" - .stripIndent(); + // Task-T structured prompt, mirroring spec case 7.3 (missing params, triggers negotiation). + return "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" + + "## 任务描述(Task Description)\n" + + "基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, " + + "达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n" + + "## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n" + + "## 任务对象(Task Object)\n接入端口名称:\n\n" + + "## 任务上下文(Task Context)\n" + + "1. 投诉分类:\n" + + "2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n" + + "3. OSS侧事件流水号:\"event-id-20260511-09013\"\n" + + "4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," + + "访问广州机房的核心交易系统非常慢。\"\n\n" + + "## 预期输出(Expected Output)\n" + + "1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n" + + "2. 诊断结果详情;(必选)\n" + + "3. 修复建议;(可选)\n" + + "4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"; } private static String buildCity2Task() { - return -""" -## 任务 -SPN专线故障诊断 - 城市2OMC侧 - -## 排查要求 -- 排查城市2OMC端口状态是否正常 -- 确认光功率是否在正常范围 -- 确认是否存在异常告警 - -请针对城市2OMC侧进行排查。请用中文回复。""" - .stripIndent(); + return "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" + + "## 任务描述(Task Description)\n" + + "基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, " + + "达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n" + + "## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n" + + "## 任务对象(Task Object)\n接入端口名称:P882-珠江新城-PTN7900-23-TPA1EG24-11\n\n" + + "## 任务上下文(Task Context)\n" + + "1. 投诉分类:\"专线质差\"\n" + + "2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n" + + "3. OSS侧事件流水号:\"event-id-20260511-09013\"\n" + + "4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," + + "访问广州机房的核心交易系统非常慢。\"\n\n" + + "## 预期输出(Expected Output)\n" + + "1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n" + + "2. 诊断结果详情;(必选)\n" + + "3. 修复建议;(可选)\n" + + "4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"; } @Override @@ -132,8 +159,19 @@ public CompletableFuture onTask( String step = request.getStepName(); String agentName = request.getAgentName(); String targetedMessage = buildTargetedTaskMessage(step); + // Send the Task-T structured prompt via message metadata (spec §6), so the OMC agent + // reads it from message.metadata[Task-T URI] exactly like spec cases 7.1/7.3. + Map metadata = new LinkedHashMap<>(); + metadata.put(NegotiationUtils.TASK_PROMPT_KEY, targetedMessage); + // For diagnosis_city1, activate Negotiation-T extension (spec case 7.3: missing params + // trigger negotiation). The ExtensionInterceptor adds it to the A2A-Extensions header + // when the metadata key is present. + if ("diagnosis_city1".equals(step)) { + metadata.put(NegotiationUtils.NEGOTIATION_T_URI, ""); + } + String partsText = "创建专线业务投诉诊断任务"; return engineClient - .sendMessage(agentName, targetedMessage) + .sendMessage(agentName, partsText, null, metadata) .thenApply( r -> { boolean success = r.getText() != null && !r.getText().isEmpty(); @@ -166,7 +204,7 @@ public CompletableFuture onSelfTask(TaskRequest request) { request.getAgentName()); String message = request.getMessage(); String fallback = analyzeFaultLocation(message); - String sys = "你是SPN跨城故障协同诊断汇总专家。根据两地市OMC诊断结果,输出故障定位结论。简洁专业,中文。"; + String sys = "你是SPN跨城故障协同诊断汇总专家。按如下结构输出:1. 诊断结果;2. 诊断结果详情;3. 修复建议。简洁专业,中文。"; String result = LlmHelper.text(a2atEnvPath, sys, message, fallback); log.info("[onSelfTask] Merge result ({} chars): {}", result.length(), result); return CompletableFuture.completedFuture( diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/demo/A2ATProtocolCases.java b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/A2ATProtocolCases.java new file mode 100644 index 00000000..4fd0e7b0 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/A2ATProtocolCases.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.demo; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.client.A2ATransport; +import dev.openan.workflow.engine.client.DefaultExtensionSender; +import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; +import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; +import dev.openan.workflow.engine.client.AgentCardJacksonModule; +import dev.openan.workflow.engine.model.SendMessageResult; +import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; +import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; +import dev.openan.workflow.engine.examples.util.EnvResolver; + +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * 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 ObjectMapper mapper = new ObjectMapper().registerModule(new AgentCardJacksonModule()); + + private static final String TASK_T_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; + + 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 JdkHttpA2AServer omcServer; + private A2ATransport transport; + private DefaultWorkflowEngineClient client; + private DefaultExtensionSender sender; + private final AtomicReference lastTaskId = new AtomicReference<>(); + + public static void main(String[] args) throws Exception { + System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); + 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_10(); case_7_11(); + } else { + switch (caseId) { + case "0" -> case_0(); + case "7.1" -> case_7_1(); + case "7.2" -> case_7_2(); + case "7.3" -> case_7_3(); + case "7.4" -> case_7_4(); + case "7.5" -> case_7_5(); + case "7.6" -> case_7_6(); + case "7.7" -> case_7_7(); + case "7.8" -> case_7_8(); + case "7.9" -> case_7_9(); + case "7.10" -> case_7_10(); + case "7.11" -> case_7_11(); + default -> log.warn("[ProtocolCases] Unknown case: {}", caseId); + } + } + } finally { teardown(); } + log.info("[ProtocolCases] DONE"); + } + + private void setup() throws Exception { + String path = getClass().getClassLoader().getResource("agentcard/spn_domain_agent_city1.json").getPath(); + @SuppressWarnings("unchecked") + Map cardMap = mapper.readValue(new File(path), Map.class); + omcServer = new JdkHttpA2AServer("127.0.0.1", 26335, cardMap, new SpnDomainAgentCity1Executor()); + omcServer.start(); + TimeUnit.SECONDS.sleep(STARTUP_WAIT); + log.info("[ProtocolCases] OMC agent started on https://127.0.0.1:26335"); + + AgentCard agentCard = mapper.readValue(new File(path), AgentCard.class); + String credPath = getClass().getClassLoader().getResource("spn_agent_credentials.json").getPath(); + String envPath = EnvResolver.resolveEnvPath(); + transport = new A2ATransport(List.of(agentCard), null, + WorkflowEngineClientConfig.builder().sslVerify(false).a2atEnvPath(envPath).credentialsConfigPath(credPath).build()); + client = new DefaultWorkflowEngineClient(transport); + sender = new DefaultExtensionSender(transport); + } + + private void teardown() { + if (client != null) client.close(); + if (transport != null) transport.close(); + if (omcServer != null) { try { omcServer.close(); } catch (Exception e) { log.warn("OMC close: {}", e.getMessage()); } } + } + + /** Case 0: enumerate available A2A-T templates via the engine's template-query surface. */ + private void case_0() { + log.info("[Case 0] Enumerate A2A-T templates"); + var prompts = client.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 = client.getNegotiationPrompts(); + log.info("[Case 0] Negotiation templates: {}", negPrompts.size()); + for (var t : negPrompts) { + log.info("[Case 0] {} -> {}", t.templateUri().uri(), t.description()); + } + var one = client.getPrompt(net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT); + log.info("[Case 0] getTaskTemplate(private-line-complaint): present={}", one.isPresent()); + } + + private void case_7_1() throws Exception { + log.info("[Case 7.1] Create diagnosis task (Task-T)"); + Map metadata = new LinkedHashMap<>(); + metadata.put(TASK_T_URI, "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n## 任务描述(Task Description)\n基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n## 任务对象(Task Object)\n接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17\n\n## 任务上下文(Task Context)\n1. 投诉分类:\"专线质差\"\n2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n3. OSS侧事件流水号:\"event-id-20260511-09013\"\n4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢。\"\n\n## 预期输出(Expected Output)\n1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n2. 诊断结果详情;(必选)\n3. 修复建议;(可选)\n4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"); + SendMessageResult result = client.sendMessage(AGENT_NAME, "创建专线业务投诉诊断任务", null, metadata).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 void case_7_3() { + log.info("[Case 7.3] Negotiation - param missing (Task-T + Negotiation-T)"); + Map metadata = new LinkedHashMap<>(); + metadata.put(TASK_T_URI, "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n## 任务描述(Task Description)\n基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n## 任务对象(Task Object)\n接入端口名称:\n\n## 任务上下文(Task Context)\n1. 投诉分类:\n2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n3. OSS侧事件流水号:\"event-id-20260511-09013\"\n4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢。\"\n\n## 预期输出(Expected Output)\n1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n2. 诊断结果详情;(必选)\n3. 修复建议;(可选)\n4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"); + metadata.put(NEGOTIATION_T_URI, ""); // activate Negotiation-T extension + SendMessageResult result = client.sendMessage(AGENT_NAME, "创建专线业务投诉诊断任务(参数缺失)", null, metadata).join(); + log.info("[Case 7.3] state={}, metaKeys={}", result.getTaskState(), result.getMetadata() != null ? result.getMetadata().keySet() : "none"); + } + + private void case_7_4() { + log.info("[Case 7.4] Negotiation - semantic error (Task-T + Negotiation-T)"); + Map metadata = new LinkedHashMap<>(); + metadata.put(TASK_T_URI, "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n## 任务描述(Task Description)\n基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n## 任务对象(Task Object)\n接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-18\n\n## 任务上下文(Task Context)\n1. 投诉分类:\"专线质差\"\n2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n3. OSS侧事件流水号:\"event-id-20260511-09013\"\n4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢。\"\n\n## 预期输出(Expected Output)\n1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n2. 诊断结果详情;(必选)\n3. 修复建议;(可选)\n4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"); + metadata.put(NEGOTIATION_T_URI, ""); // activate Negotiation-T extension + SendMessageResult result = client.sendMessage(AGENT_NAME, "创建专线业务投诉诊断任务(语义错误)", null, metadata).join(); + 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)"); + String authPrompt = + "## 授权策略的操作类型\n新增授权策略\n\n" + + "## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作," + + "按照<预期输出>中定义的结构返回授权策略的操作执行结果。\n\n" + + "## 动网操作的授权策略列表\n" + + " - 动网操作的授权策略1\n" + + " - 动网操作的业务场景:业务投诉诊断\n" + + " - 动网操作的处置类型:业务抢通\n" + + " - 动网操作名称:隧道调优\n" + + " - 有效期:2026-06-01T12:00:00Z ~ 2030-06-18T12:00:00Z\n\n" + + "## 预期输出\n" + + "1. 授权操作执行结果,取值范围:成功、失败、部分成功;\n" + + "2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n" + + "3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;"; + SendMessageResult result = sender.sendExtensionMessage(AGENT_NAME, "新增动网操作授权", authPrompt, A2ATExtension.AUTHORIZATION_T).join(); + log.info("[Case 7.5] state={}", result.getTaskState()); + } + + private void case_7_6() { + log.info("[Case 7.6] Delete authorization (Authorization-T)"); + String delPrompt = + "## 授权策略的操作类型\n删除授权策略\n\n" + + "## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作," + + "按照<预期输出>中定义的结构返回授权策略的操作执行结果。\n\n" + + "## 动网操作的授权策略列表\n" + + " - 动网操作的授权策略1\n" + + " - 动网操作的授权策略标识:7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n\n" + + "## 预期输出\n" + + "1. 授权操作执行结果,取值范围:成功、失败、部分成功;\n" + + "2. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;"; + SendMessageResult result = sender.sendExtensionMessage(AGENT_NAME, "删除动网操作的授权", delPrompt, A2ATExtension.AUTHORIZATION_T).join(); + log.info("[Case 7.6] state={}", result.getTaskState()); + } + + private void case_7_7() { + log.info("[Case 7.7] Query authorization (Authorization-T)"); + String queryPrompt = + "## 授权策略的操作类型\n查询授权策略\n\n" + + "## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作," + + "按照<预期输出>中定义的结构返回授权策略的操作执行结果。\n\n" + + "## 动网操作的授权策略列表\n" + + " - 动网操作的授权策略1\n" + + " - 动网操作的业务场景:业务投诉诊断\n" + + " - 动网操作的处置类型:业务抢通\n" + + " - 动网操作名称:隧道调优\n\n" + + "## 预期输出\n" + + "1. 授权操作执行结果,取值范围:成功、失败;\n" + + "2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n" + + "3. 授权操作执行失败时,返回失败原因;"; + SendMessageResult result = sender.sendExtensionMessage(AGENT_NAME, "查询业务抢通操作授权信息", queryPrompt, A2ATExtension.AUTHORIZATION_T).join(); + log.info("[Case 7.7] state={}", result.getTaskState()); + } + + private void case_7_8() { + log.info("[Case 7.8] Subscribe notification (Notification-T)"); + String notifPrompt = + "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息," + + "完成网络侧业务抢通事件的订阅与上报任务。\n\n" + + "## 通知主题\n业务抢通事件\n\n" + + "## 订阅条件\n\n" + + "## 上报通知数据格式\n### 业务抢通事件\n" + + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束(必选)\n" + + "2. 投诉诊断任务流水号(必选)\n" + + "3. OSS侧事件流水号(必选)\n" + + "4. 接入端口名称(必选)\n" + + "5. 是否已授权OMC自动抢通,取值范围:是、否;举例:是(必选)\n" + + "6. 业务抢通方案名称(必选)\n" + + "7. 业务抢通方案详情(必选)\n" + + "8. 业务抢通方案执行结束时间(可选)\n" + + "9. 业务抢通方案执行结果,取值范围:成功、失败(可选)\n" + + "10. 业务抢通方案执行失败原因(可选)\n\n" + + "## 预期输出\n" + + "1. 订阅结果,取值范围:成功、失败\n" + + "2. 订阅失败原因(可选)\n" + + "3. 订阅成功后,按照<上报通知数据格式>上报消息"; + SendMessageResult result = sender.sendNotification(AGENT_NAME, "订阅业务抢通事件", notifPrompt, data -> log.info("[Case 7.8] callback: keys={}", data.keySet())).join(); + 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)"); + log.info("[Case 7.9] Waiting 5s for pushed events..."); + TimeUnit.SECONDS.sleep(5); + log.info("[Case 7.9] Done"); + } + + private void case_7_10() { + String taskId = lastTaskId.get(); + if (taskId == null) { log.info("[Case 7.10] No task, skipping"); return; } + log.info("[Case 7.10] Cancel subscription: {}", taskId); + SendMessageResult result = client.cancelTask(AGENT_NAME, taskId).join(); + log.info("[Case 7.10] state={}", result.getTaskState()); + } + + private void case_7_11() { + String taskId = lastTaskId.get(); + if (taskId == null) { log.info("[Case 7.11] No task, skipping"); return; } + log.info("[Case 7.11] Reconnect: {}", taskId); + SendMessageResult result = client.subscribeToTask(AGENT_NAME, taskId, data -> log.info("[Case 7.11] callback: keys={}", data.keySet())).join(); + log.info("[Case 7.11] state={}", result.getTaskState()); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositioner.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositioner.java new file mode 100644 index 00000000..e8bea394 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositioner.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.extension; + +import dev.openan.workflow.engine.client.ExtensionSender; + +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.concurrent.TimeUnit; + +/** + * Pre-positions Authorization-T and Notification-T to downstream agents. + * + *

Single responsibility: send the two pre-positioning control messages (one-shot whitelist + * policy + long-lived result subscription) to each non-workbench agent. The workbench extension + * lifecycle calls this once; individual workflow tasks do not own or repeat these operations. + */ +public class ExtensionPrePositioner { + + private static final Logger log = LoggerFactory.getLogger(ExtensionPrePositioner.class); + + private final String authInput; + private final String notifInput; + + public ExtensionPrePositioner() { + // Authorization-T structured prompt, mirroring spec case 7.5 (add authorization). + this.authInput = + "## 授权策略的操作类型\n新增授权策略\n\n" + + "## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作," + + "按照<预期输出>中定义的结构返回授权策略的操作执行结果。\n\n" + + "## 动网操作的授权策略列表\n" + + " - 动网操作的授权策略1\n" + + " - 动网操作的业务场景:业务投诉诊断\n" + + " - 动网操作的处置类型:业务抢通\n" + + " - 动网操作名称:隧道调优\n" + + " - 有效期:2026-06-01T12:00:00Z ~ 2030-06-18T12:00:00Z\n\n" + + "## 预期输出\n" + + "1. 授权操作执行结果,取值范围:成功、失败、部分成功;\n" + + "2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n" + + "3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;"; + // Notification-T structured prompt, mirroring spec case 7.8 (subscribe). + this.notifInput = + "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息," + + "完成网络侧业务抢通事件的订阅与上报任务。\n\n" + + "## 通知主题\n业务抢通事件\n\n" + + "## 订阅条件\n\n" + + "## 上报通知数据格式\n### 业务抢通事件\n" + + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束(必选)\n" + + "2. 投诉诊断任务流水号(必选)\n" + + "3. OSS侧事件流水号(必选)\n" + + "4. 接入端口名称(必选)\n" + + "5. 是否已授权OMC自动抢通,取值范围:是、否;举例:是(必选)\n" + + "6. 业务抢通方案名称(必选)\n" + + "7. 业务抢通方案详情(必选)\n" + + "8. 业务抢通方案执行结束时间(可选)\n" + + "9. 业务抢通方案执行结果,取值范围:成功、失败(可选)\n" + + "10. 业务抢通方案执行失败原因(可选)\n\n" + + "## 预期输出\n" + + "1. 订阅结果,取值范围:成功、失败\n" + + "2. 订阅失败原因(可选)\n" + + "3. 订阅成功后,按照<上报通知数据格式>上报消息"; + } + + /** + * Pre-position Authorization-T + Notification-T to every non-workbench agent. + * + *

When {@code notificationCallback} is non-null, subsequent events pushed by agents + * through the Notification-T SSE stream (e.g. recovery results) are forwarded to the + * callback. The callback receives a Map with keys: agent, text, metadata, state. + */ + public void prePosition( + ExtensionSender sender, + List agentCards, + Consumer> notificationCallback) { + long allStarted = System.nanoTime(); + int targetCount = 0; + for (AgentCard card : agentCards) { + if (!card.name().contains("Workbench")) { + targetCount++; + } + } + log.info( + "[PrePosition] START targetAgents={}, notificationCallback={}", + targetCount, + notificationCallback != null); + for (AgentCard card : agentCards) { + String name = card.name(); + if (name.contains("Workbench")) { + continue; + } + long agentStarted = System.nanoTime(); + try { + long operationStarted = System.nanoTime(); + log.info("[PrePosition] SEND extension=Authorization-T, agent={}", name); + var authResult = + sender.sendAuthorization(name, "新增动网操作授权", authInput).join(); + log.info( + "[PrePosition] ACK extension=Authorization-T, agent={}, state={}, " + + "responseChars={}, elapsedMs={}", + name, + authResult.getTaskState(), + authResult.getText() != null ? authResult.getText().length() : 0, + elapsedMillis(operationStarted)); + + operationStarted = System.nanoTime(); + log.info("[PrePosition] SEND extension=Notification-T, agent={}", name); + var notificationResult = + sender.sendNotification( + name, + "订阅业务抢通事件", + notifInput, + notificationCallback) + .join(); + log.info( + "[PrePosition] ACK extension=Notification-T, agent={}, state={}, " + + "responseChars={}, elapsedMs={}", + name, + notificationResult.getTaskState(), + notificationResult.getText() != null + ? notificationResult.getText().length() + : 0, + elapsedMillis(operationStarted)); + log.info( + "[PrePosition] AGENT_DONE agent={}, elapsedMs={}", + name, + elapsedMillis(agentStarted)); + } catch (RuntimeException e) { + log.error( + "[PrePosition] AGENT_FAILED agent={}, elapsedMs={}, errorType={}, message={}", + name, + elapsedMillis(agentStarted), + e.getClass().getSimpleName(), + e.getMessage(), + e); + throw e; + } + } + log.info( + "[PrePosition] DONE targetAgents={}, elapsedMs={}", + targetCount, + elapsedMillis(allStarted)); + } + + /** Pre-position without Notification-T event callback. */ + public void prePosition(ExtensionSender sender, List agentCards) { + prePosition(sender, agentCards, null); + } + + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandler.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandler.java new file mode 100644 index 00000000..01949f0f --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandler.java @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.examples.extension; + +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatus; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; + +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import java.nio.file.Path; +import org.a2aproject.sdk.spec.TextPart; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import dev.openan.workflow.engine.examples.agents.BaseAgentExecutor; +import dev.openan.workflow.engine.examples.agents.NegotiationBaseAgentExecutor; +/** + * Handles pre-positioned Authorization-T / Notification-T messages on the agent server side. + * + *

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

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

Extracted from {@code NegotiationBaseAgentExecutor} so that the latter only carries + * Negotiation-T responsibility. + */ +public class PrePositionedExtensionHandler { + private static final Logger log = LoggerFactory.getLogger(PrePositionedExtensionHandler.class); + private volatile String authorizationPolicy; + private volatile String notificationSubscription; + private volatile A2ATServer a2atServer; + + private A2ATServer a2atServer() { + if (a2atServer != null) return a2atServer; + if (Boolean.getBoolean("a2at.llm.disabled")) return null; + String env = System.getProperty("A2AT_ENV_PATH", System.getenv("A2AT_ENV_PATH")); + if (env == null || env.isBlank()) return null; + try { + a2atServer = new A2ATServer(Path.of(env)); + return a2atServer; + } catch (Exception e) { + log.warn("A2ATServer init failed: {}", e.getMessage()); + return null; + } + } + + /** + * Detect whether the incoming message carries an Authorization-T or Notification-T extension in + * its metadata. Returns the extension keyword ("Authorization-T" or "Notification-T"), or null + * for a normal task. + */ + public static String detect(RequestContext ctx) { + Message msg = ctx.getMessage(); + if (msg == null || msg.metadata() == null) { + return null; + } + for (String key : msg.metadata().keySet()) { + if (key.contains("Authorization-T")) { + return "Authorization-T"; + } + if (key.contains("Notification-T")) { + return "Notification-T"; + } + } + return null; + } + + private static void emitStatus( + AgentEmitter emitter, + TaskState state, + String contextId, + String taskId, + String text, + Map metadata) { + TaskStatus status = + new TaskStatus( + state, BaseAgentExecutor.buildStatusMessage(contextId, taskId, text), null); + TaskStatusUpdateEvent event = + TaskStatusUpdateEvent.builder() + .taskId(taskId) + .contextId(contextId) + .status(status) + .metadata(metadata) + .build(); + emitter.emitEvent(event); + } + + /** The pre-positioned Authorization-T whitelist policy text, or null. */ + public String getAuthorizationPolicy() { + return authorizationPolicy; + } + + /** The pre-positioned Notification-T subscription text, or null. */ + public String getNotificationSubscription() { + return notificationSubscription; + } + + /** + * Parameter JSON Schema for the Authorization-T validate-and-fill pipeline: the whitelist + * policy fields the agent wants extracted from a rendered authorization prompt. + */ + private static Map buildAuthParamSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("授权策略的操作类型", Map.of("type", "string")); + properties.put("动网操作的授权策略列表", Map.of("type", "string")); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + return schema; + } + + /** + * Parameter JSON Schema for the Notification-T validate-and-fill pipeline: the subscription + * fields extracted from a rendered subscribe-incident prompt. + */ + private static Map buildNotificationParamSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("通知主题", Map.of("type", "string")); + properties.put("订阅条件", Map.of("type", "string")); + properties.put("上报通知数据格式", Map.of("type", "string")); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + return schema; + } + + /** + * Handle a pre-positioned message: store the payload, emit an ACK artifact, and complete the + * task. + * + * @param agentTag short agent class name for logging + */ + public void handle( + RequestContext ctx, AgentEmitter emitter, String extKeyword, String agentTag) { + String taskId = ctx.getTaskId(); + String contextId = ctx.getContextId(); + String payloadText = + ctx.getMessage().metadata().entrySet().stream() + .filter(e -> e.getKey().contains(extKeyword)) + .map(Map.Entry::getValue) + .findFirst() + .map(v -> v instanceof String s ? s : String.valueOf(v)) + .orElse(""); + // Extract templateUri from metadata if present (sent by SDK prompt generation) + String templateUri = null; + if (ctx.getMessage().metadata() != null) { + Object tu = ctx.getMessage().metadata().get(MetadataContent.TEMPLATE_URI_METADATA_KEY); + if (tu instanceof String s) templateUri = s; + } + if (extKeyword.contains("Authorization")) { + authorizationPolicy = payloadText; + // Full validate-and-fill pipeline: rule gate + LLM semantic validation + param + // extraction, falling back to the lightweight compliance check. + A2ATServer server = a2atServer(); + if (server != null) { + try { + net.openan.a2at.sdk.core.model.FilledParamData filled = + server.validateAuthPromptAndDataFilling( + payloadText, + buildAuthParamSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + log.info( + "[{}] Authorization-T validateAuthPromptAndDataFilling passed, templateUri={}, params={}", + agentTag, + templateUri, + filled.data() != null ? filled.data().keySet() : java.util.Set.of()); + } catch (Exception ve) { + log.warn( + "[{}] Authorization-T validate-and-fill rejected ({}); falling back to compliance check", + agentTag, + ve.getMessage()); + try { + PromptComplianceResult result = server.checkTaskPrompt(payloadText); + if (result.success()) { + log.info("[{}] Authorization-T prompt compliance check passed, templateUri={}", + agentTag, templateUri); + } else { + log.warn("[{}] Authorization-T prompt compliance check failed: code={}, message={}", + agentTag, + result.failure() != null ? result.failure().code() : "unknown", + result.failure() != null ? result.failure().message() : "unknown"); + } + } catch (Exception e) { + log.warn("[{}] Authorization-T compliance check error: {}", agentTag, e.getMessage()); + } + } + } + } else if (extKeyword.contains("Notification")) { + notificationSubscription = payloadText; + A2ATServer server = a2atServer(); + if (server != null) { + try { + net.openan.a2at.sdk.core.model.FilledParamData filled = + server.validateNotificationPromptAndDataFilling( + payloadText, + buildNotificationParamSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates.SUBSCRIBE_INCIDENT); + log.info( + "[{}] Notification-T validateNotificationPromptAndDataFilling" + + " passed, templateUri={}, params={}", + agentTag, + templateUri, + filled.data() != null ? filled.data().keySet() : java.util.Set.of()); + } catch (Exception ve) { + log.warn( + "[{}] Notification-T validate-and-fill rejected ({}); keeping payload as subscription", + agentTag, + ve.getMessage()); + } + } else { + log.info("[{}] Notification-T received, templateUri={}", agentTag, templateUri); + } + } + log.info( + "[{}] Pre-positioned {} received, payload length={}", + agentTag, + extKeyword, + payloadText.length()); + String ackText = extKeyword + " pre-positioning acknowledged"; + List> parts = List.of(new TextPart(ackText)); + emitter.addArtifact(parts, "result", agentTag + " ack", Map.of(), false, true); + emitStatus( + emitter, + TaskState.TASK_STATE_COMPLETED, + contextId, + taskId, + extKeyword + " pre-positioned successfully", + Map.of()); + emitter.complete(BaseAgentExecutor.buildStatusMessage(contextId, taskId, "Completed")); + log.info("[{}] {} pre-positioning completed", agentTag, extKeyword); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategy.java b/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategy.java new file mode 100644 index 00000000..94dd25b3 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategy.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the License); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an AS IS BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.negotiation; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Negotiation clarification strategy for the SPN cross-city workbench. + * + *

Single responsibility: when a downstream agent returns INPUT_REQUIRED (Negotiation-T), + * generate a clarification text that supplements the missing parameters. The returned text is + * fed to the engine's SDK content layer, which renders the structured Accept message (the raw + * text is also kept as the fallback message body). Uses LLM when the A2A-T .env is configured, + * with a deterministic fallback. + * + *

The Accept payload follows the SDK scenario data for the private-line complaint case: + * the two parameters the agent negotiated for (接入端口名称, 投诉分类), filled with the values + * from the task context. + */ +public class NegotiationStrategy implements dev.openan.workflow.engine.control.NegotiationStrategy { + + private static final Logger log = LoggerFactory.getLogger(NegotiationStrategy.class); + + /** Filled values for the two negotiated parameters (from the task context, spec case 7.3). */ + private static final String FILLED_PORT_NAME = "P533-珠江旧城-PTN3900-23-TPA1EG24-1"; + + private static final String FILLED_COMPLAINT_CATEGORY = "专线质差"; + + private final String a2atEnvPath; + + public NegotiationStrategy(String a2atEnvPath) { + this.a2atEnvPath = a2atEnvPath; + } + + /** + * Generate a clarification for the given negotiation request. + * + * @param agentName the agent requesting negotiation + * @param negotiationText the concern/question raised by the agent + * @param receiveResult the response metadata, carrying the SDK negotiation context under the + * {@code negotiationContext} key (id/round/maxRounds) when present + * @return clarification text to send back to the agent + */ + public CompletableFuture resolve( + String agentName, String negotiationText, Map receiveResult) { + log.info("[NegotiationStrategy] agent={}: {}", agentName, negotiationText); + // Negotiation-T resolution (Accept), mirroring spec case 7.3 step 3. The engine's SDK + // content layer renders the structured Accept template from this text; the session + // context travels in the negotiationContext metadata key, so no context echoing here. + String accept = + "同意补充以下信息:\n" + + "1. 接入端口名称:" + + FILLED_PORT_NAME + + "\n" + + "2. 投诉分类:" + + FILLED_COMPLAINT_CATEGORY + + "\n" + + "信息已完整,可以启动诊断。"; + log.info("[NegotiationStrategy] Accept generated for agent={}", agentName); + return CompletableFuture.completedFuture(accept); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationUtils.java b/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationUtils.java new file mode 100644 index 00000000..a65c6a4c --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationUtils.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the License); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an AS IS BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.negotiation; + +import dev.openan.workflow.engine.client.A2ATExtension; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Negotiation-T helpers using only standard A2A-T protocol fields (spec §5.1 + * Message.role and §6 metadata-key conventions). No custom markers, no internal-only + * keys, no value-content inspection. + */ +public final class NegotiationUtils { + + /** Standard Negotiation-T extension URI (spec §2). */ + public static final String NEGOTIATION_T_URI = A2ATExtension.NEGOTIATION_T.uri(); + + /** Standard Task-T extension URI (spec §2). */ + public static final String TASK_PROMPT_KEY = A2ATExtension.TASK_T.uri(); + + private NegotiationUtils() {} + + /** + * Whether metadata carries the Negotiation-T extension key (key-level check). Used by both + * client and server to detect negotiation messages generically. + */ + public static boolean hasNegotiationMetadata(Map metadata) { + if (metadata == null) return false; + return metadata.containsKey(NEGOTIATION_T_URI); + } + + /** + * Whether metadata carries the Task-T extension key (key-level check). Distinguishes a new + * diagnostic task (has Task-T) from a negotiation reply (only Negotiation-T). + */ + public static boolean hasTaskMetadata(Map metadata) { + if (metadata == null) return false; + return metadata.containsKey(TASK_PROMPT_KEY); + } + + /** Build the Negotiation-T metadata for an OMC-side Message(ROLE_AGENT) propose. */ + public static Map negotiationResponseMetadata(String negotiationText) { + Map metadata = new LinkedHashMap<>(); + if (negotiationText != null && !negotiationText.isEmpty()) { + metadata.put(NEGOTIATION_T_URI, negotiationText); + } + return metadata; + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/server/JdkHttpA2AServer.java b/samples/src/main/java/dev/openan/workflow/engine/examples/server/JdkHttpA2AServer.java index 4223749d..db7eb811 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 @@ -397,6 +397,25 @@ private void handleExchange(HttpExchange exchange, RestHandler restHandler) thro handleStream(exchange, restHandler, readBody(exchange)); return; } + // Task routes (?? A2A: ??/??/??) + if ("GET".equalsIgnoreCase(method) && path.startsWith("/tasks/") && !path.contains(":")) { + String taskId = path.substring("/tasks/".length()); + if (taskId.contains("?")) taskId = taskId.substring(0, taskId.indexOf("?")); + var resp = restHandler.getTask(buildCallContext(exchange), "", taskId, null); + sendJson(exchange, resp.getStatusCode(), resp.getBody()); + return; + } + if ("POST".equalsIgnoreCase(method) && path.contains(":cancel")) { + String taskId = path.substring("/tasks/".length(), path.indexOf(":cancel")); + var resp = restHandler.cancelTask(buildCallContext(exchange), "", readBody(exchange), taskId); + sendJson(exchange, resp.getStatusCode(), resp.getBody()); + return; + } + if ("POST".equalsIgnoreCase(method) && path.contains(":subscribe")) { + String taskId = path.substring("/tasks/".length(), path.indexOf(":subscribe")); + handleSubscribe(exchange, restHandler, taskId); + return; + } exchange.sendResponseHeaders(404, -1); } catch (Exception e) { log.error("[{}] Handler error: {}", agentName, e.getMessage(), e); @@ -439,6 +458,44 @@ private void handleLogin(HttpExchange exchange) throws IOException { } } + @SuppressWarnings("unchecked") + private void handleSubscribe(HttpExchange exchange, RestHandler restHandler, String taskId) + throws IOException { + try { + var resp = restHandler.subscribeToTask(buildCallContext(exchange), "", taskId); + if (resp instanceof RestHandler.HTTPRestStreamingResponse streaming) { + exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); + exchange.sendResponseHeaders(200, 0); + AtomicLong seq = new AtomicLong(0); + CountDownLatch done = new CountDownLatch(1); + OutputStream os = exchange.getResponseBody(); + streaming.getPublisher().subscribe(new Flow.Subscriber() { + private Flow.Subscription sub; + public void onSubscribe(Flow.Subscription s) { sub = s; s.request(1); } + public void onNext(String item) { + try { + String sse = String.format(Locale.ROOT, "id:%d%n", seq.incrementAndGet()) + + "data:" + item + "\n\n"; + os.write(sse.getBytes(StandardCharsets.UTF_8)); + os.flush(); + sub.request(1); + } catch (IOException e) { + sub.cancel(); done.countDown(); + } + } + public void onError(Throwable t) { done.countDown(); } + public void onComplete() { done.countDown(); } + }); + try { done.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + finally { os.close(); } + } else { + sendJson(exchange, resp.getStatusCode(), resp.getBody()); + } + } catch (A2AError e) { + sendJson(exchange, 500, Map.of("error", e.getMessage())); + } + } + @SuppressWarnings("unchecked") private void handleStream(HttpExchange exchange, RestHandler restHandler, String requestBody) throws IOException { diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExtensionLifecycle.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExtensionLifecycle.java new file mode 100644 index 00000000..7e01dcf9 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExtensionLifecycle.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the License); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an AS IS BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.workbench; + +import dev.openan.workflow.engine.examples.util.EnvResolver; +import dev.openan.workflow.engine.examples.config.WorkbenchClientProperties; +import dev.openan.workflow.engine.examples.workbench.WorkbenchExtensionLifecycle; +import jakarta.annotation.PreDestroy; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.util.Map; + +import dev.openan.workflow.engine.examples.gateway.ClientRuntimeFactory; +/** Keeps Authorization-T/Notification-T pre-positioning independent from individual tasks. */ +@Component +public final class SpringWorkbenchExtensionLifecycle { + private static final Logger log = + LoggerFactory.getLogger(SpringWorkbenchExtensionLifecycle.class); + + private final ClientRuntimeFactory runtimeFactory; + private final ConfigurableListableBeanFactory beanFactory; + private final WorkbenchClientProperties properties; + + private WorkbenchExtensionLifecycle lifecycle; + + public SpringWorkbenchExtensionLifecycle( + ClientRuntimeFactory runtimeFactory, + ConfigurableListableBeanFactory beanFactory, + WorkbenchClientProperties properties) { + this.runtimeFactory = runtimeFactory; + this.beanFactory = beanFactory; + this.properties = properties; + } + + @EventListener(ApplicationReadyEvent.class) + public synchronized void start() { + if (lifecycle != null && lifecycle.isActive()) { + return; + } + if (beanFactory.containsBean("eastcomOrderSimulatorServer")) { + beanFactory.registerDependentBean( + "eastcomOrderSimulatorServer", "springWorkbenchExtensionLifecycle"); + } + WorkbenchExtensionLifecycle candidate = + new WorkbenchExtensionLifecycle( + resolveCredentialsPath(), + properties.isSslVerify(), + resolveEnvPath(), + runtimeFactory::create, + this::onNotification); + candidate.start(); + lifecycle = candidate; + } + + @PreDestroy + public synchronized void close() { + if (lifecycle == null) { + return; + } + lifecycle.close(); + lifecycle = null; + } + + private void onNotification(Map data) { + Object text = data.get("text"); + log.info( + "[Notification] EVENT scope=workbench, agent={}, state={}, textChars={}, metadata={}", + data.get("agent"), + data.get("state"), + text != null ? String.valueOf(text).length() : 0, + data.containsKey("metadata") ? "yes" : "no"); + if (text != null) { + log.debug("[Notification] Recovery result from {}: {}", data.get("agent"), text); + } + } + + private String resolveEnvPath() { + return properties.getA2atEnvPath() != null && !properties.getA2atEnvPath().isBlank() + ? properties.getA2atEnvPath() + : EnvResolver.resolveEnvPath(); + } + + private String resolveCredentialsPath() { + if (properties.getCredentialsPath() != null + && !properties.getCredentialsPath().isBlank()) { + return properties.getCredentialsPath(); + } + var resource = getClass().getClassLoader().getResource("spn_agent_credentials.json"); + return resource != null ? "classpath:spn_agent_credentials.json" : null; + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalog.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalog.java new file mode 100644 index 00000000..d83b228b --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalog.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.workbench; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openan.workflow.engine.client.AgentCardJacksonModule; + +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Loads the downstream AgentCards shared by task execution and extension subscriptions. */ +public final class WorkbenchAgentCatalog { + private static final Logger log = LoggerFactory.getLogger(WorkbenchAgentCatalog.class); + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(new AgentCardJacksonModule()); + private static final List AGENT_CARD_RESOURCES = + List.of( + "agentcard/spn_domain_agent_city1.json", + "agentcard/spn_domain_agent_city2.json", + "agentcard/transport_workbench_agent.json"); + + public List load() { + Map byName = new LinkedHashMap<>(); + for (String resource : AGENT_CARD_RESOURCES) { + try { + try (var input = getClass().getClassLoader().getResourceAsStream(resource)) { + if (input == null) { + log.warn("[AgentCatalog] CARD_MISSING resource={}", resource); + continue; + } + AgentCard card = mapper.readValue(input, AgentCard.class); + byName.put(card.name(), card); + log.info( + "[AgentCatalog] CARD_LOADED resource={}, agent={}, interfaces={}", + resource, + card.name(), + card.supportedInterfaces() != null + ? card.supportedInterfaces().size() + : 0); + } + } catch (Exception e) { + log.warn( + "[AgentCatalog] CARD_FAILED resource={}, errorType={}, message={}", + resource, + e.getClass().getSimpleName(), + e.getMessage(), + e); + } + } + return new ArrayList<>(byName.values()); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchControlPoint.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchControlPoint.java new file mode 100644 index 00000000..cb9d81ae --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchControlPoint.java @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the License); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an AS IS BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.workbench; + +import dev.openan.workflow.engine.examples.util.LlmHelper; +import dev.openan.workflow.engine.client.WorkflowEngineClient; +import dev.openan.workflow.engine.control.DefaultControlPoint; +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; +import dev.openan.workflow.engine.model.JumpCondition; +import dev.openan.workflow.engine.model.RouteDecision; +import dev.openan.workflow.engine.model.TaskRequest; +import dev.openan.workflow.engine.model.TaskResponse; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.LinkedHashMap; +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; +/** + * ControlPoint for the SPN cross-city diagnosis workflow. + * + *

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

SRP: this class only contains workflow decision logic, separating it from the agent executor + * that handles message I/O. + */ +public class WorkbenchControlPoint extends DefaultControlPoint { + private static final Logger log = LoggerFactory.getLogger(WorkbenchControlPoint.class); + + private final String a2atEnvPath; + private final NegotiationStrategy negotiationStrategy; + + public WorkbenchControlPoint() { + this(null, null); + } + + public WorkbenchControlPoint(String a2atEnvPath) { + this(a2atEnvPath, null); + } + + public WorkbenchControlPoint(String a2atEnvPath, NegotiationStrategy negotiationStrategy) { + this.a2atEnvPath = a2atEnvPath != null ? a2atEnvPath : EnvResolver.resolveEnvPath(); + this.negotiationStrategy = + negotiationStrategy != null + ? negotiationStrategy + : new NegotiationStrategy(this.a2atEnvPath); + } + + private static String analyzeFaultLocation(String messageText) { + // Identify City1 fault by spec case 7.1 diagnostic-detail fault signatures, since the + // diagnosis text now follows the document format (no longer contains the "城市1" keyword). + boolean hasCity1Fault = + messageText.contains("无收光") + || messageText.contains("掉电") + || messageText.contains("端口关闭") + || messageText.contains("端口Down") + || (messageText.contains("城市1") + && (messageText.contains("故障") || messageText.contains("Down"))); + boolean hasCity2Fault = + messageText.contains("城市2") + && (messageText.contains("故障") || messageText.contains("Down")); + if (hasCity1Fault) { + // Merge result mirroring spec case 7.1 diagnosis summary in Task-T metadata. + return "1. 诊断结果:成功\n" + + "2. 诊断结果详情:汇总分析完成。故障定位:城市1地市OMC," + + "P33206-YWHJ-业务汇聚机房1(990)的12-TPJ1EM8F-4端口出现无收光问题," + + "判断原因为对端设备掉电或端口关闭\n" + + "3. 修复建议:恢复供电后重新启动网元,或者重新开启端口"; + } + if (hasCity2Fault) { + return "1. 诊断结果:成功\n" + + "2. 诊断结果详情:汇总分析完成。城市2地市OMC端口状态正常," + + "光功率-17dBm(正常范围),无异常告警,故障不在此地市\n" + + "3. 修复建议:城市2无需修复"; + } + return "1. 诊断结果:成功\n2. 诊断结果详情:汇总分析完成。两地市均未见异常。\n3. 修复建议:无需修复"; + } + + /** + * Build a targeted task message containing ONLY the parameters relevant to the target agent's + * city. The upstream context (which mixes both cities' info) is NOT forwarded -- each sub-agent + * receives a clean, self-contained task description with only its own scope. + */ + private static String buildTargetedTaskMessage(String step) { + return switch (step) { + case "diagnosis_city1" -> buildCity1Task(); + case "diagnosis_city2" -> buildCity2Task(); + default -> "创建专线业务投诉诊断任务"; + }; + } + + private static String buildCity1Task() { + // Task-T structured prompt, mirroring spec case 7.3 (missing params, triggers negotiation). + return "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" + + "## 任务描述(Task Description)\n" + + "基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, " + + "达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n" + + "## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n" + + "## 任务对象(Task Object)\n接入端口名称:\n\n" + + "## 任务上下文(Task Context)\n" + + "1. 投诉分类:\n" + + "2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n" + + "3. OSS侧事件流水号:\"event-id-20260511-09013\"\n" + + "4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," + + "访问广州机房的核心交易系统非常慢。\"\n\n" + + "## 预期输出(Expected Output)\n" + + "1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n" + + "2. 诊断结果详情;(必选)\n" + + "3. 修复建议;(可选)\n" + + "4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"; + } + + private static String buildCity2Task() { + return "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" + + "## 任务描述(Task Description)\n" + + "基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, " + + "达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n" + + "## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n" + + "## 任务对象(Task Object)\n接入端口名称:P882-珠江新城-PTN7900-23-TPA1EG24-11\n\n" + + "## 任务上下文(Task Context)\n" + + "1. 投诉分类:\"专线质差\"\n" + + "2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n" + + "3. OSS侧事件流水号:\"event-id-20260511-09013\"\n" + + "4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," + + "访问广州机房的核心交易系统非常慢。\"\n\n" + + "## 预期输出(Expected Output)\n" + + "1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n" + + "2. 诊断结果详情;(必选)\n" + + "3. 修复建议;(可选)\n" + + "4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"; + } + + @Override + public CompletableFuture onTask( + TaskRequest request, WorkflowEngineClient engineClient) { + String step = request.getStepName(); + String agentName = request.getAgentName(); + String targetedMessage = buildTargetedTaskMessage(step); + // Send the Task-T structured prompt via message metadata (spec §6), so the OMC agent + // reads it from message.metadata[Task-T URI] exactly like spec cases 7.1/7.3. + Map metadata = new LinkedHashMap<>(); + metadata.put(NegotiationUtils.TASK_PROMPT_KEY, targetedMessage); + // For diagnosis_city1, activate Negotiation-T extension (spec case 7.3: missing params + // trigger negotiation). The ExtensionInterceptor adds it to the A2A-Extensions header + // when the metadata key is present. + if ("diagnosis_city1".equals(step)) { + metadata.put(NegotiationUtils.NEGOTIATION_T_URI, ""); + } + String partsText = "创建专线业务投诉诊断任务"; + return engineClient + .sendMessage(agentName, partsText, null, metadata) + .thenApply( + r -> { + boolean success = r.getText() != null && !r.getText().isEmpty(); + log.info( + "[onTask] Response from {}: {} chars, success={}", + agentName, + r.getText() != null ? r.getText().length() : 0, + success); + return TaskResponse.builder() + .success(success) + .output(r.getText()) + .build(); + }) + .exceptionally( + e -> { + log.error("[onTask] Failed for {}: {}", agentName, e.getMessage()); + return TaskResponse.builder() + .success(false) + .error("Agent call failed: " + e.getMessage()) + .build(); + }); + } + + @Override + public CompletableFuture onSelfTask(TaskRequest request) { + String step = request.getStepName(); + log.info( + "[onSelfTask] Self-loop step={}, agent={} (local merge, no A2A-T)", + step, + request.getAgentName()); + String message = request.getMessage(); + String fallback = analyzeFaultLocation(message); + String sys = "你是SPN跨城故障协同诊断汇总专家。按如下结构输出:1. 诊断结果;2. 诊断结果详情;3. 修复建议。简洁专业,中文。"; + String result = LlmHelper.text(a2atEnvPath, sys, message, fallback); + log.info("[onSelfTask] Merge result ({} chars): {}", result.length(), result); + return CompletableFuture.completedFuture( + TaskResponse.builder().success(true).output(result).build()); + } + + @Override + public CompletableFuture onRoute( + String stepName, Map results, List conditions) { + // merge_analysis has an unconditional next -> endNode, so the executor + // never calls onRoute for it. Recovery is self-triggered by SPN agents + // via the pre-positioned Authorization-T whitelist and reported through + // the Notification-T channel. Just delegate to the default routing. + return super.onRoute(stepName, results, conditions); + } + + @Override + public CompletableFuture onNegotiation( + String agentName, String negotiationText, Map receiveResult) { + return negotiationStrategy.resolve(agentName, negotiationText, receiveResult); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchExtensionLifecycle.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchExtensionLifecycle.java new file mode 100644 index 00000000..5a6cdf11 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchExtensionLifecycle.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.workbench; + +import dev.openan.workflow.engine.client.A2AJavaClientRuntime; +import dev.openan.workflow.engine.client.A2ATransport; +import dev.openan.workflow.engine.client.DefaultExtensionSender; +import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; + +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import dev.openan.workflow.engine.examples.extension.ExtensionPrePositioner; +/** + * Owns workbench-scoped pre-positioning resources. + * + *

Authorization-T is sent once and its request session is released after its response. + * Notification-T streams remain owned by this lifecycle until {@link #close()} is called by the + * workbench service lifecycle; task completion never closes them. + */ +public final class WorkbenchExtensionLifecycle implements AutoCloseable { + private static final Logger log = LoggerFactory.getLogger(WorkbenchExtensionLifecycle.class); + + private final String credentialsPath; + private final boolean sslVerify; + private final String a2atEnvPath; + private final Supplier runtimeSupplier; + private final Consumer> notificationCallback; + + private A2ATransport transport; + + public WorkbenchExtensionLifecycle( + String credentialsPath, + boolean sslVerify, + String a2atEnvPath, + Supplier runtimeSupplier, + Consumer> notificationCallback) { + this.credentialsPath = credentialsPath; + this.sslVerify = sslVerify; + this.a2atEnvPath = a2atEnvPath; + this.runtimeSupplier = runtimeSupplier; + this.notificationCallback = notificationCallback; + } + + public synchronized void start() { + if (transport != null) { + log.info( + "[ExtensionLifecycle] START_SKIPPED reason=already_active, contextId={}", + transport.getContextId()); + return; + } + + List agentCards = new WorkbenchAgentCatalog().load(); + A2AJavaClientRuntime runtime = runtimeSupplier != null ? runtimeSupplier.get() : null; + A2ATransport candidate = + new A2ATransport( + agentCards, + runtime, + WorkflowEngineClientConfig.builder() + .sslVerify(sslVerify) + .a2atEnvPath(a2atEnvPath) + .credentialsConfigPath(credentialsPath) + .build()); + long started = System.nanoTime(); + log.info( + "[ExtensionLifecycle] START contextId={}, runtime={}, scope=workbench", + candidate.getContextId(), + runtime != null ? runtime.getClass().getSimpleName() : "DefaultA2AJavaClientRuntime"); + try { + new ExtensionPrePositioner() + .prePosition( + new DefaultExtensionSender(candidate), + agentCards, + notificationCallback); + transport = candidate; + log.info( + "[ExtensionLifecycle] ACTIVE contextId={}, notificationScope=workbench, elapsedMs={}", + candidate.getContextId(), + elapsedMillis(started)); + } catch (RuntimeException e) { + candidate.close(); + log.error( + "[ExtensionLifecycle] START_FAILED contextId={}, errorType={}, message={}", + candidate.getContextId(), + e.getClass().getSimpleName(), + e.getMessage(), + e); + throw e; + } + } + + public synchronized boolean isActive() { + return transport != null; + } + + @Override + public synchronized void close() { + A2ATransport active = transport; + transport = null; + if (active == null) { + return; + } + log.info( + "[ExtensionLifecycle] CLOSE_START contextId={}, reason=workbench_shutdown", + active.getContextId()); + active.close(); + log.info( + "[ExtensionLifecycle] CLOSE_DONE contextId={}, reason=workbench_shutdown", + active.getContextId()); + } + + private static long elapsedMillis(long startedNanos) { + return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java new file mode 100644 index 00000000..7b1d9f7d --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.workbench; + +import dev.openan.workflow.engine.client.A2ATransport; +import dev.openan.workflow.engine.client.A2AJavaClientRuntime; +import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; +import dev.openan.workflow.engine.client.WorkflowEngineClient; +import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; +import dev.openan.workflow.engine.control.EventCallback; +import dev.openan.workflow.engine.control.EventType; +import dev.openan.workflow.engine.model.ExecutionResult; +import dev.openan.workflow.engine.model.Workflow; +import dev.openan.workflow.engine.model.WorkflowSearchResult; +import dev.openan.workflow.engine.registry.LoadPsop; +import dev.openan.workflow.engine.runner.ExecutePsop; + +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import dev.openan.workflow.engine.examples.agents.TransportWorkbenchAgentExecutor; +import dev.openan.workflow.engine.examples.negotiation.NegotiationStrategy; +/** + * Workflow orchestration for the SPN cross-city diagnosis. + * + *

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

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

This class does NOT handle agent server I/O (that stays in {@link + * TransportWorkbenchAgentExecutor}) or the details of any single A2A-T extension protocol. + */ +public class WorkbenchOrchestrator { + + private static final Logger log = LoggerFactory.getLogger(WorkbenchOrchestrator.class); + private static final String FALLBACK_PSOP_ID = "psop_spn_cross_city_diagnosis"; + + private final String orchUrl; + private final String credentialsPath; + private final boolean sslVerify; + private final String a2atEnvPath; + private final A2AJavaClientRuntime clientRuntime; + + public WorkbenchOrchestrator( + String orchUrl, String credentialsPath, boolean sslVerify, String a2atEnvPath) { + this(orchUrl, credentialsPath, sslVerify, a2atEnvPath, null); + } + + public WorkbenchOrchestrator( + String orchUrl, + String credentialsPath, + boolean sslVerify, + String a2atEnvPath, + A2AJavaClientRuntime clientRuntime) { + this.orchUrl = orchUrl; + this.credentialsPath = credentialsPath; + this.sslVerify = sslVerify; + this.a2atEnvPath = a2atEnvPath; + this.clientRuntime = clientRuntime; + } + + private static String buildResultText(ExecutionResult result) { + StringBuilder sb = new StringBuilder(); + sb.append("Workflow execution ") + .append(result.isSuccess() ? "succeeded" : "failed") + .append(".\n"); + if (result.getHistory() != null) { + for (Map h : result.getHistory()) { + sb.append("- Step: ") + .append(h.get("step")) + .append(", Agent: ") + .append(h.get("agent")) + .append(", Status: ") + .append(h.get("status")) + .append("\n"); + } + } + if (result.getError() != null) { + sb.append("Error: ").append(result.getError()); + } + return sb.toString(); + } + + /** Run the full orchestration pipeline and return the result text. */ + public String run(String messageText) throws Exception { + long runStarted = System.nanoTime(); + String runtimeName = + clientRuntime != null ? clientRuntime.getClass().getSimpleName() : "DefaultA2AJavaClientRuntime"; + log.info( + "[Orchestrator] START orchUrl={}, runtime={}, sslVerify={}, inputChars={}", + orchUrl, + runtimeName, + sslVerify, + messageText != null ? messageText.length() : 0); + + long stageStarted = System.nanoTime(); + log.info("[Orchestrator] STAGE_START stage=load-agent-cards"); + List agentCards = loadAgentCards(); + log.info( + "[Orchestrator] STAGE_DONE stage=load-agent-cards, count={}, agents={}, elapsedMs={}", + agentCards.size(), + agentCards.stream().map(AgentCard::name).toList(), + elapsedMillis(stageStarted)); + + stageStarted = System.nanoTime(); + log.info("[Orchestrator] STAGE_START stage=search-load-psop"); + String psopId = searchPsop(messageText); + Workflow workflow = LoadPsop.load(orchUrl, psopId, null, sslVerify); + log.info( + "[Orchestrator] STAGE_DONE stage=search-load-psop, psopId={}, workflow={}, " + + "steps={}, elapsedMs={}", + psopId, + workflow.getName(), + workflow.getSteps().size(), + elapsedMillis(stageStarted)); + + stageStarted = System.nanoTime(); + log.info( + "[Orchestrator] STAGE_START stage=create-engine-client, runtime={}", + runtimeName); + A2ATransport transport = + new A2ATransport( + agentCards, + clientRuntime, + WorkflowEngineClientConfig.builder() + .sslVerify(sslVerify) + .a2atEnvPath(a2atEnvPath) + .credentialsConfigPath(credentialsPath) + .build()); + log.info( + "[Orchestrator] STAGE_DONE stage=create-engine-client, contextId={}, elapsedMs={}", + transport.getContextId(), + elapsedMillis(stageStarted)); + try { + WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport); + + stageStarted = System.nanoTime(); + log.info( + "[Orchestrator] STAGE_START stage=execute-workflow, contextId={}, " + + "psopId={}, workflow={}", + transport.getContextId(), + psopId, + workflow.getName()); + WorkbenchControlPoint controlPoint = + new WorkbenchControlPoint(a2atEnvPath, new NegotiationStrategy(a2atEnvPath)); + ExecutionResult result = + ExecutePsop.builder() + .psop(workflow) + .agentCards(agentCards) + .controlPoint(controlPoint) + .engineClient(engineClient) + .runtimeIntent(messageText) + .lang("zh") + .sslVerify(sslVerify) + .credentialsConfigPath(credentialsPath) + .a2atEnvPath(a2atEnvPath) + .eventCallback(createLogCallback()) + .onFinish( + (r, events) -> { + log.info( + "[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)); + return resultText; + } catch (Exception e) { + log.error( + "[Orchestrator] FAILED contextId={}, elapsedMs={}, errorType={}, message={}", + transport.getContextId(), + elapsedMillis(runStarted), + e.getClass().getSimpleName(), + e.getMessage(), + e); + throw e; + } finally { + log.info("[Orchestrator] TRANSPORT_CLOSE contextId={}", transport.getContextId()); + transport.close(); + } + } + + private List loadAgentCards() { + return new WorkbenchAgentCatalog().load(); + } + + private String searchPsop(String messageText) { + try { + List results = + LoadPsop.search(orchUrl, messageText, 3, null, sslVerify); + if (!results.isEmpty()) { + String psopId = results.get(0).getWorkflowId(); + log.info( + "[Orchestrator] Found PSOP: {} (score={})", + psopId, + results.get(0).getScore()); + return psopId; + } + } catch (Exception e) { + log.warn("[Orchestrator] PSOP search failed, using fallback: {}", e.getMessage()); + } + log.warn("[Orchestrator] PSOP_FALLBACK psopId={}", FALLBACK_PSOP_ID); + return FALLBACK_PSOP_ID; + } + + private EventCallback createLogCallback() { + return new EventCallback() { + @Override + public void onEvent(String type, Map data) { + switch (type) { + case EventType.START -> log.info(" [START] {}", data.get("workflow")); + case EventType.STEP_START -> log.info(" [STEP_START] {}", data.get("step")); + case EventType.TASK_REQUEST -> + log.info(" [TASK_REQUEST] agent={}", data.get("agent")); + case EventType.TASK_RESPONSE -> + log.info(" [TASK_RESPONSE] agent={}", data.get("agent")); + case EventType.AGENT_STATUS_UPDATE -> + log.info( + " [STATUS_UPDATE] agent={}, state={}, final={}", + data.get("agent"), + data.get("state"), + data.get("is_final")); + case EventType.AGENT_ARTIFACT_UPDATE -> + log.info( + " [ARTIFACT_UPDATE] agent={}, artifact={}", + data.get("agent"), + data.get("artifact_name")); + case EventType.AGENT_MESSAGE_EVENT -> + log.info( + " [MESSAGE] agent={}, {} chars", + data.get("agent"), + data.get("text") != null + ? ((String) data.get("text")).length() + : 0); + case EventType.STEP_COMPLETE -> + log.info(" [STEP_COMPLETE] {}", data.get("step")); + case EventType.ROUTE_DECISION -> + log.info(" [ROUTE] {} -> {}", data.get("step"), data.get("next")); + case EventType.COMPLETE -> log.info(" [COMPLETE]"); + case EventType.ERROR -> log.error(" [ERROR] {}", data.get("error")); + case EventType.CLOSE -> log.info(" [CLOSE]"); + default -> {} + } + } + }; + } + + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } +} diff --git a/samples/src/main/resources/agentcard/spn_domain_agent_city1.json b/samples/src/main/resources/agentcard/spn_domain_agent_city1.json index 412a171f..88ddb933 100644 --- a/samples/src/main/resources/agentcard/spn_domain_agent_city1.json +++ b/samples/src/main/resources/agentcard/spn_domain_agent_city1.json @@ -17,7 +17,7 @@ "required": false }, { - "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T", + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", "description": "Extension for A2A-T negotiation text exchange.", "required": false }, diff --git a/samples/src/main/resources/agentcard/spn_domain_agent_city2.json b/samples/src/main/resources/agentcard/spn_domain_agent_city2.json index b83dc3b9..33fd4a36 100644 --- a/samples/src/main/resources/agentcard/spn_domain_agent_city2.json +++ b/samples/src/main/resources/agentcard/spn_domain_agent_city2.json @@ -17,7 +17,7 @@ "required": false }, { - "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T", + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", "description": "Extension for A2A-T negotiation text exchange.", "required": false }, diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/EmbeddedA2AServerTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/EmbeddedA2AServerTest.java index e063af1b..19576e14 100644 --- a/samples/src/test/java/dev/openan/workflow/engine/examples/EmbeddedA2AServerTest.java +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/EmbeddedA2AServerTest.java @@ -109,7 +109,16 @@ void setUp() throws Exception { "extendedAgentCard", false, "extensions", - List.of()), + 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", @@ -176,7 +185,43 @@ void testSendMessage() throws Exception { String taskMeta = extractExtensionValue(result, "Task-T"); assertNotNull(taskMeta, "Task-T metadata should be present in response"); assertTrue( - taskMeta.contains("城市1"), - "Diagnosis metadata should mention City1, got: " + taskMeta); + taskMeta.contains("诊断结果"), + "Diagnosis metadata should contain structured diagnosis result, got: " + taskMeta); + } + + /** + * Negotiation path: when the message metadata carries a Task-T prompt plus the Negotiation-T + * extension key, the agent starts a 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 prompt with a blank task object so server-side validation would also flag it; + // the Negotiation-T key activates the extension on the A2A-Extensions header. + 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( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", + ""); + SendMessageResult result = + client + .sendMessage(AGENT_NAME, "diagnose SPN fault", null, metadata) + .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()); } } diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/server/EmbeddedA2AServerTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/server/EmbeddedA2AServerTest.java new file mode 100644 index 00000000..381c5b2a --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/server/EmbeddedA2AServerTest.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the License); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an AS IS BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.server; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openan.workflow.engine.client.A2ATransport; +import dev.openan.workflow.engine.client.AgentCardJacksonModule; +import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; +import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; +import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; +import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; +import dev.openan.workflow.engine.model.SendMessageResult; + +import org.a2aproject.sdk.spec.AgentCard; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; + +/** + * Integration test: start an EmbeddedA2AServer, send a message via DefaultWorkflowEngineClient (the + * real A2A client), and verify the response text is extracted correctly from the SSE stream. + */ +class EmbeddedA2AServerTest { + + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(new AgentCardJacksonModule()); + private static final String AGENT_NAME = "Test Agent"; + private JdkHttpA2AServer server; + private DefaultWorkflowEngineClient client; + private int port; + + private static String extractExtensionValue(SendMessageResult result, String extKeyword) { + Map meta = result.getMetadata(); + if (meta == null) return null; + for (String key : meta.keySet()) { + if (key.contains(extKeyword)) { + Object v = meta.get(key); + return v instanceof String s ? s : String.valueOf(v); + } + } + return null; + } + + private static javax.net.ssl.SSLContext createTrustAllSslContext() throws Exception { + javax.net.ssl.SSLContext ctx = javax.net.ssl.SSLContext.getInstance("TLS"); + ctx.init( + null, + new javax.net.ssl.TrustManager[] { + new javax.net.ssl.X509TrustManager() { + public void checkClientTrusted( + java.security.cert.X509Certificate[] chain, String authType) {} + + public void checkServerTrusted( + java.security.cert.X509Certificate[] chain, String authType) {} + + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return new java.security.cert.X509Certificate[0]; + } + } + }, + null); + return ctx; + } + + @BeforeEach + void setUp() throws Exception { + // Unit tests must stay deterministic and offline: disable LLM calls so + // agents fall back to their hardcoded diagnostic text (asserted below). + System.setProperty("a2at.llm.disabled", "true"); + port = 28000 + (int) (Math.random() * 1000); + Map card = + Map.of( + "name", AGENT_NAME, + "description", "test", + "provider", Map.of("organization", "test", "url", ""), + "version", "1.0.0", + "capabilities", + Map.of( + "streaming", + true, + "pushNotifications", + false, + "extendedAgentCard", + false, + "extensions", + List.of( + 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 JdkHttpA2AServer("127.0.0.1", port, card, new SpnDomainAgentCity1Executor()); + server.start(); + Thread.sleep(500); + A2ATransport transport = + new A2ATransport( + List.of(mapper.convertValue(card, AgentCard.class)), + null, + WorkflowEngineClientConfig.builder().sslVerify(false).build()); + client = new DefaultWorkflowEngineClient(transport); + } + + @AfterEach + void tearDown() { + if (client != null) client.close(); + if (server != null) server.close(); + } + + @Test + void testGetAgentCard() throws Exception { + HttpClient http = HttpClient.newBuilder().sslContext(createTrustAllSslContext()).build(); + HttpRequest req = + HttpRequest.newBuilder() + .uri(URI.create("https://127.0.0.1:" + port + "/")) + .GET() + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, resp.statusCode()); + Map card = mapper.readValue(resp.body(), Map.class); + assertEquals(AGENT_NAME, card.get("name")); + } + + @Test + void testSendMessage() throws Exception { + SendMessageResult result = client.sendMessage(AGENT_NAME, "diagnose SPN fault").join(); + assertNotNull(result); + assertFalse( + result.getText().isEmpty(), + "Response text should not be empty, got: " + result.getText()); + // A2A-T extension content is in metadata (Task-T/v1), not in parts.text + String taskMeta = extractExtensionValue(result, "Task-T"); + assertNotNull(taskMeta, "Task-T metadata should be present in response"); + assertTrue( + taskMeta.contains("诊断结果"), + "Diagnosis metadata should contain structured diagnosis result, got: " + taskMeta); + } + + /** + * Negotiation path: when the message metadata carries a Task-T prompt plus the Negotiation-T + * extension key, the agent starts a 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 prompt with a blank task object so server-side validation would also flag it; + // the Negotiation-T key activates the extension on the A2A-Extensions header. + 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( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", + ""); + SendMessageResult result = + client + .sendMessage(AGENT_NAME, "diagnose SPN fault", null, metadata) + .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()); + } +} From d5db86de50c558204ba1254150a77d08bd663ffd Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Sun, 23 Aug 2026 13:12:52 +0800 Subject: [PATCH 03/49] chore(samples): remove orphan demo superseded by SpringSpnDemo SpnCrossCityDiagnosisDemo and StartAgentsServer duplicated SpringSpnDemo's flow (agents startup + Task-T send) without the transport-mode support; unreferenced except javadoc mentions, zero doc coverage. Signed-off-by: Zhoujie628 --- .../embedded/SpnCrossCityDiagnosisDemo.java | 169 ---------------- .../examples/embedded/StartAgentsServer.java | 187 ------------------ 2 files changed, 356 deletions(-) delete mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/embedded/SpnCrossCityDiagnosisDemo.java delete mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/embedded/StartAgentsServer.java diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/embedded/SpnCrossCityDiagnosisDemo.java b/samples/src/main/java/dev/openan/workflow/engine/examples/embedded/SpnCrossCityDiagnosisDemo.java deleted file mode 100644 index 5cdc8748..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/embedded/SpnCrossCityDiagnosisDemo.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.embedded; - -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.openan.workflow.engine.client.A2ATransport; -import dev.openan.workflow.engine.client.AgentCardJacksonModule; -import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; -import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; -import dev.openan.workflow.engine.control.EventCallback; -import dev.openan.workflow.engine.control.EventType; -import dev.openan.workflow.engine.examples.agents.EnvResolver; -import dev.openan.workflow.engine.model.SendMessageResult; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -/** - * SPN cross-city fault diagnosis demo entry point. - * - *

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

Architecture: - * - *

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

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

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

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

Mirrors Python: samples/start_agents_server.py - */ -public class StartAgentsServer implements Runnable { - static final String REGISTRY_URL = "https://127.0.0.1:5000"; - static final String ORCH_URL = "https://127.0.0.1:5001"; - static final String CRED_FILE = "spn_agent_credentials.json"; - static final String A2AT_ENV_FILE = ".env"; - private static final Logger log = LoggerFactory.getLogger(StartAgentsServer.class); - private static final ObjectMapper mapper = new ObjectMapper(); - - // Must be set before any HttpClient is created: the JDK caches this - // property in a static final field at class-load time. If a2a-java SDK - // or any other HttpClient is initialized first, setting it later has - // no effect and self-signed cert hostname verification will fail. - static { - System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); - } - - private final List servers = new ArrayList<>(); - private final CountDownLatch shutdownLatch = new CountDownLatch(1); - private volatile boolean running = true; - - @SuppressWarnings("unchecked") - private static AgentEntry loadAgent(String resourcePath, AgentExecutor executor) - throws Exception { - String path = StartAgentsServer.class.getClassLoader().getResource(resourcePath).getPath(); - Map card = mapper.readValue(new java.io.File(path), Map.class); - List> ifaces = - (List>) card.getOrDefault("supportedInterfaces", List.of()); - String url = - ifaces.isEmpty() ? "https://127.0.0.1:0" : String.valueOf(ifaces.get(0).get("url")); - URI uri = URI.create(url); - String host = uri.getHost() != null ? uri.getHost() : "127.0.0.1"; - int port = uri.getPort() > 0 ? uri.getPort() : 0; - return new AgentEntry(host, port, card, executor); - } - - private static void registerAgent(Map agentCard, boolean sslVerify) { - try { - RegistryClient registry = new RegistryClient(REGISTRY_URL, sslVerify); - registry.registerAgentCard(agentCard); - log.info("Registered agent: {}", agentCard.get("name")); - } catch (Exception e) { - log.warn("Registration failed for {}: {}", agentCard.get("name"), e.getMessage()); - } - } - - /** - * Resolve the A2AT {@code .env} file path. Searches classpath, then the project root (two - * levels up from the samples target/classes dir), then the current working directory. Returns - * null if not found. - */ - public static String resolveEnvPath() { - var url = StartAgentsServer.class.getClassLoader().getResource(A2AT_ENV_FILE); - if (url != null && "file".equals(url.getProtocol())) { - return new java.io.File(url.getPath()).getAbsolutePath(); - } - java.nio.file.Path cwd = java.nio.file.Paths.get(System.getProperty("user.dir")); - for (java.nio.file.Path dir = cwd; dir != null; dir = dir.getParent()) { - java.io.File candidate = dir.resolve(A2AT_ENV_FILE).toFile(); - if (candidate.exists()) { - return candidate.getAbsolutePath(); - } - } - return null; - } - - public static void main(String[] args) throws Exception { - StartAgentsServer server = new StartAgentsServer(); - Runtime.getRuntime().addShutdownHook(new Thread(server::stop)); - server.start(); - } - - @Override - public void run() { - try { - start(); - } catch (Exception e) { - log.error("Failed to start agents: {}", e.getMessage(), e); - } - } - - public void start() throws Exception { - boolean sslVerify = false; - String credPath = getClass().getClassLoader().getResource(CRED_FILE).getPath(); - String envPath = resolveEnvPath(); - log.info( - "A2AT env file: {}", - envPath != null ? envPath : "(not found, A2A-T extensions disabled)"); - List agents = - List.of( - loadAgent( - "agentcard/spn_domain_agent_city1.json", - new SpnDomainAgentCity1Executor()), - loadAgent( - "agentcard/spn_domain_agent_city2.json", - new SpnDomainAgentCity2Executor()), - loadAgent( - "agentcard/transport_workbench_agent.json", - new TransportWorkbenchAgentExecutor( - REGISTRY_URL, ORCH_URL, credPath, sslVerify, envPath))); - - for (AgentEntry entry : agents) { - try { - JdkHttpA2AServer server = - new JdkHttpA2AServer(entry.host, entry.port, entry.card, entry.executor); - server.start(); - servers.add(server); - registerAgent(entry.card, sslVerify); - log.info( - "Started agent: {} on https://{}:{}/", - entry.card.get("name"), - entry.host, - entry.port); - } catch (Exception e) { - log.error( - "Failed to start agent {}: {}", entry.card.get("name"), e.getMessage(), e); - } - } - - log.info("=== All agents started. Press Ctrl+C to stop. ==="); - shutdownLatch.await(); - } - - public void stop() { - running = false; - log.info("Shutting down all agents..."); - servers.forEach( - s -> { - try { - s.close(); - } catch (Exception ignored) { - } - }); - shutdownLatch.countDown(); - } - - private record AgentEntry( - String host, int port, Map card, AgentExecutor executor) {} -} From b14d21dc103cda67faa8dec9dcd2a8b9c80df600 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Sun, 23 Aug 2026 13:13:01 +0800 Subject: [PATCH 04/49] docs: sync API reference and guides with the SDK negotiation contract - API_REFERENCE (zh/en): template queries, negotiation auto-loop behavior (trigger, Accept/Reject/Abort, round-exhaustion abort), A2ATContentFacade surface, negotiationParamSchema config, internal metadata key conventions, full license headers note; error handling updated to the abort flow - DEVELOPER_GUIDE (zh/en): document the a2at.llm.disabled test switch - README: negotiation content layer and template query features - adaptation plan: mark reform items 1-6 as landed, note post-landing SDK contract changes (core.model.NegotiationContext, explicit context param, metadata-carried negotiation context) - notification-t-design-analysis: point to current file paths Signed-off-by: Zhoujie628 --- README.md | 2 + docs/en/API_REFERENCE.md | 72 ++- docs/en/DEVELOPER_GUIDE.md | 4 + docs/en/INTEGRATION_GUIDE.md | 51 ++ docs/en/notification-t-design-analysis.md | 5 +- docs/zh/API_REFERENCE.md | 77 ++- docs/zh/DEVELOPER_GUIDE.md | 4 + .../a2a-t-sdk-negotiation-adaptation-plan.md | 599 ++++++++++++++++++ ...06\346\210\220\346\214\207\345\215\227.md" | 81 +++ 9 files changed, 884 insertions(+), 11 deletions(-) create mode 100644 docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md diff --git a/README.md b/README.md index 93b195d9..5eabe783 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ The engine handles all protocol mechanics: A2A message transport, SSE streaming, ## Features - **A2A-T Extension Support**: Task-T (structured task prompts), Negotiation-T (auto negotiation loop), Authorization-T (pre-positioned whitelist), Notification-T (long-lived SSE subscription) +- **Negotiation Content Layer**: SDK-template rendering for propose / accept / reject / abort messages (typed-data and free-text variants), validate-and-fill param extraction across all three negotiation types (information / target / feasibility), round-exhaustion abort flow +- **Template Queries**: runtime enumeration of all A2A-T prompt templates (`getPrompts` / `getNegotiationPrompts` / `getPrompt`) - **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` diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index f85a370e..676afa18 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -75,6 +75,17 @@ public interface WorkflowEngineClient { void setEventCallback(EventCallback callback); + // A2A-T template queries (empty when the SDK is not configured; never throws) + List getPrompts(); // all extensions' templates + List getNegotiationPrompts(); // negotiation templates (3 types x 2 phases + abort) + Optional getPrompt(TemplateUri); // single template by URI + + // Task lifecycle (A2A protocol operations) + CompletableFuture getTask(String agentName, String taskId); + CompletableFuture cancelTask(String agentName, String taskId); + CompletableFuture subscribeToTask( + String agentName, String taskId, Consumer> callback); + void close(); } ``` @@ -104,7 +115,36 @@ After receiving: 1. Response text extraction from SSE events 2. Metadata extraction (task-level + artifact-level) -3. Negotiation-T auto-loop (if `INPUT_REQUIRED`) +3. Negotiation-T auto-loop (triggered when metadata carries the Negotiation-T key; see "Negotiation auto-loop behavior" below) + +#### Negotiation auto-loop behavior + +- **Trigger**: the response metadata carries the Negotiation-T extension key (the agent opened a + negotiation via `Message(ROLE_AGENT)` + `INPUT_REQUIRED`). +- **Each round**: `ControlPoint.onNegotiation` produces a clarification → the SDK content layer + renders the follow-up message (Accept template; a `reject:` / `abort:` prefix on the + clarification selects the Reject / Abort terminal template) → the SDK state machine advances + (AGREED / REJECTED) → send to the agent → recurse on the next round. +- **Round exhaustion**: beyond `maxNegotiationRounds` the loop stops, a terminal message is sent + via the SDK abort template (best effort — delivery failures are logged only), a + `NEGOTIATION_FAILED` event fires, and the last agent reply stands as the final response. +- **Negotiation context**: the SDK carries id/round/maxRounds in the message metadata's + `negotiationContext` key (not rendered in the message text); the engine parses it and passes it + to the validate APIs. + +#### Template queries + +The template queries expose the SDK content layer's template catalog and return +`PromptTemplate` (templateUri / description / content): + +- `getPrompts()`: every A2A-T extension's (Task-T / Notification-T / Authorization-T / + Negotiation-T) loadable templates for the configured language (`.env` `A2AT_LANGUAGE`), sorted by URI. +- `getNegotiationPrompts()`: the full negotiation set (propose and accept-reject for information, + target and feasibility negotiation, plus the common abort template), fixed order. +- `getPrompt(uri)`: one template by `TemplateUri`; empty when missing. + +Use the SDK's `StandardTemplates` constants for template URIs (e.g. +`StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`) instead of hand-written URI strings. ### ExtensionSender @@ -175,6 +215,7 @@ Builder-based configuration for the workflow engine client. | `a2atEnvPath` | `String` | null | Path to `.env` file | | `maxNegotiationRounds` | `int` | `3` | Max negotiation auto-loop rounds | | `customHandlers` | `List` | null | Custom extension handlers | +| `negotiationParamSchema` | `Map` | null | Business parameter JSON schema for the negotiation validate-and-fill; null = empty schema (context params only). Declaring your domain fields keeps the engine core domain-agnostic | ```java WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() @@ -220,11 +261,32 @@ public interface ExtensionHandler { AgentCard agentCard, SendMessageResult result, A2ATClient a2atClient, ControlPoint controlPoint, EventCallback eventCallback); + + // Business parameter schema for the negotiation validate-and-fill (default: empty schema) + Map negotiationParamSchema(); } ``` Built-in: Task-T and Negotiation-T are handled automatically. You can register custom handlers via customHandlers in the -config. +config (a later registration with the same keyword overrides the built-in). + +### A2ATContentFacade + +Engine facade over the SDK's negotiation content layer, obtained via +`A2ATransport.getContentFacade()` (null when no `.env` is configured). It exposes the SDK's full +message-generation / validation / template-query surface, one method per `A2ATClient` counterpart: + +| Group | Methods | Notes | +|-------|---------|-------| +| fromData generation | `generateProposeFromData` / `generateAcceptFromData` / `generateRejectFromData` / `generateAbortFromData` | deterministic rendering from typed data, no LLM | +| fromText generation | `generateProposeFromText` / `generateAcceptFromText` / `generateRejectFromText` / `generateAbortFromText` | free text + one LLM extraction step | +| Runtime state machine | `startNegotiation` / `receiveNegotiation` / `continueNegotiation` | SDK negotiation round progression | +| Validate and fill | `validatePropose` / `validateAccept` / `validateReject` / `validateAbort` | rule gate + LLM semantic validation + param merge; requires a `NegotiationContext` (null = not a negotiation message) | +| Template queries | `getPrompts` / `getNegotiationPrompts` / `getPrompt` / `getNegotiationPrompt` | catalog enumeration and lookup | +| Utility | `toMetadata(MetadataContent)` | generated message → A2A metadata map (with `templateUri`, `negotiationContext` keys) | + +Pass template URIs via `StandardTemplates` constants. Failures throw `A2ATError` subtypes; +callers are expected to catch and degrade. ### A2AJavaClientRuntime @@ -601,7 +663,11 @@ Task lifecycle status, used in `TASK_STATUS_CHANGED` events for cross-SDK consis ## Error Handling - Agent call failures throw `RuntimeException` wrapping the cause. -- Negotiation failures fall through after `maxNegotiationRounds`. +- Negotiation beyond `maxNegotiationRounds` rounds: a `NEGOTIATION_FAILED` event fires, a terminal + message is sent to the agent via the SDK abort template (best effort), and the last agent reply + stands as the final response; the loop does not continue. +- SDK content-generation failures for negotiation follow-ups degrade to the raw clarification text + (placed under the Negotiation-T key), logged at `WARN`. - Auth failures (401) are logged as `ERROR` and auth headers are not set; the request proceeds without auth. - SSE stream errors after terminal events are logged at `DEBUG` level (expected behavior). diff --git a/docs/en/DEVELOPER_GUIDE.md b/docs/en/DEVELOPER_GUIDE.md index e4a06371..6564a9ae 100644 --- a/docs/en/DEVELOPER_GUIDE.md +++ b/docs/en/DEVELOPER_GUIDE.md @@ -254,6 +254,10 @@ A2AT_CRED_KEY=<32-byte hex> When `a2atEnvPath` is null, Task-T prompt generation is skipped. +**Disabling the LLM layer in tests**: set the system property `a2at.llm.disabled=true` to make the +sample agents skip A2ATClient/A2ATServer initialization entirely — negotiation falls back to +default texts, no LLM calls are made. Used by the sample test suites for hermetic runs. + ## 10. Integration Patterns ### SSE Server (Spring WebFlux) diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index eb7869e1..0d701e20 100644 --- a/docs/en/INTEGRATION_GUIDE.md +++ b/docs/en/INTEGRATION_GUIDE.md @@ -582,6 +582,57 @@ WorkflowEngineClientConfig.builder() .build(); ``` +## 14. Troubleshooting: Eastcom Order Mode Concurrent NE Timeout + +> **Critical pitfall — read before modifying the Order simulator or gateway adapter.** + +### Symptom + +In Order mode, when sending Task-T diagnosis tasks to two different NEs concurrently (or after pre-positioning Notification-T to City1, sending Authorization-T to City2), one NE's request fails with `Timeout on blocking read for 10000000000 NANOSECONDS` in `WrapperHttpClient.loadNeResource`. + +### Root Cause 1: SDK Shared RSocket Connection + +The Eastcom SDK's `ServiceReference` caches RSocket connections in a `static final Map INVOKERS` keyed by `className + host + port`. All requests to the same platform address share one RSocket connection, regardless of the NE name. + +`WrapperHttpClient.loadNeResource()` calls `Mono.block(Duration.ofSeconds(10))` — a hardcoded 10-second timeout that cannot be changed via configuration. + +### Root Cause 2: Blocking I/O on RSocket Event Loop + +The `EastcomOrderSimulatorServer.execute()` method (handling RSocket `requestChannel`) calls `forwardParsed()` which uses `HttpURLConnection` with blocking `reader.read()`. When City1's Notification-T stream is long-lived (waiting for OMC to push recovery events), `reader.read()` blocks indefinitely. If this runs on the RSocket event loop thread (`reactor-tcp-nio-*`), the thread is occupied and cannot process City2's `loadNeResource` (RSocket `requestResponse`) on the same connection — causing the 10-second timeout. + +### Fix 1: Move Forwarding I/O Off the Event Loop Thread + +Add `.subscribeOn(Schedulers.boundedElastic())` to the `forwardParsed` call so blocking I/O runs on worker threads, freeing the RSocket event loop: + +```java +forwardParsed(httpMethod, httpPath, parsedHeaders, body, forwardUrl, streaming) + .subscribeOn(Schedulers.boundedElastic()) + .subscribe(sink::next, sink::error, () -> sink.complete()); +``` + +### Fix 2: Use HTTP Host Header Instead of Shared `lastResolvedTarget` + +The original simulator used a `volatile String lastResolvedTarget` field set by `loadNeResource` and read by `execute`. When two NEs call `loadNeResource` concurrently, the second overwrites the first's target. The SDK's `configureHeaderHost()` sets the HTTP `Host` header to the full `neUrl` per request, so each request carries its own target: + +```java +String hostHeader = findHeader(parsedHeaders, "Host"); +String targetBase = (hostHeader != null && hostHeader.startsWith("http")) + ? withoutTrailingSlash(hostHeader) + : (lastResolvedTarget != null ? lastResolvedTarget : defaultUrl); +``` + +### Why `readTimeout=65s` Alone Doesn't Help + +The simulator's `connection.setReadTimeout(65_000)` only controls `HttpURLConnection`'s read timeout. It does not affect the SDK's internal `Mono.block(10s)` in `loadNeResource`. The root cause is the RSocket event loop being blocked, not HTTP read timeout. + +### Diagnostic Checklist + +1. Confirm timeout location: `WrapperHttpClient.loadNeResource` in stack trace = RSocket timeout, not HTTP +2. Confirm thread: `FORWARD_CHUNK`/`FORWARD_DONE` logs should show `boundedElastic-*`, not `reactor-tcp-nio-*` +3. Confirm target: `FORWARD_START target=` should match `LOAD_NE_ACCEPTED target=` for the same NE +4. Confirm session reuse: `SESSION_REUSE` in logs, not `SESSION_OPEN` every time +5. Confirm singleton: `ClientRuntimeFactory.create()` returns singleton in ORDER mode + ## 13. Interface Reference | Interface/Class | Purpose | diff --git a/docs/en/notification-t-design-analysis.md b/docs/en/notification-t-design-analysis.md index dad4d835..a0638ff5 100644 --- a/docs/en/notification-t-design-analysis.md +++ b/docs/en/notification-t-design-analysis.md @@ -190,9 +190,8 @@ This pattern is correct and requires no modification. - SPN agent base: samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationBaseAgentExecutor.java - SPN agent city1: samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java - SPN agent city2: samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java -- Workbench control: samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchControlPoint.java -- Demo entry: samples/src/main/java/dev/openan/workflow/engine/examples/SpnCrossCityDiagnosisDemo.java -- Agent server: samples/src/main/java/dev/openan/workflow/engine/examples/embedded/StartAgentsServer.java +- Workbench control: samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchControlPoint.java +- Demo entry: samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemo.java - E2E test: samples/src/test/java/dev/openan/workflow/engine/examples/SpnCrossCityE2ETest.java - AgentCard city1: samples/src/main/resources/agentcard/spn_domain_agent_city1.json - AgentCard city2: samples/src/main/resources/agentcard/spn_domain_agent_city2.json diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index e80f19bf..deb5c2ef 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -73,9 +73,19 @@ public interface WorkflowEngineClient { void setControlPoint(ControlPoint controlPoint); - void setEventCallback(EventCallback callback); + // A2A-T 模板查询(SDK 未配置时返回空集合,不抛异常) + List getPrompts(); // 全部扩展的可用模板 + List getNegotiationPrompts(); // 协商模板(3 类型 x 2 阶段 + abort) + Optional getPrompt(TemplateUri); // 按 URI 查询单个模板 + + // 任务生命周期(A2A 协议操作) + CompletableFuture getTask(String agentName, String taskId); + CompletableFuture cancelTask(String agentName, String taskId); + CompletableFuture subscribeToTask( + String agentName, String taskId, Consumer> callback); + void close(); } ``` @@ -104,7 +114,32 @@ public interface WorkflowEngineClient { 1. 从 SSE 事件提取响应文本 2. 提取 metadata(task 级 + artifact 级合并) -3. Negotiation-T 自动循环(如果 `INPUT_REQUIRED`) +3. Negotiation-T 自动循环(metadata 携带 Negotiation-T key 时触发;见下方「协商自动循环行为」) + +#### 协商自动循环行为 + +- **触发条件**:响应 metadata 携带 Negotiation-T 扩展 key(agent 通过 `Message(ROLE_AGENT)` + + `INPUT_REQUIRED` 发起协商)。 +- **每轮**:`ControlPoint.onNegotiation` 生成澄清文本 → SDK 内容层渲染 follow-up 消息 + (Accept 模板;clarification 以 `reject:` / `abort:` 前缀开头则渲染 Reject / Abort 模板)→ + SDK 状态机推进(AGREED / REJECTED)→ 发送给 agent → 递归检查下一轮。 +- **轮次耗尽**:超过 `maxNegotiationRounds` 后不再继续循环,通过 SDK abort 模板发送终止消息 + (尽力而为,发送失败仅记日志),发出 `NEGOTIATION_FAILED` 事件,agent 最后一次回复作为最终响应。 +- **协商上下文**:SDK 将 id/round/maxRounds 放在消息 metadata 的 `negotiationContext` key 中 + (不在消息文本里渲染),引擎解析后传给 validate API。 + +#### 模板查询 + +模板查询暴露 SDK 内容层的模板目录,返回 `PromptTemplate`(templateUri / description / content): + +- `getPrompts()`:全部 A2A-T 扩展(Task-T / Notification-T / Authorization-T / Negotiation-T) + 在当前语言(`.env` 的 `A2AT_LANGUAGE`)下的可用模板,按 URI 排序。 +- `getNegotiationPrompts()`:协商模板全集(信息/目标/可行性协商的 propose 与 accept-reject, + 加通用 abort 模板),固定顺序。 +- `getPrompt(uri)`:按 `TemplateUri` 查单个模板;不存在时返回空。 + +模板 URI 常量用 SDK 的 `StandardTemplates`(如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`), +避免手写 URI 字符串。 ### ExtensionSender @@ -172,8 +207,9 @@ public interface ExtensionSender { | `credentialsConfigPath` | `String` | null | 凭证 JSON 文件路径;显式配置缺失或损坏时启动失败 | | `credentialsConfig` | `Map` | null | 内联凭证配置;AgentCard 声明安全要求时必须匹配 | | `a2atEnvPath` | `String` | null | `.env` 文件路径 | -| `maxNegotiationRounds` | `int` | `3` | 协商自动循环最大轮数 | +| `maxNegotiationRounds` | `int` | `3` | 协商自动循环最大轮数(超限走 abort 终止) | | `customHandlers` | `List` | null | 自定义扩展处理器 | +| `negotiationParamSchema` | `Map` | null | 协商 validate-and-fill 的业务参数 JSON Schema;null = 空 schema(仅提取协商上下文参数)。声明业务字段让引擎核心保持领域无关 | ```java WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() @@ -219,10 +255,30 @@ public interface ExtensionHandler { AgentCard agentCard, SendMessageResult result, A2ATClient a2atClient, ControlPoint controlPoint, EventCallback eventCallback); + + // 协商 validate-and-fill 的业务参数 schema(默认空 schema) + Map negotiationParamSchema(); } ``` -内置:Task-T 和 Negotiation-T 自动处理。可通过配置的 customHandlers 注册自定义处理器。 +内置:Task-T 和 Negotiation-T 自动处理。可通过配置的 customHandlers 注册自定义处理器 +(同 keyword 后注册的覆盖内置)。 + +### A2ATContentFacade + +SDK 协商内容层的引擎门面,经 `A2ATransport.getContentFacade()` 获取(未配置 `.env` 时为 null)。 +SDK 消息生成/校验/模板查询的完整能力面,方法与 SDK `A2ATClient` 一一对应: + +| 分组 | 方法 | 说明 | +|------|------|------| +| fromData 生成 | `generateProposeFromData` / `generateAcceptFromData` / `generateRejectFromData` / `generateAbortFromData` | 类型化数据确定性渲染,无 LLM | +| fromText 生成 | `generateProposeFromText` / `generateAcceptFromText` / `generateRejectFromText` / `generateAbortFromText` | 自由文本 + 一步 LLM 抽取 | +| 运行时状态机 | `startNegotiation` / `receiveNegotiation` / `continueNegotiation` | SDK 协商轮次推进 | +| 校验提取 | `validatePropose` / `validateAccept` / `validateReject` / `validateAbort` | 规则门 + LLM 语义校验 + 参数合并;需传 `NegotiationContext`(null = 非协商消息) | +| 模板查询 | `getPrompts` / `getNegotiationPrompts` / `getPrompt` / `getNegotiationPrompt` | 模板目录枚举与查询 | +| 工具 | `toMetadata(MetadataContent)` | 生成消息转 A2A metadata map(含 `templateUri`、`negotiationContext` key) | + +模板 URI 用 `StandardTemplates` 常量传参。失败抛 `A2ATError` 子类,调用方应捕获并降级。 ### A2AJavaClientRuntime @@ -587,6 +643,15 @@ ExecutionResult result = executor.run().join(); > 不在 AgentCard 上声明,不被 ExtensionHandler 处理,也不出现在 > `A2A-Extensions` 头中。 +> **协商相关 metadata key 约定:** +> +> - `negotiationContext`(SDK 契约):SDK 生成协商消息时写入,携带 `{id, round, maxRounds}`。 +> validate API 依赖它区分协商消息与普通消息。 +> - `templateUri`(SDK 契约):SDK 生成消息时写入,标识渲染所用模板。 +> - `negotiation_context` / `negotiation_message` / `negotiation_params`(引擎内部): +> `NegotiationTHandler` 接收协商消息后写入 result metadata,供自动循环和调用方读取。 +> 常量定义在 `A2ATExtension`(`NEGOTIATION_CONTEXT_META_KEY` 等)。 + --- ## 线程安全 @@ -598,7 +663,9 @@ ExecutionResult result = executor.run().join(); ## 错误处理 - 智能体调用失败抛出 `RuntimeException`(包装原始异常)。 -- 协商失败在 `maxNegotiationRounds` 轮后终止。 +- 协商超过 `maxNegotiationRounds` 轮后:发出 `NEGOTIATION_FAILED` 事件,通过 SDK abort + 模板向 agent 发送终止消息(尽力而为),agent 最后一次回复作为最终响应;循环不再继续。 +- 协商 follow-up 的 SDK 内容生成失败时降级为裸澄清文本(原文放入 Negotiation-T key),记 `WARN`。 - 认证失败(401)记为 `ERROR` 日志,认证头不设置,请求继续发出。 - SSE 流在终态事件后的连接关闭日志为 `DEBUG` 级别(预期行为)。 diff --git a/docs/zh/DEVELOPER_GUIDE.md b/docs/zh/DEVELOPER_GUIDE.md index 8c4d7917..8764521c 100644 --- a/docs/zh/DEVELOPER_GUIDE.md +++ b/docs/zh/DEVELOPER_GUIDE.md @@ -247,6 +247,10 @@ A2AT_CRED_KEY=<32字节hex> `a2atEnvPath` 为 null 时,Task-T 提示词生成跳过。 +**测试中禁用 LLM 层**:设置系统属性 `a2at.llm.disabled=true`,示例 agent 将完全跳过 +A2ATClient/A2ATServer 初始化 — 协商降级为默认文本,不发起任何 LLM 调用。示例测试套件 +用它在无外部依赖环境下运行。 + ## 10. 集成模式 ### SSE Server(Spring WebFlux) diff --git a/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md b/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md new file mode 100644 index 00000000..a1e90af8 --- /dev/null +++ b/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md @@ -0,0 +1,599 @@ +# 执行引擎适配 A2A-T SDK 协商内容层改造方案 + +> **状态(2026-08-23):改造一 ~ 六已全部落地。** 引擎侧入口见 `A2ATContentFacade`(SDK 内容层门面)、 +> `WorkflowEngineClient.getPrompts/getNegotiationPrompts/getPrompt`(模板查询)、 +> `WorkflowEngineClientConfig.negotiationParamSchema`(业务 schema 注入)。本文档以下内容保留为 +> 设计依据;文中 API 签名是改造前的 SDK 形态,**当前签名以 +> `docs/zh/API_REFERENCE.md` 为准**(后续 SDK 已将 `NegotiationContext` 移至 +> `core.model`、validate API 增加显式 context 参数、协商上下文改由 metadata `negotiationContext` +> key 携带)。 + +> 本文档基于对 a2a-t-sdk-java(v1.0.0)和 workflow-exec-engine-java(v1.0.0)最新代码的逐文件分析,给出执行引擎接入 SDK 协商内容生成与校验能力的详细改造方案。 + +--- + +## 一、现状:执行引擎当前用了 SDK 的什么 + +执行引擎目前只用了 SDK 的 **运行时状态机**(startNegotiation / receiveNegotiation / continueNegotiation 三个方法),完全没有用 **内容生成引擎**(generateNegotiationProposePromptFromData/fromText 等 6 个方法)和 **校验参数提取**(validateAndFillingProposeData/AcceptData/RejectData)这两条流水线。 + +### 1.1 当前对接链路 + +**Client 侧(引擎发起方)** + +``` +DefaultWorkflowEngineClient.sendMessage() + -> transport.send() # A2A 消息发到 agent + -> NegotiationTHandler.afterReceive() # agent 返回 INPUT_REQUIRED 时触发 + -> a2atClient.receiveNegotiation() # SDK 状态机:解析 context + echo 处理 + -> autoNegotiate() # 自动循环 + -> controlPoint.onNegotiation() # 生成澄清文本(纯业务逻辑,非 SDK) + -> a2atClient.continueNegotiation() # SDK 状态机:推进到 AGREED + -> buildFallbackMeta(clarification) # 手动组装 metadata(非 SDK 模板) + -> transport.send() # 发 follow-up 给 agent +``` + +**Server 侧(agent 执行器)** + +``` +NegotiationBaseAgentExecutor.handleNewTask() + -> client.startNegotiation(TARGET, text, facts) # SDK 状态机:发起协商 + -> 从 payload 手动提取 context + text # 非 SDK 封装 + -> negotiationResponseMetadata() # 手动组装 metadata + -> emit INPUT_REQUIRED +``` + +### 1.2 当前协商消息的内容来源 + +协商消息的文本内容不是 SDK 生成的,是业务代码硬编码或 LLM 直接输出的。具体在三个地方: + +1. **发起侧**(NegotiationBaseAgentExecutor):defaultNegotiationText() 返回固定字符串如 "Please confirm the task parameters so I can proceed.",直接塞进 startNegotiation 的 contentText 参数。SDK 的 startNegotiation 只做状态机初始化,不做模板渲染。 + +2. **澄清侧**(DefaultControlPoint.onNegotiation / NegotiationStrategy.resolve):返回纯文本 clarification,不走 SDK 的内容生成。autoNegotiate 把它包进 [NEGOTIATION_RESOLUTION] 格式的字符串,这个格式是引擎自己定义的(NegotiationUtils.buildResolutionMessage),不是 SDK 的协商模板格式。 + +3. **fallback metadata**(buildFallbackMeta):当 continueNegotiation 失败时,用 A2ATExtension.NEGOTIATION_T.uri() 做 key,直接拼字符串。 + +### 1.3 依赖声明 + +引擎在 pom.xml 中声明: + +```xml + + 1.0.0 + + + + net.openan.a2a-t.sdk + a2a-t-client + ${a2a.t.sdk.version} + +``` + +a2a-t-client 通过传递依赖引入了 a2a-t-negotiation 模块,内容生成引擎和校验流水线已可用,无需额外加依赖。 + +--- + +## 二、差距:SDK 已有但引擎未用的能力 + +SDK 的 NegotiationContentService(通过 A2ATClient 暴露)提供了完整的协商内容生成和校验能力,引擎完全没有调用。 + +### 2.1 内容生成引擎(未接入) + +SDK 有 6 个生成方法,覆盖 3 种协商类型 x 2 种输入方式 x 3 个阶段: + +| 方法 | 作用 | LLM 依赖 | 引擎是否调用 | +|---|---|---|---| +| generateNegotiationProposePromptFromData | 从类型化数据生成提议消息 | 无 | 否 | +| generateNegotiationProposePromptFromText | 从自由文本生成提议消息 | 有(一步抽取) | 否 | +| generateNegotiationAcceptPromptFromData | 从类型化数据生成接受消息 | 无 | 否 | +| generateNegotiationAcceptPromptFromText | 从自由文本生成接受消息 | 有 | 否 | +| generateNegotiationRejectPromptFromData | 从类型化数据生成拒绝消息 | 无 | 否 | +| generateNegotiationRejectPromptFromText | 从自由文本生成拒绝消息 | 有 | 否 | + +这些方法返回 MetadataContent(templateUri, promptText, extensionUri),调用 buildMetadataContent() 直接生成可放入 A2A 消息的 metadata map,引擎不需要手动组装。 + +### 2.2 类型化内容模型(未使用) + +SDK 定义了完整的类型化内容 record,引擎可以用这些结构化数据驱动协商,而不是用裸字符串: + +**Propose 阶段内容** + +| 类型 | 结构 | 适用场景 | +|---|---|---| +| InfoProposeContent | items: List, relationship: String? | 信息协商:缺失信息项列表 | +| TargetProposeContent | targetNegotiationDescription, intentUnderstanding?, alignmentAndClarification?, requestForClarification? | 目标协商:目标描述 + 意图理解 + 对齐澄清 + 待澄清 | +| FeasibilityProposeContent | feasibilityNegotiationDescription, action, contentsToEvaluate?, infeasibilityDetailsAndProposal? | 可行性协商:描述 + 动作 + 评估内容 / 不可行详情 | + +**Ending 阶段内容** + +| 类型 | 结构 | 适用场景 | +|---|---|---| +| InfoEndingContent | conclusion, items | 信息协商终态 | +| TargetEndingContent | conclusion, confirmedIntent, failureReason | 目标协商终态(accept 带 confirmedIntent,reject 带 failureReason) | +| FeasibilityEndingContent | conclusion, feasibilitySummary | 可行性协商终态 | + +FeasibilityProposeContent 有一个 NegotiationAction 字段,两种取值: +- REQUEST_FEASIBILITY_EVALUATION —— 请求对端评估可行性,必须带 contentsToEvaluate +- PROPOSE_ALTERNATIVE_ON_FAILURE —— 报告不可行并提议替代方案,必须带 infeasibilityDetailsAndProposal + +当前引擎用的是 NegotiationType.TARGET,但 TargetNegotiation handler 是空壳 echo,协商消息内容是 defaultNegotiationText() 硬编码的。 + +### 2.3 校验与参数提取(未接入) + +SDK 的 validateAndFillingProposeData/AcceptData/RejectData 可以校验已渲染的协商消息文本并提取参数。管线分为三步: + +1. **规则门**(确定性,无 LLM):按 ## 分段,识别是否含协商上下文段,校验 id 为 UUID 格式、round/maxRounds 为正整数、round 不超 maxRounds +2. **LLM 语义校验**(可重试):一步 LLM 结构化调用,同时产出 verdict(通过/拒绝)、implied type、语义错误列表、按 callerSchema 提取的参数 +3. **参数合并**(确定性):先写 context 参数(id、round、maxRounds),再写 LLM 提取参数,冲突时 context 参数优先 + +引擎在 NegotiationTHandler.afterReceive 里接收协商消息时没有做内容校验,直接交给 receiveNegotiation echo 处理。 + +### 2.4 模板查询(未接入) + +SDK 的 getNegotiationPrompts() / getNegotiationPrompt(uri) 可以列出和查询可用的协商模板。引擎没有用这个能力,模板选择是隐式的(硬编码在 startNegotiation 调用里)。 + +### 2.5 Vocabulary 多语言(未使用) + +SDK 的 Vocabulary 支持 zh-CN 和 en-US,协商消息的 section 标题和 slot 名都从 vocabulary 取。引擎的 .env 配了 A2AT_LANGUAGE=zh-CN,但协商消息内容没有走模板渲染,所以这个语言设置实际只作用于 Task-T 侧(场景识别和 slot 抽取),没作用于协商消息。 + +--- + +## 三、改造方案 + +改造分三个层次,按依赖关系排序。 + +### 改造一:协商消息发起层 —— 用内容生成替代硬编码 + +> ✅ 已落地:`NegotiationBaseAgentExecutor.renderProposeText` + 子类扩展点 `negotiationType()` / `buildProposeContent()` / `proposeTemplateUri()`。 + + +**目标**:server 侧发起协商时,用 SDK 的模板渲染生成结构化协商消息,而不是 defaultNegotiationText() 硬编码。 + +**当前代码路径** + +- samples/.../NegotiationBaseAgentExecutor.java 的 startNegotiation 方法 +- 调用 client.startNegotiation(NegotiationType.TARGET, defaultNegotiationText(), facts) +- defaultNegotiationText() 返回固定字符串 + +**改造方式** + +把 startNegotiation 拆成两步 —— 先用内容生成引擎渲染消息,再用状态机初始化协商上下文: + +```java +// 步骤 1:用 SDK 内容生成引擎渲染结构化协商消息 +NegotiationContext contentCtx = new NegotiationContext( + UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS); +TargetProposeContent content = new TargetProposeContent( + targetDescription, // targetNegotiationDescription(必填) + intentUnderstandingItems, // intentUnderstanding(首轮有,后续 null) + null, // alignmentAndClarification(后续轮有,首轮 null) + requestForClarificationItems // requestForClarification(待澄清项) +); +NegotiationProposeData data = new NegotiationProposeData(contentCtx, content); +MetadataContent mc = client.generateNegotiationProposePromptFromData( + data, "Negotiation-T/v1/target-negotiation/propose"); + +// 步骤 2:用渲染好的消息文本初始化协商状态机 +Map facts = new LinkedHashMap<>(); +facts.put("agent", getClass().getSimpleName()); +facts.put("input", input); +Map neg = client.startNegotiation( + NegotiationType.TARGET, mc.promptText(), facts); + +// 步骤 3:把生成的 metadata 和状态机 payload 合并 +Map metadata = mc.buildMetadataContent(); +metadata.putAll(neg); // 协商 context 附在消息上 +``` + +这样 agent 返回的 INPUT_REQUIRED 消息就是 SDK 模板渲染的结构化文本(带 ## Negotiation Context、## Target Negotiation 等 section),而不是 "Please confirm the task parameters" 这种裸字符串。 + +**改造范围** + +NegotiationBaseAgentExecutor 需要新增一个抽象方法让子类提供 TargetProposeContent(或 InfoProposeContent / FeasibilityProposeContent),替代 defaultNegotiationText(): + +```java +// 新增抽象方法 +protected abstract NegotiationProposeContent buildProposeContent(String input); + +// 保留旧方法做降级 +protected String defaultNegotiationText() { ... } +``` + +**fromText 变体** + +如果子类不想构造类型化数据,可以用 generateNegotiationProposePromptFromText 走 LLM 抽取: + +```java +MetadataContent mc = client.generateNegotiationProposePromptFromText( + naturalLanguageDescription, contentCtx, + "Negotiation-T/v1/target-negotiation/propose"); +``` + +这会多一步 LLM 调用(结构化抽取),但调用方只需提供自由文本。 + +### 改造二:协商自动循环层 —— 用内容生成替代手动组装 + +> ✅ 已落地:`DefaultWorkflowEngineClient.buildNegotiationFollowUpMeta`(含 Reject/Abort 终态分类与 SDK 状态机推进)。 + + +**目标**:client 侧 autoNegotiate 生成 follow-up 消息时,用 SDK 的内容生成替代 buildFallbackMeta 手动拼字符串。 + +**当前代码路径** + +- workflow-engine/.../DefaultWorkflowEngineClient.java 的 autoNegotiate 和 buildNegotiationFollowUpMeta 方法 +- buildNegotiationFollowUpMeta 尝试调 continueNegotiation,失败时用 buildFallbackMeta 拼字符串 + +**改造方式** + +在 autoNegotiate 的 clarification 生成后,用 SDK 渲染 follow-up 消息: + +```java +A2ATClient a2atClient = transport.getA2atClient(); +if (a2atClient != null) { + // 用 SDK 生成 propose 阶段的 follow-up 消息 + NegotiationContext contentCtx = new NegotiationContext( + negotiationId, round, maxRounds); + // clarification 作为 TargetProposeContent 的 alignmentAndClarification + TargetProposeContent content = new TargetProposeContent( + "Clarification for " + agentName, + null, // intentUnderstanding(非首轮) + List.of(new NegotiationItem("clarification", clarification)), // alignment + null // requestForClarification + ); + NegotiationProposeData data = new NegotiationProposeData(contentCtx, content); + MetadataContent mc = a2atClient.generateNegotiationProposePromptFromData( + data, "Negotiation-T/v1/target-negotiation/propose"); + + // SDK continueNegotiation 推进状态机 + Map payload = a2atClient.continueNegotiation( + context, NegotiationStatus.AGREED, mc.promptText()); + + // metadata 直接用 SDK 生成 + followUpMeta = new HashMap<>(mc.buildMetadataContent()); + followUpMeta.putAll(payload); +} else { + // 无 SDK 时降级到旧的 fallback 逻辑 + followUpMeta = buildFallbackMeta(clarification); +} +``` + +这样 follow-up 消息也是模板渲染的结构化文本,可以逐步替代 [NEGOTIATION_RESOLUTION] 这种自定义标记格式。 + +**协商策略接口改造** + +NegotiationStrategy.resolve 当前返回 CompletableFuture(纯文本 clarification)。改造后可以增加一个变体返回类型化内容: + +```java +interface NegotiationContentStrategy { + CompletableFuture resolveContent( + String agentName, String negotiationText, Map receiveResult); +} +``` + +DefaultControlPoint 持有这个策略,在 onNegotiation 里调用它获取类型化内容,交给 SDK 渲染。不愿意构造类型化数据的用户仍用旧的 resolve 返回纯文本,走 fromText 变体。 + +### 改造三:协商消息接收校验层 —— 用 validateAndFilling 替代裸 receive + +> ✅ 已落地:`NegotiationTHandler.afterReceive` 按类型路由 validate 模板;业务 schema 经 `negotiationParamSchema` 配置注入(引擎核心不再硬编码业务字段)。 + + +**目标**:NegotiationTHandler.afterReceive 接收协商消息后,用 SDK 校验消息格式并提取参数,而不是只做 echo。 + +**当前代码路径** + +- workflow-engine/.../NegotiationTHandler.java 的 afterReceive +- 调用 a2atClient.receiveNegotiation(result.getText(), contextMap) +- SDK 的 receiveNegotiation 只做状态机轮次校验 + echo(TargetNegotiation handler 是空壳) + +**改造方式** + +在 receiveNegotiation 之后追加一步 validateAndFillingProposeData: + +```java +Map receiveResult = + a2atClient.receiveNegotiation(result.getText(), contextMap); + +if (Boolean.TRUE.equals(receiveResult.get("needResponse"))) { + try { + FilledParamData paramData = a2atClient.validateAndFillingProposeData( + result.getText(), + callerSchema, // 调用方提供的参数 JSON schema + "Negotiation-T/v1/target-negotiation/propose" + ); + // 提取的参数可用于后续路由决策 + metadata.put("negotiation_params", paramData.data()); + } catch (NegotiationParamExtractionException e) { + // 校验失败:消息不是合法的协商消息,或语义校验拒绝 + log.warn("[Negotiation-T] validation failed: code={} msg={}", + e.getCode(), e.getMessage()); + } +} +``` + +这需要 NegotiationTHandler 能拿到 callerSchema(参数 schema)。可以通过 ExtensionHandler 接口扩展一个 schema provider,或者从 AgentCard 的扩展声明里读取。 + +**无 LLM 降级** + +validateAndFillingProposeData 的管线是:规则门(确定性,无 LLM)-> LLM 语义校验 -> 参数合并。其中 LLM 语义校验需要配置 LLM client。如果 .env 里没配 LLM,语义校验会失败并返回 negotiation_llm_infrastructure_error。规则门部分(识别协商上下文段、校验 id/round/maxRounds)不需要 LLM,可以独立工作。 + +### 改造四:协商终态消息 —— 接入 accept/reject 生成 + +> ✅ 已落地:Accept/Reject/Abort 三终态 + 轮次耗尽 abort 流程。 + + +**目标**:协商结束时(AGREED 或 REJECTED),用 SDK 生成终态消息,而不是靠 continueNegotiation 的 echo。 + +**当前代码路径** + +- DefaultWorkflowEngineClient.autoNegotiate 调用 continueNegotiation(context, NegotiationStatus.AGREED, clarification) +- SDK 的 InformationNegotiation.renderContinue 在 AGREED 时产出 finalTaskPrompt,但 TargetNegotiation 没有这个逻辑 + +**改造方式** + +在协商达成一致或拒绝时,用 SDK 生成终态消息: + +```java +// 协商达成一致 +NegotiationEndingData acceptData = new NegotiationEndingData( + contentCtx, + new TargetEndingContent(NegotiationConclusion.ACCEPT, confirmedIntent, null) +); +MetadataContent acceptMc = a2atClient.generateNegotiationAcceptPromptFromData( + acceptData, "Negotiation-T/v1/target-negotiation/accept-reject"); + +// 协商拒绝 +NegotiationEndingData rejectData = new NegotiationEndingData( + contentCtx, + new TargetEndingContent(NegotiationConclusion.REJECT, null, failureReason) +); +MetadataContent rejectMc = a2atClient.generateNegotiationRejectPromptFromData( + rejectData, "Negotiation-T/v1/target-negotiation/accept-reject"); +``` + +### 改造五:协商类型选择 —— 从单一 TARGET 扩展到三种 + +> ✅ 已落地:三种类型全部支持(发送侧子类声明、接收侧按 `negotiationType` 路由模板)。 + + +**目标**:根据业务场景选择合适的协商类型,而不是固定用 TARGET。 + +**当前代码** + +NegotiationBaseAgentExecutor.startNegotiation 硬编码 NegotiationType.TARGET。 + +**改造方式** + +新增抽象方法让子类声明协商类型和对应的 content 类型: + +```java +protected abstract NegotiationType negotiationType(); +protected abstract NegotiationProposeContent buildProposeContent(String input); +``` + +各场景的映射建议: + +| 业务场景 | 协商类型 | content 类型 | 适用时机 | +|---|---|---|---| +| 参数不足,需补充信息 | INFORMATION | InfoProposeContent | agent 缺少执行所需字段 | +| 意图理解偏差,需对齐 | TARGET | TargetProposeContent | agent 和 engine 对任务目标理解不一致 | +| 无法执行,需评估可行性 | FEASIBILITY | FeasibilityProposeContent | agent 不确定能否完成请求 | + +### 改造六:模板查询与动态选择 + +> ✅ 已落地:`WorkflowEngineClient.getPrompts/getNegotiationPrompts/getPrompt`。 + + +**目标**:运行时查询可用模板,动态选择 templateUri,而不是硬编码。 + +```java +// 列出所有可用协商模板 +List templates = a2atClient.getNegotiationPrompts(); +// 查询特定模板 +Optional template = a2atClient.getNegotiationPrompt( + "Negotiation-T/v1/target-negotiation/propose"); +``` + +这可以在 NegotiationBaseAgentExecutor 初始化时调用,把可用模板列表暴露给子类做选择。 + +--- + +## 四、改造优先级 + +| 优先级 | 改造 | 价值 | 风险 | 改动模块 | +|---|---|---|---|---| +| P0 | 改造一:发起层用内容生成 | 协商消息从裸字符串变为结构化模板文本,是后续所有改造的基础 | 低,只改 sample 侧 | samples | +| P0 | 改造二:自动循环用内容生成 | follow-up 消息标准化,逐步消除 [NEGOTIATION_RESOLUTION] 自定义格式 | 中,改 DefaultWorkflowEngineClient 核心链路 | workflow-engine | +| P1 | 改造三:接收校验 | 接收侧能识别非法协商消息、提取参数做路由 | 中,依赖 LLM 配置,需处理无 LLM 降级 | workflow-engine | +| P1 | 改造五:协商类型扩展 | 覆盖三种协商场景 | 低,渐进式扩展 | samples + workflow-engine | +| P2 | 改造四:终态消息 | accept/reject 消息标准化 | 低 | workflow-engine | +| P2 | 改造六:模板查询 | 动态选择模板 | 低 | samples | + +建议实施顺序:先改 sample 验证(改造一),再改核心链路(改造二),然后渐进扩展(改造三/五),最后收尾(改造四/六)。 + +--- + +## 五、关键约束和注意事项 + +### 5.1 SDK 版本 + +引擎 pom.xml 声明 a2a.t.sdk.version=1.0.0,SDK 的内容生成 API 在这个版本已完整可用。引擎只需确保 a2a-t-client 依赖引入了 a2a-t-negotiation 模块(传递依赖已覆盖)。 + +### 5.2 LLM 依赖 + +- fromData 变体不需要 LLM(确定性渲染) +- fromText 变体需要 LLM(结构化抽取一步) +- validateAndFilling 的语义校验步骤需要 LLM,规则门步骤不需要 + +改造时应在无 LLM 环境下做降级处理(a2at.llm.disabled 系统属性已在 sample 里使用)。无 LLM 时走旧的 echo/fallback 路径即可。 + +### 5.3 两套 NegotiationContext + +SDK 有两个 NegotiationContext: + +| 类 | 用途 | 字段 | +|---|---|---| +| net.openan.a2at.sdk.negotiation.content.NegotiationContext | 内容生成/校验 | id, round, maxRounds | +| net.openan.a2at.sdk.negotiation.types.model.NegotiationContext | 状态机 | negotiationType, negotiationId, round, status | + +改造时要注意传入正确的那一个:内容生成方法用 content 层的,状态机方法用 types.model 层的。两者不能混用。 + +### 5.4 Vocabulary 语言 + +SDK 的 Vocabulary.forLanguage 只支持 zh-CN 和 en-US,不支持的 language 会抛 NegotiationContentException。引擎的 .env 已配 A2AT_LANGUAGE=zh-CN,但内容生成引擎用的是 A2ATConfig.prompt.language,不是 A2AT_LANGUAGE 直接传给 vocabulary。需要确认 config 传递链路正确。 + +### 5.5 错误码处理 + +内容生成和校验的异常体系: + +| 异常类型 | 性质 | 处理方式 | +|---|---|---| +| NegotiationContentException | 编程错误(null context、畸形 URI、类型不匹配) | 不该 catch 后忽略,应修复调用代码 | +| NegotiationGenerationException | 处理失败,带 error code | try-catch 降级到旧路径 | +| NegotiationParamExtractionException | 校验失败,带 error code | try-catch 降级到旧路径 | + +error code 体系: + +| 码 | 含义 | 可重试 | +|---|---|---| +| template_not_found | 模板/prompt 资源缺失 | 否 | +| negotiation_content_extract_failed | LLM 内容抽取响应不可解析 | 是 | +| negotiation_llm_infrastructure_error | LLM 调用基础设施失败 | 是 | +| negotiation_slot_missing | 必填字段缺失 | 否 | +| negotiation_invalid_input | 输入与 phase/action 矛盾 | 否 | +| negotiation_rule_violation | 规则门校验失败 | 否 | +| negotiation_semantic_rejected | 语义校验拒绝 | 否 | + +LLM 步骤默认重试 3 次(maxAttempts 来自 LLM config)。 + +### 5.6 状态机和内容层是解耦的 + +SDK 的 startNegotiation/receiveNegotiation/continueNegotiation 只管状态机轮次,generateNegotiationProposePromptFromData 只管消息渲染。两者通过以下方式关联: + +1. 内容生成方法的 NegotiationContext(content 层,含 id/round/maxRounds)与状态机方法的 context 是独立的 +2. 内容生成方法返回的 promptText 作为状态机方法的 contentText 参数传入 +3. 改造时先调内容生成拿到 promptText,再把 promptText 传给状态机方法做 contentText 参数 + +### 5.7 改造影响面 + +| 改造 | 改的类 | 模块 | 是否影响核心链路 | +|---|---|---|---| +| 改造一 | NegotiationBaseAgentExecutor | samples | 否 | +| 改造二 | DefaultWorkflowEngineClient | workflow-engine | 是 | +| 改造三 | NegotiationTHandler(包级私有,需改 public 或扩展接口) | workflow-engine | 是 | +| 改造四 | DefaultWorkflowEngineClient | workflow-engine | 是 | +| 改造五 | NegotiationBaseAgentExecutor + DefaultControlPoint | samples + workflow-engine | 部分 | +| 改造六 | NegotiationBaseAgentExecutor | samples | 否 | + +建议先改 sample 验证(改造一、六),再改核心链路(改造二、三),然后渐进扩展(改造四、五)。 + +--- + +## 六、SDK 协商 API 速查 + +### 6.1 运行时状态机(引擎当前已用) + +```java +// 发起协商 +Map startNegotiation(NegotiationType type, String contentText, Map facts); + +// 接收对端协商消息 +Map receiveNegotiation(String message, Map context); + +// 本地推进协商 +Map continueNegotiation( + NegotiationContext context, NegotiationStatus status, String contentText); +``` + +### 6.2 内容生成(改造后接入) + +```java +// 从类型化数据生成(无 LLM) +MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, String templateUri); +MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, String templateUri); +MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, String templateUri); + +// 从自由文本生成(含 LLM 抽取) +MetadataContent generateNegotiationProposePromptFromText(String text, NegotiationContext context, String templateUri); +MetadataContent generateNegotiationAcceptPromptFromText(String text, NegotiationContext context, String templateUri); +MetadataContent generateNegotiationRejectPromptFromText(String text, NegotiationContext context, String templateUri); +``` + +### 6.3 校验与参数提取(改造后接入) + +```java +FilledParamData validateAndFillingProposeData(String prompt, Map schema, String templateUri); +FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri); +FilledParamData validateAndFillingRejectData(String prompt, Map schema, String templateUri); +``` + +### 6.4 模板查询(改造后接入) + +```java +List getNegotiationPrompts(); +Optional getNegotiationPrompt(String uri); +List getPrompts(); +Optional getPrompt(String uri); +``` + +### 6.5 模板 URI 格式 + +``` +Negotiation-T/v1/{type-segment}/{phase-segment} +``` + +| 场景 | URI | +|---|---| +| 信息协商提议 | Negotiation-T/v1/information-negotiation/propose | +| 信息协商接受/拒绝 | Negotiation-T/v1/information-negotiation/accept-reject | +| 目标协商提议 | Negotiation-T/v1/target-negotiation/propose | +| 目标协商接受/拒绝 | Negotiation-T/v1/target-negotiation/accept-reject | +| 可行性协商提议 | Negotiation-T/v1/feasibility-negotiation/propose | +| 可行性协商接受/拒绝 | Negotiation-T/v1/feasibility-negotiation/accept-reject | + +accept 和 reject 共享 accept-reject 模板,由 NegotiationConclusion 值(Accept/Reject)区分。 + +### 6.6 扩展 URI 常量 + +| 扩展 | URI | +|---|---| +| Negotiation-T(规范) | https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 | +| Negotiation-T(NL legacy) | https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/NL/v1 | + +SDK 用规范 URI 发送新消息,接收侧同时识别 NL 别名以兼容旧消息。 + +--- + +## 七、参考文件索引 + +### SDK 侧(a2a-t-sdk-java) + +| 文件 | 作用 | +|---|---| +| a2a-t-client/.../A2ATClient.java | 对外 facade,暴露全部协商 API | +| a2a-t-negotiation/.../content/NegotiationContentService.java | 内容层共享服务,代理生成编排器 | +| a2a-t-negotiation/.../generation/NegotiationGenerationOrchestrator.java | 内容生成引擎核心 | +| a2a-t-negotiation/.../generation/NegotiationGeneratorRegistry.java | 6 个生成器的 dispatch 表 | +| a2a-t-negotiation/.../generation/DefaultNegotiationContentExtractor.java | LLM 内容抽取(fromText 用) | +| a2a-t-negotiation/.../validation/ParamExtractor.java | 校验+参数提取编排 | +| a2a-t-negotiation/.../validation/DefaultNegotiationComplianceChecker.java | 规则门(确定性) | +| a2a-t-negotiation/.../runtime/NegotiationHandler.java | 状态机 facade | +| a2a-t-negotiation/.../runtime/NegotiationRuntime.java | 状态机核心 | +| a2a-t-negotiation/.../runtime/RoleBoundNegotiationOrchestrator.java | 角色绑定 orchestrator | +| a2a-t-negotiation/.../runtime/helper/NegotiationPayloadMapper.java | payload 序列化/反序列化 | +| a2a-t-negotiation/.../handler/InformationNegotiation.java | information 运行时 handler(唯一有实质逻辑) | +| a2a-t-negotiation/.../content/Vocabulary.java | 多语言词汇表 | + +### 引擎侧(workflow-exec-engine-java) + +| 文件 | 作用 | +|---|---| +| workflow-engine/.../client/A2ATransport.java | 传输层,持有 A2ATClient | +| workflow-engine/.../client/DefaultWorkflowEngineClient.java | 工作流执行层,协商自动循环 | +| workflow-engine/.../client/NegotiationTHandler.java | 协商扩展 handler(接收侧) | +| workflow-engine/.../client/ExtensionHandler.java | 扩展 handler 接口 | +| workflow-engine/.../client/ExtensionRegistry.java | 扩展 handler 注册表 | +| workflow-engine/.../control/DefaultControlPoint.java | 默认控制点,协商策略委托 | +| workflow-engine/.../control/NegotiationStrategy.java | 协商策略接口 | +| workflow-engine/.../client/WorkflowEngineClientConfig.java | 引擎配置(含 a2atEnvPath) | +| samples/.../agents/NegotiationBaseAgentExecutor.java | server 侧协商基类 | +| samples/.../negotiation/NegotiationUtils.java | 协商常量和工具方法 | +| samples/.../negotiation/NegotiationStrategy.java | 协商策略示例实现 | diff --git "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" index 1458434a..e9cb49f0 100644 --- "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" @@ -948,6 +948,87 @@ SDK 的 `WorkflowExecutor` 自动并行调度同一层的步骤 (无依赖关系 .build()) ``` +### 6.7 东信 Order 模式: 并发 NE 断连/超时排查 + +> **这是联调中最容易踩坑的问题,务必在改造前阅读。** + +#### 问题现象 + +Order 模式下同时向两个不同地市 OMC 发送 Task-T 诊断任务(或前置阶段给 City1 发完 Notification-T 后给 City2 发 Authorization-T),其中一个城市的请求报 `Timeout on blocking read for 10000000000 NANOSECONDS`,堆栈指向 `WrapperHttpClient.loadNeResource`。 + +根因有两个,两者叠加才会复现: + +#### 根因一: 东信 SDK 共享 RSocket 连接 + +东信 SDK 的 `ServiceReference` 类内部有一个 `static final Map INVOKERS`,按 `className + host + port` 缓存 RSocket 连接。所有发往同一东信平台地址(同一 host:port)的请求,无论 NE 是 `sim-city1` 还是 `sim-city2`,都共用同一条 RSocket 连接。 + +SDK 的 `WrapperHttpClient.loadNeResource()` 通过 `Mono.block(Duration.ofSeconds(10))` 同步阻塞等待 RSocket 响应。这个 10 秒超时是 SDK 硬编码的,无法通过配置修改。 + +#### 根因二: 模拟器阻塞 I/O 卡住 RSocket 事件循环 + +`EastcomOrderSimulatorServer` 的 `execute` 方法(处理 RSocket `requestChannel`)在收到 HTTP 请求后,调用 `forwardParsed` 通过 `HttpURLConnection` 转发给 OMC。`forwardParsed` 内部的 `reader.read()` 是阻塞 I/O。 + +当 City1 的 Notification-T 是长连接(等待 OMC 上报抢通结果),`reader.read()` 会一直阻塞。如果这个阻塞发生在 RSocket 事件循环线程(`reactor-tcp-nio-*`)上,该线程被占死,无法处理同一 RSocket 连接上 City2 的 `loadNeResource` 请求(RSocket `requestResponse`),10 秒后 City2 超时。 + +#### 时序示例 + +``` +T0 City1 Notification-T: loadNeResource → simulator 设置 lastResolvedTarget=city1_url +T1 City1 Notification-T: execute → forwardParsed → reader.read() 阻塞(长连接等待) + → 阻塞在 reactor-tcp-nio-4 线程上 +T2 City2 Authorization-T: loadNeResource → 发送到同一 RSocket 连接 + → reactor-tcp-nio-4 被阻塞,无法处理 +T3 10s 后: City2 loadNeResource 超时 (Mono.block 10s timeout) +``` + +#### 解决方案 + +**修复一: 将转发 I/O 移出 RSocket 事件循环线程** + +在模拟器的 `execute` 方法中,给 `forwardParsed` 调用加 `.subscribeOn(Schedulers.boundedElastic())`,把阻塞 I/O 从 `reactor-tcp-nio-*` 线程移到 `boundedElastic-*` 工作线程。RSocket 事件循环线程释放后,能正常处理其他 NE 的 `loadNeResource` 请求。 + +```java +// EastcomOrderSimulatorServer.execute() 中: +forwardParsed(httpMethod, httpPath, parsedHeaders, body, forwardUrl, streaming) + .subscribeOn(Schedulers.boundedElastic()) // 关键: 移出事件循环线程 + .subscribe(sink::next, sink::error, () -> sink.complete()); +``` + +**修复二: 用 HTTP Host 头替代共享 lastResolvedTarget 字段** + +原模拟器用 `volatile String lastResolvedTarget` 字段在 `loadNeResource` 时设置目标 URL,`execute` 时读取。当两个 NE 并发调用 `loadNeResource`,后一个会覆盖前一个的目标,导致请求转发到错误的 OMC。 + +东信 SDK 的 `WrapperHttpClient.configureHeaderHost()` 会把 HTTP `Host` 头设为 `loadNeResource` 返回的完整 `neUrl`(如 `https://127.0.0.1:26335`)。每个请求自带目标地址,不再依赖共享字段: + +```java +// EastcomOrderSimulatorServer.execute() 中,解析 HTTP 头后: +String hostHeader = findHeader(parsedHeaders, "Host"); +String targetBase; +if (hostHeader != null + && (hostHeader.startsWith("http://") || hostHeader.startsWith("https://"))) { + targetBase = withoutTrailingSlash(hostHeader); +} else { + targetBase = lastResolvedTarget != null + ? lastResolvedTarget : "http://127.0.0.1:26335"; +} +``` + +注意: `copyHeaders` 方法已经排除了 `Host` 头不转发给 OMC(避免覆盖 OMC 自身的 Host 头),这是正确的行为。 + +#### 为什么 readTimeout=65s 不能解决问题 + +模拟器的 `connection.setReadTimeout(65_000)` 只控制 `HttpURLConnection` 的读取超时,不影响 SDK 内部 `loadNeResource` 的 `Mono.block(10s)` 超时。即使模拟器等得起,SDK 在 10 秒内收不到 RSocket 响应就会超时报错。根因是 RSocket 事件循环线程被阻塞,不是 HTTP 读取超时。 + +#### 排查清单 + +如果再次出现 Order 模式并发超时,按以下顺序排查: + +1. **确认超时位置**: 日志中 `Timeout on blocking read for 10000000000 NANOSECONDS` 出现在 `WrapperHttpClient.loadNeResource` → 是 RSocket 超时,不是 HTTP 超时 +2. **确认线程**: 模拟器日志中 `FORWARD_CHUNK` / `FORWARD_DONE` 的线程名是 `boundedElastic-*` 而非 `reactor-tcp-nio-*` → 如果还是 `reactor-tcp-nio-*`,说明 `subscribeOn` 修复未生效 +3. **确认目标地址**: 日志中 `FORWARD_START` 的 `target=` 是否与 `LOAD_NE_ACCEPTED` 的 `target=` 一致 → 如果不一致,说明 `Host` 头解析未生效 +4. **确认会话复用**: `OrderGatewayClientRuntime` 日志中 `SESSION_REUSE` 而非每次 `SESSION_OPEN` → 同一 NE 应复用会话,避免重复登录 +5. **确认 Connection 单例**: `ClientRuntimeFactory.create()` 在 ORDER 模式下返回单例 `sharedOrderRuntime`,而非每次 new + --- ## 7. 接口速查表 From 3a9939e566b6ae4712293adacd117212a6050bad Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Sun, 23 Aug 2026 17:23:39 +0800 Subject: [PATCH 05/49] refactor(samples): reorganize package structure, keep only generic demo on main - move EnvResolver/LlmHelper to examples/util (mirrors dev layout) - move EmbeddedA2AServerTest to examples/server, fix WorkbenchControlPoint import in SpnCrossCityE2ETest to the workbench package - replace the old spring/ demo pair with a direct-transport SpringSpnDemo in examples/demo (no vendor gateway coupling) - introduce WorkbenchClientProperties for orch-url/credentials/ssl-verify/ a2at-env-path configuration; drop dev-only transport-mode knobs - remove superseded duplicates in examples/agents (WorkbenchControlPoint, WorkbenchOrchestrator, ExtensionPrePositioner, PrePositionedExtensionHandler) - samples/pom: add a2a-java-sdk-spec-grpc, a2a-t-server and protobuf-java-util needed by the generic samples; no vendor SDK - application.yml: document the public demo certificate caveat Verified: mvn test -pl samples -am -> 4 tests pass, BUILD SUCCESS Signed-off-by: Zhoujie628 --- samples/pom.xml | 28 ++ .../agents/ExtensionPrePositioner.java | 90 ------ .../agents/PrePositionedExtensionHandler.java | 263 ----------------- .../agents/SpringWorkbenchExecutor.java | 21 +- .../agents/WorkbenchControlPoint.java | 229 --------------- .../agents/WorkbenchOrchestrator.java | 261 ----------------- .../config/WorkbenchClientProperties.java | 50 ++++ .../engine/examples/demo/SpringSpnDemo.java | 271 ++++++++++++++++++ .../engine/examples/spring/SpringSpnDemo.java | 180 ------------ .../spring/SpringWorkbenchExecutor.java | 120 -------- .../{agents => util}/EnvResolver.java | 2 +- .../engine/examples/{ => util}/LlmHelper.java | 2 +- .../SpringWorkbenchExtensionLifecycle.java | 17 +- samples/src/main/resources/application.yml | 2 + .../examples/EmbeddedA2AServerTest.java | 227 --------------- .../engine/examples/SpnCrossCityE2ETest.java | 2 +- 16 files changed, 359 insertions(+), 1406 deletions(-) delete mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/agents/ExtensionPrePositioner.java delete mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/agents/PrePositionedExtensionHandler.java delete mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchControlPoint.java delete mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchOrchestrator.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemo.java delete mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringSpnDemo.java delete mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringWorkbenchExecutor.java rename samples/src/main/java/dev/openan/workflow/engine/examples/{agents => util}/EnvResolver.java (97%) rename samples/src/main/java/dev/openan/workflow/engine/examples/{ => util}/LlmHelper.java (99%) delete mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/EmbeddedA2AServerTest.java diff --git a/samples/pom.xml b/samples/pom.xml index 9004c093..52b2d0a5 100644 --- a/samples/pom.xml +++ b/samples/pom.xml @@ -25,6 +25,24 @@ workflow-engine ${revision} + + + org.a2aproject.sdk + a2a-java-sdk-spec-grpc + ${a2a.java.sdk.version} + + + + net.openan.a2a-t.sdk + a2a-t-server + ${a2a.t.sdk.version} + + + + com.google.protobuf + protobuf-java-util + 3.25.5 + dev.openan.workflow.sdk @@ -56,6 +74,16 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + + + false + + + org.apache.maven.plugins maven-compiler-plugin diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/ExtensionPrePositioner.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/ExtensionPrePositioner.java deleted file mode 100644 index 90d6c288..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/ExtensionPrePositioner.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.agents; - -import dev.openan.workflow.engine.client.ExtensionSender; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; - -/** - * Pre-positions Authorization-T and Notification-T to downstream agents. - * - *

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

When {@code notificationCallback} is non-null, subsequent events pushed by agents - * through the Notification-T SSE stream (e.g. recovery results) are forwarded to the - * callback. The callback receives a Map with keys: agent, text, metadata, state. - */ - public void prePosition( - ExtensionSender sender, - List agentCards, - Consumer> notificationCallback) { - for (AgentCard card : agentCards) { - String name = card.name(); - if (name.contains("Workbench")) { - continue; - } - log.info("[PrePosition] Authorization-T to {}", name); - sender.sendAuthorization(name, "下发授权放行策略", authInput).join(); - log.info("[PrePosition] Notification-T to {}", name); - sender.sendNotification( - name, "订阅业务抢通结果通知", notifInput, - notificationCallback).join(); - } - log.info("[PrePosition] Extension pre-positioning complete"); - } - - /** Pre-position without Notification-T event callback. */ - public void prePosition(ExtensionSender sender, List agentCards) { - prePosition(sender, agentCards, null); - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/PrePositionedExtensionHandler.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/PrePositionedExtensionHandler.java deleted file mode 100644 index ab19c8dd..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/PrePositionedExtensionHandler.java +++ /dev/null @@ -1,263 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ -package dev.openan.workflow.engine.examples.agents; - -import org.a2aproject.sdk.server.agentexecution.RequestContext; -import org.a2aproject.sdk.server.tasks.AgentEmitter; -import org.a2aproject.sdk.spec.Message; -import org.a2aproject.sdk.spec.Part; -import org.a2aproject.sdk.spec.TaskState; -import org.a2aproject.sdk.spec.TaskStatus; -import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; - -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import java.nio.file.Path; -import org.a2aproject.sdk.spec.TextPart; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Handles pre-positioned Authorization-T / Notification-T messages on the agent server side. - * - *

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

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

Extracted from {@code NegotiationBaseAgentExecutor} so that the latter only carries - * Negotiation-T responsibility. - */ -public class PrePositionedExtensionHandler { - private static final Logger log = LoggerFactory.getLogger(PrePositionedExtensionHandler.class); - private volatile String authorizationPolicy; - private volatile String notificationSubscription; - private volatile A2ATServer a2atServer; - - private A2ATServer a2atServer() { - if (a2atServer != null) return a2atServer; - if (Boolean.getBoolean("a2at.llm.disabled")) return null; - String env = System.getProperty("A2AT_ENV_PATH", System.getenv("A2AT_ENV_PATH")); - if (env == null || env.isBlank()) return null; - try { - a2atServer = new A2ATServer(Path.of(env)); - return a2atServer; - } catch (Exception e) { - log.warn("A2ATServer init failed: {}", e.getMessage()); - return null; - } - } - - /** - * Detect whether the incoming message carries an Authorization-T or Notification-T extension in - * its metadata. Returns the extension keyword ("Authorization-T" or "Notification-T"), or null - * for a normal task. - */ - public static String detect(RequestContext ctx) { - Message msg = ctx.getMessage(); - if (msg == null || msg.metadata() == null) { - return null; - } - for (String key : msg.metadata().keySet()) { - if (key.contains("Authorization-T")) { - return "Authorization-T"; - } - if (key.contains("Notification-T")) { - return "Notification-T"; - } - } - return null; - } - - private static void emitStatus( - AgentEmitter emitter, - TaskState state, - String contextId, - String taskId, - String text, - Map metadata) { - TaskStatus status = - new TaskStatus( - state, BaseAgentExecutor.buildStatusMessage(contextId, taskId, text), null); - TaskStatusUpdateEvent event = - TaskStatusUpdateEvent.builder() - .taskId(taskId) - .contextId(contextId) - .status(status) - .metadata(metadata) - .build(); - emitter.emitEvent(event); - } - - /** The pre-positioned Authorization-T whitelist policy text, or null. */ - public String getAuthorizationPolicy() { - return authorizationPolicy; - } - - /** The pre-positioned Notification-T subscription text, or null. */ - public String getNotificationSubscription() { - return notificationSubscription; - } - - /** - * Parameter JSON Schema for the Authorization-T validate-and-fill pipeline: the whitelist - * policy fields the agent wants extracted from a rendered authorization prompt. - */ - private static Map buildAuthParamSchema() { - Map properties = new LinkedHashMap<>(); - properties.put("授权策略的操作类型", Map.of("type", "string")); - properties.put("动网操作的授权策略列表", Map.of("type", "string")); - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - return schema; - } - - /** - * Parameter JSON Schema for the Notification-T validate-and-fill pipeline: the subscription - * fields extracted from a rendered subscribe-incident prompt. - */ - private static Map buildNotificationParamSchema() { - Map properties = new LinkedHashMap<>(); - properties.put("通知主题", Map.of("type", "string")); - properties.put("订阅条件", Map.of("type", "string")); - properties.put("上报通知数据格式", Map.of("type", "string")); - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - return schema; - } - - /** - * Handle a pre-positioned message: store the payload, emit an ACK artifact, and complete the - * task. - * - * @param agentTag short agent class name for logging - */ - public void handle( - RequestContext ctx, AgentEmitter emitter, String extKeyword, String agentTag) { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - String payloadText = - ctx.getMessage().metadata().entrySet().stream() - .filter(e -> e.getKey().contains(extKeyword)) - .map(Map.Entry::getValue) - .findFirst() - .map(v -> v instanceof String s ? s : String.valueOf(v)) - .orElse(""); - // Extract templateUri from metadata if present (sent by SDK prompt generation) - String templateUri = null; - if (ctx.getMessage().metadata() != null) { - Object tu = ctx.getMessage().metadata().get(MetadataContent.TEMPLATE_URI_METADATA_KEY); - if (tu instanceof String s) templateUri = s; - } - if (extKeyword.contains("Authorization")) { - authorizationPolicy = payloadText; - // Full validate-and-fill pipeline: rule gate + LLM semantic validation + param - // extraction, falling back to the lightweight compliance check. - A2ATServer server = a2atServer(); - if (server != null) { - try { - net.openan.a2at.sdk.core.model.FilledParamData filled = - server.validateAuthPromptAndDataFilling( - payloadText, - buildAuthParamSchema(), - net.openan.a2at.sdk.core.model.StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); - log.info( - "[{}] Authorization-T validateAuthPromptAndDataFilling passed, templateUri={}, params={}", - agentTag, - templateUri, - filled.data() != null ? filled.data().keySet() : java.util.Set.of()); - } catch (Exception ve) { - log.warn( - "[{}] Authorization-T validate-and-fill rejected ({}); falling back to compliance check", - agentTag, - ve.getMessage()); - try { - PromptComplianceResult result = server.checkTaskPrompt(payloadText); - if (result.success()) { - log.info("[{}] Authorization-T prompt compliance check passed, templateUri={}", - agentTag, templateUri); - } else { - log.warn("[{}] Authorization-T prompt compliance check failed: code={}, message={}", - agentTag, - result.failure() != null ? result.failure().code() : "unknown", - result.failure() != null ? result.failure().message() : "unknown"); - } - } catch (Exception e) { - log.warn("[{}] Authorization-T compliance check error: {}", agentTag, e.getMessage()); - } - } - } - } else if (extKeyword.contains("Notification")) { - notificationSubscription = payloadText; - A2ATServer server = a2atServer(); - if (server != null) { - try { - net.openan.a2at.sdk.core.model.FilledParamData filled = - server.validateNotificationPromptAndDataFilling( - payloadText, - buildNotificationParamSchema(), - net.openan.a2at.sdk.core.model.StandardTemplates.SUBSCRIBE_INCIDENT); - log.info( - "[{}] Notification-T validateNotificationPromptAndDataFilling" - + " passed, templateUri={}, params={}", - agentTag, - templateUri, - filled.data() != null ? filled.data().keySet() : java.util.Set.of()); - } catch (Exception ve) { - log.warn( - "[{}] Notification-T validate-and-fill rejected ({}); keeping payload as subscription", - agentTag, - ve.getMessage()); - } - } else { - log.info("[{}] Notification-T received, templateUri={}", agentTag, templateUri); - } - } - log.info( - "[{}] Pre-positioned {} received, payload length={}", - agentTag, - extKeyword, - payloadText.length()); - String ackText = extKeyword + " pre-positioning acknowledged"; - List> parts = List.of(new TextPart(ackText)); - emitter.addArtifact(parts, "result", agentTag + " ack", Map.of(), false, true); - emitStatus( - emitter, - TaskState.TASK_STATE_COMPLETED, - contextId, - taskId, - extKeyword + " pre-positioned successfully", - Map.of()); - emitter.complete(BaseAgentExecutor.buildStatusMessage(contextId, taskId, "Completed")); - log.info("[{}] {} pre-positioning completed", agentTag, extKeyword); - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java index 5cf6aa81..1a106d96 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java @@ -20,7 +20,6 @@ package dev.openan.workflow.engine.examples.agents; import dev.openan.workflow.engine.client.A2ATExtension; -import dev.openan.workflow.engine.client.A2AJavaClientRuntime; import dev.openan.workflow.engine.examples.agents.BaseAgentExecutor; import dev.openan.workflow.engine.examples.config.WorkbenchClientProperties; import dev.openan.workflow.engine.examples.util.EnvResolver; @@ -41,7 +40,6 @@ import java.util.Map; import java.util.concurrent.TimeUnit; -import dev.openan.workflow.engine.examples.gateway.ClientRuntimeFactory; import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; /** * Spring-managed Workbench AgentExecutor. @@ -56,12 +54,9 @@ public class SpringWorkbenchExecutor extends BaseAgentExecutor { private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchExecutor.class); - private final ClientRuntimeFactory runtimeFactory; private final WorkbenchClientProperties properties; - public SpringWorkbenchExecutor( - ClientRuntimeFactory runtimeFactory, WorkbenchClientProperties properties) { - this.runtimeFactory = runtimeFactory; + public SpringWorkbenchExecutor(WorkbenchClientProperties properties) { this.properties = properties; } @@ -82,15 +77,6 @@ private String resolveCredentialsPath() { : null; } - private A2AJavaClientRuntime createClientRuntime() { - A2AJavaClientRuntime runtime = runtimeFactory.create(); - log.info( - "[SpringWorkbench] TRANSPORT_MODE mode={}, runtime={}", - runtimeFactory.mode(), - runtime != null ? runtime.getClass().getSimpleName() : "DefaultA2AJavaClientRuntime"); - return runtime; - } - @Override public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { String taskId = ctx.getTaskId(); @@ -104,7 +90,7 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { contextId, input.length(), properties.getOrchUrl(), - runtimeFactory.mode(), + "direct", properties.isSslVerify()); emitter.submit(buildStatusMessage(contextId, taskId, "Task received")); @@ -116,8 +102,7 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { properties.getOrchUrl(), resolveCredentialsPath(), properties.isSslVerify(), - resolveEnvPath(), - createClientRuntime()) + resolveEnvPath()) .run(input); Map metadata = new LinkedHashMap<>(); metadata.put(A2ATExtension.TASK_T.uri(), result); diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchControlPoint.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchControlPoint.java deleted file mode 100644 index 8421ee2b..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchControlPoint.java +++ /dev/null @@ -1,229 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.agents; - -import dev.openan.workflow.engine.examples.LlmHelper; -import dev.openan.workflow.engine.client.WorkflowEngineClient; -import dev.openan.workflow.engine.control.DefaultControlPoint; -import dev.openan.workflow.engine.client.A2ATExtension; -import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; -import dev.openan.workflow.engine.model.JumpCondition; -import dev.openan.workflow.engine.model.RouteDecision; -import dev.openan.workflow.engine.model.TaskRequest; -import dev.openan.workflow.engine.model.TaskResponse; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -/** - * ControlPoint for the SPN cross-city diagnosis workflow. - * - *

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

SRP: this class only contains workflow decision logic, separating it from the agent executor - * that handles message I/O. - */ -public class WorkbenchControlPoint extends DefaultControlPoint { - private static final Logger log = LoggerFactory.getLogger(WorkbenchControlPoint.class); - - private final String a2atEnvPath; - private final NegotiationStrategy negotiationStrategy; - - public WorkbenchControlPoint() { - this(null, null); - } - - public WorkbenchControlPoint(String a2atEnvPath) { - this(a2atEnvPath, null); - } - - public WorkbenchControlPoint(String a2atEnvPath, NegotiationStrategy negotiationStrategy) { - this.a2atEnvPath = a2atEnvPath != null ? a2atEnvPath : EnvResolver.resolveEnvPath(); - this.negotiationStrategy = - negotiationStrategy != null - ? negotiationStrategy - : new NegotiationStrategy(this.a2atEnvPath); - } - - private static String analyzeFaultLocation(String messageText) { - // Identify City1 fault by spec case 7.1 diagnostic-detail fault signatures, since the - // diagnosis text now follows the document format (no longer contains the "城市1" keyword). - boolean hasCity1Fault = - messageText.contains("无收光") - || messageText.contains("掉电") - || messageText.contains("端口关闭") - || messageText.contains("端口Down") - || (messageText.contains("城市1") - && (messageText.contains("故障") || messageText.contains("Down"))); - boolean hasCity2Fault = - messageText.contains("城市2") - && (messageText.contains("故障") || messageText.contains("Down")); - if (hasCity1Fault) { - // Merge result mirroring spec case 7.1 diagnosis summary in Task-T metadata. - return "1. 诊断结果:成功\n" - + "2. 诊断结果详情:汇总分析完成。故障定位:城市1地市OMC," - + "P33206-YWHJ-业务汇聚机房1(990)的12-TPJ1EM8F-4端口出现无收光问题," - + "判断原因为对端设备掉电或端口关闭\n" - + "3. 修复建议:恢复供电后重新启动网元,或者重新开启端口"; - } - if (hasCity2Fault) { - return "1. 诊断结果:成功\n" - + "2. 诊断结果详情:汇总分析完成。城市2地市OMC端口状态正常," - + "光功率-17dBm(正常范围),无异常告警,故障不在此地市\n" - + "3. 修复建议:城市2无需修复"; - } - return "1. 诊断结果:成功\n2. 诊断结果详情:汇总分析完成。两地市均未见异常。\n3. 修复建议:无需修复"; - } - - /** - * Build a targeted task message containing ONLY the parameters relevant to the target agent's - * city. The upstream context (which mixes both cities' info) is NOT forwarded -- each sub-agent - * receives a clean, self-contained task description with only its own scope. - */ - private static String buildTargetedTaskMessage(String step) { - return switch (step) { - case "diagnosis_city1" -> buildCity1Task(); - case "diagnosis_city2" -> buildCity2Task(); - default -> "创建专线业务投诉诊断任务"; - }; - } - - private static String buildCity1Task() { - // Task-T structured prompt, mirroring spec case 7.3 (missing params, triggers negotiation). - return "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" - + "## 任务描述(Task Description)\n" - + "基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, " - + "达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n" - + "## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n" - + "## 任务对象(Task Object)\n接入端口名称:\n\n" - + "## 任务上下文(Task Context)\n" - + "1. 投诉分类:\n" - + "2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n" - + "3. OSS侧事件流水号:\"event-id-20260511-09013\"\n" - + "4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," - + "访问广州机房的核心交易系统非常慢。\"\n\n" - + "## 预期输出(Expected Output)\n" - + "1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n" - + "2. 诊断结果详情;(必选)\n" - + "3. 修复建议;(可选)\n" - + "4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"; - } - - private static String buildCity2Task() { - return "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" - + "## 任务描述(Task Description)\n" - + "基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, " - + "达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n" - + "## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n" - + "## 任务对象(Task Object)\n接入端口名称:P882-珠江新城-PTN7900-23-TPA1EG24-11\n\n" - + "## 任务上下文(Task Context)\n" - + "1. 投诉分类:\"专线质差\"\n" - + "2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n" - + "3. OSS侧事件流水号:\"event-id-20260511-09013\"\n" - + "4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," - + "访问广州机房的核心交易系统非常慢。\"\n\n" - + "## 预期输出(Expected Output)\n" - + "1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n" - + "2. 诊断结果详情;(必选)\n" - + "3. 修复建议;(可选)\n" - + "4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"; - } - - @Override - public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - String step = request.getStepName(); - String agentName = request.getAgentName(); - String targetedMessage = buildTargetedTaskMessage(step); - // Send the Task-T structured prompt via message metadata (spec §6), so the OMC agent - // reads it from message.metadata[Task-T URI] exactly like spec cases 7.1/7.3. - Map metadata = new LinkedHashMap<>(); - metadata.put(NegotiationUtils.TASK_PROMPT_KEY, targetedMessage); - // For diagnosis_city1, activate Negotiation-T extension (spec case 7.3: missing params - // trigger negotiation). The ExtensionInterceptor adds it to the A2A-Extensions header - // when the metadata key is present. - if ("diagnosis_city1".equals(step)) { - metadata.put(NegotiationUtils.NEGOTIATION_T_URI, ""); - } - String partsText = "创建专线业务投诉诊断任务"; - return engineClient - .sendMessage(agentName, partsText, null, metadata) - .thenApply( - r -> { - boolean success = r.getText() != null && !r.getText().isEmpty(); - log.info( - "[onTask] Response from {}: {} chars, success={}", - agentName, - r.getText() != null ? r.getText().length() : 0, - success); - return TaskResponse.builder() - .success(success) - .output(r.getText()) - .build(); - }) - .exceptionally( - e -> { - log.error("[onTask] Failed for {}: {}", agentName, e.getMessage()); - return TaskResponse.builder() - .success(false) - .error("Agent call failed: " + e.getMessage()) - .build(); - }); - } - - @Override - public CompletableFuture onSelfTask(TaskRequest request) { - String step = request.getStepName(); - log.info( - "[onSelfTask] Self-loop step={}, agent={} (local merge, no A2A-T)", - step, - request.getAgentName()); - String message = request.getMessage(); - String fallback = analyzeFaultLocation(message); - String sys = "你是SPN跨城故障协同诊断汇总专家。按如下结构输出:1. 诊断结果;2. 诊断结果详情;3. 修复建议。简洁专业,中文。"; - String result = LlmHelper.text(a2atEnvPath, sys, message, fallback); - log.info("[onSelfTask] Merge result ({} chars): {}", result.length(), result); - return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output(result).build()); - } - - @Override - public CompletableFuture onRoute( - String stepName, Map results, List conditions) { - // merge_analysis has an unconditional next -> endNode, so the executor - // never calls onRoute for it. Recovery is self-triggered by SPN agents - // via the pre-positioned Authorization-T whitelist and reported through - // the Notification-T channel. Just delegate to the default routing. - return super.onRoute(stepName, results, conditions); - } - - @Override - public CompletableFuture onNegotiation( - String agentName, String negotiationText, Map receiveResult) { - return negotiationStrategy.resolve(agentName, negotiationText, receiveResult); - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchOrchestrator.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchOrchestrator.java deleted file mode 100644 index 5b64e2a9..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/WorkbenchOrchestrator.java +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.agents; - -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.openan.workflow.engine.client.A2ATransport; -import dev.openan.workflow.engine.client.AgentCardJacksonModule; -import dev.openan.workflow.engine.client.DefaultExtensionSender; -import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; -import dev.openan.workflow.engine.client.ExtensionSender; -import dev.openan.workflow.engine.client.WorkflowEngineClient; -import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; -import dev.openan.workflow.engine.control.EventCallback; -import dev.openan.workflow.engine.control.EventType; -import dev.openan.workflow.engine.model.ExecutionResult; -import dev.openan.workflow.engine.model.Workflow; -import dev.openan.workflow.engine.model.WorkflowSearchResult; -import dev.openan.workflow.engine.registry.LoadPsop; -import dev.openan.workflow.engine.runner.ExecutePsop; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -/** - * Workflow orchestration for the SPN cross-city diagnosis. - * - *

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

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

This class does NOT handle agent server I/O (that stays in {@link - * TransportWorkbenchAgentExecutor}) or the details of any single A2A-T extension protocol. - */ -public class WorkbenchOrchestrator { - - private static final Logger log = LoggerFactory.getLogger(WorkbenchOrchestrator.class); - private static final ObjectMapper mapper = - new ObjectMapper().registerModule(new AgentCardJacksonModule()); - - private static final String FALLBACK_PSOP_ID = "psop_spn_cross_city_diagnosis"; - private static final List AGENT_CARD_RESOURCES = - List.of( - "agentcard/spn_domain_agent_city1.json", - "agentcard/spn_domain_agent_city2.json", - "agentcard/transport_workbench_agent.json"); - - private final String orchUrl; - private final String credentialsPath; - private final boolean sslVerify; - private final String a2atEnvPath; - - public WorkbenchOrchestrator( - String orchUrl, String credentialsPath, boolean sslVerify, String a2atEnvPath) { - this.orchUrl = orchUrl; - this.credentialsPath = credentialsPath; - this.sslVerify = sslVerify; - this.a2atEnvPath = a2atEnvPath; - } - - private static String buildResultText(ExecutionResult result) { - StringBuilder sb = new StringBuilder(); - sb.append("Workflow execution ") - .append(result.isSuccess() ? "succeeded" : "failed") - .append(".\n"); - if (result.getHistory() != null) { - for (Map h : result.getHistory()) { - sb.append("- Step: ") - .append(h.get("step")) - .append(", Agent: ") - .append(h.get("agent")) - .append(", Status: ") - .append(h.get("status")) - .append("\n"); - } - } - if (result.getError() != null) { - sb.append("Error: ").append(result.getError()); - } - return sb.toString(); - } - - /** Run the full orchestration pipeline and return the result text. */ - public String run(String messageText) throws Exception { - log.info("[Orchestrator] Step 1: Load agent cards"); - List agentCards = loadAgentCards(); - log.info("[Orchestrator] Loaded {} agent card(s)", agentCards.size()); - - log.info("[Orchestrator] Step 2: Search + load PSOP workflow"); - String psopId = searchPsop(messageText); - Workflow workflow = LoadPsop.load(orchUrl, psopId, null, sslVerify); - log.info( - "[Orchestrator] Workflow: {} ({} steps)", - workflow.getName(), - workflow.getSteps().size()); - - log.info("[Orchestrator] Step 3: Create engine client"); - A2ATransport transport = - new A2ATransport( - agentCards, - null, - WorkflowEngineClientConfig.builder() - .sslVerify(sslVerify) - .a2atEnvPath(a2atEnvPath) - .credentialsConfigPath(credentialsPath) - .build()); - try { - WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport); - ExtensionSender extensionSender = new DefaultExtensionSender(transport); - - log.info("[Orchestrator] Step 4: Pre-position extensions"); - new ExtensionPrePositioner().prePosition(extensionSender, agentCards, data -> { - Object agent = data.get("agent"); - Object text = data.get("text"); - Object metadata = data.get("metadata"); - log.info("[onNotification] agent={}, text={} chars, metadata={}", - agent, - text != null ? String.valueOf(text).length() : 0, - metadata != null ? "yes" : "no"); - if (text != null) { - log.info("[onNotification] Recovery result from {}: {}", agent, text); - } - }); - - log.info("[Orchestrator] Step 5: Execute workflow"); - WorkbenchControlPoint controlPoint = - new WorkbenchControlPoint(a2atEnvPath, new NegotiationStrategy(a2atEnvPath)); - ExecutionResult result = - ExecutePsop.builder() - .psop(workflow) - .agentCards(agentCards) - .controlPoint(controlPoint) - .engineClient(engineClient) - .runtimeIntent(messageText) - .lang("zh") - .sslVerify(sslVerify) - .credentialsConfigPath(credentialsPath) - .a2atEnvPath(a2atEnvPath) - .eventCallback(createLogCallback()) - .onFinish( - (r, events) -> { - log.info( - "[onFinish] Success={}, Events={}", - r.isSuccess(), - events.size()); - return CompletableFuture.completedFuture(null); - }) - .execute() - .join(); - return buildResultText(result); - } finally { - transport.close(); - } - } - - private List loadAgentCards() { - Map byName = new LinkedHashMap<>(); - for (String res : AGENT_CARD_RESOURCES) { - try { - var url = getClass().getClassLoader().getResource(res); - if (url != null) { - AgentCard card = - mapper.readValue(new java.io.File(url.getPath()), AgentCard.class); - byName.put(card.name(), card); - } - } catch (Exception e) { - log.warn("Failed to load agent card {}: {}", res, e.getMessage()); - } - } - return new ArrayList<>(byName.values()); - } - - private String searchPsop(String messageText) { - try { - List results = - LoadPsop.search(orchUrl, messageText, 3, null, sslVerify); - if (!results.isEmpty()) { - String psopId = results.get(0).getWorkflowId(); - log.info( - "[Orchestrator] Found PSOP: {} (score={})", - psopId, - results.get(0).getScore()); - return psopId; - } - } catch (Exception e) { - log.warn("[Orchestrator] PSOP search failed, using fallback: {}", e.getMessage()); - } - return FALLBACK_PSOP_ID; - } - - private EventCallback createLogCallback() { - return new EventCallback() { - @Override - public void onEvent(String type, Map data) { - switch (type) { - case EventType.START -> log.info(" [START] {}", data.get("workflow")); - case EventType.STEP_START -> log.info(" [STEP_START] {}", data.get("step")); - case EventType.TASK_REQUEST -> - log.info(" [TASK_REQUEST] agent={}", data.get("agent")); - case EventType.TASK_RESPONSE -> - log.info(" [TASK_RESPONSE] agent={}", data.get("agent")); - case EventType.AGENT_STATUS_UPDATE -> - log.info( - " [STATUS_UPDATE] agent={}, state={}, final={}", - data.get("agent"), - data.get("state"), - data.get("is_final")); - case EventType.AGENT_ARTIFACT_UPDATE -> - log.info( - " [ARTIFACT_UPDATE] agent={}, artifact={}", - data.get("agent"), - data.get("artifact_name")); - case EventType.AGENT_MESSAGE_EVENT -> - log.info( - " [MESSAGE] agent={}, {} chars", - data.get("agent"), - data.get("text") != null - ? ((String) data.get("text")).length() - : 0); - case EventType.STEP_COMPLETE -> - log.info(" [STEP_COMPLETE] {}", data.get("step")); - case EventType.ROUTE_DECISION -> - log.info(" [ROUTE] {} -> {}", data.get("step"), data.get("next")); - case EventType.COMPLETE -> log.info(" [COMPLETE]"); - case EventType.ERROR -> log.error(" [ERROR] {}", data.get("error")); - case EventType.CLOSE -> log.info(" [CLOSE]"); - default -> {} - } - } - }; - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java b/samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java new file mode 100644 index 00000000..dda1e394 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** Sample workbench outbound-client settings. Vendor-specific settings live separately. */ +@Component +@ConfigurationProperties(prefix = "a2a") +public class WorkbenchClientProperties { + private String orchUrl = "https://127.0.0.1:5001"; + private String credentialsPath = ""; + private boolean sslVerify; + private String a2atEnvPath = ""; + + public String getOrchUrl() { + return orchUrl; + } + + public void setOrchUrl(String orchUrl) { + this.orchUrl = orchUrl; + } + + public String getCredentialsPath() { + return credentialsPath; + } + + public void setCredentialsPath(String credentialsPath) { + this.credentialsPath = credentialsPath; + } + + public boolean isSslVerify() { + return sslVerify; + } + + public void setSslVerify(boolean sslVerify) { + this.sslVerify = sslVerify; + } + + public String getA2atEnvPath() { + return a2atEnvPath; + } + + public void setA2atEnvPath(String a2atEnvPath) { + this.a2atEnvPath = a2atEnvPath; + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemo.java b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemo.java new file mode 100644 index 00000000..6e291beb --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemo.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.demo; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openan.workflow.engine.client.A2ATransport; +import dev.openan.workflow.engine.client.AgentCardJacksonModule; +import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; +import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; +import dev.openan.workflow.engine.examples.util.EnvResolver; +import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; +import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity2Executor; +import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; +import dev.openan.workflow.engine.model.SendMessageResult; + +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ConfigurableApplicationContext; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import dev.openan.workflow.engine.examples.SpringWorkbenchApplication; + +/** + * Demo entry point for the SPN cross-city diagnosis (direct HTTP+JSON transport). + * + *

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

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

Demonstrates heterogeneous architecture: Workbench (Spring Boot) and OMC (JDK HttpServer) + * communicate via the unified A2A-T protocol. + */ +public class SpringSpnDemo { + private static final Logger log = LoggerFactory.getLogger(SpringSpnDemo.class); + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(new AgentCardJacksonModule()); + + private static final String WB_AGENT_NAME = "Transport Workbench Agent"; + private static final long STARTUP_WAIT = 3; + + private final List omcServers = new ArrayList<>(); + + public static void main(String[] args) throws Exception { + System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); + SpringWorkbenchApplication.loadDotEnv(); + new SpringSpnDemo().run(args); + } + + public void run(String... applicationArgs) throws Exception { + long demoStarted = System.nanoTime(); + ConfigurableApplicationContext ctx = null; + boolean success = false; + 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"); + startOmcAgents(); + 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={}", + omcServers.size(), + elapsedMillis(stageStarted)); + + stageStarted = System.nanoTime(); + log.info("[Demo] STAGE_START stage=start-spring-workbench"); + ctx = SpringApplication.run(SpringWorkbenchApplication.class, 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, + 14); + log.debug("[Demo] Workbench task text={}", "创建专线业务投诉诊断任务"); + String response = sendTaskToWorkbench(); + 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; + } 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={}", + omcServers.size(), + ctx != null); + if (ctx != null) { + try { + ctx.close(); + } catch (Exception e) { + log.warn("[Demo] Failed to close Spring context: {}", e.getMessage(), e); + } + } + shutdownOmcAgents(); + log.info( + "[Demo] SHUTDOWN_DONE success={}, elapsedMs={}", + success, + elapsedMillis(demoStarted)); + } + } + + private void startOmcAgents() throws Exception { + startOmcAgent("agentcard/spn_domain_agent_city1.json", new SpnDomainAgentCity1Executor()); + startOmcAgent("agentcard/spn_domain_agent_city2.json", new SpnDomainAgentCity2Executor()); + } + + @SuppressWarnings("unchecked") + private void startOmcAgent(String resourcePath, AgentExecutor executor) throws Exception { + long started = System.nanoTime(); + String path = getClass().getClassLoader().getResource(resourcePath).getPath(); + Map card = mapper.readValue(new File(path), Map.class); + List> ifaces = + (List>) card.getOrDefault("supportedInterfaces", List.of()); + String url = + ifaces.isEmpty() ? "https://127.0.0.1:0" : String.valueOf(ifaces.get(0).get("url")); + java.net.URI uri = java.net.URI.create(url); + String host = uri.getHost() != null ? uri.getHost() : "127.0.0.1"; + int port = Math.max(uri.getPort(), 0); + JdkHttpA2AServer server = new JdkHttpA2AServer(host, port, card, executor); + server.start(); + omcServers.add(server); + log.info( + "[Demo] OMC_STARTED agent={}, resource={}, endpoint={}, elapsedMs={}", + card.get("name"), resourcePath, url, elapsedMillis(started)); + } + + /** Northbound Task-T message to the Workbench Agent, mirroring spec case 7.1. */ + private String sendTaskToWorkbench() throws Exception { + long started = System.nanoTime(); + String cardPath = + getClass() + .getClassLoader() + .getResource("agentcard/transport_workbench_agent.json") + .getPath(); + AgentCard wbCard = mapper.readValue(new File(cardPath), AgentCard.class); + + String credPath = + getClass().getClassLoader().getResource("spn_agent_credentials.json").getPath(); + String envPath = EnvResolver.resolveEnvPath(); + + A2ATransport transport = + new A2ATransport( + List.of(wbCard), + null, + WorkflowEngineClientConfig.builder() + .sslVerify(false) + .a2atEnvPath(envPath) + .credentialsConfigPath(credPath) + .build()); + DefaultWorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); + try { + // Task-T structured prompt placed in message.metadata (spec §6). + String taskPrompt = + "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" + + "## 任务描述(Task Description)\n" + + "基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, " + + "达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n" + + "## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n" + + "## 任务对象(Task Object)\n" + + "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17\n\n" + + "## 任务上下文(Task Context)\n" + + "1. 投诉分类:\"专线质差\"\n" + + "2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n" + + "3. OSS侧事件流水号:\"event-id-20260511-09013\"\n" + + "4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," + + "访问广州机房的核心交易系统非常慢。\"\n\n" + + "## 预期输出(Expected Output)\n" + + "1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n" + + "2. 诊断结果详情;(必选)\n" + + "3. 修复建议;(可选)\n" + + "4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"; + String taskTUri = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; + java.util.Map metadata = new java.util.LinkedHashMap<>(); + metadata.put(taskTUri, taskPrompt); + String partsText = "创建专线业务投诉诊断任务"; + log.info( + "[Demo] NORTHBOUND_SEND target={}, contextId={}, endpoint={}, inputChars={}", + WB_AGENT_NAME, + transport.getContextId(), + wbCard.supportedInterfaces().isEmpty() + ? "?" + : wbCard.supportedInterfaces().get(0).url(), + partsText.length()); + SendMessageResult result = + client.sendMessage(WB_AGENT_NAME, partsText, null, metadata).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.getText(); + } finally { + client.close(); + transport.close(); + } + } + + private void shutdownOmcAgents() { + log.info("[Demo] OMC_SHUTDOWN_START count={}", omcServers.size()); + omcServers.forEach( + s -> { + try { + s.close(); + } catch (Exception e) { + log.warn("[Demo] Failed to close OMC server: {}", e.getMessage(), e); + } + }); + omcServers.clear(); + log.info("[Demo] OMC_SHUTDOWN_DONE"); + } + + /** Backward-compatible programmatic entry point. */ + public void run() throws Exception { + run(new String[0]); + } + + private static long elapsedMillis(long startedNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringSpnDemo.java b/samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringSpnDemo.java deleted file mode 100644 index 2da30099..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/spring/SpringSpnDemo.java +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.spring; - -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.openan.workflow.engine.client.A2ATransport; -import dev.openan.workflow.engine.client.AgentCardJacksonModule; -import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; -import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; -import dev.openan.workflow.engine.examples.agents.EnvResolver; -import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; -import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity2Executor; -import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; -import dev.openan.workflow.engine.model.SendMessageResult; - -import dev.openan.workflow.engine.examples.embedded.SpnCrossCityDiagnosisDemo; -import org.a2aproject.sdk.server.agentexecution.AgentExecutor; -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.SpringApplication; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -/** - * Demo entry point for the SPN cross-city diagnosis. - * - *

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

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

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

Same business logic as {@link - * TransportWorkbenchAgentExecutor} -- receives a Task-T from - * the upper layer, delegates to {@link WorkbenchOrchestrator} for the full pipeline, and returns - * the result. The difference is purely the server container: this runs inside Spring Boot instead - * of the JDK HttpServer-based {@code JdkHttpA2AServer}. - */ -@Component -public class SpringWorkbenchExecutor extends BaseAgentExecutor { - private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchExecutor.class); - - @Value("${a2a.orch-url:https://127.0.0.1:5001}") - private String orchUrl; - - @Value("${a2a.credentials-path:}") - private String credentialsPath; - - @Value("${a2a.ssl-verify:false}") - private boolean sslVerify; - - @Value("${a2a.a2at-env-path:}") - private String a2atEnvPath; - - private String resolveEnvPath() { - if (a2atEnvPath != null && !a2atEnvPath.isBlank()) { - return a2atEnvPath; - } - return EnvResolver.resolveEnvPath(); - } - - private String resolveCredentialsPath() { - if (credentialsPath != null && !credentialsPath.isBlank()) { - return credentialsPath; - } - var url = getClass().getClassLoader().getResource("spn_agent_credentials.json"); - if (url != null) { - return new File(url.getPath()).getAbsolutePath(); - } - return null; - } - - @Override - public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - String input = extractText(ctx.getMessage()); - log.info( - "[SpringWorkbench] Received task: taskId={}, text={} chars", - taskId, - input.length()); - - emitter.submit(buildStatusMessage(contextId, taskId, "Task received")); - emitter.startWork(buildStatusMessage(contextId, taskId, "Processing")); - - try { - String result = - new WorkbenchOrchestrator(orchUrl, resolveCredentialsPath(), sslVerify, resolveEnvPath()) - .run(input); - Map metadata = new LinkedHashMap<>(); - metadata.put(A2ATExtension.TASK_T.uri(), result); - List> parts = List.of(new TextPart("SPN cross-city fault diagnosis summary")); - emitter.addArtifact( - parts, "result", "cross-city-diagnosis-summary", metadata, false, true); - emitter.complete(buildStatusMessage(contextId, taskId, "Completed")); - log.info("[SpringWorkbench] Task completed"); - } catch (Exception e) { - log.error("[SpringWorkbench] Failed: {}", e.getMessage(), e); - emitter.fail(buildStatusMessage(contextId, taskId, "Failed: " + e.getMessage())); - } - } - - @Override - public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { - emitter.cancel(); - } -} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/EnvResolver.java b/samples/src/main/java/dev/openan/workflow/engine/examples/util/EnvResolver.java similarity index 97% rename from samples/src/main/java/dev/openan/workflow/engine/examples/agents/EnvResolver.java rename to samples/src/main/java/dev/openan/workflow/engine/examples/util/EnvResolver.java index 47063284..c989c6b1 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/EnvResolver.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/util/EnvResolver.java @@ -17,7 +17,7 @@ * under the License. */ -package dev.openan.workflow.engine.examples.agents; +package dev.openan.workflow.engine.examples.util; import java.io.File; diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/LlmHelper.java b/samples/src/main/java/dev/openan/workflow/engine/examples/util/LlmHelper.java similarity index 99% rename from samples/src/main/java/dev/openan/workflow/engine/examples/LlmHelper.java rename to samples/src/main/java/dev/openan/workflow/engine/examples/util/LlmHelper.java index 49dfac9d..bf12b8d7 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/LlmHelper.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/util/LlmHelper.java @@ -17,7 +17,7 @@ * under the License. */ -package dev.openan.workflow.engine.examples; +package dev.openan.workflow.engine.examples.util; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; 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 7e01dcf9..9832d979 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 @@ -26,32 +26,23 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import java.util.Map; -import dev.openan.workflow.engine.examples.gateway.ClientRuntimeFactory; /** Keeps Authorization-T/Notification-T pre-positioning independent from individual tasks. */ @Component public final class SpringWorkbenchExtensionLifecycle { private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchExtensionLifecycle.class); - private final ClientRuntimeFactory runtimeFactory; - private final ConfigurableListableBeanFactory beanFactory; private final WorkbenchClientProperties properties; private WorkbenchExtensionLifecycle lifecycle; - public SpringWorkbenchExtensionLifecycle( - ClientRuntimeFactory runtimeFactory, - ConfigurableListableBeanFactory beanFactory, - WorkbenchClientProperties properties) { - this.runtimeFactory = runtimeFactory; - this.beanFactory = beanFactory; + public SpringWorkbenchExtensionLifecycle(WorkbenchClientProperties properties) { this.properties = properties; } @@ -60,16 +51,12 @@ public synchronized void start() { if (lifecycle != null && lifecycle.isActive()) { return; } - if (beanFactory.containsBean("eastcomOrderSimulatorServer")) { - beanFactory.registerDependentBean( - "eastcomOrderSimulatorServer", "springWorkbenchExtensionLifecycle"); - } WorkbenchExtensionLifecycle candidate = new WorkbenchExtensionLifecycle( resolveCredentialsPath(), properties.isSslVerify(), resolveEnvPath(), - runtimeFactory::create, + null, this::onNotification); candidate.start(); lifecycle = candidate; diff --git a/samples/src/main/resources/application.yml b/samples/src/main/resources/application.yml index c38fbd2c..377db843 100644 --- a/samples/src/main/resources/application.yml +++ b/samples/src/main/resources/application.yml @@ -1,6 +1,8 @@ server: port: 26337 ssl: + # Public demo certificate only. Its private key and password are intentionally known; + # replace both before any shared, test, staging, or production deployment. enabled: true key-store: classpath:a2a-server-keystore.p12 key-store-password: changeit diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/EmbeddedA2AServerTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/EmbeddedA2AServerTest.java deleted file mode 100644 index 19576e14..00000000 --- a/samples/src/test/java/dev/openan/workflow/engine/examples/EmbeddedA2AServerTest.java +++ /dev/null @@ -1,227 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples; - -import static org.junit.jupiter.api.Assertions.*; - -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.openan.workflow.engine.client.A2ATransport; -import dev.openan.workflow.engine.client.AgentCardJacksonModule; -import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; -import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; -import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; -import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; -import dev.openan.workflow.engine.model.SendMessageResult; - -import org.a2aproject.sdk.spec.AgentCard; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.List; -import java.util.Map; - -/** - * Integration test: start an EmbeddedA2AServer, send a message via DefaultWorkflowEngineClient (the - * real A2A client), and verify the response text is extracted correctly from the SSE stream. - */ -class EmbeddedA2AServerTest { - - private static final ObjectMapper mapper = - new ObjectMapper().registerModule(new AgentCardJacksonModule()); - private static final String AGENT_NAME = "Test Agent"; - private JdkHttpA2AServer server; - private DefaultWorkflowEngineClient client; - private int port; - - private static String extractExtensionValue(SendMessageResult result, String extKeyword) { - Map meta = result.getMetadata(); - if (meta == null) return null; - for (String key : meta.keySet()) { - if (key.contains(extKeyword)) { - Object v = meta.get(key); - return v instanceof String s ? s : String.valueOf(v); - } - } - return null; - } - - private static javax.net.ssl.SSLContext createTrustAllSslContext() throws Exception { - javax.net.ssl.SSLContext ctx = javax.net.ssl.SSLContext.getInstance("TLS"); - ctx.init( - null, - new javax.net.ssl.TrustManager[] { - new javax.net.ssl.X509TrustManager() { - public void checkClientTrusted( - java.security.cert.X509Certificate[] chain, String authType) {} - - public void checkServerTrusted( - java.security.cert.X509Certificate[] chain, String authType) {} - - public java.security.cert.X509Certificate[] getAcceptedIssuers() { - return new java.security.cert.X509Certificate[0]; - } - } - }, - null); - return ctx; - } - - @BeforeEach - void setUp() throws Exception { - // Unit tests must stay deterministic and offline: disable LLM calls so - // agents fall back to their hardcoded diagnostic text (asserted below). - System.setProperty("a2at.llm.disabled", "true"); - port = 28000 + (int) (Math.random() * 1000); - Map card = - Map.of( - "name", AGENT_NAME, - "description", "test", - "provider", Map.of("organization", "test", "url", ""), - "version", "1.0.0", - "capabilities", - Map.of( - "streaming", - true, - "pushNotifications", - false, - "extendedAgentCard", - false, - "extensions", - List.of( - 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 JdkHttpA2AServer("127.0.0.1", port, card, new SpnDomainAgentCity1Executor()); - server.start(); - Thread.sleep(500); - A2ATransport transport = - new A2ATransport( - List.of(mapper.convertValue(card, AgentCard.class)), - null, - WorkflowEngineClientConfig.builder().sslVerify(false).build()); - client = new DefaultWorkflowEngineClient(transport); - } - - @AfterEach - void tearDown() { - if (client != null) client.close(); - if (server != null) server.close(); - } - - @Test - void testGetAgentCard() throws Exception { - HttpClient http = HttpClient.newBuilder().sslContext(createTrustAllSslContext()).build(); - HttpRequest req = - HttpRequest.newBuilder() - .uri(URI.create("https://127.0.0.1:" + port + "/")) - .GET() - .build(); - HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString()); - assertEquals(200, resp.statusCode()); - Map card = mapper.readValue(resp.body(), Map.class); - assertEquals(AGENT_NAME, card.get("name")); - } - - @Test - void testSendMessage() throws Exception { - SendMessageResult result = client.sendMessage(AGENT_NAME, "diagnose SPN fault").join(); - assertNotNull(result); - assertFalse( - result.getText().isEmpty(), - "Response text should not be empty, got: " + result.getText()); - // A2A-T extension content is in metadata (Task-T/v1), not in parts.text - String taskMeta = extractExtensionValue(result, "Task-T"); - assertNotNull(taskMeta, "Task-T metadata should be present in response"); - assertTrue( - taskMeta.contains("诊断结果"), - "Diagnosis metadata should contain structured diagnosis result, got: " + taskMeta); - } - - /** - * Negotiation path: when the message metadata carries a Task-T prompt plus the Negotiation-T - * extension key, the agent starts a 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 prompt with a blank task object so server-side validation would also flag it; - // the Negotiation-T key activates the extension on the A2A-Extensions header. - 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( - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", - ""); - SendMessageResult result = - client - .sendMessage(AGENT_NAME, "diagnose SPN fault", null, metadata) - .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()); - } -} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/SpnCrossCityE2ETest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/SpnCrossCityE2ETest.java index a2af84c5..321a2110 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 @@ -20,7 +20,7 @@ import dev.openan.workflow.engine.control.EventType; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity2Executor; -import dev.openan.workflow.engine.examples.agents.WorkbenchControlPoint; +import dev.openan.workflow.engine.examples.workbench.WorkbenchControlPoint; import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; import dev.openan.workflow.engine.model.ExecutionResult; import dev.openan.workflow.engine.model.JumpCondition; From 324c35c840f52e3e77d4777df7a8bf40a7c96a4f Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Sun, 23 Aug 2026 18:43:44 +0800 Subject: [PATCH 06/49] docs: sync workbench integration guide generic improvements from dev Bring over the non-vendor parts of the dev-branch guide updates: - transport lifecycle clarification (task-level vs workbench-level A2ATransport instances to keep Notification-T streams alive) - downstream AgentCard security field semantics (securitySchemes as capability, securityRequirements gating credentials auth) - AuthProvider/credentials header merge rules and conflict error - defaultInputModes/defaultOutputModes and skill description fields in the workbench AgentCard example - defaultInputModes note and prompt-compliance guidance refresh Vendor-specific sections (Eastcom SDK token acquisition, Order-mode concurrent NE troubleshooting 6.7) stay on dev only. Signed-off-by: Zhoujie628 --- ...06\346\210\220\346\214\207\345\215\227.md" | 211 ++++++++---------- 1 file changed, 98 insertions(+), 113 deletions(-) diff --git "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" index e9cb49f0..c4580542 100644 --- "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" @@ -33,7 +33,7 @@ SDK 在该场景中承载四类 A2A-T 交互,对应业务流文档的四个接 | 3 | Authorization-T | 抢通授权 | 工作台 -> OMC | 工作流启动前,前置预定位 | | 4 | Notification-T | 抢通结果订阅/上报 | 工作台 -> OMC (订阅) / OMC -> 工作台 (SSE 上报) | 工作流启动前订阅,执行中上报 | -序号 1、2 在工作流执行过程中发生,由 SDK 的 `WorkflowEngineClient` 承载。序号 3、4 是前置动作,在工作流启动前完成,由 SDK 的 `ExtensionSender` 承载。这两条路径共享同一个底层传输层 `A2ATransport`,职责隔离。 +序号 1、2 在工作流执行过程中发生,由 SDK 的 `WorkflowEngineClient` 承载。序号 3、4 是前置动作,在工作流启动前完成,由 SDK 的 `ExtensionSender` 承载。两条路径使用同一类底层传输组件 `A2ATransport`,但生命周期不同:工作流发送使用任务级 transport,Notification-T 订阅使用工作台级 transport,避免工作流结束时关闭长连接。 ### 1.3 SDK 自动处理 vs 工作台实现 @@ -129,8 +129,15 @@ Spring Boot starter 会自动注册 `AgentCard`、`RequestHandler`、`RestHandle {"uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1", "required": false} ] }, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], "skills": [ - {"id": "cross-city-fault-diagnosis", "name": "跨城故障协同诊断"} + { + "id": "cross-city-fault-diagnosis", + "name": "跨城故障协同诊断", + "description": "协同多地市 OMC 执行专线故障诊断并汇总结果", + "tags": ["SPN", "fault-diagnosis"] + } ], "supportedInterfaces": [ {"protocolBinding": "HTTP+JSON", "url": "https://工作台地址/a2a/json"} @@ -140,16 +147,38 @@ Spring Boot starter 会自动注册 `AgentCard`、`RequestHandler`、`RestHandle 下游 SPN Domain Agent 的 AgentCard 由厂商提供或从注册中心获取。工作台只需加载它们用于 SDK 通信。关键信息是 `name` (SDK 中用此名称寻址) 和 `supportedInterfaces.url` (通信地址)。 +下游 AgentCard 的安全字段语义: + +- `securitySchemes` 表示智能体支持的认证方式,不等于强制要求 +- `securityRequirements` 表示当前对接必须满足的认证方式;缺失、`null` 或 `[]` 都表示不启用内置凭证认证 +- 如果工作台通过 `AuthProvider` 自行获取 OMC Token,推荐配置 `"securityRequirements": []`,`securitySchemes` 可以保留为能力声明 +- 如果希望内置 credentials 认证生效,`securityRequirements` 必须非空,并引用 `securitySchemes` 中声明的方案名 + +AuthProvider-only 场景的下游 AgentCard 安全字段示例: + +```json +{ + "securitySchemes": { + "bearerAuth": { + "httpAuthSecurityScheme": { + "scheme": "Bearer" + } + } + }, + "securityRequirements": [] +} +``` + #### 2.2 认证配置 SDK 提供两层认证机制,可单独使用或组合使用: | 认证方式 | 适用场景 | 配置方式 | |---------|---------|---------| -| Credentials 认证 | Agent 使用标准 Bearer/API Key 认证,有登录接口获取令牌 | 提供 credentials.json 文件 | -| 自定义 AuthProvider | 企业 SSO、非标准认证协议、AgentCard 无 securitySchemes 但仍需认证 | 实现 `AuthProvider` 接口 | +| Credentials 认证 | Agent 提供登录接口并使用 Bearer 或自定义认证头 | 提供 credentials.json 文件 | +| 自定义 AuthProvider | 工作台自管 Token、企业 SSO、非标准认证头,或经自定义网关/协议转发获取 OMC Token | 实现 `AuthProvider` 接口 | -**执行顺序:** 自定义 AuthProvider 先执行,Credentials 认证后执行。两者可同时使用,各自向请求头注入认证信息。 +**合并规则:** 两种认证来源分别生成 Header 后合并。`AuthProvider` 可以作为唯一认证来源;如果两者对同名 Header 生成不同值,引擎会抛出 `SecurityException`。 ##### 方式一: Credentials 认证 (credentials.json) @@ -243,7 +272,7 @@ String encrypted = CredentialCrypto.encrypt("Admin@123"); | `request_fields` | Map | - | 请求体字段 (替代 legacy 的 username/password) | | `token_field` | String | `accessSession` | 从响应 JSON 提取令牌的路径 (支持点号嵌套,如 `data.access_token`) | | `token_ttl` | int | 3600 | 令牌缓存有效期 (秒),到期前 60 秒自动刷新 | -| `auth_header` | String | `Authorization` | 自定义认证头名称 (设置后使用 `CustomAuthInterceptor`) | +| `auth_header` | String | `Authorization` | 自定义认证头名称 (设置后引擎改用该 Header 注入凭证) | | `auth_header_prefix` | String | `Bearer ` | 自定义认证头前缀 (与 `auth_header` 配合使用) | ##### 方式二: 自定义 AuthProvider @@ -283,6 +312,9 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() - 认证机制非 A2A 标准类型 (Bearer、API Key、OAuth2) - 令牌来自企业统一身份认证平台 (如企业 SSO) - 需要注入非标准认证头 (如 `X-Custom-Token`、`X-Request-Signature`) +- 消息经自定义 `A2AJavaClientRuntime` 协议转发时,认证 Header 已由 `AuthProvider` 注入 `callContext`,转发实现原样复制即可 + +如果 Authorization-T / Notification-T 前置消息和工作流 Task-T 消息都需要认证,请把同一个 `AuthProvider` 配置到 `extensionTransport` 和 `taskTransport` 的 `WorkflowEngineClientConfig` 中。 ##### 两种方式组合使用 @@ -298,17 +330,18 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .build(); ``` -执行顺序: `AuthProvider.applyAuth()` 先执行 -> `AgentAuthManager` 后执行。两者写入同一个 headers Map,互不覆盖。 +`AuthProvider` 与 credentials 认证分别生成 Header 后再合并。`AuthProvider` 可以作为唯一认证来源,即使 AgentCard 的 `securityRequirements` 非空且未配置 credentials。不同名称的 Header 可同时使用;如果两者对同名 Header(例如 `Authorization`)给出不同值,引擎会立即抛出 `SecurityException`,避免认证信息被顺序覆盖。建议每个认证 Header 只配置一个来源。 #### 2.3 A2A-T SDK 环境 (.env) A2A-T SDK 的 LLM 配置 (用于 Task-T 提示词生成、场景识别等)。最小配置: ```ini -LLM_API_KEY=你的API密钥 -LLM_BASE_URL=https://api.example.com/v1 -LLM_MODEL=gpt-4o -LLM_LANGUAGE=zh-CN +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=deepseek-chat +A2AT_LLM_API_KEY=你的API密钥 +A2AT_LLM_BASE_URL=https://api.deepseek.com +A2AT_LANGUAGE=zh-CN ``` SDK 通过此配置调用 A2A-T SDK 生成 Task-T 结构化提示词。如果此文件未配置或生成失败,SDK 会降级为使用原始输入文本作为消息内容,不阻断流程。 @@ -443,7 +476,10 @@ public class WorkbenchControlPoint extends DefaultControlPoint { return engineClient.sendMessage(agentName, targetedMessage) .thenApply(r -> { - boolean success = r.getText() != null && !r.getText().isEmpty(); + String state = r.getTaskState(); + boolean success = state == null || state.isBlank() + ? r.getText() != null && !r.getText().isBlank() + : state.endsWith("COMPLETED"); return TaskResponse.builder() .success(success) .output(r.getText()) @@ -595,8 +631,9 @@ public class WorkbenchOrchestrator { String psopId = searchPsop(messageText); Workflow workflow = LoadPsop.load(orchUrl, psopId, null, sslVerify); - // 3. 创建共享传输层 + 两个门面 - A2ATransport transport = new A2ATransport( + // 3. 前置预定位使用工作台级 transport + // 生产环境应在服务启动时创建并持有 + A2ATransport extensionTransport = new A2ATransport( agentCards, null, WorkflowEngineClientConfig.builder() @@ -608,13 +645,23 @@ public class WorkbenchOrchestrator { // headers.put("Authorization", "Bearer " + ssoClient.getToken(agentName)); // }) .build()); - WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport); - ExtensionSender extensionSender = new DefaultExtensionSender(transport); + ExtensionSender extensionSender = new DefaultExtensionSender(extensionTransport); // 4. 前置预定位: 向每个 OMC 下发授权策略 + 订阅通知 prePositionExtensions(extensionSender, agentCards); - // 5. 执行工作流 + // 5. 工作流发送使用任务级 transport + A2ATransport taskTransport = new A2ATransport( + agentCards, + null, + WorkflowEngineClientConfig.builder() + .sslVerify(sslVerify) + .a2atEnvPath(a2atEnvPath) + .credentialsConfigPath(credentialsPath) + .build()); + WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(taskTransport); + + // 6. 执行工作流 WorkbenchControlPoint controlPoint = new WorkbenchControlPoint(a2atEnvPath, new NegotiationStrategy(a2atEnvPath)); ExecutionResult result = ExecutePsop.builder() @@ -682,7 +729,7 @@ private void prePositionExtensions(ExtensionSender sender, List agent - `sendAuthorization`: 向 OMC 下发授权白名单。OMC 在后续诊断中如果命中已授权操作 (如光模块更换),自动执行抢通。 - `sendNotification`: 建立 SSE 订阅通道。第四个参数 `Consumer>` 是长连接 SSE 回调,OMC 后续推送的抢通结果 (recovery result) 通过此回调实时接收。回调收到的 Map 包含 `agent`、`text`、`metadata`、`state` 等字段。不传回调 (null) 时后续事件被丢弃。 -这两个方法返回 `CompletableFuture`,`.join()` 阻塞等待首个事件 (订阅确认) 完成。SDK 内部自动处理 Authorization-T / Notification-T 报文构造和 Bearer 认证。对于 `sendNotification`,`.join()` 返回后 SSE 长连接仍然存活,后续事件持续推送到回调,直到工作台主动关闭 transport。 +这两个方法返回 `CompletableFuture`,`.join()` 阻塞等待首个事件 (订阅确认) 完成。SDK 内部自动处理 Authorization-T / Notification-T 报文构造和认证头注入(来自 `AuthProvider` 或 credentials 配置)。对于 `sendNotification`,`.join()` 返回后 SSE 长连接仍然存活,后续事件持续推送到回调,直到工作台主动关闭 transport。 #### 5.2 PSOP 工作流搜索与加载 @@ -756,7 +803,7 @@ SDK 提供两个独立的门面 (facade),职责隔离: **`ExecutePsop` 不做任何预定位。** 它只负责 DAG 遍历、调用 `ControlPoint` 决策回调、通过 `WorkflowEngineClient` 发送 Task-T 消息、处理 Negotiation-T 协商循环。预定位是工作台编排类的职责,必须在调用 `ExecutePsop` 之前完成。 -两个门面共享同一个 `A2ATransport` (HTTP 连接池、认证管理器、AgentCard 注册表),避免重复初始化。 +两个门面分别使用独立的 `A2ATransport` 生命周期。`WorkflowEngineClient` 使用任务级 transport,工作流结束后由 `ExecutePsop` 关闭;`ExtensionSender` 使用工作台级 transport,承载 Notification-T 长连接,直到服务关闭。 ### 4.2 完整时序图 @@ -777,8 +824,8 @@ sequenceDiagram Note over WB: 工作台编排: 初始化 Note over WB: 1. 加载 AgentCard (工作台 + OMC) Note over WB: 2. 搜索/加载 PSOP (从编排中心) - Note over WB: 3. 创建 A2ATransport (共享) - Note over WB: 4. 创建 ExtensionSender + WorkflowEngineClient + Note over WB: 3. 创建工作台级 A2ATransport + ExtensionSender + Note over WB: 4. 创建任务级 A2ATransport + WorkflowEngineClient end rect rgba(240, 248, 255, 1) @@ -855,7 +902,7 @@ sequenceDiagram **工作流执行阶段 (工作台调用 ExecutePsop):** - `ExecutePsop` 内部创建 `WorkflowExecutor`,执行 DAG 遍历 - 每个步骤: `WorkflowExecutor` 调用 `ControlPoint.onTask()`,工作台决定发什么内容 -- 工作台在 `onTask` 中调用 `engineClient.sendMessage()`,SDK 自动处理 Task-T 提示词生成 + Bearer 认证 + SSE 流式发送 + Negotiation-T 协商循环 +- 工作台在 `onTask` 中调用 `engineClient.sendMessage()`,SDK 自动处理 Task-T 提示词生成 + 认证头注入 + SSE 流式发送 + Negotiation-T 协商循环 - `SELF_LOOP` 步骤调用 `onSelfTask()`,工作台本地处理,不发送 A2A-T 消息 **后续通知阶段 (SSE 长连接):** @@ -886,12 +933,16 @@ sequenceDiagram 生产环境 OMC 通常使用自签名证书。`WorkflowEngineClientConfig` 中: - `sslVerify(true)`: 校验证书 (需配置 CA 信任库 `caCertsPath`) -- `sslVerify(false)`: 跳过校验 (仅开发环境) +- `sslVerify(false)`: 仅跳过证书链校验,主机名校验仍启用;只用于受控的本地诊断 + (HTTP/JSON-RPC 会继续加载 mTLS 客户端证书;默认 gRPC 使用 plaintext,不能同时配置 mTLS/CRL) ```java WorkflowEngineClientConfig.builder() .sslVerify(true) - .caCertsPath("/path/to/ca-truststore.jks") + .caCertsPath("/path/to/ca-certs.pem") + // OMC 要求双向 TLS 时配置: + .clientCertPath("/path/to/client-cert.pem") + .clientKeyPath("/path/to/client-key.pem") .credentialsConfigPath(credentialsPath) .a2atEnvPath(a2atEnvPath) .build() @@ -926,7 +977,7 @@ WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport, n ### 6.4 SSE 长连接管理 -`sendNotification` 建立的 SSE 订阅通道是长连接。SDK 的 `A2ATransport` 管理连接池和超时。工作台应在服务关闭时调用 `transport.close()` 释放连接。`ExecutePsop` 在工作流结束后自动关闭 `engineClient`,但 `ExtensionSender` 的连接需要手动管理 (或共享同一个 transport,随 engineClient 一起关闭)。 +`sendNotification` 建立的 SSE 订阅通道是长连接。SDK 的 `A2ATransport` 管理连接池和超时。工作台应在服务关闭时调用 `extensionTransport.close()` 释放连接。`ExecutePsop` 在工作流结束后自动关闭 `engineClient` 及其任务级 transport;不要把这个 transport 与 `ExtensionSender` 共用,否则 Notification-T 长连接会在工作流结束时被关闭。 ### 6.5 并发与并行 @@ -939,98 +990,21 @@ SDK 的 `WorkflowExecutor` 自动并行调度同一层的步骤 (无依赖关系 - `INPUT_REQUIRED`: 需要协商 (SDK 自动处理,超过轮次后返回此状态) - `FAILED`: 任务失败 -工作台在 `onTask` 中应检查 `result.getText()` 是否为空来判断成功: +有明确 `taskState` 时,工作台应以任务状态判断成功;只有旧协议或消息响应没有状态时,才回退到文本内容判断: ```java -.thenApply(r -> TaskResponse.builder() - .success(r.getText() != null && !r.getText().isEmpty()) - .output(r.getText()) - .build()) +.thenApply(r -> { + String state = r.getTaskState(); + boolean success = state == null || state.isBlank() + ? r.getText() != null && !r.getText().isBlank() + : state.endsWith("COMPLETED"); + return TaskResponse.builder() + .success(success) + .output(r.getText()) + .build(); +}) ``` -### 6.7 东信 Order 模式: 并发 NE 断连/超时排查 - -> **这是联调中最容易踩坑的问题,务必在改造前阅读。** - -#### 问题现象 - -Order 模式下同时向两个不同地市 OMC 发送 Task-T 诊断任务(或前置阶段给 City1 发完 Notification-T 后给 City2 发 Authorization-T),其中一个城市的请求报 `Timeout on blocking read for 10000000000 NANOSECONDS`,堆栈指向 `WrapperHttpClient.loadNeResource`。 - -根因有两个,两者叠加才会复现: - -#### 根因一: 东信 SDK 共享 RSocket 连接 - -东信 SDK 的 `ServiceReference` 类内部有一个 `static final Map INVOKERS`,按 `className + host + port` 缓存 RSocket 连接。所有发往同一东信平台地址(同一 host:port)的请求,无论 NE 是 `sim-city1` 还是 `sim-city2`,都共用同一条 RSocket 连接。 - -SDK 的 `WrapperHttpClient.loadNeResource()` 通过 `Mono.block(Duration.ofSeconds(10))` 同步阻塞等待 RSocket 响应。这个 10 秒超时是 SDK 硬编码的,无法通过配置修改。 - -#### 根因二: 模拟器阻塞 I/O 卡住 RSocket 事件循环 - -`EastcomOrderSimulatorServer` 的 `execute` 方法(处理 RSocket `requestChannel`)在收到 HTTP 请求后,调用 `forwardParsed` 通过 `HttpURLConnection` 转发给 OMC。`forwardParsed` 内部的 `reader.read()` 是阻塞 I/O。 - -当 City1 的 Notification-T 是长连接(等待 OMC 上报抢通结果),`reader.read()` 会一直阻塞。如果这个阻塞发生在 RSocket 事件循环线程(`reactor-tcp-nio-*`)上,该线程被占死,无法处理同一 RSocket 连接上 City2 的 `loadNeResource` 请求(RSocket `requestResponse`),10 秒后 City2 超时。 - -#### 时序示例 - -``` -T0 City1 Notification-T: loadNeResource → simulator 设置 lastResolvedTarget=city1_url -T1 City1 Notification-T: execute → forwardParsed → reader.read() 阻塞(长连接等待) - → 阻塞在 reactor-tcp-nio-4 线程上 -T2 City2 Authorization-T: loadNeResource → 发送到同一 RSocket 连接 - → reactor-tcp-nio-4 被阻塞,无法处理 -T3 10s 后: City2 loadNeResource 超时 (Mono.block 10s timeout) -``` - -#### 解决方案 - -**修复一: 将转发 I/O 移出 RSocket 事件循环线程** - -在模拟器的 `execute` 方法中,给 `forwardParsed` 调用加 `.subscribeOn(Schedulers.boundedElastic())`,把阻塞 I/O 从 `reactor-tcp-nio-*` 线程移到 `boundedElastic-*` 工作线程。RSocket 事件循环线程释放后,能正常处理其他 NE 的 `loadNeResource` 请求。 - -```java -// EastcomOrderSimulatorServer.execute() 中: -forwardParsed(httpMethod, httpPath, parsedHeaders, body, forwardUrl, streaming) - .subscribeOn(Schedulers.boundedElastic()) // 关键: 移出事件循环线程 - .subscribe(sink::next, sink::error, () -> sink.complete()); -``` - -**修复二: 用 HTTP Host 头替代共享 lastResolvedTarget 字段** - -原模拟器用 `volatile String lastResolvedTarget` 字段在 `loadNeResource` 时设置目标 URL,`execute` 时读取。当两个 NE 并发调用 `loadNeResource`,后一个会覆盖前一个的目标,导致请求转发到错误的 OMC。 - -东信 SDK 的 `WrapperHttpClient.configureHeaderHost()` 会把 HTTP `Host` 头设为 `loadNeResource` 返回的完整 `neUrl`(如 `https://127.0.0.1:26335`)。每个请求自带目标地址,不再依赖共享字段: - -```java -// EastcomOrderSimulatorServer.execute() 中,解析 HTTP 头后: -String hostHeader = findHeader(parsedHeaders, "Host"); -String targetBase; -if (hostHeader != null - && (hostHeader.startsWith("http://") || hostHeader.startsWith("https://"))) { - targetBase = withoutTrailingSlash(hostHeader); -} else { - targetBase = lastResolvedTarget != null - ? lastResolvedTarget : "http://127.0.0.1:26335"; -} -``` - -注意: `copyHeaders` 方法已经排除了 `Host` 头不转发给 OMC(避免覆盖 OMC 自身的 Host 头),这是正确的行为。 - -#### 为什么 readTimeout=65s 不能解决问题 - -模拟器的 `connection.setReadTimeout(65_000)` 只控制 `HttpURLConnection` 的读取超时,不影响 SDK 内部 `loadNeResource` 的 `Mono.block(10s)` 超时。即使模拟器等得起,SDK 在 10 秒内收不到 RSocket 响应就会超时报错。根因是 RSocket 事件循环线程被阻塞,不是 HTTP 读取超时。 - -#### 排查清单 - -如果再次出现 Order 模式并发超时,按以下顺序排查: - -1. **确认超时位置**: 日志中 `Timeout on blocking read for 10000000000 NANOSECONDS` 出现在 `WrapperHttpClient.loadNeResource` → 是 RSocket 超时,不是 HTTP 超时 -2. **确认线程**: 模拟器日志中 `FORWARD_CHUNK` / `FORWARD_DONE` 的线程名是 `boundedElastic-*` 而非 `reactor-tcp-nio-*` → 如果还是 `reactor-tcp-nio-*`,说明 `subscribeOn` 修复未生效 -3. **确认目标地址**: 日志中 `FORWARD_START` 的 `target=` 是否与 `LOAD_NE_ACCEPTED` 的 `target=` 一致 → 如果不一致,说明 `Host` 头解析未生效 -4. **确认会话复用**: `OrderGatewayClientRuntime` 日志中 `SESSION_REUSE` 而非每次 `SESSION_OPEN` → 同一 NE 应复用会话,避免重复登录 -5. **确认 Connection 单例**: `ClientRuntimeFactory.create()` 在 ORDER 模式下返回单例 `sharedOrderRuntime`,而非每次 new - ---- - ## 7. 接口速查表 ### 工作台实现的接口 @@ -1075,3 +1049,14 @@ if (hostHeader != null | 事件 | 日志打印 | SSE 推送前端 + 持久化数据库 | | 协商 | 固定澄清文本 | LLM 生成或业务规则引擎 | | 抢通结果 | 不处理 | 通过 Notification-T SSE 通道接收并入库 | + +--- + +## 9. 集成自查清单 + +- AgentCard 是否包含 `name`、`description`、`version`、`capabilities`、`defaultInputModes`、`defaultOutputModes`、`skills`、`supportedInterfaces` +- 下游 AgentCard 的 `securitySchemes` / `securityRequirements` 是否按实际认证方式配置;AuthProvider-only 推荐 `securityRequirements: []` +- `AuthProvider` 是否已同时配置到前置预定位 transport 和工作流任务 transport +- 前置预定位 transport 是否为工作台级生命周期,工作流 transport 是否为任务级生命周期 +- 自定义 `onTask` 是否优先使用 `taskState` 判断成功,而不是只用文本非空判断 +- `sendNotification` 是否传入回调,并在服务关闭时释放前置预定位 transport From ed17eec5a4c9ef0e70bfdd13cf3570414c15320a Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Sun, 23 Aug 2026 18:57:57 +0800 Subject: [PATCH 07/49] refactor(samples): remove redundant demo code and deduplicate shared logic - delete TransportWorkbenchAgentExecutor: no callers anywhere, only Javadoc references (the Spring-managed SpringWorkbenchExecutor is the sole workbench executor) - extract SpnCasePrompts: the ~600-char Task-T private-line complaint prompt was hardcoded identically in SpringSpnDemo and three times in A2ATProtocolCases; now defined once with well-formed / blank-object / unknown-port variants per spec cases 7.1/7.3/7.4 - extract OmcAgentLauncher: the load-agentcard -> resolve-bind-address -> start-server boilerplate was duplicated in SpringSpnDemo, A2ATProtocolCases and both integration tests - SpringSpnDemo: only start the HTTP mock gateway (26400) in mock mode; order mode routes through the Eastcom Order SDK and its bundled protocol simulator (26401), so the 26400 instance sat idle - update DESIGN-NOTES.md and notification-t-design-analysis.md to the current class names Verified: mvn verify all green (23 samples tests); SpringSpnDemo runs end-to-end in direct, mock and order+simulator modes; A2ATProtocolCases case 7.1 completes. Signed-off-by: Zhoujie628 --- docs/en/notification-t-design-analysis.md | 4 +- samples/DESIGN-NOTES.md | 60 +++++---- .../agents/SpringWorkbenchExecutor.java | 10 +- .../TransportWorkbenchAgentExecutor.java | 127 ------------------ .../examples/demo/A2ATProtocolCases.java | 61 ++++----- .../engine/examples/demo/SpnCasePrompts.java | 105 +++++++++++++++ .../engine/examples/demo/SpringSpnDemo.java | 108 +++------------ .../examples/server/OmcAgentLauncher.java | 97 +++++++++++++ .../workbench/WorkbenchOrchestrator.java | 5 +- .../engine/examples/SpnCrossCityE2ETest.java | 31 +---- .../server/EmbeddedA2AServerTest.java | 5 +- 11 files changed, 302 insertions(+), 311 deletions(-) delete mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/agents/TransportWorkbenchAgentExecutor.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpnCasePrompts.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/server/OmcAgentLauncher.java diff --git a/docs/en/notification-t-design-analysis.md b/docs/en/notification-t-design-analysis.md index a0638ff5..5c81e276 100644 --- a/docs/en/notification-t-design-analysis.md +++ b/docs/en/notification-t-design-analysis.md @@ -271,7 +271,7 @@ Current: Demo sends Task-T to Workbench Agent (A2A server) -> Workbench Agent re workflow -> workflow sends Task-T to SPN agents. The Workbench correctly plays both server (upper layer) and client (SPN agents) roles. But the -TransportWorkbenchAgentExecutor mixes "receive task" +workbench executor mixes "receive task" and "run workflow" in one execute () call, making the flow hard to follow. Simplification: Keep the dual role but make it cleaner: @@ -313,7 +313,7 @@ Current: AgentExecutor (SDK interface) -> BaseAgentExecutor (extractText, buildStatusMessage) -> NegotiationBaseAgentExecutor (negotiation, pre-positioning, business) --> SpnDomainAgentCity1Executor -> SpnDomainAgentCity2Executor -> TransportWorkbenchAgentExecutor +-> SpnDomainAgentCity1Executor -> SpnDomainAgentCity2Executor -> (workbench executor) Three levels of inheritance makes it hard to trace the flow. diff --git a/samples/DESIGN-NOTES.md b/samples/DESIGN-NOTES.md index a42d7e88..7a431ca5 100644 --- a/samples/DESIGN-NOTES.md +++ b/samples/DESIGN-NOTES.md @@ -15,23 +15,31 @@ ### 文件结构 +```mermaid +graph TD + root["samples/src/main/java/.../examples/"] + srv["server/JdkHttpA2AServer.java
可复用 A2A server(JDK HttpServer)"] + launch["server/OmcAgentLauncher.java
从 agentcard JSON 启动并跟踪 Agent"] + base["agents/BaseAgentExecutor.java
基类:extractText / buildStatusMessage"] + spn1["agents/SpnDomainAgentCity1Executor.java
城市1 OMC Agent"] + spn2["agents/SpnDomainAgentCity2Executor.java
城市2 OMC Agent"] + wb["workbench/SpringWorkbenchExecutor.java
Spring 管理的工作台 Agent"] + cp["workbench/WorkbenchControlPoint.java
ControlPoint 决策逻辑(SRP 拆分)"] + prompts["demo/SpnCasePrompts.java
专线投诉场景 Task-T 结构化提示词"] + demo["demo/SpringSpnDemo.java
Demo 入口(direct 直连传输)"] + + root --> srv & launch & base & demo + base --> spn1 & spn2 & wb + demo --> prompts + wb --> cp ``` -samples/src/main/java/.../examples/ - server/EmbeddedA2AServer.java # 可复用 A2A server(a2a-java SDK RestHandler) - agents/BaseAgentExecutor.java # 基类:extractText / buildStatusMessage - agents/SpnDomainAgentExecutor.java # 上海 OMC Agent - agents/SpnDomainAgentCity2Executor.java # 广州 OMC Agent - agents/TransportWorkbenchAgentExecutor.java # 工作台 Agent(编排者) - agents/WorkbenchControlPoint.java # ControlPoint 决策逻辑(SRP 拆分) - StartAgentsServer.java # 从 agentcard JSON 启动所有 Agent - SpnCrossCityDiagnosisDemo.java # Demo 入口 - -samples/src/main/resources/ - agentcard/spn_domain_agent.json # AgentCard 配置(标准格式,URL 带 /a2a/json 前缀) - agentcard/spn_domain_agent_city2.json - agentcard/transport_workbench_agent.json - spn_agent_credentials.json # Bearer 认证配置 -``` + +**资源文件:** + +- `samples/src/main/resources/agentcard/spn_domain_agent.json` — AgentCard 配置(标准格式,URL 带 /a2a/json 前缀) +- `samples/src/main/resources/agentcard/spn_domain_agent_city2.json` +- `samples/src/main/resources/agentcard/transport_workbench_agent.json` +- `samples/src/main/resources/spn_agent_credentials.json` — Bearer 认证配置 ## 三、AgentCard URL 必须用标准格式 @@ -40,7 +48,7 @@ samples/src/main/resources/ 现网 AgentCard 的 URL 格式是 `http://host:port/a2a/json`,路径前缀 `/a2a/json` 是标准做法。A2A REST 端点实际是 `http://host:port/a2a/json/message:stream`。 -`EmbeddedA2AServer` 必须从 AgentCard URL 提取路径前缀,在该前缀下注册路由: +`JdkHttpA2AServer` 必须从 AgentCard URL 提取路径前缀,在该前缀下注册路由: ```java // 从 AgentCard URL 提取路径前缀 @@ -53,7 +61,7 @@ String path = fullPath.startsWith(pathPrefix) ? fullPath.substring(pathPrefix.le ## 四、Agent card 从配置文件加载,不依赖注册中心 -**`TransportWorkbenchAgentExecutor` 执行工作流时,从 classpath 的 agentcard JSON 加载 agent card,不从注册中心 fetch。** +**工作台执行工作流时,从 classpath 的 agentcard JSON 加载 agent card,不从注册中心 fetch。** 原因:注册中心可能残留 之前注册的旧 card(端口 8904、URL 不带前缀等),与 Java 版的 agent 不匹配。注册返回 409 duplicate 时注册中心不会更新,导致引擎拿到错误的 URL。 @@ -97,18 +105,22 @@ SendMessageResult result = client.sendMessage("Transport Workbench Agent", taskT ## 七、SSL 主机名验证 -`jdk.internal.httpclient.disableHostnameVerification` 是 JVM 级静态缓存属性,必须在任何 HttpClient 创建前设置: +`jdk.internal.httpclient.disableHostnameVerification` 是 JVM 级、JDK 内部且可能被静态缓存的开关。SDK +不会修改它;只有使用仓库公开 demo 证书的独立样例进程才在入口类、创建任何 `HttpClient` 之前设置: ```java -// StartAgentsServer 入口类的 static 块 +// demo 入口类(SpringSpnDemo 等)的 main 方法开头 static { System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); } ``` +该设置会影响同一 JVM 的其他 JDK `HttpClient`,不能用于共享进程或生产环境。生产应使用带正确 SAN +的服务端证书并保持 `sslVerify=true`;需要私有 CA 时配置 `caCertsPath`。 + ## 八、SOLID 原则 -- **SRP**:`WorkbenchControlPoint` 只管工作流决策(路由、授权、通知),`TransportWorkbenchAgentExecutor` 只管消息 I/O +- **SRP**:`WorkbenchControlPoint` 只管工作流决策(路由、授权、通知),`SpringWorkbenchExecutor` 只管消息 I/O - **DRY**:`BaseAgentExecutor` 提供共享的 `extractText` / `buildStatusMessage`,3 个 Agent executor 都继承它 - **无 Unicode 转义**:Java 源文件中的中文字符串直接用字面中文,不用 `\uXXXX` - 用 `apply_patch` 写文件(保持 UTF-8),不用 PowerShell here-string(会损坏中文) @@ -125,7 +137,7 @@ static { ## 十、验证清单 -- `mvn compile -pl workflow-engine,samples` 编译通过 -- `mvn test -pl workflow-engine,samples` 71 个测试通过(引擎 69 + samples 2) +- `mvn verify` 在无供应商私有依赖的公共环境中通过 - `EmbeddedA2AServerTest` 验证:启动 server → sendMessage → 从 SSE 流提取诊断结果 -- `SpnCrossCityDiagnosisDemo` 端到端:3 个 Agent 启动 → 触发工作流 → 5 步全部执行成功 +- `SpnCrossCityE2ETest` 端到端:2 个 OMC Agent 启动 → 前置预定位 → 3 步工作流全部成功 +- `SpringSpnDemo` 端到端:Spring 工作台 + 2 个 OMC Agent → Task-T → 跨城诊断工作流执行成功 diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java index 1a106d96..a41f6c89 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java @@ -23,7 +23,6 @@ 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 dev.openan.workflow.engine.examples.agents.TransportWorkbenchAgentExecutor; import dev.openan.workflow.engine.examples.workbench.WorkbenchOrchestrator; import org.a2aproject.sdk.server.agentexecution.RequestContext; @@ -40,15 +39,12 @@ import java.util.Map; import java.util.concurrent.TimeUnit; -import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; /** * Spring-managed Workbench AgentExecutor. * - *

Same business logic as {@link - * TransportWorkbenchAgentExecutor} -- receives a Task-T from - * the upper layer, delegates to {@link WorkbenchOrchestrator} for the full pipeline, and returns - * the result. The difference is purely the server container: this runs inside Spring Boot instead - * of the JDK HttpServer-based {@code JdkHttpA2AServer}. + *

Receives a Task-T from the upper layer, delegates to {@link WorkbenchOrchestrator} for the + * full pipeline, and returns the result. The A2A server container is provided by the Spring Boot + * starter's auto-configuration. */ @Component public class SpringWorkbenchExecutor extends BaseAgentExecutor { diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/TransportWorkbenchAgentExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/TransportWorkbenchAgentExecutor.java deleted file mode 100644 index 3f12672a..00000000 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/TransportWorkbenchAgentExecutor.java +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.examples.agents; - -import org.a2aproject.sdk.server.agentexecution.RequestContext; -import org.a2aproject.sdk.server.tasks.AgentEmitter; -import org.a2aproject.sdk.spec.A2AError; -import org.a2aproject.sdk.spec.Part; -import org.a2aproject.sdk.spec.TextPart; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; -import dev.openan.workflow.engine.examples.workbench.WorkbenchExtensionLifecycle; -import dev.openan.workflow.engine.examples.workbench.WorkbenchOrchestrator; -/** - * Transport Workbench Agent - the orchestrator. - * - *

Single responsibility: agent server I/O. Receives a Task-T from the upper layer, - * delegates the full orchestration pipeline to {@link WorkbenchOrchestrator}, and returns the - * result. This class does NOT contain workflow logic, pre-positioning, or negotiation strategy -- - * those live in dedicated classes. - */ -public class TransportWorkbenchAgentExecutor extends BaseAgentExecutor implements AutoCloseable { - private static final Logger log = - LoggerFactory.getLogger(TransportWorkbenchAgentExecutor.class); - - private final String orchUrl; - private final String credentialsPath; - private final boolean sslVerify; - private final String a2atEnvPath; - private final WorkbenchExtensionLifecycle extensionLifecycle; - - public TransportWorkbenchAgentExecutor( - String registryUrl, String orchUrl, String credentialsPath, boolean sslVerify) { - this(registryUrl, orchUrl, credentialsPath, sslVerify, null); - } - - public TransportWorkbenchAgentExecutor( - String registryUrl, - String orchUrl, - String credentialsPath, - boolean sslVerify, - String a2atEnvPath) { - // registryUrl is retained for source compatibility; orchUrl is the unified registry API. - this.orchUrl = orchUrl; - this.credentialsPath = credentialsPath; - this.sslVerify = sslVerify; - this.a2atEnvPath = a2atEnvPath; - this.extensionLifecycle = - new WorkbenchExtensionLifecycle( - credentialsPath, - sslVerify, - a2atEnvPath, - () -> null, - data -> - log.info( - "[Notification] EVENT scope=workbench, agent={}, state={}, textChars={}", - data.get("agent"), - data.get("state"), - data.get("text") != null - ? String.valueOf(data.get("text")).length() - : 0)); - } - - /** Establish persistent workbench-level pre-positioning after all agent servers are ready. */ - public void startExtensions() { - extensionLifecycle.start(); - } - - @Override - public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - String input = extractText(ctx.getMessage()); - log.info("[Workbench] Received task: taskId={}, text={} chars", taskId, input.length()); - emitter.submit(buildStatusMessage(contextId, taskId, "Task received")); - emitter.startWork(buildStatusMessage(contextId, taskId, "Processing")); - try { - startExtensions(); - String result = - new WorkbenchOrchestrator(orchUrl, credentialsPath, sslVerify, a2atEnvPath) - .run(input); - Map metadata = new LinkedHashMap<>(); - metadata.put(NegotiationUtils.TASK_PROMPT_KEY, result); - List> parts = List.of(new TextPart("跨城故障协同诊断汇总结果")); - emitter.addArtifact( - parts, "result", "cross-city-diagnosis-summary", metadata, false, true); - emitter.complete(buildStatusMessage(contextId, taskId, "Completed")); - log.info("[Workbench] Task completed"); - } catch (Exception e) { - log.error("[Workbench] Failed: {}", e.getMessage(), e); - emitter.fail(buildStatusMessage(contextId, taskId, "Failed: " + e.getMessage())); - } - } - - @Override - public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { - emitter.cancel(); - } - - @Override - public void close() { - extensionLifecycle.close(); - } -} 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 4fd0e7b0..10f4762e 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 @@ -9,23 +9,19 @@ import dev.openan.workflow.engine.client.DefaultExtensionSender; import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; -import dev.openan.workflow.engine.client.AgentCardJacksonModule; -import dev.openan.workflow.engine.model.SendMessageResult; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; -import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; +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.a2aproject.sdk.spec.AgentCard; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.File; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; -import com.fasterxml.jackson.databind.ObjectMapper; /** * Reproduces the 11 protocol verification cases from the A2A-T spec extraction document. @@ -33,18 +29,13 @@ */ public class A2ATProtocolCases { private static final Logger log = LoggerFactory.getLogger(A2ATProtocolCases.class); - private static final ObjectMapper mapper = new ObjectMapper().registerModule(new AgentCardJacksonModule()); - - private static final String TASK_T_URI = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; - 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 JdkHttpA2AServer omcServer; + private final OmcAgentLauncher omc = new OmcAgentLauncher(); private A2ATransport transport; private DefaultWorkflowEngineClient client; private DefaultExtensionSender sender; @@ -89,19 +80,20 @@ public void run(String caseId) throws Exception { } private void setup() throws Exception { - String path = getClass().getClassLoader().getResource("agentcard/spn_domain_agent_city1.json").getPath(); - @SuppressWarnings("unchecked") - Map cardMap = mapper.readValue(new File(path), Map.class); - omcServer = new JdkHttpA2AServer("127.0.0.1", 26335, cardMap, new SpnDomainAgentCity1Executor()); - omcServer.start(); + 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"); + log.info("[ProtocolCases] OMC agent {} started on https://127.0.0.1:26335", AGENT_NAME); - AgentCard agentCard = mapper.readValue(new File(path), AgentCard.class); - String credPath = getClass().getClassLoader().getResource("spn_agent_credentials.json").getPath(); - String envPath = EnvResolver.resolveEnvPath(); - transport = new A2ATransport(List.of(agentCard), null, - WorkflowEngineClientConfig.builder().sslVerify(false).a2atEnvPath(envPath).credentialsConfigPath(credPath).build()); + transport = new A2ATransport( + List.of(OmcAgentLauncher.cardFromResource("agentcard/spn_domain_agent_city1.json")), + null, + WorkflowEngineClientConfig.builder() + .sslVerify(false) + .a2atEnvPath(EnvResolver.resolveEnvPath()) + .credentialsConfigPath( + OmcAgentLauncher.resourcePath("spn_agent_credentials.json")) + .build()); client = new DefaultWorkflowEngineClient(transport); sender = new DefaultExtensionSender(transport); } @@ -109,7 +101,7 @@ private void setup() throws Exception { private void teardown() { if (client != null) client.close(); if (transport != null) transport.close(); - if (omcServer != null) { try { omcServer.close(); } catch (Exception e) { log.warn("OMC close: {}", e.getMessage()); } } + omc.close(); } /** Case 0: enumerate available A2A-T templates via the engine's template-query surface. */ @@ -132,9 +124,10 @@ private void case_0() { private void case_7_1() throws Exception { log.info("[Case 7.1] Create diagnosis task (Task-T)"); - Map metadata = new LinkedHashMap<>(); - metadata.put(TASK_T_URI, "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n## 任务描述(Task Description)\n基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n## 任务对象(Task Object)\n接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17\n\n## 任务上下文(Task Context)\n1. 投诉分类:\"专线质差\"\n2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n3. OSS侧事件流水号:\"event-id-20260511-09013\"\n4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢。\"\n\n## 预期输出(Expected Output)\n1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n2. 诊断结果详情;(必选)\n3. 修复建议;(可选)\n4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"); - SendMessageResult result = client.sendMessage(AGENT_NAME, "创建专线业务投诉诊断任务", null, metadata).join(); + Map metadata = + SpnCasePrompts.taskTMetadata(SpnCasePrompts.privateLineComplaintTask()); + SendMessageResult result = + client.sendMessage(AGENT_NAME, SpnCasePrompts.TASK_TEXT, null, metadata).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()); } } @@ -149,19 +142,19 @@ private void case_7_2() throws Exception { private void case_7_3() { log.info("[Case 7.3] Negotiation - param missing (Task-T + Negotiation-T)"); - Map metadata = new LinkedHashMap<>(); - metadata.put(TASK_T_URI, "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n## 任务描述(Task Description)\n基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n## 任务对象(Task Object)\n接入端口名称:\n\n## 任务上下文(Task Context)\n1. 投诉分类:\n2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n3. OSS侧事件流水号:\"event-id-20260511-09013\"\n4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢。\"\n\n## 预期输出(Expected Output)\n1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n2. 诊断结果详情;(必选)\n3. 修复建议;(可选)\n4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"); + Map metadata = + SpnCasePrompts.taskTMetadata(SpnCasePrompts.privateLineComplaintTaskBlankObject()); metadata.put(NEGOTIATION_T_URI, ""); // activate Negotiation-T extension - SendMessageResult result = client.sendMessage(AGENT_NAME, "创建专线业务投诉诊断任务(参数缺失)", null, metadata).join(); + SendMessageResult result = client.sendMessage(AGENT_NAME, SpnCasePrompts.TASK_TEXT + "(参数缺失)", null, metadata).join(); log.info("[Case 7.3] state={}, metaKeys={}", result.getTaskState(), result.getMetadata() != null ? result.getMetadata().keySet() : "none"); } private void case_7_4() { log.info("[Case 7.4] Negotiation - semantic error (Task-T + Negotiation-T)"); - Map metadata = new LinkedHashMap<>(); - metadata.put(TASK_T_URI, "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n## 任务描述(Task Description)\n基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n## 任务对象(Task Object)\n接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-18\n\n## 任务上下文(Task Context)\n1. 投诉分类:\"专线质差\"\n2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n3. OSS侧事件流水号:\"event-id-20260511-09013\"\n4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms,访问广州机房的核心交易系统非常慢。\"\n\n## 预期输出(Expected Output)\n1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n2. 诊断结果详情;(必选)\n3. 修复建议;(可选)\n4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"); + Map metadata = + SpnCasePrompts.taskTMetadata(SpnCasePrompts.privateLineComplaintTaskUnknownPort()); metadata.put(NEGOTIATION_T_URI, ""); // activate Negotiation-T extension - SendMessageResult result = client.sendMessage(AGENT_NAME, "创建专线业务投诉诊断任务(语义错误)", null, metadata).join(); + SendMessageResult result = client.sendMessage(AGENT_NAME, SpnCasePrompts.TASK_TEXT + "(语义错误)", null, metadata).join(); log.info("[Case 7.4] state={}, metaKeys={}", result.getTaskState(), result.getMetadata() != null ? result.getMetadata().keySet() : "none"); } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpnCasePrompts.java b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpnCasePrompts.java new file mode 100644 index 00000000..3df81754 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpnCasePrompts.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.demo; + +import dev.openan.workflow.engine.client.A2ATExtension; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Structured prompt fixtures for the SPN private-line complaint scenario (spec §6 metadata-key + * conventions). Shared by the demo entry points and the protocol verification cases so the + * Task-T payload is defined exactly once. + */ +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() {} + + /** + * Task-T structured prompt for the private-line complaint diagnosis (spec case 7.1): a + * well-formed complaint with a known faulty port in City1. + */ + public static String privateLineComplaintTask() { + return taskT( + "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "\"专线质差\"", + "\"event-id-20260511-09013\""); + } + + /** + * Task-T prompt variant with a blank task object and complaint category (spec case 7.3): + * triggers server-side blank-slot negotiation. + */ + public static String privateLineComplaintTaskBlankObject() { + return taskT("", "", "\"event-id-20260511-09013\""); + } + + /** + * Task-T prompt variant referencing an unknown port (spec case 7.4): triggers the + * semantic-error negotiation path. + */ + public static String privateLineComplaintTaskUnknownPort() { + return taskT( + "P781-珠江新城-PTN7900-23-TPA1EG24-18", + "\"专线质差\"", + "\"event-id-20260511-09013\""); + } + + /** 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); + return metadata; + } + + private static String taskT(String portName, String complaintCategory, String ossEventId) { + return "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" + + "## 任务描述(Task Description)\n" + + "基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, " + + "达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n" + + "## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n" + + "## 任务对象(Task Object)\n" + + "接入端口名称:" + + portName + + "\n\n" + + "## 任务上下文(Task Context)\n" + + "1. 投诉分类:" + + complaintCategory + + "\n" + + "2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n" + + "3. OSS侧事件流水号:" + + ossEventId + + "\n" + + "4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," + + "访问广州机房的核心交易系统非常慢。\"\n\n" + + "## 预期输出(Expected Output)\n" + + "1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n" + + "2. 诊断结果详情;(必选)\n" + + "3. 修复建议;(可选)\n" + + "4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"; + } +} 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 6e291beb..97bc494d 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,25 +20,23 @@ package dev.openan.workflow.engine.examples.demo; import com.fasterxml.jackson.databind.ObjectMapper; + import dev.openan.workflow.engine.client.A2ATransport; import dev.openan.workflow.engine.client.AgentCardJacksonModule; import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; -import dev.openan.workflow.engine.examples.util.EnvResolver; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity2Executor; -import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; +import dev.openan.workflow.engine.examples.server.OmcAgentLauncher; +import dev.openan.workflow.engine.examples.util.EnvResolver; import dev.openan.workflow.engine.model.SendMessageResult; -import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.spec.AgentCard; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; import org.springframework.context.ConfigurableApplicationContext; -import java.io.File; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -68,7 +66,7 @@ public class SpringSpnDemo { private static final String WB_AGENT_NAME = "Transport Workbench Agent"; private static final long STARTUP_WAIT = 3; - private final List omcServers = new ArrayList<>(); + private final OmcAgentLauncher omc = new OmcAgentLauncher(); public static void main(String[] args) throws Exception { System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); @@ -87,12 +85,15 @@ public void run(String... applicationArgs) throws Exception { long stageStarted; stageStarted = System.nanoTime(); log.info("[Demo] STAGE_START stage=start-omc-agents"); - startOmcAgents(); + 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={}", - omcServers.size(), + omc.servers().size(), elapsedMillis(stageStarted)); stageStarted = System.nanoTime(); @@ -106,8 +107,8 @@ public void run(String... applicationArgs) throws Exception { log.info( "[Demo] STAGE_START stage=send-workbench-task, target={}, inputChars={}", WB_AGENT_NAME, - 14); - log.debug("[Demo] Workbench task text={}", "创建专线业务投诉诊断任务"); + SpnCasePrompts.TASK_TEXT.length()); + log.debug("[Demo] Workbench task text={}", SpnCasePrompts.TASK_TEXT); String response = sendTaskToWorkbench(); log.info( "[Demo] STAGE_DONE stage=send-workbench-task, responseChars={}, elapsedMs={}", @@ -130,7 +131,7 @@ public void run(String... applicationArgs) throws Exception { } finally { log.info( "[Demo] SHUTDOWN_START omcCount={}, springStarted={}", - omcServers.size(), + omc.servers().size(), ctx != null); if (ctx != null) { try { @@ -139,7 +140,7 @@ public void run(String... applicationArgs) throws Exception { log.warn("[Demo] Failed to close Spring context: {}", e.getMessage(), e); } } - shutdownOmcAgents(); + omc.close(); log.info( "[Demo] SHUTDOWN_DONE success={}, elapsedMs={}", success, @@ -147,43 +148,12 @@ public void run(String... applicationArgs) throws Exception { } } - private void startOmcAgents() throws Exception { - startOmcAgent("agentcard/spn_domain_agent_city1.json", new SpnDomainAgentCity1Executor()); - startOmcAgent("agentcard/spn_domain_agent_city2.json", new SpnDomainAgentCity2Executor()); - } - - @SuppressWarnings("unchecked") - private void startOmcAgent(String resourcePath, AgentExecutor executor) throws Exception { - long started = System.nanoTime(); - String path = getClass().getClassLoader().getResource(resourcePath).getPath(); - Map card = mapper.readValue(new File(path), Map.class); - List> ifaces = - (List>) card.getOrDefault("supportedInterfaces", List.of()); - String url = - ifaces.isEmpty() ? "https://127.0.0.1:0" : String.valueOf(ifaces.get(0).get("url")); - java.net.URI uri = java.net.URI.create(url); - String host = uri.getHost() != null ? uri.getHost() : "127.0.0.1"; - int port = Math.max(uri.getPort(), 0); - JdkHttpA2AServer server = new JdkHttpA2AServer(host, port, card, executor); - server.start(); - omcServers.add(server); - log.info( - "[Demo] OMC_STARTED agent={}, resource={}, endpoint={}, elapsedMs={}", - card.get("name"), resourcePath, url, elapsedMillis(started)); - } - /** Northbound Task-T message to the Workbench Agent, mirroring spec case 7.1. */ private String sendTaskToWorkbench() throws Exception { long started = System.nanoTime(); - String cardPath = - getClass() - .getClassLoader() - .getResource("agentcard/transport_workbench_agent.json") - .getPath(); - AgentCard wbCard = mapper.readValue(new File(cardPath), AgentCard.class); - - String credPath = - getClass().getClassLoader().getResource("spn_agent_credentials.json").getPath(); + AgentCard wbCard = + OmcAgentLauncher.cardFromResource("agentcard/transport_workbench_agent.json"); + String credPath = OmcAgentLauncher.resourcePath("spn_agent_credentials.json"); String envPath = EnvResolver.resolveEnvPath(); A2ATransport transport = @@ -198,30 +168,8 @@ private String sendTaskToWorkbench() throws Exception { DefaultWorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); try { // Task-T structured prompt placed in message.metadata (spec §6). - String taskPrompt = - "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" - + "## 任务描述(Task Description)\n" - + "基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, " - + "达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n" - + "## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n" - + "## 任务对象(Task Object)\n" - + "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17\n\n" - + "## 任务上下文(Task Context)\n" - + "1. 投诉分类:\"专线质差\"\n" - + "2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n" - + "3. OSS侧事件流水号:\"event-id-20260511-09013\"\n" - + "4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," - + "访问广州机房的核心交易系统非常慢。\"\n\n" - + "## 预期输出(Expected Output)\n" - + "1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n" - + "2. 诊断结果详情;(必选)\n" - + "3. 修复建议;(可选)\n" - + "4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"; - String taskTUri = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; - java.util.Map metadata = new java.util.LinkedHashMap<>(); - metadata.put(taskTUri, taskPrompt); - String partsText = "创建专线业务投诉诊断任务"; + Map metadata = + SpnCasePrompts.taskTMetadata(SpnCasePrompts.privateLineComplaintTask()); log.info( "[Demo] NORTHBOUND_SEND target={}, contextId={}, endpoint={}, inputChars={}", WB_AGENT_NAME, @@ -229,9 +177,11 @@ private String sendTaskToWorkbench() throws Exception { wbCard.supportedInterfaces().isEmpty() ? "?" : wbCard.supportedInterfaces().get(0).url(), - partsText.length()); + SpnCasePrompts.TASK_TEXT.length()); SendMessageResult result = - client.sendMessage(WB_AGENT_NAME, partsText, null, metadata).join(); + client + .sendMessage(WB_AGENT_NAME, SpnCasePrompts.TASK_TEXT, null, metadata) + .join(); log.info( "[Demo] NORTHBOUND_DONE target={}, contextId={}, state={}, responseChars={}, elapsedMs={}", WB_AGENT_NAME, @@ -246,20 +196,6 @@ private String sendTaskToWorkbench() throws Exception { } } - private void shutdownOmcAgents() { - log.info("[Demo] OMC_SHUTDOWN_START count={}", omcServers.size()); - omcServers.forEach( - s -> { - try { - s.close(); - } catch (Exception e) { - log.warn("[Demo] Failed to close OMC server: {}", e.getMessage(), e); - } - }); - omcServers.clear(); - log.info("[Demo] OMC_SHUTDOWN_DONE"); - } - /** Backward-compatible programmatic entry point. */ public void run() throws Exception { run(new String[0]); diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/server/OmcAgentLauncher.java b/samples/src/main/java/dev/openan/workflow/engine/examples/server/OmcAgentLauncher.java new file mode 100644 index 00000000..ea0a8535 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/server/OmcAgentLauncher.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.server; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import dev.openan.workflow.engine.client.AgentCardJacksonModule; + +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +import org.a2aproject.sdk.spec.AgentCard; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Starts and tracks {@link JdkHttpA2AServer} instances from agentcard resources. Centralizes the + * load-card → resolve-bind-address → start boilerplate shared by the demos. + */ +public final class OmcAgentLauncher implements AutoCloseable { + + private static final ObjectMapper MAPPER = + new ObjectMapper().registerModule(new AgentCardJacksonModule()); + + private final List servers = new ArrayList<>(); + + /** Load an agentcard resource, bind to the address declared in its first interface, and start. */ + public JdkHttpA2AServer startFromResource(String resourcePath, AgentExecutor executor) + throws Exception { + String path = OmcAgentLauncher.class.getClassLoader().getResource(resourcePath).getPath(); + @SuppressWarnings("unchecked") + Map card = MAPPER.readValue(new File(path), Map.class); + return startFromCard(card, executor); + } + + /** Bind to the address declared in the card's first supportedInterface and start. */ + public JdkHttpA2AServer startFromCard(Map card, AgentExecutor executor) + throws Exception { + @SuppressWarnings("unchecked") + List> ifaces = + (List>) card.getOrDefault("supportedInterfaces", List.of()); + String url = + ifaces.isEmpty() ? "https://127.0.0.1:0" : String.valueOf(ifaces.get(0).get("url")); + java.net.URI uri = java.net.URI.create(url); + String host = uri.getHost() != null ? uri.getHost() : "127.0.0.1"; + int port = Math.max(uri.getPort(), 0); + JdkHttpA2AServer server = new JdkHttpA2AServer(host, port, card, executor); + server.start(); + servers.add(server); + return server; + } + + /** The {@link AgentCard} view of an agentcard resource, for building engine clients. */ + public static AgentCard cardFromResource(String resourcePath) throws Exception { + String path = OmcAgentLauncher.class.getClassLoader().getResource(resourcePath).getPath(); + return MAPPER.readValue(new File(path), AgentCard.class); + } + + /** Path of a classpath resource as an absolute file path (demo credentials lookup). */ + public static String resourcePath(String resourcePath) { + return OmcAgentLauncher.class.getClassLoader().getResource(resourcePath).getPath(); + } + + public List servers() { + return servers; + } + + @Override + public void close() { + for (JdkHttpA2AServer server : servers) { + try { + server.close(); + } catch (Exception ignored) { + // shutdown is best-effort + } + } + servers.clear(); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java index 7b1d9f7d..f1038813 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 @@ -41,7 +41,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -import dev.openan.workflow.engine.examples.agents.TransportWorkbenchAgentExecutor; import dev.openan.workflow.engine.examples.negotiation.NegotiationStrategy; /** * Workflow orchestration for the SPN cross-city diagnosis. @@ -55,8 +54,8 @@ *

  • {@link NegotiationStrategy} -- negotiation clarification (injected into the control point) * * - *

    This class does NOT handle agent server I/O (that stays in {@link - * TransportWorkbenchAgentExecutor}) or the details of any single A2A-T extension protocol. + *

    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 { 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 321a2110..7e7a50bc 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 @@ -21,7 +21,7 @@ import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity2Executor; 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.ExecutionResult; import dev.openan.workflow.engine.model.JumpCondition; import dev.openan.workflow.engine.model.StepType; @@ -35,7 +35,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -55,7 +54,7 @@ class SpnCrossCityE2ETest { private static final ObjectMapper mapper = new ObjectMapper().registerModule(new AgentCardJacksonModule()); - private final List servers = new ArrayList<>(); + private final OmcAgentLauncher omc = new OmcAgentLauncher(); private DefaultWorkflowEngineClient client; private ExtensionSender sender; private int port1; @@ -112,22 +111,8 @@ void setUp() throws Exception { port2 = port1 + 1; AgentCard c1 = cardFor("SPN Domain Agent City1", port1); AgentCard c2 = cardFor("SPN Domain Agent City2", port2); - JdkHttpA2AServer s1 = - new JdkHttpA2AServer( - "127.0.0.1", - port1, - mapper.convertValue(c1, Map.class), - new SpnDomainAgentCity1Executor()); - JdkHttpA2AServer s2 = - new JdkHttpA2AServer( - "127.0.0.1", - port2, - mapper.convertValue(c2, Map.class), - new SpnDomainAgentCity2Executor()); - s1.start(); - s2.start(); - servers.add(s1); - servers.add(s2); + omc.startFromCard(mapper.convertValue(c1, Map.class), new SpnDomainAgentCity1Executor()); + omc.startFromCard(mapper.convertValue(c2, Map.class), new SpnDomainAgentCity2Executor()); Thread.sleep(600); A2ATransport transport = new A2ATransport( @@ -141,13 +126,7 @@ void setUp() throws Exception { @AfterEach void tearDown() { if (client != null) client.close(); - servers.forEach( - s -> { - try { - s.close(); - } catch (Exception ignored) { - } - }); + omc.close(); } private Workflow crossCityWorkflow() { 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 381c5b2a..f90eb0a9 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 @@ -28,6 +28,7 @@ import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; +import dev.openan.workflow.engine.examples.server.OmcAgentLauncher; import dev.openan.workflow.engine.model.SendMessageResult; import org.a2aproject.sdk.spec.AgentCard; @@ -143,8 +144,8 @@ void setUp() throws Exception { "https://127.0.0.1:" + port, "tenant", ""))); - server = new JdkHttpA2AServer("127.0.0.1", port, card, new SpnDomainAgentCity1Executor()); - server.start(); + server = + new OmcAgentLauncher().startFromCard(card, new SpnDomainAgentCity1Executor()); Thread.sleep(500); A2ATransport transport = new A2ATransport( From 0b2eea87c28e1607a98ca390250ac336bfbd28d8 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Sun, 23 Aug 2026 19:10:17 +0800 Subject: [PATCH 08/49] refactor(samples): move SpringWorkbenchExecutor to the workbench package The workbench executor lives with the orchestration it drives. This restores a clean layering: agents/ now contains only the OMC-side executors (zero cross-package dependencies), workbench/ is self-contained, and the agents -> workbench / agents -> gateway dependency edges disappear. Update the Javadoc reference in SpringWorkbenchApplication, the class table in the SpringSpnDemo call-chain doc (also fixes several stale pre-reorg paths) and the DESIGN-NOTES structure diagram. Verified: mvn test all green (23 samples tests); SpringSpnDemo direct mode runs end-to-end. Signed-off-by: Zhoujie628 --- .../workflow/engine/examples/SpringWorkbenchApplication.java | 2 +- .../{agents => workbench}/SpringWorkbenchExecutor.java | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) rename samples/src/main/java/dev/openan/workflow/engine/examples/{agents => workbench}/SpringWorkbenchExecutor.java (97%) 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 d1a534a3..4e3c3a68 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 @@ -35,7 +35,7 @@ * *

    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.agents.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}. diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExecutor.java similarity index 97% rename from samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java rename to samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExecutor.java index a41f6c89..9cbd9adb 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpringWorkbenchExecutor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExecutor.java @@ -17,13 +17,12 @@ * under the License. */ -package dev.openan.workflow.engine.examples.agents; +package dev.openan.workflow.engine.examples.workbench; import dev.openan.workflow.engine.client.A2ATExtension; import dev.openan.workflow.engine.examples.agents.BaseAgentExecutor; import dev.openan.workflow.engine.examples.config.WorkbenchClientProperties; import dev.openan.workflow.engine.examples.util.EnvResolver; -import dev.openan.workflow.engine.examples.workbench.WorkbenchOrchestrator; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; From 53ec6f0b9241f8d21586a655d9ac7daa7873bd49 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 24 Aug 2026 06:22:29 +0800 Subject: [PATCH 09/49] =?UTF-8?q?feat(engine):=20full=20A2A-T=20SDK=20inte?= =?UTF-8?q?gration=20=E2=80=94=20fromData=20dual=20track=20and=20the=20sta?= =?UTF-8?q?teful=20negotiation=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the gaps found in the SDK-usage audit; the engine now exercises the SDK's full surface per the official demo patterns instead of degrading to fallback paths. fromData dual track (E1): - WorkflowEngineClient.sendMessageFromData(agent, message, data, schema, templateUri): deterministic Task-T rendering via generateTaskPromptFromDataWithSchema (no scenario recognition, no LLM); travels through the a2at.taskData/taskSchema/taskTemplate metadata keys consumed by TaskTHandler - ExtensionSender.sendExtensionMessageFromData: same track for Authorization-T / Notification-T pre-positioning - A2ATContentFacade exposes generateTask/Auth/NotificationFromData Stateful negotiation loop (E2) — the auto-loop previously degraded on every round ('must be a string: negotiationType', 'No negotiation context', 'Unsupported negotiation type'): - agent propose now carries the startNegotiation payload's context in the negotiation_context metadata key (SDK demo convention) - NegotiationTHandler reads negotiation_context first, hands the raw context map to receiveNegotiation, and validates the full propose text (not the short parts placeholder) through validateProposePromptAndDataFilling - context synthesis covers both context shapes (raw map and receiveNegotiation result) for follow-up rendering - requires the SDK client-handler fix (a2a-t-sdk-java fix/client-negotiation-handlers) to fully run Follow-up dual track (E3): 'data:{json}' clarification prefix renders the Accept deterministically via generateAcceptFromData; reject:/abort: and plain-text (fromText) paths unchanged Demo passes original data (D1): SpnCasePrompts now holds the raw complaint fields + schema; SpringSpnDemo and A2ATProtocolCases send data through the fromData track, letting the SDK render the Task-T prompt (577 chars via Task-T/private-line-complaint/v1) instead of hardcoding the rendered prompt text. Verified: mvn verify all green (105+5+23 tests); SpringSpnDemo end-to-end in direct/mock/order modes with the full SDK negotiation chain logged (propose fromData -> receiveNegotiation -> validatePropose extracted params -> generateAcceptFromText); protocol case 7.1 completes. Signed-off-by: Zhoujie628 --- .../agents/NegotiationBaseAgentExecutor.java | 34 +++++- .../examples/demo/A2ATProtocolCases.java | 27 +++-- .../engine/examples/demo/SpnCasePrompts.java | 112 ++++++++++-------- .../engine/examples/demo/SpringSpnDemo.java | 22 ++-- .../negotiation/NegotiationUtils.java | 54 +++++++++ .../engine/client/A2ATContentFacade.java | 44 +++++++ .../workflow/engine/client/A2ATExtension.java | 25 ++++ .../engine/client/DefaultExtensionSender.java | 87 ++++++++++++++ .../client/DefaultWorkflowEngineClient.java | 104 +++++++++++++++- .../engine/client/ExtensionSender.java | 24 ++++ .../engine/client/NegotiationTHandler.java | 86 +++++++++++--- .../workflow/engine/client/TaskTHandler.java | 61 ++++++++++ .../engine/client/WorkflowEngineClient.java | 32 +++++ 13 files changed, 619 insertions(+), 93 deletions(-) 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 ceb8205b..41c063c3 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 @@ -398,7 +398,13 @@ private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, String String contextId = ctx.getContextId(); String negId = java.util.UUID.randomUUID().toString(); String negText = renderProposeText(negId, input); - Map metadata = NegotiationUtils.negotiationResponseMetadata(negText); + // Combine the content layer with the stateful SDK runtime per the official demo + // pattern: startNegotiation produces the transport context payload, whose context + // map travels in the propose metadata so the client-side engine can advance the + // state machine (receiveNegotiation/continueNegotiation) with a well-formed context. + Map startPayload = startStatefulNegotiation(negText); + Map metadata = + NegotiationUtils.negotiationResponseMetadata(negText, startPayload); Message proposeMessage = Message.builder() .messageId(java.util.UUID.randomUUID().toString()) @@ -410,8 +416,30 @@ private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, String .build(); emitter.requiresInput(proposeMessage); log.info( - "[{}] Requested negotiation via INPUT_REQUIRED status message", - getClass().getSimpleName()); + "[{}] Requested negotiation via INPUT_REQUIRED status message (context={})", + getClass().getSimpleName(), + startPayload != null ? "stateful" : "fallback"); + } + + /** + * Starts the SDK negotiation state machine for a propose: returns the {@code + * startNegotiation} transport payload when the server facade is available, else null (the + * propose then goes out without a context and the client falls back to direct extraction). + */ + private Map startStatefulNegotiation(String negText) { + A2ATServer server = a2atServer(); + if (server == null) { + return null; + } + try { + return server.startNegotiation(negotiationType(), negText, Map.of()); + } catch (Exception e) { + log.warn( + "[{}] startNegotiation failed ({}); propose goes without SDK context", + getClass().getSimpleName(), + e.getMessage()); + return null; + } } /** 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 10f4762e..58afc936 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 @@ -123,11 +123,16 @@ private void case_0() { } private void case_7_1() throws Exception { - log.info("[Case 7.1] Create diagnosis task (Task-T)"); - Map metadata = - SpnCasePrompts.taskTMetadata(SpnCasePrompts.privateLineComplaintTask()); + log.info("[Case 7.1] Create diagnosis task (Task-T, fromData track)"); SendMessageResult result = - client.sendMessage(AGENT_NAME, SpnCasePrompts.TASK_TEXT, null, metadata).join(); + client.sendMessageFromData( + AGENT_NAME, + SpnCasePrompts.TASK_TEXT, + SpnCasePrompts.privateLineComplaintData(), + SpnCasePrompts.privateLineComplaintSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates + .PRIVATE_LINE_COMPLAINT) + .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()); } } @@ -141,18 +146,20 @@ private void case_7_2() throws Exception { } private void case_7_3() { - log.info("[Case 7.3] Negotiation - param missing (Task-T + Negotiation-T)"); - Map metadata = - SpnCasePrompts.taskTMetadata(SpnCasePrompts.privateLineComplaintTaskBlankObject()); + log.info("[Case 7.3] Negotiation - param missing (Task-T fromData + Negotiation-T)"); + Map metadata = new LinkedHashMap<>(); + metadata.put(A2ATExtension.TASK_DATA_META_KEY, SpnCasePrompts.privateLineComplaintDataBlankObject()); + metadata.put(A2ATExtension.TASK_SCHEMA_META_KEY, SpnCasePrompts.privateLineComplaintSchema()); metadata.put(NEGOTIATION_T_URI, ""); // activate Negotiation-T extension SendMessageResult result = client.sendMessage(AGENT_NAME, SpnCasePrompts.TASK_TEXT + "(参数缺失)", null, metadata).join(); log.info("[Case 7.3] state={}, metaKeys={}", result.getTaskState(), result.getMetadata() != null ? result.getMetadata().keySet() : "none"); } private void case_7_4() { - log.info("[Case 7.4] Negotiation - semantic error (Task-T + Negotiation-T)"); - Map metadata = - SpnCasePrompts.taskTMetadata(SpnCasePrompts.privateLineComplaintTaskUnknownPort()); + log.info("[Case 7.4] Negotiation - semantic error (Task-T fromData + Negotiation-T)"); + Map metadata = new LinkedHashMap<>(); + metadata.put(A2ATExtension.TASK_DATA_META_KEY, SpnCasePrompts.privateLineComplaintDataUnknownPort()); + metadata.put(A2ATExtension.TASK_SCHEMA_META_KEY, SpnCasePrompts.privateLineComplaintSchema()); metadata.put(NEGOTIATION_T_URI, ""); // activate Negotiation-T extension SendMessageResult result = client.sendMessage(AGENT_NAME, SpnCasePrompts.TASK_TEXT + "(语义错误)", null, metadata).join(); log.info("[Case 7.4] state={}, metaKeys={}", result.getTaskState(), result.getMetadata() != null ? result.getMetadata().keySet() : "none"); 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 3df81754..b2d04780 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 @@ -25,9 +25,15 @@ import java.util.Map; /** - * Structured prompt fixtures for the SPN private-line complaint scenario (spec §6 metadata-key - * conventions). Shared by the demo entry points and the protocol verification cases so the - * Task-T payload is defined exactly once. + * 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 + * engine's fromData track ({@code WorkflowEngineClient.sendMessageFromData}) and the A2A-T SDK + * renders the Task-T prompt deterministically from the data + schema. This mirrors the official + * SDK sample pattern — callers hand over business data, never a pre-rendered prompt. + * + *

    The prompt-fixture variants (blank object / unknown port) for the protocol verification + * cases are expressed as data too: the same schema with different values. */ public final class SpnCasePrompts { @@ -40,33 +46,62 @@ public final class SpnCasePrompts { private SpnCasePrompts() {} /** - * Task-T structured prompt for the private-line complaint diagnosis (spec case 7.1): a - * well-formed complaint with a known faulty port in City1. + * 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 String privateLineComplaintTask() { - return taskT( - "P781-珠江新城-PTN7900-23-TPA1EG24-17", - "\"专线质差\"", - "\"event-id-20260511-09013\""); + public static Map privateLineComplaintSchema() { + Map object = new LinkedHashMap<>(); + object.put("type", "string"); + object.put("description", "专线名称/专线业务标识/接入端口名称,三选一标识专线业务对象"); + Map context = new LinkedHashMap<>(); + context.put("type", "string"); + context.put( + "description", + "专线业务的故障现象描述和诊断任务上下文:投诉分类(专线中断/专线质差)、问题发生时间、" + + "OSS侧事件流水号、投诉详情"); + Map properties = new LinkedHashMap<>(); + properties.put("任务对象", object); + properties.put("任务上下文", context); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + return schema; } - /** - * Task-T prompt variant with a blank task object and complaint category (spec case 7.3): - * triggers server-side blank-slot negotiation. - */ - public static String privateLineComplaintTaskBlankObject() { - return taskT("", "", "\"event-id-20260511-09013\""); + /** 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; } - /** - * Task-T prompt variant referencing an unknown port (spec case 7.4): triggers the - * semantic-error negotiation path. - */ - public static String privateLineComplaintTaskUnknownPort() { - return taskT( - "P781-珠江新城-PTN7900-23-TPA1EG24-18", - "\"专线质差\"", - "\"event-id-20260511-09013\""); + /** 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; + } + + /** 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; } /** Task-T metadata map carrying the given prompt under the standard extension key. */ @@ -75,31 +110,4 @@ public static Map taskTMetadata(String taskPrompt) { metadata.put(TASK_T_URI, taskPrompt); return metadata; } - - private static String taskT(String portName, String complaintCategory, String ossEventId) { - return "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" - + "## 任务描述(Task Description)\n" - + "基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, " - + "达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n" - + "## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n" - + "## 任务对象(Task Object)\n" - + "接入端口名称:" - + portName - + "\n\n" - + "## 任务上下文(Task Context)\n" - + "1. 投诉分类:" - + complaintCategory - + "\n" - + "2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n" - + "3. OSS侧事件流水号:" - + ossEventId - + "\n" - + "4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," - + "访问广州机房的核心交易系统非常慢。\"\n\n" - + "## 预期输出(Expected Output)\n" - + "1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n" - + "2. 诊断结果详情;(必选)\n" - + "3. 修复建议;(可选)\n" - + "4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"; - } } 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 97bc494d..1111d54d 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 @@ -148,7 +148,13 @@ public void run(String... applicationArgs) throws Exception { } } - /** Northbound Task-T message to the Workbench Agent, mirroring spec case 7.1. */ + /** + * 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 deterministically (no hand-written prompt text, no + * LLM call) — the same pattern as the official SDK sample. + */ private String sendTaskToWorkbench() throws Exception { long started = System.nanoTime(); AgentCard wbCard = @@ -167,11 +173,8 @@ private String sendTaskToWorkbench() throws Exception { .build()); DefaultWorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); try { - // Task-T structured prompt placed in message.metadata (spec §6). - Map metadata = - SpnCasePrompts.taskTMetadata(SpnCasePrompts.privateLineComplaintTask()); log.info( - "[Demo] NORTHBOUND_SEND target={}, contextId={}, endpoint={}, inputChars={}", + "[Demo] NORTHBOUND_SEND target={}, contextId={}, endpoint={}, inputChars={}, track=fromData", WB_AGENT_NAME, transport.getContextId(), wbCard.supportedInterfaces().isEmpty() @@ -179,8 +182,13 @@ private String sendTaskToWorkbench() throws Exception { : wbCard.supportedInterfaces().get(0).url(), SpnCasePrompts.TASK_TEXT.length()); SendMessageResult result = - client - .sendMessage(WB_AGENT_NAME, SpnCasePrompts.TASK_TEXT, null, metadata) + client.sendMessageFromData( + WB_AGENT_NAME, + SpnCasePrompts.TASK_TEXT, + SpnCasePrompts.privateLineComplaintData(), + SpnCasePrompts.privateLineComplaintSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates + .PRIVATE_LINE_COMPLAINT) .join(); log.info( "[Demo] NORTHBOUND_DONE target={}, contextId={}, state={}, responseChars={}, elapsedMs={}", 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 a65c6a4c..4ef0fbbd 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 @@ -65,4 +65,58 @@ public static Map negotiationResponseMetadata(String negotiation } return metadata; } + + /** + * Metadata key carrying the JSON-serialised negotiation context map, matching the SDK demo + * convention ({@code NegotiationPayloadMapper.contextPayload} shape: {@code negotiationType} + * / {@code negotiationId} / {@code round} / {@code status}, all string or number values). + * + *

    Propose replies must carry this key so the client-side engine can hand a well-formed + * context to {@code receiveNegotiation} / {@code continueNegotiation}; without it the SDK + * state machine rejects the payload ("Negotiation context field must be a string"). + */ + public static final String NEGOTIATION_CONTEXT_KEY = "negotiation_context"; + + /** + * Builds Negotiation-T propose metadata carrying the stateful context payload: the rendered + * propose text under the extension URI plus the {@code negotiation_context} key holding the + * SDK {@code startNegotiation} payload's context map. + * + * @param negotiationText rendered propose text + * @param startPayload the {@code startNegotiation} return payload (context extracted via its + * Negotiation-T data key); may be null for the fallback path (context omitted) + */ + public static Map negotiationResponseMetadata( + String negotiationText, Map startPayload) { + Map metadata = negotiationResponseMetadata(negotiationText); + Map contextMap = extractStartContext(startPayload); + if (contextMap != null && !contextMap.isEmpty()) { + metadata.put(NEGOTIATION_CONTEXT_KEY, contextMap); + } + return metadata; + } + + /** Extracts the context map from a startNegotiation payload; null when absent. */ + private static Map extractStartContext(Map startPayload) { + if (startPayload == null) { + return null; + } + // startNegotiation payload shape: { Negotiation-T URI: {message, negotiationType, + // negotiationId, round, status, extra}, facts? } — the context fields sit in the + // Negotiation-T entry itself, minus the message key. + for (var entry : startPayload.entrySet()) { + String key = entry.getKey(); + if (key.contains("Negotiation-T") + && !key.contains("DATA-NEGOTIATION-T") + && entry.getValue() instanceof Map data) { + @SuppressWarnings("unchecked") + Map typed = new LinkedHashMap<>((Map) data); + typed.remove("message"); + if (typed.containsKey("negotiationType")) { + return typed; + } + } + } + return null; + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java index 2b0124bc..70525c1b 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java @@ -69,6 +69,50 @@ public A2ATClient client() { // Message generation: propose phase (from typed data, no LLM) // ------------------------------------------------------------------ + /** + * Renders a Task-T prompt from structured data and a schema. Deterministic slot filling, no + * scenario recognition, no LLM call. + * + * @param data structured task input (string-to-object map) + * @param schema schema describing the meaning of each data field; must not be empty + * @param templateUri task template, e.g. {@link StandardTemplates#PRIVATE_LINE_COMPLAINT} + * @return rendered prompt with template URI and Task-T extension URI + */ + public MetadataContent generateTaskFromData( + Map data, Map schema, TemplateUri templateUri) { + return client.generateTaskPromptFromDataWithSchema(data, schema, templateUri); + } + + /** + * Renders an Authorization-T prompt from structured data and a schema. Deterministic, no LLM + * call. + * + * @param data structured authorization input + * @param schema schema describing the data fields; must not be empty + * @param templateUri authorization template, e.g. {@link + * StandardTemplates#AUTHORIZATION_POLICY_MANAGEMENT} + * @return rendered prompt with template URI and Authorization-T extension URI + */ + public MetadataContent generateAuthFromData( + Map data, Map schema, TemplateUri templateUri) { + return client.generateAuthPromptFromDataWithSchema(data, schema, templateUri); + } + + /** + * Renders a Notification-T prompt from structured data and a schema. Deterministic, no LLM + * call. + * + * @param data structured notification input + * @param schema schema describing the data fields; must not be empty + * @param templateUri notification template, e.g. {@link + * StandardTemplates#SUBSCRIBE_INCIDENT} + * @return rendered prompt with template URI and Notification-T extension URI + */ + public MetadataContent generateNotificationFromData( + Map data, Map schema, TemplateUri templateUri) { + return client.generateNotificationPromptFromDataWithSchema(data, schema, templateUri); + } + /** * Renders a propose-phase negotiation message from typed data. Deterministic, no LLM call. * 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 ae450297..12e6e185 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 @@ -73,6 +73,31 @@ public enum A2ATExtension { */ public static final String NEGOTIATION_PARAMS_META_KEY = "negotiation_params"; + /** + * Engine-internal metadata key carrying structured task input (a string-to-object map) for + * the SDK's {@code generateTaskPromptFromDataWithSchema} pipeline. When present in the + * caller-supplied metadata, {@code TaskTHandler} renders the Task-T prompt from the data + * deterministically instead of running scenario recognition on the message text. Consumed + * (removed) before the A2A message goes on the wire; the rendered prompt travels under the + * Task-T extension URI. + */ + public static final String TASK_DATA_META_KEY = "a2at.taskData"; + + /** + * Engine-internal metadata key carrying the JSON schema describing {@link + * #TASK_DATA_META_KEY} fields. Required when task data is present; the schema tells the + * SDK renderer what each field means. Engine-internal, never sent on the wire. + */ + public static final String TASK_SCHEMA_META_KEY = "a2at.taskSchema"; + + /** + * Engine-internal metadata key carrying the template URI string for the fromData rendering + * track (e.g. {@code "Task-T/private-line-complaint/v1"}). Optional; defaults to {@link + * net.openan.a2at.sdk.core.model.StandardTemplates#PRIVATE_LINE_COMPLAINT}. Engine-internal, + * never sent on the wire. + */ + public static final String TASK_TEMPLATE_META_KEY = "a2at.taskTemplate"; + private final String uri; A2ATExtension(String uri) { 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 87fa5810..8ec1678f 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 @@ -123,6 +123,93 @@ public CompletableFuture sendExtensionMessage( }); } + @Override + public CompletableFuture sendExtensionMessageFromData( + String agentName, + String instruction, + Map data, + Map schema, + A2ATExtension extension) { + if (extension != A2ATExtension.AUTHORIZATION_T + && extension != A2ATExtension.NOTIFICATION_T) { + return CompletableFuture.failedFuture( + new IllegalArgumentException( + "fromData rendering supports AUTHORIZATION_T and NOTIFICATION_T, got " + + extension)); + } + AgentCard agentCard = transport.getCard(agentName); + if (agentCard == null) { + log.error("[ExtensionSender] Agent not found: {}", agentName); + return CompletableFuture.failedFuture( + new RuntimeException("Agent not found: " + agentName)); + } + return CompletableFuture.supplyAsync(() -> generateExtensionPromptFromData(extension, data, schema)) + .thenCompose( + mc -> { + if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { + log.error( + "[ExtensionSender] fromData rendering unavailable for {} ({}); refusing to send raw data", + agentName, + extension.displayName()); + return CompletableFuture.failedFuture( + new RuntimeException( + "fromData rendering failed for " + extension)); + } + Map metadata = new HashMap<>(); + metadata.putAll(mc.buildMetadataContent()); + log.info( + "[ExtensionSender] sendExtensionMessageFromData to {}: extension={}, renderedChars={}", + agentName, + extension.displayName(), + mc.promptText().length()); + if (extension == A2ATExtension.NOTIFICATION_T) { + return transport.sendNotificationStream( + agentCard, + agentName, + instruction, + transport.getContextId(), + metadata, + null); + } + return transport + .send( + agentCard, + agentName, + instruction, + transport.getContextId(), + metadata, + null) + .whenComplete( + (ignored, error) -> + transport.closeConversation( + agentCard, transport.getContextId())); + }); + } + + /** Deterministic fromData rendering for Authorization-T / Notification-T. */ + private MetadataContent generateExtensionPromptFromData( + A2ATExtension extension, Map data, Map schema) { + net.openan.a2at.sdk.client.A2ATClient a2atClient = transport.getA2atClient(); + if (a2atClient == null) { + return null; + } + try { + return switch (extension) { + case AUTHORIZATION_T -> a2atClient.generateAuthPromptFromDataWithSchema( + data, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + case NOTIFICATION_T -> a2atClient.generateNotificationPromptFromDataWithSchema( + data, schema, StandardTemplates.SUBSCRIBE_INCIDENT); + default -> null; + }; + } catch (Exception e) { + log.warn( + "[ExtensionSender] SDK {} fromData rendering error: {}", + extension.displayName(), + e.getMessage()); + return null; + } + } + @Override public CompletableFuture sendNotification( String agentName, 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 34558e7f..676ba654 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 @@ -434,9 +434,14 @@ private CompletableFuture> buildNegotiationFollowUpMeta( } /** - * Renders the follow-up message matching the clarification's decision prefix: {@code - * reject:} / {@code abort:} select the terminal templates, anything else renders an Accept - * message. + * Renders the follow-up message matching the clarification's decision prefix: + * + *

      + *
    • {@code reject:...} / {@code abort:...} — Reject / Abort terminal templates + *
    • {@code data:{...}} — deterministic fromData Accept rendering (JSON body carries the + * filled parameter map; no LLM call) + *
    • anything else — Accept via fromText (one LLM extraction step) + *
    */ private MetadataContent renderFollowUpMessage( A2ATContentFacade content, @@ -446,6 +451,10 @@ private MetadataContent renderFollowUpMessage( net.openan.a2at.sdk.core.model.NegotiationContext contentCtx = extractContentContext(negMeta); String decision = clarification.strip().toLowerCase(java.util.Locale.ROOT); + if (decision.startsWith("data:")) { + // Deterministic fromData Accept: the JSON body carries the filled parameter map. + return renderAcceptFromData(content, agentName, contentCtx, clarification.substring("data:".length())); + } if (decision.startsWith("reject:")) { log.info("[Negotiation] SDK generateNegotiationRejectPrompt for '{}'", agentName); return content.generateRejectFromText( @@ -470,6 +479,48 @@ private MetadataContent renderFollowUpMessage( return mc; } + /** + * Deterministic fromData Accept rendering: parses the JSON payload into {@code NegotiationItem} + * name/value pairs and renders through the SDK's fromData pipeline (no LLM call). + */ + private MetadataContent renderAcceptFromData( + A2ATContentFacade content, + String agentName, + net.openan.a2at.sdk.core.model.NegotiationContext contentCtx, + String jsonBody) { + requireContext(contentCtx, agentName); + try { + @SuppressWarnings("unchecked") + Map params = + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(jsonBody.strip(), Map.class); + java.util.List items = + new java.util.ArrayList<>(); + for (var entry : params.entrySet()) { + items.add( + new net.openan.a2at.sdk.negotiation.content.NegotiationItem( + entry.getKey(), String.valueOf(entry.getValue()))); + } + MetadataContent mc = + content.generateAcceptFromData( + new net.openan.a2at.sdk.negotiation.content.NegotiationEndingData( + contentCtx, + new net.openan.a2at.sdk.negotiation.content + .InformationEndingContent( + net.openan.a2at.sdk.negotiation.content + .NegotiationConclusion.ACCEPT, + items)), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + log.info( + "[Negotiation] SDK generateNegotiationAcceptPromptFromData: templateUri={}, items={}", + mc.templateUri(), + params.keySet()); + return mc; + } catch (Exception e) { + throw new IllegalStateException("fromData Accept rendering failed: " + e.getMessage(), e); + } + } + /** * Builds the follow-up metadata from the rendered message and, when the state-machine context * is available, advances the SDK state machine so the follow-up carries the next-round (or @@ -527,8 +578,9 @@ private static NegotiationContext extractTypesContext(Map negMet /** * Extracts the SDK-carried negotiation context (id/round/maxRounds) from the received - * metadata's {@code negotiationContext} key, or null when absent. Falls back to synthesizing - * one from the state-machine context payload when only that is present. + * metadata's {@code negotiationContext} key, or null when absent. Falls back to the + * state-machine context payload ({@code negotiation_context} key, either the raw + * startNegotiation context map or the receiveNegotiation result) before giving up. */ private static net.openan.a2at.sdk.core.model.NegotiationContext extractContentContext( Map negMeta) { @@ -547,6 +599,34 @@ private static net.openan.a2at.sdk.core.model.NegotiationContext extractContentC } } } + // State-machine context: the negotiation_context key holds either the raw context map + // (negotiationType/negotiationId/round/status) or the receiveNegotiation result wrapping + // it under "context". + Object stateful = negMeta.get(A2ATExtension.NEGOTIATION_CONTEXT_META_KEY); + Map statefulMap = null; + if (stateful instanceof Map m) { + Object inner = m.get("context"); + statefulMap = + inner instanceof Map innerMap + ? castMap(innerMap) + : castMap(m); + } else { + // The propose metadata's negotiation_context key (engine-external convention shared + // with the samples; mirrors the SDK demo constants). + Object rawStateCtx = negMeta.get("negotiation_context"); + if (rawStateCtx instanceof Map m2) { + statefulMap = castMap(m2); + } + } + if (statefulMap != null) { + NegotiationContext typesCtx = parseTypesContext(statefulMap); + if (typesCtx != null) { + return new net.openan.a2at.sdk.core.model.NegotiationContext( + typesCtx.negotiationId(), + typesCtx.round(), + net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); + } + } NegotiationContext typesCtx = extractTypesContext(negMeta); if (typesCtx == null) { return null; @@ -557,6 +637,20 @@ private static net.openan.a2at.sdk.core.model.NegotiationContext extractContentC net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); } + @SuppressWarnings("unchecked") + private static Map castMap(Map map) { + return (Map) map; + } + + /** Parses a state-machine context map ({negotiationType, negotiationId, round, status}). */ + private static NegotiationContext parseTypesContext(Map contextMap) { + try { + return NegotiationPayloadMapper.contextFromMap(contextMap); + } catch (Exception e) { + return null; + } + } + private static net.openan.a2at.sdk.core.model.NegotiationContext requireContext( net.openan.a2at.sdk.core.model.NegotiationContext context, String agentName) { if (context == null) { diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java index c2c75653..ce381d71 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 @@ -59,6 +59,30 @@ default CompletableFuture sendAuthorization( agentName, instruction, naturalLanguageInput, A2ATExtension.AUTHORIZATION_T); } + /** + * Structured-data send: renders the extension prompt deterministically from typed data via + * the SDK's fromData pipeline (no LLM), then sends as a one-shot pre-positioning message. + * + *

    Callers holding structured business data (e.g. an authorization policy as fields, not + * prose) should prefer this over the natural-language variants. + * + * @param agentName target agent name + * @param instruction short instruction text (becomes message parts) + * @param data structured extension input (string-to-object map) + * @param schema JSON schema describing the meaning of each data field + * @param extension extension type; only AUTHORIZATION_T and NOTIFICATION_T support + * fromData rendering here + * @return future completing with the first response + */ + default CompletableFuture sendExtensionMessageFromData( + String agentName, + String instruction, + Map data, + Map schema, + A2ATExtension extension) { + throw new UnsupportedOperationException("sendExtensionMessageFromData not implemented"); + } + /** * Establish a Notification-T subscription. * diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java index e64552d5..0f7bd745 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java @@ -78,11 +78,27 @@ public Map negotiationParamSchema() { return paramSchema; } + /** + * Extracts the stateful negotiation context map from the reply metadata. + * + *

    Priority 1: the {@code negotiation_context} key — the SDK demo convention; the agent + * embeds the {@code startNegotiation} payload's context map (negotiationType / + * negotiationId / round / status) there, exactly what {@code receiveNegotiation} expects. + * + *

    Priority 2 (legacy): a {@code DATA-NEGOTIATION-T} metadata entry. When neither is + * present the caller falls back to passing the whole metadata map, which the SDK rejects + * with a context-parse error — handled by the direct-extraction fallback. + */ @SuppressWarnings("unchecked") private static Map extractNegotiationContext(Map metadata) { if (metadata == null) { return null; } + Object contextValue = metadata.get("negotiation_context"); + if (contextValue instanceof Map contextMap + && ((Map) contextMap).containsKey("negotiationType")) { + return (Map) contextMap; + } for (var entry : metadata.entrySet()) { if (entry.getKey().contains("DATA-NEGOTIATION-T") && entry.getValue() instanceof Map) { return (Map) entry.getValue(); @@ -136,26 +152,57 @@ private static String getAgentName(AgentCard agentCard) { } /** - * Parses the SDK-carried negotiation context ({@code negotiationContext} metadata key with - * id/round/maxRounds) from the received message metadata. Returns null when absent or - * malformed; the SDK validate APIs treat a null context as not-a-negotiation-message. + * Parses the SDK-carried negotiation context for the validate APIs. + * + *

    Primary source: the {@code negotiationContext} metadata key (id/round/maxRounds). The + * SDK's {@code buildMetadataContent} only embeds {@code id} there, so as a fallback the + * stateful context ({@code negotiation_context} key, carrying negotiationId/round) is + * synthesized into a full context. Returns null when neither is usable; the SDK validate + * APIs treat a null context as not-a-negotiation-message. */ private static net.openan.a2at.sdk.core.model.NegotiationContext parseNegotiationContext( Map metadata) { Object raw = metadata.get(net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); - if (!(raw instanceof Map contextMap)) { - return null; + if (raw instanceof Map contextMap) { + try { + Object id = contextMap.get("id"); + Object round = contextMap.get("round"); + Object maxRounds = contextMap.get("maxRounds"); + if (id instanceof String s + && round instanceof Number r + && maxRounds instanceof Number m) { + return new net.openan.a2at.sdk.core.model.NegotiationContext( + s, r.intValue(), m.intValue()); + } + } catch (IllegalArgumentException e) { + log.debug("[Negotiation-T] Malformed negotiationContext metadata: {}", e.getMessage()); + } } - try { - Object id = contextMap.get("id"); - Object round = contextMap.get("round"); - Object maxRounds = contextMap.get("maxRounds"); - if (id instanceof String s && round instanceof Number r && maxRounds instanceof Number m) { - return new net.openan.a2at.sdk.core.model.NegotiationContext( - s, r.intValue(), m.intValue()); + // Fallback: synthesize from the stateful negotiation_context map. Two shapes: + // 1) raw context map {negotiationType, negotiationId, round, status} (from the propose + // metadata), 2) the receiveNegotiation result {needResponse, message, context: {...}} + // stored under the same key after receiveNegotiation succeeds. + Object stateful = metadata.get("negotiation_context"); + if (stateful instanceof Map outer) { + Map stateMap = outer; + Object inner = outer.get("context"); + if (inner instanceof Map innerMap && innerMap.get("negotiationId") != null) { + stateMap = innerMap; + } + Object id = stateMap.get("negotiationId") != null + ? stateMap.get("negotiationId") + : stateMap.get("id"); + Object round = stateMap.get("round"); + if (id instanceof String s && round instanceof Number r) { + try { + return new net.openan.a2at.sdk.core.model.NegotiationContext( + s, + r.intValue(), + net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); + } catch (IllegalArgumentException ignored) { + // fall through + } } - } catch (IllegalArgumentException e) { - log.debug("[Negotiation-T] Malformed negotiationContext metadata: {}", e.getMessage()); } return null; } @@ -223,8 +270,15 @@ public CompletableFuture afterReceive( if (contextMap == null) { contextMap = metadata; } + // The negotiation message travels in metadata under the Negotiation-T + // URI key (the parts text is a short placeholder), per the SDK demo + // convention. Validate/receive against that full propose text. + String proposeText = extractNegotiationText(metadata); + if (proposeText == null || proposeText.isEmpty()) { + proposeText = result.getText(); + } Map receiveResult = - a2atClient.receiveNegotiation(result.getText(), contextMap); + a2atClient.receiveNegotiation(proposeText, contextMap); { Map rr = receiveResult; Boolean needResponse = (Boolean) rr.get("needResponse"); @@ -246,7 +300,7 @@ public CompletableFuture afterReceive( parseNegotiationContext(metadata); try { FilledParamData paramData = a2atClient.validateProposePromptAndDataFilling( - result.getText(), context, negotiationParamSchema(), proposeUri); + proposeText, context, negotiationParamSchema(), proposeUri); if (paramData != null && paramData.data() != null && !paramData.data().isEmpty()) { metadata.put( diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java index e15e37d9..f2206e82 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java @@ -98,6 +98,24 @@ public CompletableFuture> beforeSend( log.info("[Task-T] Metadata already preset, skipping generation"); return CompletableFuture.completedFuture(result); } + // Structured-data track: caller supplied taskData + taskSchema → deterministic + // fromData rendering (no scenario recognition, no LLM). + Object taskData = result.remove(A2ATExtension.TASK_DATA_META_KEY); + Object taskSchema = result.remove(A2ATExtension.TASK_SCHEMA_META_KEY); + Object taskTemplate = result.remove(A2ATExtension.TASK_TEMPLATE_META_KEY); + if (taskData instanceof Map data && taskSchema instanceof Map schema) { + return CompletableFuture.supplyAsync( + () -> + renderFromData( + a2atClient, + agentCard, + taskTUri, + data, + schema, + taskTemplate instanceof String s ? s : null, + result)); + } + // Free-text track: scenario recognition on the message text. return CompletableFuture.supplyAsync( () -> { try { @@ -129,6 +147,49 @@ public CompletableFuture> beforeSend( }); } + /** Deterministic fromData rendering; on failure degrades to the free-text track. */ + private Map renderFromData( + A2ATClient a2atClient, + AgentCard agentCard, + String taskTUri, + Map data, + Map schema, + String templateUriStr, + Map result) { + try { + @SuppressWarnings("unchecked") + Map typedData = (Map) data; + @SuppressWarnings("unchecked") + Map typedSchema = (Map) schema; + net.openan.a2at.sdk.core.model.TemplateUri templateUri = + templateUriStr != null + ? net.openan.a2at.sdk.core.model.TemplateUri.parse(templateUriStr) + .orElse( + net.openan.a2at.sdk.core.model.StandardTemplates + .PRIVATE_LINE_COMPLAINT) + : net.openan.a2at.sdk.core.model.StandardTemplates + .PRIVATE_LINE_COMPLAINT; + net.openan.a2at.sdk.core.model.MetadataContent content = + a2atClient.generateTaskPromptFromDataWithSchema( + typedData, typedSchema, templateUri); + if (content != null && content.promptText() != null && !content.promptText().isEmpty()) { + result.put(taskTUri, content.promptText()); + log.info( + "[Task-T] Rendered prompt from task data for '{}': {} chars (template={})", + getAgentName(agentCard), + content.promptText().length(), + content.templateUri()); + return result; + } + } catch (Exception e) { + log.warn( + "[Task-T] fromData rendering failed for '{}': {}", + getAgentName(agentCard), + e.getMessage()); + } + return result; + } + @Override public CompletableFuture afterReceive( AgentCard agentCard, 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 b10335bb..fc47a19c 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 @@ -67,6 +67,38 @@ default CompletableFuture sendMessage(String agentName, Strin return sendMessage(agentName, message, null, null); } + /** + * Structured-data send: renders the Task-T prompt deterministically from typed data via the + * SDK's fromData pipeline (no scenario recognition, no LLM), then runs the normal send path + * (Negotiation-T auto-loop, auth, extension headers). + * + *

    Callers holding structured business data should prefer this over {@link #sendMessage}: + * the SDK renders the prompt from the data and schema instead of the caller hand-writing the + * rendered prompt text. + * + * @param agentName target agent name (must match AgentCard.name) + * @param message short accompanying message text (parts text of the A2A message) + * @param data structured task input (string-to-object map) + * @param schema JSON schema describing the meaning of each data field + * @param templateUri template the data renders through (e.g. {@code + * StandardTemplates.PRIVATE_LINE_COMPLAINT}); null uses the SDK default + * @return future completing with response text, task, metadata, task state + */ + default CompletableFuture sendMessageFromData( + String agentName, + String message, + Map data, + Map schema, + net.openan.a2at.sdk.core.model.TemplateUri templateUri) { + Map metadata = new java.util.LinkedHashMap<>(); + metadata.put(A2ATExtension.TASK_DATA_META_KEY, data); + metadata.put(A2ATExtension.TASK_SCHEMA_META_KEY, schema); + if (templateUri != null) { + metadata.put(A2ATExtension.TASK_TEMPLATE_META_KEY, templateUri.uri()); + } + return sendMessage(agentName, message, null, metadata); + } + void setControlPoint(ControlPoint controlPoint); void setEventCallback(EventCallback callback); From cdb280189ed416181e186aa118548b563ab93875 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 24 Aug 2026 06:25:32 +0800 Subject: [PATCH 10/49] docs: document the fromData dual track and the stateful negotiation loop API_REFERENCE (zh/en): sendMessageFromData / sendExtensionMessageFromData signatures and track-selection guidance, the clarified auto-loop description (receiveNegotiation -> validate -> render -> continue), the negotiation_context metadata convention, and the data:/reject:/abort: clarification prefixes on onNegotiation. Signed-off-by: Zhoujie628 --- docs/en/API_REFERENCE.md | 52 +++++++++++++++++++++++++++++++++------- docs/zh/API_REFERENCE.md | 46 ++++++++++++++++++++++++++++++----- 2 files changed, 84 insertions(+), 14 deletions(-) diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index 676afa18..32803bf9 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -117,20 +117,56 @@ After receiving: 2. Metadata extraction (task-level + artifact-level) 3. Negotiation-T auto-loop (triggered when metadata carries the Negotiation-T key; see "Negotiation auto-loop behavior" below) +#### sendMessageFromData (structured-data dual track) + +```java +CompletableFuture sendMessageFromData( + String agentName, + String message, + Map data, + Map schema, + TemplateUri templateUri); +``` + +| Parameter | Type | Description | +|---------------|-----------------------|---------------------------------------------------------------------| +| `agentName` | `String` | Target agent name | +| `message` | `String` | Short accompanying message text (A2A message parts text) | +| `data` | `Map` | Structured business data (raw fields, not a rendered prompt) | +| `schema` | `Map` | JSON schema describing what each data field means | +| `templateUri` | `TemplateUri` | Target template; null uses the SDK default (PRIVATE_LINE_COMPLAINT)| + +**Choosing a track**: when the caller holds structured data (e.g. complaint-ticket fields) prefer +this method — the SDK renders the Task-T prompt deterministically via +`generateTaskPromptFromDataWithSchema` (no scenario recognition, no LLM call). With free-form +natural-language input use `sendMessage` (the SDK scenario-recognition pipeline). Both tracks run +through the full Negotiation-T auto-loop, auth, and extension-header injection. + +`ExtensionSender.sendExtensionMessageFromData(agentName, instruction, data, schema, extension)` +provides the same structured-data track for Authorization-T / Notification-T pre-positioning. + #### Negotiation auto-loop behavior - **Trigger**: the response metadata carries the Negotiation-T extension key (the agent opened a - negotiation via `Message(ROLE_AGENT)` + `INPUT_REQUIRED`). -- **Each round**: `ControlPoint.onNegotiation` produces a clarification → the SDK content layer - renders the follow-up message (Accept template; a `reject:` / `abort:` prefix on the - clarification selects the Reject / Abort terminal template) → the SDK state machine advances - (AGREED / REJECTED) → send to the agent → recurse on the next round. + negotiation via `Message(ROLE_AGENT)` + `INPUT_REQUIRED`) and the message metadata carries + `negotiation_context` (the negotiation context produced by the agent-side `startNegotiation`). +- **Each round**: the SDK `receiveNegotiation` parses the request → + `validateProposePromptAndDataFilling` validates the propose against its template and extracts + parameters → `ControlPoint.onNegotiation` produces a clarification → the SDK content layer + renders the follow-up message → the SDK state machine advances (`continueNegotiation`, + AGREED / REJECTED) → send to the agent → recurse on the next round. +- **Clarification conventions** (the onNegotiation return value): + - `data:{...json...}` — deterministic fromData Accept rendering (no LLM call; the JSON body is + the filled-parameter map) + - `reject:` / `abort:` — Reject / Abort terminal templates + - any other text — fromText Accept rendering (one LLM extraction step) - **Round exhaustion**: beyond `maxNegotiationRounds` the loop stops, a terminal message is sent via the SDK abort template (best effort — delivery failures are logged only), a `NEGOTIATION_FAILED` event fires, and the last agent reply stands as the final response. -- **Negotiation context**: the SDK carries id/round/maxRounds in the message metadata's - `negotiationContext` key (not rendered in the message text); the engine parses it and passes it - to the validate APIs. +- **Negotiation context**: the agent carries the `startNegotiation` payload's context + (negotiationType / negotiationId / round / status) in the message metadata's + `negotiation_context` key; the engine parses it and passes it to the receive / validate / + continue APIs. #### Template queries diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index deb5c2ef..2176ddac 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -116,17 +116,51 @@ public interface WorkflowEngineClient { 2. 提取 metadata(task 级 + artifact 级合并) 3. Negotiation-T 自动循环(metadata 携带 Negotiation-T key 时触发;见下方「协商自动循环行为」) +#### sendMessageFromData(结构化数据双轨) + +```java +CompletableFuture sendMessageFromData( + String agentName, + String message, + Map data, + Map schema, + TemplateUri templateUri); +``` + +| 参数 | 类型 | 说明 | +|----------------|-----------------------|---------------------------------------------------------| +| `agentName` | `String` | 目标智能体名称 | +| `message` | `String` | 伴随的短消息文本(A2A 消息 parts 文本) | +| `data` | `Map` | 结构化业务数据(原始字段,非渲染后的提示词) | +| `schema` | `Map` | JSON Schema,描述各数据字段的含义 | +| `templateUri` | `TemplateUri` | 目标模板;null 用 SDK 默认(PRIVATE_LINE_COMPLAINT) | + +**双轨选型**:业务方持有结构化数据(如投诉工单字段)时优先用本方法——SDK 走 +`generateTaskPromptFromDataWithSchema` 确定性渲染(无场景识别、无 LLM 调用);持有自然语言 +描述时用 `sendMessage`(SDK 场景识别管线)。两条轨道都完整经过 Negotiation-T 自动循环、 +认证和扩展头注入。 + +`ExtensionSender.sendExtensionMessageFromData(agentName, instruction, data, schema, extension)` +为 Authorization-T / Notification-T 前置预置提供同样的结构化数据轨道。 + #### 协商自动循环行为 - **触发条件**:响应 metadata 携带 Negotiation-T 扩展 key(agent 通过 `Message(ROLE_AGENT)` + - `INPUT_REQUIRED` 发起协商)。 -- **每轮**:`ControlPoint.onNegotiation` 生成澄清文本 → SDK 内容层渲染 follow-up 消息 - (Accept 模板;clarification 以 `reject:` / `abort:` 前缀开头则渲染 Reject / Abort 模板)→ - SDK 状态机推进(AGREED / REJECTED)→ 发送给 agent → 递归检查下一轮。 + `INPUT_REQUIRED` 发起协商),且消息 metadata 携带 `negotiation_context`(协商上下文, + 由 agent 侧 `startNegotiation` 产生)。 +- **每轮**:SDK `receiveNegotiation` 解析协商请求 → `validateProposePromptAndDataFilling` + 按模板校验并抽取参数 → `ControlPoint.onNegotiation` 生成澄清 → SDK 内容层渲染 follow-up + 消息 → SDK 状态机推进(`continueNegotiation`,AGREED / REJECTED)→ 发送给 agent → + 递归检查下一轮。 +- **澄清文本约定**(onNegotiation 返回值): + - `data:{...json...}` — 确定性 fromData 渲染 Accept(无 LLM 调用,JSON 为补充参数 map) + - `reject:原因` / `abort:原因` — 渲染 Reject / Abort 终态模板 + - 其他文本 — fromText 渲染 Accept(一次 LLM 抽取) - **轮次耗尽**:超过 `maxNegotiationRounds` 后不再继续循环,通过 SDK abort 模板发送终止消息 (尽力而为,发送失败仅记日志),发出 `NEGOTIATION_FAILED` 事件,agent 最后一次回复作为最终响应。 -- **协商上下文**:SDK 将 id/round/maxRounds 放在消息 metadata 的 `negotiationContext` key 中 - (不在消息文本里渲染),引擎解析后传给 validate API。 +- **协商上下文**:agent 将 `startNegotiation` 载荷的上下文(negotiationType / negotiationId / + round / status)放在消息 metadata 的 `negotiation_context` key 中,引擎解析后传给 + receive / validate / continue API。 #### 模板查询 From bc369886c3e175584b5edb0b61864fdd00764a2f Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 24 Aug 2026 07:11:42 +0800 Subject: [PATCH 11/49] feat(samples): southbound fromData dispatch, fromData pre-positioning, template fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the dual-track rollout to every remaining send path and fix the template mismatches found in the sweep: - WorkbenchControlPoint.onTask: southbound workbench->OMC task dispatch now goes through sendMessageFromData with city-scoped data (city1 = blank slots to trigger negotiation, city2 = complete complaint); the hand-built City1/City2 Task-T prompt strings are gone - WorkflowEngineClient.sendMessageFromData gains the extraMetadata overload (workbench passes the empty Negotiation-T key to activate the extension header alongside the data track) - ExtensionPrePositioner: Authorization-T / Notification-T pre-positioning now renders via sendExtensionMessageFromData / sendNotificationFromData with typed policy/subscription data and schemas mirroring the bundled SDK slot schemas - ExtensionSender.sendNotificationFromData implemented (event callback preserved; shared stream-open path extracted) - Template fixes: Notification-T default is SERVICE_RECOVERY (service recovery event), not SUBSCRIBE_INCIDENT (incident subscription) — the wrong template was rendering the recovery subscription against the incident template; agent-side validate schema aligned to the service-recovery slots (drop the non-existent 通知主题 slot) - TaskTHandler: negotiation-follow-up skip now requires a NON-EMPTY Negotiation-T value (an empty value only activates the extension header on the first send and must not skip fromData rendering); engine-internal a2at.taskData keys are stripped on that path so they never reach the wire - advanceStateMachine: the continueNegotiation context is surfaced as the top-level negotiation_context key so the agent-side parser finds a full stateful map on the follow-up - NegotiationBaseAgentExecutor.parseNegotiationContext: same fallback chain as the engine side (negotiationContext -> negotiation_context, both raw and receive-result shapes) Verified: mvn verify all green; direct and order demos end-to-end with ZERO degradation warnings — the full chain runs the SDK path: fromData Task-T render (city1+city2), stateful propose, receiveNegotiation, validatePropose extracted [id, round, maxRounds, 接入端口名称, 投诉分类], SDK Accept render on both sides. Signed-off-by: Zhoujie628 --- .../agents/NegotiationBaseAgentExecutor.java | 59 +++++++++---- .../engine/examples/demo/SpnCasePrompts.java | 85 ++++++++++++++++++ .../extension/ExtensionPrePositioner.java | 67 ++++++--------- .../PrePositionedExtensionHandler.java | 5 +- .../workbench/WorkbenchControlPoint.java | 84 ++++++------------ .../engine/client/DefaultExtensionSender.java | 86 +++++++++++++++---- .../client/DefaultWorkflowEngineClient.java | 12 +++ .../engine/client/ExtensionSender.java | 14 +++ .../workflow/engine/client/TaskTHandler.java | 19 ++-- .../engine/client/WorkflowEngineClient.java | 22 ++++- 10 files changed, 313 insertions(+), 140 deletions(-) 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 41c063c3..27b5650d 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 @@ -668,9 +668,13 @@ protected String defaultNegotiationConcern() { } /** - * Parses the SDK-carried negotiation context ({@code negotiationContext} metadata key) from - * the incoming message metadata. Returns null when absent or malformed; the SDK validate - * APIs treat a null context as not-a-negotiation-message. + * Parses the SDK-carried negotiation context for the validate APIs. + * + *

    Primary source: the {@code negotiationContext} metadata key — but the SDK's {@code + * buildMetadataContent} only embeds {@code id} there, so as a fallback the stateful {@code + * negotiation_context} map is synthesized (covering both the raw context shape and the + * receiveNegotiation-result shape wrapping it under "context"). Returns null when neither is + * usable; the SDK validate APIs treat a null context as not-a-negotiation-message. */ protected static net.openan.a2at.sdk.core.model.NegotiationContext parseNegotiationContext( Map metadata) { @@ -680,22 +684,43 @@ protected static net.openan.a2at.sdk.core.model.NegotiationContext parseNegotiat Object raw = metadata.get( net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); - if (!(raw instanceof Map contextMap)) { - return null; + if (raw instanceof Map contextMap) { + try { + Object id = contextMap.get("id"); + Object round = contextMap.get("round"); + Object maxRounds = contextMap.get("maxRounds"); + if (id instanceof String s && round instanceof Number r && maxRounds instanceof Number m) { + return new net.openan.a2at.sdk.core.model.NegotiationContext( + s, r.intValue(), m.intValue()); + } + } catch (IllegalArgumentException e) { + log.debug( + "[{}] Malformed negotiationContext metadata: {}", + NegotiationBaseAgentExecutor.class.getSimpleName(), + e.getMessage()); + } } - try { - Object id = contextMap.get("id"); - Object round = contextMap.get("round"); - Object maxRounds = contextMap.get("maxRounds"); - if (id instanceof String s && round instanceof Number r && maxRounds instanceof Number m) { - return new net.openan.a2at.sdk.core.model.NegotiationContext( - s, r.intValue(), m.intValue()); + Object stateful = metadata.get("negotiation_context"); + if (stateful instanceof Map outer) { + Map stateMap = outer; + Object inner = outer.get("context"); + if (inner instanceof Map innerMap && innerMap.get("negotiationId") != null) { + stateMap = innerMap; + } + Object id = stateMap.get("negotiationId") != null + ? stateMap.get("negotiationId") + : stateMap.get("id"); + Object round = stateMap.get("round"); + if (id instanceof String s && round instanceof Number r) { + try { + return new net.openan.a2at.sdk.core.model.NegotiationContext( + s, + r.intValue(), + net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); + } catch (IllegalArgumentException ignored) { + // fall through + } } - } catch (IllegalArgumentException e) { - log.debug( - "[{}] Malformed negotiationContext metadata: {}", - NegotiationBaseAgentExecutor.class.getSimpleName(), - e.getMessage()); } return null; } 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 b2d04780..7609e2e9 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 @@ -104,10 +104,95 @@ public static Map privateLineComplaintDataUnknownPort() { 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); return metadata; } + + // ------------------------------------------------------------------ + // Authorization-T (spec case 7.5: add authorization policy) + // ------------------------------------------------------------------ + + /** Schema mirroring the SDK's Authorization-T slot schema (授权策略的操作类型 + 策略列表). */ + public static Map authorizationSchema() { + Map operationType = new LinkedHashMap<>(); + operationType.put("type", "string"); + operationType.put("description", "授权策略的操作类型:新增/修改/删除/查询授权策略"); + Map policyList = new LinkedHashMap<>(); + policyList.put("type", "string"); + policyList.put( + "description", + "动网操作的授权策略列表,每条包含:业务场景、处置类型、操作名称、有效期"); + Map properties = new LinkedHashMap<>(); + properties.put("授权策略的操作类型", operationType); + properties.put("动网操作的授权策略列表", policyList); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + return schema; + } + + /** 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( + "动网操作的授权策略列表", + "业务投诉诊断/业务抢通/隧道调优/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() { + Map condition = new LinkedHashMap<>(); + condition.put("type", "string"); + condition.put("description", "订阅条件,例如子网名称约束"); + Map format = new LinkedHashMap<>(); + format.put("type", "string"); + format.put( + "description", + "上报通知数据格式:业务抢通事件的数据结构描述(执行状态/流水号/端口/授权/方案等字段)"); + Map properties = new LinkedHashMap<>(); + properties.put("订阅条件", condition); + properties.put("上报通知数据格式", format); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + return schema; + } + + /** Service-recovery subscription data (spec case 7.8). */ + public static Map subscribeServiceRecoveryData() { + Map data = new LinkedHashMap<>(); + data.put("订阅条件", ""); + data.put( + "上报通知数据格式", + "业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、" + + "OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、" + + "业务抢通方案名称、业务抢通方案详情、业务抢通方案执行结束时间、" + + "业务抢通方案执行结果(成功、失败)及失败原因。"); + return data; + } } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositioner.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositioner.java index e8bea394..ee75c187 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 @@ -20,6 +20,7 @@ package dev.openan.workflow.engine.examples.extension; import dev.openan.workflow.engine.client.ExtensionSender; +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; import org.a2aproject.sdk.spec.AgentCard; import org.slf4j.Logger; @@ -41,46 +42,18 @@ public class ExtensionPrePositioner { private static final Logger log = LoggerFactory.getLogger(ExtensionPrePositioner.class); - private final String authInput; - private final String notifInput; + private final Map authData; + private final Map authSchema; + private final Map notifData; + private final Map notifSchema; public ExtensionPrePositioner() { - // Authorization-T structured prompt, mirroring spec case 7.5 (add authorization). - this.authInput = - "## 授权策略的操作类型\n新增授权策略\n\n" - + "## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作," - + "按照<预期输出>中定义的结构返回授权策略的操作执行结果。\n\n" - + "## 动网操作的授权策略列表\n" - + " - 动网操作的授权策略1\n" - + " - 动网操作的业务场景:业务投诉诊断\n" - + " - 动网操作的处置类型:业务抢通\n" - + " - 动网操作名称:隧道调优\n" - + " - 有效期:2026-06-01T12:00:00Z ~ 2030-06-18T12:00:00Z\n\n" - + "## 预期输出\n" - + "1. 授权操作执行结果,取值范围:成功、失败、部分成功;\n" - + "2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n" - + "3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;"; - // Notification-T structured prompt, mirroring spec case 7.8 (subscribe). - this.notifInput = - "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息," - + "完成网络侧业务抢通事件的订阅与上报任务。\n\n" - + "## 通知主题\n业务抢通事件\n\n" - + "## 订阅条件\n\n" - + "## 上报通知数据格式\n### 业务抢通事件\n" - + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束(必选)\n" - + "2. 投诉诊断任务流水号(必选)\n" - + "3. OSS侧事件流水号(必选)\n" - + "4. 接入端口名称(必选)\n" - + "5. 是否已授权OMC自动抢通,取值范围:是、否;举例:是(必选)\n" - + "6. 业务抢通方案名称(必选)\n" - + "7. 业务抢通方案详情(必选)\n" - + "8. 业务抢通方案执行结束时间(可选)\n" - + "9. 业务抢通方案执行结果,取值范围:成功、失败(可选)\n" - + "10. 业务抢通方案执行失败原因(可选)\n\n" - + "## 预期输出\n" - + "1. 订阅结果,取值范围:成功、失败\n" - + "2. 订阅失败原因(可选)\n" - + "3. 订阅成功后,按照<上报通知数据格式>上报消息"; + // Structured-data track: the raw policy / subscription fields; the SDK renders the + // Authorization-T and Notification-T prompts deterministically (spec cases 7.5/7.8). + this.authData = SpnCasePrompts.addAuthorizationData(); + this.authSchema = SpnCasePrompts.authorizationSchema(); + this.notifData = SpnCasePrompts.subscribeServiceRecoveryData(); + this.notifSchema = SpnCasePrompts.serviceRecoverySchema(); } /** @@ -115,7 +88,14 @@ public void prePosition( long operationStarted = System.nanoTime(); log.info("[PrePosition] SEND extension=Authorization-T, agent={}", name); var authResult = - sender.sendAuthorization(name, "新增动网操作授权", authInput).join(); + sender.sendExtensionMessageFromData( + name, + "新增动网操作授权", + authData, + authSchema, + dev.openan.workflow.engine.client.A2ATExtension + .AUTHORIZATION_T) + .join(); log.info( "[PrePosition] ACK extension=Authorization-T, agent={}, state={}, " + "responseChars={}, elapsedMs={}", @@ -127,11 +107,12 @@ public void prePosition( operationStarted = System.nanoTime(); log.info("[PrePosition] SEND extension=Notification-T, agent={}", name); var notificationResult = - sender.sendNotification( - name, + sender.sendNotificationFromData( + name, "订阅业务抢通事件", - notifInput, - notificationCallback) + notifData, + notifSchema, + notificationCallback) .join(); log.info( "[PrePosition] ACK extension=Notification-T, agent={}, state={}, " 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 01949f0f..8fd492bb 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 @@ -147,8 +147,8 @@ private static Map buildAuthParamSchema() { * fields extracted from a rendered subscribe-incident prompt. */ private static Map buildNotificationParamSchema() { + // Slots of the SDK's Notification-T service-recovery template (订阅条件 + 上报通知数据格式). Map properties = new LinkedHashMap<>(); - properties.put("通知主题", Map.of("type", "string")); properties.put("订阅条件", Map.of("type", "string")); properties.put("上报通知数据格式", Map.of("type", "string")); Map schema = new LinkedHashMap<>(); @@ -227,7 +227,8 @@ public void handle( server.validateNotificationPromptAndDataFilling( payloadText, buildNotificationParamSchema(), - net.openan.a2at.sdk.core.model.StandardTemplates.SUBSCRIBE_INCIDENT); + net.openan.a2at.sdk.core.model.StandardTemplates + .SERVICE_RECOVERY); log.info( "[{}] Notification-T validateNotificationPromptAndDataFilling" + " passed, templateUri={}, params={}", 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 cb9d81ae..d3f7f18b 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 @@ -102,78 +102,50 @@ private static String analyzeFaultLocation(String messageText) { } /** - * Build a targeted task message containing ONLY the parameters relevant to the target agent's - * city. The upstream context (which mixes both cities' info) is NOT forwarded -- each sub-agent - * receives a clean, self-contained task description with only its own scope. + * Build the city-scoped structured data for the target agent's step. The upstream context + * (which mixes both cities' info) is NOT forwarded — each sub-agent receives a clean, + * self-contained complaint with only its own scope. City1 goes out with blank slots (spec + * case 7.3: triggers negotiation); City2 with complete parameters. */ - private static String buildTargetedTaskMessage(String step) { + private static java.util.Map buildTaskData(String step) { return switch (step) { - case "diagnosis_city1" -> buildCity1Task(); - case "diagnosis_city2" -> buildCity2Task(); - default -> "创建专线业务投诉诊断任务"; + case "diagnosis_city1" -> + dev.openan.workflow.engine.examples.demo.SpnCasePrompts + .privateLineComplaintDataBlankObject(); + case "diagnosis_city2" -> + dev.openan.workflow.engine.examples.demo.SpnCasePrompts + .privateLineComplaintDataCity2(); + default -> + dev.openan.workflow.engine.examples.demo.SpnCasePrompts + .privateLineComplaintData(); }; } - private static String buildCity1Task() { - // Task-T structured prompt, mirroring spec case 7.3 (missing params, triggers negotiation). - return "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" - + "## 任务描述(Task Description)\n" - + "基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, " - + "达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n" - + "## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n" - + "## 任务对象(Task Object)\n接入端口名称:\n\n" - + "## 任务上下文(Task Context)\n" - + "1. 投诉分类:\n" - + "2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n" - + "3. OSS侧事件流水号:\"event-id-20260511-09013\"\n" - + "4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," - + "访问广州机房的核心交易系统非常慢。\"\n\n" - + "## 预期输出(Expected Output)\n" - + "1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n" - + "2. 诊断结果详情;(必选)\n" - + "3. 修复建议;(可选)\n" - + "4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"; - } - - private static String buildCity2Task() { - return "## 任务类型(Task Type)\n传输专线业务投诉诊断\n\n" - + "## 任务描述(Task Description)\n" - + "基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, " - + "达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n" - + "## 任务目标(Task Target)\n对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。\n\n" - + "## 任务对象(Task Object)\n接入端口名称:P882-珠江新城-PTN7900-23-TPA1EG24-11\n\n" - + "## 任务上下文(Task Context)\n" - + "1. 投诉分类:\"专线质差\"\n" - + "2. 问题发生时间:\"2026-05-11T08:21:46Z\"\n" - + "3. OSS侧事件流水号:\"event-id-20260511-09013\"\n" - + "4. 投诉详情:\"从5月11号早上8点半开始,深圳访问广州的响应延迟从平均12ms骤升至320ms," - + "访问广州机房的核心交易系统非常慢。\"\n\n" - + "## 预期输出(Expected Output)\n" - + "1. 诊断结果;参数的取值范围包括:成功、失败;(必选)\n" - + "2. 诊断结果详情;(必选)\n" - + "3. 修复建议;(可选)\n" - + "4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息;(可选)"; - } - @Override public CompletableFuture onTask( TaskRequest request, WorkflowEngineClient engineClient) { String step = request.getStepName(); String agentName = request.getAgentName(); - String targetedMessage = buildTargetedTaskMessage(step); - // Send the Task-T structured prompt via message metadata (spec §6), so the OMC agent - // reads it from message.metadata[Task-T URI] exactly like spec cases 7.1/7.3. + // Structured-data track: hand over the city-scoped complaint fields + schema; the SDK + // renders the Task-T prompt deterministically (no hand-written prompt text). + // For diagnosis_city1, activate Negotiation-T extension alongside (spec case 7.3: + // missing params trigger negotiation). The ExtensionInterceptor adds it to the + // A2A-Extensions header when the metadata key is present. Map metadata = new LinkedHashMap<>(); - metadata.put(NegotiationUtils.TASK_PROMPT_KEY, targetedMessage); - // For diagnosis_city1, activate Negotiation-T extension (spec case 7.3: missing params - // trigger negotiation). The ExtensionInterceptor adds it to the A2A-Extensions header - // when the metadata key is present. if ("diagnosis_city1".equals(step)) { metadata.put(NegotiationUtils.NEGOTIATION_T_URI, ""); } String partsText = "创建专线业务投诉诊断任务"; + Map taskData = buildTaskData(step); return engineClient - .sendMessage(agentName, partsText, null, metadata) + .sendMessageFromData( + agentName, + partsText, + taskData, + dev.openan.workflow.engine.examples.demo.SpnCasePrompts + .privateLineComplaintSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT, + metadata) .thenApply( r -> { boolean success = r.getText() != null && !r.getText().isEmpty(); 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 8ec1678f..4d27f0d1 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 @@ -198,7 +198,7 @@ private MetadataContent generateExtensionPromptFromData( case AUTHORIZATION_T -> a2atClient.generateAuthPromptFromDataWithSchema( data, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); case NOTIFICATION_T -> a2atClient.generateNotificationPromptFromDataWithSchema( - data, schema, StandardTemplates.SUBSCRIBE_INCIDENT); + data, schema, StandardTemplates.SERVICE_RECOVERY); default -> null; }; } catch (Exception e) { @@ -237,27 +237,77 @@ public CompletableFuture sendNotification( value = naturalLanguageInput; metadata.put(A2ATExtension.NOTIFICATION_T.uri(), value); } - Consumer eventSink = - eventCallback != null - ? event -> - forwardNotificationEvent( - event, agentName, eventCallback) - : null; - log.info( - "[ExtensionSender] sendNotification to {}: metadataValue={} chars, callback={}", - agentName, - value.length(), - eventCallback != null); - return transport.sendNotificationStream( + return sendNotificationMetadata( + agentCard, agentName, instruction, metadata, eventCallback, value); + }); + } + + @Override + public CompletableFuture sendNotificationFromData( + String agentName, + String instruction, + Map data, + Map schema, + Consumer> eventCallback) { + AgentCard agentCard = transport.getCard(agentName); + if (agentCard == null) { + log.error("[ExtensionSender] Agent not found: {}", agentName); + return CompletableFuture.failedFuture( + new RuntimeException("Agent not found: " + agentName)); + } + return CompletableFuture.supplyAsync( + () -> + generateExtensionPromptFromData( + A2ATExtension.NOTIFICATION_T, data, schema)) + .thenCompose( + mc -> { + if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { + log.error( + "[ExtensionSender] Notification-T fromData rendering" + + " unavailable for {}; refusing to send raw data", + agentName); + return CompletableFuture.failedFuture( + new RuntimeException( + "Notification-T fromData rendering failed")); + } + Map metadata = new HashMap<>(); + metadata.putAll(mc.buildMetadataContent()); + return sendNotificationMetadata( agentCard, agentName, instruction, - transport.getContextId(), metadata, - eventSink); + eventCallback, + mc.promptText()); }); } + /** Shared Notification-T stream-open path used by both the text and fromData tracks. */ + private CompletableFuture sendNotificationMetadata( + AgentCard agentCard, + String agentName, + String instruction, + Map metadata, + Consumer> eventCallback, + String renderedValue) { + Consumer eventSink = + eventCallback != null + ? event -> forwardNotificationEvent(event, agentName, eventCallback) + : null; + log.info( + "[ExtensionSender] sendNotification to {}: metadataValue={} chars, callback={}", + agentName, + renderedValue.length(), + eventCallback != null); + return transport.sendNotificationStream( + agentCard, + agentName, + instruction, + transport.getContextId(), + metadata, + eventSink); + } + private void forwardNotificationEvent( ClientEvent event, String agentName, Consumer> callback) { Map data = new HashMap<>(); @@ -308,6 +358,10 @@ private void forwardNotificationEvent( * SDK's own {@code A2AT_LANGUAGE} configuration, no longer from a caller-supplied language * string. * + *

    Notification-T defaults to the service-recovery template (业务抢通事件订阅) — the + * subscription the workbench pre-positions in this scenario; pass an explicit template via + * the fromData variant when subscribing to something else (e.g. SUBSCRIBE_INCIDENT). + * * @return {@link MetadataContent} with prompt text and template URI, or null if SDK unavailable */ MetadataContent generateExtensionPrompt(A2ATExtension extension, String naturalLanguageInput) { @@ -322,7 +376,7 @@ MetadataContent generateExtensionPrompt(A2ATExtension extension, String naturalL case AUTHORIZATION_T -> a2atClient.generateAuthPromptFromText( naturalLanguageInput, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); case NOTIFICATION_T -> a2atClient.generateNotificationPromptFromText( - naturalLanguageInput, StandardTemplates.SUBSCRIBE_INCIDENT); + naturalLanguageInput, StandardTemplates.SERVICE_RECOVERY); case NEGOTIATION_T -> null; // Negotiation uses a dedicated flow in NegotiationTHandler }; } catch (Exception e) { 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 676ba654..06c7b324 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 @@ -546,6 +546,18 @@ private Map advanceStateMachine( mc.promptText()); if (payload != null && !payload.isEmpty()) { meta.putAll(payload); + // The continueNegotiation payload nests the context under the Negotiation-T NL + // key ({message, negotiationType, negotiationId, round, status}). Surface it as + // the top-level negotiation_context key too, so the receiving agent's context + // parser finds a full stateful map in one place. + for (var entry : payload.entrySet()) { + if (entry.getKey().contains("Negotiation-T") + && !entry.getKey().contains("DATA-NEGOTIATION-T") + && entry.getValue() instanceof Map data) { + meta.put("negotiation_context", new java.util.LinkedHashMap<>(castMap(data))); + break; + } + } } } catch (Exception ce) { log.debug( 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 ce381d71..0d7ac3ca 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 @@ -83,6 +83,20 @@ default CompletableFuture sendExtensionMessageFromData( throw new UnsupportedOperationException("sendExtensionMessageFromData not implemented"); } + /** + * Structured-data Notification-T subscription: renders the service-recovery subscription + * prompt deterministically from typed data (no LLM), then opens the long-lived stream. + * Subsequent events pushed by the agent flow to {@code eventCallback}. + */ + default CompletableFuture sendNotificationFromData( + String agentName, + String instruction, + Map data, + Map schema, + Consumer> eventCallback) { + throw new UnsupportedOperationException("sendNotificationFromData not implemented"); + } + /** * Establish a Notification-T subscription. * diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java index f2206e82..dcd77cf7 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java @@ -79,13 +79,22 @@ public CompletableFuture> beforeSend( if (a2atClient == null) { return CompletableFuture.completedFuture(metadata); } - // Skip Task-T prompt generation when this is a Negotiation-T follow-up - // (metadata already carries the Negotiation-T key from the Accept prompt). + // Skip Task-T prompt generation when this is a Negotiation-T follow-up: the metadata + // carries a RENDERED follow-up message under the Negotiation-T key. An EMPTY + // Negotiation-T value only activates the A2A-Extensions header (first task send) and + // must NOT skip generation. if (metadata != null) { - for (String key : metadata.keySet()) { - if (key.toUpperCase(java.util.Locale.ROOT).contains("NEGOTIATION-T")) { + for (Map.Entry entry : metadata.entrySet()) { + if (entry.getKey().toUpperCase(java.util.Locale.ROOT).contains("NEGOTIATION-T") + && entry.getValue() instanceof String s + && !s.isEmpty()) { log.info("[Task-T] Skipping prompt generation for negotiation follow-up"); - return CompletableFuture.completedFuture(metadata); + // Still strip engine-internal fromData keys: they must never reach the wire. + Map cleaned = new HashMap<>(metadata); + cleaned.remove(A2ATExtension.TASK_DATA_META_KEY); + cleaned.remove(A2ATExtension.TASK_SCHEMA_META_KEY); + cleaned.remove(A2ATExtension.TASK_TEMPLATE_META_KEY); + return CompletableFuture.completedFuture(cleaned); } } } 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 fc47a19c..d7aaf9c5 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 @@ -90,7 +90,27 @@ default CompletableFuture sendMessageFromData( Map data, Map schema, net.openan.a2at.sdk.core.model.TemplateUri templateUri) { - Map metadata = new java.util.LinkedHashMap<>(); + return sendMessageFromData(agentName, message, data, schema, templateUri, null); + } + + /** + * Structured-data send with additional preset metadata (e.g. the empty Negotiation-T key to + * activate the extension alongside the task). + * + * @param extraMetadata additional preset metadata merged into the message; engine-internal + * keys (a2at.taskData/taskSchema/taskTemplate) are added on top + */ + default CompletableFuture sendMessageFromData( + String agentName, + String message, + Map data, + Map schema, + net.openan.a2at.sdk.core.model.TemplateUri templateUri, + Map extraMetadata) { + Map metadata = + extraMetadata != null + ? new java.util.LinkedHashMap<>(extraMetadata) + : new java.util.LinkedHashMap<>(); metadata.put(A2ATExtension.TASK_DATA_META_KEY, data); metadata.put(A2ATExtension.TASK_SCHEMA_META_KEY, schema); if (templateUri != null) { From 2d56c277c7613d09d79e66dc7b14fac4fbdaf536 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 24 Aug 2026 10:43:34 +0800 Subject: [PATCH 12/49] refactor(engine): migrate off the SDK's deprecated negotiation state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per upstream review (a2a-t-sdk-java PR #138): negotiation/{types, store, runtime, handler} is deprecated demo-era code slated for removal; the current architecture is content/ + generation/ + resources/ + validation/ exposed via NegotiationContentService. The engine now uses only current APIs — when the SDK deletes the deprecated packages, nothing here breaks. Migration (the content layer is stateless by design — session identity and round tracking stay with the caller, SDK guide §1.10): - A2ATContentFacade: drop the startNegotiation / receiveNegotiation / continueNegotiation pass-throughs; add contextPayload/contextFromMap — the engine's serialization of the session context ({id, round, maxRounds}) for the negotiation_context metadata key (with legacy state-machine-format tolerance in the parser) - NegotiationTHandler.afterReceive: no receiveNegotiation call — the INPUT_REQUIRED task state plus the propose text in metadata already signal the pending negotiation; the engine answers directly and validates the propose through the current validateProposePromptAndDataFilling - DefaultWorkflowEngineClient.advanceStateMachine: replace the SDK state machine with engine-held rounds (core.model.NegotiationContext. nextRound(); terminal on reject/abort), re-serialized into negotiation_context; drop the NegotiationPayloadMapper / types.model dependencies entirely - samples NegotiationBaseAgentExecutor: agent-owned session context (UUID + round 1 + DEFAULT_MAX_ROUNDS) serialized into the propose metadata instead of server.startNegotiation; negotiationType() extension point replaced by proposeTemplateUri() (the template URI encodes the type — no deprecated enum needed) - NegotiationUtils.negotiationResponseMetadata takes the context map directly (no startNegotiation payload extraction) Verified against clean trunk SDK (823f76a, PR #136 included): mvn verify all green (105+5+23); direct/order demos end-to-end with the full negotiation chain on current APIs — propose context (id/round/maxRounds) → validatePropose extracted params → engine Context advanced round 1->2 → SDK accept rendering; A2ATProtocolCases case 7.1 completes. Signed-off-by: Zhoujie628 --- .../agents/NegotiationBaseAgentExecutor.java | 71 +++---- .../negotiation/NegotiationUtils.java | 47 +---- .../engine/client/A2ATContentFacade.java | 77 ++++---- .../client/DefaultWorkflowEngineClient.java | 158 +++++----------- .../engine/client/NegotiationTHandler.java | 174 ++++++------------ 5 files changed, 183 insertions(+), 344 deletions(-) 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 27b5650d..fad47f61 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 @@ -22,7 +22,6 @@ import net.openan.a2at.sdk.client.A2ATClient; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; import net.openan.a2at.sdk.server.A2ATServer; import org.a2aproject.sdk.server.agentexecution.RequestContext; @@ -398,13 +397,17 @@ private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, String String contextId = ctx.getContextId(); String negId = java.util.UUID.randomUUID().toString(); String negText = renderProposeText(negId, input); - // Combine the content layer with the stateful SDK runtime per the official demo - // pattern: startNegotiation produces the transport context payload, whose context - // map travels in the propose metadata so the client-side engine can advance the - // state machine (receiveNegotiation/continueNegotiation) with a well-formed context. - Map startPayload = startStatefulNegotiation(negText); + // The content layer is stateless — the agent owns the session context. Serialize it + // into the propose metadata (negotiation_context key) so the client-side engine can + // parse it and advance rounds itself, mirroring the official demo pattern. + Map contextMap = new LinkedHashMap<>(); + contextMap.put("id", negId); + contextMap.put("round", 1); + contextMap.put( + "maxRounds", + net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); Map metadata = - NegotiationUtils.negotiationResponseMetadata(negText, startPayload); + NegotiationUtils.negotiationResponseMetadata(negText, contextMap); Message proposeMessage = Message.builder() .messageId(java.util.UUID.randomUUID().toString()) @@ -416,36 +419,14 @@ private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, String .build(); emitter.requiresInput(proposeMessage); log.info( - "[{}] Requested negotiation via INPUT_REQUIRED status message (context={})", - getClass().getSimpleName(), - startPayload != null ? "stateful" : "fallback"); - } - - /** - * Starts the SDK negotiation state machine for a propose: returns the {@code - * startNegotiation} transport payload when the server facade is available, else null (the - * propose then goes out without a context and the client falls back to direct extraction). - */ - private Map startStatefulNegotiation(String negText) { - A2ATServer server = a2atServer(); - if (server == null) { - return null; - } - try { - return server.startNegotiation(negotiationType(), negText, Map.of()); - } catch (Exception e) { - log.warn( - "[{}] startNegotiation failed ({}); propose goes without SDK context", - getClass().getSimpleName(), - e.getMessage()); - return null; - } + "[{}] Requested negotiation via INPUT_REQUIRED status message (context=id/round/maxRounds)", + getClass().getSimpleName()); } /** * Renders the propose text through the SDK content engine when available: the subclass's - * {@link #negotiationType()} picks the template, {@link #buildProposeContent(String)} supplies - * the typed content, and the SDK renders deterministically (no LLM). Falls back to + * {@link #proposeTemplateUri()} picks the template, {@link #buildProposeContent(String)} + * supplies the typed content, and the SDK renders deterministically (no LLM). Falls back to * {@link #defaultNegotiationText()} when the SDK client is unavailable or rendering fails. */ private String renderProposeText(String negotiationId, String input) { @@ -540,19 +521,20 @@ private void emitStatus( // ---- subclass extension points ---- /** - * The negotiation type this agent starts. Selects the SDK propose template and the typed - * content model expected from {@link #buildProposeContent(String)}. Default: information - * negotiation (missing parameters). Override for {@code TARGET} (intent alignment) or {@code - * FEASIBILITY} (whether the request can be fulfilled). + * The propose template this agent's negotiation starts with. The template URI encodes the + * negotiation type (information / target / feasibility) in its URI segment and selects the + * typed content model expected from {@link #buildProposeContent(String)}. Default: + * information negotiation (missing parameters). Override for target negotiation (intent + * alignment) or feasibility negotiation (whether the request can be fulfilled). */ - protected NegotiationType negotiationType() { - return NegotiationType.INFORMATION; + protected net.openan.a2at.sdk.core.model.TemplateUri proposeTemplateUri() { + return StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; } /** * Typed propose content rendered by the SDK content engine. The default returns {@code null} * to signal "no typed content"; subclasses override together with {@link - * #negotiationType()}: + * #proposeTemplateUri()}: * *

      *
    • INFORMATION - {@code InformationProposeContent(items, relationship)} @@ -568,15 +550,6 @@ protected net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent buil return null; } - /** Resolves the SDK propose template URI for {@link #negotiationType()}. */ - protected net.openan.a2at.sdk.core.model.TemplateUri proposeTemplateUri() { - return switch (negotiationType()) { - case TARGET -> StandardTemplates.TARGET_NEGOTIATION_PROPOSE; - case FEASIBILITY -> StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; - case INFORMATION -> StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - }; - } - /** * Whether the incoming task parameters are incomplete/wrong and require a Negotiation-T round * before proceeding. 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 4ef0fbbd..7f3e7964 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 @@ -67,56 +67,29 @@ public static Map negotiationResponseMetadata(String negotiation } /** - * Metadata key carrying the JSON-serialised negotiation context map, matching the SDK demo - * convention ({@code NegotiationPayloadMapper.contextPayload} shape: {@code negotiationType} - * / {@code negotiationId} / {@code round} / {@code status}, all string or number values). + * Metadata key carrying the negotiation session context map ({@code id} / {@code round} / + * {@code maxRounds}). * - *

      Propose replies must carry this key so the client-side engine can hand a well-formed - * context to {@code receiveNegotiation} / {@code continueNegotiation}; without it the SDK - * state machine rejects the payload ("Negotiation context field must be a string"). + *

      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 = "negotiation_context"; /** - * Builds Negotiation-T propose metadata carrying the stateful context payload: the rendered - * propose text under the extension URI plus the {@code negotiation_context} key holding the - * SDK {@code startNegotiation} payload's context map. + * Builds Negotiation-T propose metadata carrying the session context: the rendered propose + * text under the extension URI plus the {@code negotiation_context} key holding the + * engine-format context map ({@code id}/{@code round}/{@code maxRounds}). * * @param negotiationText rendered propose text - * @param startPayload the {@code startNegotiation} return payload (context extracted via its - * Negotiation-T data key); may be null for the fallback path (context omitted) + * @param contextMap the session context map; null omits the key (fallback path) */ public static Map negotiationResponseMetadata( - String negotiationText, Map startPayload) { + String negotiationText, Map contextMap) { Map metadata = negotiationResponseMetadata(negotiationText); - Map contextMap = extractStartContext(startPayload); if (contextMap != null && !contextMap.isEmpty()) { metadata.put(NEGOTIATION_CONTEXT_KEY, contextMap); } return metadata; } - - /** Extracts the context map from a startNegotiation payload; null when absent. */ - private static Map extractStartContext(Map startPayload) { - if (startPayload == null) { - return null; - } - // startNegotiation payload shape: { Negotiation-T URI: {message, negotiationType, - // negotiationId, round, status, extra}, facts? } — the context fields sit in the - // Negotiation-T entry itself, minus the message key. - for (var entry : startPayload.entrySet()) { - String key = entry.getKey(); - if (key.contains("Negotiation-T") - && !key.contains("DATA-NEGOTIATION-T") - && entry.getValue() instanceof Map data) { - @SuppressWarnings("unchecked") - Map typed = new LinkedHashMap<>((Map) data); - typed.remove("message"); - if (typed.containsKey("negotiationType")) { - return typed; - } - } - } - return null; - } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java index 70525c1b..54a39d16 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java @@ -223,48 +223,63 @@ public MetadataContent generateAbortFromText( } // ------------------------------------------------------------------ - // Runtime state machine + // Negotiation session context (engine-held, content-layer contract) // ------------------------------------------------------------------ /** - * Starts a negotiation payload in the SDK's client role. + * Builds the transport serialization of a negotiation session context: the {@code + * negotiation_context} metadata value carrying {@code id} / {@code round} / {@code + * maxRounds}. * - * @param type negotiation type (INFORMATION / TARGET / FEASIBILITY) - * @param contentText rendered negotiation message text - * @param facts structured facts attached to the payload - * @return transport payload for the initial turn (carries the negotiation context) - */ - public Map startNegotiation( - net.openan.a2at.sdk.negotiation.types.model.NegotiationType type, - String contentText, - Map facts) { - return client.startNegotiation(type, contentText, facts); - } - - /** - * Processes a received negotiation message using its transport context payload. + *

      The content layer is stateless by design — session identity and round tracking stay + * with the caller (see SDK developer guide §1.10). The engine owns the context: it + * serializes it here for the wire and advances rounds itself via {@code + * NegotiationContext.nextRound()}. * - * @param message received negotiation message text - * @param context transport context payload associated with the message - * @return normalized payload ({@code needResponse}, {@code message}, {@code context}, ...) + * @param context the engine-held session context + * @return wire map for the {@code negotiation_context} metadata key */ - public Map receiveNegotiation(String message, Map context) { - return client.receiveNegotiation(message, context); + public static Map contextPayload(NegotiationContext context) { + Map payload = new LinkedHashMap<>(); + payload.put("id", context.id()); + payload.put("round", context.round()); + payload.put("maxRounds", context.maxRounds()); + return payload; } /** - * Continues an existing negotiation with a locally stored context snapshot. + * Parses a negotiation session context from its {@code negotiation_context} wire + * serialization. Accepts both the engine format ({@code id}/{@code round}/{@code + * maxRounds}) and the legacy state-machine format ({@code negotiationId}/{@code round}, + * optionally nested under a {@code context} key from removed receive payloads) for + * cross-version tolerance. Returns null when the map carries no usable identity. * - * @param context current negotiation context (types.model variant) - * @param status next status to emit (IN_PROGRESS / AGREED / REJECTED) - * @param contentText continuation message content - * @return transport payload for the next turn + * @param map the metadata value under the {@code negotiation_context} key + * @return the session context, or null when malformed */ - public Map continueNegotiation( - net.openan.a2at.sdk.negotiation.types.model.NegotiationContext context, - net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus status, - String contentText) { - return client.continueNegotiation(context, status, contentText); + public static NegotiationContext contextFromMap(Map map) { + if (map == null) { + return null; + } + Map effective = map; + Object inner = map.get("context"); + if (inner instanceof Map innerMap && innerMap.get("id") != null) { + effective = innerMap; + } + Object id = effective.get("id") != null ? effective.get("id") : effective.get("negotiationId"); + Object round = effective.get("round"); + if (id instanceof String s && round instanceof Number r && r.intValue() > 0) { + Object maxRounds = effective.get("maxRounds"); + int max = maxRounds instanceof Number m && m.intValue() > 0 + ? m.intValue() + : NegotiationContext.DEFAULT_MAX_ROUNDS; + try { + return new NegotiationContext(s, r.intValue(), max); + } catch (IllegalArgumentException ignored) { + // fall through + } + } + return null; } // ------------------------------------------------------------------ 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 06c7b324..001ada47 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 @@ -29,9 +29,6 @@ import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.runtime.helper.NegotiationPayloadMapper; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; @@ -522,9 +519,13 @@ private MetadataContent renderAcceptFromData( } /** - * Builds the follow-up metadata from the rendered message and, when the state-machine context - * is available, advances the SDK state machine so the follow-up carries the next-round (or - * terminal) context payload alongside the message. + * Builds the follow-up metadata from the rendered message and, when the session context is + * available, advances the engine-held round so the follow-up carries the next-round (or + * terminal) context alongside the message. + * + *

      The content layer is stateless — session identity and round tracking stay with the + * caller (SDK guide §1.10). The engine owns the context: parses it from the propose + * metadata, advances via {@code nextRound()}, and re-serializes it for the wire. */ private Map advanceStateMachine( A2ATContentFacade content, @@ -533,134 +534,63 @@ private Map advanceStateMachine( String clarification, MetadataContent mc) { Map meta = A2ATContentFacade.toMetadata(mc); - NegotiationContext typesCtx = extractTypesContext(negMeta); - if (typesCtx == null) { + net.openan.a2at.sdk.core.model.NegotiationContext ctx = extractContentContext(negMeta); + if (ctx == null) { return meta; } String decision = clarification.strip().toLowerCase(java.util.Locale.ROOT); - try { - Map payload = - content.continueNegotiation( - typesCtx, - decision.startsWith("reject:") ? NegotiationStatus.REJECTED : NegotiationStatus.AGREED, - mc.promptText()); - if (payload != null && !payload.isEmpty()) { - meta.putAll(payload); - // The continueNegotiation payload nests the context under the Negotiation-T NL - // key ({message, negotiationType, negotiationId, round, status}). Surface it as - // the top-level negotiation_context key too, so the receiving agent's context - // parser finds a full stateful map in one place. - for (var entry : payload.entrySet()) { - if (entry.getKey().contains("Negotiation-T") - && !entry.getKey().contains("DATA-NEGOTIATION-T") - && entry.getValue() instanceof Map data) { - meta.put("negotiation_context", new java.util.LinkedHashMap<>(castMap(data))); - break; - } - } - } - } catch (Exception ce) { - log.debug( - "[Negotiation] continueNegotiation unavailable for '{}': {}", - agentName, - ce.getMessage()); - } + boolean terminal = + decision.startsWith("reject:") || decision.startsWith("abort:"); + net.openan.a2at.sdk.core.model.NegotiationContext next = + terminal ? ctx : ctx.nextRound(); + meta.put("negotiation_context", A2ATContentFacade.contextPayload(next)); + log.info( + "[Negotiation] Context advanced for '{}' id={} round={} -> {}", + agentName, + next.id(), + ctx.round(), + terminal ? "terminal" : next.round()); return meta; } - /** Extracts the SDK state-machine context from the negotiation receive payload, or null. */ - private static NegotiationContext extractTypesContext(Map negMeta) { - Object ctxObj = negMeta.get(A2ATExtension.NEGOTIATION_CONTEXT_META_KEY); - if (!(ctxObj instanceof Map rr)) { - return null; - } - Object innerCtx = rr.get("context"); - if (!(innerCtx instanceof Map contextMap)) { - return null; - } - try { - @SuppressWarnings("unchecked") - NegotiationContext typesCtx = - NegotiationPayloadMapper.contextFromMap((Map) contextMap); - return typesCtx; - } catch (Exception e) { - return null; - } - } - /** - * Extracts the SDK-carried negotiation context (id/round/maxRounds) from the received - * metadata's {@code negotiationContext} key, or null when absent. Falls back to the - * state-machine context payload ({@code negotiation_context} key, either the raw - * startNegotiation context map or the receiveNegotiation result) before giving up. + * Extracts the negotiation session context (id/round/maxRounds) from the received + * metadata. Primary source: the engine's {@code negotiation_context} key (the wire + * serialization produced by {@link A2ATContentFacade#contextPayload}); fallback: the SDK + * content-layer's {@code negotiationContext} key ({@code buildMetadataContent} embeds + * {@code id} only — the missing rounds default). Returns null when neither is usable. */ private static net.openan.a2at.sdk.core.model.NegotiationContext extractContentContext( Map negMeta) { + Object stateful = negMeta.get(A2ATExtension.NEGOTIATION_CONTEXT_META_KEY); + if (!(stateful instanceof Map stateMap)) { + // The propose metadata's negotiation_context key (engine-external convention shared + // with the samples; mirrors the SDK demo constants). + stateful = negMeta.get("negotiation_context"); + } + if (stateful instanceof Map stateMap2) { + net.openan.a2at.sdk.core.model.NegotiationContext ctx = + A2ATContentFacade.contextFromMap(stateMap2); + if (ctx != null) { + return ctx; + } + } Object raw = negMeta.get(net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); if (raw instanceof Map contextMap) { Object id = contextMap.get("id"); - Object round = contextMap.get("round"); - Object maxRounds = contextMap.get("maxRounds"); - if (id instanceof String s && round instanceof Number r && maxRounds instanceof Number m) { + if (id instanceof String s) { try { return new net.openan.a2at.sdk.core.model.NegotiationContext( - s, r.intValue(), m.intValue()); + s, + 1, + net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); } catch (IllegalArgumentException ignored) { - // fall through to the state-machine fallback + // fall through } } } - // State-machine context: the negotiation_context key holds either the raw context map - // (negotiationType/negotiationId/round/status) or the receiveNegotiation result wrapping - // it under "context". - Object stateful = negMeta.get(A2ATExtension.NEGOTIATION_CONTEXT_META_KEY); - Map statefulMap = null; - if (stateful instanceof Map m) { - Object inner = m.get("context"); - statefulMap = - inner instanceof Map innerMap - ? castMap(innerMap) - : castMap(m); - } else { - // The propose metadata's negotiation_context key (engine-external convention shared - // with the samples; mirrors the SDK demo constants). - Object rawStateCtx = negMeta.get("negotiation_context"); - if (rawStateCtx instanceof Map m2) { - statefulMap = castMap(m2); - } - } - if (statefulMap != null) { - NegotiationContext typesCtx = parseTypesContext(statefulMap); - if (typesCtx != null) { - return new net.openan.a2at.sdk.core.model.NegotiationContext( - typesCtx.negotiationId(), - typesCtx.round(), - net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); - } - } - NegotiationContext typesCtx = extractTypesContext(negMeta); - if (typesCtx == null) { - return null; - } - return new net.openan.a2at.sdk.core.model.NegotiationContext( - typesCtx.negotiationId(), - typesCtx.round(), - net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); - } - - @SuppressWarnings("unchecked") - private static Map castMap(Map map) { - return (Map) map; - } - - /** Parses a state-machine context map ({negotiationType, negotiationId, round, status}). */ - private static NegotiationContext parseTypesContext(Map contextMap) { - try { - return NegotiationPayloadMapper.contextFromMap(contextMap); - } catch (Exception e) { - return null; - } + return null; } private static net.openan.a2at.sdk.core.model.NegotiationContext requireContext( diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java index 0f7bd745..841dbda9 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java @@ -152,55 +152,35 @@ private static String getAgentName(AgentCard agentCard) { } /** - * Parses the SDK-carried negotiation context for the validate APIs. + * Parses the negotiation session context for the validate APIs. * - *

      Primary source: the {@code negotiationContext} metadata key (id/round/maxRounds). The - * SDK's {@code buildMetadataContent} only embeds {@code id} there, so as a fallback the - * stateful context ({@code negotiation_context} key, carrying negotiationId/round) is - * synthesized into a full context. Returns null when neither is usable; the SDK validate - * APIs treat a null context as not-a-negotiation-message. + *

      Primary source: the engine's {@code negotiation_context} key (id/round/maxRounds — + * the wire serialization from {@code A2ATContentFacade.contextPayload}). Fallback: the + * SDK content-layer's {@code negotiationContext} key, whose {@code buildMetadataContent} + * embeds {@code id} only (round defaults to 1). Returns null when neither is usable; the + * SDK validate APIs treat a null context as not-a-negotiation-message. */ private static net.openan.a2at.sdk.core.model.NegotiationContext parseNegotiationContext( Map metadata) { - Object raw = metadata.get(net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); - if (raw instanceof Map contextMap) { - try { - Object id = contextMap.get("id"); - Object round = contextMap.get("round"); - Object maxRounds = contextMap.get("maxRounds"); - if (id instanceof String s - && round instanceof Number r - && maxRounds instanceof Number m) { - return new net.openan.a2at.sdk.core.model.NegotiationContext( - s, r.intValue(), m.intValue()); - } - } catch (IllegalArgumentException e) { - log.debug("[Negotiation-T] Malformed negotiationContext metadata: {}", e.getMessage()); - } - } - // Fallback: synthesize from the stateful negotiation_context map. Two shapes: - // 1) raw context map {negotiationType, negotiationId, round, status} (from the propose - // metadata), 2) the receiveNegotiation result {needResponse, message, context: {...}} - // stored under the same key after receiveNegotiation succeeds. Object stateful = metadata.get("negotiation_context"); - if (stateful instanceof Map outer) { - Map stateMap = outer; - Object inner = outer.get("context"); - if (inner instanceof Map innerMap && innerMap.get("negotiationId") != null) { - stateMap = innerMap; + if (stateful instanceof Map stateMap) { + net.openan.a2at.sdk.core.model.NegotiationContext ctx = + A2ATContentFacade.contextFromMap(stateMap); + if (ctx != null) { + return ctx; } - Object id = stateMap.get("negotiationId") != null - ? stateMap.get("negotiationId") - : stateMap.get("id"); - Object round = stateMap.get("round"); - if (id instanceof String s && round instanceof Number r) { + } + Object raw = metadata.get(net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + if (raw instanceof Map contextMap) { + Object id = contextMap.get("id"); + if (id instanceof String s) { try { return new net.openan.a2at.sdk.core.model.NegotiationContext( s, - r.intValue(), + 1, net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); - } catch (IllegalArgumentException ignored) { - // fall through + } catch (IllegalArgumentException e) { + log.debug("[Negotiation-T] Malformed negotiationContext metadata: {}", e.getMessage()); } } } @@ -265,85 +245,53 @@ public CompletableFuture afterReceive( result.getMetadata() != null ? new HashMap<>(result.getMetadata()) : new HashMap<>(); - try { - Map contextMap = extractNegotiationContext(metadata); - if (contextMap == null) { - contextMap = metadata; - } - // The negotiation message travels in metadata under the Negotiation-T - // URI key (the parts text is a short placeholder), per the SDK demo - // convention. Validate/receive against that full propose text. - String proposeText = extractNegotiationText(metadata); - if (proposeText == null || proposeText.isEmpty()) { - proposeText = result.getText(); - } - Map receiveResult = - a2atClient.receiveNegotiation(proposeText, contextMap); - { - Map rr = receiveResult; - Boolean needResponse = (Boolean) rr.get("needResponse"); - if (Boolean.TRUE.equals(needResponse)) { - String negMsg = (String) rr.getOrDefault("message", ""); - metadata.put(A2ATExtension.NEGOTIATION_MESSAGE_META_KEY, negMsg); - metadata.put(A2ATExtension.NEGOTIATION_CONTEXT_META_KEY, rr); + // The negotiation message travels in metadata under the Negotiation-T + // URI key (the parts text is a short placeholder), per the SDK demo + // convention. + String proposeText = extractNegotiationText(metadata); + if (proposeText == null || proposeText.isEmpty()) { + proposeText = result.getText(); + } + // The agent's reply already signals a pending negotiation: INPUT_REQUIRED + // task state plus a negotiation message in metadata. The engine answers + // directly (no SDK state machine involved — the content layer is stateless + // and the engine owns the session context). + if (proposeText != null && !proposeText.isEmpty()) { + metadata.put(A2ATExtension.NEGOTIATION_MESSAGE_META_KEY, proposeText); + log.info( + "[Negotiation-T] Agent '{}' requested negotiation: {}", + getAgentName(agentCard), + proposeText); + // Validate the received negotiation message and extract params against + // the propose template matching the declared type. The context travels + // in the negotiation_context metadata key; without it the SDK treats + // the text as a non-negotiation message. + TemplateUri proposeUri = proposeTemplateFor(extractNegotiationContext(metadata)); + net.openan.a2at.sdk.core.model.NegotiationContext context = + parseNegotiationContext(metadata); + try { + FilledParamData paramData = + a2atClient.validateProposePromptAndDataFilling( + proposeText, context, negotiationParamSchema(), proposeUri); + if (paramData != null + && paramData.data() != null + && !paramData.data().isEmpty()) { + metadata.put( + A2ATExtension.NEGOTIATION_PARAMS_META_KEY, paramData.data()); log.info( - "[Negotiation-T] Agent '{}' requested negotiation: {}", + "[Negotiation-T] Validated negotiation message" + + " for '{}' ({}), extracted {} params", getAgentName(agentCard), - negMsg); - // Validate the received negotiation message and extract params - // against the propose template matching the declared type. The - // SDK contract carries the context in the negotiationContext - // metadata key; without it the SDK treats the text as a - // non-negotiation message. - TemplateUri proposeUri = proposeTemplateFor(contextMap); - net.openan.a2at.sdk.core.model.NegotiationContext context = - parseNegotiationContext(metadata); - try { - FilledParamData paramData = a2atClient.validateProposePromptAndDataFilling( - proposeText, context, negotiationParamSchema(), proposeUri); - if (paramData != null && paramData.data() != null - && !paramData.data().isEmpty()) { - metadata.put( - A2ATExtension.NEGOTIATION_PARAMS_META_KEY, - paramData.data()); - log.info( - "[Negotiation-T] Validated negotiation message" - + " for '{}' ({}), extracted {} params", - getAgentName(agentCard), - proposeUri.uri(), - paramData.data().keySet()); - } - } catch (Exception ve) { - log.warn( - "[Negotiation-T] validateProposePromptAndDataFilling" - + " failed for '{}' ({}): {}", - getAgentName(agentCard), - proposeUri.uri(), - ve.getMessage()); - } + proposeUri.uri(), + paramData.data().keySet()); } - } - } catch (Exception e) { - if (e.getMessage() != null - && e.getMessage().contains("Unsupported negotiation type")) { - log.debug( - "[Negotiation-T] SDK receiveNegotiation has no handler for" - + " '{}' ({}); using direct extraction", - getAgentName(agentCard), - e.getMessage()); - } else { + } catch (Exception ve) { log.warn( - "[Negotiation-T] receiveNegotiation error for '{}': {}; using direct extraction", - getAgentName(agentCard), - e.getMessage()); - } - String fallbackText = extractNegotiationText(metadata); - if (fallbackText != null && !fallbackText.isEmpty()) { - metadata.put(A2ATExtension.NEGOTIATION_MESSAGE_META_KEY, fallbackText); - log.info( - "[Negotiation-T] Agent '{}' requested negotiation (direct): {}", + "[Negotiation-T] validateProposePromptAndDataFilling" + + " failed for '{}' ({}): {}", getAgentName(agentCard), - fallbackText); + proposeUri.uri(), + ve.getMessage()); } } result.setMetadata(metadata); From aa4dae14c5ff984832716f51d380dfba00b5a314 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Tue, 25 Aug 2026 20:40:46 +0800 Subject: [PATCH 13/49] chore: pin unreleased A2A-T SDK revision Signed-off-by: Zhoujie628 --- .github/workflows/ci.yml | 45 +++++++++++++++++++++++++++++++++++----- pom.xml | 4 +++- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e19fdbbe..12748438 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,10 @@ name: CI +env: + A2AT_SDK_REPOSITORY: Zhoujie628/a2a-t-sdk-java + A2AT_SDK_REF: 0ef79d37f49a9b7a2dbe16b6d9fd1ccdb6d9538d + A2AT_SDK_VERSION: 1.0.0-0ef79d3 + on: push: branches: [main] @@ -15,6 +20,13 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Checkout pinned unreleased A2A-T SDK + uses: actions/checkout@v4 + with: + repository: ${{ env.A2AT_SDK_REPOSITORY }} + ref: ${{ env.A2AT_SDK_REF }} + path: .dependencies/a2a-t-sdk-java + - name: Set up JDK 17 uses: actions/setup-java@v4 with: @@ -22,6 +34,15 @@ jobs: distribution: 'temurin' cache: 'maven' + - name: Install pinned A2A-T SDK components + working-directory: .dependencies/a2a-t-sdk-java + run: >- + mvn -B + -Drevision=${A2AT_SDK_VERSION} + -DskipTests + -pl a2a-t-client,a2a-t-server + -am install + - name: Build and test core modules run: | mvn -B clean verify \ @@ -39,7 +60,7 @@ jobs: spring-boot-starter/target/surefire-reports/ retention-days: 7 - samples-compileilation-check: + samples-compilation-check: runs-on: ubuntu-latest name: Samples Compilation Check (optional) continue-on-error: true @@ -48,6 +69,13 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Checkout pinned unreleased A2A-T SDK + uses: actions/checkout@v4 + with: + repository: ${{ env.A2AT_SDK_REPOSITORY }} + ref: ${{ env.A2AT_SDK_REF }} + path: .dependencies/a2a-t-sdk-java + - name: Set up JDK 17 uses: actions/setup-java@v4 with: @@ -55,7 +83,14 @@ jobs: distribution: 'temurin' cache: 'maven' - - name: Check samples compilation - run: | - echo "::notice::Samples module depends on Eastcom SDK (not on Maven Central). Skipping compilation check in CI." - echo "::notice::Local verification: mvn -pl samples -am compile" + - name: Install pinned A2A-T SDK components + working-directory: .dependencies/a2a-t-sdk-java + run: >- + mvn -B + -Drevision=${A2AT_SDK_VERSION} + -DskipTests + -pl a2a-t-client,a2a-t-server + -am install + + - name: Build and test samples + run: mvn -B -pl samples -am verify diff --git a/pom.xml b/pom.xml index 5cb24a24..ceda04c1 100644 --- a/pom.xml +++ b/pom.xml @@ -52,7 +52,9 @@ 17 UTF-8 1.0.0.Beta1 - 1.0.0 + + 1.0.0-0ef79d3 + 0ef79d37f49a9b7a2dbe16b6d9fd1ccdb6d9538d 1.18.38 2.21.1 2.0.17 From e85746dd3ee845869a9bf36ec882ff0bb89180e9 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Tue, 25 Aug 2026 20:41:14 +0800 Subject: [PATCH 14/49] docs: document pinned A2A-T SDK setup Signed-off-by: Zhoujie628 --- docs/zh/A2AT-SDK-DEPENDENCY.md | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/zh/A2AT-SDK-DEPENDENCY.md diff --git a/docs/zh/A2AT-SDK-DEPENDENCY.md b/docs/zh/A2AT-SDK-DEPENDENCY.md new file mode 100644 index 00000000..0c44319f --- /dev/null +++ b/docs/zh/A2AT-SDK-DEPENDENCY.md @@ -0,0 +1,61 @@ +# 未发布 A2A-T SDK 依赖说明 + +当前执行引擎不是按“文档里写的旧接口”兼容,而是严格按下列 A2A-T SDK 源码版本编译和测试: + +- 仓库:`Zhoujie628/a2a-t-sdk-java` +- Git commit:`0ef79d37f49a9b7a2dbe16b6d9fd1ccdb6d9538d` +- 本地 Maven 版本:`1.0.0-0ef79d3`(精确提交构建的不可变本地版本) +- 使用组件:`a2a-t-client`、`a2a-t-server` 及其传递依赖 + +## 已知上游生命周期缺口 + +该固定提交中的 `A2ATClient`、`A2ATServer` 和 `LLMClient` 尚未实现 +`AutoCloseable`,因此调用过 OpenAI 兼容 LLM 后,底层 OpenAI Java 客户端的 +`DefaultSleeper` 定时线程无法由工作流引擎通过公共 API 关闭。`SpringSpnDemo` 的 +Task-T、Authorization-T、Notification-T 和直连业务链路均可正常完成, +但一次性关闭内嵌 Tomcat 时会提示这一条上游线程生命周期告警。 + +上游 SDK 应在新提交中补充幂等 `close()`,由 `A2ATClient/A2ATServer` 级联关闭其拥有的 +LLM provider;执行引擎届时应升级到该新提交对应的新 Maven 版本。禁止在不改变版本号的 +情况下覆盖 `1.0.0-0ef79d3`,否则 IDEA 与 CI 将得到不可复现的同坐标异内容制品。 + +该 SDK 尚未发布到 Maven Central。引擎刻意不再复用含义不明确的本地 `1.0.0`,以免 Maven +静默选中带有 `startNegotiation` 等废弃接口的旧 jar。 + +## Windows / PowerShell 安装 + +```powershell +git clone https://github.com/Zhoujie628/a2a-t-sdk-java.git +Set-Location a2a-t-sdk-java +git checkout 0ef79d37f49a9b7a2dbe16b6d9fd1ccdb6d9538d +mvn -B "-Drevision=1.0.0-0ef79d3" -DskipTests ` + -pl a2a-t-client,a2a-t-server -am install +``` + +随后在执行引擎目录执行: + +```powershell +mvn -B clean verify +``` + +## 升级 A2A-T SDK + +升级时必须同步修改三处,且用全量测试证明一致性: + +1. 根 `pom.xml` 的 `a2a.t.sdk.version` 和 `a2a.t.sdk.git.revision`; +2. `.github/workflows/ci.yml` 的 `A2AT_SDK_REF` 和 `A2AT_SDK_VERSION`; +3. 本文及 README 的安装命令。 + +禁止只覆盖本地同版本 jar。新版本至少要验证 Task-T 生成/校验、无状态 Negotiation-T +metadata、Authorization-T、Notification-T 长连接,以及直连端到端路径。 + +## 当前内容校验兼容层 + +锁定版本的 `TaskPromptRenderer` 会把 slot 模板折叠为简洁自然语言,但 SDK 自带的 +`content_validation` 提示词仍要求输出复现完整模板结构;在当前示例模型上,这会使 SDK +官方 Authorization-T 成功样例也被误判为缺字段。samples 模块通过同名 classpath 资源 +覆盖 `prompt_resources/prompts/content_validation/{zh-CN,en-US}`,仍调用 SDK 的 +`validate*PromptAndDataFilling`,只把语义判定标准校准为 renderer 的真实输出契约。 + +升级 SDK 时必须先运行其官方 Authorization-T/Notification-T 样例;若上游已统一 renderer +与 validator,应删除该覆盖并执行本仓库全量测试及直连模式的端到端验证。 From a4c3de67f9538b914ad1cbe125dece2188fa268b Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Tue, 25 Aug 2026 20:41:33 +0800 Subject: [PATCH 15/49] feat(engine): align protocol clients with current A2A-T SDK Signed-off-by: Zhoujie628 --- .../engine/client/A2ATContentFacade.java | 46 +-- .../workflow/engine/client/A2ATExtension.java | 24 +- .../workflow/engine/client/A2ATransport.java | 374 ++++++++++++++---- .../engine/client/AgentAuthManager.java | 56 ++- .../engine/client/AgentCredentialService.java | 88 +++-- .../engine/client/ClientEventMapper.java | 75 ++++ .../engine/client/CredentialCrypto.java | 30 +- .../client/CredentialHttpTransport.java | 80 ++++ .../client/DefaultA2AJavaClientRuntime.java | 22 +- .../engine/client/DefaultExtensionSender.java | 216 ++++++---- .../client/DefaultWorkflowEngineClient.java | 167 +++++--- .../workflow/engine/client/EnvFileLoader.java | 57 +-- .../engine/client/ExtensionHandler.java | 4 +- .../engine/client/ExtensionRegistry.java | 13 +- .../engine/client/ExtensionSender.java | 65 +-- .../engine/client/NegotiationTHandler.java | 139 +++---- .../client/NotificationSubscription.java | 132 +++++++ .../engine/client/ProtocolLogger.java | 20 +- .../workflow/engine/client/TaskTHandler.java | 108 +++-- .../engine/client/WorkflowEngineClient.java | 26 +- .../client/WorkflowEngineClientConfig.java | 56 ++- 21 files changed, 1319 insertions(+), 479 deletions(-) create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientEventMapper.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHttpTransport.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/NotificationSubscription.java diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java index 54a39d16..aec5e0d7 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java @@ -66,12 +66,12 @@ public A2ATClient client() { } // ------------------------------------------------------------------ - // Message generation: propose phase (from typed data, no LLM) + // Task/Authorization/Notification generation from schema-aware data (may use LLM) // ------------------------------------------------------------------ /** - * Renders a Task-T prompt from structured data and a schema. Deterministic slot filling, no - * scenario recognition, no LLM call. + * Renders a Task-T prompt from structured data and a schema. Scenario recognition is bypassed; + * the SDK's schema-aware slot extractor may still invoke the configured LLM. * * @param data structured task input (string-to-object map) * @param schema schema describing the meaning of each data field; must not be empty @@ -84,8 +84,8 @@ public MetadataContent generateTaskFromData( } /** - * Renders an Authorization-T prompt from structured data and a schema. Deterministic, no LLM - * call. + * Renders an Authorization-T prompt from structured data and a schema. The SDK's schema-aware + * slot extractor may invoke the configured LLM. * * @param data structured authorization input * @param schema schema describing the data fields; must not be empty @@ -99,8 +99,8 @@ public MetadataContent generateAuthFromData( } /** - * Renders a Notification-T prompt from structured data and a schema. Deterministic, no LLM - * call. + * Renders a Notification-T prompt from structured data and a schema. The SDK's schema-aware + * slot extractor may invoke the configured LLM. * * @param data structured notification input * @param schema schema describing the data fields; must not be empty @@ -228,7 +228,7 @@ public MetadataContent generateAbortFromText( /** * Builds the transport serialization of a negotiation session context: the {@code - * negotiation_context} metadata value carrying {@code id} / {@code round} / {@code + * negotiationContext} metadata value carrying {@code id} / {@code round} / {@code * maxRounds}. * *

      The content layer is stateless by design — session identity and round tracking stay @@ -237,7 +237,7 @@ public MetadataContent generateAbortFromText( * NegotiationContext.nextRound()}. * * @param context the engine-held session context - * @return wire map for the {@code negotiation_context} metadata key + * @return wire map for the canonical {@code negotiationContext} metadata key */ public static Map contextPayload(NegotiationContext context) { Map payload = new LinkedHashMap<>(); @@ -248,33 +248,25 @@ public static Map contextPayload(NegotiationContext context) { } /** - * Parses a negotiation session context from its {@code negotiation_context} wire - * serialization. Accepts both the engine format ({@code id}/{@code round}/{@code - * maxRounds}) and the legacy state-machine format ({@code negotiationId}/{@code round}, - * optionally nested under a {@code context} key from removed receive payloads) for - * cross-version tolerance. Returns null when the map carries no usable identity. + * Parses a negotiation session context from its canonical {@code negotiationContext} wire + * serialization ({@code id}/{@code round}/{@code maxRounds}). * - * @param map the metadata value under the {@code negotiation_context} key + * @param map the metadata value under the {@code negotiationContext} key * @return the session context, or null when malformed */ public static NegotiationContext contextFromMap(Map map) { if (map == null) { return null; } - Map effective = map; - Object inner = map.get("context"); - if (inner instanceof Map innerMap && innerMap.get("id") != null) { - effective = innerMap; - } - Object id = effective.get("id") != null ? effective.get("id") : effective.get("negotiationId"); - Object round = effective.get("round"); + Object id = map.get("id"); + Object round = map.get("round"); if (id instanceof String s && round instanceof Number r && r.intValue() > 0) { - Object maxRounds = effective.get("maxRounds"); - int max = maxRounds instanceof Number m && m.intValue() > 0 - ? m.intValue() - : NegotiationContext.DEFAULT_MAX_ROUNDS; + Object maxRounds = map.get("maxRounds"); + if (!(maxRounds instanceof Number m) || m.intValue() <= 0) { + return null; + } try { - return new NegotiationContext(s, r.intValue(), max); + return new NegotiationContext(s, r.intValue(), m.intValue()); } catch (IllegalArgumentException ignored) { // fall through } 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 12e6e185..08fa8d7e 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 @@ -32,34 +32,28 @@ *

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

      {@code DATA-NEGOTIATION-T/v1} is intentionally absent -- it is an SDK-internal metadata key - * for negotiation context, not a user-declared extension. */ public enum A2ATExtension { /** Structured task prompt. Handled automatically during workflow execution. */ - TASK_T("https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"), + TASK_T(ExtensionUriConstants.TASK_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( - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1"), + AUTHORIZATION_T(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI), /** Result notification subscription. Pre-positioned before workflow starts. */ - NOTIFICATION_T( - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"); + NOTIFICATION_T(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); - /** - * Internal metadata key under which {@code NegotiationTHandler} stores the SDK receive - * payload (needResponse/message/context). Engine-internal, not an A2A-T protocol key. - */ - public static final String NEGOTIATION_CONTEXT_META_KEY = "negotiation_context"; + /** Canonical SDK metadata key carrying {@code id}/{@code round}/{@code maxRounds}. */ + public static final String NEGOTIATION_CONTEXT_META_KEY = + net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY; /** * Internal metadata key under which {@code NegotiationTHandler} stores the extracted @@ -77,7 +71,7 @@ public enum A2ATExtension { * Engine-internal metadata key carrying structured task input (a string-to-object map) for * the SDK's {@code generateTaskPromptFromDataWithSchema} pipeline. When present in the * caller-supplied metadata, {@code TaskTHandler} renders the Task-T prompt from the data - * deterministically instead of running scenario recognition on the message text. Consumed + * through the schema-aware path instead of running scenario recognition on the message text. Consumed * (removed) before the A2A message goes on the wire; the rendered prompt travels under the * Task-T extension URI. */ diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java index e7aff1b3..462d04d1 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 @@ -41,20 +41,22 @@ import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.time.Duration; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; /** @@ -74,7 +76,7 @@ public class A2ATransport implements AutoCloseable { private static final Logger log = LoggerFactory.getLogger(A2ATransport.class); - private final Map cardMap = new ConcurrentHashMap<>(); + private volatile Map cardMap = Map.of(); private final A2AJavaClientRuntime a2aClientRuntime; private final AgentAuthManager authManager; private final AuthProvider authProvider; @@ -85,11 +87,15 @@ public class A2ATransport implements AutoCloseable { private final ExecutorService asyncExecutor; private final long notificationAckTimeoutSeconds; 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 @@ -120,30 +126,40 @@ public A2ATransport( return t; }, new ThreadPoolExecutor.CallerRunsPolicy()); + // Do not copy SDK .env values into global JVM properties. Each transport constructs its + // own SDK client from the explicit path, which keeps Task/Authorization/Notification + // channels independently configurable and prevents cross-instance credential leakage. + CredentialHttpTransport credentialHttpTransport = null; + String credentialEncryptionKey = null; if (config.getA2atEnvPath() != null) { - EnvFileLoader.loadToSystemProperties(java.nio.file.Path.of(config.getA2atEnvPath())); + credentialEncryptionKey = + EnvFileLoader.read(java.nio.file.Path.of(config.getA2atEnvPath())) + .get("A2AT_CRED_KEY"); } - java.net.http.HttpClient credentialHttpClient = null; if (config.getCredentialsConfigPath() != null || config.getCredentialsConfig() != null) { - credentialHttpClient = - JdkHttpClientFactory.create( + credentialHttpTransport = + CredentialHttpTransport.create( config.isSslVerify(), config.getCaCertsPath(), config.getClientCertPath(), config.getClientKeyPath(), config.getClientKeyPassword(), config.getCrlPath(), - Duration.ofSeconds(30), - null); + Duration.ofSeconds(30)); } if (config.getCredentialsConfigPath() != null) { this.authManager = new AgentAuthManager( - config.getCredentialsConfigPath(), credentialHttpClient); + config.getCredentialsConfigPath(), + credentialHttpTransport, + credentialEncryptionKey); } else if (config.getCredentialsConfig() != null) { this.authManager = - new AgentAuthManager(config.getCredentialsConfig(), credentialHttpClient); + new AgentAuthManager( + config.getCredentialsConfig(), + credentialHttpTransport, + credentialEncryptionKey); } else { this.authManager = new AgentAuthManager(); } @@ -154,11 +170,7 @@ public A2ATransport( new AuthProviderHeaderContributor(authProvider), new CredentialHeaderContributor(authManager, authProvider), new ExtensionHeaderContributor(authManager)); - for (AgentCard card : agentCards) { - if (!card.name().isEmpty()) { - cardMap.put(card.name(), card); - } - } + cardMap = java.util.Collections.unmodifiableMap(validatedCards); log.info( "[Transport] Initialized with {} agent(s), a2at={}", cardMap.size(), @@ -166,19 +178,78 @@ public A2ATransport( } public static String extractResponseText(Iterable events) { - StringBuilder text = new StringBuilder(); + // A2A clients commonly expose the same artifact three times: first as an artifact update, + // then in the Task snapshot attached to a status update, and finally in the terminal Task. + // Treat artifact updates as the authoritative stream and use snapshots/messages only as + // fallbacks. Blindly concatenating every representation corrupts workflow input. + Map streamedArtifacts = new LinkedHashMap<>(); + Map snapshotArtifacts = new LinkedHashMap<>(); + Set fallbackMessages = new LinkedHashSet<>(); for (ClientEvent event : events) { - if (event instanceof TaskEvent te) extractTextFromTask(te.getTask(), text); - else if (event instanceof TaskUpdateEvent tue) { - extractTextFromTask(tue.getTask(), text); - if (tue.getUpdateEvent() instanceof TaskArtifactUpdateEvent ae) - extractTextFromArtifact(ae.artifact(), text); + 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) - extractTextFromMessage(sue.status().message(), text); - } else if (event instanceof MessageEvent me) - extractTextFromMessage(me.getMessage(), text); + && sue.status().message() != null) { + addFallbackMessage(sue.status().message(), fallbackMessages); + } + } else if (event instanceof MessageEvent me) { + addFallbackMessage(me.getMessage(), fallbackMessages); + } + } + if (!streamedArtifacts.isEmpty()) { + return concatenate(streamedArtifacts.values()); } + if (!snapshotArtifacts.isEmpty()) { + return concatenate(snapshotArtifacts.values()); + } + return String.join("", fallbackMessages); + } + + private static void captureTaskArtifacts(Task task, Map artifacts) { + if (task == null || task.artifacts() == null) return; + for (Artifact artifact : task.artifacts()) { + artifacts.put(artifactKey(artifact, artifacts.size()), artifactText(artifact)); + } + } + + private static String artifactKey(Artifact artifact, int fallbackIndex) { + if (artifact.artifactId() != null && !artifact.artifactId().isBlank()) { + return artifact.artifactId(); + } + if (artifact.name() != null && !artifact.name().isBlank()) { + return artifact.name(); + } + return "artifact-" + fallbackIndex; + } + + private static String artifactText(Artifact artifact) { + StringBuilder text = new StringBuilder(); + extractTextFromArtifact(artifact, text); + return text.toString(); + } + + private static void addFallbackMessage(Message message, Set messages) { + StringBuilder text = new StringBuilder(); + extractTextFromMessage(message, text); + if (!text.isEmpty()) messages.add(text.toString()); + } + + private static String concatenate(Iterable values) { + StringBuilder text = new StringBuilder(); + for (Object value : values) text.append(value); return text.toString(); } @@ -268,26 +339,30 @@ public static Task extractResponseTask(Iterable events) { public static List extractExtensionUris(AgentCard agentCard) { List uris = new ArrayList<>(); + if (agentCard == null || agentCard.capabilities() == null) { + return uris; + } var extensions = agentCard.capabilities().extensions(); if (extensions == null) { return uris; } for (var ext : extensions) { + if (ext == null) continue; String uri = ext.uri(); - if (!uri.isEmpty()) uris.add(uri); + if (uri != null && !uri.isBlank()) uris.add(uri); } return uris; } - private A2ATClient initA2atClient(String a2atEnvPath) { + private A2ATClient initA2atClient(String a2atEnvPath) { if (a2atEnvPath == null || a2atEnvPath.isEmpty()) { return null; } try { return new A2ATClient(java.nio.file.Path.of(a2atEnvPath)); } catch (Exception e) { - log.warn("Failed to init A2ATClient: {}", e.getMessage()); - return null; + throw new IllegalArgumentException( + "Failed to initialize A2A-T client from " + a2atEnvPath, e); } } @@ -335,13 +410,32 @@ public String getContextId() { } public void updateAgentCards(List agentCards) { - cardMap.clear(); + Map validatedCards = validateAgentCards(agentCards); + cardMap = java.util.Collections.unmodifiableMap(validatedCards); + log.info("[Transport] Updated agent cards: {} agent(s)", cardMap.size()); + } + + private static Map validateAgentCards(List agentCards) { + java.util.Objects.requireNonNull(agentCards, "agentCards"); + Map validated = new java.util.LinkedHashMap<>(); for (AgentCard card : agentCards) { - if (!card.name().isEmpty()) { - cardMap.put(card.name(), card); + java.util.Objects.requireNonNull(card, "agentCard"); + if (card.name() == null || card.name().isBlank()) { + throw new IllegalArgumentException("AgentCard name must not be blank"); + } + if (card.capabilities() == null) { + throw new IllegalArgumentException( + "AgentCard capabilities are required: " + card.name()); + } + if (card.supportedInterfaces() == null || card.supportedInterfaces().isEmpty()) { + throw new IllegalArgumentException( + "AgentCard supportedInterfaces are required: " + card.name()); + } + if (validated.putIfAbsent(card.name(), card) != null) { + throw new IllegalArgumentException("Duplicate AgentCard name: " + card.name()); } } - log.info("[Transport] Updated agent cards: {} agent(s)", cardMap.size()); + return validated; } /** @@ -446,8 +540,8 @@ public void closeConversation(AgentCard agentCard, String contextId) { /** * Long-lived SSE stream for Notification-T subscription. Opens a daemon thread that keeps the * SSE response stream open. The eventSink callback processes events in real-time (subscribed - * ack + later recovery results). The returned future completes on the first event (subscription - * confirmed). + * ack + later recovery results). The returned future completes on the first event carrying a + * concrete task state; an artifact alone is application data, not a protocol acknowledgement. */ public CompletableFuture sendNotificationStream( AgentCard agentCard, @@ -456,11 +550,39 @@ public CompletableFuture sendNotificationStream( String contextId, Map metadata, Consumer eventSink) { + try { + return openNotificationStream( + agentCard, agentName, message, contextId, metadata, eventSink) + .acknowledgement(); + } catch (RuntimeException e) { + return CompletableFuture.failedFuture(e); + } + } + + /** Opens a long-lived Notification-T stream and returns its explicit lifecycle handle. */ + public NotificationSubscription openNotificationStream( + AgentCard agentCard, + String agentName, + String message, + String contextId, + Map metadata, + Consumer eventSink) { if (closed.get()) { - return CompletableFuture.failedFuture( - new IllegalStateException("A2A transport is closed")); + throw new IllegalStateException("A2A transport is closed"); } - CompletableFuture future = new CompletableFuture<>(); + AtomicReference streamThreadRef = new AtomicReference<>(); + NotificationSubscription subscription = + new NotificationSubscription( + agentName, + contextId, + () -> { + closeConversation(agentCard, contextId); + Thread running = streamThreadRef.get(); + if (running != null) { + running.interrupt(); + } + }); + notificationSubscriptions.add(subscription); Thread streamThread = new Thread( () -> { @@ -487,32 +609,45 @@ public CompletableFuture sendNotificationStream( "[Transport] Notification-T event from {}: {}", agentName, event.getClass().getSimpleName()); + subscription.recordEvent(); if (eventSink != null) { eventSink.accept(event); } - if (!future.isDone()) { - future.complete( - SendMessageResult.builder() - .text("Subscribed") - .taskState("TASK_STATE_WORKING") - .build()); + SendMessageResult acknowledgement = + eventResult(event); + if (isFailureState(acknowledgement.getTaskState())) { + subscription.failAcknowledgement( + new IllegalStateException( + "Notification-T subscription rejected by '" + + agentName + + "': state=" + + acknowledgement + .getTaskState() + + ", response=" + + acknowledgement.getText())); + } else if (isAcknowledgementState( + acknowledgement.getTaskState())) { + subscription.acknowledge(acknowledgement); } }, s -> log.info("[A2A] {}", s)); log.info( "[Transport] Notification-T stream closed for {}", agentName); - if (!future.isDone()) { - future.complete( + if (!subscription.acknowledgement().isDone()) { + subscription.acknowledge( SendMessageResult.builder() - .text("Stream closed") + .text("Stream closed before acknowledgement") .taskState("TASK_STATE_COMPLETED") .build()); } + subscription.completeStream(); } catch (Exception e) { String msg = e.getMessage() != null ? e.getMessage() : ""; boolean connectionClosed = - msg.contains("connection closed locally") + !subscription.isActive() + || causedByInterruption(e) + || msg.contains("connection closed locally") || msg.contains( "chunked transfer encoding, state: READING_LENGTH"); if (connectionClosed) { @@ -526,38 +661,73 @@ public CompletableFuture sendNotificationStream( e.getMessage(), e); } - if (!future.isDone()) { - future.completeExceptionally(e); + if (!subscription.acknowledgement().isDone()) { + subscription.failAcknowledgement(e); } + if (!connectionClosed && subscription.isActive()) { + subscription.failStream(e); + } else { + subscription.completeStream(); + } + } finally { + notificationSubscriptions.remove(subscription); + subscription.markStreamTerminated(); } }, "notif-t-" + agentName); streamThread.setDaemon(true); + streamThreadRef.set(streamThread); streamThread.start(); - return future.orTimeout(notificationAckTimeoutSeconds, TimeUnit.SECONDS) - .handle( - (result, error) -> { - if (error == null) return result; - Throwable cause = unwrapCompletionError(error); - if (!(cause instanceof TimeoutException)) { - throw new CompletionException(cause); - } - log.warn( - "[Transport] Notification-T subscription: no event in {}s, assuming active (stream stays open)", - notificationAckTimeoutSeconds); - return SendMessageResult.builder() - .text("Subscribed (no-ack)") - .taskState("TASK_STATE_WORKING") - .build(); - }); + CompletableFuture.runAsync( + () -> { + if (subscription.isActive() + && !subscription.acknowledgement().isDone()) { + log.warn( + "[Transport] Notification-T subscription: no event in {}s, assuming active (stream stays open)", + notificationAckTimeoutSeconds); + subscription.acknowledge( + SendMessageResult.builder() + .text("Subscribed (no-ack)") + .taskState("TASK_STATE_WORKING") + .build()); + } + }, + CompletableFuture.delayedExecutor( + notificationAckTimeoutSeconds, TimeUnit.SECONDS)); + return subscription; } - private static Throwable unwrapCompletionError(Throwable error) { + private static SendMessageResult eventResult(ClientEvent event) { + List eventList = List.of(event); + return SendMessageResult.builder() + .text(extractResponseText(eventList)) + .task(extractResponseTask(eventList)) + .metadata(extractResponseMetadata(eventList)) + .taskState(extractResponseTaskState(eventList)) + .build(); + } + + private static boolean isFailureState(String state) { + if (state == null) return false; + return state.contains("FAILED") + || state.contains("CANCELED") + || state.contains("CANCELLED") + || state.contains("REJECTED"); + } + + static boolean isAcknowledgementState(String state) { + return state != null && !state.isBlank(); + } + + private static boolean causedByInterruption(Throwable error) { Throwable current = error; - while (current instanceof CompletionException && current.getCause() != null) { + while (current != null) { + if (current instanceof InterruptedException) { + return true; + } current = current.getCause(); } - return current; + return false; } private MessageSendParams buildMessageSendParams( @@ -605,7 +775,7 @@ public CompletableFuture getTask( .text(text.toString()) .task(task) .metadata(metadata) - .taskState(task.status().state().name()) + .taskState(taskState(task)) .build(); } catch (Exception e) { throw new RuntimeException("getTask failed for " + agentName + ": " + e.getMessage(), e); @@ -624,10 +794,13 @@ public CompletableFuture cancelTask( 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) - .taskState(task.status().state().name()) + .metadata(metadata) + .taskState(taskState(task)) .build(); } catch (Exception e) { throw new RuntimeException("cancelTask failed for " + agentName + ": " + e.getMessage(), e); @@ -645,15 +818,36 @@ public CompletableFuture subscribeToTask( return a2aClientRuntime.subscribeToTask(agentCard, taskId, ctx, event -> { if (eventSink != null) { - try { eventSink.accept(event); } catch (Exception ignored) {} + try { + eventSink.accept(event); + } catch (RuntimeException callbackError) { + log.warn( + "[Transport] Task subscription callback failed for {}: {}", + agentName, + callbackError.getMessage(), + callbackError); + } } }); } + 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)) { @@ -669,4 +863,38 @@ public void close() { log.warn("[Transport] Runtime close failed: {}", e.getMessage(), e); } } + + private void awaitNotificationShutdown(List subscriptions) { + if (subscriptions.isEmpty()) { + return; + } + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + int terminated = 0; + for (NotificationSubscription subscription : subscriptions) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + break; + } + try { + subscription.streamTermination().get(remaining, TimeUnit.NANOSECONDS); + terminated++; + } catch (java.util.concurrent.TimeoutException e) { + break; + } catch (java.util.concurrent.ExecutionException e) { + terminated++; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + if (terminated == subscriptions.size()) { + log.info( + "[Transport] NOTIFICATION_SHUTDOWN_DONE subscriptions={}", terminated); + } else { + log.warn( + "[Transport] NOTIFICATION_SHUTDOWN_TIMEOUT terminated={}, total={}, timeoutSeconds=2", + terminated, + subscriptions.size()); + } + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java index 30c9f6dd..febad9db 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 @@ -47,6 +47,8 @@ class AgentAuthManager { private final Map>> config; private final HttpClient credentialHttpClient; + private final CredentialHttpTransport credentialHttpTransport; + private final String credentialEncryptionKey; private final Map services = new ConcurrentHashMap<>(); /** Create with a config map (agent name -> scheme name -> scheme config). */ @@ -58,9 +60,33 @@ public AgentAuthManager(Map>> config) { public AgentAuthManager( Map>> config, HttpClient credentialHttpClient) { + this(config, credentialHttpClient, null); + } + + AgentAuthManager( + Map>> config, + HttpClient credentialHttpClient, + String credentialEncryptionKey) { + this(config, credentialHttpClient, null, credentialEncryptionKey); + } + + AgentAuthManager( + Map>> config, + CredentialHttpTransport credentialHttpTransport, + String credentialEncryptionKey) { + this(config, null, credentialHttpTransport, credentialEncryptionKey); + } + + private AgentAuthManager( + Map>> config, + HttpClient credentialHttpClient, + CredentialHttpTransport credentialHttpTransport, + String credentialEncryptionKey) { this.config = config != null ? config : new HashMap<>(); this.credentialHttpClient = credentialHttpClient; - validateEncryptedCredentials(this.config); + this.credentialHttpTransport = credentialHttpTransport; + this.credentialEncryptionKey = credentialEncryptionKey; + validateEncryptedCredentials(this.config, credentialEncryptionKey); if (!this.config.isEmpty()) { log.info( "[Auth] Loaded credentials for {} agent(s): {}", @@ -79,6 +105,20 @@ public AgentAuthManager(String configPath, HttpClient credentialHttpClient) { this(loadFromFile(configPath), credentialHttpClient); } + AgentAuthManager( + String configPath, + HttpClient credentialHttpClient, + String credentialEncryptionKey) { + this(loadFromFile(configPath), credentialHttpClient, credentialEncryptionKey); + } + + AgentAuthManager( + String configPath, + CredentialHttpTransport credentialHttpTransport, + String credentialEncryptionKey) { + this(loadFromFile(configPath), credentialHttpTransport, credentialEncryptionKey); + } + /** Create with no credentials (auth disabled). */ public AgentAuthManager() { this(new HashMap<>()); @@ -147,7 +187,12 @@ public AgentCredentialService getService(String agentName) { return null; } log.info("[Auth] Created credential service for agent: {}", name); - return new AgentCredentialService(name, agentCreds, credentialHttpClient); + return new AgentCredentialService( + name, + agentCreds, + credentialHttpClient, + credentialHttpTransport, + credentialEncryptionKey); }); } @@ -156,12 +201,13 @@ public Map> getConfig(String agentName) { return config.get(agentName); } - private static void validateEncryptedCredentials(Map values) { + private static void validateEncryptedCredentials( + Map values, String credentialEncryptionKey) { for (Object value : values.values()) { if (value instanceof Map nested) { - validateEncryptedCredentials(nested); + validateEncryptedCredentials(nested, credentialEncryptionKey); } else if (value instanceof String text && text.startsWith("enc:")) { - CredentialCrypto.decryptIfNeeded(text); + CredentialCrypto.decryptIfNeeded(text, credentialEncryptionKey); } } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCredentialService.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCredentialService.java index 9aef5477..5dd629b0 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCredentialService.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCredentialService.java @@ -24,7 +24,7 @@ import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; import org.a2aproject.sdk.client.transport.spi.interceptors.auth.CredentialService; -import org.jetbrains.annotations.NotNull; +import org.a2aproject.sdk.util.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,21 +57,46 @@ class AgentCredentialService implements CredentialService { private final HttpClient httpClient; + private final CredentialHttpTransport httpTransport; + + private final String credentialEncryptionKey; + private final Map tokenCache = new ConcurrentHashMap<>(); public AgentCredentialService( String agentName, Map> schemeConfigs) { - this(agentName, schemeConfigs, null); + this(agentName, schemeConfigs, null, null); } public AgentCredentialService( String agentName, Map> schemeConfigs, HttpClient httpClient) { + this(agentName, schemeConfigs, httpClient, null); + } + + AgentCredentialService( + String agentName, + Map> schemeConfigs, + HttpClient httpClient, + String credentialEncryptionKey) { + this(agentName, schemeConfigs, httpClient, null, credentialEncryptionKey); + } + + AgentCredentialService( + String agentName, + Map> schemeConfigs, + HttpClient httpClient, + CredentialHttpTransport httpTransport, + String credentialEncryptionKey) { this.agentName = agentName; this.schemeConfigs = schemeConfigs != null ? schemeConfigs : Map.of(); + this.credentialEncryptionKey = credentialEncryptionKey; + this.httpTransport = httpTransport; if (httpClient != null) { this.httpClient = httpClient; + } else if (httpTransport != null) { + this.httpClient = null; } else { this.httpClient = JdkHttpClientFactory.create( @@ -98,17 +123,21 @@ private static String extractNestedValue(Map data, String path) } @SuppressWarnings("unchecked") - private static Map buildLoginBody(Map schemeCfg) { + private Map buildLoginBody(Map schemeCfg) { Map body = new HashMap<>(); Object requestFields = schemeCfg.get("request_fields"); if (requestFields instanceof Map) { for (var entry : ((Map) requestFields).entrySet()) { String val = entry.getValue() != null ? entry.getValue().toString() : ""; - body.put(entry.getKey(), CredentialCrypto.decryptIfNeeded(val)); + body.put( + entry.getKey(), + CredentialCrypto.decryptIfNeeded(val, credentialEncryptionKey)); } } else { String username = (String) schemeCfg.get("username"); - String password = CredentialCrypto.decryptIfNeeded((String) schemeCfg.get("password")); + String password = + CredentialCrypto.decryptIfNeeded( + (String) schemeCfg.get("password"), credentialEncryptionKey); if (username == null || password == null) return body; body.put(schemeCfg.getOrDefault("username_field", "username").toString(), username); body.put(schemeCfg.getOrDefault("password_field", "password").toString(), password); @@ -191,7 +220,7 @@ private String login(Map schemeCfg) { method, loginUrl, sanitizeBody(body)); - HttpRequest.BodyPublisher bodyPublisher; + String requestBody; if ("application/x-www-form-urlencoded".equals(contentType)) { StringBuilder form = new StringBuilder(); for (Map.Entry e : body.entrySet()) { @@ -202,35 +231,25 @@ private String login(Map schemeCfg) { .append("=") .append(encodeForm(String.valueOf(e.getValue()))); } - bodyPublisher = HttpRequest.BodyPublishers.ofString(form.toString()); + requestBody = form.toString(); } else { - bodyPublisher = - HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)); + requestBody = mapper.writeValueAsString(body); + } + if (httpTransport != null) { + CredentialHttpTransport.Response response = + httpTransport.send(URI.create(loginUrl), method, contentType, requestBody); + return parseTokenResponse(tokenField, response.statusCode(), response.body()); } HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(loginUrl)) .header("Content-Type", contentType) - .method(method, bodyPublisher) + .method(method, HttpRequest.BodyPublishers.ofString(requestBody)) .timeout(Duration.ofSeconds(30)) .build(); HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (resp.statusCode() >= 400) { - log.error("[Auth] Login failed: agent={}, status={}", agentName, resp.statusCode()); - return null; - } - Map data = - mapper.readValue(resp.body(), new TypeReference>() {}); - String token = extractNestedValue(data, tokenField); - if (token == null) { - token = - (String) - data.getOrDefault( - "accessSession", - data.getOrDefault("access_token", data.get("token"))); - } - return token; + return parseTokenResponse(tokenField, resp.statusCode(), resp.body()); } catch (Exception e) { log.error( "[Auth] Login failed: agent={}, url={}, error={}", @@ -241,6 +260,25 @@ private String login(Map schemeCfg) { } } + private String parseTokenResponse(String tokenField, int status, String responseBody) + throws Exception { + if (status >= 400) { + log.error("[Auth] Login failed: agent={}, status={}", agentName, status); + return null; + } + Map data = + mapper.readValue(responseBody, new TypeReference>() {}); + String token = extractNestedValue(data, tokenField); + if (token == null) { + token = + (String) + data.getOrDefault( + "accessSession", + data.getOrDefault("access_token", data.get("token"))); + } + return token; + } + private static String encodeForm(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientEventMapper.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientEventMapper.java new file mode 100644 index 00000000..39997073 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientEventMapper.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; + +import java.util.HashMap; +import java.util.Map; + +/** Converts SDK events into the stable map exposed to integration callbacks. */ +public final class ClientEventMapper { + private ClientEventMapper() {} + + public static Map toMap(ClientEvent event, String agentName) { + Map data = new HashMap<>(); + data.put("agent", agentName); + if (event instanceof TaskEvent te) { + data.put("event_kind", "task"); + data.put("task_id", te.getTask().id()); + data.put("state", te.getTask().status().state().name()); + data.put("is_final", te.getTask().status().state().isFinal()); + StringBuilder text = new StringBuilder(); + A2ATransport.extractTextFromTask(te.getTask(), text); + if (!text.isEmpty()) data.put("text", text.toString()); + Map metadata = new HashMap<>(); + A2ATransport.mergeTaskMetadata(te.getTask(), metadata); + if (!metadata.isEmpty()) data.put("metadata", metadata); + } else if (event instanceof TaskUpdateEvent tue) { + data.put("task_id", tue.getTask().id()); + if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { + data.put("event_kind", "status"); + data.put("state", sue.status().state().name()); + data.put("is_final", sue.isFinal()); + StringBuilder text = new StringBuilder(); + A2ATransport.extractTextFromMessage(sue.status().message(), text); + if (!text.isEmpty()) data.put("text", text.toString()); + if (sue.metadata() != null && !sue.metadata().isEmpty()) { + data.put("metadata", sue.metadata()); + } + } else if (tue.getUpdateEvent() instanceof TaskArtifactUpdateEvent ae) { + data.put("event_kind", "artifact"); + data.put("artifact_id", ae.artifact().artifactId()); + data.put("artifact_name", ae.artifact().name()); + data.put("append", ae.append()); + data.put("last_chunk", ae.lastChunk()); + StringBuilder text = new StringBuilder(); + A2ATransport.extractTextFromArtifact(ae.artifact(), text); + if (!text.isEmpty()) data.put("text", text.toString()); + Map metadata = ae.artifact().metadata(); + if (metadata != null && !metadata.isEmpty()) { + data.put("metadata", metadata); + } else if (ae.metadata() != null && !ae.metadata().isEmpty()) { + data.put("metadata", ae.metadata()); + } + } + } else if (event instanceof MessageEvent me) { + data.put("event_kind", "message"); + data.put("role", me.getMessage().role().name()); + StringBuilder text = new StringBuilder(); + A2ATransport.extractTextFromMessage(me.getMessage(), text); + if (!text.isEmpty()) data.put("text", text.toString()); + if (me.getMessage().metadata() != null && !me.getMessage().metadata().isEmpty()) { + data.put("metadata", me.getMessage().metadata()); + } + } + return data; + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialCrypto.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialCrypto.java index 3f1e02a3..6715f126 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialCrypto.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialCrypto.java @@ -93,7 +93,9 @@ public static void main(String[] args) { System.err.println("Error: " + e.getMessage()); System.exit(1); } - }/** + } + + /** * Decrypt a credential value if it has the {@code enc:} prefix. Values without the prefix are * returned as-is (plaintext fallback). * @@ -102,10 +104,15 @@ public static void main(String[] args) { * @throws IllegalStateException when an encrypted value cannot be decrypted */ public static String decryptIfNeeded(String value) { + return decryptIfNeeded(value, null); + } + + /** Decrypts with an optional instance-scoped key read from a configured SDK env file. */ + static String decryptIfNeeded(String value, String configuredKeyHex) { if (value == null || !value.startsWith(PREFIX)) { return value; } - String keyHex = resolveKey(); + String keyHex = resolveKey(configuredKeyHex); if (keyHex == null || keyHex.isBlank()) { throw new IllegalStateException( "Encrypted credential found but " @@ -174,17 +181,29 @@ public static String encrypt(String plaintext) { * @return the hex key string, or null if not found */ private static String resolveKey() { + return resolveKey(null); + } + + private static String resolveKey(String configuredKeyHex) { + if (configuredKeyHex != null && !configuredKeyHex.isBlank()) { + return configuredKeyHex; + } String key = System.getenv(ENV_KEY); if (key != null && !key.isBlank()) { return key; } - return System.getProperty(ENV_KEY); + key = System.getProperty(ENV_KEY); + if (key != null && !key.isBlank()) { + return key; + } + return null; } private static byte[] hexToBytes(String hex) { int len = hex.length(); if (len != 64) { - throw new IllegalArgumentException("A2AT_CRED_KEY must contain 64 hexadecimal characters"); + throw new IllegalArgumentException( + "A2AT_CRED_KEY must contain 64 hexadecimal characters"); } byte[] data = new byte[len / 2]; for (int i = 0; i < len; i += 2) { @@ -194,8 +213,7 @@ private static byte[] hexToBytes(String hex) { throw new IllegalArgumentException( "A2AT_CRED_KEY must contain hexadecimal characters only"); } - data[i / 2] = - (byte) ((high << 4) + low); + data[i / 2] = (byte) ((high << 4) + low); } return data; } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHttpTransport.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHttpTransport.java new file mode 100644 index 00000000..1e5f4616 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHttpTransport.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** Synchronous, conversation-free HTTP boundary used by credential login requests. */ +final class CredentialHttpTransport { + private final SSLContext sslContext; + private final int timeoutMillis; + + private CredentialHttpTransport(SSLContext sslContext, Duration timeout) { + this.sslContext = sslContext; + this.timeoutMillis = Math.toIntExact(timeout.toMillis()); + } + + static CredentialHttpTransport create( + boolean sslVerify, + String caCertsPath, + String clientCertPath, + String clientKeyPath, + String clientKeyPassword, + String crlPath, + Duration timeout) { + SSLContext context = + SslContextFactory.create( + sslVerify, + caCertsPath, + clientCertPath, + clientKeyPath, + clientKeyPassword, + crlPath) + .orElse(null); + return new CredentialHttpTransport(context, timeout); + } + + Response send(URI uri, String method, String contentType, String body) throws Exception { + HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection(); + if (sslContext != null && connection instanceof HttpsURLConnection https) { + https.setSSLSocketFactory(sslContext.getSocketFactory()); + } + try { + connection.setConnectTimeout(timeoutMillis); + connection.setReadTimeout(timeoutMillis); + connection.setInstanceFollowRedirects(true); + connection.setRequestMethod(method); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", contentType); + try (OutputStream output = connection.getOutputStream()) { + output.write(body.getBytes(StandardCharsets.UTF_8)); + } + int status = connection.getResponseCode(); + InputStream input = + status >= 400 ? connection.getErrorStream() : connection.getInputStream(); + String responseBody; + if (input == null) { + responseBody = ""; + } else { + try (input) { + responseBody = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + return new Response(status, responseBody); + } finally { + connection.disconnect(); + } + } + + record Response(int statusCode, String body) {} +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java index d44e9b45..7556d2a3 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 @@ -512,16 +512,26 @@ public java.util.concurrent.CompletableFuture subscribeToTask if (eventSink != null) { try { eventSink.accept(event); } catch (Exception ignored) {} } - if (!future.isDone()) { - String state = A2ATransport.extractResponseTaskState(events); - String text = A2ATransport.extractResponseText(events); + if (isTerminal(event) && !future.isDone()) { + List snapshot; + synchronized (events) { + snapshot = List.copyOf(events); + } future.complete(SendMessageResult.builder() - .text(text) - .taskState(state) + .text(A2ATransport.extractResponseText(snapshot)) + .task(A2ATransport.extractResponseTask(snapshot)) + .taskState(A2ATransport.extractResponseTaskState(snapshot)) + .metadata(A2ATransport.extractResponseMetadata(snapshot)) .build()); } }), - null, + 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( 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 4d27f0d1..79018b13 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 @@ -45,46 +45,53 @@ import java.util.function.Consumer; /** - * Default {@link ExtensionSender} built on a shared {@link A2ATransport}. + * Default {@link ExtensionSender} built on a caller-owned {@link A2ATransport}. * - *

      Owns extension prompt-generation dispatch (Task-T via the A2A-T SDK; Negotiation-T / - * Authorization-T / Notification-T reserved for future SDK support). All wire-level work delegates - * to the transport. + *

      Uses the A2A-T SDK to render Authorization-T and Notification-T metadata. All wire-level work + * delegates to the transport. The facade does not close that transport; the caller owns it. */ public record DefaultExtensionSender(A2ATransport transport) implements ExtensionSender, AutoCloseable { private static final Logger log = LoggerFactory.getLogger(DefaultExtensionSender.class); + public DefaultExtensionSender { + java.util.Objects.requireNonNull(transport, "transport"); + } + @Override public CompletableFuture sendExtensionMessage( String agentName, String instruction, String naturalLanguageInput, A2ATExtension extension) { + CompletableFuture invalid = + validateRequest(agentName, instruction, naturalLanguageInput, extension); + if (invalid != null) return invalid; AgentCard agentCard = transport.getCard(agentName); if (agentCard == null) { log.error("[ExtensionSender] Agent not found: {}", agentName); return CompletableFuture.failedFuture( new RuntimeException("Agent not found: " + agentName)); } + if (!supports(agentCard, extension)) { + return CompletableFuture.failedFuture( + new IllegalArgumentException( + "Agent '" + agentName + "' does not declare " + extension.uri())); + } return CompletableFuture.supplyAsync( () -> generateExtensionPrompt(extension, naturalLanguageInput)) .thenCompose( mc -> { - String value; - Map metadata = new HashMap<>(); - if (mc != null && mc.promptText() != null && !mc.promptText().isEmpty()) { - value = mc.promptText(); - metadata.putAll(mc.buildMetadataContent()); - } else { - value = naturalLanguageInput; - metadata.put(extension.uri(), value); - log.info( - "[ExtensionSender] SDK prompt generation unavailable for {} ({}), using input as metadata", - agentName, - extension.displayName()); + if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { + return CompletableFuture.failedFuture( + new IllegalStateException( + "A2A-T SDK prompt generation failed for " + + extension.displayName())); } + String value = mc.promptText(); + Map metadata = new HashMap<>(); + metadata.putAll(mc.buildMetadataContent()); log.info( "[ExtensionSender] sendExtensionMessage to {}: extension={}, metadataValue={} chars", agentName, @@ -130,6 +137,14 @@ public CompletableFuture sendExtensionMessageFromData( Map data, Map schema, A2ATExtension extension) { + if (agentName == null || agentName.isBlank() || instruction == null || instruction.isBlank()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("agentName and instruction must not be blank")); + } + if (data == null || schema == null || schema.isEmpty()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("fromData requires non-null data and a non-empty schema")); + } if (extension != A2ATExtension.AUTHORIZATION_T && extension != A2ATExtension.NOTIFICATION_T) { return CompletableFuture.failedFuture( @@ -143,6 +158,11 @@ public CompletableFuture sendExtensionMessageFromData( return CompletableFuture.failedFuture( new RuntimeException("Agent not found: " + agentName)); } + if (!supports(agentCard, extension)) { + return CompletableFuture.failedFuture( + new IllegalArgumentException( + "Agent '" + agentName + "' does not declare " + extension.uri())); + } return CompletableFuture.supplyAsync(() -> generateExtensionPromptFromData(extension, data, schema)) .thenCompose( mc -> { @@ -186,7 +206,7 @@ public CompletableFuture sendExtensionMessageFromData( }); } - /** Deterministic fromData rendering for Authorization-T / Notification-T. */ + /** SDK schema-aware fromData rendering for Authorization-T / Notification-T. */ private MetadataContent generateExtensionPromptFromData( A2ATExtension extension, Map data, Map schema) { net.openan.a2at.sdk.client.A2ATClient a2atClient = transport.getA2atClient(); @@ -216,27 +236,54 @@ public CompletableFuture sendNotification( String instruction, String naturalLanguageInput, Consumer> eventCallback) { + return openNotification(agentName, instruction, naturalLanguageInput, eventCallback) + .thenCompose(NotificationSubscription::acknowledgement); + } + + @Override + public CompletableFuture openNotification( + String agentName, + String instruction, + String naturalLanguageInput, + Consumer> eventCallback) { + if (agentName == null + || agentName.isBlank() + || instruction == null + || instruction.isBlank() + || naturalLanguageInput == null + || naturalLanguageInput.isBlank()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException( + "agentName, instruction and naturalLanguageInput must not be blank")); + } AgentCard agentCard = transport.getCard(agentName); if (agentCard == null) { log.error("[ExtensionSender] Agent not found: {}", agentName); return CompletableFuture.failedFuture( new RuntimeException("Agent not found: " + agentName)); } + if (!supports(agentCard, A2ATExtension.NOTIFICATION_T)) { + return CompletableFuture.failedFuture( + new IllegalArgumentException( + "Agent '" + + agentName + + "' does not declare " + + A2ATExtension.NOTIFICATION_T.uri())); + } return CompletableFuture.supplyAsync( () -> generateExtensionPrompt( A2ATExtension.NOTIFICATION_T, naturalLanguageInput)) .thenCompose( mc -> { - String value; - Map metadata = new HashMap<>(); - if (mc != null && mc.promptText() != null && !mc.promptText().isEmpty()) { - value = mc.promptText(); - metadata.putAll(mc.buildMetadataContent()); - } else { - value = naturalLanguageInput; - metadata.put(A2ATExtension.NOTIFICATION_T.uri(), value); + if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { + return CompletableFuture.failedFuture( + new IllegalStateException( + "A2A-T SDK prompt generation failed for Notification-T")); } + String value = mc.promptText(); + Map metadata = new HashMap<>(); + metadata.putAll(mc.buildMetadataContent()); return sendNotificationMetadata( agentCard, agentName, instruction, metadata, eventCallback, value); }); @@ -249,12 +296,42 @@ public CompletableFuture sendNotificationFromData( Map data, Map schema, Consumer> eventCallback) { + return openNotificationFromData(agentName, instruction, data, schema, eventCallback) + .thenCompose(NotificationSubscription::acknowledgement); + } + + @Override + public CompletableFuture openNotificationFromData( + String agentName, + String instruction, + Map data, + Map schema, + Consumer> eventCallback) { + if (agentName == null + || agentName.isBlank() + || instruction == null + || instruction.isBlank() + || data == null + || schema == null + || schema.isEmpty()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException( + "fromData notification requires agentName, instruction, data and schema")); + } AgentCard agentCard = transport.getCard(agentName); if (agentCard == null) { log.error("[ExtensionSender] Agent not found: {}", agentName); return CompletableFuture.failedFuture( new RuntimeException("Agent not found: " + agentName)); } + if (!supports(agentCard, A2ATExtension.NOTIFICATION_T)) { + return CompletableFuture.failedFuture( + new IllegalArgumentException( + "Agent '" + + agentName + + "' does not declare " + + A2ATExtension.NOTIFICATION_T.uri())); + } return CompletableFuture.supplyAsync( () -> generateExtensionPromptFromData( @@ -283,7 +360,7 @@ public CompletableFuture sendNotificationFromData( } /** Shared Notification-T stream-open path used by both the text and fromData tracks. */ - private CompletableFuture sendNotificationMetadata( + private CompletableFuture sendNotificationMetadata( AgentCard agentCard, String agentName, String instruction, @@ -299,51 +376,30 @@ private CompletableFuture sendNotificationMetadata( agentName, renderedValue.length(), eventCallback != null); - return transport.sendNotificationStream( - agentCard, - agentName, - instruction, - transport.getContextId(), - metadata, - eventSink); + return CompletableFuture.completedFuture( + transport.openNotificationStream( + agentCard, + agentName, + instruction, + transport.getContextId(), + metadata, + eventSink)); } private void forwardNotificationEvent( ClientEvent event, String agentName, Consumer> callback) { - Map data = new HashMap<>(); - data.put("agent", agentName); - if (event instanceof TaskUpdateEvent tue) { - if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { - data.put("state", sue.status().state().name()); - data.put("is_final", sue.isFinal()); - StringBuilder text = new StringBuilder(); - A2ATransport.extractTextFromMessage(sue.status().message(), text); - if (!text.isEmpty()) data.put("text", text.toString()); - if (sue.metadata() != null && !sue.metadata().isEmpty()) - data.put("metadata", sue.metadata()); - } else if (tue.getUpdateEvent() instanceof TaskArtifactUpdateEvent ae) { - StringBuilder text = new StringBuilder(); - for (Part part : ae.artifact().parts()) { - if (part instanceof TextPart tp) text.append(tp.text()); - } - data.put("artifact_name", ae.artifact().name()); - data.put("append", ae.append()); - if (!text.isEmpty()) data.put("text", text.toString()); - if (ae.metadata() != null && !ae.metadata().isEmpty()) - data.put("metadata", ae.metadata()); - } - } else if (event instanceof MessageEvent me) { - Message msg = me.getMessage(); - StringBuilder text = new StringBuilder(); - A2ATransport.extractTextFromMessage(msg, text); - data.put("role", msg.role().name()); - if (!text.isEmpty()) data.put("text", text.toString()); - if (msg.metadata() != null && !msg.metadata().isEmpty()) - data.put("metadata", msg.metadata()); - } + Map data = ClientEventMapper.toMap(event, agentName); if (!data.isEmpty()) { log.info("[ExtensionSender] Notification-T callback for {}: {} keys", agentName, data.keySet()); - callback.accept(data); + try { + callback.accept(data); + } catch (RuntimeException callbackError) { + log.warn( + "[ExtensionSender] Notification-T callback failed for {}: {}", + agentName, + callbackError.getMessage(), + callbackError); + } } } @@ -359,7 +415,7 @@ private void forwardNotificationEvent( * string. * *

      Notification-T defaults to the service-recovery template (业务抢通事件订阅) — the - * subscription the workbench pre-positions in this scenario; pass an explicit template via + * subscription the workbench opens in this scenario; pass an explicit template via * the fromData variant when subscribing to something else (e.g. SUBSCRIBE_INCIDENT). * * @return {@link MetadataContent} with prompt text and template URI, or null if SDK unavailable @@ -388,6 +444,34 @@ MetadataContent generateExtensionPrompt(A2ATExtension extension, String naturalL } } + private static boolean supports(AgentCard card, A2ATExtension extension) { + return A2ATransport.extractExtensionUris(card).contains(extension.uri()); + } + + private static CompletableFuture validateRequest( + String agentName, + String instruction, + String naturalLanguageInput, + A2ATExtension extension) { + if (agentName == null + || agentName.isBlank() + || instruction == null + || instruction.isBlank() + || naturalLanguageInput == null + || naturalLanguageInput.isBlank()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException( + "agentName, instruction and naturalLanguageInput must not be blank")); + } + if (extension != A2ATExtension.AUTHORIZATION_T + && extension != A2ATExtension.NOTIFICATION_T) { + return CompletableFuture.failedFuture( + new IllegalArgumentException( + "ExtensionSender supports Authorization-T and Notification-T only")); + } + return null; + } + @Override public void close() { // Transport is owned by the caller; do not close it here. 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 001ada47..824e80bf 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 @@ -53,8 +53,8 @@ * ControlPoint wiring. All wire-level work (client runtime, auth, SSE event extraction) delegates * to the transport. * - *

      One-shot pre-positioning sends (Authorization-T / Notification-T) are a separate concern and - * live on {@link DefaultExtensionSender}. + *

      Independent Authorization-T operations and Notification-T subscriptions live on {@link + * DefaultExtensionSender}. */ public class DefaultWorkflowEngineClient implements WorkflowEngineClient, AutoCloseable { @@ -63,6 +63,9 @@ public class DefaultWorkflowEngineClient implements WorkflowEngineClient, AutoCl private final A2ATransport transport; private final ExtensionRegistry extensionRegistry; private final int maxNegotiationRounds; + private final boolean closeTransportOnClose; + private final java.util.concurrent.atomic.AtomicBoolean closed = + new java.util.concurrent.atomic.AtomicBoolean(); private EventCallback eventCallback = new EventCallback(); private ControlPoint controlPoint; @@ -71,7 +74,25 @@ public DefaultWorkflowEngineClient( int maxNegotiationRounds, List customHandlers, Map negotiationParamSchema) { - this.transport = transport; + this( + transport, + maxNegotiationRounds, + customHandlers, + negotiationParamSchema, + false); + } + + private DefaultWorkflowEngineClient( + A2ATransport transport, + int maxNegotiationRounds, + List customHandlers, + Map negotiationParamSchema, + boolean closeTransportOnClose) { + this.transport = java.util.Objects.requireNonNull(transport, "transport"); + if (maxNegotiationRounds < 1) { + throw new IllegalArgumentException("maxNegotiationRounds must be positive"); + } + this.closeTransportOnClose = closeTransportOnClose; this.extensionRegistry = new ExtensionRegistry(negotiationParamSchema); if (customHandlers != null) { for (ExtensionHandler h : customHandlers) { @@ -96,6 +117,33 @@ public DefaultWorkflowEngineClient(A2ATransport transport) { this(transport, 3, null, null); } + /** Applies negotiation and extension settings from the same config used by the transport. */ + public DefaultWorkflowEngineClient( + A2ATransport transport, WorkflowEngineClientConfig config) { + this( + transport, + java.util.Objects.requireNonNull(config, "config").getMaxNegotiationRounds(), + config.getCustomHandlers(), + config.getNegotiationParamSchema()); + } + + /** Creates a client which closes the supplied transport when the client is closed. */ + public static DefaultWorkflowEngineClient owning(A2ATransport transport) { + return new DefaultWorkflowEngineClient(transport, 3, null, null, true); + } + + /** Owning variant which also applies all client-level configuration. */ + public static DefaultWorkflowEngineClient owning( + A2ATransport transport, WorkflowEngineClientConfig config) { + java.util.Objects.requireNonNull(config, "config"); + return new DefaultWorkflowEngineClient( + transport, + config.getMaxNegotiationRounds(), + config.getCustomHandlers(), + config.getNegotiationParamSchema(), + true); + } + // ------------------------------------------------------------------ // Wiring // ------------------------------------------------------------------ @@ -154,7 +202,15 @@ public Optional getPrompt(TemplateUri templateUri) { // Workflow send path // ------------------------------------------------------------------ private void emit(String type, Map data) { - eventCallback.onEvent(type, data); + try { + eventCallback.onEvent(type, data); + } catch (RuntimeException callbackError) { + log.warn( + "[EngineClient] Event callback failed for type={}: {}", + type, + callbackError.getMessage(), + callbackError); + } } // ------------------------------------------------------------------ @@ -163,6 +219,10 @@ private void emit(String type, Map data) { @Override public CompletableFuture sendMessage( String agentName, String message, String contextId, Map metadata) { + if (agentName == null || agentName.isBlank() || message == null || message.isBlank()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("agentName and message must not be blank")); + } AgentCard agentCard = transport.getCard(agentName); if (agentCard == null) { log.error("[EngineClient] Agent not found: {}", agentName); @@ -353,8 +413,7 @@ private CompletableFuture continueNegotiationRound( .thenCompose( followUpMeta -> { // The message body is the rendered negotiation text itself: the SDK - // template output when generation succeeded, the raw clarification on - // fallback. The same text also travels in metadata under the + // template output. The same text also travels in metadata under the // Negotiation-T key; agents read either. Object rendered = followUpMeta.get(A2ATExtension.NEGOTIATION_T.uri()); @@ -403,30 +462,33 @@ private CompletableFuture continueNegotiationRound( *

      The clarification text is classified: {@code reject:...} / {@code abort:...} prefixes * select the Reject or Abort terminal templates, anything else renders an Accept message via * {@code generateNegotiationAcceptPromptFromText} (one LLM extraction step). When the - * negotiation context is available, the SDK state machine is also advanced so the follow-up - * payload carries the next-round context. All failures degrade to the raw-clarification - * fallback. + * negotiation context is available, the engine advances the stateless context so the + * follow-up payload carries the next round. Rendering failures are fatal: raw clarifications + * are never disguised as protocol messages. */ private CompletableFuture> buildNegotiationFollowUpMeta( String agentName, Map negMeta, String clarification) { A2ATContentFacade content = transport.getContentFacade(); if (content == null) { - return CompletableFuture.completedFuture(buildFallbackMeta(clarification)); + return CompletableFuture.failedFuture( + new IllegalStateException( + "A2A-T client is required to render a Negotiation-T follow-up for '" + + agentName + + "'")); } return CompletableFuture.supplyAsync( () -> { try { MetadataContent mc = renderFollowUpMessage(content, agentName, negMeta, clarification); if (mc.promptText() != null && !mc.promptText().isEmpty()) { - return advanceStateMachine(content, agentName, negMeta, clarification, mc); + return advanceContext(agentName, negMeta, clarification, mc); } + throw new IllegalStateException("A2A-T SDK returned empty negotiation content"); } catch (Exception e) { - log.warn( - "[Negotiation] SDK content generation failed for '{}': {}; using fallback", - agentName, - e.getMessage()); + throw new IllegalStateException( + "Negotiation-T content generation failed for '" + agentName + "'", + e); } - return buildFallbackMeta(clarification); }); } @@ -527,8 +589,7 @@ private MetadataContent renderAcceptFromData( * caller (SDK guide §1.10). The engine owns the context: parses it from the propose * metadata, advances via {@code nextRound()}, and re-serializes it for the wire. */ - private Map advanceStateMachine( - A2ATContentFacade content, + private Map advanceContext( String agentName, Map negMeta, String clarification, @@ -543,7 +604,10 @@ private Map advanceStateMachine( decision.startsWith("reject:") || decision.startsWith("abort:"); net.openan.a2at.sdk.core.model.NegotiationContext next = terminal ? ctx : ctx.nextRound(); - meta.put("negotiation_context", A2ATContentFacade.contextPayload(next)); + meta.put( + net.openan.a2at.sdk.core.model.MetadataContent + .NEGOTIATION_CONTEXT_METADATA_KEY, + A2ATContentFacade.contextPayload(next)); log.info( "[Negotiation] Context advanced for '{}' id={} round={} -> {}", agentName, @@ -555,41 +619,19 @@ private Map advanceStateMachine( /** * Extracts the negotiation session context (id/round/maxRounds) from the received - * metadata. Primary source: the engine's {@code negotiation_context} key (the wire - * serialization produced by {@link A2ATContentFacade#contextPayload}); fallback: the SDK - * content-layer's {@code negotiationContext} key ({@code buildMetadataContent} embeds - * {@code id} only — the missing rounds default). Returns null when neither is usable. + * metadata. The latest SDK's canonical {@code negotiationContext} entry carries all three + * values; no stateful negotiation API or legacy metadata shape is involved. */ private static net.openan.a2at.sdk.core.model.NegotiationContext extractContentContext( Map negMeta) { Object stateful = negMeta.get(A2ATExtension.NEGOTIATION_CONTEXT_META_KEY); - if (!(stateful instanceof Map stateMap)) { - // The propose metadata's negotiation_context key (engine-external convention shared - // with the samples; mirrors the SDK demo constants). - stateful = negMeta.get("negotiation_context"); - } - if (stateful instanceof Map stateMap2) { + if (stateful instanceof Map stateMap) { net.openan.a2at.sdk.core.model.NegotiationContext ctx = - A2ATContentFacade.contextFromMap(stateMap2); + A2ATContentFacade.contextFromMap(stateMap); if (ctx != null) { return ctx; } } - Object raw = - negMeta.get(net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); - if (raw instanceof Map contextMap) { - Object id = contextMap.get("id"); - if (id instanceof String s) { - try { - return new net.openan.a2at.sdk.core.model.NegotiationContext( - s, - 1, - net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); - } catch (IllegalArgumentException ignored) { - // fall through - } - } - } return null; } @@ -602,11 +644,6 @@ private static net.openan.a2at.sdk.core.model.NegotiationContext requireContext( return context; } - private static Map buildFallbackMeta(String clarification) { - Map meta = new HashMap<>(); - meta.put(A2ATExtension.NEGOTIATION_T.uri(), clarification); - return meta; - } // ------------------------------------------------------------------ // Extension handler chain // ------------------------------------------------------------------ @@ -685,8 +722,11 @@ private void forwardIntermediateEvent(ClientEvent event, String agentName) { data.put("append", ae.append()); data.put("last_chunk", ae.lastChunk()); if (!text.isEmpty()) data.put("text", text.toString()); - if (ae.metadata() != null && !ae.metadata().isEmpty()) - data.put("metadata", ae.metadata()); + // Business metadata belongs to the Artifact. Event metadata describes delivery + // (chunking, tracing, etc.) and must not hide the protocol payload. + Map artifactMetadata = ae.artifact().metadata(); + if (artifactMetadata != null && !artifactMetadata.isEmpty()) + data.put("metadata", artifactMetadata); log.info( "[EngineClient] Agent {} artifact update: {} ({})", agentName, @@ -716,6 +756,10 @@ private void forwardIntermediateEvent(ClientEvent event, String agentName) { @Override public CompletableFuture getTask(String agentName, String taskId) { + 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)); @@ -725,6 +769,10 @@ public CompletableFuture getTask(String agentName, String tas @Override public CompletableFuture cancelTask(String agentName, String taskId) { + 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)); @@ -736,16 +784,27 @@ public CompletableFuture cancelTask(String agentName, String public CompletableFuture subscribeToTask( String agentName, String taskId, java.util.function.Consumer> eventCallback) { + 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)); + event -> { + forwardIntermediateEvent(event, agentName); + if (eventCallback != null) { + eventCallback.accept(ClientEventMapper.toMap(event, agentName)); + } + }); } @Override public void close() { - // Transport is owned by the caller; do not close it here. + if (closeTransportOnClose && closed.compareAndSet(false, true)) { + transport.close(); + } } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/EnvFileLoader.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/EnvFileLoader.java index 4bb0bea8..6b0438ce 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/EnvFileLoader.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/EnvFileLoader.java @@ -25,15 +25,18 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** - * Loads key-value pairs from a {@code .env} file and sets them as system properties (only if not - * already present in the OS environment). + * Explicit application-bootstrap utility that copies key-value pairs from a {@code .env} file to + * system properties (only if not already present in the OS environment or JVM). * - *

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

      {@link A2ATransport} deliberately does not call this utility: SDK clients read their explicit + * env path per instance, preserving channel isolation. Applications may call it once, before + * creating any clients, only when a separate application component must read a value such as + * {@code A2AT_CRED_KEY} through {@link System#getenv} or {@link System#getProperty}. */ public final class EnvFileLoader { @@ -48,21 +51,41 @@ private EnvFileLoader() {} * @param envFilePath path to the {@code .env} file */ public static void loadToSystemProperties(Path envFilePath) { + Map values = read(envFilePath); + int count = 0; + for (Map.Entry entry : values.entrySet()) { + String key = entry.getKey(); + if (System.getenv(key) != null || System.getProperty(key) != null) { + continue; + } + System.setProperty(key, entry.getValue()); + count++; + } + if (count > 0) { + log.info( + "[EnvLoader] Loaded {} property(ies) from {} into system properties", + count, + envFilePath); + } + } + + /** Reads an env file without mutating process-global state. */ + static Map read(Path envFilePath) { if (envFilePath == null) { - return; + return Map.of(); } if (!Files.exists(envFilePath)) { log.debug("[EnvLoader] File not found: {}", envFilePath); - return; + return Map.of(); } List lines; try { lines = Files.readAllLines(envFilePath); } catch (IOException e) { log.warn("[EnvLoader] Failed to read {}: {}", envFilePath, e.getMessage()); - return; + return Map.of(); } - int count = 0; + Map values = new LinkedHashMap<>(); for (String line : lines) { String trimmed = line.trim(); if (trimmed.isEmpty() || trimmed.startsWith("#")) { @@ -77,20 +100,8 @@ public static void loadToSystemProperties(Path envFilePath) { if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { value = value.substring(1, value.length() - 1); } - if (System.getenv(key) != null) { - continue; - } - if (System.getProperty(key) != null) { - continue; - } - System.setProperty(key, value); - count++; - } - if (count > 0) { - log.info( - "[EnvLoader] Loaded {} property(ies) from {} into system properties", - count, - envFilePath); + values.putIfAbsent(key, value); } + return java.util.Collections.unmodifiableMap(values); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java index 209fbca3..3260af1b 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java @@ -37,8 +37,8 @@ * structured task prompt on send; Negotiation-T extracts the negotiation context on receive and * feeds the auto-loop. * - *

      Authorization-T / Notification-T are pre-positioning operations (one-shot, via {@link - * ExtensionSender#sendExtensionMessage}), so they are not part of this in-workflow chain. + *

      Authorization-T / Notification-T are independent operations (via {@link ExtensionSender}), + * so they are not part of this in-workflow chain. */ public interface ExtensionHandler { diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java index 5b554d16..0bd0d46a 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java @@ -34,9 +34,8 @@ * Registry of A2A-T extension handlers. * *

      The workflow engine only handles Task-T (task prompt generation) and Negotiation-T (auto - * negotiation loop). Authorization-T and Notification-T are pre-positioning operations done once - * before the workflow starts (see {@link WorkflowEngineClient#sendExtensionMessage}), so they are - * NOT part of the workflow's extension handler chain. + * negotiation loop). Authorization-T and Notification-T are independent protocol operations (see + * {@link ExtensionSender}), so they are NOT part of the workflow's extension handler chain. */ class ExtensionRegistry { @@ -84,6 +83,14 @@ public List getHandlersForExtensions(List extensionUri } java.util.Set pathSegments = extensionPathSegments(uri); for (Map.Entry entry : handlers.entrySet()) { + if (entry.getValue() instanceof TaskTHandler + && !A2ATExtension.TASK_T.uri().equals(uri)) { + continue; + } + if (entry.getValue() instanceof NegotiationTHandler + && !A2ATExtension.NEGOTIATION_T.uri().equals(uri)) { + continue; + } String keyword = entry.getKey().toLowerCase(Locale.ROOT); if (pathSegments.contains(keyword) && !seen.contains(entry.getKey())) { 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 0d7ac3ca..a9e09fad 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 @@ -26,20 +26,21 @@ import java.util.function.Consumer; /** - * One-shot pre-positioning facade over a shared {@link A2ATransport}. + * Facade for protocol operations that are independent of workflow DAG execution. * - *

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

      Single responsibility: send Authorization-T operations and establish Notification-T + * subscriptions. These operations may happen before, during, or after a workflow according to the + * workbench business lifecycle. They bypass Task-T prompt generation and the Negotiation-T + * auto-loop. The returned future carries the first response; later Notification-T events flow + * through the subscription callback. * - *

      Kept separate from {@link WorkflowEngineClient} so a caller that only wants to pre-position is - * not forced to hold a workflow-machinery facade. + *

      Kept separate from {@link WorkflowEngineClient} so protocol channels and lifecycles cannot be + * accidentally coupled to a workflow task. */ public interface ExtensionSender { /** - * Send a one-shot extension message for pre-positioning. + * Send a one-shot independent extension message. * * @param agentName target agent name * @param instruction short instruction text (becomes message parts) @@ -52,7 +53,7 @@ CompletableFuture sendExtensionMessage( String naturalLanguageInput, A2ATExtension extension); - /** Convenience for Authorization-T pre-positioning. */ + /** Natural-language compatibility convenience for a one-shot Authorization-T operation. */ default CompletableFuture sendAuthorization( String agentName, String instruction, String naturalLanguageInput) { return sendExtensionMessage( @@ -60,8 +61,9 @@ default CompletableFuture sendAuthorization( } /** - * Structured-data send: renders the extension prompt deterministically from typed data via - * the SDK's fromData pipeline (no LLM), then sends as a one-shot pre-positioning message. + * Structured-data send: renders the extension prompt from typed data via the SDK's + * schema-aware fromData pipeline. Scenario recognition is bypassed, but slot mapping may + * invoke the configured LLM. Rendering failures are never sent as raw extension metadata. * *

      Callers holding structured business data (e.g. an authorization policy as fields, not * prose) should prefer this over the natural-language variants. @@ -74,35 +76,43 @@ default CompletableFuture sendAuthorization( * fromData rendering here * @return future completing with the first response */ - default CompletableFuture sendExtensionMessageFromData( + CompletableFuture sendExtensionMessageFromData( String agentName, String instruction, Map data, Map schema, - A2ATExtension extension) { - throw new UnsupportedOperationException("sendExtensionMessageFromData not implemented"); - } + A2ATExtension extension); /** * Structured-data Notification-T subscription: renders the service-recovery subscription - * prompt deterministically from typed data (no LLM), then opens the long-lived stream. + * prompt from typed data through the SDK schema-aware pipeline, then opens the long-lived + * stream. Slot mapping may invoke the configured LLM. * Subsequent events pushed by the agent flow to {@code eventCallback}. */ - default CompletableFuture sendNotificationFromData( + CompletableFuture sendNotificationFromData( String agentName, String instruction, Map data, Map schema, - Consumer> eventCallback) { - throw new UnsupportedOperationException("sendNotificationFromData not implemented"); - } + Consumer> eventCallback); + + /** + * Opens a structured-data Notification-T subscription and exposes its full stream lifecycle. + * Callers should retain and close the returned handle after the expected notification arrives. + */ + CompletableFuture openNotificationFromData( + String agentName, + String instruction, + Map data, + Map schema, + Consumer> eventCallback); /** * Establish a Notification-T subscription. * - *

      The returned future completes on the first acknowledgement or event. Subsequent events - * pushed by the agent must be forwarded to {@code eventCallback}; implementations cannot - * silently discard it. + *

      The returned future completes on the first status-bearing acknowledgement (normally + * {@code TASK_STATE_WORKING}), not on an artifact-only event. Subsequent events pushed by the + * agent must be forwarded to {@code eventCallback}; implementations cannot silently discard it. */ CompletableFuture sendNotification( String agentName, @@ -110,7 +120,14 @@ CompletableFuture sendNotification( String naturalLanguageInput, Consumer> eventCallback); - /** Convenience for Notification-T pre-positioning (no event callback). */ + /** Opens a Notification-T subscription and returns an explicit close/health handle. */ + CompletableFuture openNotification( + String agentName, + String instruction, + String naturalLanguageInput, + Consumer> eventCallback); + + /** Natural-language compatibility convenience for a Notification-T subscription. */ default CompletableFuture sendNotification( String agentName, String instruction, String naturalLanguageInput) { return sendNotification(agentName, instruction, naturalLanguageInput, null); diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java index 841dbda9..5c24c753 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java @@ -25,12 +25,10 @@ import net.openan.a2at.sdk.client.A2ATClient; 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 java.util.Locale; -import java.util.Map; - import org.a2aproject.sdk.spec.AgentCard; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -79,31 +77,20 @@ public Map negotiationParamSchema() { } /** - * Extracts the stateful negotiation context map from the reply metadata. - * - *

      Priority 1: the {@code negotiation_context} key — the SDK demo convention; the agent - * embeds the {@code startNegotiation} payload's context map (negotiationType / - * negotiationId / round / status) there, exactly what {@code receiveNegotiation} expects. - * - *

      Priority 2 (legacy): a {@code DATA-NEGOTIATION-T} metadata entry. When neither is - * present the caller falls back to passing the whole metadata map, which the SDK rejects - * with a context-parse error — handled by the direct-extraction fallback. + * Extracts the canonical stateless content-layer context map from reply metadata. */ @SuppressWarnings("unchecked") private static Map extractNegotiationContext(Map metadata) { if (metadata == null) { return null; } - Object contextValue = metadata.get("negotiation_context"); - if (contextValue instanceof Map contextMap - && ((Map) contextMap).containsKey("negotiationType")) { + Object contextValue = + metadata.get( + net.openan.a2at.sdk.core.model.MetadataContent + .NEGOTIATION_CONTEXT_METADATA_KEY); + if (contextValue instanceof Map contextMap) { return (Map) contextMap; } - for (var entry : metadata.entrySet()) { - if (entry.getKey().contains("DATA-NEGOTIATION-T") && entry.getValue() instanceof Map) { - return (Map) entry.getValue(); - } - } return null; } @@ -111,25 +98,20 @@ private static String extractNegotiationText(Map metadata) { if (metadata == null) { return null; } - for (var entry : metadata.entrySet()) { - String upperKey = entry.getKey().toUpperCase(Locale.ROOT); - if (upperKey.contains("NEGOTIATION-T") - && !upperKey.contains("DATA-NEGOTIATION-T") - && entry.getValue() instanceof String) { - return (String) entry.getValue(); - } - } - return null; + Object value = metadata.get(A2ATExtension.NEGOTIATION_T.uri()); + return value instanceof String text ? text : null; } private static boolean supportsNegotiation(AgentCard agentCard) { + if (agentCard.capabilities() == null) { + return false; + } var extensions = agentCard.capabilities().extensions(); if (extensions == null) { return false; } for (var ext : extensions) { - String uri = ext.uri(); - if (uri.toUpperCase(Locale.ROOT).contains("NEGOTIATION-T")) { + if (A2ATExtension.NEGOTIATION_T.uri().equals(ext.uri())) { return true; } } @@ -137,14 +119,7 @@ private static boolean supportsNegotiation(AgentCard agentCard) { } private static boolean hasNegotiationMetadata(Map metadata) { - if (metadata == null) return false; - for (String key : metadata.keySet()) { - String upper = key.toUpperCase(Locale.ROOT); - if (upper.contains("NEGOTIATION-T") && !upper.contains("DATA-NEGOTIATION-T")) { - return true; - } - } - return false; + return metadata != null && metadata.containsKey(A2ATExtension.NEGOTIATION_T.uri()); } private static String getAgentName(AgentCard agentCard) { @@ -154,53 +129,40 @@ private static String getAgentName(AgentCard agentCard) { /** * Parses the negotiation session context for the validate APIs. * - *

      Primary source: the engine's {@code negotiation_context} key (id/round/maxRounds — - * the wire serialization from {@code A2ATContentFacade.contextPayload}). Fallback: the - * SDK content-layer's {@code negotiationContext} key, whose {@code buildMetadataContent} - * embeds {@code id} only (round defaults to 1). Returns null when neither is usable; the - * SDK validate APIs treat a null context as not-a-negotiation-message. + *

      The latest SDK carries all three fields in {@code negotiationContext}; the content layer + * is stateless and the engine advances this value between messages. */ private static net.openan.a2at.sdk.core.model.NegotiationContext parseNegotiationContext( Map metadata) { - Object stateful = metadata.get("negotiation_context"); - if (stateful instanceof Map stateMap) { - net.openan.a2at.sdk.core.model.NegotiationContext ctx = - A2ATContentFacade.contextFromMap(stateMap); - if (ctx != null) { - return ctx; - } - } - Object raw = metadata.get(net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); - if (raw instanceof Map contextMap) { - Object id = contextMap.get("id"); - if (id instanceof String s) { - try { - return new net.openan.a2at.sdk.core.model.NegotiationContext( - s, - 1, - net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); - } catch (IllegalArgumentException e) { - log.debug("[Negotiation-T] Malformed negotiationContext metadata: {}", e.getMessage()); - } - } - } - return null; + return A2ATContentFacade.contextFromMap(extractNegotiationContext(metadata)); } /** - * Resolves the propose template URI matching the negotiation type declared in the received - * context payload. Falls back to information negotiation when the type is missing or unknown. + * Resolves the propose template URI carried by {@link MetadataContent#buildMetadataContent()}. */ - private static TemplateUri proposeTemplateFor(Map contextMap) { - Object type = contextMap != null ? contextMap.get("negotiationType") : null; - if (type instanceof String s) { - return switch (s.replace('-', '_').toUpperCase(Locale.ROOT)) { - case "TARGET" -> StandardTemplates.TARGET_NEGOTIATION_PROPOSE; - case "FEASIBILITY" -> StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; - default -> StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - }; + private static TemplateUri proposeTemplateFor(Map metadata) { + Object raw = metadata.get( + net.openan.a2at.sdk.core.model.MetadataContent.TEMPLATE_URI_METADATA_KEY); + TemplateUri parsed = null; + if (raw instanceof TemplateUri template) { + parsed = template; + } else if (raw instanceof String text) { + parsed = TemplateUri.parse(text).orElse(null); + } else if (raw instanceof Map map + && map.get("extensionName") instanceof String extensionName + && map.get("pathSegments") instanceof java.util.List pathSegments + && map.get("templateVersion") instanceof String version) { + parsed = TemplateUri.of( + extensionName, + pathSegments.stream().map(String::valueOf).toList(), + version); } - return StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + if (parsed == null + || !"Negotiation-T".equals(parsed.extensionName()) + || !parsed.pathSegments().contains("propose")) { + throw new IllegalArgumentException("Missing or invalid Negotiation-T propose templateUri"); + } + return parsed; } @Override @@ -264,11 +226,15 @@ public CompletableFuture afterReceive( proposeText); // Validate the received negotiation message and extract params against // the propose template matching the declared type. The context travels - // in the negotiation_context metadata key; without it the SDK treats + // in the negotiationContext metadata key; without it the SDK treats // the text as a non-negotiation message. - TemplateUri proposeUri = proposeTemplateFor(extractNegotiationContext(metadata)); + TemplateUri proposeUri = proposeTemplateFor(metadata); net.openan.a2at.sdk.core.model.NegotiationContext context = parseNegotiationContext(metadata); + if (context == null) { + throw new IllegalArgumentException( + "Missing or invalid Negotiation-T negotiationContext"); + } try { FilledParamData paramData = a2atClient.validateProposePromptAndDataFilling( @@ -286,12 +252,13 @@ public CompletableFuture afterReceive( paramData.data().keySet()); } } catch (Exception ve) { - log.warn( - "[Negotiation-T] validateProposePromptAndDataFilling" - + " failed for '{}' ({}): {}", - getAgentName(agentCard), - proposeUri.uri(), - ve.getMessage()); + throw new IllegalStateException( + "Negotiation-T propose validation failed for '" + + getAgentName(agentCard) + + "' (" + + proposeUri.uri() + + ")", + ve); } } result.setMetadata(metadata); diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NotificationSubscription.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NotificationSubscription.java new file mode 100644 index 00000000..191fdf9a --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NotificationSubscription.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import dev.openan.workflow.engine.model.SendMessageResult; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CancellationException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Explicit lifecycle handle for one long-lived Notification-T subscription. + * + *

      The acknowledgement and stream completion are intentionally separate: an acknowledgement + * only means that the subscription is active, while {@link #completion()} represents the lifetime + * of the SSE channel. + */ +public final class NotificationSubscription implements AutoCloseable { + private final String agentName; + private final String contextId; + private final Instant openedAt = Instant.now(); + private final CompletableFuture acknowledgement = new CompletableFuture<>(); + private final CompletableFuture completion = new CompletableFuture<>(); + private final CompletableFuture streamTermination = new CompletableFuture<>(); + private final Runnable closeAction; + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicLong eventCount = new AtomicLong(); + private final AtomicReference lastEventAt = new AtomicReference<>(); + + NotificationSubscription(String agentName, String contextId, Runnable closeAction) { + this.agentName = Objects.requireNonNull(agentName, "agentName"); + this.contextId = Objects.requireNonNull(contextId, "contextId"); + this.closeAction = Objects.requireNonNull(closeAction, "closeAction"); + } + + public String agentName() { + return agentName; + } + + public String contextId() { + return contextId; + } + + public CompletableFuture acknowledgement() { + return acknowledgement; + } + + public CompletableFuture completion() { + return completion; + } + + public boolean isActive() { + return !closed.get() && !completion.isDone(); + } + + /** Current local liveness snapshot; every protocol event, including heartbeats, refreshes it. */ + public Heartbeat heartbeat() { + return new Heartbeat( + openedAt, lastEventAt.get(), eventCount.get(), isActive()); + } + + public boolean isHealthy(Duration maximumIdle) { + Objects.requireNonNull(maximumIdle, "maximumIdle"); + if (maximumIdle.isNegative()) { + throw new IllegalArgumentException("maximumIdle must not be negative"); + } + Instant activity = lastEventAt.get(); + if (!isActive() || activity == null) { + return false; + } + return Duration.between(activity, Instant.now()).compareTo(maximumIdle) <= 0; + } + + void recordEvent() { + eventCount.incrementAndGet(); + lastEventAt.set(Instant.now()); + } + + void acknowledge(SendMessageResult result) { + acknowledgement.complete(result); + } + + void failAcknowledgement(Throwable error) { + acknowledgement.completeExceptionally(error); + } + + void completeStream() { + closed.set(true); + completion.complete(null); + } + + void failStream(Throwable error) { + closed.set(true); + completion.completeExceptionally(error); + } + + /** Internal signal that the thread executing the transport call has actually exited. */ + CompletableFuture streamTermination() { + return streamTermination; + } + + void markStreamTerminated() { + streamTermination.complete(null); + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + try { + closeAction.run(); + } finally { + acknowledgement.completeExceptionally( + new CancellationException( + "Notification-T subscription closed before acknowledgement")); + completion.complete(null); + } + } + + public record Heartbeat( + Instant openedAt, Instant lastEventAt, long eventCount, boolean active) {} +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java index fe7dd229..e1fce808 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 @@ -42,9 +42,10 @@ import java.util.concurrent.atomic.AtomicBoolean; /** - * Logs complete A2A protocol messages (headers + body) for protocol-level verification against real - * network captures. Uses a dedicated "PROTOCOL" logger so output can be independently enabled or - * suppressed via logging configuration. + * Logs A2A protocol messages for controlled protocol-level diagnostics. Uses a dedicated + * "PROTOCOL" logger so output can be independently enabled or suppressed. Full payloads are only + * emitted at DEBUG and body logging is opt-in because Task-T and extension metadata can contain + * customer or network data. * *

      Request side: serializes {@link MessageSendParams} to pretty-printed JSON. Sensitive headers * are redacted unless {@code WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=true} is explicitly @@ -96,12 +97,12 @@ static void logRequest( String endpoint, MessageSendParams params, Map headers) { - if (!log.isInfoEnabled()) { + if (!log.isDebugEnabled()) { return; } try { String bodyJson = formatBody(mapper.writeValueAsString(params)); - log.info( + log.debug( ">>> [{}] REQUEST to {}\n=== Headers ===\n{}\n=== Body ===\n{}", agentName, endpoint, @@ -119,18 +120,19 @@ static void logRequest( * @param event the received client event */ static void logResponseEvent(String agentName, ClientEvent event) { - if (!log.isInfoEnabled()) { + if (!log.isDebugEnabled()) { return; } try { Object payload = extractPayload(event); String eventType = event.getClass().getSimpleName(); if (payload == null) { - log.info("<<< [{}] RESPONSE [{}]: (no serializable payload)", agentName, eventType); + log.debug( + "<<< [{}] RESPONSE [{}]: (no serializable payload)", agentName, eventType); return; } String json = formatBody(mapper.writeValueAsString(payload)); - log.info("<<< [{}] RESPONSE [{}]\n{}", agentName, eventType, json); + log.debug("<<< [{}] RESPONSE [{}]\n{}", agentName, eventType, json); } catch (Exception e) { log.warn("<<< [{}] Failed to serialize response event: {}", agentName, e.getMessage()); } @@ -187,7 +189,7 @@ static String formatHeaders(Map headers) { } private static String formatBody(String body) { - if (!booleanSetting(INCLUDE_BODY, true)) return "(body logging disabled)"; + if (!booleanSetting(INCLUDE_BODY, false)) return "(body logging disabled)"; int maxChars = intSetting(MAX_BODY_CHARS, DEFAULT_MAX_BODY_CHARS); if (body.length() <= maxChars) return body; return body.substring(0, maxChars) diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java index dcd77cf7..9c885733 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java @@ -47,13 +47,16 @@ class TaskTHandler implements ExtensionHandler { private static final Logger log = LoggerFactory.getLogger(TaskTHandler.class); private static String findTaskTUri(AgentCard agentCard) { + if (agentCard.capabilities() == null) { + return null; + } var extensions = agentCard.capabilities().extensions(); if (extensions == null) { return null; } for (var ext : extensions) { String uri = ext.uri(); - if (uri.contains("Task-T")) { + if (A2ATExtension.TASK_T.uri().equals(uri)) { return uri; } } @@ -77,25 +80,35 @@ public CompletableFuture> beforeSend( A2ATClient a2atClient, ControlPoint controlPoint) { if (a2atClient == null) { - return CompletableFuture.completedFuture(metadata); + Map cleaned = + metadata != null ? new HashMap<>(metadata) : new HashMap<>(); + cleaned.remove(A2ATExtension.TASK_TEMPLATE_META_KEY); + Object taskData = cleaned.remove(A2ATExtension.TASK_DATA_META_KEY); + Object taskSchema = cleaned.remove(A2ATExtension.TASK_SCHEMA_META_KEY); + if (taskData != null || taskSchema != null) { + return CompletableFuture.failedFuture( + new IllegalStateException( + "A2A-T client is required for structured Task-T rendering")); + } + log.warn( + "[Task-T] A2A-T client unavailable for '{}'; sending plain A2A without forged Task-T metadata", + getAgentName(agentCard)); + return CompletableFuture.completedFuture(cleaned); } // Skip Task-T prompt generation when this is a Negotiation-T follow-up: the metadata // carries a RENDERED follow-up message under the Negotiation-T key. An EMPTY // Negotiation-T value only activates the A2A-Extensions header (first task send) and // must NOT skip generation. if (metadata != null) { - for (Map.Entry entry : metadata.entrySet()) { - if (entry.getKey().toUpperCase(java.util.Locale.ROOT).contains("NEGOTIATION-T") - && entry.getValue() instanceof String s - && !s.isEmpty()) { - log.info("[Task-T] Skipping prompt generation for negotiation follow-up"); - // Still strip engine-internal fromData keys: they must never reach the wire. - Map cleaned = new HashMap<>(metadata); - cleaned.remove(A2ATExtension.TASK_DATA_META_KEY); - cleaned.remove(A2ATExtension.TASK_SCHEMA_META_KEY); - cleaned.remove(A2ATExtension.TASK_TEMPLATE_META_KEY); - return CompletableFuture.completedFuture(cleaned); - } + Object negotiation = metadata.get(A2ATExtension.NEGOTIATION_T.uri()); + if (negotiation instanceof String s && !s.isEmpty()) { + log.info("[Task-T] Skipping prompt generation for negotiation follow-up"); + // Still strip engine-internal fromData keys: they must never reach the wire. + Map cleaned = new HashMap<>(metadata); + cleaned.remove(A2ATExtension.TASK_DATA_META_KEY); + cleaned.remove(A2ATExtension.TASK_SCHEMA_META_KEY); + cleaned.remove(A2ATExtension.TASK_TEMPLATE_META_KEY); + return CompletableFuture.completedFuture(cleaned); } } String taskTUri = findTaskTUri(agentCard); @@ -107,12 +120,19 @@ public CompletableFuture> beforeSend( log.info("[Task-T] Metadata already preset, skipping generation"); return CompletableFuture.completedFuture(result); } - // Structured-data track: caller supplied taskData + taskSchema → deterministic - // fromData rendering (no scenario recognition, no LLM). + // Structured-data track: caller supplied taskData + taskSchema. The SDK bypasses scenario + // recognition but its schema-aware slot extractor can still invoke the configured LLM. Object taskData = result.remove(A2ATExtension.TASK_DATA_META_KEY); Object taskSchema = result.remove(A2ATExtension.TASK_SCHEMA_META_KEY); Object taskTemplate = result.remove(A2ATExtension.TASK_TEMPLATE_META_KEY); - if (taskData instanceof Map data && taskSchema instanceof Map schema) { + if (taskData != null || taskSchema != null) { + if (!(taskData instanceof Map data) + || !(taskSchema instanceof Map schema) + || schema.isEmpty()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException( + "Structured Task-T requires non-null data and a non-empty schema")); + } return CompletableFuture.supplyAsync( () -> renderFromData( @@ -130,7 +150,7 @@ public CompletableFuture> beforeSend( try { PromptGenerationResult promptResult = a2atClient.generateTaskPrompt(messageText); - if (promptResult.success()) { + if (promptResult != null && promptResult.success()) { String promptText = promptResult.promptText(); if (promptText != null && !promptText.isEmpty()) { result.put(taskTUri, promptText); @@ -139,24 +159,34 @@ public CompletableFuture> beforeSend( getAgentName(agentCard), promptText.length()); log.debug("[Task-T] Prompt content: [{}]", promptText); + return result; } + throw new IllegalStateException("A2A-T SDK returned an empty Task-T prompt"); } else { - PromptGenerationFailure f = promptResult.failure(); - log.warn( - "[Task-T] Prompt generation failed for '{}': code={}, stage={}, message={}", - getAgentName(agentCard), - f != null ? f.code() : "unknown", - f != null ? f.stage() : "unknown", - f != null ? f.message() : "unknown"); + PromptGenerationFailure f = promptResult != null ? promptResult.failure() : null; + throw new IllegalStateException( + "Task-T prompt generation failed for '" + + getAgentName(agentCard) + + "': code=" + + (f != null ? f.code() : "unknown") + + ", stage=" + + (f != null ? f.stage() : "unknown") + + ", message=" + + (f != null ? f.message() : "unknown")); } } catch (Exception e) { - log.warn("[Task-T] Failed: {}", e.getMessage()); + throw e instanceof IllegalStateException state + ? state + : new IllegalStateException( + "Task-T prompt generation failed for '" + + getAgentName(agentCard) + + "'", + e); } - return result; }); } - /** Deterministic fromData rendering; on failure degrades to the free-text track. */ + /** SDK schema-aware fromData rendering. Failure is fatal; never send an unrendered task. */ private Map renderFromData( A2ATClient a2atClient, AgentCard agentCard, @@ -173,16 +203,18 @@ private Map renderFromData( net.openan.a2at.sdk.core.model.TemplateUri templateUri = templateUriStr != null ? net.openan.a2at.sdk.core.model.TemplateUri.parse(templateUriStr) - .orElse( - net.openan.a2at.sdk.core.model.StandardTemplates - .PRIVATE_LINE_COMPLAINT) + .orElseThrow( + () -> + new IllegalArgumentException( + "Invalid Task-T template URI: " + + templateUriStr)) : net.openan.a2at.sdk.core.model.StandardTemplates .PRIVATE_LINE_COMPLAINT; net.openan.a2at.sdk.core.model.MetadataContent content = a2atClient.generateTaskPromptFromDataWithSchema( typedData, typedSchema, templateUri); if (content != null && content.promptText() != null && !content.promptText().isEmpty()) { - result.put(taskTUri, content.promptText()); + result.putAll(content.buildMetadataContent()); log.info( "[Task-T] Rendered prompt from task data for '{}': {} chars (template={})", getAgentName(agentCard), @@ -190,13 +222,15 @@ private Map renderFromData( content.templateUri()); return result; } + throw new IllegalStateException("A2A-T SDK returned empty Task-T content"); } catch (Exception e) { - log.warn( - "[Task-T] fromData rendering failed for '{}': {}", - getAgentName(agentCard), - e.getMessage()); + throw new IllegalStateException( + "Task-T fromData rendering failed for '" + + getAgentName(agentCard) + + "': " + + e.getMessage(), + e); } - return result; } @Override 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 d7aaf9c5..a792330c 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 @@ -34,9 +34,10 @@ * wiring. All wire-level work (client runtime, auth, SSE event extraction) delegates to the * transport. * - *

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

      Authorization-T operations and Notification-T subscriptions live on {@link ExtensionSender}. + * Task, Authorization and Notification protocol channels have independent + * lifecycles, so production integrations should give those facades separate transport/runtime + * instances even when all three ultimately target the same OMC. * *

      The single message type on this facade: * @@ -68,8 +69,9 @@ default CompletableFuture sendMessage(String agentName, Strin } /** - * Structured-data send: renders the Task-T prompt deterministically from typed data via the - * SDK's fromData pipeline (no scenario recognition, no LLM), then runs the normal send path + * Structured-data send: renders the Task-T prompt from typed data via the SDK's schema-aware + * fromData pipeline. Scenario recognition is bypassed, but slot mapping may invoke the + * configured LLM. The result then runs through the normal send path * (Negotiation-T auto-loop, auth, extension headers). * *

      Callers holding structured business data should prefer this over {@link #sendMessage}: @@ -156,21 +158,15 @@ default java.util.Optional getPro /** Query a task by ID (A2A GET tasks/{id}). */ - default CompletableFuture getTask(String agentName, String taskId) { - throw new UnsupportedOperationException("getTask not implemented"); - } + CompletableFuture getTask(String agentName, String taskId); /** Cancel a task by ID (A2A POST tasks/{id}:cancel). */ - default CompletableFuture cancelTask(String agentName, String taskId) { - throw new UnsupportedOperationException("cancelTask not implemented"); - } + CompletableFuture cancelTask(String agentName, String taskId); /** Subscribe to a task stream (A2A POST tasks/{id}:subscribe). */ - default CompletableFuture subscribeToTask( + CompletableFuture subscribeToTask( String agentName, String taskId, - java.util.function.Consumer> eventCallback) { - throw new UnsupportedOperationException("subscribeToTask not implemented"); - } + java.util.function.Consumer> eventCallback); void close(); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java index 54425455..8b969df6 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java @@ -21,6 +21,9 @@ import lombok.Getter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -67,12 +70,56 @@ private WorkflowEngineClientConfig(Builder b) { this.sendExecutorQueueCapacity = b.sendExecutorQueueCapacity; this.authProvider = b.authProvider; this.credentialsConfigPath = b.credentialsConfigPath; - this.credentialsConfig = b.credentialsConfig; + this.credentialsConfig = + b.credentialsConfig != null ? copyCredentials(b.credentialsConfig) : null; this.a2atEnvPath = b.a2atEnvPath; this.maxNegotiationRounds = b.maxNegotiationRounds; - this.customHandlers = b.customHandlers; + this.customHandlers = b.customHandlers != null ? List.copyOf(b.customHandlers) : null; this.preferredProtocol = b.preferredProtocol; - this.negotiationParamSchema = b.negotiationParamSchema; + this.negotiationParamSchema = + b.negotiationParamSchema != null + ? immutableObjectMap(b.negotiationParamSchema) + : null; + } + + private static Map>> copyCredentials( + Map>> source) { + Map>> top = new LinkedHashMap<>(); + source.forEach( + (agent, schemes) -> { + Map> schemeCopy = new LinkedHashMap<>(); + if (schemes != null) { + schemes.forEach( + (scheme, values) -> + schemeCopy.put( + scheme, + values != null + ? immutableObjectMap(values) + : Map.of())); + } + top.put(agent, Collections.unmodifiableMap(schemeCopy)); + }); + return Collections.unmodifiableMap(top); + } + + private static Map immutableObjectMap(Map source) { + Map copy = new LinkedHashMap<>(); + source.forEach((key, value) -> copy.put(key, immutableValue(value))); + return Collections.unmodifiableMap(copy); + } + + private static Object immutableValue(Object value) { + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + map.forEach((key, nested) -> copy.put(key, immutableValue(nested))); + return Collections.unmodifiableMap(copy); + } + if (value instanceof List list) { + List copy = new ArrayList<>(list.size()); + list.forEach(item -> copy.add(immutableValue(item))); + return Collections.unmodifiableList(copy); + } + return value; } public static Builder builder() { @@ -209,6 +256,9 @@ public WorkflowEngineClientConfig build() { || sendExecutorQueueCapacity <= 0) { throw new IllegalArgumentException("Invalid send executor configuration"); } + if (maxNegotiationRounds <= 0) { + throw new IllegalArgumentException("maxNegotiationRounds must be positive"); + } return new WorkflowEngineClientConfig(this); } } From 4485b58c680132c02ffc72073a07ece1e2576a66 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Tue, 25 Aug 2026 20:41:41 +0800 Subject: [PATCH 16/49] fix(engine): enforce workflow execution invariants Signed-off-by: Zhoujie628 --- .../engine/core/WorkflowExecutor.java | 135 ++++++++++++++---- .../workflow/engine/registry/LoadPsop.java | 73 ++++++---- .../workflow/engine/runner/ExecutePsop.java | 95 ++++++++---- 3 files changed, 215 insertions(+), 88 deletions(-) diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java index 22884d79..77e129ab 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 @@ -40,12 +40,14 @@ import java.util.Collections; import java.util.Deque; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; /** @@ -142,17 +144,19 @@ public CompletableFuture run() { .build()); } Deque pending = new ConcurrentLinkedDeque<>(); + Set scheduled = ConcurrentHashMap.newKeySet(); Set activated = ConcurrentHashMap.newKeySet(); for (int i = 0; i < workflow.getSteps().size(); i++) { var s = workflow.getSteps().get(i); if (contextBuilder.getStepPredecessors(s.getName()).isEmpty()) { pending.add(i); + scheduled.add(i); activated.add(i); } } Set executed = ConcurrentHashMap.newKeySet(); - boolean[] failed = {false}; - return executeSteps(pending, executed, activated, failed) + AtomicBoolean failed = new AtomicBoolean(); + return executeSteps(pending, scheduled, executed, activated, failed) .thenApply( v -> { emit(EventType.WORKFLOW_COMPLETE, Map.of()); @@ -161,10 +165,10 @@ public CompletableFuture run() { workflow.getName(), executionHistory.size()); return ExecutionResult.builder() - .success(!failed[0]) + .success(!failed.get()) .history(new ArrayList<>(executionHistory)) .stepOutputs(new HashMap<>(stepOutputs)) - .error(failed[0] ? "Step execution failed" : null) + .error(failed.get() ? "Step execution failed" : null) .build(); }) .exceptionally( @@ -182,10 +186,11 @@ public CompletableFuture run() { private CompletableFuture executeSteps( Deque pending, + Set scheduled, Set executed, Set activated, - boolean[] failed) { - if (pending.isEmpty() || failed[0]) { + AtomicBoolean failed) { + if (pending.isEmpty() || failed.get()) { return CompletableFuture.completedFuture(null); } @@ -258,18 +263,18 @@ private CompletableFuture executeSteps( for (int idx : readySteps) { executed.add(idx); var step = workflow.getSteps().get(idx); - stepFutures.add(executeStep(step, executed, activated, pending, failed)); + stepFutures.add(executeStep(step, scheduled, activated, pending, failed)); } return CompletableFuture.allOf(stepFutures.toArray(new CompletableFuture[0])) - .thenCompose(v -> executeSteps(pending, executed, activated, failed)); + .thenCompose(v -> executeSteps(pending, scheduled, executed, activated, failed)); } private CompletableFuture executeStep( WorkflowStep step, - Set executed, + Set scheduled, Set activated, Deque pending, - boolean[] failed) { + AtomicBoolean failed) { emit(EventType.STEP_START, Map.of("step", step.getName())); log.info("--- Executing step: {} ---", step.getName()); return executeSubtasks(step) @@ -285,7 +290,7 @@ private CompletableFuture executeStep( step.getName(), "results", result.results())); - failed[0] = true; + failed.set(true); return CompletableFuture.completedFuture(null); } emit( @@ -296,9 +301,8 @@ private CompletableFuture executeStep( nextIndices -> { for (int i = nextIndices.size() - 1; i >= 0; i--) { int nxt = nextIndices.get(i); - if (!executed.contains(nxt) - && !pending.contains(nxt)) { - activated.add(nxt); + activated.add(nxt); + if (scheduled.add(nxt)) { pending.addFirst(nxt); } } @@ -355,7 +359,12 @@ private static boolean isTerminalRoute(String stepName) { } private record StepResult( - String taskDesc, String output, boolean success, Map results) {} + String taskDesc, + String agentName, + int subtaskIndex, + String output, + boolean success, + Map results) {} private CompletableFuture executeSubtasks(WorkflowStep step) { String contextMessage = contextBuilder.buildContext(step, stepOutputs); @@ -419,16 +428,45 @@ private CompletableFuture dispatchTask(WorkflowStep step, TaskRequ } private StepResult collectAllSuccess(List> futures) { - Map results = new HashMap<>(); + List completedResults = new ArrayList<>(); boolean anyFailed = false; for (var f : futures) { var r = f.join(); - results.put(r.taskDesc(), r.output()); + completedResults.add(r); if (!r.success()) { anyFailed = true; } } - return new StepResult(null, null, !anyFailed, results); + return new StepResult( + null, null, -1, null, !anyFailed, collectTaskResults(completedResults)); + } + + /** Keeps every subtask result even when descriptions repeat within one parallel step. */ + private static Map collectTaskResults(List completedResults) { + Map descriptionCounts = + completedResults.stream() + .filter(result -> result.taskDesc() != null) + .collect( + Collectors.groupingBy( + StepResult::taskDesc, + LinkedHashMap::new, + Collectors.counting())); + Map results = new LinkedHashMap<>(); + for (StepResult result : completedResults) { + if (result.taskDesc() == null) continue; + boolean duplicate = descriptionCounts.getOrDefault(result.taskDesc(), 0L) > 1; + String key = + duplicate + ? result.taskDesc() + + " [" + + result.agentName() + + "#" + + result.subtaskIndex() + + "]" + : result.taskDesc(); + results.put(key, result.output()); + } + return results; } private StepResult processTaskResponse( @@ -453,12 +491,14 @@ private StepResult processTaskResponse( } String output = response.isSuccess() - ? response.getOutput() + ? (response.getOutput() != null ? response.getOutput() : "") : (response.getError() != null ? response.getError() : ""); executionHistory.add( Map.of( "step", step.getName(), + "subtask_index", + subtaskIndex, "task", task.getDescription(), "agent", @@ -472,13 +512,21 @@ private StepResult processTaskResponse( Map.of( "step", step.getName(), + "subtask_index", + subtaskIndex, "agent", task.getAgent(), "task", task.getDescription(), "output", output)); - return new StepResult(task.getDescription(), output, response.isSuccess(), null); + return new StepResult( + task.getDescription(), + task.getAgent(), + subtaskIndex, + output, + response.isSuccess(), + null); } private StepResult processTaskError( @@ -495,15 +543,21 @@ private StepResult processTaskError( task.getAgent(), "status", TaskStatus.FAILED.getValue())); + String errorMessage = + e.getMessage() != null && !e.getMessage().isBlank() + ? e.getMessage() + : e.getClass().getSimpleName(); log.error( "[Executor] Task {} -> {}: exception: {}", task.getDescription(), task.getAgent(), - e.getMessage()); + errorMessage); executionHistory.add( Map.of( "step", step.getName(), + "subtask_index", + subtaskIndex, "task", task.getDescription(), "agent", @@ -511,8 +565,14 @@ private StepResult processTaskError( "status", "failed", "output", - e.getMessage())); - return new StepResult(task.getDescription(), e.getMessage(), false, null); + errorMessage)); + return new StepResult( + task.getDescription(), + task.getAgent(), + subtaskIndex, + errorMessage, + false, + null); } /** @@ -522,7 +582,8 @@ private StepResult processTaskError( */ private CompletableFuture anySuccess(List> futures) { if (futures.isEmpty()) { - return CompletableFuture.completedFuture(new StepResult(null, null, true, Map.of())); + return CompletableFuture.completedFuture( + new StepResult(null, null, -1, null, true, Map.of())); } CompletableFuture result = new CompletableFuture<>(); int total = futures.size(); @@ -543,38 +604,52 @@ private CompletableFuture anySuccess(List results = new HashMap<>(); + List completedResults = new ArrayList<>(); for (CompletableFuture cf : futures) { if (cf.isDone() && !cf.isCompletedExceptionally()) { try { var r = cf.join(); if (r != null && r.taskDesc() != null) { - results.put(r.taskDesc(), r.output()); + completedResults.add(r); } } catch (Exception ignored) { // Cancellation race, skip } } } - result.complete(new StepResult(null, null, true, results)); + result.complete( + new StepResult( + null, + null, + -1, + null, + true, + collectTaskResults(completedResults))); } else if (!success) { failedCount[0]++; if (completed[0] == total && !result.isDone()) { // All failed - Map results = new HashMap<>(); + List completedResults = new ArrayList<>(); for (CompletableFuture cf : futures) { if (cf.isDone() && !cf.isCompletedExceptionally()) { try { var r = cf.join(); if (r != null && r.taskDesc() != null) { - results.put(r.taskDesc(), r.output()); + completedResults.add(r); } } catch (Exception ignored) { // Cancellation race, skip } } } - result.complete(new StepResult(null, null, false, results)); + result.complete( + new StepResult( + null, + null, + -1, + null, + false, + collectTaskResults(completedResults))); } } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java index ff5e4d4a..371f2e96 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java @@ -27,13 +27,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.net.ssl.HttpsURLConnection; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; import java.net.URI; import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; -import java.time.Duration; import java.util.List; import java.util.Map; @@ -60,9 +61,7 @@ public static Workflow load( } String url = urlBuilder.toString(); log.info("[Registry] Loading PSOP from {} (ssl_verify={})", url, sslVerify); - HttpClient client = createHttpClient(sslVerify); - HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build(); - HttpResponse resp = client.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResult resp = execute("GET", url, null, sslVerify); if (resp.statusCode() != 200) { throw new RuntimeException("Orchestration center returned " + resp.statusCode()); } @@ -79,21 +78,6 @@ public static Workflow load(String baseUrl, String psopId) throws Exception { return load(baseUrl, psopId, null, true); } - /** - * @param sslVerify set false for self-signed certs (dev only) - * @return ranked list of workflow summaries - */ - private static HttpClient createHttpClient(boolean sslVerify) { - HttpClient.Builder b = - HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(30)) - .followRedirects(HttpClient.Redirect.ALWAYS); - if (!sslVerify) { - b.sslContext(SslContextFactory.createTrustAll()); - } - return b.build(); - } - public static List search( String baseUrl, String intent, int topN, String accessToken, boolean sslVerify) throws Exception { @@ -106,14 +90,7 @@ public static List search( String url = urlBuilder.toString(); log.info("[Registry] Searching PSOP at {} (intent={}, top_n={})", url, intent, topN); String jsonBody = mapper.writeValueAsString(Map.of("intent", intent, "top_n", topN)); - HttpClient client = createHttpClient(sslVerify); - HttpRequest request = - HttpRequest.newBuilder() - .uri(URI.create(url)) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) - .build(); - HttpResponse resp = client.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResult resp = execute("POST", url, jsonBody, sslVerify); if (resp.statusCode() != 200) { throw new RuntimeException("Orchestration center returned " + resp.statusCode()); } @@ -147,6 +124,42 @@ public static List search( return results; } + private static HttpResult execute( + String method, String url, String jsonBody, boolean sslVerify) throws Exception { + HttpURLConnection connection = + (HttpURLConnection) URI.create(url).toURL().openConnection(); + if (!sslVerify && connection instanceof HttpsURLConnection https) { + // Trust policy and hostname policy remain independent: this accepts an untrusted + // development CA, but still rejects a certificate issued for another host. + https.setSSLSocketFactory(SslContextFactory.createTrustAll().getSocketFactory()); + } + try { + connection.setInstanceFollowRedirects(true); + connection.setConnectTimeout(30_000); + connection.setReadTimeout(30_000); + connection.setRequestMethod(method); + if (jsonBody != null) { + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json"); + try (OutputStream output = connection.getOutputStream()) { + output.write(jsonBody.getBytes(StandardCharsets.UTF_8)); + } + } + int status = connection.getResponseCode(); + InputStream input = + status >= 400 ? connection.getErrorStream() : connection.getInputStream(); + String body = input == null ? "" : new String(input.readAllBytes(), StandardCharsets.UTF_8); + if (input != null) { + input.close(); + } + return new HttpResult(status, body); + } finally { + connection.disconnect(); + } + } + + private record HttpResult(int statusCode, String body) {} + /** Convenience: search with defaults (top_n=5, no token, ssl_verify=true). */ public static List search(String baseUrl, String intent) throws Exception { diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/runner/ExecutePsop.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/runner/ExecutePsop.java index 278388a6..8d8614f4 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/runner/ExecutePsop.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/runner/ExecutePsop.java @@ -41,6 +41,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.function.BiConsumer; import java.util.function.BiFunction; @@ -92,6 +93,8 @@ public static CompletableFuture execute( onFinish, Function, Object> onEvent) { + Objects.requireNonNull(psop, "psop"); + Objects.requireNonNull(controlPoint, "controlPoint"); EventCallback emitter = eventCallback != null ? eventCallback : new EventCallback(); List> collected = Collections.synchronizedList(new ArrayList<>()); EventCallback collectingCallback = createCollectingCallback(emitter, collected, onEvent); @@ -106,6 +109,7 @@ public static CompletableFuture execute( credentialsConfigPath, a2atEnvPath, collectingCallback); + boolean closeClientOnFinish = engineClient == null; WorkflowExecutor executor = new WorkflowExecutor( psop, @@ -127,7 +131,12 @@ public static CompletableFuture execute( .thenCompose( result -> finalizeResult( - result, client, collectingCallback, collected, onFinish)); + result, + client, + closeClientOnFinish, + collectingCallback, + collected, + onFinish)); } /** Simplified overload without SSL/auth/A2AT config (legacy compatibility). */ @@ -227,9 +236,13 @@ private static void emitEventMap( collected.add(m); Object typeObj = m.get("type"); Object dataObj = m.get("data"); - emitter.onEvent( - typeObj != null ? typeObj.toString() : defaultType, - dataObj instanceof Map ? (Map) dataObj : Map.of()); + try { + emitter.onEvent( + typeObj != null ? typeObj.toString() : defaultType, + dataObj instanceof Map ? (Map) dataObj : Map.of()); + } catch (Exception e) { + log.warn("[execute_psop] Event callback failed: {}", e.getMessage()); + } } private static WorkflowEngineClient createEngineClient( @@ -240,17 +253,16 @@ private static WorkflowEngineClient createEngineClient( String credentialsConfigPath, String a2atEnvPath, EventCallback callback) { + WorkflowEngineClientConfig config = + WorkflowEngineClientConfig.builder() + .sslVerify(sslVerify) + .caCertsPath(caCertsPath) + .credentialsConfigPath(credentialsConfigPath) + .a2atEnvPath(a2atEnvPath) + .build(); A2ATransport transport = - new A2ATransport( - agentCards, - a2aClientRuntime, - WorkflowEngineClientConfig.builder() - .sslVerify(sslVerify) - .caCertsPath(caCertsPath) - .credentialsConfigPath(credentialsConfigPath) - .a2atEnvPath(a2atEnvPath) - .build()); - WorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); + new A2ATransport(agentCards, a2aClientRuntime, config); + WorkflowEngineClient client = DefaultWorkflowEngineClient.owning(transport, config); client.setEventCallback(callback); return client; } @@ -268,22 +280,35 @@ private static ExecutionResult handleExecutionError(Throwable error) { private static CompletableFuture finalizeResult( ExecutionResult result, WorkflowEngineClient client, + boolean closeClientOnFinish, EventCallback callback, List> collected, BiFunction>, CompletableFuture> onFinish) { - emitResultEvent(result, callback); + try { + emitResultEvent(result, callback); + } catch (Exception e) { + log.warn("[execute_psop] Result event callback failed: {}", e.getMessage()); + } CompletableFuture finishFuture = invokeOnFinish(result, collected, onFinish); return finishFuture.thenApply( v -> { - log.info( - "[execute_psop] Finished: workflow success={}, history={}", - result.isSuccess(), - result.getHistory() != null ? result.getHistory().size() : 0); - callback.onEvent(EventType.CLOSE, Map.of()); try { - client.close(); - } catch (Exception ignored) { + log.info( + "[execute_psop] Finished: workflow success={}, history={}", + result.isSuccess(), + result.getHistory() != null ? result.getHistory().size() : 0); + callback.onEvent(EventType.CLOSE, Map.of()); + } finally { + if (closeClientOnFinish) { + try { + client.close(); + } catch (Exception e) { + log.warn( + "[execute_psop] Owned client close failed: {}", + e.getMessage()); + } + } } return result; }); @@ -317,12 +342,26 @@ private static CompletableFuture invokeOnFinish( if (onFinish == null) { return CompletableFuture.completedFuture(null); } - return onFinish.apply(result, new ArrayList<>(collected)) - .exceptionally( - e -> { - log.error("[execute_psop] on_finish failed: {}", e.getMessage()); - return null; - }); + try { + List> eventSnapshot; + synchronized (collected) { + eventSnapshot = new ArrayList<>(collected); + } + CompletableFuture completion = + onFinish.apply(result, eventSnapshot); + if (completion == null) { + log.warn("[execute_psop] on_finish returned null; treating as completed"); + return CompletableFuture.completedFuture(null); + } + return completion.exceptionally( + e -> { + log.error("[execute_psop] on_finish failed: {}", e.getMessage()); + return null; + }); + } catch (Exception e) { + log.error("[execute_psop] on_finish failed synchronously: {}", e.getMessage()); + return CompletableFuture.completedFuture(null); + } } @SuppressWarnings("unchecked") From c9faa817495af3a2e816f045e60ec2d98aa16f75 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Tue, 25 Aug 2026 20:41:48 +0800 Subject: [PATCH 17/49] test(engine): cover protocol and workflow lifecycle behavior Signed-off-by: Zhoujie628 --- .../engine/StubWorkflowEngineClient.java | 27 ++ .../engine/client/A2ATContentFacadeTest.java | 30 +- .../engine/client/A2ATransportHeaderTest.java | 144 +++++++++ .../client/A2ATransportResponseTest.java | 134 +++++++++ .../engine/client/CredentialCryptoTest.java | 12 + .../engine/client/EnvFileLoaderTest.java | 39 +++ .../engine/client/ExtensionRegistryTest.java | 16 +- .../engine/client/ExtensionSenderTest.java | 273 ++++++++++++++++++ .../client/NotificationSubscriptionTest.java | 64 ++++ .../WorkflowEngineClientConfigTest.java | 48 +++ .../engine/core/WorkflowExecutorTest.java | 244 ++++++++++++++++ .../engine/runner/ExecutePsopTest.java | 23 ++ .../src/test/resources/a2at-mock.env | 16 + .../src/test/resources/a2at-test.env | 16 + 14 files changed, 1058 insertions(+), 28 deletions(-) create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportResponseTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/EnvFileLoaderTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/NotificationSubscriptionTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfigTest.java create mode 100644 workflow-engine/src/test/resources/a2at-mock.env create mode 100644 workflow-engine/src/test/resources/a2at-test.env diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/StubWorkflowEngineClient.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/StubWorkflowEngineClient.java index a8ba0490..95759b85 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 @@ -102,6 +102,33 @@ public void setEventCallback(EventCallback callback) { this.eventCallback = callback != null ? callback : new EventCallback(); } + @Override + public CompletableFuture getTask(String agentName, String taskId) { + return cannedResult(agentName, defaultTaskState); + } + + @Override + public CompletableFuture cancelTask(String agentName, String taskId) { + return cannedResult(agentName, "TASK_STATE_CANCELED"); + } + + @Override + public CompletableFuture subscribeToTask( + String agentName, + String taskId, + java.util.function.Consumer> eventCallback) { + return cannedResult(agentName, defaultTaskState); + } + + private CompletableFuture cannedResult(String agentName, String taskState) { + return CompletableFuture.completedFuture( + SendMessageResult.builder() + .text(cannedResponses.getOrDefault(agentName, defaultResponse)) + .taskState(taskState) + .metadata(Map.of()) + .build()); + } + @Override public void close() {} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java index 1bb50707..b84b4cb1 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java @@ -23,7 +23,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeTrue; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; @@ -52,26 +51,22 @@ */ class A2ATContentFacadeTest { - private static final String ENV_PATH = resolveSampleEnv(); + private static final String ENV_PATH = resolveTestEnv(); - private static String resolveSampleEnv() { - // The facade needs a real A2ATClient; use the engine repo's .env when present so the - // template language resolves to zh-CN. Walk up from the module dir to the repo root. - java.nio.file.Path dir = java.nio.file.Path.of("").toAbsolutePath(); - while (dir != null) { - java.nio.file.Path candidate = dir.resolve(".env"); - if (java.nio.file.Files.exists(candidate)) { - return candidate.toString(); + private static String resolveTestEnv() { + try { + java.net.URL resource = + A2ATContentFacadeTest.class.getClassLoader().getResource("a2at-test.env"); + if (resource == null) { + throw new IllegalStateException("Missing test resource a2at-test.env"); } - dir = dir.getParent(); + return java.nio.file.Path.of(resource.toURI()).toString(); + } catch (java.net.URISyntaxException e) { + throw new IllegalStateException("Invalid a2at-test.env resource URI", e); } - return null; } private A2ATContentFacade facade() { - if (ENV_PATH == null) { - return null; - } return new A2ATContentFacade(new net.openan.a2at.sdk.client.A2ATClient(java.nio.file.Path.of(ENV_PATH))); } @@ -82,7 +77,6 @@ private static NegotiationContext context() { @Test void rendersInformationProposeFromData() { A2ATContentFacade facade = facade(); - assumeTrue(facade != null, ".env not found; SDK-dependent assertions skipped"); net.openan.a2at.sdk.core.model.MetadataContent mc = facade.generateProposeFromData( new NegotiationProposeData( @@ -116,7 +110,6 @@ void rendersInformationProposeFromData() { @Test void rendersAcceptFromData() { A2ATContentFacade facade = facade(); - assumeTrue(facade != null, ".env not found; SDK-dependent assertions skipped"); net.openan.a2at.sdk.core.model.MetadataContent mc = facade.generateAcceptFromData( new NegotiationEndingData( @@ -133,7 +126,6 @@ void rendersAcceptFromData() { @Test void rendersRejectFromData() { A2ATContentFacade facade = facade(); - assumeTrue(facade != null, ".env not found; SDK-dependent assertions skipped"); net.openan.a2at.sdk.core.model.MetadataContent mc = facade.generateRejectFromData( new NegotiationEndingData( @@ -148,7 +140,6 @@ void rendersRejectFromData() { @Test void rendersAbortFromData() { A2ATContentFacade facade = facade(); - assumeTrue(facade != null, ".env not found; SDK-dependent assertions skipped"); net.openan.a2at.sdk.core.model.MetadataContent mc = facade.generateAbortFromData( new NegotiationAbortData( @@ -160,7 +151,6 @@ void rendersAbortFromData() { @Test void negotiationTemplateQueriesResolve() { A2ATContentFacade facade = facade(); - assumeTrue(facade != null, ".env not found; SDK-dependent assertions skipped"); var prompts = facade.getNegotiationPrompts(); // 3 types x 2 phases + common abort assertEquals(7, prompts.size(), "bundled negotiation templates must be enumerable"); diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java index 0c6e81f9..37fe6eb9 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java @@ -12,6 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; +import net.openan.a2at.sdk.core.model.StandardTemplates; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; @@ -22,7 +23,11 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.CompletionException; import java.util.function.Consumer; @@ -166,6 +171,112 @@ void authenticationHeaderConflictsAreCaseInsensitive() { assertEquals(1, headers.size()); } + @Test + void closeWaitsUntilNotificationTransportCallHasExited() throws Exception { + AgentCard card = agentCard(); + CountDownLatch entered = new CountDownLatch(1); + AtomicBoolean exited = new AtomicBoolean(); + A2AJavaClientRuntime runtime = + new A2AJavaClientRuntime() { + @Override + public Iterable sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer eventSink, + Consumer logSink) { + entered.countDown(); + try { + new CountDownLatch(1).await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + exited.set(true); + } + return List.of(); + } + + @Override + public void close() {} + }; + A2ATransport transport = + new A2ATransport( + List.of(card), runtime, WorkflowEngineClientConfig.builder().build()); + + transport.openNotificationStream( + card, card.name(), "subscribe", "context-1", Map.of(), 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 structuredTaskWithoutSdkFailsBeforeRuntimeInvocation() throws Exception { + AtomicInteger sendCalls = new AtomicInteger(); + A2AJavaClientRuntime runtime = new SendCountingRuntime(sendCalls); + try (A2ATransport transport = + new A2ATransport( + List.of(agentCard()), + runtime, + WorkflowEngineClientConfig.builder().build())) { + CompletionException error = + assertThrows( + CompletionException.class, + () -> + new DefaultWorkflowEngineClient(transport) + .sendMessageFromData( + "Test Agent", + "diagnose", + Map.of("任务对象", "port-1"), + Map.of( + "type", + "object", + "properties", + Map.of( + "任务对象", + Map.of("type", "string"))), + StandardTemplates.PRIVATE_LINE_COMPLAINT) + .join()); + + assertTrue(error.getCause().getMessage().contains("A2A-T client is required")); + assertEquals(0, sendCalls.get()); + } + } + private static AgentCard agentCard() throws Exception { String json = """ @@ -249,4 +360,37 @@ public Iterable sendMessage( @Override public void close() {} } + + private record CountingRuntime(AtomicInteger closeCalls) implements A2AJavaClientRuntime { + @Override + public Iterable sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer eventSink, + Consumer logSink) { + return List.of(); + } + + @Override + public void close() { + closeCalls.incrementAndGet(); + } + } + + private record SendCountingRuntime(AtomicInteger sendCalls) implements A2AJavaClientRuntime { + @Override + public Iterable sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer eventSink, + Consumer logSink) { + sendCalls.incrementAndGet(); + return List.of(); + } + + @Override + public void close() {} + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportResponseTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportResponseTest.java new file mode 100644 index 00000000..e693297e --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportResponseTest.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatus; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.a2aproject.sdk.spec.TextPart; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +class A2ATransportResponseTest { + + @Test + void notificationAcknowledgementRequiresANonBlankProtocolState() { + assertFalse(A2ATransport.isAcknowledgementState(null)); + assertFalse(A2ATransport.isAcknowledgementState("")); + assertFalse(A2ATransport.isAcknowledgementState(" ")); + assertTrue(A2ATransport.isAcknowledgementState("TASK_STATE_WORKING")); + } + + @Test + void extractsArtifactOnlyOnceAcrossUpdateSnapshotAndTerminalTask() { + Artifact artifact = artifact("result", "diagnosis"); + Task working = task(TaskState.TASK_STATE_WORKING, List.of(artifact)); + Task completed = task(TaskState.TASK_STATE_COMPLETED, List.of(artifact)); + TaskArtifactUpdateEvent update = + TaskArtifactUpdateEvent.builder() + .taskId("task-1") + .contextId("context-1") + .artifact(artifact) + .append(false) + .lastChunk(true) + .metadata(Map.of()) + .build(); + + List events = + List.of( + new TaskUpdateEvent(working, update), + new TaskEvent(completed)); + + assertEquals("diagnosis", A2ATransport.extractResponseText(events)); + } + + @Test + void assemblesAppendChunksForEachArtifact() { + Task task = task(TaskState.TASK_STATE_WORKING, List.of()); + List events = + List.of( + new TaskUpdateEvent(task, artifactUpdate("result", "part-1", false)), + new TaskUpdateEvent(task, artifactUpdate("result", "+part-2", true))); + + assertEquals("part-1+part-2", A2ATransport.extractResponseText(events)); + } + + @Test + void fallsBackToDistinctStatusOrMessageTextWhenNoArtifactExists() { + Message concern = message("need parameter"); + TaskStatus status = new TaskStatus(TaskState.TASK_STATE_INPUT_REQUIRED, concern, null); + Task task = + Task.builder() + .id("task-1") + .contextId("context-1") + .status(status) + .artifacts(List.of()) + .history(List.of()) + .metadata(Map.of()) + .build(); + TaskStatusUpdateEvent update = + new TaskStatusUpdateEvent("task-1", status, "context-1", false, Map.of()); + + assertEquals( + "need parameter", + A2ATransport.extractResponseText( + List.of(new TaskUpdateEvent(task, update), new MessageEvent(concern)))); + } + + private static TaskArtifactUpdateEvent artifactUpdate( + String id, String text, boolean append) { + return TaskArtifactUpdateEvent.builder() + .taskId("task-1") + .contextId("context-1") + .artifact(artifact(id, text)) + .append(append) + .lastChunk(!append) + .metadata(Map.of()) + .build(); + } + + private static Task task(TaskState state, List artifacts) { + return Task.builder() + .id("task-1") + .contextId("context-1") + .status(new TaskStatus(state)) + .artifacts(artifacts) + .history(List.of()) + .metadata(Map.of()) + .build(); + } + + private static Artifact artifact(String id, String text) { + return Artifact.builder() + .artifactId(id) + .name(id) + .parts(new TextPart(text)) + .metadata(Map.of()) + .build(); + } + + private static Message message(String text) { + return Message.builder() + .role(Message.Role.ROLE_AGENT) + .messageId("message-1") + .parts(new TextPart(text)) + .metadata(Map.of()) + .build(); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/CredentialCryptoTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/CredentialCryptoTest.java index f5953fed..196508eb 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/CredentialCryptoTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/CredentialCryptoTest.java @@ -13,6 +13,8 @@ class CredentialCryptoTest { private static final String KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private static final String OTHER_KEY = + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; @AfterEach void clearKey() { @@ -38,4 +40,14 @@ void corruptedEncryptedCredentialFailsClosed() { void plaintextRemainsBackwardCompatible() { assertEquals("plain", CredentialCrypto.decryptIfNeeded("plain")); } + + @Test + void instanceScopedKeyTakesPrecedenceOverJvmProperty() { + System.setProperty("A2AT_CRED_KEY", KEY); + String encrypted = CredentialCrypto.encrypt("city-specific-secret"); + System.setProperty("A2AT_CRED_KEY", OTHER_KEY); + + assertEquals( + "city-specific-secret", CredentialCrypto.decryptIfNeeded(encrypted, KEY)); + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/EnvFileLoaderTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/EnvFileLoaderTest.java new file mode 100644 index 00000000..058e4cc6 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/EnvFileLoaderTest.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +class EnvFileLoaderTest { + private static final String ISOLATED_KEY = "A2AT_TEST_INSTANCE_SCOPED_KEY"; + + @TempDir Path tempDirectory; + + @Test + void readParsesValuesWithoutMutatingJvmProperties() throws Exception { + Path envFile = tempDirectory.resolve("city-one.env"); + Files.writeString( + envFile, + "# city-specific configuration\n" + + ISOLATED_KEY + + "=city-one\n" + + "QUOTED_VALUE=\"with spaces\"\n"); + System.clearProperty(ISOLATED_KEY); + + Map values = EnvFileLoader.read(envFile); + + assertEquals("city-one", values.get(ISOLATED_KEY)); + assertEquals("with spaces", values.get("QUOTED_VALUE")); + assertNull(System.getProperty(ISOLATED_KEY)); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java index 0321f1e0..44a28118 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java @@ -37,10 +37,10 @@ void preRegistersTwoBuiltinHandlers() { List handlers = reg.getHandlersForExtensions( List.of( - "https://example.com/Task-T", - "https://example.com/Negotiation-T", - "https://example.com/Authorization-T", - "https://example.com/Notification-T")); + A2ATExtension.TASK_T.uri(), + A2ATExtension.NEGOTIATION_T.uri(), + A2ATExtension.AUTHORIZATION_T.uri(), + A2ATExtension.NOTIFICATION_T.uri())); // Authorization-T and Notification-T are pre-positioning operations, // not part of the workflow engine's extension handler chain. assertEquals(2, handlers.size()); @@ -51,7 +51,7 @@ void matchesTaskTHandler() { ExtensionRegistry reg = new ExtensionRegistry(); List handlers = reg.getHandlersForExtensions( - List.of("https://a2a.example.org/extensions/Task-T/v1")); + List.of(A2ATExtension.TASK_T.uri())); assertEquals(1, handlers.size()); assertEquals("Task-T", handlers.get(0).extensionKeyword()); } @@ -61,18 +61,18 @@ void matchesNegotiationTHandler() { ExtensionRegistry reg = new ExtensionRegistry(); List handlers = reg.getHandlersForExtensions( - List.of("https://a2a.example.org/extensions/NEGOTIATION-T/v1")); + List.of(A2ATExtension.NEGOTIATION_T.uri())); assertEquals(1, handlers.size()); assertEquals("Negotiation-T", handlers.get(0).extensionKeyword()); } @Test - void deduplicatesWhenMultipleUrisMatchSameHandler() { + void rejectsNonCanonicalBuiltinUris() { ExtensionRegistry reg = new ExtensionRegistry(); List handlers = reg.getHandlersForExtensions( List.of("https://example.com/Task-T/v1", "https://example.com/Task-T/v2")); - assertEquals(1, handlers.size(), "Task-T should be matched once even with two URIs"); + assertTrue(handlers.isEmpty()); } @Test diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java index 64091c59..4090831d 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 @@ -6,15 +6,48 @@ package dev.openan.workflow.engine.client; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.databind.ObjectMapper; + +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; + +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 org.junit.jupiter.api.BeforeAll; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.CompletionException; +import java.util.function.Consumer; class ExtensionSenderTest { + private static final String TEST_PROVIDER = "workflow-engine-test"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_PROVIDER)) { + LLMClientFactory.register(TEST_PROVIDER, TestLlmClient.class); + } + } + @Test void notificationCallbackMethodIsAnInterfaceContract() throws Exception { Method method = @@ -27,4 +60,244 @@ void notificationCallbackMethodIsAnInterfaceContract() throws Exception { assertTrue(Modifier.isAbstract(method.getModifiers())); } + + @Test + void authorizationFromDataUsesRealSdkMetadataRendering() throws Exception { + AtomicReference captured = new AtomicReference<>(); + A2ATransport transport = transport(captured); + try { + new DefaultExtensionSender(transport) + .sendExtensionMessageFromData( + "Test Agent", + "下发授权策略", + Map.of( + "授权策略的操作类型", "新增授权策略", + "动网操作的授权策略列表", "业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18"), + schema("授权策略的操作类型", "动网操作的授权策略列表"), + A2ATExtension.AUTHORIZATION_T) + .join(); + + Map metadata = captured.get().message().metadata(); + assertEquals( + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT.uri(), + metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertTrue( + String.valueOf(metadata.get(A2ATExtension.AUTHORIZATION_T.uri())) + .contains("新增授权策略")); + } finally { + transport.close(); + } + } + + @Test + void notificationFromDataReturnsLifecycleHandleAndSdkMetadata() throws Exception { + AtomicReference captured = new AtomicReference<>(); + A2ATransport transport = transport(captured); + try { + NotificationSubscription subscription = + new DefaultExtensionSender(transport) + .openNotificationFromData( + "Test Agent", + "订阅抢通结果", + Map.of( + "订阅条件", "", + "上报通知数据格式", "业务抢通方案执行状态、任务流水号、执行结果"), + schema("订阅条件", "上报通知数据格式"), + ignored -> {}) + .join(); + subscription.acknowledgement().join(); + subscription.completion().join(); + + assertNotNull(captured.get()); + Map metadata = captured.get().message().metadata(); + assertEquals( + StandardTemplates.SERVICE_RECOVERY.uri(), + metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertTrue(metadata.containsKey(A2ATExtension.NOTIFICATION_T.uri())); + } finally { + transport.close(); + } + } + + @Test + void taskFromDataUsesRealSdkMetadataPipeline() throws Exception { + AtomicReference captured = new AtomicReference<>(); + A2ATransport transport = transport(captured); + try { + new DefaultWorkflowEngineClient(transport) + .sendMessageFromData( + "Test Agent", + "创建诊断任务", + Map.of( + "任务对象", "接入端口名称:P781-test", + "任务上下文", "投诉分类:专线质差"), + schema("任务对象", "任务上下文"), + StandardTemplates.PRIVATE_LINE_COMPLAINT) + .join(); + + Map metadata = captured.get().message().metadata(); + assertEquals( + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri(), + metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertTrue(metadata.containsKey(A2ATExtension.TASK_T.uri())); + } finally { + transport.close(); + } + } + + @Test + void naturalLanguageExtensionWithoutSdkDoesNotForgeProtocolMetadata() throws Exception { + AtomicReference captured = new AtomicReference<>(); + A2ATransport transport = + new A2ATransport( + List.of(agentCard()), + new CapturingRuntime(captured), + WorkflowEngineClientConfig.builder().build()); + try { + CompletionException error = + assertThrows( + CompletionException.class, + () -> + new DefaultExtensionSender(transport) + .sendAuthorization( + "Test Agent", + "authorize", + "新增业务抢通白名单") + .join()); + + assertTrue(error.getCause().getMessage().contains("SDK prompt generation failed")); + assertNull(captured.get()); + } finally { + transport.close(); + } + } + + private static Map schema(String... fields) { + Map properties = new java.util.LinkedHashMap<>(); + for (String field : fields) { + properties.put(field, Map.of("type", "string", "description", field)); + } + return Map.of("type", "object", "properties", properties); + } + + private static A2ATransport transport(AtomicReference captured) + throws Exception { + java.net.URL env = + ExtensionSenderTest.class.getClassLoader().getResource("a2at-mock.env"); + if (env == null) { + throw new IllegalStateException("Missing test resource a2at-mock.env"); + } + WorkflowEngineClientConfig config = + WorkflowEngineClientConfig.builder() + .a2atEnvPath(Path.of(env.toURI()).toString()) + .build(); + return new A2ATransport(List.of(agentCard()), new CapturingRuntime(captured), config); + } + + private static AgentCard agentCard() throws Exception { + String json = + """ + { + "name": "Test Agent", + "description": "test", + "version": "1.0", + "capabilities": { + "streaming": true, + "extensions": [ + {"uri": "%s", "required": false}, + {"uri": "%s", "required": false}, + {"uri": "%s", "required": false} + ] + }, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [], + "supportedInterfaces": [{ + "protocolBinding": "HTTP+JSON", + "protocolVersion": "1.0", + "url": "https://agent.example.test/a2a/json", + "tenant": "" + }] + } + """ + .formatted( + A2ATExtension.TASK_T.uri(), + A2ATExtension.AUTHORIZATION_T.uri(), + A2ATExtension.NOTIFICATION_T.uri()); + return new ObjectMapper() + .registerModule(new AgentCardJacksonModule()) + .readValue(json, AgentCard.class); + } + + private record CapturingRuntime(AtomicReference captured) + implements A2AJavaClientRuntime { + @Override + public Iterable sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer eventSink, + Consumer logSink) { + captured.set(params); + return List.of(); + } + + @Override + public void close() {} + } + + /** Offline LLM provider exercising the SDK's real schema-mapping pipelines without network. */ + public static final class TestLlmClient implements LLMClient { + private final LLMClientConfig config; + + public TestLlmClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + Object properties = jsonSchema.get("properties"); + if (properties instanceof Map propertyMap + && propertyMap.containsKey("semantic_verdict")) { + return response( + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," + + "\"errors\":[],\"params\":{}}"); + } + Object slotNames = jsonSchema.get("slotNames"); + StringBuilder slots = new StringBuilder("{"); + if (slotNames instanceof List names) { + for (int i = 0; i < names.size(); i++) { + if (i > 0) slots.append(','); + String name = String.valueOf(names.get(i)); + slots.append('"') + .append(name) + .append("\":\"") + .append(slotValue(name)) + .append('"'); + } + } + slots.append('}'); + return response("{\"slots\":" + slots + ",\"slot_errors\":[]}"); + } + + private LLMResponse response(String content) { + return new LLMResponse(content, config.model(), Map.of(), Map.of()); + } + + private static String slotValue(String name) { + if (name.contains("操作类型")) return "新增授权策略"; + if (name.contains("策略列表")) { + return "业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18"; + } + if (name.contains("订阅条件")) return "全部地市"; + if (name.contains("上报通知数据格式")) return "执行状态、任务流水号、执行结果"; + if (name.contains("任务对象")) return "接入端口名称:P781-test"; + if (name.contains("任务上下文")) return "投诉分类:专线质差"; + return "test-value"; + } + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/NotificationSubscriptionTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/NotificationSubscriptionTest.java new file mode 100644 index 00000000..a4ddab7b --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/NotificationSubscriptionTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openan.workflow.engine.model.SendMessageResult; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +class NotificationSubscriptionTest { + + @Test + void eventsRefreshHeartbeatAndExplicitCloseIsIdempotent() { + AtomicInteger closes = new AtomicInteger(); + NotificationSubscription subscription = + new NotificationSubscription("city1", "ctx-1", closes::incrementAndGet); + + assertTrue(subscription.isActive()); + assertEquals(0, subscription.heartbeat().eventCount()); + assertFalse(subscription.isHealthy(Duration.ofSeconds(1))); + + subscription.recordEvent(); + subscription.acknowledge( + SendMessageResult.builder().text("ack").taskState("TASK_STATE_WORKING").build()); + + assertEquals(1, subscription.heartbeat().eventCount()); + assertTrue(subscription.isHealthy(Duration.ofSeconds(1))); + subscription.close(); + subscription.close(); + + assertEquals(1, closes.get()); + assertFalse(subscription.isActive()); + assertTrue(subscription.completion().isDone()); + assertFalse(subscription.streamTermination().isDone()); + subscription.markStreamTerminated(); + 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/WorkflowEngineClientConfigTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfigTest.java new file mode 100644 index 00000000..572c433d --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfigTest.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +class WorkflowEngineClientConfigTest { + + @Test + void rejectsInvalidNegotiationRoundLimit() { + assertThrows( + IllegalArgumentException.class, + () -> WorkflowEngineClientConfig.builder().maxNegotiationRounds(0).build()); + } + + @Test + @SuppressWarnings("unchecked") + void deeplySnapshotsNegotiationSchema() { + List required = new ArrayList<>(List.of("city")); + Map properties = new LinkedHashMap<>(); + properties.put("city", Map.of("type", "string")); + Map schema = new LinkedHashMap<>(); + schema.put("required", required); + schema.put("properties", properties); + + WorkflowEngineClientConfig config = + WorkflowEngineClientConfig.builder().negotiationParamSchema(schema).build(); + required.add("port"); + properties.put("port", Map.of("type", "string")); + + Map snapshot = config.getNegotiationParamSchema(); + assertEquals(List.of("city"), snapshot.get("required")); + assertEquals(1, ((Map) snapshot.get("properties")).size()); + assertThrows( + UnsupportedOperationException.class, + () -> ((List) snapshot.get("required")).add("port")); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java index 598b27c3..e16fd5e4 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 @@ -32,6 +32,11 @@ import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; /** * Tests for WorkflowExecutor: DAG traversal, parallel subtasks, ANY_SUCCESS, conditional routing, @@ -154,6 +159,118 @@ void parallelFanOutAllUnconditionalNext() { assertEquals(Set.of("A", "B", "C"), agents); } + @Test + void parallelBranchesScheduleSharedMergeExactlyOnce() { + WorkflowStep city1 = + WorkflowStep.builder() + .name("diagnosis_city1") + .layer(0) + .subtasks(List.of(task("OMC-1", "diagnose city 1"))) + .next(List.of(jump("merge", ""))) + .build(); + WorkflowStep city2 = + WorkflowStep.builder() + .name("diagnosis_city2") + .layer(0) + .subtasks(List.of(task("OMC-2", "diagnose city 2"))) + .next(List.of(jump("merge", ""))) + .build(); + WorkflowStep merge = + WorkflowStep.builder() + .name("merge") + .layer(1) + .stepType(StepType.SELF_LOOP) + .contextFrom(List.of("diagnosis_city1", "diagnosis_city2")) + .subtasks(List.of(task("Workbench", "merge diagnoses"))) + .build(); + Workflow workflow = + Workflow.builder() + .name("parallel-join") + .steps(List.of(city1, city2, merge)) + .build(); + + ExecutorService taskExecutor = Executors.newFixedThreadPool(2); + CyclicBarrier diagnosisBarrier = new CyclicBarrier(2); + CyclicBarrier schedulingBarrier = new CyclicBarrier(2); + AtomicInteger mergeCalls = new AtomicInteger(); + ControlPoint controlPoint = + new ControlPoint() { + @Override + public CompletableFuture onTask( + TaskRequest request, WorkflowEngineClient engineClient) { + return CompletableFuture.supplyAsync( + () -> { + awaitBarrier(diagnosisBarrier); + return TaskResponse.builder() + .success(true) + .output(request.getAgentName() + " diagnosis") + .build(); + }, + taskExecutor); + } + + @Override + public CompletableFuture onSelfTask(TaskRequest request) { + mergeCalls.incrementAndGet(); + return CompletableFuture.completedFuture( + TaskResponse.builder().success(true).output("merged").build()); + } + + @Override + public CompletableFuture onRoute( + String stepName, + Map results, + List conditions) { + return CompletableFuture.completedFuture( + RouteDecision.builder() + .nextStep(conditions.get(0).getStep()) + .reason("first") + .build()); + } + + }; + EventCallback callback = + new EventCallback() { + @Override + public void onEvent(String type, Map data) { + if (EventType.STEP_COMPLETE.equals(type) + && String.valueOf(data.get("step")).startsWith("diagnosis_")) { + awaitBarrier(schedulingBarrier); + } + } + }; + + try { + ExecutionResult result = + new WorkflowExecutor( + workflow, + controlPoint, + new StubWorkflowEngineClient("OMC-1", "OMC-2"), + callback, + "cross-city complaint", + "zh") + .run() + .join(); + + assertTrue(result.isSuccess()); + assertEquals(1, mergeCalls.get(), "a converging step must execute exactly once"); + assertEquals(3, result.getHistory().size()); + assertEquals( + Set.of("diagnosis_city1", "diagnosis_city2", "merge"), + result.getStepOutputs().keySet()); + } finally { + taskExecutor.shutdownNow(); + } + } + + private static void awaitBarrier(CyclicBarrier barrier) { + try { + barrier.await(5, TimeUnit.SECONDS); + } catch (Exception e) { + throw new IllegalStateException("Timed out synchronizing parallel workflow test", e); + } + } + @Test void conditionalRouteOnRouteCalled() { WorkflowStep s1 = @@ -671,4 +788,131 @@ public CompletableFuture onRoute( assertTrue(events.contains(EventType.TASK_REQUEST)); assertTrue(events.contains(EventType.TASK_RESPONSE)); } + + @Test + void repeatedDescriptionsInParallelStepDoNotOverwriteResults() { + WorkflowStep parallel = + WorkflowStep.builder() + .name("diagnosis") + .layer(0) + .subtasks( + List.of( + task("City1", "SPN专线故障诊断"), + task("City2", "SPN专线故障诊断"))) + .build(); + ControlPoint cp = + new ControlPoint() { + @Override + public CompletableFuture onTask( + TaskRequest request, WorkflowEngineClient engineClient) { + return CompletableFuture.completedFuture( + TaskResponse.builder() + .success(true) + .output(request.getAgentName() + " result") + .build()); + } + + @Override + public CompletableFuture onRoute( + String stepName, + Map results, + List conditions) { + 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(), + recordingCallback(), + "", + "zh") + .run() + .join(); + + assertTrue(result.isSuccess()); + assertEquals( + Map.of( + "SPN专线故障诊断 [City1#0]", "City1 result", + "SPN专线故障诊断 [City2#1]", "City2 result"), + result.getStepOutputs().get("diagnosis")); + assertEquals(List.of(0, 1), + result.getHistory().stream().map(item -> item.get("subtask_index")).toList()); + } + + @Test + void nullTaskOutputAndNullExceptionMessageAreNormalized() { + WorkflowStep step = + WorkflowStep.builder() + .name("nullable") + .layer(0) + .subtasks(List.of(task("A", "empty"))) + .build(); + ControlPoint nullOutput = + new ControlPoint() { + @Override + public CompletableFuture onTask( + TaskRequest request, WorkflowEngineClient engineClient) { + return CompletableFuture.completedFuture( + TaskResponse.builder().success(true).output(null).build()); + } + + @Override + public CompletableFuture onRoute( + String stepName, + Map results, + List conditions) { + 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(), + recordingCallback(), + "", + "zh") + .run() + .join(); + assertTrue(success.isSuccess()); + assertEquals("", success.getStepOutputs().get("nullable").get("empty")); + + ControlPoint nullMessageFailure = + new ControlPoint() { + @Override + public CompletableFuture onTask( + TaskRequest request, WorkflowEngineClient engineClient) { + return CompletableFuture.failedFuture(new RuntimeException((String) null)); + } + + @Override + public CompletableFuture onRoute( + String stepName, + Map results, + List conditions) { + 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(), + recordingCallback(), + "", + "zh") + .run() + .join(); + assertFalse(failure.isSuccess()); + assertFalse(String.valueOf(failure.getHistory().get(0).get("output")).isBlank()); + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/runner/ExecutePsopTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/runner/ExecutePsopTest.java index 851886a6..27d69f11 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 @@ -307,6 +307,29 @@ void builderRequiresControlPoint() { () -> ExecutePsop.builder().psop(linearWorkflow()).execute()); } + @Test + void injectedClientRemainsCallerOwnedAfterExecution() { + AtomicInteger closeCalls = new AtomicInteger(); + StubWorkflowEngineClient client = + new StubWorkflowEngineClient("A", "B") { + @Override + public void close() { + closeCalls.incrementAndGet(); + } + }; + + ExecutionResult result = + ExecutePsop.builder() + .psop(linearWorkflow()) + .controlPoint(autoCp()) + .engineClient(client) + .execute() + .join(); + + assertTrue(result.isSuccess()); + assertEquals(0, closeCalls.get()); + } + @Test void errorLifecycleOnTaskFailure() { StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); diff --git a/workflow-engine/src/test/resources/a2at-mock.env b/workflow-engine/src/test/resources/a2at-mock.env new file mode 100644 index 00000000..0a9f2d84 --- /dev/null +++ b/workflow-engine/src/test/resources/a2at-mock.env @@ -0,0 +1,16 @@ +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=classpath +A2AT_PROMPT_COMPLIANCE_ENABLED=false +A2AT_LLM_PROVIDER=workflow-engine-test +A2AT_LLM_MODEL=test-model +A2AT_LLM_API_KEY=test-key-not-used +A2AT_LLM_BASE_URL=https://llm.example.test/v1 +A2AT_LLM_MAX_TOKENS=256 +A2AT_LLM_TEMPERATURE=0 +A2AT_LLM_TIMEOUT_SECONDS=1 +A2AT_LLM_HISTORY_WINDOW=1 +A2AT_LLM_DISABLE_SYSTEM_PROXY=true +A2AT_LLM_SESSION_MAX_TOTAL=10 +A2AT_LLM_SESSION_MAX_PER_PROVIDER=10 +A2AT_LLM_MAX_ATTEMPTS=1 +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory diff --git a/workflow-engine/src/test/resources/a2at-test.env b/workflow-engine/src/test/resources/a2at-test.env new file mode 100644 index 00000000..9c09c95d --- /dev/null +++ b/workflow-engine/src/test/resources/a2at-test.env @@ -0,0 +1,16 @@ +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=classpath +A2AT_PROMPT_COMPLIANCE_ENABLED=false +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=test-model +A2AT_LLM_API_KEY=test-key-not-used +A2AT_LLM_BASE_URL=http://127.0.0.1:1/v1 +A2AT_LLM_MAX_TOKENS=256 +A2AT_LLM_TEMPERATURE=0 +A2AT_LLM_TIMEOUT_SECONDS=1 +A2AT_LLM_HISTORY_WINDOW=1 +A2AT_LLM_DISABLE_SYSTEM_PROXY=true +A2AT_LLM_SESSION_MAX_TOTAL=10 +A2AT_LLM_SESSION_MAX_PER_PROVIDER=10 +A2AT_LLM_MAX_ATTEMPTS=1 +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory From ff20c4247e547baebe99de8f157288842e57a999 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Tue, 25 Aug 2026 20:42:05 +0800 Subject: [PATCH 18/49] fix(starter): close streaming responses deterministically Signed-off-by: Zhoujie628 --- .../engine/spring/A2AAutoConfiguration.java | 4 +- .../workflow/engine/spring/A2AController.java | 98 ++++++++++++++++--- .../spring/A2AAutoConfigurationTest.java | 36 +++++++ 3 files changed, 120 insertions(+), 18 deletions(-) diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java index 4a13720e..e83032e3 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 @@ -203,7 +203,7 @@ public RestHandler restHandler(AgentCard card, RequestHandler handler, ExecutorS @Bean @ConditionalOnMissingBean public A2AController a2aController( - RestHandler restHandler, RequestHandler requestHandler, A2AProperties properties) { - return new A2AController(restHandler, requestHandler, properties); + RestHandler restHandler, RequestHandler requestHandler) { + return new A2AController(restHandler, requestHandler); } } diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java index c7643f42..4d1624d6 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 @@ -46,7 +46,11 @@ 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; /** @@ -71,13 +75,12 @@ public class A2AController { private final RestHandler restHandler; private final RequestHandler requestHandler; - private final String pathPrefix; + private final AtomicInteger activeStreams = new AtomicInteger(); + private final Object streamCompletionMonitor = new Object(); - public A2AController( - RestHandler restHandler, RequestHandler requestHandler, A2AProperties properties) { + public A2AController(RestHandler restHandler, RequestHandler requestHandler) { this.restHandler = restHandler; this.requestHandler = requestHandler; - this.pathPrefix = properties.getPathPrefix(); } @PostMapping("${a2at.server.path-prefix}/message:send") @@ -97,7 +100,7 @@ public ResponseEntity sendMessage(HttpServletRequest req, @RequestBody S value = "${a2at.server.path-prefix}/message:stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public ResponseBodyEmitter streamMessage(HttpServletRequest req, @RequestBody String body) { - ResponseBodyEmitter emitter = new ResponseBodyEmitter(0L); + ResponseBodyEmitter emitter = track(new ResponseBodyEmitter(0L)); try { SendMessageRequest.Builder builder = SendMessageRequest.newBuilder(); JsonFormat.parser().merge(body, builder); @@ -123,10 +126,10 @@ public void onSubscribe(Flow.Subscription s) { public void onNext(StreamingEventKind item) { try { StreamResponse sr = ProtoUtils.ToProto.streamResponse(item); - String compact = - JsonFormat.printer() - .omittingInsignificantWhitespace() - .print(sr); + String compact = + JsonFormat.printer() + .omittingInsignificantWhitespace() + .print(sr); String sse = String.format(Locale.ROOT, "id:%d%n", seq.incrementAndGet()) + "data:" @@ -160,7 +163,7 @@ public void onComplete() { return emitter; } - @GetMapping("/tasks/{id}") + @GetMapping("${a2at.server.path-prefix}/tasks/{id}") public ResponseEntity getTask( HttpServletRequest req, @PathVariable("id") String taskId) { @@ -175,7 +178,7 @@ public ResponseEntity getTask( .body(resp.getBody()); } - @PostMapping("/tasks/{id}:cancel") + @PostMapping("${a2at.server.path-prefix}/tasks/{id}:cancel") public ResponseEntity cancelTask( HttpServletRequest req, @PathVariable("id") String taskId, @@ -192,12 +195,12 @@ public ResponseEntity cancelTask( } @PostMapping( - value = "/tasks/{id}:subscribe", + value = "${a2at.server.path-prefix}/tasks/{id}:subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public ResponseBodyEmitter subscribeToTask( HttpServletRequest req, @PathVariable("id") String taskId) { - ResponseBodyEmitter emitter = new ResponseBodyEmitter(0L); + ResponseBodyEmitter emitter = track(new ResponseBodyEmitter(0L)); try { var ctx = buildContext(req); var resp = restHandler.subscribeToTask(ctx, "", taskId); @@ -246,14 +249,77 @@ public void onComplete() { } return emitter; } + + /** + * Waits until every SSE response has left the Servlet asynchronous context. This is mainly + * useful for deterministic application shutdown; receiving the terminal A2A event can precede + * the container's response-completion callback by a few milliseconds. + * + * @return {@code true} when all streams drained before the timeout + */ + public boolean awaitStreamsDrained(Duration timeout) { + if (timeout == null || timeout.isNegative()) { + throw new IllegalArgumentException("timeout must not be null or negative"); + } + long deadline = System.nanoTime() + timeout.toNanos(); + synchronized (streamCompletionMonitor) { + while (activeStreams.get() > 0) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + return false; + } + try { + TimeUnit.NANOSECONDS.timedWait(streamCompletionMonitor, remaining); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + } + return true; + } + + public int activeStreamCount() { + return activeStreams.get(); + } + + private ResponseBodyEmitter track(ResponseBodyEmitter emitter) { + activeStreams.incrementAndGet(); + AtomicBoolean completed = new AtomicBoolean(); + Runnable finish = + () -> { + if (completed.compareAndSet(false, true)) { + activeStreams.decrementAndGet(); + synchronized (streamCompletionMonitor) { + streamCompletionMonitor.notifyAll(); + } + } + }; + emitter.onCompletion(finish); + emitter.onTimeout(finish); + emitter.onError(ignored -> finish.run()); + return emitter; + } + private ServerCallContext buildContext(HttpServletRequest req) { Map state = new LinkedHashMap<>(); Map headers = new LinkedHashMap<>(); - java.util.Collections.list(req.getHeaderNames()) - .forEach(h -> headers.put(h.toLowerCase(Locale.ROOT), req.getHeader(h))); + if (req.getHeaderNames() != null) { + java.util.Collections.list(req.getHeaderNames()) + .forEach(h -> headers.put(h.toLowerCase(Locale.ROOT), req.getHeader(h))); + } state.put("headers", headers); String ext = req.getHeader("A2A-Extensions"); - Set exts = (ext == null || ext.isBlank()) ? Set.of() : Set.of(ext.split(",")); + 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()) { diff --git a/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java index 55d8ab32..05d2524d 100644 --- a/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java +++ b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java @@ -17,12 +17,48 @@ 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 java.util.concurrent.ExecutorService; import java.util.concurrent.ThreadPoolExecutor; class A2AAutoConfigurationTest { + @Test + void everyTaskRouteUsesConfiguredA2aPathPrefix() throws Exception { + String placeholder = "${a2at.server.path-prefix}"; + + assertEquals( + placeholder + "/tasks/{id}", + A2AController.class + .getMethod( + "getTask", + jakarta.servlet.http.HttpServletRequest.class, + String.class) + .getAnnotation(GetMapping.class) + .value()[0]); + assertEquals( + placeholder + "/tasks/{id}:cancel", + A2AController.class + .getMethod( + "cancelTask", + jakarta.servlet.http.HttpServletRequest.class, + String.class, + String.class) + .getAnnotation(PostMapping.class) + .value()[0]); + assertEquals( + placeholder + "/tasks/{id}:subscribe", + A2AController.class + .getMethod( + "subscribeToTask", + jakarta.servlet.http.HttpServletRequest.class, + String.class) + .getAnnotation(PostMapping.class) + .value()[0]); + } + @Test void blockingTimeoutsComeFromProperties() { A2AProperties properties = new A2AProperties(); From f32766b1028a6d8ebbbbd2fd03f834e96cbbf017 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Tue, 25 Aug 2026 20:42:58 +0800 Subject: [PATCH 19/49] feat(samples): model SPN workflow protocol operations Signed-off-by: Zhoujie628 --- .../agents/NegotiationBaseAgentExecutor.java | 337 ++++++-------- .../agents/SpnDomainAgentCity1Executor.java | 27 +- .../agents/SpnDomainAgentCity2Executor.java | 11 +- .../examples/demo/A2ATProtocolCases.java | 198 ++++---- .../engine/examples/demo/SpnCasePrompts.java | 11 +- .../extension/AuthorizationPolicy.java | 185 ++++++++ .../extension/ExtensionPrePositioner.java | 112 ++++- .../extension/NotificationPolicy.java | 40 ++ .../PrePositionedExtensionHandler.java | 435 +++++++++++++----- .../extension/SdkSlotSchemaLoader.java | 53 +++ .../negotiation/NegotiationStrategy.java | 26 +- .../negotiation/NegotiationUtils.java | 5 +- .../examples/server/JdkHttpA2AServer.java | 45 +- .../engine/examples/util/EnvResolver.java | 20 +- .../engine/examples/util/LlmHelper.java | 59 ++- .../workbench/WorkbenchControlPoint.java | 83 ++-- .../WorkbenchExtensionLifecycle.java | 107 +++-- .../workbench/WorkbenchOrchestrator.java | 104 ++++- .../workbench/WorkbenchTaskInputParser.java | 140 ++++++ .../agentcard/transport_workbench_agent.json | 4 +- .../content_validation/en-US/system.md | 28 ++ .../prompts/content_validation/en-US/user.md | 7 + .../content_validation/zh-CN/system.md | 28 ++ .../prompts/content_validation/zh-CN/user.md | 7 + 24 files changed, 1530 insertions(+), 542 deletions(-) create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicy.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/extension/NotificationPolicy.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/extension/SdkSlotSchemaLoader.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskInputParser.java create mode 100644 samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md create mode 100644 samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/user.md create mode 100644 samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md create mode 100644 samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/user.md 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 fad47f61..4ecfdd25 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 @@ -23,6 +23,8 @@ 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.ServerCallContext; @@ -46,7 +48,10 @@ 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.client.A2ATExtension; import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; /** * Server-side negotiation base, mirroring the Python reference @@ -57,33 +62,39 @@ * negotiation and replies with INPUT_REQUIRED carrying the negotiation context in task metadata; on * a Negotiation-T reply (metadata has Negotiation-T key, no Task-T key) it re-executes the business and completes. * - *

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

      Negotiation is triggered by the extension header or incomplete SDK-validated task data. A + * configured A2A-T client/server is mandatory for protocol generation and validation; the sample + * never manufactures protocol-shaped fallback text. */ public abstract class NegotiationBaseAgentExecutor extends BaseAgentExecutor { private static final Logger log = LoggerFactory.getLogger(NegotiationBaseAgentExecutor.class); // Pre-positioned extensions (Authorization-T / Notification-T) are handled // by a dedicated handler, keeping this class focused on Negotiation-T. - private final PrePositionedExtensionHandler prePositionedHandler = - new PrePositionedExtensionHandler(); + private final PrePositionedExtensionHandler prePositionedHandler; private final BlockingQueue notificationQueue = new LinkedBlockingQueue<>(); private volatile A2ATClient a2atClient; - /** The pre-positioned Authorization-T whitelist policy text, or null. */ - protected final String getAuthorizationPolicy() { + protected NegotiationBaseAgentExecutor() { + this(new PrePositionedExtensionHandler()); + } + + protected NegotiationBaseAgentExecutor(PrePositionedExtensionHandler prePositionedHandler) { + this.prePositionedHandler = java.util.Objects.requireNonNull(prePositionedHandler); + } + + /** The active SDK-validated Authorization-T whitelist, or null. */ + protected final AuthorizationPolicy getAuthorizationPolicy() { return prePositionedHandler.getAuthorizationPolicy(); } - /** The pre-positioned Notification-T subscription text, or null. */ - protected final String getNotificationSubscription() { + /** The active Notification-T subscription, or null. */ + protected final NotificationPolicy getNotificationSubscription() { return prePositionedHandler.getNotificationSubscription(); } protected void reportRecoveryResult(AgentEmitter emitter, String result) { - String notifUri = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + String notifUri = A2ATExtension.NOTIFICATION_T.uri(); Map notifMeta = new LinkedHashMap<>(); notifMeta.put(notifUri, result); List> rParts = List.of(new TextPart(result)); @@ -92,14 +103,19 @@ protected void reportRecoveryResult(AgentEmitter emitter, String result) { } protected void pushNotificationResult(String result) { + if (getNotificationSubscription() == null) { + log.warn( + "[{}] Dropping recovery result because no validated Notification-T subscription is active", + getClass().getSimpleName()); + return; + } if (!notificationQueue.offer(result)) { log.warn("[{}] Notification queue rejected a result", getClass().getSimpleName()); } } /** - * Resolve the A2A-T .env path; null disables A2ATClient (negotiation still works with fallback - * context). + * Resolve the A2A-T .env path; null means Negotiation-T cannot be served. */ protected abstract String resolveEnvPath(); @@ -107,9 +123,6 @@ private A2ATClient a2at() { if (a2atClient != null) { return a2atClient; } - if (Boolean.getBoolean("a2at.llm.disabled")) { - return null; - } String env = resolveEnvPath(); if (env == null || env.isBlank()) { return null; @@ -119,11 +132,8 @@ private A2ATClient a2at() { log.info("[{}] A2ATClient ready for negotiation", getClass().getSimpleName()); return a2atClient; } catch (Exception e) { - log.warn( - "[{}] A2ATClient init failed, negotiation will use fallback context: {}", - getClass().getSimpleName(), - e.getMessage()); - return null; + throw new IllegalStateException( + "A2A-T client initialization failed for " + getClass().getSimpleName(), e); } } @@ -134,9 +144,6 @@ protected net.openan.a2at.sdk.server.A2ATServer a2atServer() { if (a2atServer != null) { return a2atServer; } - if (Boolean.getBoolean("a2at.llm.disabled")) { - return null; - } String env = resolveEnvPath(); if (env == null || env.isBlank()) { return null; @@ -145,11 +152,8 @@ protected net.openan.a2at.sdk.server.A2ATServer a2atServer() { a2atServer = new net.openan.a2at.sdk.server.A2ATServer(Path.of(env)); return a2atServer; } catch (Exception e) { - log.warn( - "[{}] A2ATServer init failed, task validation disabled: {}", - getClass().getSimpleName(), - e.getMessage()); - return null; + throw new IllegalStateException( + "A2A-T server initialization failed for " + getClass().getSimpleName(), e); } } @@ -203,7 +207,7 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { try { String prePositionedExt = PrePositionedExtensionHandler.detect(ctx); if (prePositionedExt != null) { - if (prePositionedExt.contains("Notification")) { + if (A2ATExtension.NOTIFICATION_T.uri().equals(prePositionedExt)) { handleNotificationSubscription(ctx, emitter); } else { prePositionedHandler.handle( @@ -249,6 +253,26 @@ private void handleNotificationSubscription(RequestContext ctx, AgentEmitter emi String agentTag = getClass().getSimpleName(); String notifUri = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + Map requestMetadata = ctx.getMessage().metadata(); + Object promptValue = requestMetadata.entrySet().stream() + .filter(entry -> entry.getKey().contains("Notification-T")) + .map(Map.Entry::getValue) + .findFirst() + .orElse(""); + 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, @@ -265,8 +289,19 @@ private void handleNotificationSubscription(RequestContext ctx, AgentEmitter emi "订阅成功,启动业务抢通事件上报任务", Map.of()); while (!Thread.currentThread().isInterrupted()) { - String result = notificationQueue.poll(30, TimeUnit.SECONDS); + long heartbeatSeconds = Math.max( + 1L, Long.getLong("a2at.notification.heartbeat.seconds", 30L)); + String result = notificationQueue.poll(heartbeatSeconds, TimeUnit.SECONDS); if (result == 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( @@ -290,7 +325,7 @@ private void handleNewTask(RequestContext ctx, AgentEmitter emitter, String inpu } else { log.info( "[{}] Parameters sufficient, skipping negotiation", getClass().getSimpleName()); - runBusinessAndComplete(ctx, emitter, input, null); + runBusinessAndComplete(ctx, emitter, input); } } @@ -298,11 +333,8 @@ private boolean isNegotiationRequested(RequestContext ctx) { ServerCallContext callContext = ctx.getCallContext(); if (callContext == null) return false; String negUri = NegotiationUtils.NEGOTIATION_T_URI; - if (callContext.isExtensionRequested(negUri)) return true; - for (String ext : callContext.getRequestedExtensions()) { - if (ext.toUpperCase(java.util.Locale.ROOT).contains("NEGOTIATION-T")) return true; - } - return false; + return callContext.isExtensionRequested(negUri) + || callContext.getRequestedExtensions().contains(negUri); } private boolean isNegotiationReply(RequestContext ctx) { @@ -323,60 +355,27 @@ private void handleFollowUp(RequestContext ctx, AgentEmitter emitter, String inp ctx.getMessage() != null ? ctx.getMessage().metadata() : null; net.openan.a2at.sdk.core.model.NegotiationContext negotiationCtx = parseNegotiationContext(meta); - String acceptText = null; if (client != null) { try { - FilledParamData filled = client.validateProposePromptAndDataFilling( + FilledParamData filled = client.validateAcceptPromptAndDataFilling( cleanInput, negotiationCtx, buildNegotiationParamSchema(), - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); if (filled != null && filled.data() != null && !filled.data().isEmpty()) { log.info("[{}] Negotiation params validated and filled: {} keys", getClass().getSimpleName(), filled.data().keySet()); } } catch (Exception e) { - log.warn("[{}] validateProposePromptAndDataFilling failed: {}", + log.warn("[{}] validateAcceptPromptAndDataFilling rejected the follow-up: {}", getClass().getSimpleName(), e.getMessage()); + emitter.fail(buildStatusMessage( + ctx.getContextId(), + ctx.getTaskId(), + "Negotiation-T Accept validation failed")); + return; } - acceptText = renderAcceptText(client, negotiationCtx); } log.info("[{}] Follow-up received, re-executing business", getClass().getSimpleName()); - runBusinessAndComplete(ctx, emitter, cleanInput, acceptText); - } - - /** - * Renders the Negotiation-T Accept for the received propose. Rendering failures are logged - * only (null return); the business proceeds regardless. - */ - private String renderAcceptText( - A2ATClient client, net.openan.a2at.sdk.core.model.NegotiationContext negotiationCtx) { - try { - net.openan.a2at.sdk.core.model.NegotiationContext acceptCtx = - negotiationCtx != null - ? negotiationCtx - : 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.MetadataContent mc = - client.generateNegotiationAcceptPromptFromText( - "同意补充所缺参数,信息已完整", - acceptCtx, - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); - if (mc != null && mc.promptText() != null && !mc.promptText().isEmpty()) { - log.info( - "[{}] Negotiation Accept rendered via {}", - getClass().getSimpleName(), - mc.templateUri()); - return mc.promptText(); - } - } catch (Exception e) { - log.warn( - "[{}] Accept rendering failed: {}", - getClass().getSimpleName(), - e.getMessage()); - } - return null; + runBusinessAndComplete(ctx, emitter, cleanInput); } /** @@ -395,19 +394,12 @@ private String renderAcceptText( private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, String input) { String taskId = ctx.getTaskId(); String contextId = ctx.getContextId(); - String negId = java.util.UUID.randomUUID().toString(); - String negText = renderProposeText(negId, input); - // The content layer is stateless — the agent owns the session context. Serialize it - // into the propose metadata (negotiation_context key) so the client-side engine can - // parse it and advance rounds itself, mirroring the official demo pattern. - Map contextMap = new LinkedHashMap<>(); - contextMap.put("id", negId); - contextMap.put("round", 1); - contextMap.put( - "maxRounds", - net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); - Map metadata = - NegotiationUtils.negotiationResponseMetadata(negText, contextMap); + net.openan.a2at.sdk.core.model.NegotiationContext negotiationContext = + new net.openan.a2at.sdk.core.model.NegotiationContext( + java.util.UUID.randomUUID().toString(), + 1, + net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); + Map metadata = renderProposeMetadata(negotiationContext, input); Message proposeMessage = Message.builder() .messageId(java.util.UUID.randomUUID().toString()) @@ -424,49 +416,42 @@ private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, String } /** - * Renders the propose text through the SDK content engine when available: the subclass's + * Renders the propose text through the SDK content engine: the subclass's * {@link #proposeTemplateUri()} picks the template, {@link #buildProposeContent(String)} - * supplies the typed content, and the SDK renders deterministically (no LLM). Falls back to - * {@link #defaultNegotiationText()} when the SDK client is unavailable or rendering fails. + * supplies the typed content, and the SDK renders deterministically (no LLM). Missing SDK + * configuration, missing typed content, or rendering failure rejects the task. */ - private String renderProposeText(String negotiationId, String input) { + private Map renderProposeMetadata( + net.openan.a2at.sdk.core.model.NegotiationContext negotiationContext, + String input) { A2ATClient client = a2at(); net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent content = buildProposeContent(input); - if (client == null || content == null) { - return defaultNegotiationText().replace("PLACEHOLDER_NEGOTIATION_ID", negotiationId); + if (client == null) { + throw new IllegalStateException("A2A-T client is required for Negotiation-T propose"); } - try { - net.openan.a2at.sdk.core.model.NegotiationContext contentCtx = - new net.openan.a2at.sdk.core.model.NegotiationContext( - negotiationId, - 1, - net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); - net.openan.a2at.sdk.core.model.MetadataContent mc = - client.generateNegotiationProposePromptFromData( - new net.openan.a2at.sdk.negotiation.content.NegotiationProposeData( - contentCtx, content), - proposeTemplateUri()); - if (mc != null && mc.promptText() != null && !mc.promptText().isEmpty()) { - log.info( - "[{}] SDK-rendered propose via {}: {} chars", - getClass().getSimpleName(), - mc.templateUri(), - mc.promptText().length()); - return mc.promptText(); - } - } catch (Exception e) { - log.warn( - "[{}] SDK propose rendering failed ({}); using default negotiation text", - getClass().getSimpleName(), - e.getMessage()); + if (content == null) { + throw new IllegalStateException("Typed Negotiation-T propose content is required"); + } + net.openan.a2at.sdk.core.model.MetadataContent mc = + client.generateNegotiationProposePromptFromData( + new net.openan.a2at.sdk.negotiation.content.NegotiationProposeData( + negotiationContext, content), + proposeTemplateUri()); + if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { + throw new IllegalStateException("A2A-T SDK returned empty Negotiation-T propose"); } - return defaultNegotiationText().replace("PLACEHOLDER_NEGOTIATION_ID", negotiationId); + log.info( + "[{}] SDK-rendered propose via {}: {} chars", + getClass().getSimpleName(), + mc.templateUri(), + mc.promptText().length()); + return new LinkedHashMap<>(mc.buildMetadataContent()); } /** Run business logic, emit artifact, and complete the task. */ private void runBusinessAndComplete( - RequestContext ctx, AgentEmitter emitter, String input, String acceptText) { + RequestContext ctx, AgentEmitter emitter, String input) { String taskId = ctx.getTaskId(); String contextId = ctx.getContextId(); long started = System.nanoTime(); @@ -478,13 +463,6 @@ private void runBusinessAndComplete( input != null ? input.length() : 0); String response = executeBusiness(ctx, emitter, input); Map metadata = buildResponseMetadata(ctx, response); - if (acceptText != null && !acceptText.isEmpty()) { - // Negotiation Accept rides with the result artifact (not as a standalone agent - // Message, which would close the SSE stream before the final artifact). A dedicated - // key — NOT the Negotiation-T extension URI, which the engine treats as the - // "negotiation needed" trigger signal and would loop on. - metadata.put("negotiation_accept", acceptText); - } List> parts = List.of(new TextPart(response)); emitter.addArtifact(parts, "result", buildArtifactName(), metadata, false, true); emitStatus( @@ -543,7 +521,7 @@ protected net.openan.a2at.sdk.core.model.TemplateUri proposeTemplateUri() { * * * @param input the task input text - * @return typed content, or null to fall back to {@link #defaultNegotiationText()} + * @return typed content; null rejects negotiation because raw fallbacks are not protocol-safe */ protected net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent buildProposeContent( String input) { @@ -569,19 +547,33 @@ protected boolean needsNegotiation(String input) { input, buildTaskParamSchema(), taskTemplateUri()); Map data = filled != null ? filled.data() : null; if (data == null) { - return false; + log.info("[{}] Task validation returned no data; negotiation needed", + getClass().getSimpleName()); + return true; } - for (Map.Entry entry : data.entrySet()) { - Object value = entry.getValue(); + Object properties = buildTaskParamSchema().get("properties"); + java.util.Set expectedFields = + properties instanceof Map propertyMap + ? propertyMap.keySet().stream().map(String::valueOf) + .collect(java.util.stream.Collectors.toCollection( + java.util.LinkedHashSet::new)) + : data.keySet(); + for (String field : expectedFields) { + Object value = data.get(field); if (value == null || (value instanceof String s && s.isBlank())) { log.info("[{}] Task param '{}' is blank; negotiation needed", - getClass().getSimpleName(), entry.getKey()); + getClass().getSimpleName(), field); return true; } } return false; - } catch (Exception e) { - // Validation rejected (semantic/rule failure) means the prompt is not usable as-is. + } catch (ContentValidationException e) { + if (!A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED.equals(e.getCode()) + && !A2ATErrorCodes.SLOT_VALIDATION_ERROR.equals(e.getCode())) { + throw e; + } + // A semantic/slot rejection is resolvable through Negotiation-T. Infrastructure and + // configuration failures are propagated instead of misreported as missing data. log.info("[{}] Task validation failed ({}); negotiation needed", getClass().getSimpleName(), e.getMessage()); return true; @@ -610,20 +602,10 @@ protected String buildArtifactName() { return getClass().getSimpleName() + " result"; } - /** Default negotiation text shown when A2ATClient is unavailable. */ - protected String defaultNegotiationText() { - // Mirrors the SDK information-propose template without the context section (the SDK - // carries id/round/maxRounds in the negotiationContext metadata key since 2026-08). - return "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n" - + "## 所需信息项\n1. 接入端口名称,举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1\n" - + "2. 投诉分类,举例:专线质差\n" - + "缺失项之间的关系:以上参数均为必选,缺少无法启动诊断"; - } - /** * Parameter JSON Schema for the SDK validate-and-fill pipeline: the business parameters this - * agent expects the negotiation follow-up to carry. Mirrors the missing-info items of - * {@link #defaultNegotiationText()}. + * agent expects the negotiation follow-up to carry. Mirrors the typed information-propose + * items returned by {@link #buildProposeContent(String)}. */ protected Map buildNegotiationParamSchema() { Map properties = new LinkedHashMap<>(); @@ -643,59 +625,20 @@ protected String defaultNegotiationConcern() { /** * Parses the SDK-carried negotiation context for the validate APIs. * - *

      Primary source: the {@code negotiationContext} metadata key — but the SDK's {@code - * buildMetadataContent} only embeds {@code id} there, so as a fallback the stateful {@code - * negotiation_context} map is synthesized (covering both the raw context shape and the - * receiveNegotiation-result shape wrapping it under "context"). Returns null when neither is - * usable; the SDK validate APIs treat a null context as not-a-negotiation-message. + *

      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); - if (raw instanceof Map contextMap) { - try { - Object id = contextMap.get("id"); - Object round = contextMap.get("round"); - Object maxRounds = contextMap.get("maxRounds"); - if (id instanceof String s && round instanceof Number r && maxRounds instanceof Number m) { - return new net.openan.a2at.sdk.core.model.NegotiationContext( - s, r.intValue(), m.intValue()); - } - } catch (IllegalArgumentException e) { - log.debug( - "[{}] Malformed negotiationContext metadata: {}", - NegotiationBaseAgentExecutor.class.getSimpleName(), - e.getMessage()); - } - } - Object stateful = metadata.get("negotiation_context"); - if (stateful instanceof Map outer) { - Map stateMap = outer; - Object inner = outer.get("context"); - if (inner instanceof Map innerMap && innerMap.get("negotiationId") != null) { - stateMap = innerMap; - } - Object id = stateMap.get("negotiationId") != null - ? stateMap.get("negotiationId") - : stateMap.get("id"); - Object round = stateMap.get("round"); - if (id instanceof String s && round instanceof Number r) { - try { - return new net.openan.a2at.sdk.core.model.NegotiationContext( - s, - r.intValue(), - net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS); - } catch (IllegalArgumentException ignored) { - // fall through - } - } - } - 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.A2ATContentFacade.contextFromMap(contextMap) + : null; } private static long elapsedMillis(long startedNanos) { 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 c0befdf1..a069f583 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java @@ -19,12 +19,16 @@ package dev.openan.workflow.engine.examples.agents; +import dev.openan.workflow.engine.examples.extension.AuthorizationPolicy; +import dev.openan.workflow.engine.examples.extension.PrePositionedExtensionHandler; import dev.openan.workflow.engine.examples.util.LlmHelper; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.LinkedHashMap; import java.util.Map; @@ -37,7 +41,7 @@ * it replies INPUT_REQUIRED to start a Negotiation-T round, and on the follow-up it runs the * diagnosis/recovery business. City1 side has a FAULT (port Down, optical power -28dBm). * Diagnosis/recovery text is LLM-generated (deepseek) when the A2A-T .env is configured, else - * deterministic. Authorization-T and Notification-T are pre-positioned before the workflow starts, + * deterministic. Authorization-T and Notification-T are initiated on independent channels, * so this agent no longer injects them in response metadata. */ public class SpnDomainAgentCity1Executor extends NegotiationBaseAgentExecutor { @@ -63,6 +67,14 @@ public class SpnDomainAgentCity1Executor extends NegotiationBaseAgentExecutor { + "8. 业务抢通方案执行结束时间:2026-05-11T08:31:46Z\n" + "9. 业务抢通方案执行结果:成功"; + 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. 诊断结果(成功/失败);" @@ -92,18 +104,19 @@ protected String executeBusiness(RequestContext ctx, AgentEmitter emitter, Strin } /** - * After diagnosis, check the pre-positioned Authorization-T whitelist policy. If the repair + * After diagnosis, check the active Authorization-T whitelist policy. If the repair * action matches the whitelist, execute recovery and return the result (reported as * Notification-T metadata by the base class). If not in whitelist, return a refusal message. */ private String selfTriggerRecovery(RequestContext ctx, String diagnosisResult) { - String policy = getAuthorizationPolicy(); + AuthorizationPolicy policy = getAuthorizationPolicy(); boolean inWhitelist = policy != null - && !policy.isEmpty() - && (policy.contains("业务抢通") - || policy.contains("光模块") - || policy.contains("授权")); + && policy.authorizes( + "业务投诉诊断", + "业务抢通", + "隧道调优", + LocalDate.now(ZoneId.of("Asia/Shanghai"))); if (inWhitelist) { log.info("[SPN-Domain-Agent] Fault in whitelist, self-triggering recovery"); String recoveryResult = llmRecoveryResult(diagnosisResult, RECOVERY_RESULT); 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 8276ccd6..1cf0e720 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java @@ -19,6 +19,7 @@ package dev.openan.workflow.engine.examples.agents; +import dev.openan.workflow.engine.examples.extension.PrePositionedExtensionHandler; import dev.openan.workflow.engine.examples.util.LlmHelper; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; @@ -35,7 +36,7 @@ * *

      Server-side negotiation-capable (extends {@link NegotiationBaseAgentExecutor}). City2 side is * NORMAL. Diagnosis/recovery text is LLM-generated when the A2A-T .env is configured, else - * deterministic. Authorization-T and Notification-T are pre-positioned before the workflow starts. + * deterministic. Authorization-T and Notification-T are initiated on independent channels. */ public class SpnDomainAgentCity2Executor extends NegotiationBaseAgentExecutor { private static final Logger log = LoggerFactory.getLogger(SpnDomainAgentCity2Executor.class); @@ -46,6 +47,14 @@ public class SpnDomainAgentCity2Executor extends NegotiationBaseAgentExecutor { + "2. 诊断结果详情:城市2OMC端口状态正常,光功率-17dBm(正常范围),无异常告警,故障不在此地市\n" + "3. 修复建议:城市2无需修复"; + public SpnDomainAgentCity2Executor() { + super(); + } + + public SpnDomainAgentCity2Executor(PrePositionedExtensionHandler extensionHandler) { + super(extensionHandler); + } + private static String llmDiagnosisResult(String input, String fallback) { String env = EnvResolver.resolveEnvPath(); String sys = "你是SPN领域OMC故障诊断专家。按如下结构输出:1. 诊断结果(成功/失败);" 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 58afc936..b6c217fe 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 @@ -8,6 +8,7 @@ import dev.openan.workflow.engine.client.A2ATransport; import dev.openan.workflow.engine.client.DefaultExtensionSender; import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; +import dev.openan.workflow.engine.client.NotificationSubscription; import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; import dev.openan.workflow.engine.examples.server.OmcAgentLauncher; @@ -21,6 +22,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; /** @@ -36,13 +38,19 @@ public class A2ATProtocolCases { private static final long STARTUP_WAIT = 3; private final OmcAgentLauncher omc = new OmcAgentLauncher(); - private A2ATransport transport; + private A2ATransport taskTransport; + private A2ATransport authorizationTransport; + private A2ATransport notificationTransport; private DefaultWorkflowEngineClient client; - private DefaultExtensionSender sender; + private DefaultWorkflowEngineClient notificationControlClient; + private DefaultExtensionSender authorizationSender; + private DefaultExtensionSender notificationSender; private final AtomicReference lastTaskId = new AtomicReference<>(); + private final AtomicReference notificationTaskId = new AtomicReference<>(); + private final AtomicBoolean recoveryNotificationReceived = new AtomicBoolean(); + private NotificationSubscription notificationSubscription; public static void main(String[] args) throws Exception { - System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); String caseId = "all"; for (String arg : args) { if (arg.startsWith("--case=")) caseId = arg.substring("--case=".length()); @@ -85,22 +93,34 @@ private void setup() throws Exception { TimeUnit.SECONDS.sleep(STARTUP_WAIT); log.info("[ProtocolCases] OMC agent {} started on https://127.0.0.1:26335", AGENT_NAME); - transport = new A2ATransport( - List.of(OmcAgentLauncher.cardFromResource("agentcard/spn_domain_agent_city1.json")), - null, + List cards = + List.of(OmcAgentLauncher.cardFromResource( + "agentcard/spn_domain_agent_city1.json")); + WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .sslVerify(false) .a2atEnvPath(EnvResolver.resolveEnvPath()) .credentialsConfigPath( OmcAgentLauncher.resourcePath("spn_agent_credentials.json")) - .build()); - client = new DefaultWorkflowEngineClient(transport); - sender = new DefaultExtensionSender(transport); + .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); + 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 (transport != null) transport.close(); + if (notificationControlClient != null) notificationControlClient.close(); + if (taskTransport != null) taskTransport.close(); + if (authorizationTransport != null) authorizationTransport.close(); + if (notificationTransport != null) notificationTransport.close(); omc.close(); } @@ -167,105 +187,127 @@ private void case_7_4() { private void case_7_5() { log.info("[Case 7.5] Add authorization (Authorization-T)"); - String authPrompt = - "## 授权策略的操作类型\n新增授权策略\n\n" - + "## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作," - + "按照<预期输出>中定义的结构返回授权策略的操作执行结果。\n\n" - + "## 动网操作的授权策略列表\n" - + " - 动网操作的授权策略1\n" - + " - 动网操作的业务场景:业务投诉诊断\n" - + " - 动网操作的处置类型:业务抢通\n" - + " - 动网操作名称:隧道调优\n" - + " - 有效期:2026-06-01T12:00:00Z ~ 2030-06-18T12:00:00Z\n\n" - + "## 预期输出\n" - + "1. 授权操作执行结果,取值范围:成功、失败、部分成功;\n" - + "2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n" - + "3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;"; - SendMessageResult result = sender.sendExtensionMessage(AGENT_NAME, "新增动网操作授权", authPrompt, A2ATExtension.AUTHORIZATION_T).join(); + SendMessageResult result = + authorizationSender.sendExtensionMessageFromData( + AGENT_NAME, + "新增动网操作授权", + SpnCasePrompts.addAuthorizationData(), + SpnCasePrompts.authorizationSchema(), + A2ATExtension.AUTHORIZATION_T) + .join(); log.info("[Case 7.5] state={}", result.getTaskState()); } private void case_7_6() { log.info("[Case 7.6] Delete authorization (Authorization-T)"); - String delPrompt = - "## 授权策略的操作类型\n删除授权策略\n\n" - + "## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作," - + "按照<预期输出>中定义的结构返回授权策略的操作执行结果。\n\n" - + "## 动网操作的授权策略列表\n" - + " - 动网操作的授权策略1\n" - + " - 动网操作的授权策略标识:7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n\n" - + "## 预期输出\n" - + "1. 授权操作执行结果,取值范围:成功、失败、部分成功;\n" - + "2. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;"; - SendMessageResult result = sender.sendExtensionMessage(AGENT_NAME, "删除动网操作的授权", delPrompt, A2ATExtension.AUTHORIZATION_T).join(); + SendMessageResult result = + authorizationSender.sendExtensionMessageFromData( + AGENT_NAME, + "删除动网操作的授权", + Map.of( + "授权策略的操作类型", + "删除授权策略", + "动网操作的授权策略列表", + "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"), + SpnCasePrompts.authorizationSchema(), + A2ATExtension.AUTHORIZATION_T) + .join(); log.info("[Case 7.6] state={}", result.getTaskState()); } private void case_7_7() { log.info("[Case 7.7] Query authorization (Authorization-T)"); - String queryPrompt = - "## 授权策略的操作类型\n查询授权策略\n\n" - + "## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作," - + "按照<预期输出>中定义的结构返回授权策略的操作执行结果。\n\n" - + "## 动网操作的授权策略列表\n" - + " - 动网操作的授权策略1\n" - + " - 动网操作的业务场景:业务投诉诊断\n" - + " - 动网操作的处置类型:业务抢通\n" - + " - 动网操作名称:隧道调优\n\n" - + "## 预期输出\n" - + "1. 授权操作执行结果,取值范围:成功、失败;\n" - + "2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n" - + "3. 授权操作执行失败时,返回失败原因;"; - SendMessageResult result = sender.sendExtensionMessage(AGENT_NAME, "查询业务抢通操作授权信息", queryPrompt, A2ATExtension.AUTHORIZATION_T).join(); + SendMessageResult result = + authorizationSender.sendExtensionMessageFromData( + AGENT_NAME, + "查询业务抢通操作授权信息", + Map.of( + "授权策略的操作类型", + "查询授权策略", + "动网操作的授权策略列表", + "业务投诉诊断/业务抢通/隧道调优"), + SpnCasePrompts.authorizationSchema(), + A2ATExtension.AUTHORIZATION_T) + .join(); log.info("[Case 7.7] state={}", result.getTaskState()); } private void case_7_8() { log.info("[Case 7.8] Subscribe notification (Notification-T)"); - String notifPrompt = - "## 订阅描述\n请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息," - + "完成网络侧业务抢通事件的订阅与上报任务。\n\n" - + "## 通知主题\n业务抢通事件\n\n" - + "## 订阅条件\n\n" - + "## 上报通知数据格式\n### 业务抢通事件\n" - + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束(必选)\n" - + "2. 投诉诊断任务流水号(必选)\n" - + "3. OSS侧事件流水号(必选)\n" - + "4. 接入端口名称(必选)\n" - + "5. 是否已授权OMC自动抢通,取值范围:是、否;举例:是(必选)\n" - + "6. 业务抢通方案名称(必选)\n" - + "7. 业务抢通方案详情(必选)\n" - + "8. 业务抢通方案执行结束时间(可选)\n" - + "9. 业务抢通方案执行结果,取值范围:成功、失败(可选)\n" - + "10. 业务抢通方案执行失败原因(可选)\n\n" - + "## 预期输出\n" - + "1. 订阅结果,取值范围:成功、失败\n" - + "2. 订阅失败原因(可选)\n" - + "3. 订阅成功后,按照<上报通知数据格式>上报消息"; - SendMessageResult result = sender.sendNotification(AGENT_NAME, "订阅业务抢通事件", notifPrompt, data -> log.info("[Case 7.8] callback: keys={}", data.keySet())).join(); + recoveryNotificationReceived.set(false); + notificationSubscription = + notificationSender.openNotificationFromData( + AGENT_NAME, + "订阅业务抢通事件", + SpnCasePrompts.subscribeServiceRecoveryData(), + SpnCasePrompts.serviceRecoverySchema(), + data -> { + log.info("[Case 7.8] callback: keys={}", data.keySet()); + if ("recovery-result".equals(data.get("artifact_name"))) { + recoveryNotificationReceived.set(true); + } + }) + .join(); + SendMessageResult result = notificationSubscription.acknowledgement().join(); + 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(); + } log.info("[Case 7.9] Waiting 5s for pushed events..."); TimeUnit.SECONDS.sleep(5); - log.info("[Case 7.9] Done"); + if (recoveryNotificationReceived.get() && notificationSubscription != null) { + notificationSubscription.close(); + notificationSubscription = null; + log.info("[Case 7.9] Recovery result received; Notification-T stream closed"); + } else { + log.info("[Case 7.9] No recovery result yet; Notification-T stream remains active"); + } } private void case_7_10() { - String taskId = lastTaskId.get(); - if (taskId == null) { log.info("[Case 7.10] No task, skipping"); return; } + 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 = client.cancelTask(AGENT_NAME, taskId).join(); + SendMessageResult result = notificationControlClient.cancelTask(AGENT_NAME, taskId).join(); + if (notificationSubscription != null) { + notificationSubscription.close(); + notificationSubscription = null; + } log.info("[Case 7.10] state={}", result.getTaskState()); } private void case_7_11() { - String taskId = lastTaskId.get(); - if (taskId == null) { log.info("[Case 7.11] No task, skipping"); return; } + 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); - SendMessageResult result = client.subscribeToTask(AGENT_NAME, taskId, data -> log.info("[Case 7.11] callback: keys={}", data.keySet())).join(); + SendMessageResult result = + notificationControlClient + .subscribeToTask( + AGENT_NAME, + taskId, + data -> + log.info( + "[Case 7.11] callback: keys={}", data.keySet())) + .join(); log.info("[Case 7.11] state={}", result.getTaskState()); } } 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 7609e2e9..8dc6bf63 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 @@ -22,6 +22,7 @@ import dev.openan.workflow.engine.client.A2ATExtension; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -29,8 +30,9 @@ * *

      Holds the raw structured fields (not the rendered prompt): the demo passes these to the * engine's fromData track ({@code WorkflowEngineClient.sendMessageFromData}) and the A2A-T SDK - * renders the Task-T prompt deterministically from the data + schema. This mirrors the official - * SDK sample pattern — callers hand over business data, never a pre-rendered prompt. + * 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. @@ -69,6 +71,11 @@ public static Map privateLineComplaintSchema() { return schema; } + /** Required business fields returned by Task-T validation for this scenario. */ + public static List privateLineComplaintSchemaProperties() { + return List.of("任务对象", "任务上下文"); + } + /** Well-formed complaint (spec case 7.1): known faulty port in City1. */ public static Map privateLineComplaintData() { Map data = new LinkedHashMap<>(); diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicy.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicy.java new file mode 100644 index 00000000..8b279ae9 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicy.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Validated Authorization-T whitelist used by the SPN business callback. + * + *

      The protocol prompt itself is deliberately not used for authorization decisions. Only the + * fields returned by the A2A-T SDK validate-and-fill pipeline can create this value. + */ +public record AuthorizationPolicy(String operationType, List rules) { + + public static final String OPERATION_TYPE_FIELD = "授权策略的操作类型"; + public static final String POLICY_LIST_FIELD = "动网操作的授权策略列表"; + + public static final String ADD = "新增授权策略"; + public static final String MODIFY = "修改授权策略"; + public static final String DELETE = "删除授权策略"; + public static final String QUERY = "查询授权策略"; + + public AuthorizationPolicy { + operationType = requireText(operationType, OPERATION_TYPE_FIELD); + if (!ADD.equals(operationType) && !MODIFY.equals(operationType)) { + throw new IllegalArgumentException( + "Authorization operation must be " + ADD + " or " + MODIFY); + } + rules = List.copyOf(Objects.requireNonNull(rules, "Authorization rules are required")); + if (rules.isEmpty()) { + throw new IllegalArgumentException("Authorization policy must contain at least one rule"); + } + } + + /** Builds a strict policy from A2A-T SDK validated-and-filled fields. */ + public static AuthorizationPolicy fromValidated(Map data) { + Objects.requireNonNull(data, "Validated Authorization-T data is required"); + String operationType = operationFromValidated(data); + if (!ADD.equals(operationType) && !MODIFY.equals(operationType)) { + throw new IllegalArgumentException( + "Only add/modify 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 : policyList.split("[;\uff1b\\r\\n]+")) { + if (!entry.isBlank()) { + rules.add(Rule.parse(entry.strip())); + } + } + 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); + } + + /** Exact whitelist match; no substring or fuzzy authorization is permitted. */ + public boolean authorizes( + String businessScenario, String disposalType, String actionName, LocalDate onDate) { + Objects.requireNonNull(onDate, "Authorization decision date is required"); + return rules.stream() + .anyMatch(rule -> rule.authorizes(businessScenario, disposalType, actionName, onDate)); + } + + private static String requireText(Object value, String field) { + String text = value instanceof String string ? string.strip() : ""; + if (text.isEmpty()) { + throw new IllegalArgumentException("Authorization field is required: " + field); + } + return text; + } + + /** One exact whitelist rule: scenario / disposal type / action / validity range. */ + public record Rule( + String businessScenario, + String disposalType, + String actionName, + LocalDate validFrom, + LocalDate validUntil) { + + public Rule { + businessScenario = requireText(businessScenario, "业务场景"); + disposalType = requireText(disposalType, "处置类型"); + actionName = requireText(actionName, "操作名称"); + Objects.requireNonNull(validFrom, "Authorization validFrom is required"); + Objects.requireNonNull(validUntil, "Authorization validUntil is required"); + if (validUntil.isBefore(validFrom)) { + throw new IllegalArgumentException("Authorization validity end precedes start"); + } + } + + private static Rule parse(String entry) { + String[] fields = entry.split("/", -1); + if (fields.length != 4 && fields.length != 7) { + throw new IllegalArgumentException( + "Authorization rule must contain either the four required fields or all " + + "seven SDK fields: " + + entry); + } + int scenarioIndex = fields.length == 4 ? 0 : 1; + int validityIndex = fields.length == 4 ? 3 : 4; + String validity = normalizeValidity(fields[validityIndex]); + if ("永久生效".equals(validity)) { + return new Rule( + fields[scenarioIndex].strip(), + fields[scenarioIndex + 1].strip(), + fields[scenarioIndex + 2].strip(), + LocalDate.MIN, + LocalDate.MAX); + } + String[] range = validity.split("[~\uff5e]", -1); + if (range.length != 2) { + throw new IllegalArgumentException( + "Authorization validity must be start~end or 永久生效: " + + fields[validityIndex]); + } + try { + return new Rule( + fields[scenarioIndex].strip(), + fields[scenarioIndex + 1].strip(), + fields[scenarioIndex + 2].strip(), + parseDate(range[0]), + parseDate(range[1])); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException( + "Authorization validity must use ISO dates or offset date-times: " + + fields[validityIndex], + e); + } + } + + private static String normalizeValidity(String value) { + String normalized = value.strip(); + if (normalized.startsWith("限期生效")) { + normalized = normalized.substring("限期生效".length()).strip(); + } + if ((normalized.startsWith("(") && normalized.endsWith(")")) + || (normalized.startsWith("(") && normalized.endsWith(")"))) { + normalized = normalized.substring(1, normalized.length() - 1).strip(); + } + return normalized; + } + + private static LocalDate parseDate(String value) { + String normalized = value.strip(); + try { + return LocalDate.parse(normalized); + } catch (DateTimeParseException dateOnlyFailure) { + return OffsetDateTime.parse(normalized).toLocalDate(); + } + } + + 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 ee75c187..ddf2b32e 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 @@ -20,7 +20,9 @@ package dev.openan.workflow.engine.examples.extension; import dev.openan.workflow.engine.client.ExtensionSender; +import dev.openan.workflow.engine.client.NotificationSubscription; import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; +import dev.openan.workflow.engine.model.SendMessageResult; import org.a2aproject.sdk.spec.AgentCard; import org.slf4j.Logger; @@ -30,17 +32,19 @@ import java.util.Map; import java.util.function.Consumer; import java.util.concurrent.TimeUnit; +import net.openan.a2at.sdk.core.model.StandardTemplates; /** - * Pre-positions Authorization-T and Notification-T to downstream agents. + * Initializes independent Authorization-T and Notification-T operations for downstream agents. * - *

      Single responsibility: send the two pre-positioning control messages (one-shot whitelist - * policy + long-lived result subscription) to each non-workbench agent. The workbench extension - * lifecycle calls this once; individual workflow tasks do not own or repeat these operations. + *

      Single responsibility: send a one-shot whitelist operation and open a long-lived result + * subscription for each non-workbench agent on separate channels. The historical class name is + * retained for sample source compatibility; individual workflow tasks do not own these operations. */ public class ExtensionPrePositioner { private static final Logger log = LoggerFactory.getLogger(ExtensionPrePositioner.class); + private static final int AUTHORIZATION_VALIDATION_ATTEMPTS = 3; private final Map authData; private final Map authSchema; @@ -49,11 +53,13 @@ public class ExtensionPrePositioner { public ExtensionPrePositioner() { // Structured-data track: the raw policy / subscription fields; the SDK renders the - // Authorization-T and Notification-T prompts deterministically (spec cases 7.5/7.8). + // Authorization-T and Notification-T prompts through the SDK schema-aware pipelines + // (spec cases 7.5/7.8; slot extraction may use the SDK-configured LLM). this.authData = SpnCasePrompts.addAuthorizationData(); - this.authSchema = SpnCasePrompts.authorizationSchema(); + this.authSchema = SdkSlotSchemaLoader.loadConfigured( + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); this.notifData = SpnCasePrompts.subscribeServiceRecoveryData(); - this.notifSchema = SpnCasePrompts.serviceRecoverySchema(); + this.notifSchema = SdkSlotSchemaLoader.loadConfigured(StandardTemplates.SERVICE_RECOVERY); } /** @@ -63,8 +69,9 @@ public ExtensionPrePositioner() { * through the Notification-T SSE stream (e.g. recovery results) are forwarded to the * callback. The callback receives a Map with keys: agent, text, metadata, state. */ - public void prePosition( - ExtensionSender sender, + public List prePosition( + ExtensionSender authorizationSender, + ExtensionSender notificationSender, List agentCards, Consumer> notificationCallback) { long allStarted = System.nanoTime(); @@ -78,6 +85,7 @@ public void prePosition( "[PrePosition] START targetAgents={}, notificationCallback={}", targetCount, notificationCallback != null); + java.util.ArrayList subscriptions = new java.util.ArrayList<>(); for (AgentCard card : agentCards) { String name = card.name(); if (name.contains("Workbench")) { @@ -86,16 +94,8 @@ public void prePosition( long agentStarted = System.nanoTime(); try { long operationStarted = System.nanoTime(); - log.info("[PrePosition] SEND extension=Authorization-T, agent={}", name); - var authResult = - sender.sendExtensionMessageFromData( - name, - "新增动网操作授权", - authData, - authSchema, - dev.openan.workflow.engine.client.A2ATExtension - .AUTHORIZATION_T) - .join(); + log.info("[PrePosition] SEND extension=Authorization-T, agent={}", name); + SendMessageResult authResult = sendAuthorization(authorizationSender, name); log.info( "[PrePosition] ACK extension=Authorization-T, agent={}, state={}, " + "responseChars={}, elapsedMs={}", @@ -103,17 +103,30 @@ public void prePosition( authResult.getTaskState(), authResult.getText() != null ? authResult.getText().length() : 0, elapsedMillis(operationStarted)); + requireState( + authResult, + "Authorization-T", + name, + "TASK_STATE_COMPLETED"); operationStarted = System.nanoTime(); log.info("[PrePosition] SEND extension=Notification-T, agent={}", name); - var notificationResult = - sender.sendNotificationFromData( + NotificationSubscription subscription = + notificationSender.openNotificationFromData( name, "订阅业务抢通事件", notifData, notifSchema, notificationCallback) .join(); + subscriptions.add(subscription); + var notificationResult = subscription.acknowledgement().join(); + requireState( + notificationResult, + "Notification-T", + name, + "TASK_STATE_WORKING", + "TASK_STATE_COMPLETED"); log.info( "[PrePosition] ACK extension=Notification-T, agent={}, state={}, " + "responseChars={}, elapsedMs={}", @@ -142,14 +155,65 @@ public void prePosition( "[PrePosition] DONE targetAgents={}, elapsedMs={}", targetCount, elapsedMillis(allStarted)); + return List.copyOf(subscriptions); } - /** Pre-position without Notification-T event callback. */ - public void prePosition(ExtensionSender sender, List agentCards) { - prePosition(sender, agentCards, null); + private SendMessageResult sendAuthorization(ExtensionSender sender, String agentName) { + SendMessageResult result = null; + for (int attempt = 1; attempt <= AUTHORIZATION_VALIDATION_ATTEMPTS; attempt++) { + result = sender.sendExtensionMessageFromData( + agentName, + "新增动网操作授权", + authData, + authSchema, + dev.openan.workflow.engine.client.A2ATExtension.AUTHORIZATION_T) + .join(); + boolean retryableValidationFailure = + "TASK_STATE_FAILED".equals(result.getTaskState()) + && "Authorization-T validation failed".equals(result.getText()); + if (!retryableValidationFailure || attempt == AUTHORIZATION_VALIDATION_ATTEMPTS) { + return result; + } + log.warn( + "[PrePosition] RETRY extension=Authorization-T, agent={}, attempt={}/{}, reason=validator_mismatch", + agentName, + attempt + 1, + AUTHORIZATION_VALIDATION_ATTEMPTS); + } + return result; + } + + /** Pre-position through explicitly isolated Authorization-T and Notification-T senders. */ + public List prePosition( + ExtensionSender authorizationSender, + ExtensionSender notificationSender, + List agentCards) { + return prePosition(authorizationSender, notificationSender, agentCards, null); } private static long elapsedMillis(long startedNanos) { return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); } + + private static void requireState( + dev.openan.workflow.engine.model.SendMessageResult result, + String extension, + String agent, + String... allowedStates) { + String state = result != null ? result.getTaskState() : null; + boolean accepted = + state != null + && java.util.Arrays.stream(allowedStates) + .anyMatch(state::contains); + if (!accepted) { + throw new IllegalStateException( + extension + + " independent protocol operation failed for '" + + agent + + "': state=" + + state + + ", response=" + + (result != null ? result.getText() : "null")); + } + } } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/NotificationPolicy.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/NotificationPolicy.java new file mode 100644 index 00000000..c22549fb --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/NotificationPolicy.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import java.util.Map; +import java.util.Objects; + +/** SDK-validated Notification-T service-recovery subscription parameters. */ +public record NotificationPolicy(String condition, String reportFormat) { + public static final String CONDITION_FIELD = "订阅条件"; + public static final String REPORT_FORMAT_FIELD = "上报通知数据格式"; + + public NotificationPolicy { + condition = condition == null ? "" : condition.strip(); + reportFormat = requireText(reportFormat, REPORT_FORMAT_FIELD); + } + + public static NotificationPolicy fromValidated(Map data) { + Objects.requireNonNull(data, "Validated Notification-T data is required"); + Object rawCondition = data.get(CONDITION_FIELD); + if (rawCondition != null && !(rawCondition instanceof String)) { + throw new IllegalArgumentException("Notification field must be text: " + CONDITION_FIELD); + } + return new NotificationPolicy( + rawCondition instanceof String text ? text : "", + requireText(data.get(REPORT_FORMAT_FIELD), REPORT_FORMAT_FIELD)); + } + + private static String requireText(Object value, String field) { + String text = value instanceof String string ? string.strip() : ""; + if (text.isEmpty()) { + throw new IllegalArgumentException("Notification field is required: " + field); + } + return text; + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandler.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandler.java index 8fd492bb..ad169b8d 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 @@ -28,24 +28,29 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; +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 org.a2aproject.sdk.spec.TextPart; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import dev.openan.workflow.engine.examples.agents.BaseAgentExecutor; -import dev.openan.workflow.engine.examples.agents.NegotiationBaseAgentExecutor; +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.util.EnvResolver; /** - * Handles pre-positioned Authorization-T / Notification-T messages on the agent server side. + * 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 orchestrator before the workflow starts (via {@code - * WorkflowEngineClient.sendExtensionMessage}) to establish whitelists and subscriptions. When an + * 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: * *

        @@ -59,17 +64,48 @@ */ public class PrePositionedExtensionHandler { private static final Logger log = LoggerFactory.getLogger(PrePositionedExtensionHandler.class); - private volatile String authorizationPolicy; - private volatile String notificationSubscription; + private final AuthorizationPromptValidator authorizationValidator; + private final NotificationPromptValidator notificationValidator; + private volatile AuthorizationPolicy authorizationPolicy; + private volatile NotificationPolicy notificationSubscription; private volatile A2ATServer a2atServer; + private volatile String validationLanguage = "zh-CN"; - private A2ATServer a2atServer() { + /** Validation seam for deterministic integration tests; production uses {@link A2ATServer}. */ + @FunctionalInterface + public interface AuthorizationPromptValidator { + FilledParamData validate(String prompt, Map schema, TemplateUri templateUri); + } + + /** Validation seam for deterministic Notification-T integration tests. */ + @FunctionalInterface + public interface NotificationPromptValidator { + FilledParamData validate(String prompt, Map schema, TemplateUri templateUri); + } + + public PrePositionedExtensionHandler() { + this(null, null); + } + + public PrePositionedExtensionHandler(AuthorizationPromptValidator authorizationValidator) { + this(authorizationValidator, null); + } + + public PrePositionedExtensionHandler( + AuthorizationPromptValidator authorizationValidator, + NotificationPromptValidator notificationValidator) { + this.authorizationValidator = authorizationValidator; + this.notificationValidator = notificationValidator; + } + + private synchronized A2ATServer a2atServer() { if (a2atServer != null) return a2atServer; - if (Boolean.getBoolean("a2at.llm.disabled")) return null; - String env = System.getProperty("A2AT_ENV_PATH", System.getenv("A2AT_ENV_PATH")); + String env = EnvResolver.resolveEnvPath(); if (env == null || env.isBlank()) return null; try { - a2atServer = new A2ATServer(Path.of(env)); + Path envPath = Path.of(env); + validationLanguage = A2ATConfig.load(envPath).prompt().language(); + a2atServer = new A2ATServer(envPath); return a2atServer; } catch (Exception e) { log.warn("A2ATServer init failed: {}", e.getMessage()); @@ -87,13 +123,11 @@ public static String detect(RequestContext ctx) { if (msg == null || msg.metadata() == null) { return null; } - for (String key : msg.metadata().keySet()) { - if (key.contains("Authorization-T")) { - return "Authorization-T"; - } - if (key.contains("Notification-T")) { - return "Notification-T"; - } + 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; } @@ -118,47 +152,22 @@ private static void emitStatus( emitter.emitEvent(event); } - /** The pre-positioned Authorization-T whitelist policy text, or null. */ - public String getAuthorizationPolicy() { + /** The SDK-validated Authorization-T whitelist, or null when none has been accepted. */ + public AuthorizationPolicy getAuthorizationPolicy() { return authorizationPolicy; } - /** The pre-positioned Notification-T subscription text, or null. */ - public String getNotificationSubscription() { + /** The SDK-validated Notification-T subscription, or null. */ + public NotificationPolicy getNotificationSubscription() { return notificationSubscription; } - /** - * Parameter JSON Schema for the Authorization-T validate-and-fill pipeline: the whitelist - * policy fields the agent wants extracted from a rendered authorization prompt. - */ - private static Map buildAuthParamSchema() { - Map properties = new LinkedHashMap<>(); - properties.put("授权策略的操作类型", Map.of("type", "string")); - properties.put("动网操作的授权策略列表", Map.of("type", "string")); - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - return schema; + private Map validationSchema(TemplateUri templateUri) { + return SdkSlotSchemaLoader.load(templateUri, validationLanguage); } /** - * Parameter JSON Schema for the Notification-T validate-and-fill pipeline: the subscription - * fields extracted from a rendered subscribe-incident prompt. - */ - private static Map buildNotificationParamSchema() { - // Slots of the SDK's Notification-T service-recovery template (订阅条件 + 上报通知数据格式). - Map properties = new LinkedHashMap<>(); - properties.put("订阅条件", Map.of("type", "string")); - properties.put("上报通知数据格式", Map.of("type", "string")); - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - return schema; - } - - /** - * Handle a pre-positioned message: store the payload, emit an ACK artifact, and complete the + * Handle an independent extension message: store the payload, emit an ACK artifact, and complete the * task. * * @param agentTag short agent class name for logging @@ -167,90 +176,49 @@ public void handle( RequestContext ctx, AgentEmitter emitter, String extKeyword, String agentTag) { String taskId = ctx.getTaskId(); String contextId = ctx.getContextId(); - String payloadText = - ctx.getMessage().metadata().entrySet().stream() - .filter(e -> e.getKey().contains(extKeyword)) - .map(Map.Entry::getValue) - .findFirst() - .map(v -> v instanceof String s ? s : String.valueOf(v)) - .orElse(""); + 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) - String templateUri = null; + Object templateUri = null; if (ctx.getMessage().metadata() != null) { - Object tu = ctx.getMessage().metadata().get(MetadataContent.TEMPLATE_URI_METADATA_KEY); - if (tu instanceof String s) templateUri = s; + templateUri = ctx.getMessage().metadata().get(MetadataContent.TEMPLATE_URI_METADATA_KEY); } - if (extKeyword.contains("Authorization")) { - authorizationPolicy = payloadText; - // Full validate-and-fill pipeline: rule gate + LLM semantic validation + param - // extraction, falling back to the lightweight compliance check. - A2ATServer server = a2atServer(); - if (server != null) { - try { - net.openan.a2at.sdk.core.model.FilledParamData filled = - server.validateAuthPromptAndDataFilling( - payloadText, - buildAuthParamSchema(), - net.openan.a2at.sdk.core.model.StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); - log.info( - "[{}] Authorization-T validateAuthPromptAndDataFilling passed, templateUri={}, params={}", - agentTag, - templateUri, - filled.data() != null ? filled.data().keySet() : java.util.Set.of()); - } catch (Exception ve) { - log.warn( - "[{}] Authorization-T validate-and-fill rejected ({}); falling back to compliance check", - agentTag, - ve.getMessage()); - try { - PromptComplianceResult result = server.checkTaskPrompt(payloadText); - if (result.success()) { - log.info("[{}] Authorization-T prompt compliance check passed, templateUri={}", - agentTag, templateUri); - } else { - log.warn("[{}] Authorization-T prompt compliance check failed: code={}, message={}", - agentTag, - result.failure() != null ? result.failure().code() : "unknown", - result.failure() != null ? result.failure().message() : "unknown"); - } - } catch (Exception e) { - log.warn("[{}] Authorization-T compliance check error: {}", agentTag, e.getMessage()); - } - } + if (A2ATExtension.AUTHORIZATION_T.uri().equals(extKeyword)) { + try { + FilledParamData filled = validateAuthorization(payloadText, templateUri); + 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 (extKeyword.contains("Notification")) { - notificationSubscription = payloadText; - A2ATServer server = a2atServer(); - if (server != null) { - try { - net.openan.a2at.sdk.core.model.FilledParamData filled = - server.validateNotificationPromptAndDataFilling( - payloadText, - buildNotificationParamSchema(), - net.openan.a2at.sdk.core.model.StandardTemplates - .SERVICE_RECOVERY); - log.info( - "[{}] Notification-T validateNotificationPromptAndDataFilling" - + " passed, templateUri={}, params={}", - agentTag, - templateUri, - filled.data() != null ? filled.data().keySet() : java.util.Set.of()); - } catch (Exception ve) { - log.warn( - "[{}] Notification-T validate-and-fill rejected ({}); keeping payload as subscription", - agentTag, - ve.getMessage()); - } - } else { - log.info("[{}] Notification-T received, templateUri={}", agentTag, templateUri); + } else if (A2ATExtension.NOTIFICATION_T.uri().equals(extKeyword)) { + try { + acceptNotification(payloadText, templateUri, agentTag); + } 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()); - String ackText = extKeyword + " pre-positioning acknowledged"; + String ackText = extKeyword + " operation acknowledged"; List> parts = List.of(new TextPart(ackText)); emitter.addArtifact(parts, "result", agentTag + " ack", Map.of(), false, true); emitStatus( @@ -258,9 +226,224 @@ public void handle( TaskState.TASK_STATE_COMPLETED, contextId, taskId, - extKeyword + " pre-positioned successfully", + extKeyword + " operation applied successfully", Map.of()); emitter.complete(BaseAgentExecutor.buildStatusMessage(contextId, taskId, "Completed")); - log.info("[{}] {} pre-positioning completed", agentTag, extKeyword); + log.info("[{}] {} operation completed", agentTag, extKeyword); + } + + synchronized void applyAuthorization( + Map validatedData, Object templateUri, String agentTag) { + String operation = AuthorizationPolicy.operationFromValidated(validatedData); + switch (operation) { + case AuthorizationPolicy.ADD, AuthorizationPolicy.MODIFY -> { + AuthorizationPolicy candidate = AuthorizationPolicy.fromValidated(validatedData); + authorizationPolicy = candidate; + log.info( + "[{}] Authorization-T accepted, templateUri={}, operation={}, rules={}", + agentTag, + templateUri, + operation, + candidate.rules().size()); + } + case AuthorizationPolicy.DELETE -> { + // This sample models one active whitelist per OMC agent. A production callback + // should use the validated selector/list to delete matching persisted policies. + AuthorizationPolicy.requirePolicyList(validatedData); + authorizationPolicy = null; + log.info( + "[{}] Authorization-T accepted, templateUri={}, operation={}, active policy cleared", + agentTag, + templateUri, + operation); + } + case AuthorizationPolicy.QUERY -> + // Query is side-effect free. The current sample returns the normal protocol ACK; + // an integration callback can render its persisted policies in the artifact. + log.info( + "[{}] Authorization-T accepted, templateUri={}, operation={}, activeRules={}", + agentTag, + templateUri, + operation, + authorizationPolicy == null ? 0 : authorizationPolicy.rules().size()); + default -> throw new IllegalArgumentException( + "Unsupported Authorization-T operation: " + operation); + } + } + + 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"); + } + filled = server.validateAuthPromptAndDataFilling( + prompt, + validationSchema(StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT), + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + } + requireExtractedSectionsMatch( + prompt, + filled.data(), + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.POLICY_LIST_FIELD); + return filled; + } + + /** 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"); + } + filled = server.validateNotificationPromptAndDataFilling( + prompt, + validationSchema(StandardTemplates.SERVICE_RECOVERY), + StandardTemplates.SERVICE_RECOVERY); + } + requireExtractedSectionsMatch( + prompt, + filled.data(), + NotificationPolicy.CONDITION_FIELD, + NotificationPolicy.REPORT_FORMAT_FIELD); + NotificationPolicy candidate = NotificationPolicy.fromValidated(filled.data()); + notificationSubscription = candidate; + log.info( + "[{}] Notification-T accepted, templateUri={}, conditionChars={}, reportFormatChars={}", + agentTag, + receivedTemplateUri, + candidate.condition().length(), + candidate.reportFormat().length()); + return candidate; + } + + static void requireExtractedSectionsMatch( + String renderedPrompt, Map extractedData, String... slotNames) { + Objects.requireNonNull(renderedPrompt, "Rendered A2A-T prompt is required"); + Objects.requireNonNull(extractedData, "SDK extracted parameters are required"); + for (String slotName : slotNames) { + String renderedValue = sectionValue(renderedPrompt, slotName); + Object extractedValue = extractedData.get(slotName); + String extractedText = extractedValue instanceof String text ? text : ""; + if (!normalizeSlotValue(renderedValue).equals(normalizeSlotValue(extractedText))) { + throw new IllegalArgumentException( + "SDK extracted value does not match rendered prompt section: " + slotName); + } + } + } + + private static String sectionValue(String prompt, String slotName) { + String[] lines = prompt.replace("\r\n", "\n").replace('\r', '\n').split("\n", -1); + String expectedHeading = "## " + slotName; + for (int index = 0; index < lines.length; index++) { + if (!expectedHeading.equals(lines[index].strip())) { + continue; + } + StringBuilder value = new StringBuilder(); + for (int valueIndex = index + 1; valueIndex < lines.length; valueIndex++) { + String line = lines[valueIndex]; + if (line.strip().startsWith("## ")) { + break; + } + if (!line.isBlank()) { + if (!value.isEmpty()) { + value.append('\n'); + } + value.append(line.strip()); + } + } + return value.toString(); + } + throw new IllegalArgumentException( + "Rendered A2A-T prompt is missing section: " + + slotName + + "; headings=" + + java.util.Arrays.stream(lines) + .map(String::strip) + .filter(line -> line.startsWith("## ")) + .toList()); + } + + private static String normalizeSlotValue(String value) { + return value == null ? "" : value.strip().replaceAll("\\s+", " "); + } + + private static void requireTemplateUri(Object actualValue, TemplateUri expected) { + String actual = null; + if (actualValue instanceof TemplateUri template) { + actual = template.uri(); + } else if (actualValue instanceof String text) { + actual = text; + } else if (actualValue instanceof Map map) { + Object uri = map.get("uri"); + if (uri instanceof String text) { + actual = text; + } else { + Object extension = map.get("extensionName"); + Object segments = map.get("pathSegments"); + Object version = map.get("templateVersion"); + if (extension instanceof String extensionName + && segments instanceof List pathSegments + && version instanceof String templateVersion) { + actual = String.join( + "/", + java.util.stream.Stream.concat( + java.util.stream.Stream.of(extensionName), + java.util.stream.Stream.concat( + pathSegments.stream().map(String::valueOf), + java.util.stream.Stream.of(templateVersion))) + .toList()); + } + } + } + if (!expected.uri().equals(actual)) { + throw new IllegalArgumentException( + "Unexpected template URI: expected=" + expected.uri() + ", actual=" + actual); + } + } + + private static String validationFailureSummary(Exception error) { + if (error instanceof ContentValidationException validationError) { + return "code=" + + validationError.getCode() + + ", slotErrors=" + + validationError.errors() + + ", partialParamKeys=" + + validationError.params().keySet(); + } + return error.getClass().getSimpleName() + ": " + error.getMessage(); + } + + private static void reject( + AgentEmitter emitter, String contextId, String taskId, String publicMessage) { + emitStatus( + emitter, + TaskState.TASK_STATE_FAILED, + contextId, + taskId, + publicMessage, + Map.of()); + emitter.fail(BaseAgentExecutor.buildStatusMessage(contextId, taskId, publicMessage)); } } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/SdkSlotSchemaLoader.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/SdkSlotSchemaLoader.java new file mode 100644 index 00000000..88dec8b7 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/SdkSlotSchemaLoader.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.TemplateUri; +import dev.openan.workflow.engine.examples.util.EnvResolver; + +/** Loads the validation slot schema bundled with the active A2A-T SDK version. */ +final class SdkSlotSchemaLoader { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private SdkSlotSchemaLoader() {} + + 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); + } + + 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 94dd25b3..40fa4c63 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 @@ -29,10 +29,9 @@ * Negotiation clarification strategy for the SPN cross-city workbench. * *

        Single responsibility: when a downstream agent returns INPUT_REQUIRED (Negotiation-T), - * generate a clarification text that supplements the missing parameters. The returned text is - * fed to the engine's SDK content layer, which renders the structured Accept message (the raw - * text is also kept as the fallback message body). Uses LLM when the A2A-T .env is configured, - * with a deterministic fallback. + * generate typed clarification data that supplements the missing parameters. The returned + * {@code data:{...}} value selects the latest SDK's deterministic typed Accept renderer; it is + * never treated as a hand-written protocol prompt. * *

        The Accept payload follows the SDK scenario data for the private-line complaint case: * the two parameters the agent negotiated for (接入端口名称, 投诉分类), filled with the values @@ -47,10 +46,9 @@ public class NegotiationStrategy implements dev.openan.workflow.engine.control.N private static final String FILLED_COMPLAINT_CATEGORY = "专线质差"; - private final String a2atEnvPath; - public NegotiationStrategy(String a2atEnvPath) { - this.a2atEnvPath = a2atEnvPath; + // Kept in the constructor contract so applications can use one strategy factory for + // environment-aware implementations. This deterministic strategy needs no LLM itself. } /** @@ -65,18 +63,14 @@ public NegotiationStrategy(String a2atEnvPath) { public CompletableFuture resolve( String agentName, String negotiationText, Map receiveResult) { log.info("[NegotiationStrategy] agent={}: {}", agentName, negotiationText); - // Negotiation-T resolution (Accept), mirroring spec case 7.3 step 3. The engine's SDK - // content layer renders the structured Accept template from this text; the session - // context travels in the negotiationContext metadata key, so no context echoing here. + // The data: contract is consumed by DefaultWorkflowEngineClient and rendered with + // generateNegotiationAcceptPromptFromData. The negotiationContext remains metadata. String accept = - "同意补充以下信息:\n" - + "1. 接入端口名称:" + "data:{\"接入端口名称\":\"" + FILLED_PORT_NAME - + "\n" - + "2. 投诉分类:" + + "\",\"投诉分类\":\"" + FILLED_COMPLAINT_CATEGORY - + "\n" - + "信息已完整,可以启动诊断。"; + + "\"}"; log.info("[NegotiationStrategy] Accept generated for agent={}", agentName); return CompletableFuture.completedFuture(accept); } 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 7f3e7964..6abbeb6f 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 @@ -74,11 +74,12 @@ public static Map negotiationResponseMetadata(String negotiation * 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 = "negotiation_context"; + public static final String NEGOTIATION_CONTEXT_KEY = + net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY; /** * Builds Negotiation-T propose metadata carrying the session context: the rendered propose - * text under the extension URI plus the {@code negotiation_context} key holding the + * text under the extension URI plus the canonical {@code negotiationContext} key holding the * engine-format context map ({@code id}/{@code round}/{@code maxRounds}). * * @param negotiationText rendered propose text 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 db7eb811..b12695e6 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/server/JdkHttpA2AServer.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/server/JdkHttpA2AServer.java @@ -58,6 +58,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.security.KeyStore; import java.util.LinkedHashMap; @@ -372,7 +373,7 @@ private RestHandler initServerComponents( } private void handleExchange(HttpExchange exchange, RestHandler restHandler) throws IOException { - String fullPath = exchange.getRequestURI().getPath(); + String fullPath = exchange.getRequestURI().getRawPath(); String method = exchange.getRequestMethod(); String path = pathPrefix.isEmpty() || !fullPath.startsWith(pathPrefix) @@ -398,25 +399,26 @@ private void handleExchange(HttpExchange exchange, RestHandler restHandler) thro return; } // Task routes (?? A2A: ??/??/??) - if ("GET".equalsIgnoreCase(method) && path.startsWith("/tasks/") && !path.contains(":")) { - String taskId = path.substring("/tasks/".length()); - if (taskId.contains("?")) taskId = taskId.substring(0, taskId.indexOf("?")); + 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; } - if ("POST".equalsIgnoreCase(method) && path.contains(":cancel")) { - String taskId = path.substring("/tasks/".length(), path.indexOf(":cancel")); + 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; } - if ("POST".equalsIgnoreCase(method) && path.contains(":subscribe")) { - String taskId = path.substring("/tasks/".length(), path.indexOf(":subscribe")); + 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( @@ -426,6 +428,33 @@ private void handleExchange(HttpExchange exchange, RestHandler restHandler) thro } } + /** Parses exactly one encoded task-id path segment and rejects ambiguous task routes. */ + static String taskIdFromPath(String path, String operationSuffix) { + String prefix = "/tasks/"; + if (path == null || !path.startsWith(prefix)) { + return null; + } + String encoded = path.substring(prefix.length()); + if (operationSuffix != null) { + if (!encoded.endsWith(operationSuffix)) { + return null; + } + encoded = encoded.substring(0, encoded.length() - operationSuffix.length()); + } else if (encoded.contains(":")) { + return null; + } + if (encoded.isEmpty() || encoded.contains("/")) { + return null; + } + try { + // URLDecoder follows form semantics where '+' means space. In a URI path segment an + // unescaped '+' is literal, so protect it before decoding percent escapes. + return URLDecoder.decode(encoded.replace("+", "%2B"), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid encoded task id", e); + } + } + @SuppressWarnings("unchecked") private void handleLogin(HttpExchange exchange) throws IOException { if (!"PUT".equalsIgnoreCase(exchange.getRequestMethod()) 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 c989c6b1..3e064207 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 @@ -20,19 +20,33 @@ package dev.openan.workflow.engine.examples.util; import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; /** * Shared utility for resolving the A2A-T {@code .env} file path. * - *

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

        Resolution order: the {@code a2at.env.path} system property, the {@code A2AT_ENV_PATH} + * environment variable, the classpath, then a walk up from the working directory. Used by both + * demo variants (embedded and Spring) and by shared agent executors that need one consistent SDK + * configuration file. */ public final class EnvResolver { private EnvResolver() {} public static String resolveEnvPath() { + String configured = System.getProperty("a2at.env.path"); + if (configured == null || configured.isBlank()) { + configured = System.getenv("A2AT_ENV_PATH"); + } + if (configured != null && !configured.isBlank()) { + Path path = Path.of(configured.strip()).toAbsolutePath().normalize(); + if (!Files.isRegularFile(path)) { + throw new IllegalArgumentException("A2A-T env file does not exist: " + path); + } + return path.toString(); + } var url = EnvResolver.class.getClassLoader().getResource(".env"); if (url != null && "file".equals(url.getProtocol())) { return new File(url.getPath()).getAbsolutePath(); diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/util/LlmHelper.java b/samples/src/main/java/dev/openan/workflow/engine/examples/util/LlmHelper.java index bf12b8d7..bd9437a3 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 @@ -26,10 +26,10 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; @@ -59,22 +59,12 @@ public static String text(String envPath, String system, String user, String fal } try { String body = buildRequestBody(config, system, user); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(config.baseUrl + "/v1/chat/completions")) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + config.apiKey) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .timeout(Duration.ofSeconds(config.timeoutSeconds)) - .build(); - HttpResponse response = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build() - .send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) { - log.warn("[LlmHelper] HTTP {}: {}", response.statusCode(), response.body()); + HttpResponse response = post(config, body); + if (response.statusCode != 200) { + log.warn("[LlmHelper] HTTP {}: {}", response.statusCode, response.body); return fallback; } - String content = extractContent(response.body()); + String content = extractContent(response.body); if (content == null || content.isBlank()) { log.warn("[LlmHelper] empty model reply, using fallback"); return fallback; @@ -86,6 +76,39 @@ public static String text(String envPath, String system, String user, String fal } } + 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"); @@ -235,4 +258,6 @@ private Config(String envPath, String apiKey, String baseUrl, String model, this.timeoutSeconds = timeoutSeconds; } } + + private record HttpResponse(int statusCode, String body) {} } 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 d3f7f18b..18aee249 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 @@ -22,8 +22,6 @@ import dev.openan.workflow.engine.examples.util.LlmHelper; import dev.openan.workflow.engine.client.WorkflowEngineClient; import dev.openan.workflow.engine.control.DefaultControlPoint; -import dev.openan.workflow.engine.client.A2ATExtension; -import dev.openan.workflow.engine.examples.negotiation.NegotiationUtils; import dev.openan.workflow.engine.model.JumpCondition; import dev.openan.workflow.engine.model.RouteDecision; import dev.openan.workflow.engine.model.TaskRequest; @@ -32,7 +30,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -44,7 +41,7 @@ * *

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

        SRP: this class only contains workflow decision logic, separating it from the agent executor * that handles message I/O. @@ -104,14 +101,15 @@ private static String analyzeFaultLocation(String messageText) { /** * Build the city-scoped structured data for the target agent's step. The upstream context * (which mixes both cities' info) is NOT forwarded — each sub-agent receives a clean, - * self-contained complaint with only its own scope. City1 goes out with blank slots (spec - * case 7.3: triggers negotiation); City2 with complete parameters. + * self-contained complaint with only its own scope. Both production workflow branches carry + * complete diagnosis input. Negotiation-T protocol cases are exercised independently and do + * not alter this main business flow. */ private static java.util.Map buildTaskData(String step) { return switch (step) { case "diagnosis_city1" -> dev.openan.workflow.engine.examples.demo.SpnCasePrompts - .privateLineComplaintDataBlankObject(); + .privateLineComplaintData(); case "diagnosis_city2" -> dev.openan.workflow.engine.examples.demo.SpnCasePrompts .privateLineComplaintDataCity2(); @@ -127,28 +125,16 @@ public CompletableFuture onTask( String step = request.getStepName(); String agentName = request.getAgentName(); // Structured-data track: hand over the city-scoped complaint fields + schema; the SDK - // renders the Task-T prompt deterministically (no hand-written prompt text). - // For diagnosis_city1, activate Negotiation-T extension alongside (spec case 7.3: - // missing params trigger negotiation). The ExtensionInterceptor adds it to the - // A2A-Extensions header when the metadata key is present. - Map metadata = new LinkedHashMap<>(); - if ("diagnosis_city1".equals(step)) { - metadata.put(NegotiationUtils.NEGOTIATION_T_URI, ""); - } + // renders the Task-T prompt through its schema-aware pipeline (no hand-written prompt). String partsText = "创建专线业务投诉诊断任务"; Map taskData = buildTaskData(step); - return engineClient - .sendMessageFromData( - agentName, - partsText, - taskData, - dev.openan.workflow.engine.examples.demo.SpnCasePrompts - .privateLineComplaintSchema(), - net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT, - metadata) + return sendTaskWithRenderingRetry( + engineClient, agentName, partsText, taskData, 1) .thenApply( r -> { - boolean success = r.getText() != null && !r.getText().isEmpty(); + boolean success = + r.getTaskState() != null + && r.getTaskState().contains("TASK_STATE_COMPLETED"); log.info( "[onTask] Response from {}: {} chars, success={}", agentName, @@ -157,6 +143,9 @@ public CompletableFuture onTask( return TaskResponse.builder() .success(success) .output(r.getText()) + .error(success + ? null + : "Agent returned state=" + r.getTaskState()) .build(); }) .exceptionally( @@ -169,6 +158,48 @@ public CompletableFuture onTask( }); } + private CompletableFuture + sendTaskWithRenderingRetry( + WorkflowEngineClient engineClient, + String agentName, + String message, + Map taskData, + int attempt) { + return engineClient + .sendMessageFromData( + agentName, + message, + taskData, + dev.openan.workflow.engine.examples.demo.SpnCasePrompts + .privateLineComplaintSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT) + .exceptionallyCompose(error -> { + Throwable cause = unwrap(error); + if (attempt < 3 + && cause.getMessage() != null + && cause.getMessage().contains("Task-T fromData rendering failed")) { + log.warn( + "[onTask] SDK Task-T rendering failed for {}, retry={}/3: {}", + agentName, + attempt + 1, + cause.getMessage()); + return sendTaskWithRenderingRetry( + engineClient, agentName, message, taskData, attempt + 1); + } + return CompletableFuture.failedFuture(cause); + }); + } + + private static Throwable unwrap(Throwable error) { + Throwable current = error; + while ((current instanceof java.util.concurrent.CompletionException + || current instanceof java.util.concurrent.ExecutionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + @Override public CompletableFuture onSelfTask(TaskRequest request) { String step = request.getStepName(); @@ -190,7 +221,7 @@ public CompletableFuture onRoute( String stepName, Map results, List conditions) { // merge_analysis has an unconditional next -> endNode, so the executor // never calls onRoute for it. Recovery is self-triggered by SPN agents - // via the pre-positioned Authorization-T whitelist and reported through + // via the active Authorization-T whitelist and reported through // the Notification-T channel. Just delegate to the default routing. return super.onRoute(stepName, results, conditions); } 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 5a6cdf11..99bb52ee 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 @@ -7,6 +7,7 @@ import dev.openan.workflow.engine.client.A2AJavaClientRuntime; import dev.openan.workflow.engine.client.A2ATransport; import dev.openan.workflow.engine.client.DefaultExtensionSender; +import dev.openan.workflow.engine.client.NotificationSubscription; import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; import org.a2aproject.sdk.spec.AgentCard; @@ -15,12 +16,13 @@ import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.function.Supplier; import dev.openan.workflow.engine.examples.extension.ExtensionPrePositioner; /** - * Owns workbench-scoped pre-positioning resources. + * Owns workbench-scoped Authorization-T and Notification-T resources outside the workflow DAG. * *

        Authorization-T is sent once and its request session is released after its response. * Notification-T streams remain owned by this lifecycle until {@link #close()} is called by the @@ -35,7 +37,9 @@ public final class WorkbenchExtensionLifecycle implements AutoCloseable { private final Supplier runtimeSupplier; private final Consumer> notificationCallback; - private A2ATransport transport; + private final Map subscriptions = + new ConcurrentHashMap<>(); + private A2ATransport notificationTransport; public WorkbenchExtensionLifecycle( String credentialsPath, @@ -51,45 +55,66 @@ public WorkbenchExtensionLifecycle( } public synchronized void start() { - if (transport != null) { + if (notificationTransport != null) { log.info( "[ExtensionLifecycle] START_SKIPPED reason=already_active, contextId={}", - transport.getContextId()); + notificationTransport.getContextId()); return; } List agentCards = new WorkbenchAgentCatalog().load(); - A2AJavaClientRuntime runtime = runtimeSupplier != null ? runtimeSupplier.get() : null; - A2ATransport candidate = + 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) + .a2atEnvPath(a2atEnvPath) + .credentialsConfigPath(credentialsPath) + .build(); + A2ATransport authorizationTransport = new A2ATransport( agentCards, - runtime, - WorkflowEngineClientConfig.builder() - .sslVerify(sslVerify) - .a2atEnvPath(a2atEnvPath) - .credentialsConfigPath(credentialsPath) - .build()); + authorizationRuntime, + config); + A2ATransport notificationCandidate = + new A2ATransport(agentCards, notificationRuntime, config); long started = System.nanoTime(); log.info( - "[ExtensionLifecycle] START contextId={}, runtime={}, scope=workbench", - candidate.getContextId(), - runtime != null ? runtime.getClass().getSimpleName() : "DefaultA2AJavaClientRuntime"); + "[ExtensionLifecycle] START authorizationContextId={}, notificationContextId={}, scope=workbench", + authorizationTransport.getContextId(), + notificationCandidate.getContextId()); try { - new ExtensionPrePositioner() + List opened = + new ExtensionPrePositioner() .prePosition( - new DefaultExtensionSender(candidate), + new DefaultExtensionSender(authorizationTransport), + new DefaultExtensionSender(notificationCandidate), agentCards, - notificationCallback); - transport = candidate; + this::handleNotification); + opened.forEach(subscription -> subscriptions.put( + subscription.agentName(), subscription)); + authorizationTransport.close(); + notificationTransport = notificationCandidate; log.info( - "[ExtensionLifecycle] ACTIVE contextId={}, notificationScope=workbench, elapsedMs={}", - candidate.getContextId(), + "[ExtensionLifecycle] ACTIVE contextId={}, subscriptions={}, notificationScope=workbench, elapsedMs={}", + notificationCandidate.getContextId(), + subscriptions.size(), elapsedMillis(started)); } catch (RuntimeException e) { - candidate.close(); + subscriptions.values().forEach(NotificationSubscription::close); + subscriptions.clear(); + authorizationTransport.close(); + notificationCandidate.close(); log.error( - "[ExtensionLifecycle] START_FAILED contextId={}, errorType={}, message={}", - candidate.getContextId(), + "[ExtensionLifecycle] START_FAILED authorizationContextId={}, notificationContextId={}, errorType={}, message={}", + authorizationTransport.getContextId(), + notificationCandidate.getContextId(), e.getClass().getSimpleName(), e.getMessage(), e); @@ -98,25 +123,53 @@ public synchronized void start() { } public synchronized boolean isActive() { - return transport != null; + return notificationTransport != null; } @Override public synchronized void close() { - A2ATransport active = transport; - transport = null; + A2ATransport active = notificationTransport; + notificationTransport = null; if (active == null) { return; } log.info( "[ExtensionLifecycle] CLOSE_START contextId={}, reason=workbench_shutdown", active.getContextId()); + // Let the transport own cancellation and wait for the underlying stream threads before + // the Order simulator or web container can be stopped. active.close(); + subscriptions.clear(); log.info( "[ExtensionLifecycle] CLOSE_DONE contextId={}, reason=workbench_shutdown", active.getContextId()); } + private void handleNotification(Map data) { + if ("recovery-result".equals(data.get("artifact_name"))) { + String agent = String.valueOf(data.get("agent")); + NotificationSubscription completed = subscriptions.remove(agent); + if (completed != null) { + log.info( + "[ExtensionLifecycle] NOTIFICATION_COMPLETE agent={}, contextId={}, events={}, action=close-stream", + agent, + completed.contextId(), + completed.heartbeat().eventCount()); + completed.close(); + } + } + if (notificationCallback != null) { + try { + notificationCallback.accept(data); + } catch (RuntimeException callbackError) { + log.warn( + "[ExtensionLifecycle] Notification observer failed: {}", + callbackError.getMessage(), + callbackError); + } + } + } + private static long elapsedMillis(long startedNanos) { return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java index f1038813..14a6892b 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 @@ -27,8 +27,12 @@ import dev.openan.workflow.engine.control.EventCallback; import dev.openan.workflow.engine.control.EventType; import dev.openan.workflow.engine.model.ExecutionResult; +import dev.openan.workflow.engine.model.JumpCondition; +import dev.openan.workflow.engine.model.StepType; +import dev.openan.workflow.engine.model.Task; import dev.openan.workflow.engine.model.Workflow; import dev.openan.workflow.engine.model.WorkflowSearchResult; +import dev.openan.workflow.engine.model.WorkflowStep; import dev.openan.workflow.engine.registry.LoadPsop; import dev.openan.workflow.engine.runner.ExecutePsop; @@ -86,7 +90,23 @@ public WorkbenchOrchestrator( this.clientRuntime = clientRuntime; } - private static String buildResultText(ExecutionResult result) { + 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") @@ -108,6 +128,17 @@ private static String buildResultText(ExecutionResult result) { return sb.toString(); } + private static String outputText(Map output) { + if (output == null || output.isEmpty()) { + return ""; + } + return output.values().stream() + .filter(value -> value != null && !String.valueOf(value).isBlank()) + .map(String::valueOf) + .distinct() + .collect(java.util.stream.Collectors.joining("\n\n")); + } + /** Run the full orchestration pipeline and return the result text. */ public String run(String messageText) throws Exception { long runStarted = System.nanoTime(); @@ -131,8 +162,9 @@ public String run(String messageText) throws Exception { stageStarted = System.nanoTime(); log.info("[Orchestrator] STAGE_START stage=search-load-psop"); - String psopId = searchPsop(messageText); - Workflow workflow = LoadPsop.load(orchUrl, psopId, null, sslVerify); + 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={}", @@ -207,6 +239,9 @@ public String run(String messageText) throws Exception { result.isSuccess(), resultText.length(), elapsedMillis(runStarted)); + if (!result.isSuccess()) { + throw new IllegalStateException(resultText); + } return resultText; } catch (Exception e) { log.error( @@ -227,7 +262,7 @@ private List loadAgentCards() { return new WorkbenchAgentCatalog().load(); } - private String searchPsop(String messageText) { + private ResolvedWorkflow resolveWorkflow(String messageText) { try { List results = LoadPsop.search(orchUrl, messageText, 3, null, sslVerify); @@ -237,15 +272,72 @@ private String searchPsop(String messageText) { "[Orchestrator] Found PSOP: {} (score={})", psopId, results.get(0).getScore()); - return psopId; + 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 FALLBACK_PSOP_ID; + return new ResolvedWorkflow(FALLBACK_PSOP_ID, fallbackWorkflow()); } + static Workflow fallbackWorkflow() { + WorkflowStep city1 = + WorkflowStep.builder() + .name("diagnosis_city1") + .layer(0) + .subtasks(List.of( + Task.builder() + .agent("SPN Domain Agent City1") + .description("SPN专线故障诊断") + .build())) + .next(List.of(JumpCondition.builder() + .step("merge_analysis") + .condition("") + .build())) + .build(); + WorkflowStep city2 = + WorkflowStep.builder() + .name("diagnosis_city2") + .layer(0) + .subtasks(List.of( + Task.builder() + .agent("SPN Domain Agent City2") + .description("SPN专线故障诊断") + .build())) + .next(List.of(JumpCondition.builder() + .step("merge_analysis") + .condition("") + .build())) + .build(); + WorkflowStep merge = + WorkflowStep.builder() + .name("merge_analysis") + .layer(1) + .stepType(StepType.SELF_LOOP) + .contextFrom(List.of("diagnosis_city1", "diagnosis_city2")) + .subtasks(List.of( + Task.builder() + .agent("Workbench") + .description("汇总两地市OMC诊断结论") + .build())) + .next(List.of(JumpCondition.builder() + .step("endNode") + .condition("") + .build())) + .build(); + return Workflow.builder() + .id(FALLBACK_PSOP_ID) + .name("SPN跨地市专线投诉诊断") + .description("两个地市并行诊断后由工作台本地汇总") + .steps(List.of(city1, city2, merge)) + .build(); + } + + private record ResolvedWorkflow(String psopId, Workflow workflow) {} + private EventCallback createLogCallback() { return new EventCallback() { @Override diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskInputParser.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskInputParser.java new file mode 100644 index 00000000..b617b296 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskInputParser.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.workbench; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; + +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.server.A2ATServer; + +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Validates an inbound Workbench Task-T message and exposes its structured business input. */ +final class WorkbenchTaskInputParser { + + @FunctionalInterface + interface TaskPromptValidator { + FilledParamData validate( + String prompt, Map schema, TemplateUri templateUri); + } + + record ParsedTask( + String userText, + String prompt, + TemplateUri templateUri, + Map parameters) { + + String runtimeIntent() { + StringBuilder intent = new StringBuilder(); + if (userText != null && !userText.isBlank()) { + intent.append(userText.strip()).append('\n'); + } + intent.append("Task-T template: ").append(templateUri.uri()).append('\n'); + intent.append("Validated complaint parameters:"); + parameters.forEach( + (key, value) -> + intent.append("\n- ") + .append(key) + .append(": ") + .append(value != null ? value : "")); + return intent.toString(); + } + } + + private final TaskPromptValidator validator; + + WorkbenchTaskInputParser(TaskPromptValidator validator) { + this.validator = validator; + } + + static WorkbenchTaskInputParser fromEnv(String envPath) { + if (envPath == null || envPath.isBlank()) { + throw new IllegalStateException( + "A2A-T SDK env path is required to validate inbound Workbench Task-T messages"); + } + A2ATServer server = new A2ATServer(Path.of(envPath)); + return new WorkbenchTaskInputParser(server::validateTaskPromptAndDataFilling); + } + + ParsedTask parse(String userText, Map metadata) { + if (metadata == null || metadata.isEmpty()) { + throw new IllegalArgumentException("Inbound Workbench request is missing Task-T metadata"); + } + String prompt = extractTaskPrompt(metadata); + TemplateUri templateUri = extractTemplateUri(metadata); + FilledParamData filled = + validator.validate(prompt, SpnCasePrompts.privateLineComplaintSchema(), templateUri); + if (filled == null || filled.data() == null || filled.data().isEmpty()) { + throw new IllegalArgumentException( + "Inbound Workbench Task-T did not contain validated complaint parameters"); + } + Map parameters = new LinkedHashMap<>(filled.data()); + for (String required : SpnCasePrompts.privateLineComplaintSchemaProperties()) { + Object value = parameters.get(required); + if (value == null || String.valueOf(value).isBlank()) { + throw new IllegalArgumentException( + "Inbound Workbench Task-T is missing required parameter: " + required); + } + } + return new ParsedTask(userText, prompt, templateUri, Map.copyOf(parameters)); + } + + private static String extractTaskPrompt(Map metadata) { + Object exact = metadata.get(A2ATExtension.TASK_T.uri()); + if (exact instanceof String prompt && !prompt.isBlank()) { + return prompt; + } + for (Map.Entry entry : metadata.entrySet()) { + if (entry.getKey().contains("/Task-T/") + && entry.getValue() instanceof String prompt + && !prompt.isBlank()) { + return prompt; + } + } + throw new IllegalArgumentException("Inbound Workbench request has no Task-T prompt payload"); + } + + private static TemplateUri extractTemplateUri(Map metadata) { + Object raw = metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY); + if (raw == null) { + return StandardTemplates.PRIVATE_LINE_COMPLAINT; + } + if (raw instanceof TemplateUri templateUri) { + return requireTaskTemplate(templateUri); + } + String uri = null; + if (raw instanceof String text) { + uri = text; + } else if (raw instanceof Map map && map.get("uri") instanceof String text) { + uri = text; + } + if (uri == null) { + throw new IllegalArgumentException("Inbound Workbench Task-T has malformed templateUri metadata"); + } + String candidate = uri; + TemplateUri parsed = + TemplateUri.parse(candidate) + .orElseThrow( + () -> + new IllegalArgumentException( + "Inbound Workbench Task-T has invalid templateUri: " + + candidate)); + return requireTaskTemplate(parsed); + } + + private static TemplateUri requireTaskTemplate(TemplateUri templateUri) { + if (!templateUri.uri().startsWith("Task-T/")) { + throw new IllegalArgumentException( + "Inbound Workbench template is not a Task-T template: " + templateUri.uri()); + } + return templateUri; + } +} diff --git a/samples/src/main/resources/agentcard/transport_workbench_agent.json b/samples/src/main/resources/agentcard/transport_workbench_agent.json index 6ca5b163..b44eb6bf 100644 --- a/samples/src/main/resources/agentcard/transport_workbench_agent.json +++ b/samples/src/main/resources/agentcard/transport_workbench_agent.json @@ -17,7 +17,7 @@ "required": false }, { - "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T", + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", "description": "Extension for A2A-T negotiation text exchange.", "required": false }, @@ -79,4 +79,4 @@ "tenant": "" } ] -} \ No newline at end of file +} diff --git a/samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md b/samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md new file mode 100644 index 00000000..0c81baf0 --- /dev/null +++ b/samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md @@ -0,0 +1,28 @@ +You validate A2A-T content and extract parameters. Output exactly one JSON object and no Markdown, comments, or extra text. + +The output must contain exactly these three keys: +{ + "semantic_verdict": true, + "errors": [], + "params": {} +} + +Extract params using the property names from the parameter schema. Use null when a property cannot be extracted. + +Protocol context: the input is a runtime message produced by the A2A-T SDK renderer. The renderer intentionally removes requirement prose, examples, and required markers from slot-driven template sections, retaining the section heading and actual slot value. The template content is therefore a semantic reference, not a byte-for-byte format that the input must reproduce. Never reject an input merely because this scaffolding is absent. + +Use an accept-unless-clearly-disproved semantic verdict. Set semantic_verdict to false only when: +1. a slot value directly contradicts an explicit hard constraint in the parameter schema or template; +2. a slot is an obvious placeholder or invalid value such as abc, xxx, unknown, or TBD; or +3. there is clear cross-scenario contamination or an internal contradiction. + +Rules: +1. Do not reject merely for not matching an example, being concise or identifier-like, lacking extra context, or omitting optional fields. +2. Schema required fields govern extraction. If one cannot be extracted, set it to null in params; that alone must not make semantic_verdict false. +3. When the template defines an operation-specific smaller required field set, follow that operation rule. Do not require every field that a complete entity may contain. +4. A value exactly matching an explicitly allowed value must be accepted. +5. Parameter names and structure must follow the schema and must not add undeclared properties. +6. Extract params only from Input Content. Never select values from Template Content, schema descriptions, examples, or allowed-value lists. +7. For a `## Slot Name` heading followed by a non-blank value, copy that value verbatim into the matching parameter. For enum fields, return only the value actually present below the input heading, even when the template lists several choices. Before responding, verify every non-null value occurs in Input Content. + +Every errors item must contain exactly the string fields slot_name, code, and message. errors must be empty when semantic_verdict is true and non-empty with explicit evidence when it is false. diff --git a/samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/user.md b/samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/user.md new file mode 100644 index 00000000..e8e4e0a0 --- /dev/null +++ b/samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/user.md @@ -0,0 +1,7 @@ +Extension name: [extension_name] +Input content: [input] +Template URI: [template_uri] +Template content: [template_content] +Parameter schema: [schema] + +Apply the system rules and output only the JSON object containing semantic_verdict, errors, and params. diff --git a/samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md b/samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md new file mode 100644 index 00000000..b5e2a44a --- /dev/null +++ b/samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md @@ -0,0 +1,28 @@ +你是 A2A-T 内容校验与参数提取代理。你只能输出一个 JSON 对象,不得输出 Markdown、注释或额外文本。 + +输出必须包含且仅包含以下三个键: +{ + "semantic_verdict": true, + "errors": [], + "params": {} +} + +其中 params 必须按照参数 schema 的属性名提取;无法提取的属性填 null。 + +重要协议背景:输入内容是 A2A-T SDK 渲染器生成的运行时消息。渲染器会有意删除模板槽位区段中的“要求”、示例和“必填”标记,只保留区段标题和实际槽位值。因此模板正文仅用于理解字段语义,不是要求输入逐字复现的格式范本。不得因为输入省略模板中的说明文字、示例、标记或其他脚手架而判定失败。 + +semantic_verdict 采用“无明确反证则通过”原则。只有下列情况可判定为 false: +1. 槽位值与参数 schema 或模板中的明确强约束直接冲突; +2. 槽位值是明显占位词或无效值,例如 abc、xxx、未知、待定; +3. 输入存在明确的跨场景污染或自相矛盾。 + +校验规则: +1. 不得仅因未命中示例、表达简短、像标识符、缺少额外上下文或省略可选字段而失败。 +2. schema 的 required 只决定参数是否应提取;无法提取时在 params 中填 null,不得仅据此把 semantic_verdict 设为 false。 +3. 当模板针对具体操作声明了更小的必填字段集合时,以该操作的必填规则为准;不得把实体可能包含的全部字段都当成必填。 +4. 输入值与明确允许值完全一致时必须接受,不得改写或臆测它不在取值范围。 +5. params 的属性名与结构必须遵循参数 schema,不得新增 schema 外的属性。 +6. params 只能从“输入内容”提取,禁止从“模板正文”、schema 的 description、examples 或允许值列表中选择值。 +7. 对 `## 槽位名` 后紧邻的非空内容,必须原样复制为同名参数值。尤其是枚举字段,即使模板列出了多个允许值,也只能返回输入标题下实际出现的那个值;输出前逐项确认非 null 值确实存在于输入内容中。 + +errors 中每项必须恰好包含 slot_name、code、message 三个字符串字段。semantic_verdict=true 时 errors 必须为空;false 时至少包含一项有明确证据的错误。 diff --git a/samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/user.md b/samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/user.md new file mode 100644 index 00000000..514e32c8 --- /dev/null +++ b/samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/user.md @@ -0,0 +1,7 @@ +扩展名称:[extension_name] +输入内容:[input] +模板标识:[template_uri] +模板正文:[template_content] +参数模式:[schema] + +请按系统规则完成语义校验和参数提取,只输出包含 semantic_verdict、errors、params 的 JSON 对象。 From 617dcb405a75bc4caeaeffd297b2110b99a43c8a Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Tue, 25 Aug 2026 20:43:17 +0800 Subject: [PATCH 20/49] test(samples): cover SPN protocol orchestration Signed-off-by: Zhoujie628 --- .../engine/examples/SpnCrossCityE2ETest.java | 174 ++++++++++++--- .../examples/demo/SpringSpnDemoTest.java | 24 +++ .../extension/AuthorizationPolicyTest.java | 107 ++++++++++ .../extension/NotificationPolicyTest.java | 45 ++++ .../PrePositionedExtensionHandlerTest.java | 147 +++++++++++++ .../server/EmbeddedA2AServerTest.java | 35 ++- .../testsupport/OfflineA2ATLlmClient.java | 200 ++++++++++++++++++ .../workbench/WorkbenchTaskFlowTest.java | 127 +++++++++++ samples/src/test/resources/a2at-e2e.env | 16 ++ 9 files changed, 841 insertions(+), 34 deletions(-) create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoTest.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicyTest.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/extension/NotificationPolicyTest.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandlerTest.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/testsupport/OfflineA2ATLlmClient.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskFlowTest.java create mode 100644 samples/src/test/resources/a2at-e2e.env 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 7e7a50bc..3f0fcbca 100644 --- a/samples/src/test/java/dev/openan/workflow/engine/examples/SpnCrossCityE2ETest.java +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/SpnCrossCityE2ETest.java @@ -15,11 +15,15 @@ import dev.openan.workflow.engine.client.DefaultExtensionSender; import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; import dev.openan.workflow.engine.client.ExtensionSender; +import dev.openan.workflow.engine.client.NotificationSubscription; import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; import dev.openan.workflow.engine.control.EventCallback; import dev.openan.workflow.engine.control.EventType; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity2Executor; +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; +import dev.openan.workflow.engine.examples.extension.PrePositionedExtensionHandler; +import dev.openan.workflow.engine.examples.testsupport.OfflineA2ATLlmClient; import dev.openan.workflow.engine.examples.workbench.WorkbenchControlPoint; import dev.openan.workflow.engine.examples.server.OmcAgentLauncher; import dev.openan.workflow.engine.model.ExecutionResult; @@ -32,22 +36,30 @@ 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.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; /** * End-to-end business integration test for the SPN cross-city diagnosis workflow. Starts two real * SPN agents (JdkHttpA2AServer + A2A-T protocol over HTTPS+SSE), pre-positions Authorization-T and * Notification-T, runs a 3-step workflow (diagnose x2 + SelfLoop merge), and asserts the full - * business path: negotiation -> diagnosis -> whitelist self-recovery -> Notification-T report -> - * local merge (no A2A-T to self). + * business path: parallel diagnosis -> whitelist self-recovery -> Notification-T report -> local + * merge (no A2A-T to self). The diagnosis tasks carry complete input parameters; Negotiation-T is + * covered by its independent protocol case and is not part of this workflow. * - *

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

        The A2A-T client/server paths use an offline structured-LLM provider, so the real SDK + * generation and validation pipelines run without network access. The demo's unrelated business + * prose LLM remains disabled by the Maven test profile. */ class SpnCrossCityE2ETest { @@ -56,9 +68,20 @@ class SpnCrossCityE2ETest { private final OmcAgentLauncher omc = new OmcAgentLauncher(); private DefaultWorkflowEngineClient client; - private ExtensionSender sender; + private ExtensionSender authorizationSender; + private ExtensionSender notificationSender; + private A2ATransport taskTransport; + private A2ATransport authorizationTransport; + private A2ATransport notificationTransport; + private final List subscriptions = new CopyOnWriteArrayList<>(); private int port1; private int port2; + private String sdkEnvPath; + + @BeforeAll + static void registerMockProvider() { + OfflineA2ATLlmClient.install(); + } private static AgentCard cardFor(String name, int port) { Map card = @@ -76,7 +99,27 @@ private static AgentCard cardFor(String name, int port) { "extendedAgentCard", false, "extensions", - List.of()), + List.of( + Map.of( + "uri", + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1", + "required", + false), + Map.of( + "uri", + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", + "required", + false), + Map.of( + "uri", + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1", + "required", + false), + Map.of( + "uri", + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1", + "required", + false))), "defaultInputModes", List.of("text/plain"), "defaultOutputModes", List.of("text/plain"), "skills", @@ -106,27 +149,42 @@ private static AgentCard cardFor(String name, int port) { @BeforeEach void setUp() throws Exception { - System.setProperty("a2at.llm.disabled", "true"); + sdkEnvPath = OfflineA2ATLlmClient.envPath(); + System.setProperty("a2at.env.path", sdkEnvPath); port1 = 28200 + (int) (Math.random() * 500); port2 = port1 + 1; AgentCard c1 = cardFor("SPN Domain Agent City1", port1); AgentCard c2 = cardFor("SPN Domain Agent City2", port2); - omc.startFromCard(mapper.convertValue(c1, Map.class), new SpnDomainAgentCity1Executor()); - omc.startFromCard(mapper.convertValue(c2, Map.class), new SpnDomainAgentCity2Executor()); + omc.startFromCard( + mapper.convertValue(c1, Map.class), + new SpnDomainAgentCity1Executor(new PrePositionedExtensionHandler())); + omc.startFromCard( + mapper.convertValue(c2, Map.class), + new SpnDomainAgentCity2Executor(new PrePositionedExtensionHandler())); Thread.sleep(600); - A2ATransport transport = - new A2ATransport( - List.of(c1, c2), - null, - WorkflowEngineClientConfig.builder().sslVerify(false).build()); - client = new DefaultWorkflowEngineClient(transport); - sender = new DefaultExtensionSender(transport); + WorkflowEngineClientConfig config = + WorkflowEngineClientConfig.builder() + .sslVerify(false) + .a2atEnvPath(sdkEnvPath) + .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); + authorizationSender = new DefaultExtensionSender(authorizationTransport); + notificationSender = new DefaultExtensionSender(notificationTransport); } @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() { @@ -175,24 +233,48 @@ private Workflow crossCityWorkflow() { } @Test - void fullBusinessPathNegotiationDiagnosisRecoveryAndSelfLoopMerge() { + void fullBusinessPathDiagnosisRecoveryAndSelfLoopMerge() { + assertNotEquals(taskTransport.getContextId(), authorizationTransport.getContextId()); + assertNotEquals(taskTransport.getContextId(), notificationTransport.getContextId()); + assertNotEquals(authorizationTransport.getContextId(), notificationTransport.getContextId()); // Pre-position Authorization-T + Notification-T to both SPN agents - sender.sendAuthorization("SPN Domain Agent City1", "下发授权放行策略", "任务类型新增授权,操作名称业务抢通").join(); - sender.sendNotification( - "SPN Domain Agent City1", - "订阅业务抢通结果通知", - "通知主题为service-recovery-execution-result") - .join(); - sender.sendAuthorization("SPN Domain Agent City2", "下发授权放行策略", "任务类型新增授权,操作名称业务抢通").join(); - sender.sendNotification( - "SPN Domain Agent City2", - "订阅业务抢通结果通知", - "通知主题为service-recovery-execution-result") - .join(); + for (String agent : List.of("SPN Domain Agent City1", "SPN Domain Agent City2")) { + authorizationSender + .sendExtensionMessageFromData( + agent, + "下发授权放行策略", + SpnCasePrompts.addAuthorizationData(), + SpnCasePrompts.authorizationSchema(), + dev.openan.workflow.engine.client.A2ATExtension.AUTHORIZATION_T) + .join(); + } + + CountDownLatch recoveryNotification = new CountDownLatch(1); + AtomicReference recoveryNotificationText = new AtomicReference<>(); + java.util.function.Consumer> notificationCallback = + event -> { + if ("recovery-result".equals(event.get("artifact_name"))) { + recoveryNotificationText.set(String.valueOf(event.get("text"))); + recoveryNotification.countDown(); + } + }; + for (String agent : List.of("SPN Domain Agent City1", "SPN Domain Agent City2")) { + NotificationSubscription subscription = + notificationSender.openNotificationFromData( + agent, + "订阅业务抢通结果通知", + SpnCasePrompts.subscribeServiceRecoveryData(), + SpnCasePrompts.serviceRecoverySchema(), + notificationCallback) + .join(); + subscription.acknowledgement().join(); + subscriptions.add(subscription); + } Map allOutputs = new ConcurrentHashMap<>(); AtomicBoolean sawRecovery = new AtomicBoolean(false); AtomicBoolean sawSelfLoop = new AtomicBoolean(false); + AtomicBoolean sawNegotiation = new AtomicBoolean(false); EventCallback cb = new EventCallback() { @Override @@ -212,11 +294,14 @@ public void onEvent(String type, Map data) { && "Workbench".equals(data.get("agent"))) { sawSelfLoop.set(true); } + if (EventType.NEGOTIATION_REQUEST.equals(type)) { + sawNegotiation.set(true); + } allOutputs.put(type + ":" + data.get("agent"), data); } }; - WorkbenchControlPoint cp = new WorkbenchControlPoint(null); + WorkbenchControlPoint cp = new WorkbenchControlPoint(sdkEnvPath); ExecutionResult result = ExecutePsop.builder() .psop(crossCityWorkflow()) @@ -231,15 +316,46 @@ public void onEvent(String type, Map data) { assertTrue(result.isSuccess(), "Workflow must succeed: " + result.getError()); assertFalse(result.getHistory().isEmpty()); + assertEquals(3, result.getHistory().size(), "two diagnoses plus one merge must execute"); + long mergeExecutions = + result.getHistory().stream() + .filter(entry -> "merge_analysis".equals(entry.get("step"))) + .count(); + assertEquals(1, mergeExecutions, "cross-city diagnoses must be merged exactly once"); + assertFalse( + sawNegotiation.get(), + "The complete-input diagnosis workflow must not trigger Negotiation-T"); // Self-loop merge step executed locally (no A2A-T to self) assertTrue(sawSelfLoop.get(), "Self-loop merge must run via onSelfTask"); // SPN agents reported recovery via Notification-T channel assertTrue( sawRecovery.get(), "SPN must self-trigger recovery and report via Notification-T"); + assertTrue( + await(recoveryNotification, 5), + "Recovery result must arrive on the isolated Notification-T stream"); + assertTrue( + recoveryNotificationText.get() != null + && recoveryNotificationText.get().contains("业务抢通方案执行结果:成功"), + "Whitelist authorization must produce a successful recovery notification: " + + recoveryNotificationText.get()); + subscriptions.forEach(NotificationSubscription::close); + assertTrue( + subscriptions.stream().noneMatch(NotificationSubscription::isActive), + "Notification-T streams must close after the recovery result is received"); // Merge output contains fault localization Map mergeOut = result.getStepOutputs().get("merge_analysis"); assertNotNull(mergeOut, "merge_analysis output must exist"); String mergeText = String.valueOf(mergeOut.values().iterator().next()); assertTrue(mergeText.contains("城市1"), "Merge must locate fault in City1: " + mergeText); } + + private static boolean await(CountDownLatch latch, long seconds) { + try { + return latch.await(seconds, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + } diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoTest.java new file mode 100644 index 00000000..4a261c2f --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoTest.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.demo; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.openan.workflow.engine.model.SendMessageResult; +import org.junit.jupiter.api.Test; + +class SpringSpnDemoTest { + + @Test + void demoOnlyReportsSuccessForACompletedWorkbenchTask() { + assertDoesNotThrow(() -> SpringSpnDemo.requireCompleted( + SendMessageResult.builder().taskState("TASK_STATE_COMPLETED").text("ok").build())); + assertThrows( + IllegalStateException.class, + () -> SpringSpnDemo.requireCompleted( + SendMessageResult.builder().taskState("TASK_STATE_FAILED").text("failed").build())); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicyTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicyTest.java new file mode 100644 index 00000000..2ca1f284 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicyTest.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.Map; + +class AuthorizationPolicyTest { + + @Test + void exactRuleAndValidityAuthorizeRecovery() { + AuthorizationPolicy policy = + AuthorizationPolicy.fromValidated(SpnCasePrompts.addAuthorizationData()); + + assertTrue( + policy.authorizes( + "业务投诉诊断", "业务抢通", "隧道调优", LocalDate.of(2026, 8, 25))); + assertFalse( + policy.authorizes( + "业务投诉诊断", "业务抢通", "光模块重启", LocalDate.of(2026, 8, 25))); + assertFalse( + policy.authorizes( + "业务投诉诊断", "业务抢通", "隧道调优", LocalDate.of(2031, 1, 1))); + } + + @Test + void nonMutatingOperationCannotCreateExecutableWhitelist() { + Map data = new LinkedHashMap<>(SpnCasePrompts.addAuthorizationData()); + data.put(AuthorizationPolicy.OPERATION_TYPE_FIELD, "查询授权策略"); + + assertThrows(IllegalArgumentException.class, () -> AuthorizationPolicy.fromValidated(data)); + assertEquals( + AuthorizationPolicy.QUERY, + AuthorizationPolicy.operationFromValidated(data)); + } + + @Test + void sdkDefinedDeleteOperationAndSelectorAreAcceptedAsACommand() { + Map data = Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.DELETE, + AuthorizationPolicy.POLICY_LIST_FIELD, + "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"); + + assertEquals(AuthorizationPolicy.DELETE, AuthorizationPolicy.operationFromValidated(data)); + assertEquals( + "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + AuthorizationPolicy.requirePolicyList(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 sdkPermanentAndFullSevenFieldRulesAreSupported() { + AuthorizationPolicy permanent = AuthorizationPolicy.fromValidated(Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, + AuthorizationPolicy.POLICY_LIST_FIELD, + "载波调度/业务抢通/载波调度/永久生效")); + AuthorizationPolicy full = AuthorizationPolicy.fromValidated(Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, + AuthorizationPolicy.POLICY_LIST_FIELD, + "policy-1/业务投诉诊断/业务抢通/隧道调优/" + + "2026-06-01T12:00:00Z~2030-06-18T12:00:00Z/" + + "2026-06-01T12:00:00Z/2026-06-18T12:00:00Z")); + + assertTrue(permanent.authorizes( + "载波调度", "业务抢通", "载波调度", LocalDate.of(2099, 1, 1))); + assertTrue(full.authorizes( + "业务投诉诊断", "业务抢通", "隧道调优", LocalDate.of(2028, 1, 1))); + } + + @Test + void sdkLimitedValidityWrapperIsSupported() { + AuthorizationPolicy policy = AuthorizationPolicy.fromValidated(Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, + AuthorizationPolicy.POLICY_LIST_FIELD, + "业务投诉诊断/业务抢通/隧道调优/限期生效(2026-06-01~2030-06-18)")); + + assertTrue(policy.authorizes( + "业务投诉诊断", "业务抢通", "隧道调优", LocalDate.of(2028, 1, 1))); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/extension/NotificationPolicyTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/NotificationPolicyTest.java new file mode 100644 index 00000000..a3e78de6 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/NotificationPolicyTest.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +class NotificationPolicyTest { + + @Test + void acceptsSdkValidatedRecoverySubscription() { + NotificationPolicy policy = + NotificationPolicy.fromValidated( + Map.of( + NotificationPolicy.CONDITION_FIELD, + "业务抢通完成", + NotificationPolicy.REPORT_FORMAT_FIELD, + "投诉诊断任务流水号、执行状态、执行结果")); + + assertEquals("业务抢通完成", policy.condition()); + assertEquals("投诉诊断任务流水号、执行状态、执行结果", policy.reportFormat()); + } + + @Test + void missingReportSchemaOrWrongConditionTypeFailsClosed() { + assertThrows( + IllegalArgumentException.class, + () -> NotificationPolicy.fromValidated(Map.of("订阅条件", "业务抢通完成"))); + assertThrows( + IllegalArgumentException.class, + () -> + NotificationPolicy.fromValidated( + Map.of( + "订阅条件", + 1, + "上报通知数据格式", + "执行结果"))); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandlerTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandlerTest.java new file mode 100644 index 00000000..dccf4646 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/PrePositionedExtensionHandlerTest.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; + +class PrePositionedExtensionHandlerTest { + + @Test + void sampleOverridesTheSdkValidatorPromptToMatchItsRenderedMessageContract() + throws IOException { + try (InputStream stream = ClasspathResourceStreams.open( + "prompt_resources/prompts/content_validation/zh-CN/system.md")) { + 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")); + + Map notificationSchema = + SdkSlotSchemaLoader.load(StandardTemplates.SERVICE_RECOVERY, "zh-CN"); + assertEquals( + List.of(NotificationPolicy.REPORT_FORMAT_FIELD), + notificationSchema.get("required")); + } + + @Test + void sdkExtractionMustExactlyMatchValuesInRenderedSections() { + String prompt = + "## 授权策略的操作类型\n" + + "新增授权策略\n\n" + + "## 动网操作的授权策略列表\n" + + "业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18\n"; + Map exact = SpnCasePrompts.addAuthorizationData(); + + PrePositionedExtensionHandler.requireExtractedSectionsMatch( + prompt, + exact, + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.POLICY_LIST_FIELD); + + Map wrongOperation = new java.util.LinkedHashMap<>(exact); + wrongOperation.put( + AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.QUERY); + assertThrows( + IllegalArgumentException.class, + () -> PrePositionedExtensionHandler.requireExtractedSectionsMatch( + prompt, + wrongOperation, + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.POLICY_LIST_FIELD)); + } + + @Test + void sdkExtractionCannotHallucinateAnEmptyNotificationCondition() { + String prompt = + "## 订阅条件\n\n## 上报通知数据格式\n业务抢通事件数据包含执行状态\n"; + assertThrows( + IllegalArgumentException.class, + () -> PrePositionedExtensionHandler.requireExtractedSectionsMatch( + prompt, + Map.of( + NotificationPolicy.CONDITION_FIELD, + "xx子网", + NotificationPolicy.REPORT_FORMAT_FIELD, + "业务抢通事件数据包含执行状态"), + NotificationPolicy.CONDITION_FIELD, + NotificationPolicy.REPORT_FORMAT_FIELD)); + } + + @Test + void queryIsSideEffectFreeAndDeleteClearsTheDemoWhitelist() { + PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); + handler.applyAuthorization(SpnCasePrompts.addAuthorizationData(), "template", "test"); + AuthorizationPolicy added = handler.getAuthorizationPolicy(); + assertNotNull(added); + + handler.applyAuthorization( + Map.of(AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.QUERY), + "template", + "test"); + assertSame(added, handler.getAuthorizationPolicy()); + + handler.applyAuthorization( + Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.DELETE, + AuthorizationPolicy.POLICY_LIST_FIELD, + "policy-id"), + "template", + "test"); + assertNull(handler.getAuthorizationPolicy()); + } + + @Test + void deleteWithoutASelectorFailsClosedAndKeepsTheExistingWhitelist() { + PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); + handler.applyAuthorization(SpnCasePrompts.addAuthorizationData(), "template", "test"); + AuthorizationPolicy existing = handler.getAuthorizationPolicy(); + + assertThrows( + IllegalArgumentException.class, + () -> handler.applyAuthorization( + Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.DELETE), + "template", + "test")); + assertSame(existing, handler.getAuthorizationPolicy()); + } +} 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 f90eb0a9..52ee58d4 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 @@ -27,6 +27,8 @@ import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; +import dev.openan.workflow.engine.examples.testsupport.OfflineA2ATLlmClient; +import dev.openan.workflow.engine.examples.workbench.WorkbenchControlPoint; import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; import dev.openan.workflow.engine.examples.server.OmcAgentLauncher; import dev.openan.workflow.engine.model.SendMessageResult; @@ -34,6 +36,7 @@ import org.a2aproject.sdk.spec.AgentCard; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.net.URI; @@ -54,7 +57,14 @@ class EmbeddedA2AServerTest { private static final String AGENT_NAME = "Test Agent"; private JdkHttpA2AServer server; private DefaultWorkflowEngineClient client; + private A2ATransport transport; private int port; + private String sdkEnvPath; + + @BeforeAll + static void installOfflineSdkProvider() { + OfflineA2ATLlmClient.install(); + } private static String extractExtensionValue(SendMessageResult result, String extKeyword) { Map meta = result.getMetadata(); @@ -91,9 +101,9 @@ public java.security.cert.X509Certificate[] getAcceptedIssuers() { @BeforeEach void setUp() throws Exception { - // Unit tests must stay deterministic and offline: disable LLM calls so - // agents fall back to their hardcoded diagnostic text (asserted below). - System.setProperty("a2at.llm.disabled", "true"); + // 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( @@ -147,18 +157,24 @@ void setUp() throws Exception { server = new OmcAgentLauncher().startFromCard(card, new SpnDomainAgentCity1Executor()); Thread.sleep(500); - A2ATransport transport = + transport = new A2ATransport( List.of(mapper.convertValue(card, AgentCard.class)), null, - WorkflowEngineClientConfig.builder().sslVerify(false).build()); + WorkflowEngineClientConfig.builder() + .sslVerify(false) + .a2atEnvPath(sdkEnvPath) + .build()); client = new DefaultWorkflowEngineClient(transport); + client.setControlPoint(new WorkbenchControlPoint(sdkEnvPath)); } @AfterEach void tearDown() { if (client != null) client.close(); + if (transport != null) transport.close(); if (server != null) server.close(); + System.clearProperty("a2at.env.path"); } @Test @@ -225,4 +241,13 @@ void testNegotiationAutoLoopCompletes() { result.getText().contains("诊断结果"), "Final response must be the completed diagnosis, got: " + result.getText()); } + + @Test + void taskRouteParserDecodesExactlyOnePathSegment() { + assertEquals("task id+/city", JdkHttpA2AServer.taskIdFromPath("/tasks/task%20id%2B%2Fcity", null)); + assertEquals("task+id", JdkHttpA2AServer.taskIdFromPath("/tasks/task+id:cancel", ":cancel")); + assertNull(JdkHttpA2AServer.taskIdFromPath("/other/task", null)); + assertNull(JdkHttpA2AServer.taskIdFromPath("/tasks/a/b", null)); + assertNull(JdkHttpA2AServer.taskIdFromPath("/tasks/id:subscribe", ":cancel")); + } } diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/testsupport/OfflineA2ATLlmClient.java b/samples/src/test/java/dev/openan/workflow/engine/examples/testsupport/OfflineA2ATLlmClient.java new file mode 100644 index 00000000..45e021b5 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/testsupport/OfflineA2ATLlmClient.java @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.testsupport; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; + +/** Offline structured provider shared by the sample's real A2A-T integration tests. */ +public final class OfflineA2ATLlmClient implements LLMClient { + public static final String PROVIDER = "workflow-engine-e2e-test"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final LLMClientConfig config; + + public OfflineA2ATLlmClient(LLMClientConfig config) { + this.config = config; + } + + public static void install() { + if (!LLMClientFactory.availableProviders().contains(PROVIDER)) { + LLMClientFactory.register(PROVIDER, OfflineA2ATLlmClient.class); + } + } + + public static String envPath() { + try { + var env = + OfflineA2ATLlmClient.class + .getClassLoader() + .getResource("a2at-e2e.env"); + if (env == null) { + throw new IllegalStateException("Missing test resource a2at-e2e.env"); + } + return Path.of(env.toURI()).toString(); + } catch (java.net.URISyntaxException e) { + throw new IllegalStateException("Invalid a2at-e2e.env resource URI", e); + } + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + String prompt = + messages.isEmpty() + ? "" + : messages.get(messages.size() - 1).getOrDefault("content", ""); + Object propertiesValue = jsonSchema.get("properties"); + if (propertiesValue instanceof Map properties + && properties.containsKey("semantic_verdict")) { + Map response = new LinkedHashMap<>(); + response.put("semantic_verdict", true); + if (properties.containsKey("negotiation_type")) { + response.put("negotiation_type", "information"); + } + response.put("errors", List.of()); + response.put("params", validationParams(properties, prompt)); + 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", + "P533-珠江旧城-PTN3900-23-TPA1EG24-1"), + Map.of("name", "投诉分类", "value", "专线质差")))); + } + + 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, 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 prompt) { + Object paramsValue = outputProperties.get("params"); + if (paramsValue instanceof Map paramsSchema + && paramsSchema.get("properties") instanceof Map businessProperties + && !businessProperties.isEmpty()) { + Map params = new LinkedHashMap<>(); + for (Object rawName : businessProperties.keySet()) { + String name = String.valueOf(rawName); + params.put(name, slotValue(name, prompt)); + } + return params; + } + if (prompt.contains("授权策略") || prompt.contains("白名单")) { + return authorizationData(prompt); + } + if (prompt.contains("订阅条件") || prompt.contains("上报通知数据格式")) { + return SpnCasePrompts.subscribeServiceRecoveryData(); + } + Map task = new LinkedHashMap<>(); + task.put("任务对象", slotValue("任务对象", prompt)); + task.put("任务上下文", slotValue("任务上下文", prompt)); + return task; + } + + private static Map authorizationData(String prompt) { + Map data = new LinkedHashMap<>(); + data.put("授权策略的操作类型", authorizationOperation(prompt)); + int ruleIndex = prompt.indexOf("业务投诉诊断/业务抢通/隧道调优/"); + int idIndex = prompt.indexOf("7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"); + data.put( + "动网操作的授权策略列表", + idIndex >= 0 && (ruleIndex < 0 || idIndex < ruleIndex) + ? "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3" + : "业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18"); + return data; + } + + private static Object slotValue(String name, String prompt) { + if (name.contains("授权策略的操作类型") || name.contains("操作类型")) { + return authorizationOperation(prompt); + } + if (name.contains("动网操作的授权策略列表") || name.contains("策略列表")) { + return authorizationData(prompt).get("动网操作的授权策略列表"); + } + if (name.contains("订阅条件")) return ""; + if (name.contains("上报通知数据格式")) { + return SpnCasePrompts.subscribeServiceRecoveryData().get("上报通知数据格式"); + } + if (name.contains("接入端口名称")) { + return "P533-珠江旧城-PTN3900-23-TPA1EG24-1"; + } + if (name.contains("投诉分类")) return "专线质差"; + if (name.contains("任务对象")) { + if (prompt.contains("P882-")) { + return "接入端口名称:P882-珠江新城-PTN7900-23-TPA1EG24-11"; + } + if (prompt.contains("\"任务对象\":\"\"") + || prompt.contains("'任务对象': ''")) { + return ""; + } + return "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17"; + } + if (name.contains("任务上下文")) { + return "投诉分类:专线质差;OSS侧事件流水号:event-id-20260511-09013"; + } + return "test-value"; + } + + private static String authorizationOperation(String prompt) { + Map candidates = + Map.of( + "新增授权策略", "新增授权策略", + "修改授权策略", "修改授权策略", + "删除授权策略", "删除授权策略", + "查询授权策略", "查询授权策略"); + int earliest = Integer.MAX_VALUE; + String operation = "新增授权策略"; + for (Map.Entry candidate : candidates.entrySet()) { + int index = prompt.indexOf(candidate.getKey()); + if (index >= 0 && index < earliest) { + earliest = index; + operation = candidate.getValue(); + } + } + return operation; + } + + 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/workbench/WorkbenchTaskFlowTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskFlowTest.java new file mode 100644 index 00000000..b0b467e6 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskFlowTest.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.workbench; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.model.ExecutionResult; +import dev.openan.workflow.engine.model.StepType; +import dev.openan.workflow.engine.model.Workflow; + +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +class WorkbenchTaskFlowTest { + + @Test + void localFallbackKeepsBothCityDiagnosesParallelBeforeSelfLoopMerge() { + Workflow workflow = WorkbenchOrchestrator.fallbackWorkflow(); + + assertEquals(3, workflow.getSteps().size()); + assertEquals(0, workflow.getSteps().get(0).getLayer()); + assertEquals(0, workflow.getSteps().get(1).getLayer()); + assertEquals("SPN Domain Agent City1", workflow.getSteps().get(0).getSubtasks().get(0).getAgent()); + assertEquals("SPN Domain Agent City2", workflow.getSteps().get(1).getSubtasks().get(0).getAgent()); + assertEquals(StepType.SELF_LOOP, workflow.getSteps().get(2).getStepType()); + assertEquals( + List.of("diagnosis_city1", "diagnosis_city2"), + workflow.getSteps().get(2).getContextFrom()); + } + + @Test + void validatedTaskTParametersDriveRuntimeIntent() { + AtomicReference validatedPrompt = new AtomicReference<>(); + AtomicReference validatedTemplate = new AtomicReference<>(); + WorkbenchTaskInputParser parser = + new WorkbenchTaskInputParser( + (prompt, schema, templateUri) -> { + validatedPrompt.set(prompt); + validatedTemplate.set(templateUri.uri()); + return new FilledParamData( + Map.of( + "任务对象", + "接入端口名称:P781-city1-port", + "任务上下文", + "OSS侧事件流水号:event-123")); + }); + + WorkbenchTaskInputParser.ParsedTask parsed = + parser.parse( + "创建专线业务投诉诊断任务", + Map.of( + A2ATExtension.TASK_T.uri(), + "rendered Task-T prompt", + MetadataContent.TEMPLATE_URI_METADATA_KEY, + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri())); + + assertEquals("rendered Task-T prompt", validatedPrompt.get()); + assertEquals(StandardTemplates.PRIVATE_LINE_COMPLAINT.uri(), validatedTemplate.get()); + assertTrue(parsed.runtimeIntent().contains("P781-city1-port")); + assertTrue(parsed.runtimeIntent().contains("event-123")); + } + + @Test + void missingOrBlankTaskTBusinessFieldsAreRejected() { + WorkbenchTaskInputParser parser = + new WorkbenchTaskInputParser( + (prompt, schema, templateUri) -> + new FilledParamData(Map.of("任务对象", "", "任务上下文", "context"))); + + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> + parser.parse( + "task", + Map.of(A2ATExtension.TASK_T.uri(), "rendered prompt"))); + + 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 successfulWorkflowReturnsMergedBusinessOutput() { + ExecutionResult result = + ExecutionResult.builder() + .success(true) + .history( + List.of( + Map.of( + "step", "diagnosis_city1", + "output", "city1"), + Map.of( + "step", "diagnosis_city2", + "output", "city2"), + Map.of( + "step", "merge_analysis", + "output", "merged result"))) + .stepOutputs( + Map.of( + "diagnosis_city1", Map.of("diagnose", "city1"), + "diagnosis_city2", Map.of("diagnose", "city2"), + "merge_analysis", Map.of("merge", "merged result"))) + .build(); + + assertEquals("merged result", WorkbenchOrchestrator.buildResultText(result)); + } +} diff --git a/samples/src/test/resources/a2at-e2e.env b/samples/src/test/resources/a2at-e2e.env new file mode 100644 index 00000000..64710823 --- /dev/null +++ b/samples/src/test/resources/a2at-e2e.env @@ -0,0 +1,16 @@ +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=classpath +A2AT_PROMPT_COMPLIANCE_ENABLED=false +A2AT_LLM_PROVIDER=workflow-engine-e2e-test +A2AT_LLM_MODEL=offline-e2e-model +A2AT_LLM_API_KEY=test-key-not-used +A2AT_LLM_BASE_URL=https://llm.example.test/v1 +A2AT_LLM_MAX_TOKENS=256 +A2AT_LLM_TEMPERATURE=0 +A2AT_LLM_TIMEOUT_SECONDS=1 +A2AT_LLM_HISTORY_WINDOW=1 +A2AT_LLM_DISABLE_SYSTEM_PROXY=true +A2AT_LLM_SESSION_MAX_TOTAL=10 +A2AT_LLM_SESSION_MAX_PER_PROVIDER=10 +A2AT_LLM_MAX_ATTEMPTS=1 +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory From b52676d262e7d9de327193c481f31147caa4fdfa Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Tue, 25 Aug 2026 20:43:39 +0800 Subject: [PATCH 21/49] fix(samples): validate SPN completion and shutdown lifecycles Signed-off-by: Zhoujie628 --- .../examples/SpringWorkbenchApplication.java | 3 +- .../engine/examples/demo/SpringSpnDemo.java | 56 ++++++++++++++++--- .../workbench/SpringWorkbenchExecutor.java | 48 ++++++++++++---- .../SpringWorkbenchExtensionLifecycle.java | 2 +- 4 files changed, 85 insertions(+), 24 deletions(-) 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 4e3c3a68..6a664658 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,6 +20,7 @@ package dev.openan.workflow.engine.examples; import dev.openan.workflow.engine.client.EnvFileLoader; +import dev.openan.workflow.engine.examples.demo.SpringSpnDemo; import dev.openan.workflow.engine.examples.util.EnvResolver; import org.slf4j.Logger; @@ -29,7 +30,6 @@ import java.nio.file.Path; -import dev.openan.workflow.engine.examples.demo.SpringSpnDemo; /** * Spring Boot Workbench Agent -- northbound A2A server. * @@ -48,7 +48,6 @@ public class SpringWorkbenchApplication { private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchApplication.class); public static void main(String[] args) { - System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); loadDotEnv(); SpringApplication.run(SpringWorkbenchApplication.class, args); log.info("[SpringWorkbench] A2A server started -- ready to receive A2A-T messages"); 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 1111d54d..9fe20f24 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 @@ -25,11 +25,14 @@ import dev.openan.workflow.engine.client.AgentCardJacksonModule; import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; +import dev.openan.workflow.engine.examples.SpringWorkbenchApplication; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity1Executor; import dev.openan.workflow.engine.examples.agents.SpnDomainAgentCity2Executor; import dev.openan.workflow.engine.examples.server.OmcAgentLauncher; import dev.openan.workflow.engine.examples.util.EnvResolver; +import dev.openan.workflow.engine.examples.workbench.SpringWorkbenchExtensionLifecycle; import dev.openan.workflow.engine.model.SendMessageResult; +import dev.openan.workflow.engine.spring.A2AController; import org.a2aproject.sdk.spec.AgentCard; import org.slf4j.Logger; @@ -41,8 +44,6 @@ import java.util.Map; import java.util.concurrent.TimeUnit; -import dev.openan.workflow.engine.examples.SpringWorkbenchApplication; - /** * Demo entry point for the SPN cross-city diagnosis (direct HTTP+JSON transport). * @@ -69,7 +70,6 @@ public class SpringSpnDemo { private final OmcAgentLauncher omc = new OmcAgentLauncher(); public static void main(String[] args) throws Exception { - System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); SpringWorkbenchApplication.loadDotEnv(); new SpringSpnDemo().run(args); } @@ -109,7 +109,9 @@ public void run(String... applicationArgs) throws Exception { WB_AGENT_NAME, SpnCasePrompts.TASK_TEXT.length()); log.debug("[Demo] Workbench task text={}", SpnCasePrompts.TASK_TEXT); - String response = sendTaskToWorkbench(); + 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, @@ -134,6 +136,30 @@ public void run(String... applicationArgs) throws Exception { 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 { ctx.close(); } catch (Exception e) { @@ -152,10 +178,10 @@ public void run(String... applicationArgs) throws Exception { * 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 deterministically (no hand-written prompt text, no - * LLM call) — the same pattern as the official SDK sample. + * 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 String sendTaskToWorkbench() throws Exception { + private SendMessageResult sendTaskToWorkbench() throws Exception { long started = System.nanoTime(); AgentCard wbCard = OmcAgentLauncher.cardFromResource("agentcard/transport_workbench_agent.json"); @@ -197,14 +223,26 @@ private String sendTaskToWorkbench() throws Exception { result.getTaskState(), result.getText() != null ? result.getText().length() : 0, elapsedMillis(started)); - return result.getText(); + return result; } finally { client.close(); transport.close(); } } - /** Backward-compatible programmatic entry point. */ + 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")); + } + } + + /** Backward-compatible programmatic entry point for direct transport. */ public void run() throws Exception { run(new String[0]); } 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 9cbd9adb..90674d58 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 @@ -50,6 +50,7 @@ public class SpringWorkbenchExecutor extends BaseAgentExecutor { private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchExecutor.class); private final WorkbenchClientProperties properties; + private volatile WorkbenchTaskInputParser taskInputParser; public SpringWorkbenchExecutor(WorkbenchClientProperties properties) { this.properties = properties; @@ -76,22 +77,32 @@ private String resolveCredentialsPath() { public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { String taskId = ctx.getTaskId(); String contextId = ctx.getContextId(); - String input = extractText(ctx.getMessage()); + String userText = extractText(ctx.getMessage()); long started = System.nanoTime(); - log.info( - "[SpringWorkbench] TASK_START taskId={}, contextId={}, inputChars={}, " - + "orchUrl={}, transportMode={}, sslVerify={}", - taskId, - contextId, - input.length(), - properties.getOrchUrl(), - "direct", - properties.isSslVerify()); - 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(), @@ -101,7 +112,7 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { .run(input); Map metadata = new LinkedHashMap<>(); metadata.put(A2ATExtension.TASK_T.uri(), result); - List> parts = List.of(new TextPart("SPN cross-city fault diagnosis summary")); + List> parts = List.of(new TextPart(result)); emitter.addArtifact( parts, "result", "cross-city-diagnosis-summary", metadata, false, true); emitter.complete(buildStatusMessage(contextId, taskId, "Completed")); @@ -125,6 +136,19 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { } } + private WorkbenchTaskInputParser taskInputParser() { + WorkbenchTaskInputParser current = taskInputParser; + if (current != null) { + return current; + } + synchronized (this) { + if (taskInputParser == null) { + taskInputParser = WorkbenchTaskInputParser.fromEnv(resolveEnvPath()); + } + return taskInputParser; + } + } + @Override public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { log.warn( 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 9832d979..283ce015 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 @@ -32,7 +32,7 @@ import java.util.Map; -/** Keeps Authorization-T/Notification-T pre-positioning independent from individual tasks. */ +/** Keeps Authorization-T/Notification-T protocol lifecycles independent from individual tasks. */ @Component public final class SpringWorkbenchExtensionLifecycle { private static final Logger log = From 161f25900500192897b980744763db54d3215a0e Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Tue, 25 Aug 2026 20:44:51 +0800 Subject: [PATCH 22/49] docs: align engine guidance with current protocol behavior Signed-off-by: Zhoujie628 --- CHANGELOG.md | 17 + README.md | 31 +- docs/en/API_REFERENCE.md | 60 +- docs/en/DESIGN.md | 52 +- docs/en/DEVELOPER_GUIDE.md | 13 +- docs/en/notification-t-design-analysis.md | 9 +- docs/zh/API_REFERENCE.md | 54 +- docs/zh/DESIGN.md | 36 +- docs/zh/DEVELOPER_GUIDE.md | 11 +- .../a2a-t-sdk-negotiation-adaptation-plan.md | 634 ++------------- docs/zh/engine-code-review-response.md | 400 ++++++++++ docs/zh/engine-code-review-second-check.md | 548 +++++++++++++ docs/zh/engine-code-review.md | 745 ++++++++++++++++++ docs/zh/notification-t-design-analysis.md | 2 +- .../\344\270\232\345\212\241\346\265\201.md" | 18 +- 15 files changed, 1937 insertions(+), 693 deletions(-) create mode 100644 docs/zh/engine-code-review-response.md create mode 100644 docs/zh/engine-code-review-second-check.md create mode 100644 docs/zh/engine-code-review.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b5fc2c4b..f9a14e00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +> Entries below older release headings describe those releases and are not current API contracts. + ### Added - Client TLS options for custom CA, mTLS client certificate/private key, encrypted PKCS#8 keys, and CRL checking - Configurable bounded executors for client sends and Spring Boot server request handling - Configurable Notification-T acknowledgement timeout and protocol-log body controls +- Pinned, unreleased A2A-T SDK revision with real offline SDK end-to-end coverage +- Explicit `NotificationSubscription` lifecycle/heartbeat handle and stable SDK-event mapper +- Structured Authorization-T and Notification-T generation, server validation, and whitelist/recovery sample callbacks +- WAIMO Task-T parsing plus two-city parallel diagnosis and exact-once merged workbench result ### Changed @@ -24,14 +30,25 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Workflow execution validates graph targets and cycles up front and waits only for activated predecessors at joins - Protocol headers are redacted by default; sensitive values require an explicit diagnostic opt-in - Agent clients and transport executors are reused and closed through their owning runtime +- Task-T, Authorization-T, and Notification-T use independent transport/runtime/context instances +- Direct OMC and Eastcom Order SDK forwarding modes share the same protocol semantics; Order is the sample default +- 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 +- Eastcom Order SDK 1.1.18 outbound request buffers consumed by its Netty-to-RPC bridge are released by an isolated compatibility handler, preventing pooled direct-memory leaks without replacing the vendor jar - 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 diff --git a/README.md b/README.md index 5eabe783..00f2fda2 100644 --- a/README.md +++ b/README.md @@ -10,19 +10,46 @@ The engine handles all protocol mechanics: A2A message transport, SSE streaming, ## Features -- **A2A-T Extension Support**: Task-T (structured task prompts), Negotiation-T (auto negotiation loop), Authorization-T (pre-positioned whitelist), Notification-T (long-lived SSE subscription) +- **A2A-T Extension Support**: Task-T (structured task prompts), Negotiation-T (stateless auto negotiation loop), Authorization-T (independent whitelist operation), Notification-T (independent long-lived SSE subscription) - **Negotiation Content Layer**: SDK-template rendering for propose / accept / reject / abort messages (typed-data and free-text variants), validate-and-fill param extraction across all three negotiation types (information / target / feasibility), round-exhaustion abort flow - **Template Queries**: runtime enumeration of all A2A-T prompt templates (`getPrompts` / `getNegotiationPrompts` / `getPrompt`) - **DAG Workflow Execution**: Parallel dispatch, self-loop steps, conditional routing - **Multi-Protocol Transport**: REST, JSON-RPC, and gRPC auto-selected from AgentCard - **Authentication**: Bearer token login with TTL cache, AES-256-GCM encrypted credentials, custom `AuthProvider` - **HTTPS/TLS**: Configurable trust store, self-signed cert support for development -- **Protocol Logging**: Full request/response header and body dumps for debugging +- **Protocol Logging**: Sanitized request/response summaries; protocol bodies are logged only with explicit opt-in + +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. Outbound OMC calls support both `direct` and Eastcom `order` modes +(`order` is the production-oriented default). Task-T, Authorization-T, and Notification-T each use +an independent transport/runtime/context. Authorization and Notification are independently +triggered workbench operations rather than DAG nodes; Notification keeps an explicit long-lived +subscription until the recovery result, cancellation, or shutdown. ## Quick Start ### 1. Add Maven dependency +This revision intentionally targets an A2A-T SDK build that has not yet been published to Maven +Central. Install the pinned SDK revision first (do not use an older locally cached `1.0.0`): + +```bash +git clone https://github.com/Zhoujie628/a2a-t-sdk-java.git +cd a2a-t-sdk-java +git checkout 0ef79d37f49a9b7a2dbe16b6d9fd1ccdb6d9538d +mvn -B -Drevision=1.0.0-0ef79d3 -DskipTests \ + -pl a2a-t-client,a2a-t-server -am install +``` + +The engine POM and CI both pin that commit and immutable commit-derived version, preventing Maven from +silently resolving an older, API-incompatible jar. See the +[A2A-T SDK dependency guide](docs/zh/A2AT-SDK-DEPENDENCY.md) for Windows commands and upgrade steps. + ```xml dev.openan.workflow.sdk diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index 32803bf9..961d44d9 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -137,24 +137,25 @@ CompletableFuture sendMessageFromData( | `templateUri` | `TemplateUri` | Target template; null uses the SDK default (PRIVATE_LINE_COMPLAINT)| **Choosing a track**: when the caller holds structured data (e.g. complaint-ticket fields) prefer -this method — the SDK renders the Task-T prompt deterministically via -`generateTaskPromptFromDataWithSchema` (no scenario recognition, no LLM call). With free-form +this method — the SDK uses the schema-aware +`generateTaskPromptFromDataWithSchema` pipeline. It bypasses scenario recognition, but slot +mapping may still invoke the SDK-configured LLM. With free-form natural-language input use `sendMessage` (the SDK scenario-recognition pipeline). Both tracks run through the full Negotiation-T auto-loop, auth, and extension-header injection. `ExtensionSender.sendExtensionMessageFromData(agentName, instruction, data, schema, extension)` -provides the same structured-data track for Authorization-T / Notification-T pre-positioning. +provides the same structured-data track for independent Authorization-T operations; +`openNotificationFromData` is the preferred structured Notification-T subscription API. #### Negotiation auto-loop behavior - **Trigger**: the response metadata carries the Negotiation-T extension key (the agent opened a - negotiation via `Message(ROLE_AGENT)` + `INPUT_REQUIRED`) and the message metadata carries - `negotiation_context` (the negotiation context produced by the agent-side `startNegotiation`). -- **Each round**: the SDK `receiveNegotiation` parses the request → - `validateProposePromptAndDataFilling` validates the propose against its template and extracts - parameters → `ControlPoint.onNegotiation` produces a clarification → the SDK content layer - renders the follow-up message → the SDK state machine advances (`continueNegotiation`, - AGREED / REJECTED) → send to the agent → recurse on the next round. + negotiation via `Message(ROLE_AGENT)` + `INPUT_REQUIRED`) together with SDK-generated + `templateUri` and `negotiationContext={id,round,maxRounds}`. Missing fields fail closed. +- **Each round**: `validateProposePromptAndDataFilling` validates and extracts the Propose → + `ControlPoint.onNegotiation` decides → the SDK content layer renders Accept / Reject / Abort → + Accept is checked with `validateAcceptPromptAndDataFilling` before sending → the engine copies + and advances the stateless `NegotiationContext` → send → recurse. - **Clarification conventions** (the onNegotiation return value): - `data:{...json...}` — deterministic fromData Accept rendering (no LLM call; the JSON body is the filled-parameter map) @@ -163,10 +164,9 @@ provides the same structured-data track for Authorization-T / Notification-T pre - **Round exhaustion**: beyond `maxNegotiationRounds` the loop stops, a terminal message is sent via the SDK abort template (best effort — delivery failures are logged only), a `NEGOTIATION_FAILED` event fires, and the last agent reply stands as the final response. -- **Negotiation context**: the agent carries the `startNegotiation` payload's context - (negotiationType / negotiationId / round / status) in the message metadata's - `negotiation_context` key; the engine parses it and passes it to the receive / validate / - continue APIs. +- **Negotiation context**: the latest SDK removed `startNegotiation`, `receiveNegotiation`, and + `continueNegotiation`. The engine accepts only canonical `negotiationContext` with + `{id, round, maxRounds}` and does not read legacy `negotiation_context` metadata. #### Template queries @@ -184,8 +184,9 @@ Use the SDK's `StandardTemplates` constants for template URIs (e.g. ### ExtensionSender -Pre-positioning facade over the same `A2ATransport`. Sends one-shot Authorization-T requests, establishes long-lived -Notification-T subscriptions, and sends other one-shot extensions before a workflow starts. It bypasses Task-T prompt +Independent-protocol facade over caller-owned, dedicated `A2ATransport` instances. It sends one-shot +Authorization-T requests and establishes long-lived Notification-T subscriptions at independent +business times; neither operation is a workflow DAG node. It bypasses Task-T prompt generation and the Negotiation-T auto-loop; later Notification-T events are delivered through the subscription callback. ```java @@ -203,7 +204,7 @@ public interface ExtensionSender { String agentName, String instruction, String naturalLanguageInput); // Interface contract: Notification-T (long-lived SSE + event callback) - CompletableFuture sendNotification( + CompletableFuture openNotification( String agentName, String instruction, String naturalLanguageInput, Consumer> eventCallback); } @@ -213,9 +214,14 @@ public interface ExtensionSender { |------------------------|-----------------|-------------------------------------------------| | `agentName` | `String` | Target agent name (must match `AgentCard.name`) | | `instruction` | `String` | Short instruction text; becomes `parts[].text` in the A2A message body | -| `naturalLanguageInput` | `String` | Natural-language extension metadata. Task-T is structured by the A2A-T SDK; Authorization-T and Notification-T currently use this value directly | +| `naturalLanguageInput` | `String` | Natural-language track input; production should prefer the SDK-rendered structured-data methods | | `extension` | `A2ATExtension` | Extension enum (never hardcode URIs) | -| `eventCallback` | `Consumer>` | Optional SSE event callback (`sendNotification` only). Recovery results pushed by the agent are received here in real time; the Map contains `agent`, `text`, `metadata`, `state`. Null drops subsequent events | +| `eventCallback` | `Consumer>` | Optional SSE event callback. The stable Map includes `event_kind`, `agent`, `task_id` and event-specific state/artifact fields | + +`NotificationSubscription` separates the initial ACK (`acknowledgement()`) from stream completion +(`completion()`), and exposes `heartbeat()`, `isHealthy(maximumIdle)`, and idempotent `close()`. +Task, Authorization, and Notification must use different transport/runtime instances. Completing a +workflow must not close a Notification-T subscription. **Wire format**: The resulting A2A message sent to the agent has `parts[].text = instruction` and `metadata = { "": "" }`. For example, Authorization-T produces: @@ -316,13 +322,13 @@ message-generation / validation / template-query surface, one method per `A2ATCl |-------|---------|-------| | fromData generation | `generateProposeFromData` / `generateAcceptFromData` / `generateRejectFromData` / `generateAbortFromData` | deterministic rendering from typed data, no LLM | | fromText generation | `generateProposeFromText` / `generateAcceptFromText` / `generateRejectFromText` / `generateAbortFromText` | free text + one LLM extraction step | -| Runtime state machine | `startNegotiation` / `receiveNegotiation` / `continueNegotiation` | SDK negotiation round progression | +| Stateless context | `MetadataContent.buildMetadataContent` / `NegotiationContext` | carry and advance `{id, round, maxRounds}` | | Validate and fill | `validatePropose` / `validateAccept` / `validateReject` / `validateAbort` | rule gate + LLM semantic validation + param merge; requires a `NegotiationContext` (null = not a negotiation message) | | Template queries | `getPrompts` / `getNegotiationPrompts` / `getPrompt` / `getNegotiationPrompt` | catalog enumeration and lookup | | Utility | `toMetadata(MetadataContent)` | generated message → A2A metadata map (with `templateUri`, `negotiationContext` keys) | -Pass template URIs via `StandardTemplates` constants. Failures throw `A2ATError` subtypes; -callers are expected to catch and degrade. +Pass template URIs via `StandardTemplates` constants. Failures throw `A2ATError` subtypes and must +fail closed: callers must never degrade by placing raw text under an A2A-T extension URI. ### A2AJavaClientRuntime @@ -677,16 +683,12 @@ Task lifecycle status, used in `TASK_STATUS_CHANGED` events for cross-SDK consis | Extension | URI | |-----------------|-------------------------------------------------------------------------------------------| | Task-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1` | -| Negotiation-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T` | +| Negotiation-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1` | | Authorization-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1` | | Notification-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1` | -> **Note:** `DATA-NEGOTIATION-T/v1` is **not** an A2A-T extension. It is an -> SDK-internal metadata key used by the A2A-T SDK negotiation module to carry -> structured context (`negotiationType`, `round`, `negotiationId`, -> `status`) alongside the Negotiation-T text. It is not declared on -> AgentCard, not handled by ExtensionHandler, and not advertised in the -> `A2A-Extensions` header. +Canonical negotiation metadata is `templateUri` plus +`negotiationContext={id,round,maxRounds}`. Legacy state-machine keys are not accepted. --- diff --git a/docs/en/DESIGN.md b/docs/en/DESIGN.md index d9ae5b29..b35368eb 100644 --- a/docs/en/DESIGN.md +++ b/docs/en/DESIGN.md @@ -59,18 +59,20 @@ state, and metadata. - **`WorkflowEngineClient`** - the workflow execution send path. Owns Task-T prompt generation (before send), the Negotiation-T auto-loop (after receive), the global `EventCallback`, and the `ControlPoint` wiring. This is the facade the executor calls during workflow execution. -- **`ExtensionSender`** - pre-positioning facade. Sends one-shot Authorization-T requests or establishes long-lived - Notification-T subscriptions *before* the workflow starts. It bypasses Task-T generation and the negotiation loop; +- **`ExtensionSender`** - independent protocol-operation facade. Sends one-shot Authorization-T requests or establishes + long-lived Notification-T subscriptions at a workbench-selected business time. It bypasses Task-T generation and the negotiation loop; Notification-T events use the subscription callback rather than the workflow-global callback. #### Why reusable transport machinery with two facades? -Both the workflow send path and the pre-positioning path need the same wire-level machinery: an HTTP client, +Both the workflow send path and the independent-extension path need the same wire-level machinery: an HTTP client, TLS configuration, auth interceptors, agent-card resolution, and SSE parsing. Putting that machinery on either facade -would either (a) force a caller that only wants to pre-position to hold the full workflow facade, or (b) duplicate the +would either (a) force a caller that only needs an independent extension to hold the full workflow facade, or (b) duplicate the wire code across two classes. The reusable transport / two-facade design avoids both: the wire layer is written once on `A2ATransport`, and each facade delegates all wire work to it while keeping its own -orchestration concern isolated. The facades may use task-scoped or workbench-scoped transport lifetimes. +orchestration concern isolated. The implementation is reusable, but instances are not shared: Task-T, +Authorization-T, and Notification-T each require an independent transport/runtime/context. Authorization +uses a one-shot lifetime; Notification uses a workbench-scoped long-lived lifetime. ### 2.2 Layer 1 - Traversal @@ -110,7 +112,8 @@ decision: | `onRoute` | executor | Choose a branch at a conditional step | | `onNegotiation` | client auto-loop | Supply clarification on INPUT_REQUIRED | -Authorization-T and Notification-T are pre-positioning concerns handled via `ExtensionSender` before the workflow starts, not in-workflow callbacks. +Authorization-T and Notification-T are independently triggered workbench protocol operations handled via +`ExtensionSender`; they are not workflow DAG nodes. --- @@ -131,21 +134,19 @@ both): `ControlPoint.onNegotiation` for a clarification, resends the follow-up, and repeats up to a configured round limit. -### 4.2 Pre-positioning extensions +### 4.2 Independent-lifecycle extensions -Established through `ExtensionSender` before a workflow and decoupled from any single workflow transport: +Triggered through `ExtensionSender` at a workbench-selected business time and decoupled from any single workflow transport: -- **Authorization-T** - Sends a one-shot authorization request. The current implementation uses the caller-supplied natural-language input directly as the metadata value. -- **Notification-T** - Establishes a result subscription. Opens a long-lived SSE stream so later recovery results flow - back through the response stream. +- **Authorization-T** - Uses the SDK schema-aware generation and validation pipeline for add, modify, + delete, and query operations. A valid whitelist is a prerequisite for automatic OMC recovery. +- **Notification-T** - Uses the SDK schema-aware pipeline and returns a `NotificationSubscription`. + ACK and stream completion are separate; the handle is closed after the recovery result, cancellation, + or workbench shutdown. -The subscription *result* (e.g. a recovery outcome pushed later) flows back through the `sendNotification` response -stream, not through -`onNotification`. That hook only fires when an agent voluntarily includes a Notification-T payload in a `sendMessage` -task response. - -Task-T uses the SDK's `generateTaskPrompt`. Authorization-T and Notification-T currently use caller-supplied metadata -values because the upstream SDK does not expose corresponding prompt generators. +The subscription ACK is exposed by `NotificationSubscription.acknowledgement()` and subsequent recovery +events flow through the subscription callback. They do not enter workflow `ControlPoint` callbacks or the +global task-event stream. ### 4.3 Extension handler chain @@ -160,10 +161,10 @@ graph TD SM --> BS --> TS --> AR --> AN ``` -`ExtensionRegistry.getHandlersForExtensions` matches an agent's declared extension URIs against handler keywords -(case-insensitive) and returns the handler chain for that agent. Authorization-T / Notification-T handler classes are -retained for callers that need inline handling of agent-pushed data, but they are not auto-registered - that is a -pre-positioning concern. +`ExtensionRegistry.getHandlersForExtensions` requires exact canonical AgentCard URIs for the built-in +Task-T and Negotiation-T handlers and returns a deduplicated chain. Authorization-T / Notification-T are +not registered in the workflow handler chain; they are solely an `ExtensionSender` and +independent-lifecycle concern. --- @@ -277,9 +278,10 @@ The SDK is standalone: it does not depend on the orchestration center. `A2ATransport`; `WorkflowEngineClient` and `ExtensionSender` each own one orchestration concern and delegate wire work, with task-scoped or workbench-scoped transport lifetimes. Avoids forced-facade coupling and wire-code duplication. -2. **In-workflow vs pre-positioning extensions** - Task-T and Negotiation-T are part of the `sendMessage` chain; - Authorization-T is a one-shot request sent before the workflow, while Notification-T is a long-lived subscription - established before the workflow. The registry auto-registers only the in-workflow pair. +2. **In-workflow vs independent-lifecycle extensions** - Task-T and Negotiation-T are part of the + `sendMessage` chain. Authorization-T is an independently triggered one-shot request, while + Notification-T is a workbench-scoped long-lived subscription. The registry auto-registers only + the in-workflow pair. 3. **Auto-negotiation loop** - the engine owns the resend loop so hosts only supply clarification text (`onNegotiation`), never the protocol mechanics of resending. diff --git a/docs/en/DEVELOPER_GUIDE.md b/docs/en/DEVELOPER_GUIDE.md index 6564a9ae..f6ab1f56 100644 --- a/docs/en/DEVELOPER_GUIDE.md +++ b/docs/en/DEVELOPER_GUIDE.md @@ -56,7 +56,7 @@ public class MyControlPoint implements ControlPoint { } ``` -`onNegotiation` has a default that returns a generic clarification. Authorization and notification are pre-positioning operations sent via `ExtensionSender` before the workflow starts, not on `ControlPoint`. +`onNegotiation` has a default that returns a generic clarification. Authorization and notification are independent protocol operations sent via dedicated `ExtensionSender`/transport instances at a workbench-selected business time, not `ControlPoint` callbacks or workflow DAG nodes. ## 4. Execute via Builder (recommended) @@ -252,11 +252,14 @@ A2AT_LANGUAGE=zh-CN A2AT_CRED_KEY=<32-byte hex> ``` -When `a2atEnvPath` is null, Task-T prompt generation is skipped. +When `a2atEnvPath` is null, unstructured `sendMessage` sends plain A2A text and does not claim +Task-T semantics. Structured Task-T, Authorization-T, Notification-T, and Negotiation-T operations +fail explicitly. -**Disabling the LLM layer in tests**: set the system property `a2at.llm.disabled=true` to make the -sample agents skip A2ATClient/A2ATServer initialization entirely — negotiation falls back to -default texts, no LLM calls are made. Used by the sample test suites for hermetic runs. +**Offline tests**: test `.env` resources select `OfflineA2ATLlmClient` through the SDK SPI. This +keeps the real A2ATClient/A2ATServer generation, validation, and parameter-filling pipelines +repeatable without network access. There is no production fallback that disables the SDK and sends +default negotiation text. ## 10. Integration Patterns diff --git a/docs/en/notification-t-design-analysis.md b/docs/en/notification-t-design-analysis.md index 5c81e276..d0ba748e 100644 --- a/docs/en/notification-t-design-analysis.md +++ b/docs/en/notification-t-design-analysis.md @@ -2,9 +2,10 @@ > **Superseded.** This historical analysis has been folded into > [DESIGN.md](DESIGN.md), sections 4 (A2A-T Extension Model) and 7 -> (Interaction Sequences). The pre-positioning flow described below now -> lives on `ExtensionSender` (see DESIGN.md, "Shared transport with two -> facades"), not on `WorkflowEngineClient`. Kept for reference only. +> (Interaction Sequences). The pre-positioning/shared-channel wording below is obsolete: +> Authorization-T and Notification-T now use `ExtensionSender` at independent business times, +> with transport/runtime/context instances isolated from Task-T and from each other. Kept for +> historical reference only. --- @@ -318,4 +319,4 @@ AgentExecutor (SDK interface) Three levels of inheritance makes it hard to trace the flow. Simplification: Flatten where possible. Or at minimum, make the flow in each class readable without jumping through -three files. \ No newline at end of file +three files. diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index 2176ddac..415c87dc 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -136,31 +136,33 @@ CompletableFuture sendMessageFromData( | `templateUri` | `TemplateUri` | 目标模板;null 用 SDK 默认(PRIVATE_LINE_COMPLAINT) | **双轨选型**:业务方持有结构化数据(如投诉工单字段)时优先用本方法——SDK 走 -`generateTaskPromptFromDataWithSchema` 确定性渲染(无场景识别、无 LLM 调用);持有自然语言 +`generateTaskPromptFromDataWithSchema` schema-aware 管线(跳过场景识别,但 slot 映射可能调用 +SDK 配置的 LLM);持有自然语言 描述时用 `sendMessage`(SDK 场景识别管线)。两条轨道都完整经过 Negotiation-T 自动循环、 认证和扩展头注入。 `ExtensionSender.sendExtensionMessageFromData(agentName, instruction, data, schema, extension)` -为 Authorization-T / Notification-T 前置预置提供同样的结构化数据轨道。 +为独立 Authorization-T 操作提供同样的结构化数据轨道;Notification-T 结构化订阅优先使用 +`openNotificationFromData`。 #### 协商自动循环行为 - **触发条件**:响应 metadata 携带 Negotiation-T 扩展 key(agent 通过 `Message(ROLE_AGENT)` + - `INPUT_REQUIRED` 发起协商),且消息 metadata 携带 `negotiation_context`(协商上下文, - 由 agent 侧 `startNegotiation` 产生)。 -- **每轮**:SDK `receiveNegotiation` 解析协商请求 → `validateProposePromptAndDataFilling` - 按模板校验并抽取参数 → `ControlPoint.onNegotiation` 生成澄清 → SDK 内容层渲染 follow-up - 消息 → SDK 状态机推进(`continueNegotiation`,AGREED / REJECTED)→ 发送给 agent → - 递归检查下一轮。 + `INPUT_REQUIRED` 发起协商),并同时携带 SDK 生成的 `templateUri` 与 + `negotiationContext={id,round,maxRounds}`。缺少任一字段均按非法协商报文失败关闭。 +- **每轮**:`NegotiationTHandler` 用 `validateProposePromptAndDataFilling` 按 `templateUri` + 校验 Propose 并抽取参数 → `ControlPoint.onNegotiation` 生成决策 → SDK 内容层渲染 + Accept / Reject / Abort → Accept 在发出前再用 `validateAcceptPromptAndDataFilling` 校验 → + 引擎复制并递增无状态 `NegotiationContext` → 发送给 agent → 递归检查下一轮。 - **澄清文本约定**(onNegotiation 返回值): - `data:{...json...}` — 确定性 fromData 渲染 Accept(无 LLM 调用,JSON 为补充参数 map) - `reject:原因` / `abort:原因` — 渲染 Reject / Abort 终态模板 - 其他文本 — fromText 渲染 Accept(一次 LLM 抽取) - **轮次耗尽**:超过 `maxNegotiationRounds` 后不再继续循环,通过 SDK abort 模板发送终止消息 (尽力而为,发送失败仅记日志),发出 `NEGOTIATION_FAILED` 事件,agent 最后一次回复作为最终响应。 -- **协商上下文**:agent 将 `startNegotiation` 载荷的上下文(negotiationType / negotiationId / - round / status)放在消息 metadata 的 `negotiation_context` key 中,引擎解析后传给 - receive / validate / continue API。 +- **协商上下文**:最新 SDK 已删除 `startNegotiation` / `receiveNegotiation` / + `continueNegotiation`。引擎只接受规范键 `negotiationContext`,结构固定为 + `{id, round, maxRounds}`;不再读取 `negotiation_context` 等旧键。 #### 模板查询 @@ -177,7 +179,7 @@ CompletableFuture sendMessageFromData( ### ExtensionSender -基于同一 `A2ATransport` 的前置操作门面。在工作流开始前发送一次性 Authorization-T 请求、建立长连接 Notification-T 订阅,或发送其他一次性扩展。跳过 +基于调用方专用 `A2ATransport` 的独立协议操作门面。在独立业务时机发送一次性 Authorization-T 请求、建立长连接 Notification-T 订阅。它们不是工作流 DAG 节点。跳过 Task-T 提示词生成和 Negotiation-T 自动循环;Notification-T 后续事件通过订阅回调交付。 ```java @@ -195,7 +197,7 @@ public interface ExtensionSender { String agentName, String instruction, String naturalLanguageInput); // 接口契约:Notification-T(长连接 SSE + 事件回调) - CompletableFuture sendNotification( + CompletableFuture openNotification( String agentName, String instruction, String naturalLanguageInput, Consumer> eventCallback); } @@ -205,9 +207,13 @@ public interface ExtensionSender { |------------------------|-----------------|-------------------------------------| | `agentName` | `String` | 目标智能体名称(须匹配 `AgentCard.name`) | | `instruction` | `String` | 简短指令文本;成为 A2A 消息体的 `parts[].text` | -| `naturalLanguageInput` | `String` | 扩展 metadata 的自然语言输入。Task-T 由 A2A-T SDK 生成结构化提示词;Authorization-T / Notification-T 当前直接使用此值 | +| `naturalLanguageInput` | `String` | 自然语言轨道输入;生产优先使用 `sendExtensionMessageFromData` / `openNotificationFromData`,由 SDK 生成完整 metadata | | `extension` | `A2ATExtension` | 扩展枚举(勿硬编码 URI) | -| `eventCallback` | `Consumer>` | 可选 SSE 事件回调(`sendNotification` 专用)。OMC 后续推送的抢通结果通过此回调实时接收,回调 Map 含 `agent`、`text`、`metadata`、`state`。不传(null)时后续事件被丢弃 | +| `eventCallback` | `Consumer>` | 可选 SSE 事件回调。Map 含 `event_kind`、`agent`、`task_id` 及对应的状态/产物字段 | + +`NotificationSubscription` 将首个 ACK(`acknowledgement()`)与长连接终止 +(`completion()`)分开,并提供 `heartbeat()`、`isHealthy(maximumIdle)` 和幂等 `close()`。 +Task、Authorization、Notification 必须使用不同的 transport/runtime 实例;关闭单次工作流不得关闭订阅。 **报文格式**:发送给智能体的 A2A 消息结构为 `parts[].text = instruction`,`metadata = { "<扩展URI>": "<扩展值>" }`。以 Authorization-T 为例: @@ -307,12 +313,13 @@ SDK 消息生成/校验/模板查询的完整能力面,方法与 SDK `A2ATClie |------|------|------| | fromData 生成 | `generateProposeFromData` / `generateAcceptFromData` / `generateRejectFromData` / `generateAbortFromData` | 类型化数据确定性渲染,无 LLM | | fromText 生成 | `generateProposeFromText` / `generateAcceptFromText` / `generateRejectFromText` / `generateAbortFromText` | 自由文本 + 一步 LLM 抽取 | -| 运行时状态机 | `startNegotiation` / `receiveNegotiation` / `continueNegotiation` | SDK 协商轮次推进 | +| 无状态上下文 | `MetadataContent.buildMetadataContent` / `NegotiationContext` | 生成并传递 `{id, round, maxRounds}`;引擎负责复制和递增轮次 | | 校验提取 | `validatePropose` / `validateAccept` / `validateReject` / `validateAbort` | 规则门 + LLM 语义校验 + 参数合并;需传 `NegotiationContext`(null = 非协商消息) | | 模板查询 | `getPrompts` / `getNegotiationPrompts` / `getPrompt` / `getNegotiationPrompt` | 模板目录枚举与查询 | | 工具 | `toMetadata(MetadataContent)` | 生成消息转 A2A metadata map(含 `templateUri`、`negotiationContext` key) | -模板 URI 用 `StandardTemplates` 常量传参。失败抛 `A2ATError` 子类,调用方应捕获并降级。 +模板 URI 用 `StandardTemplates` 常量传参。失败抛 `A2ATError` 子类;协议内容必须 +fail-closed,不得将原始文本作为对应 A2A-T URI 的值降级发送。 ### A2AJavaClientRuntime @@ -667,24 +674,17 @@ ExecutionResult result = executor.run().join(); | 扩展 | URI | |-----------------|-------------------------------------------------------------------------------------------| | Task-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1` | -| Negotiation-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T` | +| Negotiation-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1` | | Authorization-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1` | | Notification-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1` | -> **注意:** `DATA-NEGOTIATION-T/v1` **不是** A2A-T 扩展。它是 A2A-T SDK -> 协商模块内部使用的 metadata key,用于携带结构化协商上下文 -> (`negotiationType`、`round`、`negotiationId`、`status`)。 -> 不在 AgentCard 上声明,不被 ExtensionHandler 处理,也不出现在 -> `A2A-Extensions` 头中。 - > **协商相关 metadata key 约定:** > > - `negotiationContext`(SDK 契约):SDK 生成协商消息时写入,携带 `{id, round, maxRounds}`。 > validate API 依赖它区分协商消息与普通消息。 > - `templateUri`(SDK 契约):SDK 生成消息时写入,标识渲染所用模板。 -> - `negotiation_context` / `negotiation_message` / `negotiation_params`(引擎内部): -> `NegotiationTHandler` 接收协商消息后写入 result metadata,供自动循环和调用方读取。 -> 常量定义在 `A2ATExtension`(`NEGOTIATION_CONTEXT_META_KEY` 等)。 +> - `negotiation_message` / `negotiation_params`(引擎内部):接收校验后供自动循环和 +> 业务控制点读取,绝不作为线上的协议兼容键。 --- diff --git a/docs/zh/DESIGN.md b/docs/zh/DESIGN.md index 3de9fa6b..fe1a6c9a 100644 --- a/docs/zh/DESIGN.md +++ b/docs/zh/DESIGN.md @@ -52,16 +52,17 @@ graph TD - **`WorkflowEngineClient`** — 工作流执行发送路径。拥有 Task-T 提示词生成(发送前)、 Negotiation-T 自动循环(接收后)、全局 `EventCallback`、`ControlPoint` 装配。 这是执行器在工作流执行期间调用的门面。 -- **`ExtensionSender`** — 前置下发门面。在工作流启动前发送一次性 Authorization-T 请求或建立长连接 Notification-T 订阅。 +- **`ExtensionSender`** — 独立协议操作门面。在工作台选定的业务时机发送一次性 Authorization-T 请求或建立长连接 Notification-T 订阅。 绕过 Task-T 生成和协商循环,不通过全局回调发射事件 — 返回的结果就是回调。 #### 为什么复用 transport 实现 + 两个门面? -工作流发送路径和前置下发路径都需要相同的通信层机制:HTTP 客户端、TLS 配置、 -认证拦截器、智能体卡片解析、SSE 解析。把这些机制放在任一门面上要么 (a) 强制只想做前置下发的 +工作流发送路径和独立扩展路径都需要相同的通信层机制:HTTP 客户端、TLS 配置、 +认证拦截器、智能体卡片解析、SSE 解析。把这些机制放在任一门面上要么 (a) 强制只想做独立协议操作的 调用方持有完整工作流门面,要么 (b) 在两个类中重复通信代码。复用 `A2ATransport` 实现 + 两个门面的设计 -避免了这两个问题。两个门面可以持有不同生命周期的 transport 实例:工作流发送使用任务级 transport, -Notification-T 订阅使用工作台级 transport。 +避免了这两个问题。类实现可复用,实例不复用:Task-T、Authorization-T、Notification-T +必须各自创建 transport/runtime/context。Task-T 使用任务级实例,Authorization-T 一次请求后释放, +Notification-T 使用工作台级长连接实例。 ### 2.2 Layer 1 - 遍历层 @@ -98,7 +99,7 @@ SDK 暴露两个用户实现的接口,按职责拆分。 | `onRoute` | 执行器 | 在条件步骤选择分支 | | `onNegotiation` | 客户端自动循环 | 在 INPUT_REQUIRED 时提供澄清文本 | -Authorization-T 和 Notification-T 是预置操作,通过 `ExtensionSender` 在工作流启动前发送,不在工作流执行中回调。 +Authorization-T 和 Notification-T 是工作台独立触发的协议操作,不是工作流 DAG 节点。 --- @@ -116,16 +117,15 @@ Authorization-T 和 Notification-T 是预置操作,通过 `ExtensionSender` 提取协商上下文和消息。这驱动自动循环:引擎调用 `ControlPoint.onNegotiation` 获取澄清, 重发后续消息,重复直到达到配置的轮次上限。 -### 4.2 前置下发扩展 +### 4.2 独立生命周期扩展 -在工作流启动前通过 `ExtensionSender` 建立,并与单次工作流 transport 解耦: +在工作台选定的业务时机通过 `ExtensionSender` 触发,并与单次工作流 transport 解耦: -- **Authorization-T** — 发送一次性授权前置请求。当前直接使用调用方提供的自然语言输入作为 metadata 值。 -- **Notification-T** — 建立结果订阅。打开长连接 SSE 流,后续抢通结果通过该流返回。 +- **Authorization-T** — 通过 SDK schema-aware 管线渲染和校验新增/修改/删除/查询请求。有效白名单是 OMC 自动抢通的前置条件。 +- **Notification-T** — 通过 SDK schema-aware 管线建立结果订阅,返回 `NotificationSubscription`。ACK 与流结束分离;收到抢通结果、取消或服务关闭时显式关闭。 -订阅结果(如后续推送的抢通结果)通过 `sendNotification` 响应流返回, -不通过 `onNotification`。该钩子仅在智能体在 `sendMessage` 任务响应中主动包含 -Notification-T 载荷时触发。 +订阅 ACK 从 `NotificationSubscription.acknowledgement()` 获取,后续抢通事件通过订阅回调返回。 +它们不进入工作流的 `ControlPoint` 或全局任务事件流。 ### 4.3 扩展处理器链 @@ -140,9 +140,9 @@ graph TD SM --> BS --> TS --> AR --> AN ``` -`ExtensionRegistry.getHandlersForExtensions` 将智能体声明的扩展 URI 与处理器关键字 -(不区分大小写)匹配,返回该智能体的处理器链。Authorization-T / Notification-T 处理器类 -保留给需要内联处理智能体推送数据的调用方,但不自动注册 — 这是前置下发的关注点。 +`ExtensionRegistry.getHandlersForExtensions` 对内置 Task-T/Negotiation-T 要求 AgentCard 中的规范 URI +精确匹配,返回去重的处理器链。Authorization-T / Notification-T 不注册到工作流处理器链, +只由 `ExtensionSender` 及独立生命周期处理。 --- @@ -253,8 +253,8 @@ SDK 是独立的:不依赖编排中心。 并可使用任务级或工作台级 transport 生命周期。 避免强制门面耦合和通信代码重复。 -2. **工作流内扩展 vs 前置下发扩展** — Task-T 和 Negotiation-T 是 `sendMessage` 链的一部分; - Authorization-T 是工作流启动前的一次性请求,Notification-T 是工作流启动前建立的长连接订阅。 +2. **工作流内扩展 vs 独立生命周期扩展** — Task-T 和 Negotiation-T 是 `sendMessage` 链的一部分; + Authorization-T 是工作台独立触发的一次性请求,Notification-T 是工作台级长连接订阅。 注册表只自动注册工作流内的一对。 3. **自动协商循环** — 引擎拥有重发循环,宿主只提供澄清文本(`onNegotiation`), diff --git a/docs/zh/DEVELOPER_GUIDE.md b/docs/zh/DEVELOPER_GUIDE.md index 8764521c..05614de5 100644 --- a/docs/zh/DEVELOPER_GUIDE.md +++ b/docs/zh/DEVELOPER_GUIDE.md @@ -54,7 +54,7 @@ public class MyControlPoint implements ControlPoint { } ``` -`onNegotiation` 有默认实现,返回通用澄清文本。授权和通知是预置操作,通过 `ExtensionSender` 在工作流启动前发送,不在 `ControlPoint` 上。 +`onNegotiation` 有默认实现,返回通用澄清文本。授权和通知是独立协议操作,由工作台在业务时机通过各自专用的 `ExtensionSender`/transport 发起;它们不在 `ControlPoint` 上,也不是工作流 DAG 节点。 ## 4. 通过 Builder 执行(推荐) @@ -245,11 +245,12 @@ A2AT_LANGUAGE=zh-CN A2AT_CRED_KEY=<32字节hex> ``` -`a2atEnvPath` 为 null 时,Task-T 提示词生成跳过。 +`a2atEnvPath` 为 null 时,非结构化 `sendMessage` 只发送普通 A2A 文本,不宣称为 +Task-T;结构化 Task-T、Authorization-T、Notification-T 和 Negotiation-T 都会显式失败。 -**测试中禁用 LLM 层**:设置系统属性 `a2at.llm.disabled=true`,示例 agent 将完全跳过 -A2ATClient/A2ATServer 初始化 — 协商降级为默认文本,不发起任何 LLM 调用。示例测试套件 -用它在无外部依赖环境下运行。 +**离线测试**:测试资源中的 `.env` 通过 SDK SPI 指定 `OfflineA2ATLlmClient`,使真实 +A2ATClient/A2ATServer 生成、校验和参数填充管线可在无外网时可重复运行。不存在 +“禁用 SDK 后发送默认协商文本”的生产降级路径。 ## 10. 集成模式 diff --git a/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md b/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md index a1e90af8..0346232b 100644 --- a/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md +++ b/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md @@ -1,599 +1,83 @@ -# 执行引擎适配 A2A-T SDK 协商内容层改造方案 +# A2A-T SDK Negotiation-T 适配说明 -> **状态(2026-08-23):改造一 ~ 六已全部落地。** 引擎侧入口见 `A2ATContentFacade`(SDK 内容层门面)、 -> `WorkflowEngineClient.getPrompts/getNegotiationPrompts/getPrompt`(模板查询)、 -> `WorkflowEngineClientConfig.negotiationParamSchema`(业务 schema 注入)。本文档以下内容保留为 -> 设计依据;文中 API 签名是改造前的 SDK 形态,**当前签名以 -> `docs/zh/API_REFERENCE.md` 为准**(后续 SDK 已将 `NegotiationContext` 移至 -> `core.model`、validate API 增加显式 context 参数、协商上下文改由 metadata `negotiationContext` -> key 携带)。 +> 状态:已按 2026-08-25 锁定的 SDK 版本完成。本文只描述当前实现,不保留旧版迁移方案。 -> 本文档基于对 a2a-t-sdk-java(v1.0.0)和 workflow-exec-engine-java(v1.0.0)最新代码的逐文件分析,给出执行引擎接入 SDK 协商内容生成与校验能力的详细改造方案。 +## 版本基线 ---- +- 仓库:`Zhoujie628/a2a-t-sdk-java` +- commit:`0ef79d37f49a9b7a2dbe16b6d9fd1ccdb6d9538d` +- Maven 版本:`1.0.0-0ef79d3` +- 引擎入口:`A2ATContentFacade`、`NegotiationTHandler`、`DefaultWorkflowEngineClient` +- Agent 入口:`NegotiationBaseAgentExecutor` -## 一、现状:执行引擎当前用了 SDK 的什么 +该 SDK 尚未发布到 Maven Central。引擎 POM 和 CI 同时锁定 commit 与由该提交派生的 +不可变版本,避免本地旧 `1.0.0` jar 被静默解析。安装与升级方法见 +[A2A-T SDK 依赖说明](A2AT-SDK-DEPENDENCY.md)。 -执行引擎目前只用了 SDK 的 **运行时状态机**(startNegotiation / receiveNegotiation / continueNegotiation 三个方法),完全没有用 **内容生成引擎**(generateNegotiationProposePromptFromData/fromText 等 6 个方法)和 **校验参数提取**(validateAndFillingProposeData/AcceptData/RejectData)这两条流水线。 +## 当前 API 边界 -### 1.1 当前对接链路 +最新 SDK 的 Negotiation-T 是无状态内容 API。引擎使用: -**Client 侧(引擎发起方)** +- `generateNegotiationProposePromptFromData/FromText` +- `generateNegotiationAcceptPromptFromData/FromText` +- `generateNegotiationRejectPromptFromData/FromText` +- `generateNegotiationAbortPromptFromData` +- `validateAndFillingProposeData/AcceptData/RejectData` +- `getNegotiationPrompts/getNegotiationPrompt` -``` -DefaultWorkflowEngineClient.sendMessage() - -> transport.send() # A2A 消息发到 agent - -> NegotiationTHandler.afterReceive() # agent 返回 INPUT_REQUIRED 时触发 - -> a2atClient.receiveNegotiation() # SDK 状态机:解析 context + echo 处理 - -> autoNegotiate() # 自动循环 - -> controlPoint.onNegotiation() # 生成澄清文本(纯业务逻辑,非 SDK) - -> a2atClient.continueNegotiation() # SDK 状态机:推进到 AGREED - -> buildFallbackMeta(clarification) # 手动组装 metadata(非 SDK 模板) - -> transport.send() # 发 follow-up 给 agent -``` - -**Server 侧(agent 执行器)** - -``` -NegotiationBaseAgentExecutor.handleNewTask() - -> client.startNegotiation(TARGET, text, facts) # SDK 状态机:发起协商 - -> 从 payload 手动提取 context + text # 非 SDK 封装 - -> negotiationResponseMetadata() # 手动组装 metadata - -> emit INPUT_REQUIRED -``` - -### 1.2 当前协商消息的内容来源 - -协商消息的文本内容不是 SDK 生成的,是业务代码硬编码或 LLM 直接输出的。具体在三个地方: - -1. **发起侧**(NegotiationBaseAgentExecutor):defaultNegotiationText() 返回固定字符串如 "Please confirm the task parameters so I can proceed.",直接塞进 startNegotiation 的 contentText 参数。SDK 的 startNegotiation 只做状态机初始化,不做模板渲染。 - -2. **澄清侧**(DefaultControlPoint.onNegotiation / NegotiationStrategy.resolve):返回纯文本 clarification,不走 SDK 的内容生成。autoNegotiate 把它包进 [NEGOTIATION_RESOLUTION] 格式的字符串,这个格式是引擎自己定义的(NegotiationUtils.buildResolutionMessage),不是 SDK 的协商模板格式。 - -3. **fallback metadata**(buildFallbackMeta):当 continueNegotiation 失败时,用 A2ATExtension.NEGOTIATION_T.uri() 做 key,直接拼字符串。 - -### 1.3 依赖声明 - -引擎在 pom.xml 中声明: - -```xml - - 1.0.0 - - - - net.openan.a2a-t.sdk - a2a-t-client - ${a2a.t.sdk.version} - -``` - -a2a-t-client 通过传递依赖引入了 a2a-t-negotiation 模块,内容生成引擎和校验流水线已可用,无需额外加依赖。 - ---- - -## 二、差距:SDK 已有但引擎未用的能力 - -SDK 的 NegotiationContentService(通过 A2ATClient 暴露)提供了完整的协商内容生成和校验能力,引擎完全没有调用。 - -### 2.1 内容生成引擎(未接入) - -SDK 有 6 个生成方法,覆盖 3 种协商类型 x 2 种输入方式 x 3 个阶段: - -| 方法 | 作用 | LLM 依赖 | 引擎是否调用 | -|---|---|---|---| -| generateNegotiationProposePromptFromData | 从类型化数据生成提议消息 | 无 | 否 | -| generateNegotiationProposePromptFromText | 从自由文本生成提议消息 | 有(一步抽取) | 否 | -| generateNegotiationAcceptPromptFromData | 从类型化数据生成接受消息 | 无 | 否 | -| generateNegotiationAcceptPromptFromText | 从自由文本生成接受消息 | 有 | 否 | -| generateNegotiationRejectPromptFromData | 从类型化数据生成拒绝消息 | 无 | 否 | -| generateNegotiationRejectPromptFromText | 从自由文本生成拒绝消息 | 有 | 否 | - -这些方法返回 MetadataContent(templateUri, promptText, extensionUri),调用 buildMetadataContent() 直接生成可放入 A2A 消息的 metadata map,引擎不需要手动组装。 - -### 2.2 类型化内容模型(未使用) - -SDK 定义了完整的类型化内容 record,引擎可以用这些结构化数据驱动协商,而不是用裸字符串: - -**Propose 阶段内容** - -| 类型 | 结构 | 适用场景 | -|---|---|---| -| InfoProposeContent | items: List, relationship: String? | 信息协商:缺失信息项列表 | -| TargetProposeContent | targetNegotiationDescription, intentUnderstanding?, alignmentAndClarification?, requestForClarification? | 目标协商:目标描述 + 意图理解 + 对齐澄清 + 待澄清 | -| FeasibilityProposeContent | feasibilityNegotiationDescription, action, contentsToEvaluate?, infeasibilityDetailsAndProposal? | 可行性协商:描述 + 动作 + 评估内容 / 不可行详情 | - -**Ending 阶段内容** - -| 类型 | 结构 | 适用场景 | -|---|---|---| -| InfoEndingContent | conclusion, items | 信息协商终态 | -| TargetEndingContent | conclusion, confirmedIntent, failureReason | 目标协商终态(accept 带 confirmedIntent,reject 带 failureReason) | -| FeasibilityEndingContent | conclusion, feasibilitySummary | 可行性协商终态 | - -FeasibilityProposeContent 有一个 NegotiationAction 字段,两种取值: -- REQUEST_FEASIBILITY_EVALUATION —— 请求对端评估可行性,必须带 contentsToEvaluate -- PROPOSE_ALTERNATIVE_ON_FAILURE —— 报告不可行并提议替代方案,必须带 infeasibilityDetailsAndProposal - -当前引擎用的是 NegotiationType.TARGET,但 TargetNegotiation handler 是空壳 echo,协商消息内容是 defaultNegotiationText() 硬编码的。 - -### 2.3 校验与参数提取(未接入) - -SDK 的 validateAndFillingProposeData/AcceptData/RejectData 可以校验已渲染的协商消息文本并提取参数。管线分为三步: - -1. **规则门**(确定性,无 LLM):按 ## 分段,识别是否含协商上下文段,校验 id 为 UUID 格式、round/maxRounds 为正整数、round 不超 maxRounds -2. **LLM 语义校验**(可重试):一步 LLM 结构化调用,同时产出 verdict(通过/拒绝)、implied type、语义错误列表、按 callerSchema 提取的参数 -3. **参数合并**(确定性):先写 context 参数(id、round、maxRounds),再写 LLM 提取参数,冲突时 context 参数优先 - -引擎在 NegotiationTHandler.afterReceive 里接收协商消息时没有做内容校验,直接交给 receiveNegotiation echo 处理。 - -### 2.4 模板查询(未接入) - -SDK 的 getNegotiationPrompts() / getNegotiationPrompt(uri) 可以列出和查询可用的协商模板。引擎没有用这个能力,模板选择是隐式的(硬编码在 startNegotiation 调用里)。 - -### 2.5 Vocabulary 多语言(未使用) - -SDK 的 Vocabulary 支持 zh-CN 和 en-US,协商消息的 section 标题和 slot 名都从 vocabulary 取。引擎的 .env 配了 A2AT_LANGUAGE=zh-CN,但协商消息内容没有走模板渲染,所以这个语言设置实际只作用于 Task-T 侧(场景识别和 slot 抽取),没作用于协商消息。 - ---- - -## 三、改造方案 - -改造分三个层次,按依赖关系排序。 - -### 改造一:协商消息发起层 —— 用内容生成替代硬编码 - -> ✅ 已落地:`NegotiationBaseAgentExecutor.renderProposeText` + 子类扩展点 `negotiationType()` / `buildProposeContent()` / `proposeTemplateUri()`。 - - -**目标**:server 侧发起协商时,用 SDK 的模板渲染生成结构化协商消息,而不是 defaultNegotiationText() 硬编码。 - -**当前代码路径** - -- samples/.../NegotiationBaseAgentExecutor.java 的 startNegotiation 方法 -- 调用 client.startNegotiation(NegotiationType.TARGET, defaultNegotiationText(), facts) -- defaultNegotiationText() 返回固定字符串 - -**改造方式** - -把 startNegotiation 拆成两步 —— 先用内容生成引擎渲染消息,再用状态机初始化协商上下文: - -```java -// 步骤 1:用 SDK 内容生成引擎渲染结构化协商消息 -NegotiationContext contentCtx = new NegotiationContext( - UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS); -TargetProposeContent content = new TargetProposeContent( - targetDescription, // targetNegotiationDescription(必填) - intentUnderstandingItems, // intentUnderstanding(首轮有,后续 null) - null, // alignmentAndClarification(后续轮有,首轮 null) - requestForClarificationItems // requestForClarification(待澄清项) -); -NegotiationProposeData data = new NegotiationProposeData(contentCtx, content); -MetadataContent mc = client.generateNegotiationProposePromptFromData( - data, "Negotiation-T/v1/target-negotiation/propose"); - -// 步骤 2:用渲染好的消息文本初始化协商状态机 -Map facts = new LinkedHashMap<>(); -facts.put("agent", getClass().getSimpleName()); -facts.put("input", input); -Map neg = client.startNegotiation( - NegotiationType.TARGET, mc.promptText(), facts); - -// 步骤 3:把生成的 metadata 和状态机 payload 合并 -Map metadata = mc.buildMetadataContent(); -metadata.putAll(neg); // 协商 context 附在消息上 -``` - -这样 agent 返回的 INPUT_REQUIRED 消息就是 SDK 模板渲染的结构化文本(带 ## Negotiation Context、## Target Negotiation 等 section),而不是 "Please confirm the task parameters" 这种裸字符串。 - -**改造范围** - -NegotiationBaseAgentExecutor 需要新增一个抽象方法让子类提供 TargetProposeContent(或 InfoProposeContent / FeasibilityProposeContent),替代 defaultNegotiationText(): - -```java -// 新增抽象方法 -protected abstract NegotiationProposeContent buildProposeContent(String input); - -// 保留旧方法做降级 -protected String defaultNegotiationText() { ... } -``` - -**fromText 变体** - -如果子类不想构造类型化数据,可以用 generateNegotiationProposePromptFromText 走 LLM 抽取: - -```java -MetadataContent mc = client.generateNegotiationProposePromptFromText( - naturalLanguageDescription, contentCtx, - "Negotiation-T/v1/target-negotiation/propose"); -``` - -这会多一步 LLM 调用(结构化抽取),但调用方只需提供自由文本。 - -### 改造二:协商自动循环层 —— 用内容生成替代手动组装 - -> ✅ 已落地:`DefaultWorkflowEngineClient.buildNegotiationFollowUpMeta`(含 Reject/Abort 终态分类与 SDK 状态机推进)。 - - -**目标**:client 侧 autoNegotiate 生成 follow-up 消息时,用 SDK 的内容生成替代 buildFallbackMeta 手动拼字符串。 - -**当前代码路径** - -- workflow-engine/.../DefaultWorkflowEngineClient.java 的 autoNegotiate 和 buildNegotiationFollowUpMeta 方法 -- buildNegotiationFollowUpMeta 尝试调 continueNegotiation,失败时用 buildFallbackMeta 拼字符串 - -**改造方式** - -在 autoNegotiate 的 clarification 生成后,用 SDK 渲染 follow-up 消息: - -```java -A2ATClient a2atClient = transport.getA2atClient(); -if (a2atClient != null) { - // 用 SDK 生成 propose 阶段的 follow-up 消息 - NegotiationContext contentCtx = new NegotiationContext( - negotiationId, round, maxRounds); - // clarification 作为 TargetProposeContent 的 alignmentAndClarification - TargetProposeContent content = new TargetProposeContent( - "Clarification for " + agentName, - null, // intentUnderstanding(非首轮) - List.of(new NegotiationItem("clarification", clarification)), // alignment - null // requestForClarification - ); - NegotiationProposeData data = new NegotiationProposeData(contentCtx, content); - MetadataContent mc = a2atClient.generateNegotiationProposePromptFromData( - data, "Negotiation-T/v1/target-negotiation/propose"); - - // SDK continueNegotiation 推进状态机 - Map payload = a2atClient.continueNegotiation( - context, NegotiationStatus.AGREED, mc.promptText()); - - // metadata 直接用 SDK 生成 - followUpMeta = new HashMap<>(mc.buildMetadataContent()); - followUpMeta.putAll(payload); -} else { - // 无 SDK 时降级到旧的 fallback 逻辑 - followUpMeta = buildFallbackMeta(clarification); -} -``` - -这样 follow-up 消息也是模板渲染的结构化文本,可以逐步替代 [NEGOTIATION_RESOLUTION] 这种自定义标记格式。 - -**协商策略接口改造** - -NegotiationStrategy.resolve 当前返回 CompletableFuture(纯文本 clarification)。改造后可以增加一个变体返回类型化内容: - -```java -interface NegotiationContentStrategy { - CompletableFuture resolveContent( - String agentName, String negotiationText, Map receiveResult); -} -``` - -DefaultControlPoint 持有这个策略,在 onNegotiation 里调用它获取类型化内容,交给 SDK 渲染。不愿意构造类型化数据的用户仍用旧的 resolve 返回纯文本,走 fromText 变体。 - -### 改造三:协商消息接收校验层 —— 用 validateAndFilling 替代裸 receive - -> ✅ 已落地:`NegotiationTHandler.afterReceive` 按类型路由 validate 模板;业务 schema 经 `negotiationParamSchema` 配置注入(引擎核心不再硬编码业务字段)。 +旧协商运行时状态机接口已从 SDK 删除,引擎不兼容、不反射调用、不降级回该类接口。 +## 协商上下文与 metadata -**目标**:NegotiationTHandler.afterReceive 接收协商消息后,用 SDK 校验消息格式并提取参数,而不是只做 echo。 +每次协商由引擎持有 `NegotiationContext`: -**当前代码路径** - -- workflow-engine/.../NegotiationTHandler.java 的 afterReceive -- 调用 a2atClient.receiveNegotiation(result.getText(), contextMap) -- SDK 的 receiveNegotiation 只做状态机轮次校验 + echo(TargetNegotiation handler 是空壳) - -**改造方式** - -在 receiveNegotiation 之后追加一步 validateAndFillingProposeData: - -```java -Map receiveResult = - a2atClient.receiveNegotiation(result.getText(), contextMap); - -if (Boolean.TRUE.equals(receiveResult.get("needResponse"))) { - try { - FilledParamData paramData = a2atClient.validateAndFillingProposeData( - result.getText(), - callerSchema, // 调用方提供的参数 JSON schema - "Negotiation-T/v1/target-negotiation/propose" - ); - // 提取的参数可用于后续路由决策 - metadata.put("negotiation_params", paramData.data()); - } catch (NegotiationParamExtractionException e) { - // 校验失败:消息不是合法的协商消息,或语义校验拒绝 - log.warn("[Negotiation-T] validation failed: code={} msg={}", - e.getCode(), e.getMessage()); - } +```json +{ + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5 } ``` -这需要 NegotiationTHandler 能拿到 callerSchema(参数 schema)。可以通过 ExtensionHandler 接口扩展一个 schema provider,或者从 AgentCard 的扩展声明里读取。 - -**无 LLM 降级** - -validateAndFillingProposeData 的管线是:规则门(确定性,无 LLM)-> LLM 语义校验 -> 参数合并。其中 LLM 语义校验需要配置 LLM client。如果 .env 里没配 LLM,语义校验会失败并返回 negotiation_llm_infrastructure_error。规则门部分(识别协商上下文段、校验 id/round/maxRounds)不需要 LLM,可以独立工作。 - -### 改造四:协商终态消息 —— 接入 accept/reject 生成 - -> ✅ 已落地:Accept/Reject/Abort 三终态 + 轮次耗尽 abort 流程。 - - -**目标**:协商结束时(AGREED 或 REJECTED),用 SDK 生成终态消息,而不是靠 continueNegotiation 的 echo。 - -**当前代码路径** - -- DefaultWorkflowEngineClient.autoNegotiate 调用 continueNegotiation(context, NegotiationStatus.AGREED, clarification) -- SDK 的 InformationNegotiation.renderContinue 在 AGREED 时产出 finalTaskPrompt,但 TargetNegotiation 没有这个逻辑 - -**改造方式** - -在协商达成一致或拒绝时,用 SDK 生成终态消息: - -```java -// 协商达成一致 -NegotiationEndingData acceptData = new NegotiationEndingData( - contentCtx, - new TargetEndingContent(NegotiationConclusion.ACCEPT, confirmedIntent, null) -); -MetadataContent acceptMc = a2atClient.generateNegotiationAcceptPromptFromData( - acceptData, "Negotiation-T/v1/target-negotiation/accept-reject"); - -// 协商拒绝 -NegotiationEndingData rejectData = new NegotiationEndingData( - contentCtx, - new TargetEndingContent(NegotiationConclusion.REJECT, null, failureReason) -); -MetadataContent rejectMc = a2atClient.generateNegotiationRejectPromptFromData( - rejectData, "Negotiation-T/v1/target-negotiation/accept-reject"); -``` - -### 改造五:协商类型选择 —— 从单一 TARGET 扩展到三种 - -> ✅ 已落地:三种类型全部支持(发送侧子类声明、接收侧按 `negotiationType` 路由模板)。 - - -**目标**:根据业务场景选择合适的协商类型,而不是固定用 TARGET。 - -**当前代码** - -NegotiationBaseAgentExecutor.startNegotiation 硬编码 NegotiationType.TARGET。 - -**改造方式** - -新增抽象方法让子类声明协商类型和对应的 content 类型: - -```java -protected abstract NegotiationType negotiationType(); -protected abstract NegotiationProposeContent buildProposeContent(String input); -``` - -各场景的映射建议: - -| 业务场景 | 协商类型 | content 类型 | 适用时机 | -|---|---|---|---| -| 参数不足,需补充信息 | INFORMATION | InfoProposeContent | agent 缺少执行所需字段 | -| 意图理解偏差,需对齐 | TARGET | TargetProposeContent | agent 和 engine 对任务目标理解不一致 | -| 无法执行,需评估可行性 | FEASIBILITY | FeasibilityProposeContent | agent 不确定能否完成请求 | - -### 改造六:模板查询与动态选择 - -> ✅ 已落地:`WorkflowEngineClient.getPrompts/getNegotiationPrompts/getPrompt`。 - - -**目标**:运行时查询可用模板,动态选择 templateUri,而不是硬编码。 - -```java -// 列出所有可用协商模板 -List templates = a2atClient.getNegotiationPrompts(); -// 查询特定模板 -Optional template = a2atClient.getNegotiationPrompt( - "Negotiation-T/v1/target-negotiation/propose"); -``` - -这可以在 NegotiationBaseAgentExecutor 初始化时调用,把可用模板列表暴露给子类做选择。 - ---- - -## 四、改造优先级 - -| 优先级 | 改造 | 价值 | 风险 | 改动模块 | -|---|---|---|---|---| -| P0 | 改造一:发起层用内容生成 | 协商消息从裸字符串变为结构化模板文本,是后续所有改造的基础 | 低,只改 sample 侧 | samples | -| P0 | 改造二:自动循环用内容生成 | follow-up 消息标准化,逐步消除 [NEGOTIATION_RESOLUTION] 自定义格式 | 中,改 DefaultWorkflowEngineClient 核心链路 | workflow-engine | -| P1 | 改造三:接收校验 | 接收侧能识别非法协商消息、提取参数做路由 | 中,依赖 LLM 配置,需处理无 LLM 降级 | workflow-engine | -| P1 | 改造五:协商类型扩展 | 覆盖三种协商场景 | 低,渐进式扩展 | samples + workflow-engine | -| P2 | 改造四:终态消息 | accept/reject 消息标准化 | 低 | workflow-engine | -| P2 | 改造六:模板查询 | 动态选择模板 | 低 | samples | - -建议实施顺序:先改 sample 验证(改造一),再改核心链路(改造二),然后渐进扩展(改造三/五),最后收尾(改造四/六)。 - ---- - -## 五、关键约束和注意事项 - -### 5.1 SDK 版本 - -引擎 pom.xml 声明 a2a.t.sdk.version=1.0.0,SDK 的内容生成 API 在这个版本已完整可用。引擎只需确保 a2a-t-client 依赖引入了 a2a-t-negotiation 模块(传递依赖已覆盖)。 - -### 5.2 LLM 依赖 - -- fromData 变体不需要 LLM(确定性渲染) -- fromText 变体需要 LLM(结构化抽取一步) -- validateAndFilling 的语义校验步骤需要 LLM,规则门步骤不需要 - -改造时应在无 LLM 环境下做降级处理(a2at.llm.disabled 系统属性已在 sample 里使用)。无 LLM 时走旧的 echo/fallback 路径即可。 - -### 5.3 两套 NegotiationContext - -SDK 有两个 NegotiationContext: - -| 类 | 用途 | 字段 | -|---|---|---| -| net.openan.a2at.sdk.negotiation.content.NegotiationContext | 内容生成/校验 | id, round, maxRounds | -| net.openan.a2at.sdk.negotiation.types.model.NegotiationContext | 状态机 | negotiationType, negotiationId, round, status | - -改造时要注意传入正确的那一个:内容生成方法用 content 层的,状态机方法用 types.model 层的。两者不能混用。 - -### 5.4 Vocabulary 语言 - -SDK 的 Vocabulary.forLanguage 只支持 zh-CN 和 en-US,不支持的 language 会抛 NegotiationContentException。引擎的 .env 已配 A2AT_LANGUAGE=zh-CN,但内容生成引擎用的是 A2ATConfig.prompt.language,不是 A2AT_LANGUAGE 直接传给 vocabulary。需要确认 config 传递链路正确。 - -### 5.5 错误码处理 - -内容生成和校验的异常体系: - -| 异常类型 | 性质 | 处理方式 | -|---|---|---| -| NegotiationContentException | 编程错误(null context、畸形 URI、类型不匹配) | 不该 catch 后忽略,应修复调用代码 | -| NegotiationGenerationException | 处理失败,带 error code | try-catch 降级到旧路径 | -| NegotiationParamExtractionException | 校验失败,带 error code | try-catch 降级到旧路径 | - -error code 体系: - -| 码 | 含义 | 可重试 | -|---|---|---| -| template_not_found | 模板/prompt 资源缺失 | 否 | -| negotiation_content_extract_failed | LLM 内容抽取响应不可解析 | 是 | -| negotiation_llm_infrastructure_error | LLM 调用基础设施失败 | 是 | -| negotiation_slot_missing | 必填字段缺失 | 否 | -| negotiation_invalid_input | 输入与 phase/action 矛盾 | 否 | -| negotiation_rule_violation | 规则门校验失败 | 否 | -| negotiation_semantic_rejected | 语义校验拒绝 | 否 | - -LLM 步骤默认重试 3 次(maxAttempts 来自 LLM config)。 - -### 5.6 状态机和内容层是解耦的 - -SDK 的 startNegotiation/receiveNegotiation/continueNegotiation 只管状态机轮次,generateNegotiationProposePromptFromData 只管消息渲染。两者通过以下方式关联: - -1. 内容生成方法的 NegotiationContext(content 层,含 id/round/maxRounds)与状态机方法的 context 是独立的 -2. 内容生成方法返回的 promptText 作为状态机方法的 contentText 参数传入 -3. 改造时先调内容生成拿到 promptText,再把 promptText 传给状态机方法做 contentText 参数 - -### 5.7 改造影响面 - -| 改造 | 改的类 | 模块 | 是否影响核心链路 | -|---|---|---|---| -| 改造一 | NegotiationBaseAgentExecutor | samples | 否 | -| 改造二 | DefaultWorkflowEngineClient | workflow-engine | 是 | -| 改造三 | NegotiationTHandler(包级私有,需改 public 或扩展接口) | workflow-engine | 是 | -| 改造四 | DefaultWorkflowEngineClient | workflow-engine | 是 | -| 改造五 | NegotiationBaseAgentExecutor + DefaultControlPoint | samples + workflow-engine | 部分 | -| 改造六 | NegotiationBaseAgentExecutor | samples | 否 | - -建议先改 sample 验证(改造一、六),再改核心链路(改造二、三),然后渐进扩展(改造四、五)。 - ---- - -## 六、SDK 协商 API 速查 - -### 6.1 运行时状态机(引擎当前已用) - -```java -// 发起协商 -Map startNegotiation(NegotiationType type, String contentText, Map facts); - -// 接收对端协商消息 -Map receiveNegotiation(String message, Map context); - -// 本地推进协商 -Map continueNegotiation( - NegotiationContext context, NegotiationStatus status, String contentText); -``` - -### 6.2 内容生成(改造后接入) - -```java -// 从类型化数据生成(无 LLM) -MetadataContent generateNegotiationProposePromptFromData(NegotiationProposeData data, String templateUri); -MetadataContent generateNegotiationAcceptPromptFromData(NegotiationEndingData data, String templateUri); -MetadataContent generateNegotiationRejectPromptFromData(NegotiationEndingData data, String templateUri); - -// 从自由文本生成(含 LLM 抽取) -MetadataContent generateNegotiationProposePromptFromText(String text, NegotiationContext context, String templateUri); -MetadataContent generateNegotiationAcceptPromptFromText(String text, NegotiationContext context, String templateUri); -MetadataContent generateNegotiationRejectPromptFromText(String text, NegotiationContext context, String templateUri); -``` - -### 6.3 校验与参数提取(改造后接入) - -```java -FilledParamData validateAndFillingProposeData(String prompt, Map schema, String templateUri); -FilledParamData validateAndFillingAcceptData(String prompt, Map schema, String templateUri); -FilledParamData validateAndFillingRejectData(String prompt, Map schema, String templateUri); -``` - -### 6.4 模板查询(改造后接入) - -```java -List getNegotiationPrompts(); -Optional getNegotiationPrompt(String uri); -List getPrompts(); -Optional getPrompt(String uri); -``` - -### 6.5 模板 URI 格式 - -``` -Negotiation-T/v1/{type-segment}/{phase-segment} -``` - -| 场景 | URI | -|---|---| -| 信息协商提议 | Negotiation-T/v1/information-negotiation/propose | -| 信息协商接受/拒绝 | Negotiation-T/v1/information-negotiation/accept-reject | -| 目标协商提议 | Negotiation-T/v1/target-negotiation/propose | -| 目标协商接受/拒绝 | Negotiation-T/v1/target-negotiation/accept-reject | -| 可行性协商提议 | Negotiation-T/v1/feasibility-negotiation/propose | -| 可行性协商接受/拒绝 | Negotiation-T/v1/feasibility-negotiation/accept-reject | +线上 metadata 必须同时包含: -accept 和 reject 共享 accept-reject 模板,由 NegotiationConclusion 值(Accept/Reject)区分。 +- 规范 Negotiation-T URI: + `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1` +- SDK 生成的 `templateUri` +- 规范 `negotiationContext` -### 6.6 扩展 URI 常量 +上下文不再嵌入 prompt 正文。接收端会检查 UUID、轮次与最大轮次,检查 +`templateUri` 是否属于对应 propose/accept/reject 阶段,再调用 SDK 校验与参数填充。 -| 扩展 | URI | -|---|---| -| Negotiation-T(规范) | https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 | -| Negotiation-T(NL legacy) | https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/NL/v1 | +## 内容生成与 LLM -SDK 用规范 URI 发送新消息,接收侧同时识别 NL 别名以兼容旧消息。 +- Negotiation-T 类型化 `fromData` 直接接收 SDK record,是确定性模板渲染,不调用 LLM。 +- Negotiation-T `fromText` 需要 SDK 配置的 LLM 进行结构化抽取。 +- `validateAndFilling*` 包含确定性规则门、LLM 语义校验/参数抽取和确定性合并。 +- Task-T、Authorization-T、Notification-T 的 schema-aware `fromData` 会跳过场景识别, + 但 slot 映射可能使用 LLM;不应宣称为“全程无 LLM”。 ---- +## 端到端流程 -## 七、参考文件索引 +1. OMC 对 Task-T 进行 SDK 校验与填充。仅缺失或业务语义错误进入 Negotiation-T; + LLM/配置/网络等基础设施错误不得伪装成业务协商。 +2. OMC 用 typed propose data 渲染 prompt,在 A2A `INPUT_REQUIRED` 事件中返回 SDK metadata。 +3. `NegotiationTHandler` 校验 URI、模板、上下文和 prompt,将已填充参数交给集成方的 + `ControlPoint.onNegotiation` 作业务决策。 +4. 引擎把决策映射为 typed accept/reject/abort,推进 `round`,通过同一 A2A task/context + 发送 follow-up。 +5. 轮次耗尽、明确拒绝或校验失败时终止,不使用手工 prompt 或原文 metadata 降级。 -### SDK 侧(a2a-t-sdk-java) +## 失败策略 -| 文件 | 作用 | -|---|---| -| a2a-t-client/.../A2ATClient.java | 对外 facade,暴露全部协商 API | -| a2a-t-negotiation/.../content/NegotiationContentService.java | 内容层共享服务,代理生成编排器 | -| a2a-t-negotiation/.../generation/NegotiationGenerationOrchestrator.java | 内容生成引擎核心 | -| a2a-t-negotiation/.../generation/NegotiationGeneratorRegistry.java | 6 个生成器的 dispatch 表 | -| a2a-t-negotiation/.../generation/DefaultNegotiationContentExtractor.java | LLM 内容抽取(fromText 用) | -| a2a-t-negotiation/.../validation/ParamExtractor.java | 校验+参数提取编排 | -| a2a-t-negotiation/.../validation/DefaultNegotiationComplianceChecker.java | 规则门(确定性) | -| a2a-t-negotiation/.../runtime/NegotiationHandler.java | 状态机 facade | -| a2a-t-negotiation/.../runtime/NegotiationRuntime.java | 状态机核心 | -| a2a-t-negotiation/.../runtime/RoleBoundNegotiationOrchestrator.java | 角色绑定 orchestrator | -| a2a-t-negotiation/.../runtime/helper/NegotiationPayloadMapper.java | payload 序列化/反序列化 | -| a2a-t-negotiation/.../handler/InformationNegotiation.java | information 运行时 handler(唯一有实质逻辑) | -| a2a-t-negotiation/.../content/Vocabulary.java | 多语言词汇表 | +协议内容统一 fail-closed。如果 SDK 客户端未配置、渲染失败、模板 URI 不匹配、 +`negotiationContext` 非法或语义校验拒绝,引擎会显式失败,不把普通文本冒充为 +Negotiation-T 内容。 -### 引擎侧(workflow-exec-engine-java) +## 验证基线 -| 文件 | 作用 | -|---|---| -| workflow-engine/.../client/A2ATransport.java | 传输层,持有 A2ATClient | -| workflow-engine/.../client/DefaultWorkflowEngineClient.java | 工作流执行层,协商自动循环 | -| workflow-engine/.../client/NegotiationTHandler.java | 协商扩展 handler(接收侧) | -| workflow-engine/.../client/ExtensionHandler.java | 扩展 handler 接口 | -| workflow-engine/.../client/ExtensionRegistry.java | 扩展 handler 注册表 | -| workflow-engine/.../control/DefaultControlPoint.java | 默认控制点,协商策略委托 | -| workflow-engine/.../control/NegotiationStrategy.java | 协商策略接口 | -| workflow-engine/.../client/WorkflowEngineClientConfig.java | 引擎配置(含 a2atEnvPath) | -| samples/.../agents/NegotiationBaseAgentExecutor.java | server 侧协商基类 | -| samples/.../negotiation/NegotiationUtils.java | 协商常量和工具方法 | -| samples/.../negotiation/NegotiationStrategy.java | 协商策略示例实现 | +- `A2ATContentFacadeTest`:真实 SDK typed propose/accept/reject/abort 渲染和 metadata 形状。 +- `SpnCrossCityE2ETest`:真实 SDK Task-T/Negotiation-T/Authorization-T/Notification-T, + 两地市并行诊断、单次汇总、白名单抢通和通知关闭。 +- `EmbeddedA2AServerTest`:HTTPS + SSE 真实网络路径。 +- SDK 不可用、模板错误、校验拒绝和通知失败 ACK 的反向用例。 diff --git a/docs/zh/engine-code-review-response.md b/docs/zh/engine-code-review-response.md new file mode 100644 index 00000000..d350d05b --- /dev/null +++ b/docs/zh/engine-code-review-response.md @@ -0,0 +1,400 @@ +# 引擎代码检视——二次审核回应与最终共识 + +> **归档状态(2026-08-25)**:本文只记录旧 commit 上的评审共识,不是当前代码或协议说明。 +> 当前状态以 [CHANGELOG](../../CHANGELOG.md)、[API 参考](API_REFERENCE.md) 和 [架构设计](DESIGN.md) 为准。 + +> **回应对象**: `docs/zh/engine-code-review-second-check.md` +> **回应日期**: 2026-08-18 +> **回应人**: 原检视方 + +--- + +## 1. 总体回应 + +二次审核质量很高,纠正了原报告 5 处实质性错误: + +1. **P0-4 SSE JSON**:我对 `trim()` 的行为判断错误,接受降级 +2. **P1-1 扩展 prompt**:我对 SDK 能力的描述不准确,接受降级 +3. **P1-2 线程池修复代码**:`LinkedBlockingQueue` 无界导致 `maximumPoolSize` 不生效,接受修正 +4. **P1-4 consumption timeout**:我把 SDK 参数含义解释错了,接受修正 +5. **P1-5 A2AProperties**:把东信配置放入通用 starter 违反模块边界,接受否决 +6. **P1-6 50ms 轮询**:我把正常路径的性能影响当成了主要问题,实际真正问题是静默跳过未标记失败 +7. **P2-3 URI 匹配修复**:取最后路径段得到 `v1` 而非 `Task-T`,接受修正 +8. **P2-5 成功判断修复**:`text 非空 AND COMPLETED` 会拒绝 artifact-only 响应,接受修正 + +同时确认二次审核新发现的 6 个遗漏问题全部成立。 + +以下逐条回应。 + +--- + +## 2. 逐条回应 + +### 2.1 P0-1:全局禁用主机名验证 → 共识 P0 + +**审核纠正**: "仅使用 trust-all SSLContext、不关闭主机名验证即可满足开发环境"的表述不准确——trust-all 只跳过证书链可信性,不自动允许 SAN/CN 不匹配。 + +**回应**: 接受纠正。trust-all `TrustManager` 的 `checkServerTrusted` 不检查证书链,但 JDK `HttpClient` 的主机名验证是独立于 `TrustManager` 的另一层,由 `jdk.internal.httpclient.disableHostnameVerification` 控制。两层验证混淆了。 + +**最终共识**: +1. 保留 P0 +2. 默认构造函数 `DefaultA2AJavaClientRuntime()` 的 `sslVerify=false` 改为 `true` +3. 通用库不得在运行时修改 JVM 全局 TLS 属性 +4. 开发环境如需不安全模式,通过隔离的传输实现提供,打印安全告警 + +--- + +### 2.2 P0-2:AgentCredentialService trust-all → 共识 P0 + +**回应**: 无异议。保留 P0。 + +最终整改方向: +1. 将 TLS/HttpClient 作为显式依赖注入 `AgentCredentialService` +2. Token 登录默认严格校验证书和主机名 +3. 自签证书通过 CA 信任配置解决 + +--- + +### 2.3 P0-3:Notification-T 超时吞没错误 → 共识 P1 高 + +**审核纠正**: 建议从 P0 降为 P1 高优先级,因为只影响 Notification-T 订阅能力,不是发布阻断项。 + +**回应**: 接受降级。Notification-T 不在核心工作流执行路径上,它只影响工作台的恢复通知订阅。P1 高优先级合理。 + +**最终共识**: +1. 降为 P1 高 +2. `exceptionally` 中解包 `CompletionException` 后,仅对 `TimeoutException` 采用"暂未收到 ACK"兼容行为 +3. 其他错误必须异常完成 +4. 5 秒等待值支持配置 + +--- + +### 2.4 P0-4:SSE compact JSON → 共识:降为 P2 + +**审核纠正**: `lines().map(String::trim)` 只删除每行两端的缩进,不会删除 JSON 字符串值内的空白。JSON 字符串中的换行必须转义(`\n`),不能跨物理行。 + +**回应**: 接受。我重新验证了: + +``` +pretty-printed JSON: +{ + "text": " value " +} + +lines() → ["{", " \"text\": \" value \"", "}"] +trim() → ["{", "\"text\": \" value \"", "}"] +concat → {"text": " value "} +``` + +`trim()` 作用于物理行,只去缩进,值内空白在引号内保留。原报告的"破坏值内空白"判断不成立。 + +**最终共识**: +1. 降为 P2(性能/实现简化) +2. `reduce("", String::concat)` 的 O(n²) 拷贝是次要问题 +3. 可优先使用 protobuf printer 的紧凑输出能力 +4. `\r\n` vs `\n` 是平台差异,SSE 规范允许两者 + +--- + +### 2.5 P1-1:三个扩展 prompt 生成器 → 共识:降为低优先级/路线图 + +**审核纠正**: 交叉核验 `a2a-t-sdk-java` 后,`A2ATClient` 只公开 `generateTaskPrompt(Object userInput)`。不存在其他三个 generate 方法。原报告"SDK 能力未利用"不成立。 + +**回应**: 接受。我未核实 SDK 实际 API,臆测了方法存在。当前实现不是"未利用 SDK 能力",而是"SDK 尚未提供对应能力"。 + +**最终共识**: +1. 降为低优先级/路线图项 +2. 可删除三个空桩方法,由分派逻辑直接回退 +3. 或保留方法并添加 TODO + 预期 SDK 契约 +4. 在 SDK 提供对应方法前不定为 P1 + +--- + +### 2.6 P1-2:无界 CachedThreadPool → 共识 P1(修复代码需修正) + +**审核纠正**: 原报告的 `new LinkedBlockingQueue<>()` 无容量上限,任务进入无界队列后线程池通常只保留 corePoolSize 个线程,`maximumPoolSize` 和 `CallerRunsPolicy` 基本不生效。 + +**回应**: 接受。这是 `ThreadPoolExecutor` 的已知行为——`LinkedBlockingQueue` 无界时,`maximumPoolSize` 永远不会触发。我的修复代码是错误的。 + +**最终共识**: +1. 保留 P1 +2. 使用有容量的 `ArrayBlockingQueue` 或 `new LinkedBlockingQueue<>(capacity)` +3. 配置 core、max、queue capacity、keep-alive 和拒绝策略 +4. 将 Executor 作为可注入依赖,明确所有权和关闭责任 +5. 长生命周期 Notification-T 订阅与短请求发送使用不同资源模型 + +--- + +### 2.7 P1-3:每次 send 创建新 Client → 共识 P1 + +**审核补充**: a2a-java `Client` Javadoc 声明实例线程安全,可以并发使用。缓存可行。 + +**回应**: 接受补充。SDK 线程安全确认后,缓存方案可行。原报告的"确认 SDK 是否线程安全"待办项已有答案。 + +**最终共识**: +1. 保留 P1 +2. 按 Agent 接口 URL、协议绑定、TLS 配置缓存 Client/Transport +3. AgentCard 或接口配置变化时更新缓存 +4. `close()` 中统一关闭缓存资源 +5. 增加并发发送、AgentCard 更新和关闭时竞态测试 + +--- + +### 2.8 P1-4:AutoConfiguration 参数硬编码 → 共识 P1(原因解释修正) + +**审核纠正**: `consumption timeout=5s` 不是"SSE 相邻事件间隔超时",而是阻塞调用中等待事件消费和持久化完成的超时。 + +**回应**: 接受。我未核实 SDK 定义,按字面推测了含义。 + +**最终共识**: +1. 保留 P1 +2. 在 `A2AProperties` 中增加服务端 blocking timeout、executor core/max/queue capacity 配置 +3. 保留当前默认值以兼容上游 SDK +4. 不依据错误的 SSE 间隔解释扩大默认值 +5. 增加配置绑定测试 + +--- + +### 2.9 P1-5:A2AProperties 缺少工作台/网关配置 → 共识:原方向否决,治理保留 + +**审核纠正**: 把 `a2a.order.host` 等东信特有配置放入通用 `spring-boot-starter` 的 `A2AProperties` 会造成通用模块依赖具体厂商适配,违背依赖倒置和模块边界。 + +**回应**: 完全接受。我的方案会污染通用 starter。 + +**最终共识**: +1. "配置散落需要治理"保留,但"全部加入 A2AProperties"否决 +2. 通用服务端参数放 `A2AProperties` +3. 通用出站引擎参数放新建 `WorkflowEngineClientProperties` +4. 东信参数放 samples 或独立 `OrderGatewayProperties` +5. 用专用 `@ConfigurationProperties` 替换 sample 中散落的 `@Value` + +--- + +### 2.10 P1-6:50ms sleep 轮询 → 共识:重写为正确性问题 + +**审核纠正**: 正常执行路径会等待 `allOf()` 完成,十层依赖链不会每层额外 50ms。真正的问题是:步骤被 deferred 超过 `workflow.getSteps().size()` 次后静默加入 `executed` 并跳过,但工作流仍可能报告 `success=true`。 + +**回应**: 接受。我重新审视代码: + +```java +int dc = deferCount.getOrDefault(idx, 0) + 1; +if (dc > workflow.getSteps().size()) { + log.warn("Step {} deferred too many times, skipping", step.getName()); + executed.add(idx); // 标记为已执行 + // 但没有设置 failed[0] = true! +} +``` + +跳过的步骤不会产生 `stepOutputs`,依赖它的后续步骤也会被跳过。但 `failed[0]` 没有被设置,最终 `ExecutionResult.success` 仍为 `true`。这是一个正确性 bug。 + +**最终共识**: +1. 重写为正确性问题,保留 P1 +2. pending 非空但没有 ready step 且没有正在执行的 step 时,识别为依赖死锁 +3. 输出未满足的前置步骤列表 +4. 将工作流标记为失败 +5. 工作流加载阶段增加缺失依赖和环检测 +6. 删除 50ms 轮询逻辑 + +--- + +### 2.11 P1-7 / P2-6:sendNotification 默认方法丢回调 → 共识:合并为 P2 + +**回应**: 接受合并。P1-7 和 P2-6 描述的是同一问题。 + +**最终共识**: +1. 合并为一个 P2 +2. 将带 callback 的 `sendNotification` 改为非 default 抽象方法 +3. 不带 callback 的便利方法继续 default 传 null +4. 增加接口契约测试 + +--- + +### 2.12 P2-1:解密失败返回密文 → 共识:提升为 P1 + +**审核纠正**: 建议提升为 P1。 + +**回应**: 接受。带 `enc:` 前缀的数据无法解密时返回密文,会导致后续认证以密文作为密码,远端返回 401 掩盖根因。这是 fail-open 行为,生产环境不应允许。 + +**最终共识**: +1. 提升为 P1 +2. `enc:` 前缀数据无法解密时抛出明确异常 +3. 异常信息包含配置位置和失败类型,不含密文/密钥/明文 +4. 应用启动阶段预校验加密凭证 + +--- + +### 2.13 P2-2:setHttpClient 空操作 → 共识 P2 + +**回应**: 无异议。保留 P2。直接删除方法和误导性日志。 + +--- + +### 2.14 P2-3:contains() 匹配 → 共识 P2(修复方案修正) + +**审核纠正**: 原修复建议取 URI 最后一个路径段,但 `https://.../Task-T/v1` 的最后路径段是 `v1`,不是 `Task-T`。 + +**回应**: 接受。我的修复方案确实会破坏现有匹配。 + +**最终共识**: +1. 保留 P2 +2. 使用 `URI` 解析路径 +3. 对路径段进行精确、大小写不敏感匹配 +4. 明确定义扩展名和版本段规则 +5. 增加 `Task-T-Extended`、非法 URI、query/fragment 测试 + +--- + +### 2.15 P2-4:手动调用 ExtensionInterceptor → 共识 P2 + +**审核补充**: 不能简单迁移到 a2a-java Client interceptor,因为 `A2ATransport` 同时服务直连 Runtime 和东信 Gateway Runtime,自定义 Runtime 也需要拿到最终 Header。 + +**回应**: 接受。迁移到 SDK interceptor 链会绕过自定义 Runtime 的 Header 消费。 + +**最终共识**: +1. 保留 P2 +2. 抽出与具体 SDK 无关的 `HeaderContributor` 或 `ClientCallContextFactory` +3. 认证、扩展分别实现 Header 贡献逻辑 +4. Runtime 只消费构建完成的 `ClientCallContext` +5. required 扩展构建失败时 fail-fast,optional 允许降级 +6. 清理当前未执行的 auth interceptor 构建逻辑 + +--- + +### 2.16 P2-5:空文本判断成功 → 共识:提升为 P1 + +**审核纠正**: 原修复建议 `text 非空 AND state COMPLETED` 会错误拒绝 artifact-only 的成功响应。 + +**回应**: 接受。Task COMPLETED 但只返回 artifact 不返回文本时,`r.getText()` 为空,我的条件会判失败。 + +**最终共识**: +1. 提升为 P1 +2. 有明确状态时以状态为准:COMPLETED 为成功,FAILED/CANCELED/REJECTED 为失败 +3. INPUT_REQUIRED/AUTH_REQUIRED 不作为普通任务成功 +4. 只有无状态时才回退到文本/内容判断 +5. 后续可增加 artifact 到 `TaskResponse` 的映射 + +--- + +## 3. 遗漏问题回应 + +### 3.1 caCertsPath 和 mTLS 未接入默认运行链路 → 接受,P1 + +**回应**: 这是原报告最重要的遗漏。`DefaultA2AJavaClientRuntime.createHttpClient()` 确实没有调用 `SslContextFactory.create(verifyServer, caCertsPath, ...)`。`caCertsPath` 被接收、保存、打印但从未使用。mTLS 代码是死代码。 + +原报告附录"mTLS 支持已确认良好"是过度结论——只验证了辅助代码存在,没有验证引擎能力可用。 + +**最终共识**: P1。统一构建 HTTP/JSONRPC/gRPC 的 TLS 配置,补充自定义 CA、mTLS 集成测试,TLS 配置错误 fail-fast。 + +### 3.2 DefaultA2AJavaClientRuntime.close() 不关闭线程池 → 接受,P1 + +**回应**: `httpClientExecutor` 是 Runtime 持有的 `ExecutorService`,`close()` 只打印日志不 shutdown。资源泄漏确认。 + +**最终共识**: P1。Runtime 自建 Executor 必须在 `close()` 中关闭。增加重复 close、发送中 close、关闭后拒绝新请求测试。 + +### 3.3 PROTOCOL 日志明文输出敏感 Header → 接受,P1/P2 + +**回应**: `ProtocolLogger` 输出完整 Header 包括 `Authorization: Bearer ...`。独立 logger 便于联调是合理设计,但生产环境需要脱敏。`A2ATransport` 中已有 `WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS` 环境变量控制,但只作用于 `OrderGatewayClientRuntime` 路径,引擎核心的 `ProtocolLogger` 没有脱敏逻辑。 + +**最终共识**: P1。默认脱敏 Authorization/Cookie/Proxy-Authorization 和已知 API Key Header,提供显式开关,启用时输出安全告警。 + +### 3.4 form-urlencoded 未编码 → 接受,P2 + +**回应**: `AgentCredentialService.login()` 中 `form.append(e.getKey()).append("=").append(e.getValue())` 确实没有 URL encode。包含 `&`、`=`、`+`、`%`、空格、中文的值会破坏请求语义。 + +**最终共识**: P2。按 UTF-8 对 key 和 value 分别执行 form URL encoding,增加特殊字符测试。 + +### 3.5 AuthProvider 与 credentials 同名 Header 冲突 → 接受,P2 + +**回应**: `buildClientCallContext()` 中 `authProvider.applyAuth()` 先写 Header,`applyAuthHeaders()` 后写,后者覆盖前者。部分集成文档说"互不覆盖"与 Map 实际行为不一致。 + +**最终共识**: P2。推荐二选一,或检测同名 Header fail-fast,或明确并测试覆盖优先级,修正文档。 + +### 3.6 SslContextFactory.create() fail-open → 接受,P2 + +**回应**: `SslContextFactory.create()` 在加载 CA/证书异常时 `return Optional.empty()`,日志 "Falling back to no verification"。虽然当前默认 Runtime 没有调用此方法,但公共方法契约不应 fail-open。 + +**最终共识**: P2。配置存在但加载失败时抛出异常,只有调用方显式设置不安全模式时才允许关闭验证。 + +--- + +## 4. 附录修正 + +### mTLS + +**原结论**: "SslContextFactory.loadKeyManagers() 的实现可以作为正面基础,但默认 Runtime 没有接入,不能直接得出'引擎已经支持 mTLS'的结论。" + +**回应**: 接受。原报告附录第 6 条"SslContextFactory mTLS 支持:loadKeyManagers 支持客户端证书+私钥,RSA 密钥格式正确"应改写为:辅助代码存在但未接入运行链路,引擎 mTLS 能力不可用。 + +### CredentialCrypto + +**原结论**: "'无 IV 重用风险'是过度绝对的表达。解密失败返回密文使整体凭证处理不满足 fail-safe。" + +**回应**: 接受。AES-GCM 算法选择正确,`SecureRandom` 生成的 12 字节 IV 碰撞概率极低但非零。解密失败返回密文的 fail-open 行为是更严重的问题(已提升为 P1)。附录结论应改写为:算法选择合理,但 fail-open 行为使整体凭证处理不满足 fail-safe。 + +### ProtocolLogger + +**原结论**: "'无需修改'不成立:必须补充敏感信息控制、默认脱敏和日志治理说明。" + +**回应**: 接受。原报告附录第 8 条"ProtocolLogger:独立 logger 名,INDENT_OUTPUT 已启用,与引擎其他日志可独立控制级别"应补充:"但未脱敏敏感 Header,生产环境需补充"。 + +--- + +## 5. 最终整改优先级共识 + +### 第一批:安全和结果正确性(P0/P1 高) + +| # | 问题 | 优先级 | 整改要点 | +|---|------|--------|---------| +| 1 | JVM 全局禁用主机名验证 | P0 | 移除全局属性修改,默认 `sslVerify=true` | +| 2 | AgentCredentialService trust-all | P0 | TLS/HttpClient 显式注入,登录默认严格校验 | +| 3 | Notification-T 超时吞没真实错误 | P1 高 | 仅 TimeoutException 走兼容路径,其他异常完成 | +| 4 | CredentialCrypto 解密失败返回密文 | P1 | `enc:` 前缀无法解密时抛异常,启动预校验 | +| 5 | DefaultControlPoint 空文本判断成功 | P1 | 以 Task 状态为准,无状态才回退文本判断 | +| 6 | WorkflowExecutor 静默跳过未满足依赖 | P1 | 依赖死锁时标记失败,加载阶段环检测 | +| 7 | caCertsPath/mTLS 未接入默认运行链路 | P1 | 统一 TLS 配置构建,补充 mTLS 集成测试 | +| 8 | Runtime.close() 不关闭线程池 | P1 | 自建 Executor 在 close() 中 shutdown | +| 9 | PROTOCOL 日志明文输出敏感 Header | P1 | 默认脱敏,显式开关 | + +### 第二批:资源和生产可配置性(P1) + +| # | 问题 | 整改要点 | +|---|------|---------| +| 10 | 无界 CachedThreadPool | 有界 `ArrayBlockingQueue`,可配置,Executor 注入 | +| 11 | 每次 send 新建 Client | 按 URL+协议+TLS 缓存 Client,close() 统一关闭 | +| 12 | AutoConfig 参数硬编码 | A2AProperties 增加 timeout/pool/queue 配置 | +| 13 | 配置散落 @Value | 分层 Properties:通用/引擎/厂商各自独立 | + +### 第三批:接口和维护性(P2) + +| # | 问题 | 整改要点 | +|---|------|---------| +| 14 | sendNotification 接口丢回调 | 带回调版本改为抽象方法 | +| 15 | setHttpClient 空操作 | 删除方法和误导日志 | +| 16 | Header 构建/interceptor 抽象 | 抽出 HeaderContributor,清理冗余 auth interceptor | +| 17 | contains() 匹配扩展 URI | URI 解析+路径段精确匹配 | +| 18 | form-urlencoded 未编码 | UTF-8 form URL encode | +| 19 | AuthProvider/credentials Header 冲突 | 二选一或 fail-fast 或明确优先级 | +| 20 | SslContextFactory.create() fail-open | 配置加载失败抛异常 | +| 21 | SSE compact JSON | 改用 protobuf printer 紧凑输出,消除 O(n²) | + +--- + +## 6. 待第三方重点复核项 + +同意二次审核第 6 节的 6 个复核项,补充以下 2 项: + +7. **JDK HttpClient 主机名验证**: 确认 JDK 11-21 是否有 per-client 的主机名验证 API(非全局系统属性) +8. **a2a-java Client 生命周期**: 确认 `Client.close()` 是否关闭底层连接池,缓存的 Client 在 AgentCard 更新时是否需要重建 + +--- + +## 7. 总结 + +二次审核纠正了原报告 8 处实质错误,补充了 6 个遗漏问题。最终共识: + +- **P0**: 2 项(全局主机名验证、登录 trust-all) +- **P1 高**: 7 项(Notification-T 超时、CredentialCrypto fail-open、成功判断、静默跳过依赖、mTLS 未接入、线程池泄漏、协议日志脱敏) +- **P1**: 4 项(无界线程池、Client 缓存、参数硬编码、配置散落) +- **P2**: 8 项(接口契约、空操作、Header 抽象、URI 匹配、form 编码、Header 冲突、fail-open、SSE JSON) + +原报告删除重复项 P2-6,P0-4 降为 P2,P1-1 降为路线图项。 diff --git a/docs/zh/engine-code-review-second-check.md b/docs/zh/engine-code-review-second-check.md new file mode 100644 index 00000000..5ff2e882 --- /dev/null +++ b/docs/zh/engine-code-review-second-check.md @@ -0,0 +1,548 @@ +# 引擎核心代码检视报告——二次审核意见 + +> **归档状态(2026-08-25)**:本文只记录旧 commit 的审核过程。已采纳项的当前实现与验证结果见 +> [CHANGELOG](../../CHANGELOG.md)、[API 参考](API_REFERENCE.md) 和 [架构设计](DESIGN.md);下文不是当前接口契约。 + +> **审核对象**: `docs/zh/engine-code-review.md` +> **审核范围**: `workflow-engine`、`spring-boot-starter`,并交叉核验同目录下的 `a2a-java`、`a2a-t-sdk-java` +> **审核日期**: 2026-08-18 +> **代码基线**: `dev` 分支 `39118ca` +> **说明**: 本文是对原检视报告的二次审核,不替代正式安全测试、压力测试和端到端联调结论。 + +--- + +## 1. 总体结论 + +原检视报告方向总体合理,但不能原样作为整改清单使用,主要存在以下问题: + +1. 部分问题定级过高,例如 SSE JSON 压缩被误判为会修改字符串语义。 +2. 部分修复方案本身不可用,例如使用无界 `LinkedBlockingQueue` 构造所谓“有界线程池”。 +3. 部分问题需要结合实际依赖实现重新解释,例如 A2A SDK 的 consumption timeout 并不是 SSE 相邻事件间隔超时。 +4. P1-7 与 P2-6 是同一个接口契约问题,存在重复计数。 +5. 报告遗漏了自定义 CA/mTLS 未接入默认运行链路、运行时线程池未关闭、协议日志泄露认证信息等重要问题。 + +建议修正后形成: + +- P0:2 项; +- P1:6 项左右; +- P2:5 项左右; +- 删除重复项 P2-6; +- 将原 P0-4 降为普通性能/可读性清理。 + +--- + +## 2. 原报告逐项审核 + +### 2.1 P0-1:`sslVerify=false` 全局禁用 JVM 主机名验证 + +**审核结论:问题成立,保留 P0;原修复建议需要修正。** + +证据: + +- `DefaultA2AJavaClientRuntime` 构造函数设置 JVM 系统属性: + `System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true")`。 +- `SslContextFactory.createTrustAll()` 再次设置相同属性。 +- `DefaultA2AJavaClientRuntime()` 无参构造函数默认使用 `sslVerify=false`。 + +该系统属性影响同一 JVM 中的 JDK HTTP Client,而不是只影响当前 A2A Client。对于作为依赖库运行在 Spring Boot 工作台中的场景,这会扩大到其他出站 HTTPS 调用。 + +原报告提出“仅使用 trust-all SSLContext、不关闭主机名验证即可满足开发环境”的表述不准确。信任证书链和主机名校验是两层不同的验证:trust-all 只跳过证书链可信性判断,并不会自动允许证书 SAN/CN 与目标主机不一致。 + +**建议整改:** + +1. 默认构造函数改为安全默认值 `sslVerify=true`。 +2. 通用库不得在运行时修改 JVM 全局 TLS 属性。 +3. 生产环境使用受信 CA 或显式配置的自定义 CA,并保证证书 SAN 与访问地址一致。 +4. 如确需开发环境不安全模式,应通过隔离的传输实现提供,并打印明确安全告警,不能污染整个 JVM。 + +--- + +### 2.2 P0-2:`AgentCredentialService` 登录端点无条件 trust-all + +**审核结论:问题成立,保留 P0。** + +当调用方没有注入 `HttpClient` 时,`AgentCredentialService` 总是使用 `SslContextFactory.createTrustAll()`。标准 `AgentAuthManager` 创建服务时也没有传入自定义客户端,因此默认凭证登录路径实际无法受工作流客户端的 `sslVerify`、自定义 CA 等配置控制。 + +这意味着即使 A2A 消息通道开启了严格 TLS 校验,获取登录 Token 的用户名、密码交换仍可能不验证服务端身份。 + +**建议整改:** + +1. 将 TLS/HttpClient 作为显式依赖注入 `AgentCredentialService`。 +2. Token 登录默认严格校验证书和主机名。 +3. 登录链路与消息链路共享统一 TLS 配置模型,但允许分别覆盖。 +4. 自签证书通过 CA 信任配置解决,不能以 trust-all 作为生产方案。 + +--- + +### 2.3 P0-3:Notification-T 超时处理吞没真实错误 + +**审核结论:问题成立,建议调整为 P1 高优先级。** + +当前实现: + +```java +return future.orTimeout(5, TimeUnit.SECONDS) + .exceptionally(e -> subscribedWithoutAck()); +``` + +`exceptionally` 会处理所有异常。流在 5 秒内发生连接失败、HTTP 错误、协议解析失败时,也会被转换为 `TASK_STATE_WORKING`,导致调用方把真实失败识别成订阅成功。 + +该问题只影响 Notification-T 订阅能力,因此是否定为 P0 取决于该能力是否属于发布阻断项;从通用引擎定级看,P1 高优先级更准确。 + +**建议整改:** + +1. 解包 `CompletionException`/`ExecutionException` 后,仅对真实 `TimeoutException` 采用“暂未收到 ACK”的兼容行为。 +2. 其他错误必须异常完成,不得转换成成功结果。 +3. 返回初始订阅状态后,后台 SSE 发生异常仍要通过状态回调、健康事件或重连管理器通知订阅生命周期持有者。 +4. 5 秒等待值应支持配置。 + +--- + +### 2.4 P0-4:SSE compact JSON 会破坏字符串内空白 + +**审核结论:核心判断不成立,应从 P0 删除。** + +`lines().map(String::trim)` 删除的是 pretty-printed JSON 每个物理行两端的缩进和换行。对于: + +```json +{ + "text": " Authorization-T pre-positioned " +} +``` + +`trim()` 作用于包含整个属性的物理行,只会删除 `{`、属性缩进和 `}` 周围的结构空白,不会删除双引号内部的前后空格。合法 JSON 字符串中的换行也必须转义,不能直接跨物理行,因此原报告示例不能证明语义被篡改。 + +以下两个次要意见可以保留: + +- `reduce("", String::concat)` 会产生不必要的中间字符串,较大报文下可能出现 O(n²) 拷贝; +- 可以优先使用 protobuf printer 的紧凑输出能力,减少手工处理。 + +`\r\n` 与 `\n` 都是 SSE 允许的行结束形式,平台差异不是协议错误。 + +**修正定级:P2 性能/实现简化。** + +--- + +### 2.5 P1-1:三个扩展 Prompt 生成器为空桩 + +**审核结论:不属于当前功能缺陷,应归入能力路线图或低优先级清理。** + +交叉核验当前 `a2a-t-sdk-java` 后,`A2ATClient` 公开的 Prompt 生成接口只有: + +```java +generateTaskPrompt(Object userInput) +``` + +不存在 `generateNegotiationPrompt`、`generateAuthorizationPrompt`、`generateNotificationPrompt`。因此原报告所称“已有 SDK 能力未利用”不成立。 + +当前 `DefaultExtensionSender` 类注释也明确说明另外三种能力为“reserved for future SDK support”,运行时会回退使用原始自然语言输入。 + +可以进行的维护性优化: + +- 删除三个没有实际行为的方法,由分派逻辑直接回退;或 +- 保留方法并添加明确 TODO、SDK 版本和预期契约。 + +在 A2A-T SDK 尚未提供对应接口前,不建议定为 P1。 + +--- + +### 2.6 P1-2:`A2ATransport` 使用无界 CachedThreadPool + +**审核结论:问题成立,保留 P1;原修复代码不正确。** + +`A2ATransport.asyncExecutor` 和 `DefaultA2AJavaClientRuntime.httpClientExecutor` 都使用 `newCachedThreadPool`。高并发工作流和阻塞式发送可能造成线程数量快速增长。 + +原报告建议: + +```java +new ThreadPoolExecutor(4, 16, ..., new LinkedBlockingQueue<>(), ...) +``` + +这里的 `LinkedBlockingQueue<>` 没有容量上限。任务进入无界队列后,线程池通常只保留 corePoolSize 个线程,`maximumPoolSize=16` 和 `CallerRunsPolicy` 基本不会发挥预期作用。 + +**建议整改:** + +- 使用有容量的 `ArrayBlockingQueue` 或 `new LinkedBlockingQueue<>(capacity)`; +- 配置 core、max、queue capacity、keep-alive 和拒绝策略; +- 将 Executor 作为可注入依赖,明确所有权和关闭责任; +- 长生命周期 Notification-T 订阅与短请求发送使用不同的资源模型。 + +--- + +### 2.7 P1-3:每次 send 创建新的 Client/HttpClient + +**审核结论:问题成立,保留 P1。** + +当前默认运行时每次消息发送都会创建 `Client` 和 `A2AHttpClient`,结束后关闭 Client。这样无法充分复用连接池、TLS 会话和底层传输资源,协商多轮发送场景会重复建立连接。 + +交叉核验当前 `a2a-java` 源码,其 `Client` Javadoc 已明确声明实例是线程安全的,可以并发使用。因此原报告中的线程安全待确认项已有答案:可以设计缓存,但事件 Consumer 必须保持线程安全。 + +**建议整改:** + +- 优先按 Agent 接口 URL、协议绑定、TLS 配置缓存 Client/Transport; +- AgentCard 或接口配置变化时更新缓存; +- 在 runtime `close()` 中统一关闭缓存资源; +- 增加并发发送、AgentCard 更新和关闭时竞态测试。 + +--- + +### 2.8 P1-4:AutoConfiguration 超时和线程池参数硬编码 + +**审核结论:可配置性问题成立,保留 P1;部分原因解释错误。** + +`a2a.blocking.agent.timeout.seconds`、`a2a.blocking.consumption.timeout.seconds` 和固定 8 线程都应该支持 Spring Boot 外部配置。 + +但原报告把 `consumption timeout=5s` 解释为“SSE 两个事件之间最多间隔 5 秒”并不符合上游 `a2a-java` 定义。上游定义是阻塞调用中等待事件消费和持久化完成的超时,不是 SSE idle timeout。 + +**建议整改:** + +- 在通用 starter 的 `A2AProperties` 中增加服务端 blocking timeout、executor core/max/queue capacity 等通用配置; +- 保留当前默认值以兼容上游 SDK; +- 不要依据错误的 SSE 间隔解释直接把默认值扩大到 60~120 秒; +- 增加配置绑定测试。 + +--- + +### 2.9 P1-5:`A2AProperties` 缺少工作台/网关配置 + +**审核结论:原整改方向不合理。** + +`spring-boot-starter` 当前承担的是通用 A2A 服务端接入职责,`A2AProperties` 使用 `a2at.server` 前缀。把以下内容加入其中会造成通用模块依赖具体应用和厂商适配: + +- `a2a.order.host`; +- `a2a.order.port`; +- 东信模拟器配置; +- 工作台 sample 的 credentials/env 路径。 + +这违背依赖倒置和模块边界,且使主干通用 starter 被东信场景污染。 + +**建议整改:** + +- 通用服务端参数继续放在 `A2AProperties`; +- 通用出站引擎参数如确需 Spring 自动装配,可新建 `WorkflowEngineClientProperties`; +- 东信参数放在 samples 或独立 `eastcom-order-adapter-starter` 的 `OrderGatewayProperties`; +- 用专用 `@ConfigurationProperties` 替换 sample 中散落的 `@Value`。 + +因此,“配置散落需要治理”可以保留,但“全部加入 A2AProperties”应否决。 + +--- + +### 2.10 P1-6:WorkflowExecutor 使用 50ms sleep 轮询 + +**审核结论:原性能分析不准确,但代码存在更严重的正确性问题,应重写该条。** + +正常执行路径会等待当前所有 ready step 的 `CompletableFuture.allOf()` 完成,再进入下一轮 DAG 遍历。因此正常的十层依赖链不会每层额外进入一次 50ms sleep。 + +`readySteps` 为空且仍有 deferred step,通常意味着: + +- 依赖缺失; +- 路由产生不可达节点; +- 依赖图存在环; +- 前置步骤未产生预期 output。 + +当前实现在超过 `workflow.getSteps().size()` 次后,仅把步骤加入 `executed` 并跳过,没有把工作流设置为失败。最后 `ExecutionResult.success` 仍可能为 true。这才是需要优先解决的问题。 + +**建议整改:** + +1. 当 pending 非空但没有 ready step 且没有正在执行的 step 时,立即识别为依赖死锁。 +2. 输出未满足的前置步骤列表。 +3. 将工作流标记为失败,不能静默跳过。 +4. 在工作流加载/启动阶段增加缺失依赖和环检测。 +5. 删除 50ms 轮询逻辑,或只保留在确实存在异步外部依赖的明确场景中。 + +--- + +### 2.11 P1-7:`ExtensionSender.sendNotification` 默认实现丢弃回调 + +**审核结论:问题成立,但建议降为 P2,并与 P2-6 合并。** + +接口默认方法接受 `eventCallback`,却直接调用不带 callback 的 `sendExtensionMessage()`。当前唯一实现 `DefaultExtensionSender` 已覆盖该方法,所以现有主路径没有丢回调,但未来实现类可能静默违反接口契约。 + +**建议整改:** + +- 将带 callback 的 `sendNotification` 改为非 default 抽象方法; +- 不带 callback 的便利方法可以继续默认传 null; +- 增加接口契约测试。 + +--- + +### 2.12 P2-1:解密失败返回密文 + +**审核结论:问题成立,建议提升为 P1。** + +`CredentialCrypto.decryptIfNeeded()` 在以下情况都会返回原始 `enc:...` 字符串: + +- 缺少 `A2AT_CRED_KEY`; +- 密文格式不正确; +- Base64、密钥长度或 GCM 认证失败。 + +随后系统会把密文当成密码继续发起登录,最终表现为远端 401 或认证失败,掩盖真实配置错误。 + +**建议整改:** + +- 带 `enc:` 前缀的数据一旦无法解密必须抛出明确异常; +- 异常信息包含配置位置和失败类型,但不得包含密文、密钥或明文; +- 应用启动阶段预校验加密凭证,避免到第一次调用时才失败。 + +--- + +### 2.13 P2-2:`AgentAuthManager.setHttpClient()` 是空操作 + +**审核结论:问题成立,保留 P2。** + +该方法没有修改任何服务,日志却输出“HTTP client propagated”,属于误导性 API 和不正确日志。仓库中也没有实际调用点。 + +如果采用依赖注入式认证客户端设计,应通过构造函数/Factory 创建服务;在此之前建议直接删除该方法和错误日志,而不是保留一个看似可用的空实现。 + +--- + +### 2.14 P2-3:扩展 URI 使用 `contains()` 匹配 + +**审核结论:问题成立,保留 P2;原修复方案不可直接使用。** + +`contains()` 可能把 `Task-T-Extended` 错误匹配为 `Task-T`。 + +原报告建议取 URI 最后一个路径段,但当前测试和常用 URI 是: + +```text +https://a2a.example.org/extensions/Task-T/v1 +``` + +最后一个路径段是 `v1`,不是 `Task-T`,因此该建议会破坏现有匹配。 + +**建议整改:** + +- 使用 `URI` 解析路径; +- 对路径段进行精确、大小写不敏感匹配; +- 明确定义扩展名和版本段规则; +- 或由 handler 声明完整 URI/受支持版本集合; +- 增加 `Task-T-Extended`、非法 URI、query/fragment 等测试。 + +--- + +### 2.15 P2-4:手动调用 ExtensionInterceptor + +**审核结论:设计问题成立,保留 P2;整改要兼容自定义 Runtime。** + +`buildInterceptors()` 同时创建认证和扩展 interceptor,但 `A2ATransport` 只手动执行 `ExtensionInterceptor`。认证头由 `applyAuthHeaders()` 另外生成,因此 auth interceptor 在该路径上属于冗余对象。 + +不能简单把逻辑全部迁移到 a2a-java Client interceptor,因为 `A2ATransport` 同时服务于默认直连 Runtime 和东信 Order Gateway Runtime,自定义 Runtime 也需要拿到最终 Header。 + +**建议整改:** + +- 抽出与具体 SDK 无关的 `HeaderContributor` 或 `ClientCallContextFactory`; +- 认证、扩展分别实现 Header 贡献逻辑; +- Runtime 只消费构建完成的 `ClientCallContext`; +- required 扩展构建失败时应 fail-fast,optional 扩展才允许降级; +- 清理当前未执行的 auth interceptor 构建逻辑。 + +--- + +### 2.16 P2-5:用空文本判断任务成功 + +**审核结论:问题成立,建议提升为 P1。** + +当前判断: + +```java +boolean success = r.getText() != null && !r.getText().isEmpty(); +``` + +会导致: + +- Task 已 COMPLETED,但只返回 artifact 时被判断为失败; +- Task 已 FAILED,但错误消息非空时被判断为成功。 + +原报告提出“文本非空且状态 COMPLETED”仍会错误拒绝 artifact-only 的成功响应。 + +**建议规则:** + +1. 有明确状态时,以状态为准; +2. `COMPLETED` 为成功; +3. `FAILED`、`CANCELED`、`REJECTED` 为失败; +4. `INPUT_REQUIRED`、`AUTH_REQUIRED` 不应作为普通任务成功; +5. 只有旧协议或 Message-only 响应没有状态时,才回退到文本/内容判断; +6. 后续可增加 artifact 到 `TaskResponse` 的映射。 + +--- + +### 2.17 P2-6:Notification callback 接口不一致 + +**审核结论:删除,和 P1-7 重复。** + +两条描述的是同一个事实:默认接口方法可能静默丢弃 callback。应合并为一个 P2 接口契约问题,避免重复统计和重复整改。 + +--- + +## 3. 原报告遗漏的问题 + +### 3.1 P1:`caCertsPath` 和 mTLS 没有接入默认运行链路 + +这是原报告最重要的遗漏之一。 + +`DefaultA2AJavaClientRuntime` 虽然接收、保存并打印 `caCertsPath`,但 `createHttpClient()` 的实际逻辑是: + +- `sslVerify=true`:直接创建默认 `JdkA2AHttpClient`; +- `sslVerify=false`:创建 trust-all JDK HttpClient。 + +代码没有调用 `SslContextFactory.create(verifyServer, caCertsPath, ...)`。全仓库也没有找到该 `create()` 方法的实际调用点。 + +因此: + +- `caCertsPath` 当前不生效; +- `SslContextFactory` 中的 mTLS 代码没有被默认 Runtime 使用; +- 附录中“mTLS 支持已确认良好”只能说明辅助代码存在,不能说明引擎能力已经可用; +- gRPC 路径也明确只使用默认信任库,自定义 CA 需要自定义 Runtime。 + +**建议整改:**统一构建 HTTP/JSONRPC/gRPC 的 TLS 配置,补充自定义 CA、证书错误、mTLS 和关闭验证的集成测试,并对 TLS 配置错误 fail-fast。 + +--- + +### 3.2 P1:`DefaultA2AJavaClientRuntime.close()` 没有关闭线程池 + +`httpClientExecutor` 是 Runtime 持有的 ExecutorService,但 `close()` 只打印日志,没有执行 shutdown。`A2ATransport.close()` 只会关闭自己的 `asyncExecutor`,无法替代 Runtime 对内部资源的关闭责任。 + +**建议整改:** + +- Runtime 自己创建的 Executor 必须在 `close()` 中关闭; +- 外部注入的 Executor 不应由 Runtime 擅自关闭; +- 增加重复 close、正在发送时 close、关闭后拒绝新请求等测试。 + +--- + +### 3.3 P1/P2:PROTOCOL 日志明文输出敏感 Header 和 Body + +`ProtocolLogger` 会输出完整 Header,其中可能包括: + +- `Authorization: Bearer ...`; +- Cookie、API Key、自定义认证头; +- 任务正文、协商信息及业务数据。 + +独立 logger 便于联调是合理设计,但生产配置和开源默认实践需要同时考虑敏感信息保护。 + +**建议整改:** + +- 默认脱敏 Authorization、Cookie、Proxy-Authorization 和已知 API Key Header; +- 提供显式的敏感 Header 开关,仅在受控联调环境启用; +- 启用时输出安全告警; +- 文档说明日志文件权限、保留周期和问题单脱敏要求; +- Body 如需完整打印,也应有独立开关和长度限制。 + +--- + +### 3.4 P2:form-urlencoded 登录参数没有编码 + +`AgentCredentialService` 当前直接拼接: + +```java +key=value&key2=value2 +``` + +如果用户名或密码包含 `+`、`&`、`=`、`%`、空格或中文,请求语义会改变。 + +**建议整改:**按 UTF-8 对 key 和 value 分别执行 form URL encoding,并增加特殊字符测试。 + +--- + +### 3.5 P2:AuthProvider 与 credentials 的同名 Header 冲突策略不清晰 + +当前顺序为: + +1. 自定义 `AuthProvider` 写入 Header; +2. credentials 认证随后写入 Header。 + +如果两者都写 `Authorization`,后执行的 credentials 会覆盖前者。部分集成文档却表述为两者“互不覆盖”,与 Map 的实际行为不一致。 + +**建议整改:** + +- 推荐两种认证方式二选一;或 +- 检测同名 Header 并 fail-fast;或 +- 明确并测试覆盖优先级,同时修正文档。 + +--- + +### 3.6 P2:TLS 配置错误存在潜在 fail-open 语义 + +`SslContextFactory.create()` 在加载 CA/证书发生异常时记录: + +```text +Falling back to no verification +``` + +随后返回 `Optional.empty()`。虽然当前默认 Runtime 没有实际调用该方法,但从公共方法契约看,生产 TLS 配置错误不应该被解释为关闭验证。 + +**建议整改:**证书、私钥或 CA 配置存在但加载失败时直接抛出配置异常。只有调用方显式设置不安全模式时,才允许关闭验证。 + +--- + +## 4. 对原报告“已确认良好设计”附录的修正 + +以下结论基本可以保留: + +1. `ConversationScopedA2AJavaClientRuntime` 的会话生命周期抽象; +2. `A2ATExtension` 集中维护扩展 URI; +3. `ExtensionInterceptor` 只注入当前消息实际携带的扩展; +4. Negotiation-T SDK 处理与直接提取的双路径; +5. 协商轮次上限; +6. `WorkflowExecutor.anySuccess` 的基本语义; +7. `EnvFileLoader` 不覆盖更高优先级配置。 + +以下结论需要改写: + +### 4.1 mTLS + +`SslContextFactory.loadKeyManagers()` 的实现可以作为正面基础,但默认 Runtime 没有接入,不能直接得出“引擎已经支持 mTLS”的结论。还需验证 PKCS#8、证书链、加密私钥、证书轮换、非 RSA 密钥等边界。 + +### 4.2 CredentialCrypto + +AES-GCM、12 字节随机 IV 和 128 位 Tag 的算法选择合理。`SecureRandom` 能把 IV 碰撞概率降到极低,但“无 IV 重用风险”是过度绝对的表达。更重要的是,解密失败返回密文使整体凭证处理不满足 fail-safe。 + +### 4.3 ProtocolLogger + +独立 logger 和 pretty JSON 有利于协议联调,应保留。但“无需修改”不成立:必须补充敏感信息控制、默认脱敏和日志治理说明。 + +--- + +## 5. 建议整改优先级 + +### 第一批:安全和结果正确性 + +1. 移除 JVM 全局关闭主机名验证的行为,调整默认构造器安全值。 +2. 修复凭证登录 trust-all,并统一 TLS 配置。 +3. 修复 Notification-T 将真实失败伪装成订阅成功的问题。 +4. 修复 `CredentialCrypto` 解密失败继续使用密文的问题。 +5. 修复 `DefaultControlPoint` 的成功状态判断。 +6. 修复 WorkflowExecutor 对未满足依赖静默跳过并可能报告成功的问题。 + +### 第二批:资源和生产可配置性 + +1. 使用可配置的有界 Executor,并实现背压。 +2. 缓存可安全复用的 A2A Client/Transport。 +3. 正确关闭 Runtime 内部线程池和缓存资源。 +4. 外部化 starter 的通用 timeout、pool 和 queue 参数。 +5. 真正接入自定义 CA 和 mTLS。 + +### 第三批:接口和维护性 + +1. 收紧 Notification callback 接口契约。 +2. 清理 `AgentAuthManager.setHttpClient()` 空操作。 +3. 重构 Header 构建和 interceptor 抽象。 +4. 修复扩展 URI 的精确匹配。 +5. 修复 form-urlencoded 编码。 +6. 明确 AuthProvider 与 credentials 冲突策略。 +7. 优化 SSE JSON 紧凑序列化,但不再作为 P0 正确性问题。 + +--- + +## 6. 建议第三方重点复核的问题 + +为了避免二次审核本身产生新的误判,建议后续检视人员重点验证: + +1. JDK 当前目标版本下,是否存在不修改 JVM 全局属性且能对单个 `HttpClient` 关闭主机名验证的受支持方案。 +2. `a2a-java` 当前实际依赖版本是否与同目录源码一致,以及 Client/Transport 缓存和关闭的线程安全边界。 +3. Notification-T 在 5 秒无 ACK 后,底层 Future 和 SSE 流是否仍保持独立生命周期,以及异常如何上报。 +4. 工作流模型中未选择的条件分支是否可能被登记为其他节点的静态 predecessor,避免把合法路由误判为依赖死锁。 +5. A2A-T 扩展 URI 的规范化格式、版本段规则和大小写要求。 +6. PROTOCOL 完整报文日志在正式交付环境中的安全要求,是否允许通过显式开关打印 Authorization。 + +以上核验项完成后,再冻结最终整改清单和优先级。 diff --git a/docs/zh/engine-code-review.md b/docs/zh/engine-code-review.md new file mode 100644 index 00000000..fd305ee8 --- /dev/null +++ b/docs/zh/engine-code-review.md @@ -0,0 +1,745 @@ +# 引擎核心代码检视报告 + +> **归档状态(2026-08-25)**:本文是旧 commit 的问题快照,不是当前代码说明。 +> 文中 P0/P1/P2 问题已在未发布版整改,包括 per-client TLS、认证 fail-closed、SSE 原样解析、 +> 有界线程池、runtime 复用、可配置 Spring 执行器、无轮询 DAG join、回调透传、凭证解密 +> fail-closed、规范 URI 精确匹配、任务状态判定,以及真实 SDK 的 Task/Negotiation/Authorization/ +> Notification 管线。当前契约请以 [API 参考](API_REFERENCE.md)、[架构设计](DESIGN.md)和 +> [CHANGELOG](../../CHANGELOG.md) 为准。下文的旧接口名和行号仅用于追溯,不得复制到新实现。 + +> **检视范围**: `workflow-engine` 模块 + `spring-boot-starter` 模块 +> **检视日期**: 2026-08-18 +> **检视人**: AI 辅助 + 人工核验 +> **源码版本**: main 分支 `4aec309` / dev 分支 `39118ca` + +--- + +## P0: 安全 / 正确性问题 + +### P0-1: `sslVerify=false` 全局禁用 JVM 主机名验证 + +**文件**: `DefaultA2AJavaClientRuntime.java:105-108`, `SslContextFactory.java:167-168` + +**代码**: +```java +// DefaultA2AJavaClientRuntime.java:108 +System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); + +// SslContextFactory.java:168 +System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); +``` + +**WHY 需要优化**: + +`jdk.internal.httpclient.disableHostnameVerification` 是一个 JVM 级别的系统属性,影响同一 JVM 中所有 `java.net.http.HttpClient` 实例。在 Spring Boot 工作台场景中,同一 JVM 可能运行多个 HTTP 客户端(工作台自身的 REST 调用、注册中心客户端、LLM API 调用等)。设置此属性后,**所有** HTTPS 连接都失去主机名验证,不仅是 A2A 通道。 + +具体风险: +- 攻击者可以注册任意域名的证书(只要 CA 链有效),JDK 不会校验证书中的 CN/SAN 与目标主机名是否匹配 +- 这是一个中间人攻击向量:攻击者用合法 CA 签发的"其他域名"证书即可冒充目标服务器 + +JDK 在类加载时缓存此属性(`jdk.internal.net.http.common.Utils`),意味着即使后来设为 `false` 也不会生效——属性必须在任何 `HttpClient` 创建之前设置。 + +**HOW 优化方向**: + +JDK `HttpClient` 没有提供 per-client 的主机名验证器 API。可选方案: +1. **生产环境强制 `sslVerify=true`**:在 `WorkflowEngineClientConfig` 中将 `sslVerify` 默认值从 `true` 保持,在 `DefaultA2AJavaClientRuntime` 构造时,如果 `sslVerify=false`,打印 **WARN** 日志明确告知影响范围,并建议仅在开发环境使用 +2. **使用信任所有证书的 `SSLContext` 但不禁用主机名验证**:当前 `SslContextFactory.createTrustAll()` 已经创建了信任所有的 `TrustManager`(接受任何证书),这在开发环境足够。主机名验证可以通过自定义 `SSLParameters` 在 `HttpClient.newBuilder().sslParameters(...)` 中更精细地控制,但 JDK 的实现仍受系统属性影响 +3. **文档约束**:在 `SslContextFactory.createTrustAll()` 的 Javadoc 中已加 WARN,但 `DefaultA2AJavaClientRuntime` 的构造函数 Javadoc 应明确说明"此设置影响整个 JVM" + +**核验要点**: 确认生产环境是否曾经使用过 `sslVerify=false`。如果仅在开发/测试环境使用,风险可控,但仍建议加日志告警。 + +--- + +### P0-2: AgentCredentialService 登录端点永远使用 trust-all SSL + +**文件**: `AgentCredentialService.java:78-81` + +**代码**: +```java +// Disable TLS verification (mirrors Python's verify=False for login endpoints) +b.sslContext(SslContextFactory.createTrustAll()); +this.httpClient = b.build(); +``` + +**WHY 需要优化**: + +`AgentCredentialService` 的 `login()` 方法向 credential config 中配置的 `login_url` 发送认证请求(用户名/密码),获取 Bearer Token。这个 HTTP 客户端**无条件使用 trust-all SSL**,不受引擎配置 `sslVerify` 的控制。 + +即使引擎配置 `sslVerify=true`(A2A 通道使用正式 TLS),**登录端点的凭证交换仍然不验证 TLS**。攻击者可以中间人攻击 login 端点,截获用户名和密码。 + +Python SDK 的对应实现确实也使用 `verify=False`,但 Python 的 `verify=False` 是 per-session 的(只影响 `httpx.AsyncClient` 实例),不影响同一进程中的其他 HTTP 客户端。Java 的 `SslContextFactory.createTrustAll()` 虽然也是 per-instance 的 SSLContext,但 `createTrustAll()` 内部又调用了 `System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true")`,触发了 P0-1 的全局问题。 + +**HOW 优化方向**: + +1. `AgentCredentialService` 构造函数增加 `sslVerify` 和 `caCertsPath` 参数,根据引擎配置决定是否验证 TLS +2. `SslContextFactory.createTrustAll()` 移除 `System.setProperty` 调用,只保留 trust-all `TrustManager`(per-instance 的 SSLContext 已经足够信任所有证书,不需要全局禁用主机名验证) +3. 如果 login 端点使用自签证书,应配置 `ca_certs_path` 指向自签 CA,而非 trust-all + +**核验要点**: 确认生产环境的 login_url 是否使用自签证书。如果是,应配置 CA 路径而非 trust-all。 + +--- + +### P0-3: Notification-T 超时处理器吞没真实错误 + +**文件**: `A2ATransport.java:445-454` + +**代码**: +```java +return future.orTimeout(5, TimeUnit.SECONDS) + .exceptionally( + e -> { + log.warn( + "[Transport] Notification-T subscription: no event in 5s, assuming active (stream stays open)"); + return SendMessageResult.builder() + .text("Subscribed (no-ack)") + .taskState("TASK_STATE_WORKING") + .build(); + }); +``` + +**WHY 需要优化**: + +`orTimeout(5, SECONDS)` 在 5 秒后触发 `TimeoutException`。但 `exceptionally(e -> ...)` 捕获**所有**异常,不仅仅是 `TimeoutException`。 + +如果 Notification-T 流在 5 秒内失败(网络中断、服务端 500 错误等),stream thread 会调用 `future.completeExceptionally(e)`。此时 `orTimeout` 返回的 future 也会以同样的异常完成。`exceptionally` 会捕获这个真实错误,打印"no event in 5s"日志,并返回 `SendMessageResult(text="Subscribed (no-ack)", taskState="TASK_STATE_WORKING")`——**将真实错误伪装成订阅成功**。 + +调用方收到 "TASK_STATE_WORKING" 后认为订阅成功,但实际 SSE 流已经断开。后续的恢复结果永远不会到达,且无人知道订阅已失败。 + +**HOW 优化方向**: + +在 `exceptionally` 中区分 `TimeoutException` 和其他异常: +```java +.exceptionally(e -> { + if (e instanceof TimeoutException) { + log.warn("[Transport] Notification-T subscription: no event in 5s, assuming active"); + return SendMessageResult.builder() + .text("Subscribed (no-ack)") + .taskState("TASK_STATE_WORKING") + .build(); + } + // 真实错误,不吞没 + throw new CompletionException(e); +}); +``` + +注意: `e` 可能被 `CompletableFuture` 包装为 `CompletionException`,需要 `e.getCause() instanceof TimeoutException` 判断。 + +**核验要点**: 确认 Notification-T 流在 5 秒内失败时,当前行为是否确实将错误吞没。可以在测试中模拟服务端立即返回 500,观察调用方收到的结果。 + +--- + +### P0-4: SSE compact JSON 破坏含空白字符的文本值 + +**文件**: `A2AController.java:125-135` + +**代码**: +```java +String compact = + json.replace("\r\n", "\n") + .replace('\r', '\n') + .lines() + .map(String::trim) + .reduce("", String::concat); +String sse = + String.format(Locale.ROOT, "id:%d%n", seq.incrementAndGet()) + + "data:" + compact + "\n\n"; +``` + +**WHY 需要优化**: + +这段代码将 protobuf JSON 序列化的 pretty-printed 输出压缩为单行。`lines()` + `map(String::trim)` + `reduce("", String::concat)` 的组合有两个问题: + +1. **trim 破坏值内空白**: pretty-printed JSON 中,字符串值内的前导/后导空格在 trim 后丢失。例如 `"text": " Authorization-T pre-positioned "` 会变成 `"text": "Authorization-T pre-positioned"`。虽然 JSON 标准说空白在结构上不显著,但 **A2A 协议传输的是语义内容**,文本值中的空格对 Agent 理解指令有语义意义。 + +2. **`reduce("", String::concat)` 的 O(n^2) 性能**: 对 n 行 JSON,`reduce` 会创建 n 个中间字符串,每个比前一个长。对于大的 Task 响应(含长文本 artifact),这会产生 O(n^2) 的内存分配和拷贝开销。 + +3. **`%n` 平台依赖**: `String.format("%n")` 在 Windows 上产生 `\r\n`,在 Unix 上产生 `\n`。虽然 SSE 规范允许 `\r\n` 和 `\n`,但 compact JSON 已被规范为 `\n`,两者不一致。 + +**HOW 优化方向**: + +使用 protobuf 的紧凑打印器(如果 API 支持),或用 Jackson 重序列化: +```java +// 方案 A: protobuf printer 配置 +String json = JsonFormat.printer().omittingInsignificantWhitespace().print(sr); + +// 方案 B: Jackson 重序列化(保证值内空白不丢失) +ObjectMapper mapper = new ObjectMapper(); +String compact = mapper.writeValueAsString(mapper.readTree(json)); + +// SSE 行终止符统一为 \n +String sse = "id:" + seq.incrementAndGet() + "\ndata:" + compact + "\n\n"; +``` + +**核验要点**: 确认 protobuf `JsonFormat.Printer` 是否有 `omittingInsignificantWhitespace()` 方法。如果有,方案 A 最简洁。如果确认 A2A 协议中文本值的空白不影响语义,则当前实现可接受但应注释说明。 + +--- + +## P1: 生产就绪性 / 设计问题 + +### P1-1: `DefaultExtensionSender` 三个扩展 prompt 生成器是空桩 + +**文件**: `DefaultExtensionSender.java:247-266` + +**代码**: +```java +private String generateNegotiationPrompt(String naturalLanguageInput) { + if (transport.getA2atClient() == null) { return null; } + return null; // STUB +} +private String generateAuthorizationPrompt(String naturalLanguageInput) { + if (transport.getA2atClient() == null) { return null; } + return null; // STUB +} +private String generateNotificationPrompt(String naturalLanguageInput) { + if (transport.getA2atClient() == null) { return null; } + return null; // STUB +} +``` + +**WHY 需要优化**: + +`DefaultExtensionSender.sendExtensionMessage()` (line 70-81) 调用 `generateExtensionPrompt()` 来生成结构化的扩展 prompt。当返回 `null` 时,fallback 使用原始自然语言文本作为 metadata value。 + +只有 Task-T 的 `generateTaskPromptText()` 调用了 `a2atClient.generateTaskPrompt()`。其他三个扩展的 prompt 生成器无条件返回 `null`,即使 `A2ATClient` 已经初始化。 + +这意味着: +- **Authorization-T 预定位**: 工作台发送的授权白名单文本是原始自然语言,没有经 LLM 加工为结构化 prompt +- **Notification-T 预定位**: 同上 +- **Negotiation-T 跟进**: 协商跟进消息的 metadata 是 fallback 文本 + +**HOW 优化方向**: + +需要确认 A2A-T SDK 的 `A2ATClient` 是否提供以下方法: +- `generateNegotiationPrompt(String input) -> PromptGenerationResult` +- `generateAuthorizationPrompt(String input) -> PromptGenerationResult` +- `generateNotificationPrompt(String input) -> PromptGenerationResult` + +如果 SDK 已有这些方法,直接调用(与 `generateTaskPromptText` 相同模式)。 +如果 SDK 尚未提供,在方法 Javadoc 中标注"等待 SDK 支持",并在 `DefaultExtensionSender` 的类级 Javadoc 中更新说明。 + +**核验要点**: 检查 `a2a-t-sdk-java` 的 `A2ATClient` 类的公开 API,确认是否存在这三个方法或等价方法。 + +--- + +### P1-2: `A2ATransport` 使用无界 CachedThreadPool + +**文件**: `A2ATransport.java:82-88` + +**代码**: +```java +private final ExecutorService asyncExecutor = + Executors.newCachedThreadPool( + r -> { + Thread t = new Thread(r, "engine-send"); + t.setDaemon(true); + return t; + }); +``` + +**WHY 需要优化**: + +`newCachedThreadPool` 没有线程数上限。每个 `send()` 调用通过 `CompletableFuture.supplyAsync(..., asyncExecutor)` 提交任务。在并行 DAG 执行中(`WorkflowExecutor` 同时执行多个 ready step),如果多个 step 同时向多个 agent 发送消息,线程数可能短暂激增。 + +每个线程默认占用约 1MB 栈空间。20 个并行 agent 调用 = 20 个线程 = 20MB 栈内存。在生产环境中,工作台可能同时处理多个工作流实例,线程数可能进一步增长。 + +此外,`DefaultA2AJavaClientRuntime` 也有一个无界的 `httpClientExecutor` (line 81-87),以及 `sendNotificationStream` 创建的独立 `Thread` (line 372-444)。三类线程池/线程叠加,资源消耗可能不可控。 + +**HOW 优化方向**: + +```java +// 有界线程池,可配置 +private final ExecutorService asyncExecutor = + new ThreadPoolExecutor( + 4, 16, // core=4, max=16 (可配置) + 60L, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(), + r -> { Thread t = new Thread(r, "engine-send"); t.setDaemon(true); return t; }, + new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:调用方执行 + ); +``` + +线程池大小应通过 `WorkflowEngineClientConfig` 外部化配置。 + +**核验要点**: 确认当前并行 DAG 执行的最大并发 agent 调用数。如果 PSOP 中的 `ALL_SUCCESS` step 最多有 2-3 个 subtask,16 个线程已经足够。 + +--- + +### P1-3: `DefaultA2AJavaClientRuntime` 每次 send 创建新 Client 和 HttpClient + +**文件**: `DefaultA2AJavaClientRuntime.java:272, 315-341, 437-450` + +**代码**: +```java +// sendMessage 中: +Client client = createClient(agentCard, agentUrl); // 每次新建 +// ... +client.close(); + +// createClient -> createHttpClient: +private A2AHttpClient createHttpClient() { + if (this.sslVerify) { + return new JdkA2AHttpClient(); // 每次新建 HttpClient + } + SSLContext trustAllCtx = SslContextFactory.createTrustAll(); + HttpClient httpClient = HttpClient.newBuilder()...build(); // 每次新建 + return new JdkA2AHttpClient(httpClient); +} +``` + +**WHY 需要优化**: + +每次 `sendMessage` 都创建新的 `Client` 和新的 `HttpClient`。`HttpClient` 内部维护连接池、SSL context、selector 管理器等,创建成本高。JDK `HttpClient` 文档明确建议复用实例。 + +在协商循环中,一次 `sendMessage` 可能触发 3 轮协商(每轮一个 `sendMessage`),每轮都创建/销毁一个 `HttpClient`。连接无法复用,TCP/TLS 握手每次重新进行。 + +`HttpClient` 没有显式 `close()` 方法(Java 11-17),其内部资源依赖 GC 回收或 `executor` 的 shutdown。大量短生命周期 `HttpClient` 实例可能导致 `Selector` 管理器泄漏(JDK 内部每个 `HttpClient` 有一个 selector,通过弱引用清理,但 GC 压力大)。 + +**HOW 优化方向**: + +```java +// 按协议+SSL配置缓存 Client 实例 +private final Map clientCache = new ConcurrentHashMap<>(); + +private Client getOrCreateClient(AgentCard agentCard) { + String cacheKey = agentCard.supportedInterfaces().get(0).url() + + "|" + sslVerify + "|" + preferredProtocol; + return clientCache.computeIfAbsent(cacheKey, k -> createClient(agentCard)); +} +``` + +注意: 缓存 `Client` 后,`client.close()` 不应在每次 `sendMessage` 后调用。需要在 `DefaultA2AJavaClientRuntime.close()` 中统一关闭。 + +**核验要点**: 确认 a2a-java SDK 的 `Client` 是否线程安全,是否支持并发 `sendMessage` 调用。如果 SDK 内部不支持,缓存可能导致并发问题。 + +--- + +### P1-4: `A2AAutoConfiguration` 超时和线程池参数硬编码 + +**文件**: `A2AAutoConfiguration.java:106-107, 146-157` + +**代码**: +```java +case "a2a.blocking.agent.timeout.seconds" -> "30"; +case "a2a.blocking.consumption.timeout.seconds" -> "5"; +// ... +new ThreadPoolExecutor( + 8, 8, // 固定 8 线程 + 0L, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(), ...); +``` + +**WHY 需要优化**: + +1. **Agent 超时 30 秒**: 某些 Agent 执行复杂诊断可能需要 60-120 秒。30 秒太短,可能导致 Task 被中断。 +2. **Consumption 超时 5 秒**: SSE 流式响应中,两个 event 之间的间隔可能超过 5 秒(LLM 思考时间)。5 秒太短。 +3. **线程池固定 8 线程**: 无法根据工作台的并发需求调整。高并发场景下,第 9 个请求会阻塞在队列中(`LinkedBlockingQueue` 无界,但只有 8 个消费线程)。 + +这些参数不可通过 `application.yml` 配置,集成方必须覆盖 Bean 才能修改。 + +**HOW 优化方向**: + +扩展 `A2AProperties`: +```java +@ConfigurationProperties(prefix = "a2at.server") +public class A2AProperties { + // 现有字段... + private int agentTimeoutSeconds = 30; + private int consumptionTimeoutSeconds = 5; + private int executorPoolCore = 8; + private int executorPoolMax = 8; +} +``` + +`A2AConfigProvider` 和 `agentExecutorPool` 读取这些属性值。 + +**核验要点**: 确认 a2a-java SDK 的 `A2AConfigProvider` 支持哪些配置 key。如果 SDK 有其他 key,也应暴露。 + +--- + +### P1-5: `A2AProperties` 缺少工作台/网关配置项 + +**文件**: `A2AProperties.java` (仅 54 行) + +**WHY 需要优化**: + +`spring-boot-starter` 的 `@ConfigurationProperties` 只有 `a2at.server.agent-card` 和 `a2at.server.path-prefix` 两个属性。以下配置散落在 `samples` 模块的 `@Value` 注解中: + +| 配置项 | 当前位置 | 应归入 | +|--------|---------|--------| +| `a2a.transport-mode` | `ClientRuntimeFactory` | `A2AProperties` | +| `a2a.order.host` | `ClientRuntimeFactory` | `A2AProperties` | +| `a2a.order.port` | `ClientRuntimeFactory` | `A2AProperties` | +| `a2a.order.simulator-enabled` | `ClientRuntimeFactory` | `A2AProperties` | +| `a2a.ssl-verify` | `ClientRuntimeFactory` | `A2AProperties` | +| `a2a.credentials-path` | `SpringWorkbenchExtensionLifecycle` | `A2AProperties` | +| `a2a.a2at-env-path` | 多处 | `A2AProperties` | + +集成方使用 `spring-boot-starter` 时,无法通过统一的 properties 管理这些配置,必须查看 sample 源码才能知道有哪些 `@Value` 属性。 + +**HOW 优化方向**: + +在 `spring-boot-starter` 中新增 `WorkflowProperties` 或扩展 `A2AProperties`: +```java +@ConfigurationProperties(prefix = "a2a") +public class WorkflowProperties { + private String transportMode = "direct"; + private boolean sslVerify = true; + private String credentialsPath; + private String a2atEnvPath; + // ... Order SDK 配置 +} +``` + +注意: `spring-boot-starter` 不应依赖 `order-shaded-client`,因此 Order SDK 特有配置应放在 samples 层的 properties 中,或通过条件化配置实现。 + +**核验要点**: 确认工作台集成方实际需要配置哪些项。有些配置(如 `a2a.order.host`)是东信特有的,不应放在通用 starter 中。 + +--- + +### P1-6: `WorkflowExecutor` 延迟步骤使用 50ms sleep 轮询 + +**文件**: `WorkflowExecutor.java:205-218` + +**代码**: +```java +if (readySteps.isEmpty()) { + if (!deferredSteps.isEmpty()) { + return CompletableFuture.runAsync( + () -> { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }) + .thenCompose(v -> executeSteps(pending, executed, failed, deferCount)); + } + return CompletableFuture.completedFuture(null); +} +``` + +**WHY 需要优化**: + +当所有剩余步骤的前置依赖尚未完成时,执行器在一个 `runAsync` 线程上 sleep 50ms 然后重试。这是一个忙等待轮询循环。 + +问题: +1. **延迟**: 每次轮询间隔 50ms,如果依赖在 sleep 后 1ms 完成,仍需等待 49ms。对于 10 层依赖链,最坏情况额外 500ms 延迟。 +2. **线程占用**: 每次轮询占用一个 `ForkJoinPool.commonPool` 线程 50ms。如果多个工作流并行执行,common pool 可能被占满。 +3. **无退出条件检查**: 如果 `deferredSteps` 永远无法满足(依赖图中存在循环依赖或缺失依赖),这个循环会无限运行(虽然有 `dc > workflow.getSteps().size()` 的保护,但需要 `workflow.getSteps().size()` 次迭代才触发)。 + +**HOW 优化方向**: + +用事件驱动替代轮询。当 `stepOutputs.put()` 完成一个步骤时,直接唤醒等待该步骤的 deferred steps: +```java +// 在 executeStep 完成后,直接将新的 ready steps 加入 pending, +// 而不是 sleep + 重试整个 pending 队列 +// 当 readySteps 为空但 deferredSteps 非空时, +// 检查 deferredSteps 是否有步骤的前置已全部满足 +``` + +或者使用 `CompletableFuture` 依赖图:每个 step 的 future 依赖其前置 step 的 future,用 `thenCompose` 链式组合,完全消除轮询。 + +**核验要点**: 确认当前 PSOP 工作流的依赖深度。如果大多数工作流只有 2-3 层,50ms 延迟可接受。如果有 5+ 层链式依赖,应优化。 + +--- + +### P1-7: `ExtensionSender` 接口默认 `sendNotification` 丢弃回调 + +**文件**: `ExtensionSender.java:70-82` + +**代码**: +```java +default CompletableFuture sendNotification( + String agentName, + String instruction, + String naturalLanguageInput, + Consumer> eventCallback) { + return sendExtensionMessage( + agentName, instruction, naturalLanguageInput, A2ATExtension.NOTIFICATION_T); + // eventCallback 被忽略! +} +``` + +**WHY 需要优化**: + +接口的 default 方法接受 `eventCallback` 参数但**完全忽略它**——直接委托给 `sendExtensionMessage` 而不传递回调。`sendExtensionMessage` 不接受回调参数,Notification-T 的后续事件无处可去。 + +`DefaultExtensionSender` 重写了此方法(line 122-164),正确处理了回调。但如果其他实现类使用 default 方法(不重写),回调会被静默丢弃,调用方以为注册了回调但永远不会收到事件。 + +**HOW 优化方向**: + +方案 A: 将 `sendNotification` 声明为抽象方法(去掉 default),强制实现类处理回调: +```java +CompletableFuture sendNotification( + String agentName, String instruction, String naturalLanguageInput, + Consumer> eventCallback); +``` + +方案 B: default 方法抛出 `UnsupportedOperationException`,提示实现类必须重写: +```java +default CompletableFuture sendNotification(..., Consumer> eventCallback) { + throw new UnsupportedOperationException("sendNotification with callback must be overridden"); +} +``` + +**核验要点**: 确认是否有除 `DefaultExtensionSender` 之外的其他 `ExtensionSender` 实现。如果没有,此问题可降低优先级。 + +--- + +## P2: 健壮性 / 边界场景 + +### P2-1: `CredentialCrypto.decryptIfNeeded` 解密失败时返回密文 + +**文件**: `CredentialCrypto.java:108-113, 132-135` + +**代码**: +```java +// Key 未设置时: +log.warn("Encrypted value found but {} not set, using as-is", ENV_KEY); +return value; // 返回 "enc:xxx:yyy" + +// 解密异常时: +log.error("[CredentialCrypto] Decryption failed: {}", e.getMessage()); +return value; // 返回 "enc:xxx:yyy" +``` + +**WHY 需要优化**: + +当解密失败(密钥错误、密文损坏、密钥未设置)时,方法返回 `enc:base64:base64` 字符串。这个字符串会被用作 Agent 的 Bearer Token 或密码发送给 login 端点。login 端点会认证失败,返回 401/403,但错误信息是"认证失败",不是"凭证解密失败"。 + +运维人员看到的是 "Auth login failed: status=401",需要追踪到 `CredentialCrypto` 的 WARN/ERROR 日志才能找到根因。如果日志级别设置不当(WARN 被过滤),根因完全隐藏。 + +**HOW 优化方向**: + +解密失败时抛出异常而非返回密文: +```java +throw new IllegalStateException( + "Credential decryption failed: " + e.getMessage() + + ". Check A2AT_CRED_KEY environment variable."); +``` + +或者返回 `null`,让 `AgentCredentialService.login()` 的 `credential == null` 检查自然失败,但日志更清晰。 + +注意: 改为抛异常可能影响现有行为——如果某些凭证确实是明文(不以 `enc:` 开头),不受影响。但如果有配置文件混用了明文和密文,需要确保所有密文都能正确解密。 + +**核验要点**: 确认现有凭证配置文件中是否有 `enc:` 前缀的值。如果没有,此问题不影响当前运行,但仍应修复以防未来使用密文时出错。 + +--- + +### P2-2: `AgentAuthManager.setHttpClient()` 是空操作 + +**文件**: `AgentAuthManager.java:151-159` + +**代码**: +```java +public void setHttpClient(java.net.http.HttpClient httpClient) { + for (AgentCredentialService svc : services.values()) { + if (svc != null) { + // AgentCredentialService stores httpClient internally + // New services created after this call will use default client + } + } + log.info("[Auth] HTTP client propagated to {} service(s)", services.size()); +} +``` + +**WHY 需要优化**: + +方法遍历 `services` 但循环体为空(只有注释)。日志输出 "propagated to N service(s)" 但实际什么都没做。调用方以为 HTTP 客户端已传播到所有 credential service,但实际上每个 `AgentCredentialService` 仍然使用自己创建的 trust-all `HttpClient`。 + +这是一个误导性 API:方法签名和日志暗示功能存在,但实际未实现。 + +**HOW 优化方向**: + +方案 A: 实现传播逻辑: +- `AgentCredentialService` 增加 `updateHttpClient(HttpClient)` 方法 +- `setHttpClient` 调用每个 service 的 `updateHttpClient` +- 注意线程安全:`AgentCredentialService.httpClient` 字段需要 `volatile` 或同步 + +方案 B: 删除方法和日志(如果不打算实现): +- 移除 `setHttpClient` 方法 +- 移除相关 Javadoc 中对它的引用 + +**核验要点**: 确认是否有调用方使用 `setHttpClient`。搜索 `authManager.setHttpClient` 的调用点。 + +--- + +### P2-3: `ExtensionRegistry` 使用 `contains()` 匹配扩展 URI + +**文件**: `ExtensionRegistry.java:72` + +**代码**: +```java +if (uri.toLowerCase().contains(entry.getKey().toLowerCase()) + && !seen.contains(entry.getKey())) { +``` + +**WHY 需要优化**: + +使用 `String.contains()` 而非精确匹配来关联扩展 URI 与 handler。当前能工作是因为: +- `"https://projects.tmforum.org/.../Task-T/v1".toLowerCase()` 包含 `"task-t"` +- `"https://projects.tmforum.org/.../NEGOTIATION-T".toLowerCase()` 包含 `"negotiation-t"` + +但 `contains` 是宽松匹配。如果未来出现名为 `"Task-T-Extended"` 的扩展 URI,它也会匹配 `"Task-T"` handler,导致错误的 handler 被调用。 + +**HOW 优化方向**: + +改为精确匹配扩展 URI 中的路径段: +```java +// 提取 URI 最后一个路径段(如 "Task-T" 或 "NEGOTIATION-T") +String uriSegment = uri.substring(uri.lastIndexOf('/') + 1).toLowerCase(); +if (uriSegment.equals(entry.getKey().toLowerCase())) { + // ... +} +``` + +或者将 handler 的 `extensionKeyword()` 改为返回完整 URI,用 `equals` 匹配。 + +**核验要点**: 确认 A2A-T 规范中扩展 URI 的命名规则。如果 URI 路径段就是扩展名,上述方案可行。如果 URI 格式可能变化,需要更健壮的匹配逻辑。 + +--- + +### P2-4: `A2ATransport.buildClientCallContext` 手动调用 ExtensionInterceptor + +**文件**: `A2ATransport.java:483-498` + +**代码**: +```java +List interceptors = authManager.buildInterceptors(agentCard, agentName); +for (ClientCallInterceptor interceptor : interceptors) { + if (interceptor instanceof ExtensionInterceptor extInterceptor) { + try { + ClientCallContext interceptCtx = new ClientCallContext(new HashMap<>(), headers); + PayloadAndHeaders ph = extInterceptor.intercept( + "message/send", messageMetadata, headers, null, interceptCtx); + headers.putAll(ph.getHeaders()); + } catch (Exception e) { + log.warn("[Transport] Extension interceptor failed: {}", e.getMessage()); + } + } +} +``` + +**WHY 需要关注**: + +1. `buildInterceptors()` 返回 auth interceptor + extension interceptor 列表,但循环只调用 `ExtensionInterceptor`,跳过 `AuthInterceptor` / `CustomAuthInterceptor`。Auth 头由同方法中的 `applyAuthHeaders()` 独立设置。这意味着 `AuthInterceptor` 和 `CustomAuthInterceptor` 被构建但从未通过此路径调用——它们是事实上的死代码(对 `A2ATransport` 路径而言)。 + +2. `intercept()` 的第 4 个参数 `agentCard` 传 `null`。`ExtensionInterceptor.intercept()` 当前不使用 agentCard,所以功能正确,但语义上不应传 null。 + +3. 拦截器异常被 catch + warn 吞没,`A2A-Extensions` 头不会设置。如果扩展头缺失,Agent 可能不识别扩展语义。这个 fallback 行为是否合理取决于 Agent 的实现。 + +**HOW 优化方向**: + +这不是 bug,但设计可以简化: +- 如果 SDK 的 `Client` 支持注册 `ClientCallInterceptor`(通过 `Client.builder().addInterceptor()`),应让 SDK 调用拦截器,而非手动调用 +- 移除 `buildInterceptors()` 中返回 auth interceptor 的逻辑(因为 `applyAuthHeaders()` 已处理 auth),只返回 `ExtensionInterceptor` +- 或者将 `applyAuthHeaders()` 的逻辑移入 `CustomAuthInterceptor.intercept()`,统一通过拦截器链处理 + +**核验要点**: 确认 a2a-java SDK 的 `Client` 是否支持 `addInterceptor()`。如果支持,应迁移到 SDK 拦截器链。如果不支持,当前手动调用方式可接受,但应清理 auth interceptor 的冗余构建。 + +--- + +### P2-5: `DefaultControlPoint.onTask` 用空文本判断成功 + +**文件**: `DefaultControlPoint.java:66` + +**代码**: +```java +boolean success = r.getText() != null && !r.getText().isEmpty(); +``` + +**WHY 需要优化**: + +`onTask` 的默认实现用"响应文本非空"判断任务是否成功。但: +- Agent 可能返回空文本但 Task 状态为 `TASK_STATE_COMPLETED`(例如只返回 artifact 不返回文本) +- Agent 可能返回非空文本但 Task 状态为 `TASK_STATE_FAILED`(例如错误消息) + +更准确的判断应使用 Task 状态: +```java +String state = r.getTaskState(); +boolean success = state != null && (state.contains("COMPLETED")); +``` + +**HOW 优化方向**: + +```java +boolean success = r.getText() != null && !r.getText().isEmpty() + && (r.getTaskState() == null || r.getTaskState().contains("COMPLETED")); +``` + +注意: 这是 `DefaultControlPoint` 的默认实现,工作台通常会重写 `onTask`。但如果使用默认实现,当前的判断逻辑可能产生误报。 + +**核验要点**: 确认是否有使用 `DefaultControlPoint` 而非自定义 `ControlPoint` 的场景。如果所有工作台都重写了 `onTask`,此问题不影响生产。 + +--- + +### P2-6: `ExtensionSender.sendNotification` 的 `eventCallback` 与 `sendExtensionMessage` 接口不一致 + +**文件**: `ExtensionSender.java:56-83` + +**WHY 需要关注**: + +`ExtensionSender` 接口有两个 Notification-T 便利方法: +- `sendNotification(agent, instruction, input, Consumer> callback)` — 带回调 +- `sendNotification(agent, instruction, input)` — 不带回调 + +default 实现中,带回调版本调用 `sendExtensionMessage(...)` — 不传回调。不带回调版本调用带回调版本,传 `null`。 + +`DefaultExtensionSender` 重写了带回调版本(line 122),正确处理回调。但接口 default 方法的设计导致了 P1-7 的问题(回调被丢弃)。 + +此外,`sendExtensionMessage` 是接口的核心方法,它不接受 `eventCallback` 参数。Notification-T 的回调语义只在 `DefaultExtensionSender` 的重写方法中存在,接口层没有强制。如果未来有其他实现,Notification-T 的回调行为不可保证。 + +**HOW 优化方向**: + +将 `sendNotification(agent, instruction, input, callback)` 提升为接口方法(非 default),去掉 `sendExtensionMessage` 中对 Notification-T 的处理。让 Notification-T 有独立的接口契约: + +```java +CompletableFuture sendNotification( + String agentName, String instruction, String naturalLanguageInput, + Consumer> eventCallback); +``` + +**核验要点**: 确认是否有调用方直接使用 `sendExtensionMessage` 发送 Notification-T。如果有,需要保持兼容。 + +--- + +## 附录: 已确认良好的设计 + +以下设计经检视确认合理,无需修改: + +1. **`ConversationScopedA2AJavaClientRuntime` 接口**: 简洁的会话生命周期钩子,`closeConversation` 在 `DefaultWorkflowEngineClient.sendMessage` 的 `whenComplete` 中调用,确保协商循环结束后释放网关会话 +2. **`A2ATExtension` 枚举**: 封装完整 URI,避免散落的字符串常量 +3. **`ExtensionInterceptor`**: 只注入当前消息 metadata 中实际存在的扩展 URI,而非 AgentCard 上声明的所有扩展——正确行为 +4. **`NegotiationTHandler.afterReceive`**: 使用最新 SDK 的无状态 + `validateProposePromptAndDataFilling` 校验提议内容并提取参数;缺少或非法 + `negotiationContext` 时失败关闭,不再调用已删除的 `receiveNegotiation`,也不保留绕过 SDK 的提取 fallback +5. **`DefaultWorkflowEngineClient.autoNegotiate`**: 递归协商循环有 `maxNegotiationRounds` 上限(默认 3),防止无限循环 +6. **`SslContextFactory` mTLS 支持**: `loadKeyManagers` 支持客户端证书 + 私钥,RSA 密钥格式正确 +7. **`CredentialCrypto` AES-GCM**: 使用 12 字节 IV + 128 位 tag,符合 NIST 推荐;IV 使用 `SecureRandom` 生成,无 IV 重用风险 +8. **`ProtocolLogger`**: 独立 logger 名 "PROTOCOL",`INDENT_OUTPUT` 已启用,与引擎其他日志可独立控制级别 +9. **`WorkflowExecutor.anySuccess`**: ANY_SUCCESS 语义正确——首个成功即返回,取消其余;全部失败才返回失败 +10. **`EnvFileLoader`**: 不覆盖已存在的 OS 环境变量和系统属性,优先级正确 + +--- + +## 检视总结 + +| 优先级 | 编号 | 一句话描述 | 影响面 | +|--------|------|-----------|--------| +| P0-1 | 全局禁用主机名验证 | 安全:同 JVM 所有 HTTPS 失去主机名校验 | 生产环境安全风险 | +| P0-2 | 登录端点 trust-all SSL | 安全:凭证交换无 TLS 保护 | 凭证泄露风险 | +| P0-3 | Notification-T 超时吞没错误 | 正确性:真实失败被伪装为订阅成功 | 故障不可见 | +| P0-4 | SSE compact JSON 破坏值内空白 | 正确性:文本内容可能被篡改 | 语义传递错误 | +| P1-1 | 3 个扩展 prompt 生成器空桩 | 功能:SDK prompt 能力未利用 | 取决于 SDK 是否提供方法 | +| P1-2 | 无界 CachedThreadPool | 资源:线程数无上限 | 高并发内存风险 | +| P1-3 | 每次 send 新建 Client | 性能:连接不可复用 | 高吞吐性能退化 | +| P1-4 | AutoConfig 参数硬编码 | 可配置性:超时/线程池不可外部化 | 集成方无法调优 | +| P1-5 | A2AProperties 属性不全 | 可配置性:配置散落 @Value | 集成体验差 | +| P1-6 | 延迟步骤 50ms 轮询 | 性能:忙等待 | 深依赖链延迟累积 | +| P1-7 | sendNotification 默认方法丢回调 | 设计:接口默认行为错误 | 其他实现可能丢回调 | +| P2-1 | 解密失败返回密文 | 健壮性:错误根因被掩盖 | 运维排障困难 | +| P2-2 | setHttpClient 空操作 | 健壮性:误导性 API | 调用方误以为已传播 | +| P2-3 | contains 匹配扩展 URI | 健壮性:宽松匹配 | 未来扩展名冲突 | +| P2-4 | 手动调用拦截器 | 设计:auth interceptor 冗余 | 代码复杂度 | +| P2-5 | 空文本判断成功 | 健壮性:误报成功 | 默认 ControlPoint 误判 | +| P2-6 | sendNotification 接口不一致 | 设计:回调契约不强制 | 其他实现行为不可保证 | diff --git a/docs/zh/notification-t-design-analysis.md b/docs/zh/notification-t-design-analysis.md index a384f839..ea42afc7 100644 --- a/docs/zh/notification-t-design-analysis.md +++ b/docs/zh/notification-t-design-analysis.md @@ -1,6 +1,6 @@ # Notification-T / Authorization-T 设计分析 -> **已归档。** 本历史分析已整合到 [DESIGN.md](DESIGN.md) 的第 4 节 (A2A-T 扩展模型) 和第 7 节 (交互时序)。下方描述的前置预定位流程现在位于 `ExtensionSender`(见 DESIGN.md "共享传输层与双门面"),不再在 `WorkflowEngineClient` 上。仅保留供参考。 +> **已归档。** 本历史分析已整合到 [DESIGN.md](DESIGN.md) 的第 4 节(A2A-T 扩展模型)和第 7 节(交互时序)。下方“前置预定位/共享通道”表述已经过时:当前 Authorization-T、Notification-T 由 `ExtensionSender` 在独立业务时机发起,并与 Task-T 及彼此使用不同 transport/runtime/context。仅保留供历史参考。 --- diff --git "a/docs/zh/\344\270\232\345\212\241\346\265\201.md" "b/docs/zh/\344\270\232\345\212\241\346\265\201.md" index 98da6067..6d713bb3 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,5 +1,19 @@ --- +## 工作台业务边界(2026-08-25 校准) + +1. WAIMO 通过 Task-T 将一个投诉诊断任务下发给工作台。工作台使用 SDK 校验并提取 + 投诉参数,搜索/加载工作流。 +2. 工作流向两个不同地市 OMC 并行下发含地市诊断输入的 Task-T。两路结果都完成后, + 工作台 join 节点仅执行一次汇总分析,把真实汇总结果返回 WAIMO。 +3. 工作台到 OMC 支持直连与东信 Order SDK/指令平台转发;两种通道上层协议和业务回调一致, + 现网优先使用东信转发。 +4. Authorization-T 和 Notification-T 由工作台在各自业务时机独立触发,不是工作流 + DAG 节点。Task、Authorization、Notification 使用不同的 transport/runtime/context/通道。 +5. OMC 对诊断后的抢通方案做白名单精确匹配;命中后自动执行,并通过已建立的 + Notification-T 长连接上报结果。工作台收到抢通结果后关闭对应订阅;ACK 不是抢通结果。 + + ### 表格一:专线业务投诉诊断接口(核心诊断) | 项目 | 详细说明 | @@ -40,7 +54,7 @@ |:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------| | **接口名称** | 专线业务抢通授权接口 | | **协议/模式** | A2A-T Authorization-T | -| **功能描述** | 工作台向OMC下发或取消抢通授权,OMC据此决定是否自动执行抢通。 | -| **授权请求(工作台→OMC)** | **包含内容:**
        1. 操作类型:新增授权 / 取消授权
        2. 操作名称:隧道调优(或其他业务抢通操作) | +| **功能描述** | 工作台向 OMC 新增、修改、删除或查询抢通授权,OMC 据有效白名单决定是否自动执行抢通。 | +| **授权请求(工作台→OMC)** | **包含内容:**
        1. 操作类型:新增/修改/删除/查询授权策略
        2. SDK 模板所定义的策略列表或查询条件 | | **授权响应(OMC→工作台)** | **成功:** 返回授权成功。
        **失败:** 返回授权失败及详细的失败原因。 | | **后置操作(OMC内部)** | 授权成功后,当OMC在执行诊断后的业务抢通时,若匹配到已授权项,则**自动执行**业务抢通,并依据**表格三**的订阅通道上报抢通结果消息。 | From ce83a0634812f0a66a82c803b677dcd9fb11e5c1 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Tue, 25 Aug 2026 20:48:35 +0800 Subject: [PATCH 23/49] docs(main): keep direct transport guidance vendor-neutral Signed-off-by: Zhoujie628 --- CHANGELOG.md | 2 -- README.md | 9 ++++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9a14e00..37015735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,6 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 -- Direct OMC and Eastcom Order SDK forwarding modes share the same protocol semantics; Order is the sample default - 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 @@ -39,7 +38,6 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed -- Eastcom Order SDK 1.1.18 outbound request buffers consumed by its Netty-to-RPC bridge are released by an isolated compatibility handler, preventing pooled direct-memory leaks without replacing the vendor jar - 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 diff --git a/README.md b/README.md index 00f2fda2..5e3eaa19 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,10 @@ generation and validation fail closed; raw text is never sent under an A2A-T URI 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. Outbound OMC calls support both `direct` and Eastcom `order` modes -(`order` is the production-oriented default). 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. +returns the real merged result over direct HTTP+JSON transport. Task-T, Authorization-T, and +Notification-T each use an independent transport/runtime/context. Authorization and Notification +are independently triggered workbench operations rather than DAG nodes; Notification keeps an +explicit long-lived subscription until the recovery result, cancellation, or shutdown. ## Quick Start From e19769c71d9737e33dc704ebf36528ab77178d60 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Wed, 26 Aug 2026 16:44:30 +0800 Subject: [PATCH 24/49] chore(a2at): pin latest SDK resources Signed-off-by: Zhoujie628 --- .github/workflows/ci.yml | 6 ++-- pom.xml | 4 +-- .../content_validation/en-US/system.md | 28 ------------------- .../prompts/content_validation/en-US/user.md | 7 ----- .../content_validation/zh-CN/system.md | 28 ------------------- .../prompts/content_validation/zh-CN/user.md | 7 ----- 6 files changed, 5 insertions(+), 75 deletions(-) delete mode 100644 samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md delete mode 100644 samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/user.md delete mode 100644 samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md delete mode 100644 samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/user.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12748438..99194828 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,9 @@ name: CI env: - A2AT_SDK_REPOSITORY: Zhoujie628/a2a-t-sdk-java - A2AT_SDK_REF: 0ef79d37f49a9b7a2dbe16b6d9fd1ccdb6d9538d - A2AT_SDK_VERSION: 1.0.0-0ef79d3 + A2AT_SDK_REPOSITORY: project-openan/a2a-t-sdk-java + A2AT_SDK_REF: 34485504675800a436d68cfaa913f14931086506 + A2AT_SDK_VERSION: 1.0.0-3448550 on: push: diff --git a/pom.xml b/pom.xml index ceda04c1..a5de2d1e 100644 --- a/pom.xml +++ b/pom.xml @@ -53,8 +53,8 @@ UTF-8 1.0.0.Beta1 - 1.0.0-0ef79d3 - 0ef79d37f49a9b7a2dbe16b6d9fd1ccdb6d9538d + 1.0.0-3448550 + 34485504675800a436d68cfaa913f14931086506 1.18.38 2.21.1 2.0.17 diff --git a/samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md b/samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md deleted file mode 100644 index 0c81baf0..00000000 --- a/samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md +++ /dev/null @@ -1,28 +0,0 @@ -You validate A2A-T content and extract parameters. Output exactly one JSON object and no Markdown, comments, or extra text. - -The output must contain exactly these three keys: -{ - "semantic_verdict": true, - "errors": [], - "params": {} -} - -Extract params using the property names from the parameter schema. Use null when a property cannot be extracted. - -Protocol context: the input is a runtime message produced by the A2A-T SDK renderer. The renderer intentionally removes requirement prose, examples, and required markers from slot-driven template sections, retaining the section heading and actual slot value. The template content is therefore a semantic reference, not a byte-for-byte format that the input must reproduce. Never reject an input merely because this scaffolding is absent. - -Use an accept-unless-clearly-disproved semantic verdict. Set semantic_verdict to false only when: -1. a slot value directly contradicts an explicit hard constraint in the parameter schema or template; -2. a slot is an obvious placeholder or invalid value such as abc, xxx, unknown, or TBD; or -3. there is clear cross-scenario contamination or an internal contradiction. - -Rules: -1. Do not reject merely for not matching an example, being concise or identifier-like, lacking extra context, or omitting optional fields. -2. Schema required fields govern extraction. If one cannot be extracted, set it to null in params; that alone must not make semantic_verdict false. -3. When the template defines an operation-specific smaller required field set, follow that operation rule. Do not require every field that a complete entity may contain. -4. A value exactly matching an explicitly allowed value must be accepted. -5. Parameter names and structure must follow the schema and must not add undeclared properties. -6. Extract params only from Input Content. Never select values from Template Content, schema descriptions, examples, or allowed-value lists. -7. For a `## Slot Name` heading followed by a non-blank value, copy that value verbatim into the matching parameter. For enum fields, return only the value actually present below the input heading, even when the template lists several choices. Before responding, verify every non-null value occurs in Input Content. - -Every errors item must contain exactly the string fields slot_name, code, and message. errors must be empty when semantic_verdict is true and non-empty with explicit evidence when it is false. diff --git a/samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/user.md b/samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/user.md deleted file mode 100644 index e8e4e0a0..00000000 --- a/samples/src/main/resources/prompt_resources/prompts/content_validation/en-US/user.md +++ /dev/null @@ -1,7 +0,0 @@ -Extension name: [extension_name] -Input content: [input] -Template URI: [template_uri] -Template content: [template_content] -Parameter schema: [schema] - -Apply the system rules and output only the JSON object containing semantic_verdict, errors, and params. diff --git a/samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md b/samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md deleted file mode 100644 index b5e2a44a..00000000 --- a/samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md +++ /dev/null @@ -1,28 +0,0 @@ -你是 A2A-T 内容校验与参数提取代理。你只能输出一个 JSON 对象,不得输出 Markdown、注释或额外文本。 - -输出必须包含且仅包含以下三个键: -{ - "semantic_verdict": true, - "errors": [], - "params": {} -} - -其中 params 必须按照参数 schema 的属性名提取;无法提取的属性填 null。 - -重要协议背景:输入内容是 A2A-T SDK 渲染器生成的运行时消息。渲染器会有意删除模板槽位区段中的“要求”、示例和“必填”标记,只保留区段标题和实际槽位值。因此模板正文仅用于理解字段语义,不是要求输入逐字复现的格式范本。不得因为输入省略模板中的说明文字、示例、标记或其他脚手架而判定失败。 - -semantic_verdict 采用“无明确反证则通过”原则。只有下列情况可判定为 false: -1. 槽位值与参数 schema 或模板中的明确强约束直接冲突; -2. 槽位值是明显占位词或无效值,例如 abc、xxx、未知、待定; -3. 输入存在明确的跨场景污染或自相矛盾。 - -校验规则: -1. 不得仅因未命中示例、表达简短、像标识符、缺少额外上下文或省略可选字段而失败。 -2. schema 的 required 只决定参数是否应提取;无法提取时在 params 中填 null,不得仅据此把 semantic_verdict 设为 false。 -3. 当模板针对具体操作声明了更小的必填字段集合时,以该操作的必填规则为准;不得把实体可能包含的全部字段都当成必填。 -4. 输入值与明确允许值完全一致时必须接受,不得改写或臆测它不在取值范围。 -5. params 的属性名与结构必须遵循参数 schema,不得新增 schema 外的属性。 -6. params 只能从“输入内容”提取,禁止从“模板正文”、schema 的 description、examples 或允许值列表中选择值。 -7. 对 `## 槽位名` 后紧邻的非空内容,必须原样复制为同名参数值。尤其是枚举字段,即使模板列出了多个允许值,也只能返回输入标题下实际出现的那个值;输出前逐项确认非 null 值确实存在于输入内容中。 - -errors 中每项必须恰好包含 slot_name、code、message 三个字符串字段。semantic_verdict=true 时 errors 必须为空;false 时至少包含一项有明确证据的错误。 diff --git a/samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/user.md b/samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/user.md deleted file mode 100644 index 514e32c8..00000000 --- a/samples/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/user.md +++ /dev/null @@ -1,7 +0,0 @@ -扩展名称:[extension_name] -输入内容:[input] -模板标识:[template_uri] -模板正文:[template_content] -参数模式:[schema] - -请按系统规则完成语义校验和参数提取,只输出包含 semantic_verdict、errors、params 的 JSON 对象。 From b82755c07bcd11919bb8c5c1fd578562eb63f83a Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Wed, 26 Aug 2026 16:45:22 +0800 Subject: [PATCH 25/49] fix(a2at): align protocol flows with latest SDK Signed-off-by: Zhoujie628 --- .../agents/NegotiationBaseAgentExecutor.java | 19 +- .../examples/demo/A2ATProtocolCases.java | 9 +- .../engine/examples/demo/SpnCasePrompts.java | 64 ++----- .../engine/examples/demo/SpringSpnDemo.java | 5 - .../extension/AuthorizationPolicy.java | 61 +++---- .../extension/ExtensionPrePositioner.java | 4 + .../PrePositionedExtensionHandler.java | 33 +--- .../extension/SdkSlotSchemaLoader.java | 10 +- .../negotiation/NegotiationUtils.java | 26 --- .../workbench/WorkbenchTaskInputParser.java | 33 ++-- .../engine/client/A2ATContentFacade.java | 29 ++- .../workflow/engine/client/A2ATExtension.java | 5 +- .../engine/client/DefaultExtensionSender.java | 163 +++++++++-------- .../client/DefaultWorkflowEngineClient.java | 167 +++++++++++------- .../engine/client/ExtensionSender.java | 37 +++- .../engine/client/NegotiationTHandler.java | 19 +- .../workflow/engine/client/TaskTHandler.java | 23 ++- .../engine/client/WorkflowEngineClient.java | 17 +- 18 files changed, 357 insertions(+), 367 deletions(-) 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 4ecfdd25..6bcab925 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 @@ -51,6 +51,7 @@ 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; /** @@ -170,14 +171,7 @@ protected net.openan.a2at.sdk.core.model.TemplateUri taskTemplateUri() { * agent requires. A blank/missing slot (or validation rejection) triggers Negotiation-T. */ protected Map buildTaskParamSchema() { - Map properties = new LinkedHashMap<>(); - properties.put("任务对象", Map.of("type", "string")); - properties.put("任务上下文", Map.of("type", "string")); - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - schema.put("required", List.of("任务上下文")); - return schema; + return SdkSlotSchemaLoader.loadConfigured(taskTemplateUri()); } @Override @@ -251,14 +245,9 @@ private void handleNotificationSubscription(RequestContext ctx, AgentEmitter emi String taskId = ctx.getTaskId(); String contextId = ctx.getContextId(); String agentTag = getClass().getSimpleName(); - String notifUri = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + String notifUri = A2ATExtension.NOTIFICATION_T.uri(); Map requestMetadata = ctx.getMessage().metadata(); - Object promptValue = requestMetadata.entrySet().stream() - .filter(entry -> entry.getKey().contains("Notification-T")) - .map(Map.Entry::getValue) - .findFirst() - .orElse(""); + 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); 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 b6c217fe..27738dd3 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 @@ -193,6 +193,8 @@ private void case_7_5() { "新增动网操作授权", SpnCasePrompts.addAuthorizationData(), SpnCasePrompts.authorizationSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates + .AUTHORIZATION_POLICY_MANAGEMENT, A2ATExtension.AUTHORIZATION_T) .join(); log.info("[Case 7.5] state={}", result.getTaskState()); @@ -210,6 +212,8 @@ private void case_7_6() { "动网操作的授权策略列表", "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"), SpnCasePrompts.authorizationSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates + .AUTHORIZATION_POLICY_MANAGEMENT, A2ATExtension.AUTHORIZATION_T) .join(); log.info("[Case 7.6] state={}", result.getTaskState()); @@ -225,8 +229,10 @@ private void case_7_7() { "授权策略的操作类型", "查询授权策略", "动网操作的授权策略列表", - "业务投诉诊断/业务抢通/隧道调优"), + "业务场景:业务投诉诊断,处置类型:业务抢通,操作名称:隧道调优"), SpnCasePrompts.authorizationSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates + .AUTHORIZATION_POLICY_MANAGEMENT, A2ATExtension.AUTHORIZATION_T) .join(); log.info("[Case 7.7] state={}", result.getTaskState()); @@ -241,6 +247,7 @@ private void case_7_8() { "订阅业务抢通事件", SpnCasePrompts.subscribeServiceRecoveryData(), SpnCasePrompts.serviceRecoverySchema(), + net.openan.a2at.sdk.core.model.StandardTemplates.SERVICE_RECOVERY, data -> { log.info("[Case 7.8] callback: keys={}", data.keySet()); if ("recovery-result".equals(data.get("artifact_name"))) { 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 8dc6bf63..8adb8205 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 @@ -20,6 +20,9 @@ package dev.openan.workflow.engine.examples.demo; import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.extension.SdkSlotSchemaLoader; + +import net.openan.a2at.sdk.core.model.StandardTemplates; import java.util.LinkedHashMap; import java.util.List; @@ -53,27 +56,19 @@ private SpnCasePrompts() {} * 任务上下文 carries the complaint context. */ public static Map privateLineComplaintSchema() { - Map object = new LinkedHashMap<>(); - object.put("type", "string"); - object.put("description", "专线名称/专线业务标识/接入端口名称,三选一标识专线业务对象"); - Map context = new LinkedHashMap<>(); - context.put("type", "string"); - context.put( - "description", - "专线业务的故障现象描述和诊断任务上下文:投诉分类(专线中断/专线质差)、问题发生时间、" - + "OSS侧事件流水号、投诉详情"); - Map properties = new LinkedHashMap<>(); - properties.put("任务对象", object); - properties.put("任务上下文", context); - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - return schema; + return SdkSlotSchemaLoader.loadConfigured(StandardTemplates.PRIVATE_LINE_COMPLAINT); } /** Required business fields returned by Task-T validation for this scenario. */ + @SuppressWarnings("unchecked") public static List privateLineComplaintSchemaProperties() { - return List.of("任务对象", "任务上下文"); + 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; } /** Well-formed complaint (spec case 7.1): known faulty port in City1. */ @@ -140,21 +135,8 @@ public static Map taskTMetadata(String taskPrompt) { /** Schema mirroring the SDK's Authorization-T slot schema (授权策略的操作类型 + 策略列表). */ public static Map authorizationSchema() { - Map operationType = new LinkedHashMap<>(); - operationType.put("type", "string"); - operationType.put("description", "授权策略的操作类型:新增/修改/删除/查询授权策略"); - Map policyList = new LinkedHashMap<>(); - policyList.put("type", "string"); - policyList.put( - "description", - "动网操作的授权策略列表,每条包含:业务场景、处置类型、操作名称、有效期"); - Map properties = new LinkedHashMap<>(); - properties.put("授权策略的操作类型", operationType); - properties.put("动网操作的授权策略列表", policyList); - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - return schema; + return SdkSlotSchemaLoader.loadConfigured( + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); } /** Add-authorization data (spec case 7.5): whitelist the tunnel-tuning recovery action. */ @@ -163,7 +145,7 @@ public static Map addAuthorizationData() { data.put("授权策略的操作类型", "新增授权策略"); data.put( "动网操作的授权策略列表", - "业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18"); + "业务投诉诊断,业务抢通,隧道调优,2026-06-01~2030-06-18"); return data; } @@ -173,21 +155,7 @@ public static Map addAuthorizationData() { /** Schema mirroring the SDK's Notification-T service-recovery slot schema. */ public static Map serviceRecoverySchema() { - Map condition = new LinkedHashMap<>(); - condition.put("type", "string"); - condition.put("description", "订阅条件,例如子网名称约束"); - Map format = new LinkedHashMap<>(); - format.put("type", "string"); - format.put( - "description", - "上报通知数据格式:业务抢通事件的数据结构描述(执行状态/流水号/端口/授权/方案等字段)"); - Map properties = new LinkedHashMap<>(); - properties.put("订阅条件", condition); - properties.put("上报通知数据格式", format); - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - return schema; + return SdkSlotSchemaLoader.loadConfigured(StandardTemplates.SERVICE_RECOVERY); } /** Service-recovery subscription data (spec case 7.8). */ 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 9fe20f24..9bc6c80d 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 @@ -242,11 +242,6 @@ static void requireCompleted(SendMessageResult result) { } } - /** Backward-compatible programmatic entry point for direct transport. */ - public void run() throws Exception { - run(new String[0]); - } - private static long elapsedMillis(long startedNanos) { return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicy.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/AuthorizationPolicy.java index 8b279ae9..eecad4e7 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 @@ -7,7 +7,6 @@ package dev.openan.workflow.engine.examples.extension; import java.time.LocalDate; -import java.time.OffsetDateTime; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.List; @@ -32,9 +31,8 @@ public record AuthorizationPolicy(String operationType, List rules) { public AuthorizationPolicy { operationType = requireText(operationType, OPERATION_TYPE_FIELD); - if (!ADD.equals(operationType) && !MODIFY.equals(operationType)) { - throw new IllegalArgumentException( - "Authorization operation must be " + ADD + " or " + MODIFY); + 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()) { @@ -46,13 +44,13 @@ public record AuthorizationPolicy(String operationType, List rules) { public static AuthorizationPolicy fromValidated(Map data) { Objects.requireNonNull(data, "Validated Authorization-T data is required"); String operationType = operationFromValidated(data); - if (!ADD.equals(operationType) && !MODIFY.equals(operationType)) { + if (!ADD.equals(operationType)) { throw new IllegalArgumentException( - "Only add/modify operations can create an active authorization policy"); + "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 : policyList.split("[;\uff1b\\r\\n]+")) { + for (String entry : policyList.split("\uff1b", -1)) { if (!entry.isBlank()) { rules.add(Rule.parse(entry.strip())); } @@ -113,21 +111,19 @@ public record Rule( } private static Rule parse(String entry) { - String[] fields = entry.split("/", -1); - if (fields.length != 4 && fields.length != 7) { + String[] fields = entry.split("\uff0c", -1); + if (fields.length != 4) { throw new IllegalArgumentException( - "Authorization rule must contain either the four required fields or all " - + "seven SDK fields: " + "Authorization rule must contain the four fields defined by the current " + + "SDK, separated by full-width commas: " + entry); } - int scenarioIndex = fields.length == 4 ? 0 : 1; - int validityIndex = fields.length == 4 ? 3 : 4; - String validity = normalizeValidity(fields[validityIndex]); + String validity = fields[3].strip(); if ("永久生效".equals(validity)) { return new Rule( - fields[scenarioIndex].strip(), - fields[scenarioIndex + 1].strip(), - fields[scenarioIndex + 2].strip(), + fields[0].strip(), + fields[1].strip(), + fields[2].strip(), LocalDate.MIN, LocalDate.MAX); } @@ -135,42 +131,25 @@ private static Rule parse(String entry) { if (range.length != 2) { throw new IllegalArgumentException( "Authorization validity must be start~end or 永久生效: " - + fields[validityIndex]); + + validity); } try { return new Rule( - fields[scenarioIndex].strip(), - fields[scenarioIndex + 1].strip(), - fields[scenarioIndex + 2].strip(), + fields[0].strip(), + fields[1].strip(), + fields[2].strip(), parseDate(range[0]), parseDate(range[1])); } catch (DateTimeParseException e) { throw new IllegalArgumentException( - "Authorization validity must use ISO dates or offset date-times: " - + fields[validityIndex], + "Authorization validity must use ISO dates: " + + fields[3], e); } } - private static String normalizeValidity(String value) { - String normalized = value.strip(); - if (normalized.startsWith("限期生效")) { - normalized = normalized.substring("限期生效".length()).strip(); - } - if ((normalized.startsWith("(") && normalized.endsWith(")")) - || (normalized.startsWith("(") && normalized.endsWith(")"))) { - normalized = normalized.substring(1, normalized.length() - 1).strip(); - } - return normalized; - } - private static LocalDate parseDate(String value) { - String normalized = value.strip(); - try { - return LocalDate.parse(normalized); - } catch (DateTimeParseException dateOnlyFailure) { - return OffsetDateTime.parse(normalized).toLocalDate(); - } + return LocalDate.parse(value.strip()); } private boolean authorizes( 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 ddf2b32e..c1c0c0ef 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 @@ -117,6 +117,8 @@ public List prePosition( "订阅业务抢通事件", notifData, notifSchema, + net.openan.a2at.sdk.core.model.StandardTemplates + .SERVICE_RECOVERY, notificationCallback) .join(); subscriptions.add(subscription); @@ -166,6 +168,8 @@ private SendMessageResult sendAuthorization(ExtensionSender sender, String agent "新增动网操作授权", authData, authSchema, + net.openan.a2at.sdk.core.model.StandardTemplates + .AUTHORIZATION_POLICY_MANAGEMENT, dev.openan.workflow.engine.client.A2ATExtension.AUTHORIZATION_T) .join(); boolean retryableValidationFailure = 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 ad169b8d..21679da1 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 @@ -236,7 +236,7 @@ synchronized void applyAuthorization( Map validatedData, Object templateUri, String agentTag) { String operation = AuthorizationPolicy.operationFromValidated(validatedData); switch (operation) { - case AuthorizationPolicy.ADD, AuthorizationPolicy.MODIFY -> { + case AuthorizationPolicy.ADD -> { AuthorizationPolicy candidate = AuthorizationPolicy.fromValidated(validatedData); authorizationPolicy = candidate; log.info( @@ -246,6 +246,9 @@ synchronized void applyAuthorization( operation, candidate.rules().size()); } + 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 -> { // This sample models one active whitelist per OMC agent. A production callback // should use the validated selector/list to delete matching persisted policies. @@ -390,33 +393,7 @@ private static String normalizeSlotValue(String value) { } private static void requireTemplateUri(Object actualValue, TemplateUri expected) { - String actual = null; - if (actualValue instanceof TemplateUri template) { - actual = template.uri(); - } else if (actualValue instanceof String text) { - actual = text; - } else if (actualValue instanceof Map map) { - Object uri = map.get("uri"); - if (uri instanceof String text) { - actual = text; - } else { - Object extension = map.get("extensionName"); - Object segments = map.get("pathSegments"); - Object version = map.get("templateVersion"); - if (extension instanceof String extensionName - && segments instanceof List pathSegments - && version instanceof String templateVersion) { - actual = String.join( - "/", - java.util.stream.Stream.concat( - java.util.stream.Stream.of(extensionName), - java.util.stream.Stream.concat( - pathSegments.stream().map(String::valueOf), - java.util.stream.Stream.of(templateVersion))) - .toList()); - } - } - } + 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); 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 88dec8b7..6e0e0180 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 @@ -17,13 +17,14 @@ import net.openan.a2at.sdk.core.model.TemplateUri; import dev.openan.workflow.engine.examples.util.EnvResolver; -/** Loads the validation slot schema bundled with the active A2A-T SDK version. */ -final class SdkSlotSchemaLoader { +/** Loads the slot schema bundled with the active A2A-T SDK version. */ +public final class SdkSlotSchemaLoader { private static final ObjectMapper MAPPER = new ObjectMapper(); private SdkSlotSchemaLoader() {} - static Map loadConfigured(TemplateUri templateUri) { + /** 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()) { @@ -32,7 +33,8 @@ static Map loadConfigured(TemplateUri templateUri) { return load(templateUri, language); } - static Map load(TemplateUri templateUri, String 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"); 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 6abbeb6f..86907bb0 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 @@ -21,7 +21,6 @@ import dev.openan.workflow.engine.client.A2ATExtension; -import java.util.LinkedHashMap; import java.util.Map; /** @@ -57,15 +56,6 @@ public static boolean hasTaskMetadata(Map metadata) { return metadata.containsKey(TASK_PROMPT_KEY); } - /** Build the Negotiation-T metadata for an OMC-side Message(ROLE_AGENT) propose. */ - public static Map negotiationResponseMetadata(String negotiationText) { - Map metadata = new LinkedHashMap<>(); - if (negotiationText != null && !negotiationText.isEmpty()) { - metadata.put(NEGOTIATION_T_URI, negotiationText); - } - return metadata; - } - /** * Metadata key carrying the negotiation session context map ({@code id} / {@code round} / * {@code maxRounds}). @@ -77,20 +67,4 @@ public static Map negotiationResponseMetadata(String negotiation public static final String NEGOTIATION_CONTEXT_KEY = net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY; - /** - * Builds Negotiation-T propose metadata carrying the session context: the rendered propose - * text under the extension URI plus the canonical {@code negotiationContext} key holding the - * engine-format context map ({@code id}/{@code round}/{@code maxRounds}). - * - * @param negotiationText rendered propose text - * @param contextMap the session context map; null omits the key (fallback path) - */ - public static Map negotiationResponseMetadata( - String negotiationText, Map contextMap) { - Map metadata = negotiationResponseMetadata(negotiationText); - if (contextMap != null && !contextMap.isEmpty()) { - metadata.put(NEGOTIATION_CONTEXT_KEY, contextMap); - } - return metadata; - } } 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 b617b296..887cdbdd 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 @@ -92,34 +92,20 @@ private static String extractTaskPrompt(Map metadata) { if (exact instanceof String prompt && !prompt.isBlank()) { return prompt; } - for (Map.Entry entry : metadata.entrySet()) { - if (entry.getKey().contains("/Task-T/") - && entry.getValue() instanceof String prompt - && !prompt.isBlank()) { - return prompt; - } - } - throw new IllegalArgumentException("Inbound Workbench request has no Task-T prompt payload"); + throw new IllegalArgumentException( + "Inbound Workbench request has no canonical Task-T prompt payload"); } private static TemplateUri extractTemplateUri(Map metadata) { Object raw = metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY); if (raw == null) { - return StandardTemplates.PRIVATE_LINE_COMPLAINT; - } - if (raw instanceof TemplateUri templateUri) { - return requireTaskTemplate(templateUri); - } - String uri = null; - if (raw instanceof String text) { - uri = text; - } else if (raw instanceof Map map && map.get("uri") instanceof String text) { - uri = text; + throw new IllegalArgumentException( + "Inbound Workbench Task-T is missing templateUri metadata"); } - if (uri == null) { - throw new IllegalArgumentException("Inbound Workbench Task-T has malformed templateUri metadata"); + if (!(raw instanceof String candidate) || candidate.isBlank()) { + throw new IllegalArgumentException( + "Inbound Workbench Task-T has malformed templateUri metadata"); } - String candidate = uri; TemplateUri parsed = TemplateUri.parse(candidate) .orElseThrow( @@ -131,9 +117,10 @@ private static TemplateUri extractTemplateUri(Map metadata) { } private static TemplateUri requireTaskTemplate(TemplateUri templateUri) { - if (!templateUri.uri().startsWith("Task-T/")) { + if (!StandardTemplates.PRIVATE_LINE_COMPLAINT.equals(templateUri)) { throw new IllegalArgumentException( - "Inbound Workbench template is not a Task-T template: " + templateUri.uri()); + "Inbound Workbench template is not the SPN private-line complaint template: " + + templateUri.uri()); } return templateUri; } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java index aec5e0d7..a5aedbc5 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java @@ -44,9 +44,9 @@ * pipeline, and the template queries. Callers get compile-time {@link TemplateUri} constants from * {@link StandardTemplates} instead of hand-written URI strings. * - *

        All methods throw {@link net.openan.a2at.sdk.core.exception.A2ATError} subtypes on failure; - * callers are expected to catch and degrade (see {@code buildFallbackMeta} in {@code - * DefaultWorkflowEngineClient}). + *

        All methods throw {@link net.openan.a2at.sdk.core.exception.A2ATError} subtypes on failure. + * Callers propagate a protocol failure rather than manufacturing legacy metadata or raw-text + * fallbacks. * *

        Instances are cheap views over the shared {@link A2ATClient}; create one per transport or * reuse freely. @@ -233,8 +233,9 @@ public MetadataContent generateAbortFromText( * *

        The content layer is stateless by design — session identity and round tracking stay * with the caller (see SDK developer guide §1.10). The engine owns the context: it - * serializes it here for the wire and advances rounds itself via {@code - * NegotiationContext.nextRound()}. + * serializes it here for the wire. An ending message preserves the received Propose context; + * only an initiator creating a genuinely new round owns a {@link + * NegotiationContext#nextRound()} transition. * * @param context the engine-held session context * @return wire map for the canonical {@code negotiationContext} metadata key @@ -260,13 +261,14 @@ public static NegotiationContext contextFromMap(Map map) { } Object id = map.get("id"); Object round = map.get("round"); - if (id instanceof String s && round instanceof Number r && r.intValue() > 0) { + if (id instanceof String s && isPositiveInteger(round)) { Object maxRounds = map.get("maxRounds"); - if (!(maxRounds instanceof Number m) || m.intValue() <= 0) { + if (!isPositiveInteger(maxRounds)) { return null; } try { - return new NegotiationContext(s, r.intValue(), m.intValue()); + return new NegotiationContext( + s, ((Number) round).intValue(), ((Number) maxRounds).intValue()); } catch (IllegalArgumentException ignored) { // fall through } @@ -274,6 +276,17 @@ public static NegotiationContext contextFromMap(Map map) { return null; } + private static boolean isPositiveInteger(Object value) { + if (!(value instanceof Number number)) { + return false; + } + double decimal = number.doubleValue(); + return Double.isFinite(decimal) + && decimal > 0 + && decimal == Math.rint(decimal) + && decimal <= Integer.MAX_VALUE; + } + // ------------------------------------------------------------------ // Validate and fill (rule gate + LLM semantic validation + param merge) // ------------------------------------------------------------------ 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 08fa8d7e..5ef6f73d 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 @@ -86,9 +86,8 @@ public enum A2ATExtension { /** * Engine-internal metadata key carrying the template URI string for the fromData rendering - * track (e.g. {@code "Task-T/private-line-complaint/v1"}). Optional; defaults to {@link - * net.openan.a2at.sdk.core.model.StandardTemplates#PRIVATE_LINE_COMPLAINT}. Engine-internal, - * never sent on the wire. + * track (e.g. {@code "Task-T/network-layer/private-line-complaint/v1"}). Required for + * structured Task-T rendering. Engine-internal, never sent on the wire. */ public static final String TASK_TEMPLATE_META_KEY = "a2at.taskTemplate"; 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 79018b13..8d9650a8 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 @@ -64,10 +64,13 @@ public CompletableFuture sendExtensionMessage( String agentName, String instruction, String naturalLanguageInput, + TemplateUri templateUri, A2ATExtension extension) { CompletableFuture invalid = validateRequest(agentName, instruction, naturalLanguageInput, extension); if (invalid != null) return invalid; + IllegalArgumentException templateError = validateTemplate(extension, templateUri); + if (templateError != null) return CompletableFuture.failedFuture(templateError); AgentCard agentCard = transport.getCard(agentName); if (agentCard == null) { log.error("[ExtensionSender] Agent not found: {}", agentName); @@ -80,7 +83,7 @@ public CompletableFuture sendExtensionMessage( "Agent '" + agentName + "' does not declare " + extension.uri())); } return CompletableFuture.supplyAsync( - () -> generateExtensionPrompt(extension, naturalLanguageInput)) + () -> generateExtensionPrompt(extension, naturalLanguageInput, templateUri)) .thenCompose( mc -> { if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { @@ -97,15 +100,6 @@ public CompletableFuture sendExtensionMessage( agentName, extension.displayName(), value.length()); - if (extension == A2ATExtension.NOTIFICATION_T) { - return transport.sendNotificationStream( - agentCard, - agentName, - instruction, - transport.getContextId(), - metadata, - null); - } CompletableFuture send = transport .send( @@ -136,6 +130,7 @@ public CompletableFuture sendExtensionMessageFromData( String instruction, Map data, Map schema, + TemplateUri templateUri, A2ATExtension extension) { if (agentName == null || agentName.isBlank() || instruction == null || instruction.isBlank()) { return CompletableFuture.failedFuture( @@ -145,13 +140,14 @@ public CompletableFuture sendExtensionMessageFromData( return CompletableFuture.failedFuture( new IllegalArgumentException("fromData requires non-null data and a non-empty schema")); } - if (extension != A2ATExtension.AUTHORIZATION_T - && extension != A2ATExtension.NOTIFICATION_T) { + if (extension != A2ATExtension.AUTHORIZATION_T) { return CompletableFuture.failedFuture( new IllegalArgumentException( - "fromData rendering supports AUTHORIZATION_T and NOTIFICATION_T, got " - + extension)); + "One-shot fromData rendering supports AUTHORIZATION_T only; " + + "use openNotificationFromData for Notification-T")); } + IllegalArgumentException templateError = validateTemplate(extension, templateUri); + if (templateError != null) return CompletableFuture.failedFuture(templateError); AgentCard agentCard = transport.getCard(agentName); if (agentCard == null) { log.error("[ExtensionSender] Agent not found: {}", agentName); @@ -163,7 +159,8 @@ public CompletableFuture sendExtensionMessageFromData( new IllegalArgumentException( "Agent '" + agentName + "' does not declare " + extension.uri())); } - return CompletableFuture.supplyAsync(() -> generateExtensionPromptFromData(extension, data, schema)) + return CompletableFuture.supplyAsync( + () -> generateExtensionPromptFromData(extension, data, schema, templateUri)) .thenCompose( mc -> { if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { @@ -182,15 +179,6 @@ public CompletableFuture sendExtensionMessageFromData( agentName, extension.displayName(), mc.promptText().length()); - if (extension == A2ATExtension.NOTIFICATION_T) { - return transport.sendNotificationStream( - agentCard, - agentName, - instruction, - transport.getContextId(), - metadata, - null); - } return transport .send( agentCard, @@ -208,26 +196,22 @@ public CompletableFuture sendExtensionMessageFromData( /** SDK schema-aware fromData rendering for Authorization-T / Notification-T. */ private MetadataContent generateExtensionPromptFromData( - A2ATExtension extension, Map data, Map schema) { + A2ATExtension extension, + Map data, + Map schema, + TemplateUri templateUri) { net.openan.a2at.sdk.client.A2ATClient a2atClient = transport.getA2atClient(); if (a2atClient == null) { return null; } - try { - return switch (extension) { - case AUTHORIZATION_T -> a2atClient.generateAuthPromptFromDataWithSchema( - data, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); - case NOTIFICATION_T -> a2atClient.generateNotificationPromptFromDataWithSchema( - data, schema, StandardTemplates.SERVICE_RECOVERY); - default -> null; - }; - } catch (Exception e) { - log.warn( - "[ExtensionSender] SDK {} fromData rendering error: {}", - extension.displayName(), - e.getMessage()); - return null; - } + return switch (extension) { + case AUTHORIZATION_T -> a2atClient.generateAuthPromptFromDataWithSchema( + data, schema, templateUri); + case NOTIFICATION_T -> a2atClient.generateNotificationPromptFromDataWithSchema( + data, schema, templateUri); + default -> throw new IllegalArgumentException( + "Unsupported schema-aware extension: " + extension); + }; } @Override @@ -235,8 +219,14 @@ public CompletableFuture sendNotification( String agentName, String instruction, String naturalLanguageInput, + TemplateUri templateUri, Consumer> eventCallback) { - return openNotification(agentName, instruction, naturalLanguageInput, eventCallback) + return openNotification( + agentName, + instruction, + naturalLanguageInput, + templateUri, + eventCallback) .thenCompose(NotificationSubscription::acknowledgement); } @@ -245,6 +235,7 @@ public CompletableFuture openNotification( String agentName, String instruction, String naturalLanguageInput, + TemplateUri templateUri, Consumer> eventCallback) { if (agentName == null || agentName.isBlank() @@ -254,8 +245,11 @@ public CompletableFuture openNotification( || naturalLanguageInput.isBlank()) { return CompletableFuture.failedFuture( new IllegalArgumentException( - "agentName, instruction and naturalLanguageInput must not be blank")); + "agentName, instruction and naturalLanguageInput must not be blank")); } + IllegalArgumentException templateError = + validateTemplate(A2ATExtension.NOTIFICATION_T, templateUri); + if (templateError != null) return CompletableFuture.failedFuture(templateError); AgentCard agentCard = transport.getCard(agentName); if (agentCard == null) { log.error("[ExtensionSender] Agent not found: {}", agentName); @@ -273,7 +267,9 @@ public CompletableFuture openNotification( return CompletableFuture.supplyAsync( () -> generateExtensionPrompt( - A2ATExtension.NOTIFICATION_T, naturalLanguageInput)) + A2ATExtension.NOTIFICATION_T, + naturalLanguageInput, + templateUri)) .thenCompose( mc -> { if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { @@ -295,8 +291,10 @@ public CompletableFuture sendNotificationFromData( String instruction, Map data, Map schema, + TemplateUri templateUri, Consumer> eventCallback) { - return openNotificationFromData(agentName, instruction, data, schema, eventCallback) + return openNotificationFromData( + agentName, instruction, data, schema, templateUri, eventCallback) .thenCompose(NotificationSubscription::acknowledgement); } @@ -306,6 +304,7 @@ public CompletableFuture openNotificationFromData( String instruction, Map data, Map schema, + TemplateUri templateUri, Consumer> eventCallback) { if (agentName == null || agentName.isBlank() @@ -316,8 +315,11 @@ public CompletableFuture openNotificationFromData( || schema.isEmpty()) { return CompletableFuture.failedFuture( new IllegalArgumentException( - "fromData notification requires agentName, instruction, data and schema")); + "fromData notification requires agentName, instruction, data and schema")); } + IllegalArgumentException templateError = + validateTemplate(A2ATExtension.NOTIFICATION_T, templateUri); + if (templateError != null) return CompletableFuture.failedFuture(templateError); AgentCard agentCard = transport.getCard(agentName); if (agentCard == null) { log.error("[ExtensionSender] Agent not found: {}", agentName); @@ -335,7 +337,10 @@ public CompletableFuture openNotificationFromData( return CompletableFuture.supplyAsync( () -> generateExtensionPromptFromData( - A2ATExtension.NOTIFICATION_T, data, schema)) + A2ATExtension.NOTIFICATION_T, + data, + schema, + templateUri)) .thenCompose( mc -> { if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { @@ -414,34 +419,30 @@ private void forwardNotificationEvent( * SDK's own {@code A2AT_LANGUAGE} configuration, no longer from a caller-supplied language * string. * - *

        Notification-T defaults to the service-recovery template (业务抢通事件订阅) — the - * subscription the workbench opens in this scenario; pass an explicit template via - * the fromData variant when subscribing to something else (e.g. SUBSCRIBE_INCIDENT). + *

        The caller selects the current SDK template explicitly. This keeps the generic sender + * capable of both Notification-T scenarios ({@code SERVICE_RECOVERY} and {@code + * SUBSCRIBE_INCIDENT}) without a scenario-specific default. * * @return {@link MetadataContent} with prompt text and template URI, or null if SDK unavailable */ - MetadataContent generateExtensionPrompt(A2ATExtension extension, String naturalLanguageInput) { + MetadataContent generateExtensionPrompt( + A2ATExtension extension, + String naturalLanguageInput, + TemplateUri templateUri) { A2ATClient a2atClient = transport.getA2atClient(); if (a2atClient == null) { return null; } - try { - return switch (extension) { - case TASK_T -> a2atClient.generateTaskPromptFromText( - naturalLanguageInput, StandardTemplates.PRIVATE_LINE_COMPLAINT); - case AUTHORIZATION_T -> a2atClient.generateAuthPromptFromText( - naturalLanguageInput, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); - case NOTIFICATION_T -> a2atClient.generateNotificationPromptFromText( - naturalLanguageInput, StandardTemplates.SERVICE_RECOVERY); - case NEGOTIATION_T -> null; // Negotiation uses a dedicated flow in NegotiationTHandler - }; - } catch (Exception e) { - log.warn( - "[ExtensionSender] SDK {} prompt generation error: {}", - extension.displayName(), - e.getMessage()); - return null; - } + return switch (extension) { + case TASK_T -> a2atClient.generateTaskPromptFromText( + naturalLanguageInput, templateUri); + case AUTHORIZATION_T -> a2atClient.generateAuthPromptFromText( + naturalLanguageInput, templateUri); + case NOTIFICATION_T -> a2atClient.generateNotificationPromptFromText( + naturalLanguageInput, templateUri); + case NEGOTIATION_T -> throw new IllegalArgumentException( + "Negotiation-T uses the dedicated negotiation flow"); + }; } private static boolean supports(AgentCard card, A2ATExtension extension) { @@ -463,11 +464,33 @@ private static CompletableFuture validateRequest( new IllegalArgumentException( "agentName, instruction and naturalLanguageInput must not be blank")); } - if (extension != A2ATExtension.AUTHORIZATION_T - && extension != A2ATExtension.NOTIFICATION_T) { + if (extension != A2ATExtension.AUTHORIZATION_T) { return CompletableFuture.failedFuture( new IllegalArgumentException( - "ExtensionSender supports Authorization-T and Notification-T only")); + "One-shot sending supports Authorization-T only; " + + "use openNotification for Notification-T")); + } + return null; + } + + private static IllegalArgumentException validateTemplate( + A2ATExtension extension, TemplateUri templateUri) { + if (templateUri == null) { + return new IllegalArgumentException("A2A-T template URI is required"); + } + String expectedExtension = + switch (extension) { + case TASK_T -> StandardTemplates.TASK_EXTENSION_NAME; + case NEGOTIATION_T -> StandardTemplates.NEGOTIATION_EXTENSION_NAME; + case AUTHORIZATION_T -> StandardTemplates.AUTHORIZATION_EXTENSION_NAME; + case NOTIFICATION_T -> StandardTemplates.NOTIFICATION_EXTENSION_NAME; + }; + if (!expectedExtension.equals(templateUri.extensionName())) { + return new IllegalArgumentException( + "Template " + + templateUri.uri() + + " does not belong to " + + extension.displayName()); } return null; } 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 824e80bf..b58e7f99 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 @@ -463,8 +463,8 @@ private CompletableFuture continueNegotiationRound( * select the Reject or Abort terminal templates, anything else renders an Accept message via * {@code generateNegotiationAcceptPromptFromText} (one LLM extraction step). When the * negotiation context is available, the engine advances the stateless context so the - * follow-up payload carries the next round. Rendering failures are fatal: raw clarifications - * are never disguised as protocol messages. + * typed ending message preserves the received session/round context. Rendering failures are + * fatal: raw clarifications are never disguised as protocol messages. */ private CompletableFuture> buildNegotiationFollowUpMeta( String agentName, Map negMeta, String clarification) { @@ -481,7 +481,7 @@ private CompletableFuture> buildNegotiationFollowUpMeta( try { MetadataContent mc = renderFollowUpMessage(content, agentName, negMeta, clarification); if (mc.promptText() != null && !mc.promptText().isEmpty()) { - return advanceContext(agentName, negMeta, clarification, mc); + return A2ATContentFacade.toMetadata(mc); } throw new IllegalStateException("A2A-T SDK returned empty negotiation content"); } catch (Exception e) { @@ -509,17 +509,24 @@ private MetadataContent renderFollowUpMessage( String clarification) { net.openan.a2at.sdk.core.model.NegotiationContext contentCtx = extractContentContext(negMeta); + net.openan.a2at.sdk.core.model.TemplateUri endingTemplate = + endingTemplateFor(negMeta); String decision = clarification.strip().toLowerCase(java.util.Locale.ROOT); if (decision.startsWith("data:")) { // Deterministic fromData Accept: the JSON body carries the filled parameter map. - return renderAcceptFromData(content, agentName, contentCtx, clarification.substring("data:".length())); + return renderAcceptFromData( + content, + agentName, + contentCtx, + endingTemplate, + clarification.substring("data:".length())); } if (decision.startsWith("reject:")) { log.info("[Negotiation] SDK generateNegotiationRejectPrompt for '{}'", agentName); return content.generateRejectFromText( clarification.substring("reject:".length()).strip(), requireContext(contentCtx, agentName), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + endingTemplate); } if (decision.startsWith("abort:")) { log.info("[Negotiation] SDK generateNegotiationAbortPrompt for '{}'", agentName); @@ -531,7 +538,7 @@ private MetadataContent renderFollowUpMessage( content.generateAcceptFromText( clarification, requireContext(contentCtx, agentName), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + endingTemplate); log.info( "[Negotiation] SDK generateNegotiationAcceptPrompt: templateUri={}", mc.templateUri()); @@ -546,75 +553,115 @@ private MetadataContent renderAcceptFromData( A2ATContentFacade content, String agentName, net.openan.a2at.sdk.core.model.NegotiationContext contentCtx, + net.openan.a2at.sdk.core.model.TemplateUri endingTemplate, String jsonBody) { requireContext(contentCtx, agentName); try { - @SuppressWarnings("unchecked") - Map params = - new com.fasterxml.jackson.databind.ObjectMapper() - .readValue(jsonBody.strip(), Map.class); - java.util.List items = - new java.util.ArrayList<>(); - for (var entry : params.entrySet()) { - items.add( - new net.openan.a2at.sdk.negotiation.content.NegotiationItem( - entry.getKey(), String.valueOf(entry.getValue()))); - } + net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent endingContent = + buildAcceptEndingContent(endingTemplate, jsonBody); MetadataContent mc = content.generateAcceptFromData( new net.openan.a2at.sdk.negotiation.content.NegotiationEndingData( - contentCtx, - new net.openan.a2at.sdk.negotiation.content - .InformationEndingContent( - net.openan.a2at.sdk.negotiation.content - .NegotiationConclusion.ACCEPT, - items)), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); + contentCtx, endingContent), + endingTemplate); log.info( - "[Negotiation] SDK generateNegotiationAcceptPromptFromData: templateUri={}, items={}", + "[Negotiation] SDK generateNegotiationAcceptPromptFromData: templateUri={}, contentType={}", mc.templateUri(), - params.keySet()); + endingContent.getClass().getSimpleName()); return mc; } catch (Exception e) { throw new IllegalStateException("fromData Accept rendering failed: " + e.getMessage(), e); } } - /** - * Builds the follow-up metadata from the rendered message and, when the session context is - * available, advances the engine-held round so the follow-up carries the next-round (or - * terminal) context alongside the message. - * - *

        The content layer is stateless — session identity and round tracking stay with the - * caller (SDK guide §1.10). The engine owns the context: parses it from the propose - * metadata, advances via {@code nextRound()}, and re-serializes it for the wire. - */ - private Map advanceContext( - String agentName, - Map negMeta, - String clarification, - MetadataContent mc) { - Map meta = A2ATContentFacade.toMetadata(mc); - net.openan.a2at.sdk.core.model.NegotiationContext ctx = extractContentContext(negMeta); - if (ctx == null) { - return meta; + static net.openan.a2at.sdk.core.model.TemplateUri endingTemplateFor( + Map negotiationMetadata) { + Object raw = negotiationMetadata.get( + net.openan.a2at.sdk.core.model.MetadataContent.TEMPLATE_URI_METADATA_KEY); + net.openan.a2at.sdk.core.model.TemplateUri propose = + raw instanceof String text + ? net.openan.a2at.sdk.core.model.TemplateUri.parse(text).orElse(null) + : null; + if (StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.equals(propose)) { + return StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; } - String decision = clarification.strip().toLowerCase(java.util.Locale.ROOT); - boolean terminal = - decision.startsWith("reject:") || decision.startsWith("abort:"); - net.openan.a2at.sdk.core.model.NegotiationContext next = - terminal ? ctx : ctx.nextRound(); - meta.put( - net.openan.a2at.sdk.core.model.MetadataContent - .NEGOTIATION_CONTEXT_METADATA_KEY, - A2ATContentFacade.contextPayload(next)); - log.info( - "[Negotiation] Context advanced for '{}' id={} round={} -> {}", - agentName, - next.id(), - ctx.round(), - terminal ? "terminal" : next.round()); - return meta; + if (StandardTemplates.TARGET_NEGOTIATION_PROPOSE.equals(propose)) { + return StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; + } + if (StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.equals(propose)) { + return StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; + } + throw new IllegalArgumentException( + "Missing or unsupported Negotiation-T propose templateUri: " + raw); + } + + static net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent + buildAcceptEndingContent( + net.openan.a2at.sdk.core.model.TemplateUri endingTemplate, + String jsonBody) + throws com.fasterxml.jackson.core.JsonProcessingException { + Map data = parseNegotiationData(jsonBody); + var accept = net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.ACCEPT; + if (StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.equals(endingTemplate)) { + var items = data.entrySet().stream() + .map(entry -> new net.openan.a2at.sdk.negotiation.content.NegotiationItem( + entry.getKey(), entry.getValue())) + .toList(); + return new net.openan.a2at.sdk.negotiation.content.InformationEndingContent( + accept, items); + } + if (StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.equals(endingTemplate)) { + return new net.openan.a2at.sdk.negotiation.content.TargetEndingContent( + accept, requireOnlyField(data, "confirmedIntent"), null); + } + if (StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.equals(endingTemplate)) { + return new net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent( + accept, requireOnlyField(data, "feasibilitySummary")); + } + throw new IllegalArgumentException( + "Unsupported Negotiation-T ending template: " + endingTemplate.uri()); + } + + static java.util.List + parseNegotiationItems(String jsonBody) throws com.fasterxml.jackson.core.JsonProcessingException { + return parseNegotiationData(jsonBody).entrySet().stream() + .map(entry -> new net.openan.a2at.sdk.negotiation.content.NegotiationItem( + entry.getKey(), entry.getValue())) + .toList(); + } + + private static Map parseNegotiationData(String jsonBody) + throws com.fasterxml.jackson.core.JsonProcessingException { + if (jsonBody == null || jsonBody.isBlank()) { + throw new IllegalArgumentException("fromData Accept JSON must not be blank"); + } + @SuppressWarnings("unchecked") + Map params = + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(jsonBody.strip(), Map.class); + if (params.isEmpty()) { + throw new IllegalArgumentException( + "fromData Accept must contain at least one filled parameter"); + } + Map data = new java.util.LinkedHashMap<>(); + for (var entry : params.entrySet()) { + String name = entry.getKey() != null ? entry.getKey().strip() : ""; + String value = entry.getValue() instanceof String text ? text.strip() : ""; + if (name.isEmpty() || value.isEmpty() || "null".equalsIgnoreCase(value)) { + throw new IllegalArgumentException( + "fromData Accept items require non-blank string names and values"); + } + data.put(name, value); + } + return java.util.Collections.unmodifiableMap(data); + } + + private static String requireOnlyField(Map data, String field) { + if (data.size() != 1 || !data.containsKey(field)) { + throw new IllegalArgumentException( + "fromData Accept requires exactly the field '" + field + "'"); + } + return data.get(field); } /** 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 a9e09fad..d1fbeb70 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 @@ -21,6 +21,9 @@ import dev.openan.workflow.engine.model.SendMessageResult; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; + import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; @@ -40,24 +43,31 @@ public interface ExtensionSender { /** - * Send a one-shot independent extension message. + * Send a one-shot Authorization-T message. * * @param agentName target agent name * @param instruction short instruction text (becomes message parts) * @param naturalLanguageInput input for SDK prompt generation - * @param extension extension type (never hardcode URIs) + * @param templateUri current SDK template used to render the input + * @param extension must be {@link A2ATExtension#AUTHORIZATION_T}; Notification-T has a + * different, long-lived lifecycle and must use {@link #openNotification} */ CompletableFuture sendExtensionMessage( String agentName, String instruction, String naturalLanguageInput, + TemplateUri templateUri, A2ATExtension extension); - /** Natural-language compatibility convenience for a one-shot Authorization-T operation. */ + /** Natural-language variant for a one-shot Authorization-T operation. */ default CompletableFuture sendAuthorization( String agentName, String instruction, String naturalLanguageInput) { return sendExtensionMessage( - agentName, instruction, naturalLanguageInput, A2ATExtension.AUTHORIZATION_T); + agentName, + instruction, + naturalLanguageInput, + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT, + A2ATExtension.AUTHORIZATION_T); } /** @@ -72,8 +82,9 @@ default CompletableFuture sendAuthorization( * @param instruction short instruction text (becomes message parts) * @param data structured extension input (string-to-object map) * @param schema JSON schema describing the meaning of each data field - * @param extension extension type; only AUTHORIZATION_T and NOTIFICATION_T support - * fromData rendering here + * @param templateUri current SDK template used to render the data + * @param extension must be {@link A2ATExtension#AUTHORIZATION_T}; Notification-T must use + * {@link #openNotificationFromData} * @return future completing with the first response */ CompletableFuture sendExtensionMessageFromData( @@ -81,6 +92,7 @@ CompletableFuture sendExtensionMessageFromData( String instruction, Map data, Map schema, + TemplateUri templateUri, A2ATExtension extension); /** @@ -94,6 +106,7 @@ CompletableFuture sendNotificationFromData( String instruction, Map data, Map schema, + TemplateUri templateUri, Consumer> eventCallback); /** @@ -105,6 +118,7 @@ CompletableFuture openNotificationFromData( String instruction, Map data, Map schema, + TemplateUri templateUri, Consumer> eventCallback); /** @@ -118,6 +132,7 @@ CompletableFuture sendNotification( String agentName, String instruction, String naturalLanguageInput, + TemplateUri templateUri, Consumer> eventCallback); /** Opens a Notification-T subscription and returns an explicit close/health handle. */ @@ -125,11 +140,15 @@ CompletableFuture openNotification( String agentName, String instruction, String naturalLanguageInput, + TemplateUri templateUri, Consumer> eventCallback); - /** Natural-language compatibility convenience for a Notification-T subscription. */ + /** Natural-language variant for a Notification-T subscription. */ default CompletableFuture sendNotification( - String agentName, String instruction, String naturalLanguageInput) { - return sendNotification(agentName, instruction, naturalLanguageInput, null); + String agentName, + String instruction, + String naturalLanguageInput, + TemplateUri templateUri) { + return sendNotification(agentName, instruction, naturalLanguageInput, templateUri, null); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java index 5c24c753..9652663c 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java @@ -144,22 +144,15 @@ private static TemplateUri proposeTemplateFor(Map metadata) { Object raw = metadata.get( net.openan.a2at.sdk.core.model.MetadataContent.TEMPLATE_URI_METADATA_KEY); TemplateUri parsed = null; - if (raw instanceof TemplateUri template) { - parsed = template; - } else if (raw instanceof String text) { + if (raw instanceof String text) { parsed = TemplateUri.parse(text).orElse(null); - } else if (raw instanceof Map map - && map.get("extensionName") instanceof String extensionName - && map.get("pathSegments") instanceof java.util.List pathSegments - && map.get("templateVersion") instanceof String version) { - parsed = TemplateUri.of( - extensionName, - pathSegments.stream().map(String::valueOf).toList(), - version); } if (parsed == null - || !"Negotiation-T".equals(parsed.extensionName()) - || !parsed.pathSegments().contains("propose")) { + || !java.util.List.of( + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, + StandardTemplates.TARGET_NEGOTIATION_PROPOSE, + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE) + .contains(parsed)) { throw new IllegalArgumentException("Missing or invalid Negotiation-T propose templateUri"); } return parsed; diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java index 9c885733..ed35d520 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java @@ -200,16 +200,21 @@ private Map renderFromData( Map typedData = (Map) data; @SuppressWarnings("unchecked") Map typedSchema = (Map) schema; + if (templateUriStr == null || templateUriStr.isBlank()) { + throw new IllegalArgumentException("Structured Task-T template URI is required"); + } net.openan.a2at.sdk.core.model.TemplateUri templateUri = - templateUriStr != null - ? net.openan.a2at.sdk.core.model.TemplateUri.parse(templateUriStr) - .orElseThrow( - () -> - new IllegalArgumentException( - "Invalid Task-T template URI: " - + templateUriStr)) - : net.openan.a2at.sdk.core.model.StandardTemplates - .PRIVATE_LINE_COMPLAINT; + net.openan.a2at.sdk.core.model.TemplateUri.parse(templateUriStr) + .orElseThrow( + () -> + new IllegalArgumentException( + "Invalid Task-T template URI: " + + templateUriStr)); + if (!net.openan.a2at.sdk.core.model.StandardTemplates.TASK_EXTENSION_NAME.equals( + templateUri.extensionName())) { + throw new IllegalArgumentException( + "Structured task template is not Task-T: " + templateUri.uri()); + } net.openan.a2at.sdk.core.model.MetadataContent content = a2atClient.generateTaskPromptFromDataWithSchema( typedData, typedSchema, templateUri); 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 a792330c..555f03d7 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 @@ -83,7 +83,7 @@ default CompletableFuture sendMessage(String agentName, Strin * @param data structured task input (string-to-object map) * @param schema JSON schema describing the meaning of each data field * @param templateUri template the data renders through (e.g. {@code - * StandardTemplates.PRIVATE_LINE_COMPLAINT}); null uses the SDK default + * StandardTemplates.PRIVATE_LINE_COMPLAINT}); must not be null * @return future completing with response text, task, metadata, task state */ default CompletableFuture sendMessageFromData( @@ -109,15 +109,24 @@ default CompletableFuture sendMessageFromData( Map schema, net.openan.a2at.sdk.core.model.TemplateUri templateUri, Map extraMetadata) { + java.util.Objects.requireNonNull(data, "Task-T structured data is required"); + java.util.Objects.requireNonNull(schema, "Task-T data schema is required"); + java.util.Objects.requireNonNull(templateUri, "Task-T template URI is required"); + if (schema.isEmpty()) { + throw new IllegalArgumentException("Task-T data schema must not be empty"); + } + if (!net.openan.a2at.sdk.core.model.StandardTemplates.TASK_EXTENSION_NAME.equals( + templateUri.extensionName())) { + throw new IllegalArgumentException( + "Structured task template is not Task-T: " + templateUri.uri()); + } Map metadata = extraMetadata != null ? new java.util.LinkedHashMap<>(extraMetadata) : new java.util.LinkedHashMap<>(); metadata.put(A2ATExtension.TASK_DATA_META_KEY, data); metadata.put(A2ATExtension.TASK_SCHEMA_META_KEY, schema); - if (templateUri != null) { - metadata.put(A2ATExtension.TASK_TEMPLATE_META_KEY, templateUri.uri()); - } + metadata.put(A2ATExtension.TASK_TEMPLATE_META_KEY, templateUri.uri()); return sendMessage(agentName, message, null, metadata); } From caaf569bf58b4b55321ef91211ff4dc9d22eef53 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Wed, 26 Aug 2026 16:45:55 +0800 Subject: [PATCH 26/49] test(a2at): cover current protocol contracts Signed-off-by: Zhoujie628 --- .../engine/examples/SpnCrossCityE2ETest.java | 3 + .../extension/AuthorizationPolicyTest.java | 35 +++++--- .../PrePositionedExtensionHandlerTest.java | 43 +++++++-- .../testsupport/OfflineA2ATLlmClient.java | 4 +- .../workbench/WorkbenchTaskFlowTest.java | 25 +++++- .../engine/client/A2ATContentFacadeTest.java | 26 ++++++ .../engine/client/A2ATransportHeaderTest.java | 54 ++++++++++++ ...ltWorkflowEngineClientNegotiationTest.java | 88 +++++++++++++++++++ .../engine/client/ExtensionSenderTest.java | 77 +++++++++++++++- 9 files changed, 331 insertions(+), 24 deletions(-) create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClientNegotiationTest.java 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 3f0fcbca..d98fbabc 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 @@ -245,6 +245,8 @@ void fullBusinessPathDiagnosisRecoveryAndSelfLoopMerge() { "下发授权放行策略", SpnCasePrompts.addAuthorizationData(), SpnCasePrompts.authorizationSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates + .AUTHORIZATION_POLICY_MANAGEMENT, dev.openan.workflow.engine.client.A2ATExtension.AUTHORIZATION_T) .join(); } @@ -265,6 +267,7 @@ void fullBusinessPathDiagnosisRecoveryAndSelfLoopMerge() { "订阅业务抢通结果通知", SpnCasePrompts.subscribeServiceRecoveryData(), SpnCasePrompts.serviceRecoverySchema(), + net.openan.a2at.sdk.core.model.StandardTemplates.SERVICE_RECOVERY, notificationCallback) .join(); subscription.acknowledgement().join(); 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 2ca1f284..21a4a749 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 @@ -76,32 +76,39 @@ void malformedOrMissingRulesFailClosed() { } @Test - void sdkPermanentAndFullSevenFieldRulesAreSupported() { + void sdkCanonicalPermanentAndLimitedRulesAreSupported() { AuthorizationPolicy permanent = AuthorizationPolicy.fromValidated(Map.of( AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, AuthorizationPolicy.POLICY_LIST_FIELD, - "载波调度/业务抢通/载波调度/永久生效")); - AuthorizationPolicy full = AuthorizationPolicy.fromValidated(Map.of( + "载波调度,业务抢通,载波调度,永久生效")); + AuthorizationPolicy limited = AuthorizationPolicy.fromValidated(Map.of( AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, AuthorizationPolicy.POLICY_LIST_FIELD, - "policy-1/业务投诉诊断/业务抢通/隧道调优/" - + "2026-06-01T12:00:00Z~2030-06-18T12:00:00Z/" - + "2026-06-01T12:00:00Z/2026-06-18T12:00:00Z")); + "业务投诉诊断,业务抢通,隧道调优,2026-06-01~2030-06-18")); assertTrue(permanent.authorizes( "载波调度", "业务抢通", "载波调度", LocalDate.of(2099, 1, 1))); - assertTrue(full.authorizes( + assertTrue(limited.authorizes( "业务投诉诊断", "业务抢通", "隧道调优", LocalDate.of(2028, 1, 1))); } @Test - void sdkLimitedValidityWrapperIsSupported() { - AuthorizationPolicy policy = AuthorizationPolicy.fromValidated(Map.of( - AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, - AuthorizationPolicy.POLICY_LIST_FIELD, - "业务投诉诊断/业务抢通/隧道调优/限期生效(2026-06-01~2030-06-18)")); + 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"))); + } - assertTrue(policy.authorizes( - "业务投诉诊断", "业务抢通", "隧道调优", LocalDate.of(2028, 1, 1))); + @Test + void currentModifyShapeIsNotMisreadAsAnAddRule() { + assertThrows( + IllegalArgumentException.class, + () -> AuthorizationPolicy.fromValidated(Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.MODIFY, + AuthorizationPolicy.POLICY_LIST_FIELD, + "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,永久生效"))); } } 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 dccf4646..58d25362 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 @@ -18,6 +18,7 @@ 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; @@ -28,14 +29,18 @@ class PrePositionedExtensionHandlerTest { @Test - void sampleOverridesTheSdkValidatorPromptToMatchItsRenderedMessageContract() + 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( - "prompt_resources/prompts/content_validation/zh-CN/system.md")) { + resourcePath)) { assertNotNull(stream); String prompt = new String(stream.readAllBytes(), StandardCharsets.UTF_8); - assertTrue(prompt.contains("渲染器会有意删除")); - assertTrue(prompt.contains("无明确反证则通过")); + assertTrue(prompt.contains("输入内容的结构")); + assertTrue(prompt.contains("条目级检查")); } } @@ -52,12 +57,21 @@ void validationUsesTheCompleteSlotSchemaBundledByTheActiveSdk() { (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 @@ -66,7 +80,7 @@ void sdkExtractionMustExactlyMatchValuesInRenderedSections() { "## 授权策略的操作类型\n" + "新增授权策略\n\n" + "## 动网操作的授权策略列表\n" - + "业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18\n"; + + "业务投诉诊断,业务抢通,隧道调优,2026-06-01~2030-06-18\n"; Map exact = SpnCasePrompts.addAuthorizationData(); PrePositionedExtensionHandler.requireExtractedSectionsMatch( @@ -144,4 +158,23 @@ void deleteWithoutASelectorFailsClosedAndKeepsTheExistingWhitelist() { "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, + "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,永久生效"), + "template", + "test")); + assertSame(existing, handler.getAuthorizationPolicy()); + } } 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 45e021b5..f3429859 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 @@ -129,13 +129,13 @@ private static Map validationParams( private static Map authorizationData(String prompt) { Map data = new LinkedHashMap<>(); data.put("授权策略的操作类型", authorizationOperation(prompt)); - int ruleIndex = prompt.indexOf("业务投诉诊断/业务抢通/隧道调优/"); + int ruleIndex = prompt.indexOf("业务投诉诊断,业务抢通,隧道调优,"); int idIndex = prompt.indexOf("7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"); data.put( "动网操作的授权策略列表", idIndex >= 0 && (ruleIndex < 0 || idIndex < ruleIndex) ? "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3" - : "业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18"); + : "业务投诉诊断,业务抢通,隧道调优,2026-06-01~2030-06-18"); return data; } 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 b0b467e6..538db3ce 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 @@ -85,7 +85,11 @@ void missingOrBlankTaskTBusinessFieldsAreRejected() { () -> parser.parse( "task", - Map.of(A2ATExtension.TASK_T.uri(), "rendered prompt"))); + Map.of( + A2ATExtension.TASK_T.uri(), + "rendered prompt", + MetadataContent.TEMPLATE_URI_METADATA_KEY, + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()))); assertTrue(error.getMessage().contains("任务对象")); } @@ -99,6 +103,25 @@ void missingTaskTMetadataIsRejectedBeforeWorkflowSelection() { 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 = diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java index b84b4cb1..c98af8e1 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import net.openan.a2at.sdk.core.model.StandardTemplates; @@ -121,6 +122,31 @@ void rendersAcceptFromData() { assertTrue(mc.promptText().contains("Accept"), "accept must carry the Accept conclusion"); // The round travels in metadata, not in the rendered text. assertFalse(mc.promptText().contains("round: 1")); + Map metadata = A2ATContentFacade.toMetadata(mc); + assertEquals( + 1, + ((Map) + metadata.get( + net.openan.a2at.sdk.core.model.MetadataContent + .NEGOTIATION_CONTEXT_METADATA_KEY)) + .get("round"), + "an ending message must preserve the received propose round"); + } + + @Test + void rejectsFractionalOrOutOfRangeNegotiationContextNumbers() { + assertNull( + A2ATContentFacade.contextFromMap( + Map.of("id", "context", "round", 1.5, "maxRounds", 5))); + assertNull( + A2ATContentFacade.contextFromMap( + Map.of( + "id", + "context", + "round", + 1, + "maxRounds", + (long) Integer.MAX_VALUE + 1))); } @Test 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 37fe6eb9..f2cd77a7 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 @@ -277,6 +277,60 @@ void structuredTaskWithoutSdkFailsBeforeRuntimeInvocation() throws Exception { } } + @Test + void structuredTaskRequiresAnExplicitCurrentSdkTemplate() throws Exception { + AtomicInteger sendCalls = new AtomicInteger(); + try (A2ATransport transport = + new A2ATransport( + List.of(agentCard()), + new SendCountingRuntime(sendCalls), + WorkflowEngineClientConfig.builder().build())) { + NullPointerException error = + assertThrows( + NullPointerException.class, + () -> + new DefaultWorkflowEngineClient(transport) + .sendMessageFromData( + "Test Agent", + "diagnose", + Map.of("任务对象", "port-1"), + Map.of( + "type", + "object", + "properties", + Map.of()), + null)); + + assertTrue(error.getMessage().contains("template URI")); + assertEquals(0, sendCalls.get()); + } + } + + @Test + void structuredTaskRejectsTemplateFromAnotherExtensionFamily() throws Exception { + AtomicInteger sendCalls = new AtomicInteger(); + try (A2ATransport transport = + new A2ATransport( + List.of(agentCard()), + new SendCountingRuntime(sendCalls), + WorkflowEngineClientConfig.builder().build())) { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> + new DefaultWorkflowEngineClient(transport) + .sendMessageFromData( + "Test Agent", + "diagnose", + Map.of("任务对象", "port-1"), + Map.of("type", "object"), + StandardTemplates.SERVICE_RECOVERY)); + + assertTrue(error.getMessage().contains("not Task-T")); + assertEquals(0, sendCalls.get()); + } + } + private static AgentCard agentCard() throws Exception { String json = """ diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClientNegotiationTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClientNegotiationTest.java new file mode 100644 index 00000000..2b07b21e --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClientNegotiationTest.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +class DefaultWorkflowEngineClientNegotiationTest { + + @Test + void currentFromDataAcceptRequiresAtLeastOneConcreteStringItem() throws Exception { + var items = DefaultWorkflowEngineClient.parseNegotiationItems( + "{\"任务对象\":\"P781-17\"}"); + + assertEquals(1, items.size()); + assertEquals("任务对象", items.get(0).name()); + assertEquals("P781-17", items.get(0).value()); + assertThrows( + IllegalArgumentException.class, + () -> DefaultWorkflowEngineClient.parseNegotiationItems("{}")); + assertThrows( + IllegalArgumentException.class, + () -> DefaultWorkflowEngineClient.parseNegotiationItems( + "{\"任务对象\":null}")); + assertThrows( + IllegalArgumentException.class, + () -> DefaultWorkflowEngineClient.parseNegotiationItems( + "{\"任务对象\":\"null\"}")); + } + + @Test + void endingTemplateMatchesTheReceivedCurrentProposeType() { + assertEquals( + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, + DefaultWorkflowEngineClient.endingTemplateFor(Map.of( + MetadataContent.TEMPLATE_URI_METADATA_KEY, + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri()))); + assertEquals( + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, + DefaultWorkflowEngineClient.endingTemplateFor(Map.of( + MetadataContent.TEMPLATE_URI_METADATA_KEY, + StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri()))); + assertEquals( + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + DefaultWorkflowEngineClient.endingTemplateFor(Map.of( + MetadataContent.TEMPLATE_URI_METADATA_KEY, + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri()))); + assertThrows( + IllegalArgumentException.class, + () -> DefaultWorkflowEngineClient.endingTemplateFor(Map.of())); + } + + @Test + void targetAndFeasibilityFromDataAcceptUseTheirTypedContent() throws Exception { + TargetEndingContent target = + assertInstanceOf( + TargetEndingContent.class, + DefaultWorkflowEngineClient.buildAcceptEndingContent( + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, + "{\"confirmedIntent\":\"诊断指定专线\"}")); + FeasibilityEndingContent feasibility = + assertInstanceOf( + FeasibilityEndingContent.class, + DefaultWorkflowEngineClient.buildAcceptEndingContent( + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + "{\"feasibilitySummary\":\"资源与权限满足\"}")); + + assertEquals("诊断指定专线", target.confirmedIntent()); + assertEquals("资源与权限满足", feasibility.feasibilitySummary()); + assertThrows( + IllegalArgumentException.class, + () -> DefaultWorkflowEngineClient.buildAcceptEndingContent( + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, + "{\"任务对象\":\"P781\"}")); + } +} 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 4090831d..2038891e 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 @@ -16,6 +16,7 @@ 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.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; @@ -56,6 +57,7 @@ void notificationCallbackMethodIsAnInterfaceContract() throws Exception { String.class, String.class, String.class, + TemplateUri.class, java.util.function.Consumer.class); assertTrue(Modifier.isAbstract(method.getModifiers())); @@ -72,8 +74,9 @@ void authorizationFromDataUsesRealSdkMetadataRendering() throws Exception { "下发授权策略", Map.of( "授权策略的操作类型", "新增授权策略", - "动网操作的授权策略列表", "业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18"), + "动网操作的授权策略列表", "业务投诉诊断,业务抢通,隧道调优,2026-06-01~2030-06-18"), schema("授权策略的操作类型", "动网操作的授权策略列表"), + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT, A2ATExtension.AUTHORIZATION_T) .join(); @@ -103,6 +106,7 @@ void notificationFromDataReturnsLifecycleHandleAndSdkMetadata() throws Exception "订阅条件", "", "上报通知数据格式", "业务抢通方案执行状态、任务流水号、执行结果"), schema("订阅条件", "上报通知数据格式"), + StandardTemplates.SERVICE_RECOVERY, ignored -> {}) .join(); subscription.acknowledgement().join(); @@ -119,6 +123,75 @@ void notificationFromDataReturnsLifecycleHandleAndSdkMetadata() throws Exception } } + @Test + void notificationTemplateIsCallerSelectedFromTheCurrentSdkCatalog() throws Exception { + AtomicReference captured = new AtomicReference<>(); + A2ATransport transport = transport(captured); + try { + MetadataContent content = + new DefaultExtensionSender(transport) + .generateExtensionPrompt( + A2ATExtension.NOTIFICATION_T, + "订阅严重告警事件", + StandardTemplates.SUBSCRIBE_INCIDENT); + + assertNotNull(content); + assertEquals(StandardTemplates.SUBSCRIBE_INCIDENT.uri(), content.templateUri()); + } finally { + transport.close(); + } + } + + @Test + void notificationCannotUseTheOneShotAuthorizationApi() throws Exception { + AtomicReference captured = new AtomicReference<>(); + A2ATransport transport = transport(captured); + try { + CompletionException error = + assertThrows( + CompletionException.class, + () -> + new DefaultExtensionSender(transport) + .sendExtensionMessage( + "Test Agent", + "subscribe", + "订阅抢通结果", + StandardTemplates.SERVICE_RECOVERY, + A2ATExtension.NOTIFICATION_T) + .join()); + + assertTrue(error.getCause().getMessage().contains("openNotification")); + assertNull(captured.get()); + } finally { + transport.close(); + } + } + + @Test + void authorizationRejectsTemplateFromAnotherExtensionFamily() throws Exception { + AtomicReference captured = new AtomicReference<>(); + A2ATransport transport = transport(captured); + try { + CompletionException error = + assertThrows( + CompletionException.class, + () -> + new DefaultExtensionSender(transport) + .sendExtensionMessage( + "Test Agent", + "authorize", + "新增授权策略", + StandardTemplates.SERVICE_RECOVERY, + A2ATExtension.AUTHORIZATION_T) + .join()); + + assertTrue(error.getCause().getMessage().contains("does not belong")); + assertNull(captured.get()); + } finally { + transport.close(); + } + } + @Test void taskFromDataUsesRealSdkMetadataPipeline() throws Exception { AtomicReference captured = new AtomicReference<>(); @@ -291,7 +364,7 @@ private LLMResponse response(String content) { private static String slotValue(String name) { if (name.contains("操作类型")) return "新增授权策略"; if (name.contains("策略列表")) { - return "业务投诉诊断/业务抢通/隧道调优/2026-06-01~2030-06-18"; + return "业务投诉诊断,业务抢通,隧道调优,2026-06-01~2030-06-18"; } if (name.contains("订阅条件")) return "全部地市"; if (name.contains("上报通知数据格式")) return "执行状态、任务流水号、执行结果"; From 595886118434c00038efa63331196c587a46d54b Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Wed, 26 Aug 2026 16:46:26 +0800 Subject: [PATCH 27/49] docs(a2at): document latest-only protocol behavior Signed-off-by: Zhoujie628 --- README.md | 6 +-- docs/en/API_REFERENCE.md | 29 +++++++----- docs/en/DESIGN.md | 2 +- docs/zh/A2AT-SDK-DEPENDENCY.md | 44 ++++++++++--------- docs/zh/API_REFERENCE.md | 29 +++++++----- docs/zh/DESIGN.md | 2 +- .../a2a-t-sdk-negotiation-adaptation-plan.md | 17 ++++--- docs/zh/engine-code-review.md | 3 +- 8 files changed, 78 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 5e3eaa19..b81802c5 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,10 @@ This revision intentionally targets an A2A-T SDK build that has not yet been pub Central. Install the pinned SDK revision first (do not use an older locally cached `1.0.0`): ```bash -git clone https://github.com/Zhoujie628/a2a-t-sdk-java.git +git clone https://github.com/project-openan/a2a-t-sdk-java.git cd a2a-t-sdk-java -git checkout 0ef79d37f49a9b7a2dbe16b6d9fd1ccdb6d9538d -mvn -B -Drevision=1.0.0-0ef79d3 -DskipTests \ +git checkout 34485504675800a436d68cfaa913f14931086506 +mvn -B -Drevision=1.0.0-3448550 -DskipTests \ -pl a2a-t-client,a2a-t-server -am install ``` diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index 961d44d9..642e0723 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -117,7 +117,7 @@ After receiving: 2. Metadata extraction (task-level + artifact-level) 3. Negotiation-T auto-loop (triggered when metadata carries the Negotiation-T key; see "Negotiation auto-loop behavior" below) -#### sendMessageFromData (structured-data dual track) +#### sendMessageFromData (structured-data input path) ```java CompletableFuture sendMessageFromData( @@ -134,7 +134,7 @@ CompletableFuture sendMessageFromData( | `message` | `String` | Short accompanying message text (A2A message parts text) | | `data` | `Map` | Structured business data (raw fields, not a rendered prompt) | | `schema` | `Map` | JSON schema describing what each data field means | -| `templateUri` | `TemplateUri` | Target template; null uses the SDK default (PRIVATE_LINE_COMPLAINT)| +| `templateUri` | `TemplateUri` | Explicit current SDK target template; must not be null | **Choosing a track**: when the caller holds structured data (e.g. complaint-ticket fields) prefer this method — the SDK uses the schema-aware @@ -143,9 +143,11 @@ mapping may still invoke the SDK-configured LLM. With free-form natural-language input use `sendMessage` (the SDK scenario-recognition pipeline). Both tracks run through the full Negotiation-T auto-loop, auth, and extension-header injection. -`ExtensionSender.sendExtensionMessageFromData(agentName, instruction, data, schema, extension)` +`ExtensionSender.sendExtensionMessageFromData(agentName, instruction, data, schema, templateUri, extension)` provides the same structured-data track for independent Authorization-T operations; -`openNotificationFromData` is the preferred structured Notification-T subscription API. +`openNotificationFromData` is the preferred structured Notification-T subscription API. All +Notification-T entry points require an explicit SDK template so callers can select either +`SERVICE_RECOVERY` or `SUBSCRIBE_INCIDENT`. #### Negotiation auto-loop behavior @@ -155,7 +157,7 @@ provides the same structured-data track for independent Authorization-T operatio - **Each round**: `validateProposePromptAndDataFilling` validates and extracts the Propose → `ControlPoint.onNegotiation` decides → the SDK content layer renders Accept / Reject / Abort → Accept is checked with `validateAcceptPromptAndDataFilling` before sending → the engine copies - and advances the stateless `NegotiationContext` → send → recurse. + the ending message preserves the received Propose `NegotiationContext` and round → send → recurse. - **Clarification conventions** (the onNegotiation return value): - `data:{...json...}` — deterministic fromData Accept rendering (no LLM call; the JSON body is the filled-parameter map) @@ -164,8 +166,9 @@ provides the same structured-data track for independent Authorization-T operatio - **Round exhaustion**: beyond `maxNegotiationRounds` the loop stops, a terminal message is sent via the SDK abort template (best effort — delivery failures are logged only), a `NEGOTIATION_FAILED` event fires, and the last agent reply stands as the final response. -- **Negotiation context**: the latest SDK removed `startNegotiation`, `receiveNegotiation`, and - `continueNegotiation`. The engine accepts only canonical `negotiationContext` with +- **Negotiation context**: the engine does not support the legacy `startNegotiation`, + `receiveNegotiation`, or `continueNegotiation` state-machine entry points. It accepts only + canonical `negotiationContext` with `{id, round, maxRounds}` and does not read legacy `negotiation_context` metadata. #### Template queries @@ -193,7 +196,8 @@ generation and the Negotiation-T auto-loop; later Notification-T events are deli public interface ExtensionSender { CompletableFuture sendExtensionMessage( String agentName, String instruction, - String naturalLanguageInput, A2ATExtension extension); + String naturalLanguageInput, TemplateUri templateUri, + A2ATExtension extension); // Authorization-T only // Convenience: Authorization-T CompletableFuture sendAuthorization( @@ -201,12 +205,14 @@ public interface ExtensionSender { // Convenience: Notification-T (long-lived SSE) CompletableFuture sendNotification( - String agentName, String instruction, String naturalLanguageInput); + String agentName, String instruction, String naturalLanguageInput, + TemplateUri templateUri, Consumer> eventCallback); // Interface contract: Notification-T (long-lived SSE + event callback) CompletableFuture openNotification( String agentName, String instruction, - String naturalLanguageInput, Consumer> eventCallback); + String naturalLanguageInput, TemplateUri templateUri, + Consumer> eventCallback); } ``` @@ -215,11 +221,14 @@ public interface ExtensionSender { | `agentName` | `String` | Target agent name (must match `AgentCard.name`) | | `instruction` | `String` | Short instruction text; becomes `parts[].text` in the A2A message body | | `naturalLanguageInput` | `String` | Natural-language track input; production should prefer the SDK-rendered structured-data methods | +| `templateUri` | `TemplateUri` | Explicit current-SDK template (bundled or caller-provided resource) | | `extension` | `A2ATExtension` | Extension enum (never hardcode URIs) | | `eventCallback` | `Consumer>` | Optional SSE event callback. The stable Map includes `event_kind`, `agent`, `task_id` and event-specific state/artifact fields | `NotificationSubscription` separates the initial ACK (`acknowledgement()`) from stream completion (`completion()`), and exposes `heartbeat()`, `isHealthy(maximumIdle)`, and idempotent `close()`. +Notification-T is deliberately rejected by the generic one-shot methods; use +`openNotification` / `openNotificationFromData` so the lifecycle handle cannot be lost. Task, Authorization, and Notification must use different transport/runtime instances. Completing a workflow must not close a Notification-T subscription. diff --git a/docs/en/DESIGN.md b/docs/en/DESIGN.md index b35368eb..39a8a569 100644 --- a/docs/en/DESIGN.md +++ b/docs/en/DESIGN.md @@ -36,7 +36,7 @@ entry point. graph TD L2["Layer 2 - Orchestration
        execute_psop / ExecutePsop
        lifecycle, event stream, cancellation, onFinish persistence"] L1["Layer 1 - Traversal
        WorkflowExecutor
        DAG walk, parallel dispatch, context assembly, routing"] - L0["Layer 0 - Communication
        A2ATransport + two facades
        WorkflowEngineClient (workflow send) | ExtensionSender (one-shot)"] + L0["Layer 0 - Communication
        A2ATransport + two facades
        WorkflowEngineClient (workflow send) | ExtensionSender (independent operations)"] F["Foundation - Decision
        ControlPoint
        user-implemented business decisions"] L2 --> L1 --> L0 diff --git a/docs/zh/A2AT-SDK-DEPENDENCY.md b/docs/zh/A2AT-SDK-DEPENDENCY.md index 0c44319f..aaa7d248 100644 --- a/docs/zh/A2AT-SDK-DEPENDENCY.md +++ b/docs/zh/A2AT-SDK-DEPENDENCY.md @@ -1,15 +1,15 @@ # 未发布 A2A-T SDK 依赖说明 -当前执行引擎不是按“文档里写的旧接口”兼容,而是严格按下列 A2A-T SDK 源码版本编译和测试: +执行引擎首发版本只支持下列最新 A2A-T SDK 源码基线,不提供旧 SDK 兼容层: -- 仓库:`Zhoujie628/a2a-t-sdk-java` -- Git commit:`0ef79d37f49a9b7a2dbe16b6d9fd1ccdb6d9538d` -- 本地 Maven 版本:`1.0.0-0ef79d3`(精确提交构建的不可变本地版本) +- 仓库:`project-openan/a2a-t-sdk-java` +- Git commit:`34485504675800a436d68cfaa913f14931086506` +- 本地 Maven 版本:`1.0.0-3448550`(精确提交构建的不可变本地版本) - 使用组件:`a2a-t-client`、`a2a-t-server` 及其传递依赖 ## 已知上游生命周期缺口 -该固定提交中的 `A2ATClient`、`A2ATServer` 和 `LLMClient` 尚未实现 +该提交中的 `A2ATClient`、`A2ATServer` 和 `LLMClient` 尚未实现 `AutoCloseable`,因此调用过 OpenAI 兼容 LLM 后,底层 OpenAI Java 客户端的 `DefaultSleeper` 定时线程无法由工作流引擎通过公共 API 关闭。`SpringSpnDemo` 的 Task-T、Authorization-T、Notification-T 和直连业务链路均可正常完成, @@ -17,18 +17,18 @@ Task-T、Authorization-T、Notification-T 和直连业务链路均可正常完 上游 SDK 应在新提交中补充幂等 `close()`,由 `A2ATClient/A2ATServer` 级联关闭其拥有的 LLM provider;执行引擎届时应升级到该新提交对应的新 Maven 版本。禁止在不改变版本号的 -情况下覆盖 `1.0.0-0ef79d3`,否则 IDEA 与 CI 将得到不可复现的同坐标异内容制品。 +情况下覆盖 `1.0.0-3448550`,否则 IDEA 与 CI 将得到不可复现的同坐标异内容制品。 -该 SDK 尚未发布到 Maven Central。引擎刻意不再复用含义不明确的本地 `1.0.0`,以免 Maven -静默选中带有 `startNegotiation` 等废弃接口的旧 jar。 +该 SDK 尚未发布到 Maven Central。引擎不探测、不加载也不降级到其他 A2A-T SDK 版本; +本机与 CI 都必须安装这里锁定的提交派生坐标。 ## Windows / PowerShell 安装 ```powershell -git clone https://github.com/Zhoujie628/a2a-t-sdk-java.git +git clone https://github.com/project-openan/a2a-t-sdk-java.git Set-Location a2a-t-sdk-java -git checkout 0ef79d37f49a9b7a2dbe16b6d9fd1ccdb6d9538d -mvn -B "-Drevision=1.0.0-0ef79d3" -DskipTests ` +git checkout 34485504675800a436d68cfaa913f14931086506 +mvn -B "-Drevision=1.0.0-3448550" -DskipTests ` -pl a2a-t-client,a2a-t-server -am install ``` @@ -47,15 +47,19 @@ mvn -B clean verify 3. 本文及 README 的安装命令。 禁止只覆盖本地同版本 jar。新版本至少要验证 Task-T 生成/校验、无状态 Negotiation-T -metadata、Authorization-T、Notification-T 长连接,以及直连端到端路径。 +metadata、Authorization-T、Notification-T 长连接,以及两条现行传输路径: -## 当前内容校验兼容层 +1. `main` 验证直连 OMC 的 SpringSpnDemo 端到端流程; +2. `dev` 同时验证直连 OMC 和东信 Order 指令平台模拟器的 SpringSpnDemo 端到端流程。 -锁定版本的 `TaskPromptRenderer` 会把 slot 模板折叠为简洁自然语言,但 SDK 自带的 -`content_validation` 提示词仍要求输出复现完整模板结构;在当前示例模型上,这会使 SDK -官方 Authorization-T 成功样例也被误判为缺字段。samples 模块通过同名 classpath 资源 -覆盖 `prompt_resources/prompts/content_validation/{zh-CN,en-US}`,仍调用 SDK 的 -`validate*PromptAndDataFilling`,只把语义判定标准校准为 renderer 的真实输出契约。 +“只支持最新 A2A-T SDK”仅表示不兼容旧 SDK 版本和旧协议格式,绝不表示取消直连或 +Order 任一传输能力,也不允许省略双传输链路测试。 -升级 SDK 时必须先运行其官方 Authorization-T/Notification-T 样例;若上游已统一 renderer -与 validator,应删除该覆盖并执行本仓库全量测试及直连模式的端到端验证。 +## 单一资源基线 + +模板、slot schema、slot extraction 和 content validation 提示词全部来自锁定的 SDK jar。 +samples 不再用同名 classpath 资源覆盖 SDK 内容,也不保留旧 renderer/validator 的兼容提示词。 +测试会核对实际加载资源来自 `a2a-t-resources`,避免升级后仍静默运行旧规则。 + +Authorization-T 只接受当前 SDK 定义的规范数据形态:单条策略字段使用全角逗号 `,`, +多条策略使用全角分号 `;`。旧 `/` 分隔格式不再接受。 diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index 415c87dc..a758ecbc 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -116,7 +116,7 @@ public interface WorkflowEngineClient { 2. 提取 metadata(task 级 + artifact 级合并) 3. Negotiation-T 自动循环(metadata 携带 Negotiation-T key 时触发;见下方「协商自动循环行为」) -#### sendMessageFromData(结构化数据双轨) +#### sendMessageFromData(结构化数据输入路径) ```java CompletableFuture sendMessageFromData( @@ -133,17 +133,18 @@ CompletableFuture sendMessageFromData( | `message` | `String` | 伴随的短消息文本(A2A 消息 parts 文本) | | `data` | `Map` | 结构化业务数据(原始字段,非渲染后的提示词) | | `schema` | `Map` | JSON Schema,描述各数据字段的含义 | -| `templateUri` | `TemplateUri` | 目标模板;null 用 SDK 默认(PRIVATE_LINE_COMPLAINT) | +| `templateUri` | `TemplateUri` | 明确指定当前 SDK 目标模板;不得为 null | -**双轨选型**:业务方持有结构化数据(如投诉工单字段)时优先用本方法——SDK 走 +**输入方式选型**:业务方持有结构化数据(如投诉工单字段)时优先用本方法——SDK 走 `generateTaskPromptFromDataWithSchema` schema-aware 管线(跳过场景识别,但 slot 映射可能调用 SDK 配置的 LLM);持有自然语言 描述时用 `sendMessage`(SDK 场景识别管线)。两条轨道都完整经过 Negotiation-T 自动循环、 认证和扩展头注入。 -`ExtensionSender.sendExtensionMessageFromData(agentName, instruction, data, schema, extension)` +`ExtensionSender.sendExtensionMessageFromData(agentName, instruction, data, schema, templateUri, extension)` 为独立 Authorization-T 操作提供同样的结构化数据轨道;Notification-T 结构化订阅优先使用 -`openNotificationFromData`。 +`openNotificationFromData`。所有 Notification-T 入口都必须显式指定 SDK 模板,因此通用 +发送器同时支持 `SERVICE_RECOVERY` 与 `SUBSCRIBE_INCIDENT`,不再暗含 SPN 场景默认值。 #### 协商自动循环行为 @@ -153,15 +154,15 @@ SDK 配置的 LLM);持有自然语言 - **每轮**:`NegotiationTHandler` 用 `validateProposePromptAndDataFilling` 按 `templateUri` 校验 Propose 并抽取参数 → `ControlPoint.onNegotiation` 生成决策 → SDK 内容层渲染 Accept / Reject / Abort → Accept 在发出前再用 `validateAcceptPromptAndDataFilling` 校验 → - 引擎复制并递增无状态 `NegotiationContext` → 发送给 agent → 递归检查下一轮。 + 结束报文保留收到的 Propose `NegotiationContext` 及轮次 → 发送给 agent → 递归检查下一轮。 - **澄清文本约定**(onNegotiation 返回值): - `data:{...json...}` — 确定性 fromData 渲染 Accept(无 LLM 调用,JSON 为补充参数 map) - `reject:原因` / `abort:原因` — 渲染 Reject / Abort 终态模板 - 其他文本 — fromText 渲染 Accept(一次 LLM 抽取) - **轮次耗尽**:超过 `maxNegotiationRounds` 后不再继续循环,通过 SDK abort 模板发送终止消息 (尽力而为,发送失败仅记日志),发出 `NEGOTIATION_FAILED` 事件,agent 最后一次回复作为最终响应。 -- **协商上下文**:最新 SDK 已删除 `startNegotiation` / `receiveNegotiation` / - `continueNegotiation`。引擎只接受规范键 `negotiationContext`,结构固定为 +- **协商上下文**:执行引擎不支持 `startNegotiation` / `receiveNegotiation` / + `continueNegotiation` 旧状态机入口。引擎只接受规范键 `negotiationContext`,结构固定为 `{id, round, maxRounds}`;不再读取 `negotiation_context` 等旧键。 #### 模板查询 @@ -186,7 +187,8 @@ Task-T 提示词生成和 Negotiation-T 自动循环;Notification-T 后续事 public interface ExtensionSender { CompletableFuture sendExtensionMessage( String agentName, String instruction, - String naturalLanguageInput, A2ATExtension extension); + String naturalLanguageInput, TemplateUri templateUri, + A2ATExtension extension); // 仅 Authorization-T // 便捷方法:Authorization-T CompletableFuture sendAuthorization( @@ -194,12 +196,14 @@ public interface ExtensionSender { // 便捷方法:Notification-T(长连接 SSE) CompletableFuture sendNotification( - String agentName, String instruction, String naturalLanguageInput); + String agentName, String instruction, String naturalLanguageInput, + TemplateUri templateUri, Consumer> eventCallback); // 接口契约:Notification-T(长连接 SSE + 事件回调) CompletableFuture openNotification( String agentName, String instruction, - String naturalLanguageInput, Consumer> eventCallback); + String naturalLanguageInput, TemplateUri templateUri, + Consumer> eventCallback); } ``` @@ -208,11 +212,14 @@ public interface ExtensionSender { | `agentName` | `String` | 目标智能体名称(须匹配 `AgentCard.name`) | | `instruction` | `String` | 简短指令文本;成为 A2A 消息体的 `parts[].text` | | `naturalLanguageInput` | `String` | 自然语言轨道输入;生产优先使用 `sendExtensionMessageFromData` / `openNotificationFromData`,由 SDK 生成完整 metadata | +| `templateUri` | `TemplateUri` | 显式指定当前 SDK 模板(内置或集成方提供的资源) | | `extension` | `A2ATExtension` | 扩展枚举(勿硬编码 URI) | | `eventCallback` | `Consumer>` | 可选 SSE 事件回调。Map 含 `event_kind`、`agent`、`task_id` 及对应的状态/产物字段 | `NotificationSubscription` 将首个 ACK(`acknowledgement()`)与长连接终止 (`completion()`)分开,并提供 `heartbeat()`、`isHealthy(maximumIdle)` 和幂等 `close()`。 +通用一次性发送方法会明确拒绝 Notification-T;必须调用 +`openNotification` / `openNotificationFromData`,避免丢失长连接关闭句柄。 Task、Authorization、Notification 必须使用不同的 transport/runtime 实例;关闭单次工作流不得关闭订阅。 **报文格式**:发送给智能体的 A2A 消息结构为 `parts[].text = instruction`,`metadata = { "<扩展URI>": "<扩展值>" }`。以 Authorization-T 为例: diff --git a/docs/zh/DESIGN.md b/docs/zh/DESIGN.md index fe1a6c9a..cfcb6e50 100644 --- a/docs/zh/DESIGN.md +++ b/docs/zh/DESIGN.md @@ -33,7 +33,7 @@ SDK 分为四层,每层构建在下一层之上,单一职责,入口清晰 graph TD L2["Layer 2 - 编排层
        ExecutePsop
        生命周期、事件流、取消、onFinish 持久化"] L1["Layer 1 - 遍历层
        WorkflowExecutor
        DAG 遍历、并行下发、上下文组装、路由"] - L0["Layer 0 - 通信层
        A2ATransport + 两个门面
        WorkflowEngineClient(工作流发送)| ExtensionSender(一次性前置下发)"] + L0["Layer 0 - 通信层
        A2ATransport + 两个门面
        WorkflowEngineClient(工作流发送)| ExtensionSender(独立协议操作)"] F["基础层 - 决策
        ControlPoint
        用户实现的业务决策"] L2 --> L1 --> L0 diff --git a/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md b/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md index 0346232b..0dde68c1 100644 --- a/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md +++ b/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md @@ -1,12 +1,12 @@ # A2A-T SDK Negotiation-T 适配说明 -> 状态:已按 2026-08-25 锁定的 SDK 版本完成。本文只描述当前实现,不保留旧版迁移方案。 +> 状态:已按 2026-08-26 锁定的 SDK 版本完成。本文只描述当前实现,不保留旧版迁移方案。 ## 版本基线 -- 仓库:`Zhoujie628/a2a-t-sdk-java` -- commit:`0ef79d37f49a9b7a2dbe16b6d9fd1ccdb6d9538d` -- Maven 版本:`1.0.0-0ef79d3` +- 仓库:`project-openan/a2a-t-sdk-java` +- commit:`34485504675800a436d68cfaa913f14931086506` +- Maven 版本:`1.0.0-3448550` - 引擎入口:`A2ATContentFacade`、`NegotiationTHandler`、`DefaultWorkflowEngineClient` - Agent 入口:`NegotiationBaseAgentExecutor` @@ -25,7 +25,8 @@ - `validateAndFillingProposeData/AcceptData/RejectData` - `getNegotiationPrompts/getNegotiationPrompt` -旧协商运行时状态机接口已从 SDK 删除,引擎不兼容、不反射调用、不降级回该类接口。 +旧协商运行时状态机接口不属于执行引擎支持边界。引擎不调用、不反射探测,也不降级到 +`startNegotiation`、`receiveNegotiation` 或 `continueNegotiation`。 ## 协商上下文与 metadata @@ -64,8 +65,10 @@ 2. OMC 用 typed propose data 渲染 prompt,在 A2A `INPUT_REQUIRED` 事件中返回 SDK metadata。 3. `NegotiationTHandler` 校验 URI、模板、上下文和 prompt,将已填充参数交给集成方的 `ControlPoint.onNegotiation` 作业务决策。 -4. 引擎把决策映射为 typed accept/reject/abort,推进 `round`,通过同一 A2A task/context - 发送 follow-up。 +4. 引擎根据收到的 Propose 类型映射为对应 Information/Target/Feasibility 的 typed + accept/reject,或映射为 common abort;ending 保留收到的 `negotiationContext` 与当前 + `round`,通过同一 A2A task/context 发送 follow-up。只有发起新一轮 Propose 的一方 + 才调用 `nextRound()`。 5. 轮次耗尽、明确拒绝或校验失败时终止,不使用手工 prompt 或原文 metadata 降级。 ## 失败策略 diff --git a/docs/zh/engine-code-review.md b/docs/zh/engine-code-review.md index fd305ee8..ec828011 100644 --- a/docs/zh/engine-code-review.md +++ b/docs/zh/engine-code-review.md @@ -712,7 +712,8 @@ CompletableFuture sendNotification( 3. **`ExtensionInterceptor`**: 只注入当前消息 metadata 中实际存在的扩展 URI,而非 AgentCard 上声明的所有扩展——正确行为 4. **`NegotiationTHandler.afterReceive`**: 使用最新 SDK 的无状态 `validateProposePromptAndDataFilling` 校验提议内容并提取参数;缺少或非法 - `negotiationContext` 时失败关闭,不再调用已删除的 `receiveNegotiation`,也不保留绕过 SDK 的提取 fallback + `negotiationContext` 时失败关闭;执行引擎不调用旧状态机入口 + `receiveNegotiation`,也不保留绕过 SDK 的提取 fallback 5. **`DefaultWorkflowEngineClient.autoNegotiate`**: 递归协商循环有 `maxNegotiationRounds` 上限(默认 3),防止无限循环 6. **`SslContextFactory` mTLS 支持**: `loadKeyManagers` 支持客户端证书 + 私钥,RSA 密钥格式正确 7. **`CredentialCrypto` AES-GCM**: 使用 12 字节 IV + 128 位 tag,符合 NIST 推荐;IV 使用 `SecureRandom` 生成,无 IV 重用风险 From b0638995ab0a0aff05eda7a0f93afb276485dff6 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Wed, 26 Aug 2026 17:33:33 +0800 Subject: [PATCH 28/49] fix(samples): restore protocol diagnostics Signed-off-by: Zhoujie628 --- docs/en/INTEGRATION_GUIDE.md | 12 +++++++----- docs/zh/INTEGRATION_GUIDE.md | 11 ++++++----- samples/src/main/resources/log4j2.properties | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index 0d701e20..7f60e77e 100644 --- a/docs/en/INTEGRATION_GUIDE.md +++ b/docs/en/INTEGRATION_GUIDE.md @@ -485,13 +485,15 @@ so mTLS and `crlPath` cannot be combined with that mode and fail fast instead of ## 9. Logging -The dedicated `PROTOCOL` logger emits protocol requests and responses. Bodies are enabled by default and truncated to a configurable size. Sensitive headers such as Authorization, cookies, API keys, tokens, and secrets are redacted by default. Configure the logger in `log4j2.properties`: +The dedicated `PROTOCOL` logger emits protocol request/response summaries. Bodies are disabled by +default. Sensitive headers such as Authorization, cookies, API keys, tokens, and secrets are +redacted by default. Enable DEBUG and bodies only in a controlled integration environment: ```properties -logger.PROTOCOL.name=PROTOCOL -logger.PROTOCOL.level=info -logger.PROTOCOL.additivity=false -logger.PROTOCOL.appenderRef=console +logger.protocol.name=PROTOCOL +logger.protocol.level=DEBUG +# Reuse the root console/file appenders; do not set false without a valid appenderRef +logger.protocol.additivity=true ``` Control the content with environment variables or same-named JVM system properties: diff --git a/docs/zh/INTEGRATION_GUIDE.md b/docs/zh/INTEGRATION_GUIDE.md index 535ebe54..28569ae7 100644 --- a/docs/zh/INTEGRATION_GUIDE.md +++ b/docs/zh/INTEGRATION_GUIDE.md @@ -475,13 +475,14 @@ HTTP/JSON-RPC 的 TLS 策略只作用于当前客户端,不修改 JVM 全局 ## 9. 日志 -引擎设有专用 `PROTOCOL` 日志器输出协议层请求/响应。Body 默认输出并按长度截断;`Authorization`、Cookie、API Key、Token、Secret 等敏感 Header 默认脱敏。在 `log4j2.properties` 中配置: +引擎设有专用 `PROTOCOL` 日志器输出协议层请求/响应摘要。Body 默认禁用; +`Authorization`、Cookie、API Key、Token、Secret 等敏感 Header 默认脱敏。只能在受控联调环境临时开启 DEBUG 和 body: ```properties -logger.PROTOCOL.name=PROTOCOL -logger.PROTOCOL.level=info -logger.PROTOCOL.additivity=false -logger.PROTOCOL.appenderRef=console +logger.protocol.name=PROTOCOL +logger.protocol.level=DEBUG +# 复用 root 的 console/file appender;不要在没有正确 appenderRef 时设为 false +logger.protocol.additivity=true ``` 以下环境变量或同名 JVM system property 控制内容: diff --git a/samples/src/main/resources/log4j2.properties b/samples/src/main/resources/log4j2.properties index 0ef096b2..57074708 100644 --- a/samples/src/main/resources/log4j2.properties +++ b/samples/src/main/resources/log4j2.properties @@ -33,7 +33,7 @@ logger.a2a.name=org.a2aproject logger.a2a.level=WARN # Protocol-level message dumps: full request/response bodies + headers logger.protocol.name=PROTOCOL -logger.protocol.level=INFO +logger.protocol.level=DEBUG # Root logger rootLogger.level=INFO rootLogger.appenderRef.console.ref=Console From c3c8ef4446026a529ea9d4dfcca6f78c0b3c582a Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Wed, 26 Aug 2026 17:33:42 +0800 Subject: [PATCH 29/49] docs(api): complete extension lifecycle signatures Signed-off-by: Zhoujie628 --- docs/en/API_REFERENCE.md | 11 +++++++++++ docs/zh/API_REFERENCE.md | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index 642e0723..082d741b 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -203,6 +203,11 @@ public interface ExtensionSender { CompletableFuture sendAuthorization( String agentName, String instruction, String naturalLanguageInput); + CompletableFuture sendExtensionMessageFromData( + String agentName, String instruction, + Map data, Map schema, + TemplateUri templateUri, A2ATExtension extension); + // Convenience: Notification-T (long-lived SSE) CompletableFuture sendNotification( String agentName, String instruction, String naturalLanguageInput, @@ -213,6 +218,12 @@ public interface ExtensionSender { String agentName, String instruction, String naturalLanguageInput, TemplateUri templateUri, Consumer> eventCallback); + + CompletableFuture openNotificationFromData( + String agentName, String instruction, + Map data, Map schema, + TemplateUri templateUri, + Consumer> eventCallback); } ``` diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index a758ecbc..b6a55d60 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -194,6 +194,11 @@ public interface ExtensionSender { CompletableFuture sendAuthorization( String agentName, String instruction, String naturalLanguageInput); + CompletableFuture sendExtensionMessageFromData( + String agentName, String instruction, + Map data, Map schema, + TemplateUri templateUri, A2ATExtension extension); + // 便捷方法:Notification-T(长连接 SSE) CompletableFuture sendNotification( String agentName, String instruction, String naturalLanguageInput, @@ -204,6 +209,12 @@ public interface ExtensionSender { String agentName, String instruction, String naturalLanguageInput, TemplateUri templateUri, Consumer> eventCallback); + + CompletableFuture openNotificationFromData( + String agentName, String instruction, + Map data, Map schema, + TemplateUri templateUri, + Consumer> eventCallback); } ``` From cdbd76462055464ef30977678d11b12d2393e113 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Wed, 26 Aug 2026 17:33:51 +0800 Subject: [PATCH 30/49] docs: remove superseded review artifacts Signed-off-by: Zhoujie628 --- README.md | 4 - docs/en/notification-t-design-analysis.md | 322 -------- .../a2a-t-sdk-negotiation-adaptation-plan.md | 86 -- docs/zh/engine-code-review-response.md | 400 ---------- docs/zh/engine-code-review-second-check.md | 548 ------------- docs/zh/engine-code-review.md | 746 ------------------ docs/zh/notification-t-design-analysis.md | 143 ---- ...03\347\224\250\350\277\207\347\250\213.md" | 92 --- 8 files changed, 2341 deletions(-) delete mode 100644 docs/en/notification-t-design-analysis.md delete mode 100644 docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md delete mode 100644 docs/zh/engine-code-review-response.md delete mode 100644 docs/zh/engine-code-review-second-check.md delete mode 100644 docs/zh/engine-code-review.md delete mode 100644 docs/zh/notification-t-design-analysis.md delete mode 100644 "docs/zh/\350\260\203\347\224\250\350\277\207\347\250\213.md" diff --git a/README.md b/README.md index b81802c5..a8b2a688 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,6 @@ graph TD - [API Reference](docs/en/API_REFERENCE.md) - Public interface and class documentation - [Design Document](docs/en/DESIGN.md) - Architecture, module structure, design decisions - [Developer Guide](docs/en/DEVELOPER_GUIDE.md) - Internal architecture, contribution, debugging -- [Notification-T Design Analysis](docs/en/notification-t-design-analysis.md) - Long-lived SSE design ### 中文 @@ -185,9 +184,6 @@ graph TD - [API 参考](docs/zh/API_REFERENCE.md) - 公共接口和类文档 - [架构设计](docs/zh/DESIGN.md) - 架构、模块结构、设计决策 - [开发者指南](docs/zh/DEVELOPER_GUIDE.md) - 内部架构、贡献、调试 -- [Notification-T 设计分析](docs/zh/notification-t-design-analysis.md) - 长连接 SSE 设计选型 -- [业务流](docs/zh/业务流.md) - SPN 跨城诊断业务流程 -- [调用过程](docs/zh/调用过程.md) - 端到端报文交互示例 - [工作台集成指南](docs/zh/工作台集成指南.md) - 工作台 Spring Boot 集成完整指南 ## Modules diff --git a/docs/en/notification-t-design-analysis.md b/docs/en/notification-t-design-analysis.md deleted file mode 100644 index d0ba748e..00000000 --- a/docs/en/notification-t-design-analysis.md +++ /dev/null @@ -1,322 +0,0 @@ -# Notification-T / Authorization-T Design Analysis - -> **Superseded.** This historical analysis has been folded into -> [DESIGN.md](DESIGN.md), sections 4 (A2A-T Extension Model) and 7 -> (Interaction Sequences). The pre-positioning/shared-channel wording below is obsolete: -> Authorization-T and Notification-T now use `ExtensionSender` at independent business times, -> with transport/runtime/context instances isolated from Task-T and from each other. Kept for -> historical reference only. - ---- - -# Notification-T / Authorization-T Design Analysis - -## Current Implementation (as of 2026-07-27) - -### Pre-positioning: Three Independent One-Shot A2A Calls - -The engine currently sends Authorization-T, Notification-T, and Task-T as three completely separate A2A message/send -calls. Each opens its own HTTP connection (SSE stream), gets a response, and closes. - -``` - -```mermaid -sequenceDiagram - participant W as Workbench - participant S as SPN Agent - - W->>S: 1. sendAuthorization (message/send) - Note right of S: detect Authorization-T, store policy - S-->>W: ack artifact + COMPLETED - W->>S: 2. sendNotification (message/send) - Note right of S: detect Notification-T, store subscription - S-->>W: ack artifact + COMPLETED - W->>S: 3. Task-T diagnosis (message/stream) - S-->>W: INPUT_REQUIRED (negotiation) - W->>S: negotiation reply - Note right of S: diagnosis -> selfTriggerRecovery() - Note right of S: check whitelist -> execute recovery - S-->>W: artifact (recovery result) - S-->>W: COMPLETED (stream closes) -``` - -### Key Code Locations - -- sendExtensionMessage: DefaultExtensionSender.java - - Generates metadata value via A2ATClient.generateTaskPrompt () (LLM + template) - - Falls back to raw natural-language input if SDK unavailable - - Calls doSendViaA2ARuntime () -> supplyAsync -> a2aClientRuntime.sendMessage () - - Bypasses Task-T prompt generation and Negotiation-T auto-loop - -- SPN Agent pre-positioning handler: NegotiationBaseAgentExecutor.java - - detectPrePositionedExtension (): scans message metadata for - "Authorization-T" or "Notification-T" keywords - - handlePrePositionedExtension (): stores payload text in volatile field, sends ack artifact + TASK_STATE_COMPLETED, - execute () returns immediately - - authorizationPolicy: volatile String, stored on first Authorization-T receipt - - notificationSubscription: volatile String, stored on first Notification-T receipt - -- SPN Agent recovery: SpnDomainAgentCity1Executor.java selfTriggerRecovery () - - getAuthorizationPolicy () to read stored whitelist policy - - Matches policy keywords ("business recovery", "optical module", "authorization") - - If matched: execute recovery, emit artifact with Notification-T URI in metadata - - If not matched: emit refusal artifact with Notification-T URI in metadata - -### Facts - -1. Authorization-T is a static store-and-read. Policy text stored in memory, read later during Task-T diagnosis via - getAuthorizationPolicy (). Works. - -2. Notification-T subscription text is stored but NEVER READ. getNotificationSubscription () exists but is not called - anywhere in samples code. The stored subscription text has no functional use. - -3. Recovery result comes back through Task-T SSE stream, NOT through a separate Notification-T channel. - selfTriggerRecovery () emits an artifact with Notification-T URI in artifact metadata, but this artifact is part of - the Task-T SSE response (executeBusiness has not returned yet, execute () - has not returned yet, the same SSE stream is still open). - -4. No long-lived connections. All three SSE streams follow open->receive->close pattern. Notification-T pre-positioning - stream is especially short-lived: - sends ack and closes immediately. - -5. All three calls are independent. They share state only through the SPN Agent's in-memory volatile fields - (authorizationPolicy, notificationSubscription). No persistent channel between them. - -## Problem Statement - -Notification-T should be a real subscription channel, not a static one-shot message. The recovery result should flow -back through the Notification-T channel (the SSE stream opened during subscription), not piggyback on the Task-T stream. - -Currently: - -- Notification-T subscription = send ack, close stream (no channel created) -- Recovery result = piggyback on Task-T stream artifact metadata - -Desired: - -- Notification-T subscription = keep SSE stream open (channel created) -- Recovery result = push through the open Notification-T SSE stream - -## Proposed Design: Long-Lived SSE for Notification-T - -### Concept - -The workbench (client) sends a Notification-T subscription via message/stream. The SPN Agent keeps the SSE response -stream open. When recovery completes, the SPN Agent pushes the result through this stream. The client cancels by -disconnecting. - -``` - -```mermaid -sequenceDiagram - participant W as Workbench - participant S as SPN Agent - - W->>S: Notification-T subscribe (message/stream, SSE) - S-->>W: "subscribed" status update - Note over S: execute() blocks, stream stays open - W->>S: Task-T diagnosis (separate connection) - Note right of S: diagnosis -> recovery -> signal queue - S-->>W: COMPLETED (Task-T stream closes) - S-->>W: SSE event: recovery result (via Notification-T stream) - W->>S: close connection (cancel subscription) -``` - -### Authentication - -Forward only. The workbench authenticates to the SPN Agent when opening the SSE stream (existing bearerAuth). The SPN -Agent writes events to the response stream it already has open. No reverse HTTP call, no reverse authentication. - -This eliminates the webhook reverse-call authentication problem entirely. - -### Implementation Requirements - -SPN Agent side (NegotiationBaseAgentExecutor): - -- handlePrePositionedExtension () Notification-T branch: do NOT call emitter.complete () or return. Instead: - 1. Send "subscribed" status update (TASK_STATE_WORKING) - 2. Register this emitter/stream as the notification target - 3. Block on a queue/CompletableFuture (keep SSE stream open) - 4. When recovery completes (signaled from Task-T execute () in another thread), push a MessageEvent or - TaskUpdateEvent with recovery result + Notification-T metadata - 5. Continue blocking until client disconnects or sends cancel - -- selfTriggerRecovery () in SpnDomainAgentCity1Executor: instead of (or in addition to) emitting artifact on Task-T - stream, signal the Notification-T stream to push the recovery result - -- Thread management: two concurrent execute () calls on the same executor instance (Notification-T subscription + Task-T - diagnosis). The A2A server must run them on separate threads. - -Workbench/client side (DefaultWorkflowEngineClient): - -- sendNotification () must NOT .join () and close. It must keep the SSE reader running in background, feeding events to - a queue/callback. -- The workflow merge step must be able to wait for Notification-T events from the queue (with timeout). - -Authorization-T: no change needed. Static store-and-read works fine. - -### Open Questions (to align with user before implementing) - -1. Does the A2A SDK (a2a-java-sdk) support keeping an SSE response stream open and pushing events to it later, after the - initial message is processed? The current EmbeddedA2AServer / AgentEmitter model assumes execute () returns to close - the stream. - -2. How does the SPN Agent correlate the Notification-T subscription stream with the Task-T diagnosis stream? Same - contextId? Same agent instance? A subscription registry? - -3. What happens if the Notification-T SSE connection drops (network issue)? Is the subscription lost? Does the workbench - need to reconnect? - -4. Should the workflow merge step block-wait for the Notification-T result, or should it proceed with diagnosis results - only and handle recovery results separately? - -5. Timeout: if recovery never completes, how long does the Notification-T stream stay open? - -6. Does the A2A-T SDK (a2a-t-sdk-java) have built-in support for long-lived Notification-T subscriptions, or do we need - to implement this ourselves? - -## Authorization-T: No Change Needed - -Authorization-T is a one-way static policy push: - -- Workbench sends policy text -> SPN Agent stores in memory -- During Task-T diagnosis, SPN Agent reads stored policy for whitelist check -- No response channel needed through Authorization-T - -This pattern is correct and requires no modification. - -## File Locations - -- Engine client: workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java -- SPN agent base: samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationBaseAgentExecutor.java -- SPN agent city1: samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java -- SPN agent city2: samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java -- Workbench control: samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchControlPoint.java -- Demo entry: samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemo.java -- E2E test: samples/src/test/java/dev/openan/workflow/engine/examples/SpnCrossCityE2ETest.java -- AgentCard city1: samples/src/main/resources/agentcard/spn_domain_agent_city1.json -- AgentCard city2: samples/src/main/resources/agentcard/spn_domain_agent_city2.json -- AgentCard workbench: samples/src/main/resources/agentcard/transport_workbench_agent.json -- Credentials: samples/src/main/resources/spn_agent_credentials.json -- Workflow JSON: orchestration-center/data/workflow_storage/psop/psop_spn_cross_city_diagnosis.json (separate repo) -- Protocol examples: docs/zh/调用过程.md - -## Business Flow Definition (from business-flow.md / yewuliu.md) - -### Four A2A-T Interfaces - -| # | Extension | Direction | Purpose | -|---|-----------------|--------------------------------------------------|----------------------------------------------------------| -| 1 | Task-T | Workbench -> SPN | Diagnosis request + diagnosis result return | -| 2 | Negotiation-T | SPN <-> Workbench (bidirectional, SPN initiates) | Parameter correction when OMC finds missing/wrong params | -| 3 | Notification-T | Workbench subscribes -> SPN pushes via SSE | Recovery result subscription + push reporting | -| 4 | Authorization-T | Workbench -> SPN | Whitelist authorization policy (static, one-way) | - -### Role Clarification (confirmed with user 2026-07-27) - -- Workbench IS an A2A-T Server (receives Task-T from upper layer) -- Upper layer -> Workbench: simple Task-T, NO negotiation -- Negotiation happens ONLY between Workbench and SPN Domain Agent -- Negotiation is CONDITIONAL: SPN initiates only when it finds missing/wrong parameters, not every time - -### Complete Business Flow - -``` - -```mermaid -sequenceDiagram - participant U as Upper layer - participant W as Workbench - participant S1 as SPN Agent City1 - participant S2 as SPN Agent City2 - - U->>W: Task-T diagnosis - par - W->>S1: Auth-T pre-position - W->>S1: Notif-T pre-position (SSE stays open) - and - W->>S2: Auth-T pre-position - W->>S2: Notif-T pre-position (SSE stays open) - end - par - W->>S1: Task-T diagnosis - and - W->>S2: Task-T diagnosis - end - alt params missing/wrong - S1-->>W: Negotiation-T (conditional) - W->>S1: negotiation reply - end - S1-->>W: diagnosis result - S2-->>W: diagnosis result - Note over S1: check whitelist -> auto recovery - S1-->>W: recovery result (via Notif-T SSE) - S2-->>W: recovery result (or no result) - Note over W: SelfLoop: merge - W-->>U: merged result -``` - -### Key Design Decisions (confirmed) - -1. Workbench = A2A Server (receives upper layer Task-T) -2. Upper layer -> Workbench: NO negotiation (just send task, get result) -3. Negotiation: Workbench <-> SPN, SPN initiates, CONDITIONAL (not every time) -4. Notification-T: long-lived SSE stream (workbench opens, SPN keeps open, pushes recovery result when done) -5. Authorization-T: static one-way push (no change from current) - -## Current Demo Complexity Issues - -### Problem 1: Unnecessary A2A indirection (Workbench server + workflow runner) - -Current: Demo sends Task-T to Workbench Agent (A2A server) -> Workbench Agent receives task -> Workbench internally runs -workflow -> workflow sends Task-T to SPN agents. - -The Workbench correctly plays both server (upper layer) and client (SPN agents) roles. But the -workbench executor mixes "receive task" -and "run workflow" in one execute () call, making the flow hard to follow. - -Simplification: Keep the dual role but make it cleaner: - -- execute () receives the upper layer task -- Pre-positions Auth-T + Notif-T to SPN agents -- Runs the workflow (ExecutePsop) -- Waits for recovery results from Notif-T streams -- Merges and returns - -### Problem 2: Negotiation forced every time - -Current: NegotiationBaseAgentExecutor.handleNewTask () ALWAYS sends INPUT_REQUIRED, even when parameters are fine. - -Business flow says: negotiation is conditional - SPN initiates ONLY when it finds missing/wrong parameters. - -Simplification: SPN agent checks parameters first. If valid, proceed to diagnosis directly. If invalid/missing, send -INPUT_REQUIRED. - -### Problem 3: Recovery result piggybacks on Task-T stream - -Current: selfTriggerRecovery () emits artifact on the Task-T SSE stream. - -Business flow says: recovery result goes through the Notification-T SSE stream (the one opened during subscription). - -Simplification: Implement long-lived Notification-T SSE (see above). - -### Problem 4: Pre-positioning not in Workbench executor - -Current: Auth-T and Notif-T pre-positioning is done by the E2E test or demo code, not by the Workbench executor itself. - -Business flow says: Workbench pre-positions before diagnosis. - -Simplification: Workbench executor handles pre-positioning internally before starting the workflow. - -### Problem 5: Agent executor inheritance chain too deep - -Current: -AgentExecutor (SDK interface) --> BaseAgentExecutor (extractText, buildStatusMessage) --> NegotiationBaseAgentExecutor (negotiation, pre-positioning, business) --> SpnDomainAgentCity1Executor -> SpnDomainAgentCity2Executor -> (workbench executor) - -Three levels of inheritance makes it hard to trace the flow. - -Simplification: Flatten where possible. Or at minimum, make the flow in each class readable without jumping through -three files. diff --git a/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md b/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md deleted file mode 100644 index 0dde68c1..00000000 --- a/docs/zh/a2a-t-sdk-negotiation-adaptation-plan.md +++ /dev/null @@ -1,86 +0,0 @@ -# A2A-T SDK Negotiation-T 适配说明 - -> 状态:已按 2026-08-26 锁定的 SDK 版本完成。本文只描述当前实现,不保留旧版迁移方案。 - -## 版本基线 - -- 仓库:`project-openan/a2a-t-sdk-java` -- commit:`34485504675800a436d68cfaa913f14931086506` -- Maven 版本:`1.0.0-3448550` -- 引擎入口:`A2ATContentFacade`、`NegotiationTHandler`、`DefaultWorkflowEngineClient` -- Agent 入口:`NegotiationBaseAgentExecutor` - -该 SDK 尚未发布到 Maven Central。引擎 POM 和 CI 同时锁定 commit 与由该提交派生的 -不可变版本,避免本地旧 `1.0.0` jar 被静默解析。安装与升级方法见 -[A2A-T SDK 依赖说明](A2AT-SDK-DEPENDENCY.md)。 - -## 当前 API 边界 - -最新 SDK 的 Negotiation-T 是无状态内容 API。引擎使用: - -- `generateNegotiationProposePromptFromData/FromText` -- `generateNegotiationAcceptPromptFromData/FromText` -- `generateNegotiationRejectPromptFromData/FromText` -- `generateNegotiationAbortPromptFromData` -- `validateAndFillingProposeData/AcceptData/RejectData` -- `getNegotiationPrompts/getNegotiationPrompt` - -旧协商运行时状态机接口不属于执行引擎支持边界。引擎不调用、不反射探测,也不降级到 -`startNegotiation`、`receiveNegotiation` 或 `continueNegotiation`。 - -## 协商上下文与 metadata - -每次协商由引擎持有 `NegotiationContext`: - -```json -{ - "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - "round": 1, - "maxRounds": 5 -} -``` - -线上 metadata 必须同时包含: - -- 规范 Negotiation-T URI: - `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1` -- SDK 生成的 `templateUri` -- 规范 `negotiationContext` - -上下文不再嵌入 prompt 正文。接收端会检查 UUID、轮次与最大轮次,检查 -`templateUri` 是否属于对应 propose/accept/reject 阶段,再调用 SDK 校验与参数填充。 - -## 内容生成与 LLM - -- Negotiation-T 类型化 `fromData` 直接接收 SDK record,是确定性模板渲染,不调用 LLM。 -- Negotiation-T `fromText` 需要 SDK 配置的 LLM 进行结构化抽取。 -- `validateAndFilling*` 包含确定性规则门、LLM 语义校验/参数抽取和确定性合并。 -- Task-T、Authorization-T、Notification-T 的 schema-aware `fromData` 会跳过场景识别, - 但 slot 映射可能使用 LLM;不应宣称为“全程无 LLM”。 - -## 端到端流程 - -1. OMC 对 Task-T 进行 SDK 校验与填充。仅缺失或业务语义错误进入 Negotiation-T; - LLM/配置/网络等基础设施错误不得伪装成业务协商。 -2. OMC 用 typed propose data 渲染 prompt,在 A2A `INPUT_REQUIRED` 事件中返回 SDK metadata。 -3. `NegotiationTHandler` 校验 URI、模板、上下文和 prompt,将已填充参数交给集成方的 - `ControlPoint.onNegotiation` 作业务决策。 -4. 引擎根据收到的 Propose 类型映射为对应 Information/Target/Feasibility 的 typed - accept/reject,或映射为 common abort;ending 保留收到的 `negotiationContext` 与当前 - `round`,通过同一 A2A task/context 发送 follow-up。只有发起新一轮 Propose 的一方 - 才调用 `nextRound()`。 -5. 轮次耗尽、明确拒绝或校验失败时终止,不使用手工 prompt 或原文 metadata 降级。 - -## 失败策略 - -协议内容统一 fail-closed。如果 SDK 客户端未配置、渲染失败、模板 URI 不匹配、 -`negotiationContext` 非法或语义校验拒绝,引擎会显式失败,不把普通文本冒充为 -Negotiation-T 内容。 - -## 验证基线 - -- `A2ATContentFacadeTest`:真实 SDK typed propose/accept/reject/abort 渲染和 metadata 形状。 -- `SpnCrossCityE2ETest`:真实 SDK Task-T/Negotiation-T/Authorization-T/Notification-T, - 两地市并行诊断、单次汇总、白名单抢通和通知关闭。 -- `EmbeddedA2AServerTest`:HTTPS + SSE 真实网络路径。 -- SDK 不可用、模板错误、校验拒绝和通知失败 ACK 的反向用例。 diff --git a/docs/zh/engine-code-review-response.md b/docs/zh/engine-code-review-response.md deleted file mode 100644 index d350d05b..00000000 --- a/docs/zh/engine-code-review-response.md +++ /dev/null @@ -1,400 +0,0 @@ -# 引擎代码检视——二次审核回应与最终共识 - -> **归档状态(2026-08-25)**:本文只记录旧 commit 上的评审共识,不是当前代码或协议说明。 -> 当前状态以 [CHANGELOG](../../CHANGELOG.md)、[API 参考](API_REFERENCE.md) 和 [架构设计](DESIGN.md) 为准。 - -> **回应对象**: `docs/zh/engine-code-review-second-check.md` -> **回应日期**: 2026-08-18 -> **回应人**: 原检视方 - ---- - -## 1. 总体回应 - -二次审核质量很高,纠正了原报告 5 处实质性错误: - -1. **P0-4 SSE JSON**:我对 `trim()` 的行为判断错误,接受降级 -2. **P1-1 扩展 prompt**:我对 SDK 能力的描述不准确,接受降级 -3. **P1-2 线程池修复代码**:`LinkedBlockingQueue` 无界导致 `maximumPoolSize` 不生效,接受修正 -4. **P1-4 consumption timeout**:我把 SDK 参数含义解释错了,接受修正 -5. **P1-5 A2AProperties**:把东信配置放入通用 starter 违反模块边界,接受否决 -6. **P1-6 50ms 轮询**:我把正常路径的性能影响当成了主要问题,实际真正问题是静默跳过未标记失败 -7. **P2-3 URI 匹配修复**:取最后路径段得到 `v1` 而非 `Task-T`,接受修正 -8. **P2-5 成功判断修复**:`text 非空 AND COMPLETED` 会拒绝 artifact-only 响应,接受修正 - -同时确认二次审核新发现的 6 个遗漏问题全部成立。 - -以下逐条回应。 - ---- - -## 2. 逐条回应 - -### 2.1 P0-1:全局禁用主机名验证 → 共识 P0 - -**审核纠正**: "仅使用 trust-all SSLContext、不关闭主机名验证即可满足开发环境"的表述不准确——trust-all 只跳过证书链可信性,不自动允许 SAN/CN 不匹配。 - -**回应**: 接受纠正。trust-all `TrustManager` 的 `checkServerTrusted` 不检查证书链,但 JDK `HttpClient` 的主机名验证是独立于 `TrustManager` 的另一层,由 `jdk.internal.httpclient.disableHostnameVerification` 控制。两层验证混淆了。 - -**最终共识**: -1. 保留 P0 -2. 默认构造函数 `DefaultA2AJavaClientRuntime()` 的 `sslVerify=false` 改为 `true` -3. 通用库不得在运行时修改 JVM 全局 TLS 属性 -4. 开发环境如需不安全模式,通过隔离的传输实现提供,打印安全告警 - ---- - -### 2.2 P0-2:AgentCredentialService trust-all → 共识 P0 - -**回应**: 无异议。保留 P0。 - -最终整改方向: -1. 将 TLS/HttpClient 作为显式依赖注入 `AgentCredentialService` -2. Token 登录默认严格校验证书和主机名 -3. 自签证书通过 CA 信任配置解决 - ---- - -### 2.3 P0-3:Notification-T 超时吞没错误 → 共识 P1 高 - -**审核纠正**: 建议从 P0 降为 P1 高优先级,因为只影响 Notification-T 订阅能力,不是发布阻断项。 - -**回应**: 接受降级。Notification-T 不在核心工作流执行路径上,它只影响工作台的恢复通知订阅。P1 高优先级合理。 - -**最终共识**: -1. 降为 P1 高 -2. `exceptionally` 中解包 `CompletionException` 后,仅对 `TimeoutException` 采用"暂未收到 ACK"兼容行为 -3. 其他错误必须异常完成 -4. 5 秒等待值支持配置 - ---- - -### 2.4 P0-4:SSE compact JSON → 共识:降为 P2 - -**审核纠正**: `lines().map(String::trim)` 只删除每行两端的缩进,不会删除 JSON 字符串值内的空白。JSON 字符串中的换行必须转义(`\n`),不能跨物理行。 - -**回应**: 接受。我重新验证了: - -``` -pretty-printed JSON: -{ - "text": " value " -} - -lines() → ["{", " \"text\": \" value \"", "}"] -trim() → ["{", "\"text\": \" value \"", "}"] -concat → {"text": " value "} -``` - -`trim()` 作用于物理行,只去缩进,值内空白在引号内保留。原报告的"破坏值内空白"判断不成立。 - -**最终共识**: -1. 降为 P2(性能/实现简化) -2. `reduce("", String::concat)` 的 O(n²) 拷贝是次要问题 -3. 可优先使用 protobuf printer 的紧凑输出能力 -4. `\r\n` vs `\n` 是平台差异,SSE 规范允许两者 - ---- - -### 2.5 P1-1:三个扩展 prompt 生成器 → 共识:降为低优先级/路线图 - -**审核纠正**: 交叉核验 `a2a-t-sdk-java` 后,`A2ATClient` 只公开 `generateTaskPrompt(Object userInput)`。不存在其他三个 generate 方法。原报告"SDK 能力未利用"不成立。 - -**回应**: 接受。我未核实 SDK 实际 API,臆测了方法存在。当前实现不是"未利用 SDK 能力",而是"SDK 尚未提供对应能力"。 - -**最终共识**: -1. 降为低优先级/路线图项 -2. 可删除三个空桩方法,由分派逻辑直接回退 -3. 或保留方法并添加 TODO + 预期 SDK 契约 -4. 在 SDK 提供对应方法前不定为 P1 - ---- - -### 2.6 P1-2:无界 CachedThreadPool → 共识 P1(修复代码需修正) - -**审核纠正**: 原报告的 `new LinkedBlockingQueue<>()` 无容量上限,任务进入无界队列后线程池通常只保留 corePoolSize 个线程,`maximumPoolSize` 和 `CallerRunsPolicy` 基本不生效。 - -**回应**: 接受。这是 `ThreadPoolExecutor` 的已知行为——`LinkedBlockingQueue` 无界时,`maximumPoolSize` 永远不会触发。我的修复代码是错误的。 - -**最终共识**: -1. 保留 P1 -2. 使用有容量的 `ArrayBlockingQueue` 或 `new LinkedBlockingQueue<>(capacity)` -3. 配置 core、max、queue capacity、keep-alive 和拒绝策略 -4. 将 Executor 作为可注入依赖,明确所有权和关闭责任 -5. 长生命周期 Notification-T 订阅与短请求发送使用不同资源模型 - ---- - -### 2.7 P1-3:每次 send 创建新 Client → 共识 P1 - -**审核补充**: a2a-java `Client` Javadoc 声明实例线程安全,可以并发使用。缓存可行。 - -**回应**: 接受补充。SDK 线程安全确认后,缓存方案可行。原报告的"确认 SDK 是否线程安全"待办项已有答案。 - -**最终共识**: -1. 保留 P1 -2. 按 Agent 接口 URL、协议绑定、TLS 配置缓存 Client/Transport -3. AgentCard 或接口配置变化时更新缓存 -4. `close()` 中统一关闭缓存资源 -5. 增加并发发送、AgentCard 更新和关闭时竞态测试 - ---- - -### 2.8 P1-4:AutoConfiguration 参数硬编码 → 共识 P1(原因解释修正) - -**审核纠正**: `consumption timeout=5s` 不是"SSE 相邻事件间隔超时",而是阻塞调用中等待事件消费和持久化完成的超时。 - -**回应**: 接受。我未核实 SDK 定义,按字面推测了含义。 - -**最终共识**: -1. 保留 P1 -2. 在 `A2AProperties` 中增加服务端 blocking timeout、executor core/max/queue capacity 配置 -3. 保留当前默认值以兼容上游 SDK -4. 不依据错误的 SSE 间隔解释扩大默认值 -5. 增加配置绑定测试 - ---- - -### 2.9 P1-5:A2AProperties 缺少工作台/网关配置 → 共识:原方向否决,治理保留 - -**审核纠正**: 把 `a2a.order.host` 等东信特有配置放入通用 `spring-boot-starter` 的 `A2AProperties` 会造成通用模块依赖具体厂商适配,违背依赖倒置和模块边界。 - -**回应**: 完全接受。我的方案会污染通用 starter。 - -**最终共识**: -1. "配置散落需要治理"保留,但"全部加入 A2AProperties"否决 -2. 通用服务端参数放 `A2AProperties` -3. 通用出站引擎参数放新建 `WorkflowEngineClientProperties` -4. 东信参数放 samples 或独立 `OrderGatewayProperties` -5. 用专用 `@ConfigurationProperties` 替换 sample 中散落的 `@Value` - ---- - -### 2.10 P1-6:50ms sleep 轮询 → 共识:重写为正确性问题 - -**审核纠正**: 正常执行路径会等待 `allOf()` 完成,十层依赖链不会每层额外 50ms。真正的问题是:步骤被 deferred 超过 `workflow.getSteps().size()` 次后静默加入 `executed` 并跳过,但工作流仍可能报告 `success=true`。 - -**回应**: 接受。我重新审视代码: - -```java -int dc = deferCount.getOrDefault(idx, 0) + 1; -if (dc > workflow.getSteps().size()) { - log.warn("Step {} deferred too many times, skipping", step.getName()); - executed.add(idx); // 标记为已执行 - // 但没有设置 failed[0] = true! -} -``` - -跳过的步骤不会产生 `stepOutputs`,依赖它的后续步骤也会被跳过。但 `failed[0]` 没有被设置,最终 `ExecutionResult.success` 仍为 `true`。这是一个正确性 bug。 - -**最终共识**: -1. 重写为正确性问题,保留 P1 -2. pending 非空但没有 ready step 且没有正在执行的 step 时,识别为依赖死锁 -3. 输出未满足的前置步骤列表 -4. 将工作流标记为失败 -5. 工作流加载阶段增加缺失依赖和环检测 -6. 删除 50ms 轮询逻辑 - ---- - -### 2.11 P1-7 / P2-6:sendNotification 默认方法丢回调 → 共识:合并为 P2 - -**回应**: 接受合并。P1-7 和 P2-6 描述的是同一问题。 - -**最终共识**: -1. 合并为一个 P2 -2. 将带 callback 的 `sendNotification` 改为非 default 抽象方法 -3. 不带 callback 的便利方法继续 default 传 null -4. 增加接口契约测试 - ---- - -### 2.12 P2-1:解密失败返回密文 → 共识:提升为 P1 - -**审核纠正**: 建议提升为 P1。 - -**回应**: 接受。带 `enc:` 前缀的数据无法解密时返回密文,会导致后续认证以密文作为密码,远端返回 401 掩盖根因。这是 fail-open 行为,生产环境不应允许。 - -**最终共识**: -1. 提升为 P1 -2. `enc:` 前缀数据无法解密时抛出明确异常 -3. 异常信息包含配置位置和失败类型,不含密文/密钥/明文 -4. 应用启动阶段预校验加密凭证 - ---- - -### 2.13 P2-2:setHttpClient 空操作 → 共识 P2 - -**回应**: 无异议。保留 P2。直接删除方法和误导性日志。 - ---- - -### 2.14 P2-3:contains() 匹配 → 共识 P2(修复方案修正) - -**审核纠正**: 原修复建议取 URI 最后一个路径段,但 `https://.../Task-T/v1` 的最后路径段是 `v1`,不是 `Task-T`。 - -**回应**: 接受。我的修复方案确实会破坏现有匹配。 - -**最终共识**: -1. 保留 P2 -2. 使用 `URI` 解析路径 -3. 对路径段进行精确、大小写不敏感匹配 -4. 明确定义扩展名和版本段规则 -5. 增加 `Task-T-Extended`、非法 URI、query/fragment 测试 - ---- - -### 2.15 P2-4:手动调用 ExtensionInterceptor → 共识 P2 - -**审核补充**: 不能简单迁移到 a2a-java Client interceptor,因为 `A2ATransport` 同时服务直连 Runtime 和东信 Gateway Runtime,自定义 Runtime 也需要拿到最终 Header。 - -**回应**: 接受。迁移到 SDK interceptor 链会绕过自定义 Runtime 的 Header 消费。 - -**最终共识**: -1. 保留 P2 -2. 抽出与具体 SDK 无关的 `HeaderContributor` 或 `ClientCallContextFactory` -3. 认证、扩展分别实现 Header 贡献逻辑 -4. Runtime 只消费构建完成的 `ClientCallContext` -5. required 扩展构建失败时 fail-fast,optional 允许降级 -6. 清理当前未执行的 auth interceptor 构建逻辑 - ---- - -### 2.16 P2-5:空文本判断成功 → 共识:提升为 P1 - -**审核纠正**: 原修复建议 `text 非空 AND state COMPLETED` 会错误拒绝 artifact-only 的成功响应。 - -**回应**: 接受。Task COMPLETED 但只返回 artifact 不返回文本时,`r.getText()` 为空,我的条件会判失败。 - -**最终共识**: -1. 提升为 P1 -2. 有明确状态时以状态为准:COMPLETED 为成功,FAILED/CANCELED/REJECTED 为失败 -3. INPUT_REQUIRED/AUTH_REQUIRED 不作为普通任务成功 -4. 只有无状态时才回退到文本/内容判断 -5. 后续可增加 artifact 到 `TaskResponse` 的映射 - ---- - -## 3. 遗漏问题回应 - -### 3.1 caCertsPath 和 mTLS 未接入默认运行链路 → 接受,P1 - -**回应**: 这是原报告最重要的遗漏。`DefaultA2AJavaClientRuntime.createHttpClient()` 确实没有调用 `SslContextFactory.create(verifyServer, caCertsPath, ...)`。`caCertsPath` 被接收、保存、打印但从未使用。mTLS 代码是死代码。 - -原报告附录"mTLS 支持已确认良好"是过度结论——只验证了辅助代码存在,没有验证引擎能力可用。 - -**最终共识**: P1。统一构建 HTTP/JSONRPC/gRPC 的 TLS 配置,补充自定义 CA、mTLS 集成测试,TLS 配置错误 fail-fast。 - -### 3.2 DefaultA2AJavaClientRuntime.close() 不关闭线程池 → 接受,P1 - -**回应**: `httpClientExecutor` 是 Runtime 持有的 `ExecutorService`,`close()` 只打印日志不 shutdown。资源泄漏确认。 - -**最终共识**: P1。Runtime 自建 Executor 必须在 `close()` 中关闭。增加重复 close、发送中 close、关闭后拒绝新请求测试。 - -### 3.3 PROTOCOL 日志明文输出敏感 Header → 接受,P1/P2 - -**回应**: `ProtocolLogger` 输出完整 Header 包括 `Authorization: Bearer ...`。独立 logger 便于联调是合理设计,但生产环境需要脱敏。`A2ATransport` 中已有 `WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS` 环境变量控制,但只作用于 `OrderGatewayClientRuntime` 路径,引擎核心的 `ProtocolLogger` 没有脱敏逻辑。 - -**最终共识**: P1。默认脱敏 Authorization/Cookie/Proxy-Authorization 和已知 API Key Header,提供显式开关,启用时输出安全告警。 - -### 3.4 form-urlencoded 未编码 → 接受,P2 - -**回应**: `AgentCredentialService.login()` 中 `form.append(e.getKey()).append("=").append(e.getValue())` 确实没有 URL encode。包含 `&`、`=`、`+`、`%`、空格、中文的值会破坏请求语义。 - -**最终共识**: P2。按 UTF-8 对 key 和 value 分别执行 form URL encoding,增加特殊字符测试。 - -### 3.5 AuthProvider 与 credentials 同名 Header 冲突 → 接受,P2 - -**回应**: `buildClientCallContext()` 中 `authProvider.applyAuth()` 先写 Header,`applyAuthHeaders()` 后写,后者覆盖前者。部分集成文档说"互不覆盖"与 Map 实际行为不一致。 - -**最终共识**: P2。推荐二选一,或检测同名 Header fail-fast,或明确并测试覆盖优先级,修正文档。 - -### 3.6 SslContextFactory.create() fail-open → 接受,P2 - -**回应**: `SslContextFactory.create()` 在加载 CA/证书异常时 `return Optional.empty()`,日志 "Falling back to no verification"。虽然当前默认 Runtime 没有调用此方法,但公共方法契约不应 fail-open。 - -**最终共识**: P2。配置存在但加载失败时抛出异常,只有调用方显式设置不安全模式时才允许关闭验证。 - ---- - -## 4. 附录修正 - -### mTLS - -**原结论**: "SslContextFactory.loadKeyManagers() 的实现可以作为正面基础,但默认 Runtime 没有接入,不能直接得出'引擎已经支持 mTLS'的结论。" - -**回应**: 接受。原报告附录第 6 条"SslContextFactory mTLS 支持:loadKeyManagers 支持客户端证书+私钥,RSA 密钥格式正确"应改写为:辅助代码存在但未接入运行链路,引擎 mTLS 能力不可用。 - -### CredentialCrypto - -**原结论**: "'无 IV 重用风险'是过度绝对的表达。解密失败返回密文使整体凭证处理不满足 fail-safe。" - -**回应**: 接受。AES-GCM 算法选择正确,`SecureRandom` 生成的 12 字节 IV 碰撞概率极低但非零。解密失败返回密文的 fail-open 行为是更严重的问题(已提升为 P1)。附录结论应改写为:算法选择合理,但 fail-open 行为使整体凭证处理不满足 fail-safe。 - -### ProtocolLogger - -**原结论**: "'无需修改'不成立:必须补充敏感信息控制、默认脱敏和日志治理说明。" - -**回应**: 接受。原报告附录第 8 条"ProtocolLogger:独立 logger 名,INDENT_OUTPUT 已启用,与引擎其他日志可独立控制级别"应补充:"但未脱敏敏感 Header,生产环境需补充"。 - ---- - -## 5. 最终整改优先级共识 - -### 第一批:安全和结果正确性(P0/P1 高) - -| # | 问题 | 优先级 | 整改要点 | -|---|------|--------|---------| -| 1 | JVM 全局禁用主机名验证 | P0 | 移除全局属性修改,默认 `sslVerify=true` | -| 2 | AgentCredentialService trust-all | P0 | TLS/HttpClient 显式注入,登录默认严格校验 | -| 3 | Notification-T 超时吞没真实错误 | P1 高 | 仅 TimeoutException 走兼容路径,其他异常完成 | -| 4 | CredentialCrypto 解密失败返回密文 | P1 | `enc:` 前缀无法解密时抛异常,启动预校验 | -| 5 | DefaultControlPoint 空文本判断成功 | P1 | 以 Task 状态为准,无状态才回退文本判断 | -| 6 | WorkflowExecutor 静默跳过未满足依赖 | P1 | 依赖死锁时标记失败,加载阶段环检测 | -| 7 | caCertsPath/mTLS 未接入默认运行链路 | P1 | 统一 TLS 配置构建,补充 mTLS 集成测试 | -| 8 | Runtime.close() 不关闭线程池 | P1 | 自建 Executor 在 close() 中 shutdown | -| 9 | PROTOCOL 日志明文输出敏感 Header | P1 | 默认脱敏,显式开关 | - -### 第二批:资源和生产可配置性(P1) - -| # | 问题 | 整改要点 | -|---|------|---------| -| 10 | 无界 CachedThreadPool | 有界 `ArrayBlockingQueue`,可配置,Executor 注入 | -| 11 | 每次 send 新建 Client | 按 URL+协议+TLS 缓存 Client,close() 统一关闭 | -| 12 | AutoConfig 参数硬编码 | A2AProperties 增加 timeout/pool/queue 配置 | -| 13 | 配置散落 @Value | 分层 Properties:通用/引擎/厂商各自独立 | - -### 第三批:接口和维护性(P2) - -| # | 问题 | 整改要点 | -|---|------|---------| -| 14 | sendNotification 接口丢回调 | 带回调版本改为抽象方法 | -| 15 | setHttpClient 空操作 | 删除方法和误导日志 | -| 16 | Header 构建/interceptor 抽象 | 抽出 HeaderContributor,清理冗余 auth interceptor | -| 17 | contains() 匹配扩展 URI | URI 解析+路径段精确匹配 | -| 18 | form-urlencoded 未编码 | UTF-8 form URL encode | -| 19 | AuthProvider/credentials Header 冲突 | 二选一或 fail-fast 或明确优先级 | -| 20 | SslContextFactory.create() fail-open | 配置加载失败抛异常 | -| 21 | SSE compact JSON | 改用 protobuf printer 紧凑输出,消除 O(n²) | - ---- - -## 6. 待第三方重点复核项 - -同意二次审核第 6 节的 6 个复核项,补充以下 2 项: - -7. **JDK HttpClient 主机名验证**: 确认 JDK 11-21 是否有 per-client 的主机名验证 API(非全局系统属性) -8. **a2a-java Client 生命周期**: 确认 `Client.close()` 是否关闭底层连接池,缓存的 Client 在 AgentCard 更新时是否需要重建 - ---- - -## 7. 总结 - -二次审核纠正了原报告 8 处实质错误,补充了 6 个遗漏问题。最终共识: - -- **P0**: 2 项(全局主机名验证、登录 trust-all) -- **P1 高**: 7 项(Notification-T 超时、CredentialCrypto fail-open、成功判断、静默跳过依赖、mTLS 未接入、线程池泄漏、协议日志脱敏) -- **P1**: 4 项(无界线程池、Client 缓存、参数硬编码、配置散落) -- **P2**: 8 项(接口契约、空操作、Header 抽象、URI 匹配、form 编码、Header 冲突、fail-open、SSE JSON) - -原报告删除重复项 P2-6,P0-4 降为 P2,P1-1 降为路线图项。 diff --git a/docs/zh/engine-code-review-second-check.md b/docs/zh/engine-code-review-second-check.md deleted file mode 100644 index 5ff2e882..00000000 --- a/docs/zh/engine-code-review-second-check.md +++ /dev/null @@ -1,548 +0,0 @@ -# 引擎核心代码检视报告——二次审核意见 - -> **归档状态(2026-08-25)**:本文只记录旧 commit 的审核过程。已采纳项的当前实现与验证结果见 -> [CHANGELOG](../../CHANGELOG.md)、[API 参考](API_REFERENCE.md) 和 [架构设计](DESIGN.md);下文不是当前接口契约。 - -> **审核对象**: `docs/zh/engine-code-review.md` -> **审核范围**: `workflow-engine`、`spring-boot-starter`,并交叉核验同目录下的 `a2a-java`、`a2a-t-sdk-java` -> **审核日期**: 2026-08-18 -> **代码基线**: `dev` 分支 `39118ca` -> **说明**: 本文是对原检视报告的二次审核,不替代正式安全测试、压力测试和端到端联调结论。 - ---- - -## 1. 总体结论 - -原检视报告方向总体合理,但不能原样作为整改清单使用,主要存在以下问题: - -1. 部分问题定级过高,例如 SSE JSON 压缩被误判为会修改字符串语义。 -2. 部分修复方案本身不可用,例如使用无界 `LinkedBlockingQueue` 构造所谓“有界线程池”。 -3. 部分问题需要结合实际依赖实现重新解释,例如 A2A SDK 的 consumption timeout 并不是 SSE 相邻事件间隔超时。 -4. P1-7 与 P2-6 是同一个接口契约问题,存在重复计数。 -5. 报告遗漏了自定义 CA/mTLS 未接入默认运行链路、运行时线程池未关闭、协议日志泄露认证信息等重要问题。 - -建议修正后形成: - -- P0:2 项; -- P1:6 项左右; -- P2:5 项左右; -- 删除重复项 P2-6; -- 将原 P0-4 降为普通性能/可读性清理。 - ---- - -## 2. 原报告逐项审核 - -### 2.1 P0-1:`sslVerify=false` 全局禁用 JVM 主机名验证 - -**审核结论:问题成立,保留 P0;原修复建议需要修正。** - -证据: - -- `DefaultA2AJavaClientRuntime` 构造函数设置 JVM 系统属性: - `System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true")`。 -- `SslContextFactory.createTrustAll()` 再次设置相同属性。 -- `DefaultA2AJavaClientRuntime()` 无参构造函数默认使用 `sslVerify=false`。 - -该系统属性影响同一 JVM 中的 JDK HTTP Client,而不是只影响当前 A2A Client。对于作为依赖库运行在 Spring Boot 工作台中的场景,这会扩大到其他出站 HTTPS 调用。 - -原报告提出“仅使用 trust-all SSLContext、不关闭主机名验证即可满足开发环境”的表述不准确。信任证书链和主机名校验是两层不同的验证:trust-all 只跳过证书链可信性判断,并不会自动允许证书 SAN/CN 与目标主机不一致。 - -**建议整改:** - -1. 默认构造函数改为安全默认值 `sslVerify=true`。 -2. 通用库不得在运行时修改 JVM 全局 TLS 属性。 -3. 生产环境使用受信 CA 或显式配置的自定义 CA,并保证证书 SAN 与访问地址一致。 -4. 如确需开发环境不安全模式,应通过隔离的传输实现提供,并打印明确安全告警,不能污染整个 JVM。 - ---- - -### 2.2 P0-2:`AgentCredentialService` 登录端点无条件 trust-all - -**审核结论:问题成立,保留 P0。** - -当调用方没有注入 `HttpClient` 时,`AgentCredentialService` 总是使用 `SslContextFactory.createTrustAll()`。标准 `AgentAuthManager` 创建服务时也没有传入自定义客户端,因此默认凭证登录路径实际无法受工作流客户端的 `sslVerify`、自定义 CA 等配置控制。 - -这意味着即使 A2A 消息通道开启了严格 TLS 校验,获取登录 Token 的用户名、密码交换仍可能不验证服务端身份。 - -**建议整改:** - -1. 将 TLS/HttpClient 作为显式依赖注入 `AgentCredentialService`。 -2. Token 登录默认严格校验证书和主机名。 -3. 登录链路与消息链路共享统一 TLS 配置模型,但允许分别覆盖。 -4. 自签证书通过 CA 信任配置解决,不能以 trust-all 作为生产方案。 - ---- - -### 2.3 P0-3:Notification-T 超时处理吞没真实错误 - -**审核结论:问题成立,建议调整为 P1 高优先级。** - -当前实现: - -```java -return future.orTimeout(5, TimeUnit.SECONDS) - .exceptionally(e -> subscribedWithoutAck()); -``` - -`exceptionally` 会处理所有异常。流在 5 秒内发生连接失败、HTTP 错误、协议解析失败时,也会被转换为 `TASK_STATE_WORKING`,导致调用方把真实失败识别成订阅成功。 - -该问题只影响 Notification-T 订阅能力,因此是否定为 P0 取决于该能力是否属于发布阻断项;从通用引擎定级看,P1 高优先级更准确。 - -**建议整改:** - -1. 解包 `CompletionException`/`ExecutionException` 后,仅对真实 `TimeoutException` 采用“暂未收到 ACK”的兼容行为。 -2. 其他错误必须异常完成,不得转换成成功结果。 -3. 返回初始订阅状态后,后台 SSE 发生异常仍要通过状态回调、健康事件或重连管理器通知订阅生命周期持有者。 -4. 5 秒等待值应支持配置。 - ---- - -### 2.4 P0-4:SSE compact JSON 会破坏字符串内空白 - -**审核结论:核心判断不成立,应从 P0 删除。** - -`lines().map(String::trim)` 删除的是 pretty-printed JSON 每个物理行两端的缩进和换行。对于: - -```json -{ - "text": " Authorization-T pre-positioned " -} -``` - -`trim()` 作用于包含整个属性的物理行,只会删除 `{`、属性缩进和 `}` 周围的结构空白,不会删除双引号内部的前后空格。合法 JSON 字符串中的换行也必须转义,不能直接跨物理行,因此原报告示例不能证明语义被篡改。 - -以下两个次要意见可以保留: - -- `reduce("", String::concat)` 会产生不必要的中间字符串,较大报文下可能出现 O(n²) 拷贝; -- 可以优先使用 protobuf printer 的紧凑输出能力,减少手工处理。 - -`\r\n` 与 `\n` 都是 SSE 允许的行结束形式,平台差异不是协议错误。 - -**修正定级:P2 性能/实现简化。** - ---- - -### 2.5 P1-1:三个扩展 Prompt 生成器为空桩 - -**审核结论:不属于当前功能缺陷,应归入能力路线图或低优先级清理。** - -交叉核验当前 `a2a-t-sdk-java` 后,`A2ATClient` 公开的 Prompt 生成接口只有: - -```java -generateTaskPrompt(Object userInput) -``` - -不存在 `generateNegotiationPrompt`、`generateAuthorizationPrompt`、`generateNotificationPrompt`。因此原报告所称“已有 SDK 能力未利用”不成立。 - -当前 `DefaultExtensionSender` 类注释也明确说明另外三种能力为“reserved for future SDK support”,运行时会回退使用原始自然语言输入。 - -可以进行的维护性优化: - -- 删除三个没有实际行为的方法,由分派逻辑直接回退;或 -- 保留方法并添加明确 TODO、SDK 版本和预期契约。 - -在 A2A-T SDK 尚未提供对应接口前,不建议定为 P1。 - ---- - -### 2.6 P1-2:`A2ATransport` 使用无界 CachedThreadPool - -**审核结论:问题成立,保留 P1;原修复代码不正确。** - -`A2ATransport.asyncExecutor` 和 `DefaultA2AJavaClientRuntime.httpClientExecutor` 都使用 `newCachedThreadPool`。高并发工作流和阻塞式发送可能造成线程数量快速增长。 - -原报告建议: - -```java -new ThreadPoolExecutor(4, 16, ..., new LinkedBlockingQueue<>(), ...) -``` - -这里的 `LinkedBlockingQueue<>` 没有容量上限。任务进入无界队列后,线程池通常只保留 corePoolSize 个线程,`maximumPoolSize=16` 和 `CallerRunsPolicy` 基本不会发挥预期作用。 - -**建议整改:** - -- 使用有容量的 `ArrayBlockingQueue` 或 `new LinkedBlockingQueue<>(capacity)`; -- 配置 core、max、queue capacity、keep-alive 和拒绝策略; -- 将 Executor 作为可注入依赖,明确所有权和关闭责任; -- 长生命周期 Notification-T 订阅与短请求发送使用不同的资源模型。 - ---- - -### 2.7 P1-3:每次 send 创建新的 Client/HttpClient - -**审核结论:问题成立,保留 P1。** - -当前默认运行时每次消息发送都会创建 `Client` 和 `A2AHttpClient`,结束后关闭 Client。这样无法充分复用连接池、TLS 会话和底层传输资源,协商多轮发送场景会重复建立连接。 - -交叉核验当前 `a2a-java` 源码,其 `Client` Javadoc 已明确声明实例是线程安全的,可以并发使用。因此原报告中的线程安全待确认项已有答案:可以设计缓存,但事件 Consumer 必须保持线程安全。 - -**建议整改:** - -- 优先按 Agent 接口 URL、协议绑定、TLS 配置缓存 Client/Transport; -- AgentCard 或接口配置变化时更新缓存; -- 在 runtime `close()` 中统一关闭缓存资源; -- 增加并发发送、AgentCard 更新和关闭时竞态测试。 - ---- - -### 2.8 P1-4:AutoConfiguration 超时和线程池参数硬编码 - -**审核结论:可配置性问题成立,保留 P1;部分原因解释错误。** - -`a2a.blocking.agent.timeout.seconds`、`a2a.blocking.consumption.timeout.seconds` 和固定 8 线程都应该支持 Spring Boot 外部配置。 - -但原报告把 `consumption timeout=5s` 解释为“SSE 两个事件之间最多间隔 5 秒”并不符合上游 `a2a-java` 定义。上游定义是阻塞调用中等待事件消费和持久化完成的超时,不是 SSE idle timeout。 - -**建议整改:** - -- 在通用 starter 的 `A2AProperties` 中增加服务端 blocking timeout、executor core/max/queue capacity 等通用配置; -- 保留当前默认值以兼容上游 SDK; -- 不要依据错误的 SSE 间隔解释直接把默认值扩大到 60~120 秒; -- 增加配置绑定测试。 - ---- - -### 2.9 P1-5:`A2AProperties` 缺少工作台/网关配置 - -**审核结论:原整改方向不合理。** - -`spring-boot-starter` 当前承担的是通用 A2A 服务端接入职责,`A2AProperties` 使用 `a2at.server` 前缀。把以下内容加入其中会造成通用模块依赖具体应用和厂商适配: - -- `a2a.order.host`; -- `a2a.order.port`; -- 东信模拟器配置; -- 工作台 sample 的 credentials/env 路径。 - -这违背依赖倒置和模块边界,且使主干通用 starter 被东信场景污染。 - -**建议整改:** - -- 通用服务端参数继续放在 `A2AProperties`; -- 通用出站引擎参数如确需 Spring 自动装配,可新建 `WorkflowEngineClientProperties`; -- 东信参数放在 samples 或独立 `eastcom-order-adapter-starter` 的 `OrderGatewayProperties`; -- 用专用 `@ConfigurationProperties` 替换 sample 中散落的 `@Value`。 - -因此,“配置散落需要治理”可以保留,但“全部加入 A2AProperties”应否决。 - ---- - -### 2.10 P1-6:WorkflowExecutor 使用 50ms sleep 轮询 - -**审核结论:原性能分析不准确,但代码存在更严重的正确性问题,应重写该条。** - -正常执行路径会等待当前所有 ready step 的 `CompletableFuture.allOf()` 完成,再进入下一轮 DAG 遍历。因此正常的十层依赖链不会每层额外进入一次 50ms sleep。 - -`readySteps` 为空且仍有 deferred step,通常意味着: - -- 依赖缺失; -- 路由产生不可达节点; -- 依赖图存在环; -- 前置步骤未产生预期 output。 - -当前实现在超过 `workflow.getSteps().size()` 次后,仅把步骤加入 `executed` 并跳过,没有把工作流设置为失败。最后 `ExecutionResult.success` 仍可能为 true。这才是需要优先解决的问题。 - -**建议整改:** - -1. 当 pending 非空但没有 ready step 且没有正在执行的 step 时,立即识别为依赖死锁。 -2. 输出未满足的前置步骤列表。 -3. 将工作流标记为失败,不能静默跳过。 -4. 在工作流加载/启动阶段增加缺失依赖和环检测。 -5. 删除 50ms 轮询逻辑,或只保留在确实存在异步外部依赖的明确场景中。 - ---- - -### 2.11 P1-7:`ExtensionSender.sendNotification` 默认实现丢弃回调 - -**审核结论:问题成立,但建议降为 P2,并与 P2-6 合并。** - -接口默认方法接受 `eventCallback`,却直接调用不带 callback 的 `sendExtensionMessage()`。当前唯一实现 `DefaultExtensionSender` 已覆盖该方法,所以现有主路径没有丢回调,但未来实现类可能静默违反接口契约。 - -**建议整改:** - -- 将带 callback 的 `sendNotification` 改为非 default 抽象方法; -- 不带 callback 的便利方法可以继续默认传 null; -- 增加接口契约测试。 - ---- - -### 2.12 P2-1:解密失败返回密文 - -**审核结论:问题成立,建议提升为 P1。** - -`CredentialCrypto.decryptIfNeeded()` 在以下情况都会返回原始 `enc:...` 字符串: - -- 缺少 `A2AT_CRED_KEY`; -- 密文格式不正确; -- Base64、密钥长度或 GCM 认证失败。 - -随后系统会把密文当成密码继续发起登录,最终表现为远端 401 或认证失败,掩盖真实配置错误。 - -**建议整改:** - -- 带 `enc:` 前缀的数据一旦无法解密必须抛出明确异常; -- 异常信息包含配置位置和失败类型,但不得包含密文、密钥或明文; -- 应用启动阶段预校验加密凭证,避免到第一次调用时才失败。 - ---- - -### 2.13 P2-2:`AgentAuthManager.setHttpClient()` 是空操作 - -**审核结论:问题成立,保留 P2。** - -该方法没有修改任何服务,日志却输出“HTTP client propagated”,属于误导性 API 和不正确日志。仓库中也没有实际调用点。 - -如果采用依赖注入式认证客户端设计,应通过构造函数/Factory 创建服务;在此之前建议直接删除该方法和错误日志,而不是保留一个看似可用的空实现。 - ---- - -### 2.14 P2-3:扩展 URI 使用 `contains()` 匹配 - -**审核结论:问题成立,保留 P2;原修复方案不可直接使用。** - -`contains()` 可能把 `Task-T-Extended` 错误匹配为 `Task-T`。 - -原报告建议取 URI 最后一个路径段,但当前测试和常用 URI 是: - -```text -https://a2a.example.org/extensions/Task-T/v1 -``` - -最后一个路径段是 `v1`,不是 `Task-T`,因此该建议会破坏现有匹配。 - -**建议整改:** - -- 使用 `URI` 解析路径; -- 对路径段进行精确、大小写不敏感匹配; -- 明确定义扩展名和版本段规则; -- 或由 handler 声明完整 URI/受支持版本集合; -- 增加 `Task-T-Extended`、非法 URI、query/fragment 等测试。 - ---- - -### 2.15 P2-4:手动调用 ExtensionInterceptor - -**审核结论:设计问题成立,保留 P2;整改要兼容自定义 Runtime。** - -`buildInterceptors()` 同时创建认证和扩展 interceptor,但 `A2ATransport` 只手动执行 `ExtensionInterceptor`。认证头由 `applyAuthHeaders()` 另外生成,因此 auth interceptor 在该路径上属于冗余对象。 - -不能简单把逻辑全部迁移到 a2a-java Client interceptor,因为 `A2ATransport` 同时服务于默认直连 Runtime 和东信 Order Gateway Runtime,自定义 Runtime 也需要拿到最终 Header。 - -**建议整改:** - -- 抽出与具体 SDK 无关的 `HeaderContributor` 或 `ClientCallContextFactory`; -- 认证、扩展分别实现 Header 贡献逻辑; -- Runtime 只消费构建完成的 `ClientCallContext`; -- required 扩展构建失败时应 fail-fast,optional 扩展才允许降级; -- 清理当前未执行的 auth interceptor 构建逻辑。 - ---- - -### 2.16 P2-5:用空文本判断任务成功 - -**审核结论:问题成立,建议提升为 P1。** - -当前判断: - -```java -boolean success = r.getText() != null && !r.getText().isEmpty(); -``` - -会导致: - -- Task 已 COMPLETED,但只返回 artifact 时被判断为失败; -- Task 已 FAILED,但错误消息非空时被判断为成功。 - -原报告提出“文本非空且状态 COMPLETED”仍会错误拒绝 artifact-only 的成功响应。 - -**建议规则:** - -1. 有明确状态时,以状态为准; -2. `COMPLETED` 为成功; -3. `FAILED`、`CANCELED`、`REJECTED` 为失败; -4. `INPUT_REQUIRED`、`AUTH_REQUIRED` 不应作为普通任务成功; -5. 只有旧协议或 Message-only 响应没有状态时,才回退到文本/内容判断; -6. 后续可增加 artifact 到 `TaskResponse` 的映射。 - ---- - -### 2.17 P2-6:Notification callback 接口不一致 - -**审核结论:删除,和 P1-7 重复。** - -两条描述的是同一个事实:默认接口方法可能静默丢弃 callback。应合并为一个 P2 接口契约问题,避免重复统计和重复整改。 - ---- - -## 3. 原报告遗漏的问题 - -### 3.1 P1:`caCertsPath` 和 mTLS 没有接入默认运行链路 - -这是原报告最重要的遗漏之一。 - -`DefaultA2AJavaClientRuntime` 虽然接收、保存并打印 `caCertsPath`,但 `createHttpClient()` 的实际逻辑是: - -- `sslVerify=true`:直接创建默认 `JdkA2AHttpClient`; -- `sslVerify=false`:创建 trust-all JDK HttpClient。 - -代码没有调用 `SslContextFactory.create(verifyServer, caCertsPath, ...)`。全仓库也没有找到该 `create()` 方法的实际调用点。 - -因此: - -- `caCertsPath` 当前不生效; -- `SslContextFactory` 中的 mTLS 代码没有被默认 Runtime 使用; -- 附录中“mTLS 支持已确认良好”只能说明辅助代码存在,不能说明引擎能力已经可用; -- gRPC 路径也明确只使用默认信任库,自定义 CA 需要自定义 Runtime。 - -**建议整改:**统一构建 HTTP/JSONRPC/gRPC 的 TLS 配置,补充自定义 CA、证书错误、mTLS 和关闭验证的集成测试,并对 TLS 配置错误 fail-fast。 - ---- - -### 3.2 P1:`DefaultA2AJavaClientRuntime.close()` 没有关闭线程池 - -`httpClientExecutor` 是 Runtime 持有的 ExecutorService,但 `close()` 只打印日志,没有执行 shutdown。`A2ATransport.close()` 只会关闭自己的 `asyncExecutor`,无法替代 Runtime 对内部资源的关闭责任。 - -**建议整改:** - -- Runtime 自己创建的 Executor 必须在 `close()` 中关闭; -- 外部注入的 Executor 不应由 Runtime 擅自关闭; -- 增加重复 close、正在发送时 close、关闭后拒绝新请求等测试。 - ---- - -### 3.3 P1/P2:PROTOCOL 日志明文输出敏感 Header 和 Body - -`ProtocolLogger` 会输出完整 Header,其中可能包括: - -- `Authorization: Bearer ...`; -- Cookie、API Key、自定义认证头; -- 任务正文、协商信息及业务数据。 - -独立 logger 便于联调是合理设计,但生产配置和开源默认实践需要同时考虑敏感信息保护。 - -**建议整改:** - -- 默认脱敏 Authorization、Cookie、Proxy-Authorization 和已知 API Key Header; -- 提供显式的敏感 Header 开关,仅在受控联调环境启用; -- 启用时输出安全告警; -- 文档说明日志文件权限、保留周期和问题单脱敏要求; -- Body 如需完整打印,也应有独立开关和长度限制。 - ---- - -### 3.4 P2:form-urlencoded 登录参数没有编码 - -`AgentCredentialService` 当前直接拼接: - -```java -key=value&key2=value2 -``` - -如果用户名或密码包含 `+`、`&`、`=`、`%`、空格或中文,请求语义会改变。 - -**建议整改:**按 UTF-8 对 key 和 value 分别执行 form URL encoding,并增加特殊字符测试。 - ---- - -### 3.5 P2:AuthProvider 与 credentials 的同名 Header 冲突策略不清晰 - -当前顺序为: - -1. 自定义 `AuthProvider` 写入 Header; -2. credentials 认证随后写入 Header。 - -如果两者都写 `Authorization`,后执行的 credentials 会覆盖前者。部分集成文档却表述为两者“互不覆盖”,与 Map 的实际行为不一致。 - -**建议整改:** - -- 推荐两种认证方式二选一;或 -- 检测同名 Header 并 fail-fast;或 -- 明确并测试覆盖优先级,同时修正文档。 - ---- - -### 3.6 P2:TLS 配置错误存在潜在 fail-open 语义 - -`SslContextFactory.create()` 在加载 CA/证书发生异常时记录: - -```text -Falling back to no verification -``` - -随后返回 `Optional.empty()`。虽然当前默认 Runtime 没有实际调用该方法,但从公共方法契约看,生产 TLS 配置错误不应该被解释为关闭验证。 - -**建议整改:**证书、私钥或 CA 配置存在但加载失败时直接抛出配置异常。只有调用方显式设置不安全模式时,才允许关闭验证。 - ---- - -## 4. 对原报告“已确认良好设计”附录的修正 - -以下结论基本可以保留: - -1. `ConversationScopedA2AJavaClientRuntime` 的会话生命周期抽象; -2. `A2ATExtension` 集中维护扩展 URI; -3. `ExtensionInterceptor` 只注入当前消息实际携带的扩展; -4. Negotiation-T SDK 处理与直接提取的双路径; -5. 协商轮次上限; -6. `WorkflowExecutor.anySuccess` 的基本语义; -7. `EnvFileLoader` 不覆盖更高优先级配置。 - -以下结论需要改写: - -### 4.1 mTLS - -`SslContextFactory.loadKeyManagers()` 的实现可以作为正面基础,但默认 Runtime 没有接入,不能直接得出“引擎已经支持 mTLS”的结论。还需验证 PKCS#8、证书链、加密私钥、证书轮换、非 RSA 密钥等边界。 - -### 4.2 CredentialCrypto - -AES-GCM、12 字节随机 IV 和 128 位 Tag 的算法选择合理。`SecureRandom` 能把 IV 碰撞概率降到极低,但“无 IV 重用风险”是过度绝对的表达。更重要的是,解密失败返回密文使整体凭证处理不满足 fail-safe。 - -### 4.3 ProtocolLogger - -独立 logger 和 pretty JSON 有利于协议联调,应保留。但“无需修改”不成立:必须补充敏感信息控制、默认脱敏和日志治理说明。 - ---- - -## 5. 建议整改优先级 - -### 第一批:安全和结果正确性 - -1. 移除 JVM 全局关闭主机名验证的行为,调整默认构造器安全值。 -2. 修复凭证登录 trust-all,并统一 TLS 配置。 -3. 修复 Notification-T 将真实失败伪装成订阅成功的问题。 -4. 修复 `CredentialCrypto` 解密失败继续使用密文的问题。 -5. 修复 `DefaultControlPoint` 的成功状态判断。 -6. 修复 WorkflowExecutor 对未满足依赖静默跳过并可能报告成功的问题。 - -### 第二批:资源和生产可配置性 - -1. 使用可配置的有界 Executor,并实现背压。 -2. 缓存可安全复用的 A2A Client/Transport。 -3. 正确关闭 Runtime 内部线程池和缓存资源。 -4. 外部化 starter 的通用 timeout、pool 和 queue 参数。 -5. 真正接入自定义 CA 和 mTLS。 - -### 第三批:接口和维护性 - -1. 收紧 Notification callback 接口契约。 -2. 清理 `AgentAuthManager.setHttpClient()` 空操作。 -3. 重构 Header 构建和 interceptor 抽象。 -4. 修复扩展 URI 的精确匹配。 -5. 修复 form-urlencoded 编码。 -6. 明确 AuthProvider 与 credentials 冲突策略。 -7. 优化 SSE JSON 紧凑序列化,但不再作为 P0 正确性问题。 - ---- - -## 6. 建议第三方重点复核的问题 - -为了避免二次审核本身产生新的误判,建议后续检视人员重点验证: - -1. JDK 当前目标版本下,是否存在不修改 JVM 全局属性且能对单个 `HttpClient` 关闭主机名验证的受支持方案。 -2. `a2a-java` 当前实际依赖版本是否与同目录源码一致,以及 Client/Transport 缓存和关闭的线程安全边界。 -3. Notification-T 在 5 秒无 ACK 后,底层 Future 和 SSE 流是否仍保持独立生命周期,以及异常如何上报。 -4. 工作流模型中未选择的条件分支是否可能被登记为其他节点的静态 predecessor,避免把合法路由误判为依赖死锁。 -5. A2A-T 扩展 URI 的规范化格式、版本段规则和大小写要求。 -6. PROTOCOL 完整报文日志在正式交付环境中的安全要求,是否允许通过显式开关打印 Authorization。 - -以上核验项完成后,再冻结最终整改清单和优先级。 diff --git a/docs/zh/engine-code-review.md b/docs/zh/engine-code-review.md deleted file mode 100644 index ec828011..00000000 --- a/docs/zh/engine-code-review.md +++ /dev/null @@ -1,746 +0,0 @@ -# 引擎核心代码检视报告 - -> **归档状态(2026-08-25)**:本文是旧 commit 的问题快照,不是当前代码说明。 -> 文中 P0/P1/P2 问题已在未发布版整改,包括 per-client TLS、认证 fail-closed、SSE 原样解析、 -> 有界线程池、runtime 复用、可配置 Spring 执行器、无轮询 DAG join、回调透传、凭证解密 -> fail-closed、规范 URI 精确匹配、任务状态判定,以及真实 SDK 的 Task/Negotiation/Authorization/ -> Notification 管线。当前契约请以 [API 参考](API_REFERENCE.md)、[架构设计](DESIGN.md)和 -> [CHANGELOG](../../CHANGELOG.md) 为准。下文的旧接口名和行号仅用于追溯,不得复制到新实现。 - -> **检视范围**: `workflow-engine` 模块 + `spring-boot-starter` 模块 -> **检视日期**: 2026-08-18 -> **检视人**: AI 辅助 + 人工核验 -> **源码版本**: main 分支 `4aec309` / dev 分支 `39118ca` - ---- - -## P0: 安全 / 正确性问题 - -### P0-1: `sslVerify=false` 全局禁用 JVM 主机名验证 - -**文件**: `DefaultA2AJavaClientRuntime.java:105-108`, `SslContextFactory.java:167-168` - -**代码**: -```java -// DefaultA2AJavaClientRuntime.java:108 -System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); - -// SslContextFactory.java:168 -System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); -``` - -**WHY 需要优化**: - -`jdk.internal.httpclient.disableHostnameVerification` 是一个 JVM 级别的系统属性,影响同一 JVM 中所有 `java.net.http.HttpClient` 实例。在 Spring Boot 工作台场景中,同一 JVM 可能运行多个 HTTP 客户端(工作台自身的 REST 调用、注册中心客户端、LLM API 调用等)。设置此属性后,**所有** HTTPS 连接都失去主机名验证,不仅是 A2A 通道。 - -具体风险: -- 攻击者可以注册任意域名的证书(只要 CA 链有效),JDK 不会校验证书中的 CN/SAN 与目标主机名是否匹配 -- 这是一个中间人攻击向量:攻击者用合法 CA 签发的"其他域名"证书即可冒充目标服务器 - -JDK 在类加载时缓存此属性(`jdk.internal.net.http.common.Utils`),意味着即使后来设为 `false` 也不会生效——属性必须在任何 `HttpClient` 创建之前设置。 - -**HOW 优化方向**: - -JDK `HttpClient` 没有提供 per-client 的主机名验证器 API。可选方案: -1. **生产环境强制 `sslVerify=true`**:在 `WorkflowEngineClientConfig` 中将 `sslVerify` 默认值从 `true` 保持,在 `DefaultA2AJavaClientRuntime` 构造时,如果 `sslVerify=false`,打印 **WARN** 日志明确告知影响范围,并建议仅在开发环境使用 -2. **使用信任所有证书的 `SSLContext` 但不禁用主机名验证**:当前 `SslContextFactory.createTrustAll()` 已经创建了信任所有的 `TrustManager`(接受任何证书),这在开发环境足够。主机名验证可以通过自定义 `SSLParameters` 在 `HttpClient.newBuilder().sslParameters(...)` 中更精细地控制,但 JDK 的实现仍受系统属性影响 -3. **文档约束**:在 `SslContextFactory.createTrustAll()` 的 Javadoc 中已加 WARN,但 `DefaultA2AJavaClientRuntime` 的构造函数 Javadoc 应明确说明"此设置影响整个 JVM" - -**核验要点**: 确认生产环境是否曾经使用过 `sslVerify=false`。如果仅在开发/测试环境使用,风险可控,但仍建议加日志告警。 - ---- - -### P0-2: AgentCredentialService 登录端点永远使用 trust-all SSL - -**文件**: `AgentCredentialService.java:78-81` - -**代码**: -```java -// Disable TLS verification (mirrors Python's verify=False for login endpoints) -b.sslContext(SslContextFactory.createTrustAll()); -this.httpClient = b.build(); -``` - -**WHY 需要优化**: - -`AgentCredentialService` 的 `login()` 方法向 credential config 中配置的 `login_url` 发送认证请求(用户名/密码),获取 Bearer Token。这个 HTTP 客户端**无条件使用 trust-all SSL**,不受引擎配置 `sslVerify` 的控制。 - -即使引擎配置 `sslVerify=true`(A2A 通道使用正式 TLS),**登录端点的凭证交换仍然不验证 TLS**。攻击者可以中间人攻击 login 端点,截获用户名和密码。 - -Python SDK 的对应实现确实也使用 `verify=False`,但 Python 的 `verify=False` 是 per-session 的(只影响 `httpx.AsyncClient` 实例),不影响同一进程中的其他 HTTP 客户端。Java 的 `SslContextFactory.createTrustAll()` 虽然也是 per-instance 的 SSLContext,但 `createTrustAll()` 内部又调用了 `System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true")`,触发了 P0-1 的全局问题。 - -**HOW 优化方向**: - -1. `AgentCredentialService` 构造函数增加 `sslVerify` 和 `caCertsPath` 参数,根据引擎配置决定是否验证 TLS -2. `SslContextFactory.createTrustAll()` 移除 `System.setProperty` 调用,只保留 trust-all `TrustManager`(per-instance 的 SSLContext 已经足够信任所有证书,不需要全局禁用主机名验证) -3. 如果 login 端点使用自签证书,应配置 `ca_certs_path` 指向自签 CA,而非 trust-all - -**核验要点**: 确认生产环境的 login_url 是否使用自签证书。如果是,应配置 CA 路径而非 trust-all。 - ---- - -### P0-3: Notification-T 超时处理器吞没真实错误 - -**文件**: `A2ATransport.java:445-454` - -**代码**: -```java -return future.orTimeout(5, TimeUnit.SECONDS) - .exceptionally( - e -> { - log.warn( - "[Transport] Notification-T subscription: no event in 5s, assuming active (stream stays open)"); - return SendMessageResult.builder() - .text("Subscribed (no-ack)") - .taskState("TASK_STATE_WORKING") - .build(); - }); -``` - -**WHY 需要优化**: - -`orTimeout(5, SECONDS)` 在 5 秒后触发 `TimeoutException`。但 `exceptionally(e -> ...)` 捕获**所有**异常,不仅仅是 `TimeoutException`。 - -如果 Notification-T 流在 5 秒内失败(网络中断、服务端 500 错误等),stream thread 会调用 `future.completeExceptionally(e)`。此时 `orTimeout` 返回的 future 也会以同样的异常完成。`exceptionally` 会捕获这个真实错误,打印"no event in 5s"日志,并返回 `SendMessageResult(text="Subscribed (no-ack)", taskState="TASK_STATE_WORKING")`——**将真实错误伪装成订阅成功**。 - -调用方收到 "TASK_STATE_WORKING" 后认为订阅成功,但实际 SSE 流已经断开。后续的恢复结果永远不会到达,且无人知道订阅已失败。 - -**HOW 优化方向**: - -在 `exceptionally` 中区分 `TimeoutException` 和其他异常: -```java -.exceptionally(e -> { - if (e instanceof TimeoutException) { - log.warn("[Transport] Notification-T subscription: no event in 5s, assuming active"); - return SendMessageResult.builder() - .text("Subscribed (no-ack)") - .taskState("TASK_STATE_WORKING") - .build(); - } - // 真实错误,不吞没 - throw new CompletionException(e); -}); -``` - -注意: `e` 可能被 `CompletableFuture` 包装为 `CompletionException`,需要 `e.getCause() instanceof TimeoutException` 判断。 - -**核验要点**: 确认 Notification-T 流在 5 秒内失败时,当前行为是否确实将错误吞没。可以在测试中模拟服务端立即返回 500,观察调用方收到的结果。 - ---- - -### P0-4: SSE compact JSON 破坏含空白字符的文本值 - -**文件**: `A2AController.java:125-135` - -**代码**: -```java -String compact = - json.replace("\r\n", "\n") - .replace('\r', '\n') - .lines() - .map(String::trim) - .reduce("", String::concat); -String sse = - String.format(Locale.ROOT, "id:%d%n", seq.incrementAndGet()) - + "data:" + compact + "\n\n"; -``` - -**WHY 需要优化**: - -这段代码将 protobuf JSON 序列化的 pretty-printed 输出压缩为单行。`lines()` + `map(String::trim)` + `reduce("", String::concat)` 的组合有两个问题: - -1. **trim 破坏值内空白**: pretty-printed JSON 中,字符串值内的前导/后导空格在 trim 后丢失。例如 `"text": " Authorization-T pre-positioned "` 会变成 `"text": "Authorization-T pre-positioned"`。虽然 JSON 标准说空白在结构上不显著,但 **A2A 协议传输的是语义内容**,文本值中的空格对 Agent 理解指令有语义意义。 - -2. **`reduce("", String::concat)` 的 O(n^2) 性能**: 对 n 行 JSON,`reduce` 会创建 n 个中间字符串,每个比前一个长。对于大的 Task 响应(含长文本 artifact),这会产生 O(n^2) 的内存分配和拷贝开销。 - -3. **`%n` 平台依赖**: `String.format("%n")` 在 Windows 上产生 `\r\n`,在 Unix 上产生 `\n`。虽然 SSE 规范允许 `\r\n` 和 `\n`,但 compact JSON 已被规范为 `\n`,两者不一致。 - -**HOW 优化方向**: - -使用 protobuf 的紧凑打印器(如果 API 支持),或用 Jackson 重序列化: -```java -// 方案 A: protobuf printer 配置 -String json = JsonFormat.printer().omittingInsignificantWhitespace().print(sr); - -// 方案 B: Jackson 重序列化(保证值内空白不丢失) -ObjectMapper mapper = new ObjectMapper(); -String compact = mapper.writeValueAsString(mapper.readTree(json)); - -// SSE 行终止符统一为 \n -String sse = "id:" + seq.incrementAndGet() + "\ndata:" + compact + "\n\n"; -``` - -**核验要点**: 确认 protobuf `JsonFormat.Printer` 是否有 `omittingInsignificantWhitespace()` 方法。如果有,方案 A 最简洁。如果确认 A2A 协议中文本值的空白不影响语义,则当前实现可接受但应注释说明。 - ---- - -## P1: 生产就绪性 / 设计问题 - -### P1-1: `DefaultExtensionSender` 三个扩展 prompt 生成器是空桩 - -**文件**: `DefaultExtensionSender.java:247-266` - -**代码**: -```java -private String generateNegotiationPrompt(String naturalLanguageInput) { - if (transport.getA2atClient() == null) { return null; } - return null; // STUB -} -private String generateAuthorizationPrompt(String naturalLanguageInput) { - if (transport.getA2atClient() == null) { return null; } - return null; // STUB -} -private String generateNotificationPrompt(String naturalLanguageInput) { - if (transport.getA2atClient() == null) { return null; } - return null; // STUB -} -``` - -**WHY 需要优化**: - -`DefaultExtensionSender.sendExtensionMessage()` (line 70-81) 调用 `generateExtensionPrompt()` 来生成结构化的扩展 prompt。当返回 `null` 时,fallback 使用原始自然语言文本作为 metadata value。 - -只有 Task-T 的 `generateTaskPromptText()` 调用了 `a2atClient.generateTaskPrompt()`。其他三个扩展的 prompt 生成器无条件返回 `null`,即使 `A2ATClient` 已经初始化。 - -这意味着: -- **Authorization-T 预定位**: 工作台发送的授权白名单文本是原始自然语言,没有经 LLM 加工为结构化 prompt -- **Notification-T 预定位**: 同上 -- **Negotiation-T 跟进**: 协商跟进消息的 metadata 是 fallback 文本 - -**HOW 优化方向**: - -需要确认 A2A-T SDK 的 `A2ATClient` 是否提供以下方法: -- `generateNegotiationPrompt(String input) -> PromptGenerationResult` -- `generateAuthorizationPrompt(String input) -> PromptGenerationResult` -- `generateNotificationPrompt(String input) -> PromptGenerationResult` - -如果 SDK 已有这些方法,直接调用(与 `generateTaskPromptText` 相同模式)。 -如果 SDK 尚未提供,在方法 Javadoc 中标注"等待 SDK 支持",并在 `DefaultExtensionSender` 的类级 Javadoc 中更新说明。 - -**核验要点**: 检查 `a2a-t-sdk-java` 的 `A2ATClient` 类的公开 API,确认是否存在这三个方法或等价方法。 - ---- - -### P1-2: `A2ATransport` 使用无界 CachedThreadPool - -**文件**: `A2ATransport.java:82-88` - -**代码**: -```java -private final ExecutorService asyncExecutor = - Executors.newCachedThreadPool( - r -> { - Thread t = new Thread(r, "engine-send"); - t.setDaemon(true); - return t; - }); -``` - -**WHY 需要优化**: - -`newCachedThreadPool` 没有线程数上限。每个 `send()` 调用通过 `CompletableFuture.supplyAsync(..., asyncExecutor)` 提交任务。在并行 DAG 执行中(`WorkflowExecutor` 同时执行多个 ready step),如果多个 step 同时向多个 agent 发送消息,线程数可能短暂激增。 - -每个线程默认占用约 1MB 栈空间。20 个并行 agent 调用 = 20 个线程 = 20MB 栈内存。在生产环境中,工作台可能同时处理多个工作流实例,线程数可能进一步增长。 - -此外,`DefaultA2AJavaClientRuntime` 也有一个无界的 `httpClientExecutor` (line 81-87),以及 `sendNotificationStream` 创建的独立 `Thread` (line 372-444)。三类线程池/线程叠加,资源消耗可能不可控。 - -**HOW 优化方向**: - -```java -// 有界线程池,可配置 -private final ExecutorService asyncExecutor = - new ThreadPoolExecutor( - 4, 16, // core=4, max=16 (可配置) - 60L, TimeUnit.SECONDS, - new LinkedBlockingQueue<>(), - r -> { Thread t = new Thread(r, "engine-send"); t.setDaemon(true); return t; }, - new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:调用方执行 - ); -``` - -线程池大小应通过 `WorkflowEngineClientConfig` 外部化配置。 - -**核验要点**: 确认当前并行 DAG 执行的最大并发 agent 调用数。如果 PSOP 中的 `ALL_SUCCESS` step 最多有 2-3 个 subtask,16 个线程已经足够。 - ---- - -### P1-3: `DefaultA2AJavaClientRuntime` 每次 send 创建新 Client 和 HttpClient - -**文件**: `DefaultA2AJavaClientRuntime.java:272, 315-341, 437-450` - -**代码**: -```java -// sendMessage 中: -Client client = createClient(agentCard, agentUrl); // 每次新建 -// ... -client.close(); - -// createClient -> createHttpClient: -private A2AHttpClient createHttpClient() { - if (this.sslVerify) { - return new JdkA2AHttpClient(); // 每次新建 HttpClient - } - SSLContext trustAllCtx = SslContextFactory.createTrustAll(); - HttpClient httpClient = HttpClient.newBuilder()...build(); // 每次新建 - return new JdkA2AHttpClient(httpClient); -} -``` - -**WHY 需要优化**: - -每次 `sendMessage` 都创建新的 `Client` 和新的 `HttpClient`。`HttpClient` 内部维护连接池、SSL context、selector 管理器等,创建成本高。JDK `HttpClient` 文档明确建议复用实例。 - -在协商循环中,一次 `sendMessage` 可能触发 3 轮协商(每轮一个 `sendMessage`),每轮都创建/销毁一个 `HttpClient`。连接无法复用,TCP/TLS 握手每次重新进行。 - -`HttpClient` 没有显式 `close()` 方法(Java 11-17),其内部资源依赖 GC 回收或 `executor` 的 shutdown。大量短生命周期 `HttpClient` 实例可能导致 `Selector` 管理器泄漏(JDK 内部每个 `HttpClient` 有一个 selector,通过弱引用清理,但 GC 压力大)。 - -**HOW 优化方向**: - -```java -// 按协议+SSL配置缓存 Client 实例 -private final Map clientCache = new ConcurrentHashMap<>(); - -private Client getOrCreateClient(AgentCard agentCard) { - String cacheKey = agentCard.supportedInterfaces().get(0).url() - + "|" + sslVerify + "|" + preferredProtocol; - return clientCache.computeIfAbsent(cacheKey, k -> createClient(agentCard)); -} -``` - -注意: 缓存 `Client` 后,`client.close()` 不应在每次 `sendMessage` 后调用。需要在 `DefaultA2AJavaClientRuntime.close()` 中统一关闭。 - -**核验要点**: 确认 a2a-java SDK 的 `Client` 是否线程安全,是否支持并发 `sendMessage` 调用。如果 SDK 内部不支持,缓存可能导致并发问题。 - ---- - -### P1-4: `A2AAutoConfiguration` 超时和线程池参数硬编码 - -**文件**: `A2AAutoConfiguration.java:106-107, 146-157` - -**代码**: -```java -case "a2a.blocking.agent.timeout.seconds" -> "30"; -case "a2a.blocking.consumption.timeout.seconds" -> "5"; -// ... -new ThreadPoolExecutor( - 8, 8, // 固定 8 线程 - 0L, TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>(), ...); -``` - -**WHY 需要优化**: - -1. **Agent 超时 30 秒**: 某些 Agent 执行复杂诊断可能需要 60-120 秒。30 秒太短,可能导致 Task 被中断。 -2. **Consumption 超时 5 秒**: SSE 流式响应中,两个 event 之间的间隔可能超过 5 秒(LLM 思考时间)。5 秒太短。 -3. **线程池固定 8 线程**: 无法根据工作台的并发需求调整。高并发场景下,第 9 个请求会阻塞在队列中(`LinkedBlockingQueue` 无界,但只有 8 个消费线程)。 - -这些参数不可通过 `application.yml` 配置,集成方必须覆盖 Bean 才能修改。 - -**HOW 优化方向**: - -扩展 `A2AProperties`: -```java -@ConfigurationProperties(prefix = "a2at.server") -public class A2AProperties { - // 现有字段... - private int agentTimeoutSeconds = 30; - private int consumptionTimeoutSeconds = 5; - private int executorPoolCore = 8; - private int executorPoolMax = 8; -} -``` - -`A2AConfigProvider` 和 `agentExecutorPool` 读取这些属性值。 - -**核验要点**: 确认 a2a-java SDK 的 `A2AConfigProvider` 支持哪些配置 key。如果 SDK 有其他 key,也应暴露。 - ---- - -### P1-5: `A2AProperties` 缺少工作台/网关配置项 - -**文件**: `A2AProperties.java` (仅 54 行) - -**WHY 需要优化**: - -`spring-boot-starter` 的 `@ConfigurationProperties` 只有 `a2at.server.agent-card` 和 `a2at.server.path-prefix` 两个属性。以下配置散落在 `samples` 模块的 `@Value` 注解中: - -| 配置项 | 当前位置 | 应归入 | -|--------|---------|--------| -| `a2a.transport-mode` | `ClientRuntimeFactory` | `A2AProperties` | -| `a2a.order.host` | `ClientRuntimeFactory` | `A2AProperties` | -| `a2a.order.port` | `ClientRuntimeFactory` | `A2AProperties` | -| `a2a.order.simulator-enabled` | `ClientRuntimeFactory` | `A2AProperties` | -| `a2a.ssl-verify` | `ClientRuntimeFactory` | `A2AProperties` | -| `a2a.credentials-path` | `SpringWorkbenchExtensionLifecycle` | `A2AProperties` | -| `a2a.a2at-env-path` | 多处 | `A2AProperties` | - -集成方使用 `spring-boot-starter` 时,无法通过统一的 properties 管理这些配置,必须查看 sample 源码才能知道有哪些 `@Value` 属性。 - -**HOW 优化方向**: - -在 `spring-boot-starter` 中新增 `WorkflowProperties` 或扩展 `A2AProperties`: -```java -@ConfigurationProperties(prefix = "a2a") -public class WorkflowProperties { - private String transportMode = "direct"; - private boolean sslVerify = true; - private String credentialsPath; - private String a2atEnvPath; - // ... Order SDK 配置 -} -``` - -注意: `spring-boot-starter` 不应依赖 `order-shaded-client`,因此 Order SDK 特有配置应放在 samples 层的 properties 中,或通过条件化配置实现。 - -**核验要点**: 确认工作台集成方实际需要配置哪些项。有些配置(如 `a2a.order.host`)是东信特有的,不应放在通用 starter 中。 - ---- - -### P1-6: `WorkflowExecutor` 延迟步骤使用 50ms sleep 轮询 - -**文件**: `WorkflowExecutor.java:205-218` - -**代码**: -```java -if (readySteps.isEmpty()) { - if (!deferredSteps.isEmpty()) { - return CompletableFuture.runAsync( - () -> { - try { - Thread.sleep(50); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }) - .thenCompose(v -> executeSteps(pending, executed, failed, deferCount)); - } - return CompletableFuture.completedFuture(null); -} -``` - -**WHY 需要优化**: - -当所有剩余步骤的前置依赖尚未完成时,执行器在一个 `runAsync` 线程上 sleep 50ms 然后重试。这是一个忙等待轮询循环。 - -问题: -1. **延迟**: 每次轮询间隔 50ms,如果依赖在 sleep 后 1ms 完成,仍需等待 49ms。对于 10 层依赖链,最坏情况额外 500ms 延迟。 -2. **线程占用**: 每次轮询占用一个 `ForkJoinPool.commonPool` 线程 50ms。如果多个工作流并行执行,common pool 可能被占满。 -3. **无退出条件检查**: 如果 `deferredSteps` 永远无法满足(依赖图中存在循环依赖或缺失依赖),这个循环会无限运行(虽然有 `dc > workflow.getSteps().size()` 的保护,但需要 `workflow.getSteps().size()` 次迭代才触发)。 - -**HOW 优化方向**: - -用事件驱动替代轮询。当 `stepOutputs.put()` 完成一个步骤时,直接唤醒等待该步骤的 deferred steps: -```java -// 在 executeStep 完成后,直接将新的 ready steps 加入 pending, -// 而不是 sleep + 重试整个 pending 队列 -// 当 readySteps 为空但 deferredSteps 非空时, -// 检查 deferredSteps 是否有步骤的前置已全部满足 -``` - -或者使用 `CompletableFuture` 依赖图:每个 step 的 future 依赖其前置 step 的 future,用 `thenCompose` 链式组合,完全消除轮询。 - -**核验要点**: 确认当前 PSOP 工作流的依赖深度。如果大多数工作流只有 2-3 层,50ms 延迟可接受。如果有 5+ 层链式依赖,应优化。 - ---- - -### P1-7: `ExtensionSender` 接口默认 `sendNotification` 丢弃回调 - -**文件**: `ExtensionSender.java:70-82` - -**代码**: -```java -default CompletableFuture sendNotification( - String agentName, - String instruction, - String naturalLanguageInput, - Consumer> eventCallback) { - return sendExtensionMessage( - agentName, instruction, naturalLanguageInput, A2ATExtension.NOTIFICATION_T); - // eventCallback 被忽略! -} -``` - -**WHY 需要优化**: - -接口的 default 方法接受 `eventCallback` 参数但**完全忽略它**——直接委托给 `sendExtensionMessage` 而不传递回调。`sendExtensionMessage` 不接受回调参数,Notification-T 的后续事件无处可去。 - -`DefaultExtensionSender` 重写了此方法(line 122-164),正确处理了回调。但如果其他实现类使用 default 方法(不重写),回调会被静默丢弃,调用方以为注册了回调但永远不会收到事件。 - -**HOW 优化方向**: - -方案 A: 将 `sendNotification` 声明为抽象方法(去掉 default),强制实现类处理回调: -```java -CompletableFuture sendNotification( - String agentName, String instruction, String naturalLanguageInput, - Consumer> eventCallback); -``` - -方案 B: default 方法抛出 `UnsupportedOperationException`,提示实现类必须重写: -```java -default CompletableFuture sendNotification(..., Consumer> eventCallback) { - throw new UnsupportedOperationException("sendNotification with callback must be overridden"); -} -``` - -**核验要点**: 确认是否有除 `DefaultExtensionSender` 之外的其他 `ExtensionSender` 实现。如果没有,此问题可降低优先级。 - ---- - -## P2: 健壮性 / 边界场景 - -### P2-1: `CredentialCrypto.decryptIfNeeded` 解密失败时返回密文 - -**文件**: `CredentialCrypto.java:108-113, 132-135` - -**代码**: -```java -// Key 未设置时: -log.warn("Encrypted value found but {} not set, using as-is", ENV_KEY); -return value; // 返回 "enc:xxx:yyy" - -// 解密异常时: -log.error("[CredentialCrypto] Decryption failed: {}", e.getMessage()); -return value; // 返回 "enc:xxx:yyy" -``` - -**WHY 需要优化**: - -当解密失败(密钥错误、密文损坏、密钥未设置)时,方法返回 `enc:base64:base64` 字符串。这个字符串会被用作 Agent 的 Bearer Token 或密码发送给 login 端点。login 端点会认证失败,返回 401/403,但错误信息是"认证失败",不是"凭证解密失败"。 - -运维人员看到的是 "Auth login failed: status=401",需要追踪到 `CredentialCrypto` 的 WARN/ERROR 日志才能找到根因。如果日志级别设置不当(WARN 被过滤),根因完全隐藏。 - -**HOW 优化方向**: - -解密失败时抛出异常而非返回密文: -```java -throw new IllegalStateException( - "Credential decryption failed: " + e.getMessage() - + ". Check A2AT_CRED_KEY environment variable."); -``` - -或者返回 `null`,让 `AgentCredentialService.login()` 的 `credential == null` 检查自然失败,但日志更清晰。 - -注意: 改为抛异常可能影响现有行为——如果某些凭证确实是明文(不以 `enc:` 开头),不受影响。但如果有配置文件混用了明文和密文,需要确保所有密文都能正确解密。 - -**核验要点**: 确认现有凭证配置文件中是否有 `enc:` 前缀的值。如果没有,此问题不影响当前运行,但仍应修复以防未来使用密文时出错。 - ---- - -### P2-2: `AgentAuthManager.setHttpClient()` 是空操作 - -**文件**: `AgentAuthManager.java:151-159` - -**代码**: -```java -public void setHttpClient(java.net.http.HttpClient httpClient) { - for (AgentCredentialService svc : services.values()) { - if (svc != null) { - // AgentCredentialService stores httpClient internally - // New services created after this call will use default client - } - } - log.info("[Auth] HTTP client propagated to {} service(s)", services.size()); -} -``` - -**WHY 需要优化**: - -方法遍历 `services` 但循环体为空(只有注释)。日志输出 "propagated to N service(s)" 但实际什么都没做。调用方以为 HTTP 客户端已传播到所有 credential service,但实际上每个 `AgentCredentialService` 仍然使用自己创建的 trust-all `HttpClient`。 - -这是一个误导性 API:方法签名和日志暗示功能存在,但实际未实现。 - -**HOW 优化方向**: - -方案 A: 实现传播逻辑: -- `AgentCredentialService` 增加 `updateHttpClient(HttpClient)` 方法 -- `setHttpClient` 调用每个 service 的 `updateHttpClient` -- 注意线程安全:`AgentCredentialService.httpClient` 字段需要 `volatile` 或同步 - -方案 B: 删除方法和日志(如果不打算实现): -- 移除 `setHttpClient` 方法 -- 移除相关 Javadoc 中对它的引用 - -**核验要点**: 确认是否有调用方使用 `setHttpClient`。搜索 `authManager.setHttpClient` 的调用点。 - ---- - -### P2-3: `ExtensionRegistry` 使用 `contains()` 匹配扩展 URI - -**文件**: `ExtensionRegistry.java:72` - -**代码**: -```java -if (uri.toLowerCase().contains(entry.getKey().toLowerCase()) - && !seen.contains(entry.getKey())) { -``` - -**WHY 需要优化**: - -使用 `String.contains()` 而非精确匹配来关联扩展 URI 与 handler。当前能工作是因为: -- `"https://projects.tmforum.org/.../Task-T/v1".toLowerCase()` 包含 `"task-t"` -- `"https://projects.tmforum.org/.../NEGOTIATION-T".toLowerCase()` 包含 `"negotiation-t"` - -但 `contains` 是宽松匹配。如果未来出现名为 `"Task-T-Extended"` 的扩展 URI,它也会匹配 `"Task-T"` handler,导致错误的 handler 被调用。 - -**HOW 优化方向**: - -改为精确匹配扩展 URI 中的路径段: -```java -// 提取 URI 最后一个路径段(如 "Task-T" 或 "NEGOTIATION-T") -String uriSegment = uri.substring(uri.lastIndexOf('/') + 1).toLowerCase(); -if (uriSegment.equals(entry.getKey().toLowerCase())) { - // ... -} -``` - -或者将 handler 的 `extensionKeyword()` 改为返回完整 URI,用 `equals` 匹配。 - -**核验要点**: 确认 A2A-T 规范中扩展 URI 的命名规则。如果 URI 路径段就是扩展名,上述方案可行。如果 URI 格式可能变化,需要更健壮的匹配逻辑。 - ---- - -### P2-4: `A2ATransport.buildClientCallContext` 手动调用 ExtensionInterceptor - -**文件**: `A2ATransport.java:483-498` - -**代码**: -```java -List interceptors = authManager.buildInterceptors(agentCard, agentName); -for (ClientCallInterceptor interceptor : interceptors) { - if (interceptor instanceof ExtensionInterceptor extInterceptor) { - try { - ClientCallContext interceptCtx = new ClientCallContext(new HashMap<>(), headers); - PayloadAndHeaders ph = extInterceptor.intercept( - "message/send", messageMetadata, headers, null, interceptCtx); - headers.putAll(ph.getHeaders()); - } catch (Exception e) { - log.warn("[Transport] Extension interceptor failed: {}", e.getMessage()); - } - } -} -``` - -**WHY 需要关注**: - -1. `buildInterceptors()` 返回 auth interceptor + extension interceptor 列表,但循环只调用 `ExtensionInterceptor`,跳过 `AuthInterceptor` / `CustomAuthInterceptor`。Auth 头由同方法中的 `applyAuthHeaders()` 独立设置。这意味着 `AuthInterceptor` 和 `CustomAuthInterceptor` 被构建但从未通过此路径调用——它们是事实上的死代码(对 `A2ATransport` 路径而言)。 - -2. `intercept()` 的第 4 个参数 `agentCard` 传 `null`。`ExtensionInterceptor.intercept()` 当前不使用 agentCard,所以功能正确,但语义上不应传 null。 - -3. 拦截器异常被 catch + warn 吞没,`A2A-Extensions` 头不会设置。如果扩展头缺失,Agent 可能不识别扩展语义。这个 fallback 行为是否合理取决于 Agent 的实现。 - -**HOW 优化方向**: - -这不是 bug,但设计可以简化: -- 如果 SDK 的 `Client` 支持注册 `ClientCallInterceptor`(通过 `Client.builder().addInterceptor()`),应让 SDK 调用拦截器,而非手动调用 -- 移除 `buildInterceptors()` 中返回 auth interceptor 的逻辑(因为 `applyAuthHeaders()` 已处理 auth),只返回 `ExtensionInterceptor` -- 或者将 `applyAuthHeaders()` 的逻辑移入 `CustomAuthInterceptor.intercept()`,统一通过拦截器链处理 - -**核验要点**: 确认 a2a-java SDK 的 `Client` 是否支持 `addInterceptor()`。如果支持,应迁移到 SDK 拦截器链。如果不支持,当前手动调用方式可接受,但应清理 auth interceptor 的冗余构建。 - ---- - -### P2-5: `DefaultControlPoint.onTask` 用空文本判断成功 - -**文件**: `DefaultControlPoint.java:66` - -**代码**: -```java -boolean success = r.getText() != null && !r.getText().isEmpty(); -``` - -**WHY 需要优化**: - -`onTask` 的默认实现用"响应文本非空"判断任务是否成功。但: -- Agent 可能返回空文本但 Task 状态为 `TASK_STATE_COMPLETED`(例如只返回 artifact 不返回文本) -- Agent 可能返回非空文本但 Task 状态为 `TASK_STATE_FAILED`(例如错误消息) - -更准确的判断应使用 Task 状态: -```java -String state = r.getTaskState(); -boolean success = state != null && (state.contains("COMPLETED")); -``` - -**HOW 优化方向**: - -```java -boolean success = r.getText() != null && !r.getText().isEmpty() - && (r.getTaskState() == null || r.getTaskState().contains("COMPLETED")); -``` - -注意: 这是 `DefaultControlPoint` 的默认实现,工作台通常会重写 `onTask`。但如果使用默认实现,当前的判断逻辑可能产生误报。 - -**核验要点**: 确认是否有使用 `DefaultControlPoint` 而非自定义 `ControlPoint` 的场景。如果所有工作台都重写了 `onTask`,此问题不影响生产。 - ---- - -### P2-6: `ExtensionSender.sendNotification` 的 `eventCallback` 与 `sendExtensionMessage` 接口不一致 - -**文件**: `ExtensionSender.java:56-83` - -**WHY 需要关注**: - -`ExtensionSender` 接口有两个 Notification-T 便利方法: -- `sendNotification(agent, instruction, input, Consumer> callback)` — 带回调 -- `sendNotification(agent, instruction, input)` — 不带回调 - -default 实现中,带回调版本调用 `sendExtensionMessage(...)` — 不传回调。不带回调版本调用带回调版本,传 `null`。 - -`DefaultExtensionSender` 重写了带回调版本(line 122),正确处理回调。但接口 default 方法的设计导致了 P1-7 的问题(回调被丢弃)。 - -此外,`sendExtensionMessage` 是接口的核心方法,它不接受 `eventCallback` 参数。Notification-T 的回调语义只在 `DefaultExtensionSender` 的重写方法中存在,接口层没有强制。如果未来有其他实现,Notification-T 的回调行为不可保证。 - -**HOW 优化方向**: - -将 `sendNotification(agent, instruction, input, callback)` 提升为接口方法(非 default),去掉 `sendExtensionMessage` 中对 Notification-T 的处理。让 Notification-T 有独立的接口契约: - -```java -CompletableFuture sendNotification( - String agentName, String instruction, String naturalLanguageInput, - Consumer> eventCallback); -``` - -**核验要点**: 确认是否有调用方直接使用 `sendExtensionMessage` 发送 Notification-T。如果有,需要保持兼容。 - ---- - -## 附录: 已确认良好的设计 - -以下设计经检视确认合理,无需修改: - -1. **`ConversationScopedA2AJavaClientRuntime` 接口**: 简洁的会话生命周期钩子,`closeConversation` 在 `DefaultWorkflowEngineClient.sendMessage` 的 `whenComplete` 中调用,确保协商循环结束后释放网关会话 -2. **`A2ATExtension` 枚举**: 封装完整 URI,避免散落的字符串常量 -3. **`ExtensionInterceptor`**: 只注入当前消息 metadata 中实际存在的扩展 URI,而非 AgentCard 上声明的所有扩展——正确行为 -4. **`NegotiationTHandler.afterReceive`**: 使用最新 SDK 的无状态 - `validateProposePromptAndDataFilling` 校验提议内容并提取参数;缺少或非法 - `negotiationContext` 时失败关闭;执行引擎不调用旧状态机入口 - `receiveNegotiation`,也不保留绕过 SDK 的提取 fallback -5. **`DefaultWorkflowEngineClient.autoNegotiate`**: 递归协商循环有 `maxNegotiationRounds` 上限(默认 3),防止无限循环 -6. **`SslContextFactory` mTLS 支持**: `loadKeyManagers` 支持客户端证书 + 私钥,RSA 密钥格式正确 -7. **`CredentialCrypto` AES-GCM**: 使用 12 字节 IV + 128 位 tag,符合 NIST 推荐;IV 使用 `SecureRandom` 生成,无 IV 重用风险 -8. **`ProtocolLogger`**: 独立 logger 名 "PROTOCOL",`INDENT_OUTPUT` 已启用,与引擎其他日志可独立控制级别 -9. **`WorkflowExecutor.anySuccess`**: ANY_SUCCESS 语义正确——首个成功即返回,取消其余;全部失败才返回失败 -10. **`EnvFileLoader`**: 不覆盖已存在的 OS 环境变量和系统属性,优先级正确 - ---- - -## 检视总结 - -| 优先级 | 编号 | 一句话描述 | 影响面 | -|--------|------|-----------|--------| -| P0-1 | 全局禁用主机名验证 | 安全:同 JVM 所有 HTTPS 失去主机名校验 | 生产环境安全风险 | -| P0-2 | 登录端点 trust-all SSL | 安全:凭证交换无 TLS 保护 | 凭证泄露风险 | -| P0-3 | Notification-T 超时吞没错误 | 正确性:真实失败被伪装为订阅成功 | 故障不可见 | -| P0-4 | SSE compact JSON 破坏值内空白 | 正确性:文本内容可能被篡改 | 语义传递错误 | -| P1-1 | 3 个扩展 prompt 生成器空桩 | 功能:SDK prompt 能力未利用 | 取决于 SDK 是否提供方法 | -| P1-2 | 无界 CachedThreadPool | 资源:线程数无上限 | 高并发内存风险 | -| P1-3 | 每次 send 新建 Client | 性能:连接不可复用 | 高吞吐性能退化 | -| P1-4 | AutoConfig 参数硬编码 | 可配置性:超时/线程池不可外部化 | 集成方无法调优 | -| P1-5 | A2AProperties 属性不全 | 可配置性:配置散落 @Value | 集成体验差 | -| P1-6 | 延迟步骤 50ms 轮询 | 性能:忙等待 | 深依赖链延迟累积 | -| P1-7 | sendNotification 默认方法丢回调 | 设计:接口默认行为错误 | 其他实现可能丢回调 | -| P2-1 | 解密失败返回密文 | 健壮性:错误根因被掩盖 | 运维排障困难 | -| P2-2 | setHttpClient 空操作 | 健壮性:误导性 API | 调用方误以为已传播 | -| P2-3 | contains 匹配扩展 URI | 健壮性:宽松匹配 | 未来扩展名冲突 | -| P2-4 | 手动调用拦截器 | 设计:auth interceptor 冗余 | 代码复杂度 | -| P2-5 | 空文本判断成功 | 健壮性:误报成功 | 默认 ControlPoint 误判 | -| P2-6 | sendNotification 接口不一致 | 设计:回调契约不强制 | 其他实现行为不可保证 | diff --git a/docs/zh/notification-t-design-analysis.md b/docs/zh/notification-t-design-analysis.md deleted file mode 100644 index ea42afc7..00000000 --- a/docs/zh/notification-t-design-analysis.md +++ /dev/null @@ -1,143 +0,0 @@ -# Notification-T / Authorization-T 设计分析 - -> **已归档。** 本历史分析已整合到 [DESIGN.md](DESIGN.md) 的第 4 节(A2A-T 扩展模型)和第 7 节(交互时序)。下方“前置预定位/共享通道”表述已经过时:当前 Authorization-T、Notification-T 由 `ExtensionSender` 在独立业务时机发起,并与 Task-T 及彼此使用不同 transport/runtime/context。仅保留供历史参考。 - ---- - -> 本文分析 Notification-T 和 Authorization-T 的设计选型,提出长连接 SSE 方案, -> 并记录已确认的关键设计决策。 - ---- - -## 1. 概述 - -Notification-T 和 Authorization-T 是 A2A-T 的前置扩展,在工作流启动前通过 -`ExtensionSender` 一次性下发。它们不参与工作流内的 `sendMessage` 链。 - -- **Authorization-T** — 下发授权白名单策略。智能体存储策略,后续操作与白名单比对, - 在策略内直接执行,不在则拒绝。 -- **Notification-T** — 建立结果订阅。打开长连接 SSE 流,后续结果通过该流返回。 - ---- - -## 2. 当前架构 - -### 2.1 ExtensionSender - -`ExtensionSender` 提供三个方法: - -| 方法 | 扩展 | 用途 | -|----------------------------|-------------------|---------------------| -| `sendExtensionMessage` | 通用 | 发送任意扩展消息 | -| `sendAuthorization` | Authorization-T | 授权前置下发 | -| `sendNotification` | Notification-T | 通知订阅前置下发 | - -### 2.2 A2ATransport - -`A2ATransport` 暴露两个发送原语: - -- `send` — 收集并返回(用于 Authorization-T) -- `sendNotificationStream` — 长连接 SSE(用于 Notification-T) - -`sendNotificationStream` 在独立守护线程上运行,保持 SSE 流打开直到客户端断开或智能体关闭。 - -### 2.3 服务端处理 - -服务端的 `NegotiationBaseAgentExecutor`(参考实现,在 samples 中)通过 -`PrePositionedExtensionHandler` 处理前置消息: - -1. 检测消息 metadata 中的扩展 URI(Authorization-T 或 Notification-T) -2. 存储策略文本(Authorization-T)或保持 SSE 流打开(Notification-T) -3. 发送确认产物,立即完成任务 - ---- - -## 3. 设计分析 - -### 3.1 Authorization-T - -Authorization-T 是一次性操作:下发策略 → 服务端确认 → 完成。不需要长连接。 - -流程: -```mermaid -graph LR - ES["ExtensionSender.sendAuthorization"] - TR["A2ATransport.send"] - SR["服务端存储策略"] - ACK["返回 ack"] - DONE["任务完成"] - - ES --> TR --> SR --> ACK --> DONE -``` - -### 3.2 Notification-T - -Notification-T 需要长连接:订阅 → 流保持打开 → 后续结果通过该流返回。 - -设计选择:**在独立守护线程上维持长连接 SSE 流**。 - -```mermaid -graph TD - ES["ExtensionSender.sendNotification"] - TR["A2ATransport.sendNotificationStream"] - DT["守护线程启动"] - ACK["服务端返回 ack"] - FUT["future 完成"] - OPEN["流保持打开"] - PUSH["后续结果通过同一 SSE 流推送"] - END["客户端断开或服务端关闭时流结束"] - - ES --> TR --> DT --> ACK --> FUT --> OPEN --> PUSH --> END -``` - ---- - -## 4. 提议设计:Notification-T 长连接 SSE - -### 4.1 客户端 - -`A2ATransport.sendNotificationStream` 方法: - -1. 创建守护线程(`"notif-t-" + agentName`) -2. 在线程内调用 `a2aClientRuntime.sendMessage()` — SDK 发起 SSE 请求 -3. 对每个 SSE 事件调用 `eventSink` 回调 -4. 第一个事件(订阅确认)完成后 future -5. 流保持打开,后续事件持续推送 - -### 4.2 服务端 - -`NegotiationBaseAgentExecutor.handleNotificationSubscription` 方法: - -1. 发送确认产物("已订阅") -2. 进入循环:`notificationQueue.poll(30, SECONDS)` -3. 有结果时,构建带 Notification-T URI 的 metadata,通过 `emitter.addArtifact` 推送 -4. 无结果时继续等待 -5. 线程中断时退出(智能体关闭) - -### 4.3 结果推送 - -业务代码通过 `pushNotificationResult(result)` 将结果推入队列, -`handleNotificationSubscription` 从队列取出并通过 SSE 流推送。 - ---- - -## 5. 关键设计决策(已确认) - -1. **Notification-T 用长连接 SSE,不用 webhook** — SSE 流由 SDK 管理, - 不需要额外的 webhook 端点或反向认证。流保持打开直到客户端断开。 - -2. **Authorization-T 是一次性操作** — 下发策略,确认即完成。不需要长连接。 - -3. **sendNotificationStream 在守护线程上运行** — 不阻塞调用线程, - future 在第一个事件后完成,流在后台持续。 - -4. **服务端用 BlockingQueue 桥接** — 业务代码通过 `pushNotificationResult()` 推入队列, - SSE 消费者从队列取出推送。解耦业务逻辑和协议传输。 - -5. **不修改 A2A SDK** — Notification-T 的长连接能力完全在引擎层实现, - 不依赖 A2A SDK 的修改。SDK 的 `sendMessage` 已经支持 SSE 流式响应, - 引擎只是不主动关闭流而已。 - -6. **前置下发不参与工作流** — Authorization-T 和 Notification-T 通过 - `ExtensionSender` 在工作流启动前完成,不在 `WorkflowEngineClient.sendMessage` 链中。 - `ExtensionRegistry` 只自动注册 Task-T 和 Negotiation-T。 diff --git "a/docs/zh/\350\260\203\347\224\250\350\277\207\347\250\213.md" "b/docs/zh/\350\260\203\347\224\250\350\277\207\347\250\213.md" deleted file mode 100644 index 28e81823..00000000 --- "a/docs/zh/\350\260\203\347\224\250\350\277\207\347\250\213.md" +++ /dev/null @@ -1,92 +0,0 @@ -## 过程数据 - -发送给SPN Domain Agent的数据: - -```json -{ - "message": { - "messageId": "0e3ba00e-df2f-4432-9d54-29b916410171", - "contextId": "c3b58035-7196-4987-a7a8-65bba7ff7f25", - "role": "ROLE_AGENT", - "parts": [ - { - "text": "## Runtime Context\n\nUser's original intent and scenario description:\nSPN专线故障诊断,已知信息有:`接入端口名称:P28499-中新水厂(960)-2-D3EM8F-3 障发生时间:2026-06-17T14:20:51Z 故障流水号:zhoujie-qwert-3 故障场景:专线中断`\n\n## Current Task\n请针对当前问题描述进行SPN专线业务投诉诊断\n\n请用中文回复。" - } - ], - "metadata": { - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1": "## 任务类型(Task Type)\nSPN专线投诉诊断\n\n## 任务描述(Task Description)\n要求:提供任务的整体概述,基于<任务对象>、<任务上下文>、<约束条件>进行投诉场景的网络侧故障根因诊断,达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n## 任务目标(Task Target)\n要求:\n1. 基于专线业务的故障现象,诊断并返回网络侧的故障根因\n\n## 任务对象(Task Object)\n接入端口名称:P28499-中新水厂(960)-2-D3EM8F-3\n\n## 任务上下文(Task Context)\n故障现象:专线中断。故障发生时间:2026-06-17T14:20:51Z。故障流水号:adsr-4gds-534g-1234。\n\n## 预期输出(Expected Output)\n" - } - } -} -``` - -SPN Domain Agent回复任务已创建: - -```json -{ - "task": { - "id": "146b73b3-636a-435d-b9bd-45a201df92ec", - "contextId": "c3b58035-7196-4987-a7a8-65bba7ff7f25", - "status": { - "state": "TASK_STATE_SUBMITTED", - "message": { - "messageId": "9d78ed69-016c-4b9c-a255-7419e02a78d7", - "contextId": "c3b58035-7196-4987-a7a8-65bba7ff7f25", - "taskId": "146b73b3-636a-435d-b9bd-45a201df92ec", - "role": "ROLE_AGENT", - "parts": [ - { - "text": "业务投诉诊断任务创建成功,启动网络侧故障根因诊断分析。", - "metadata": {} - } - ], - "metadata": {} - }, - "timestamp": "2026-06-17T09:16:15.526127602Z" - }, - "history": [ - { - "messageId": "0e3ba00e-df2f-4432-9d54-29b916410171", - "contextId": "c3b58035-7196-4987-a7a8-65bba7ff7f25", - "taskId": "146b73b3-636a-435d-b9bd-45a201df92ec", - "role": "ROLE_AGENT", - "parts": [ - { - "text": "## Runtime Context\n\nUser's original intent and scenario description:\nSPN专线故障诊断,已知信息有:`接入端口名称:P28499-中新水厂(960)-2-D3EM8F-3 障发生时间:2026-06-17T14:20:51Z 故障流水号:zhoujie-qwert-3 故障场景:专线中断`\n\n## Current Task\n请针对当前问题描述进行SPN专线业务投诉诊断\n\n请用中文回复。", - "metadata": {} - } - ], - "metadata": { - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1": "## 任务类型(Task Type)\nSPN专线投诉诊断\n\n## 任务描述(Task Description)\n要求:提供任务的整体概述,基于<任务对象>、<任务上下文>、<约束条件>进行投诉场景的网络侧故障根因诊断,达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。\n\n## 任务目标(Task Target)\n要求:\n1. 基于专线业务的故障现象,诊断并返回网络侧的故障根因\n\n## 任务对象(Task Object)\n接入端口名称:P28499-中新水厂(960)-2-D3EM8F-3\n\n## 任务上下文(Task Context)\n故障现象:专线中断。故障发生时间:2026-06-17T14:20:51Z。故障流水号:zhoujie-qwert-3。\n\n## 预期输出(Expected Output)\n" - } - } - ], - "metadata": {} - } -} -``` - -SPN Domain Agent回复诊断结果: - -```json -{ - "artifactUpdate": { - "taskId": "146b73b3-636a-435d-b9bd-45a201df92ec", - "contextId": "c3b58035-7196-4987-a7a8-65bba7ff7f25", - "artifact": { - "artifactId": "ca6d90f9-5f67-4045-b14d-ac30fe58e6bd", - "name": "private-line-service-complain-diagnosis", - "parts": [ - { - "text": "业务投诉诊断任务诊断结果消息", - "metadata": {} - } - ], - "metadata": { - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1": "诊断结果为成功。\n诊断结果详情为:【摘要】P28499-中新水厂(960) 2板3 -1.1db 收光强光\n【详情】网元P28499-中新水厂(960) 端口2-D3EM8F-3(PORT-3)(输入光功率:-1.1,参考上门限:-3,参考下门限:-19;\n输出光功率:-6.1,参考上门限:-1.5,参考下门限:-12.5) 收光强光,原因是对端光模块故障导致发光异常、两侧光模块型号不一致、光衰设置不正确,本端发光正常。网元P28499-中新水厂(960) 端口2-D3EM8F-3(PORT-3)影响的业务为广州番禺南沙机楼 -广州增城中新水厂GE0143BS_LTE。\n故障根因1:系统判定根因对象为光模块硬件故障,对象标识为1522b06b-3e46-4041-99b9-2956168675c6,对象类型为:PRT,对象名称为:网元P28499-中新水厂(960) 端口2-D3EM8F-3(PORT-3),详细位置:网元P28499-中新水厂(960) 端口2-D3EM8F-3(PORT-3),故障根因详情:网元P28499-中新水厂(960) 端口2-D3EM8F-3(PORT-3)(输入光功率:-1.1,参考上门限:-3,参考下门限:-19;输出光功率:-6.1,参考上门限:-1.5,参考下门限:-12.5) 收光强光,原因是对端光模块故障导致发光异常、两侧光模块型号不一致、光衰设置不正确。 建议处理方式为:请上站检查或更换光模块。\n" - } - }, - "metadata": {} - } -} -``` \ No newline at end of file From 8744836aaa9d3f1401f4ccec5a85e0377ac789bf Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Thu, 27 Aug 2026 09:10:11 +0800 Subject: [PATCH 31/49] docs: clarify host integration architecture Signed-off-by: Zhoujie628 --- .../workflow-engine-surrounding-systems.mmd | 56 +++++++++++ docs/en/DESIGN.md | 87 ++++++++++++------ .../workflow-engine-surrounding-systems.png | Bin 0 -> 179548 bytes docs/zh/API_REFERENCE.md | 2 +- docs/zh/DESIGN.md | 80 +++++++++++----- docs/zh/INTEGRATION_GUIDE.md | 6 +- ...06\346\210\220\346\214\207\345\215\227.md" | 28 +++--- 7 files changed, 191 insertions(+), 68 deletions(-) create mode 100644 docs/diagrams/workflow-engine-surrounding-systems.mmd create mode 100644 docs/images/workflow-engine-surrounding-systems.png diff --git a/docs/diagrams/workflow-engine-surrounding-systems.mmd b/docs/diagrams/workflow-engine-surrounding-systems.mmd new file mode 100644 index 00000000..d255c752 --- /dev/null +++ b/docs/diagrams/workflow-engine-surrounding-systems.mmd @@ -0,0 +1,56 @@ +%%{init: {"flowchart": {"curve": "basis", "nodeSpacing": 55, "rankSpacing": 75}}}%% +flowchart TB + classDef external fill:#EEF4FF,stroke:#2563EB,color:#172554,stroke-width:1.5px + classDef host fill:#FFF7ED,stroke:#EA580C,color:#431407,stroke-width:1.5px + classDef sdk fill:#ECFDF5,stroke:#059669,color:#052E16,stroke-width:1.5px + classDef local fill:#F8FAFC,stroke:#64748B,color:#0F172A,stroke-dasharray:5 3 + classDef agent fill:#F5F3FF,stroke:#7C3AED,color:#2E1065,stroke-width:1.5px + + subgraph CONTROL["周边控制面与上游系统"] + direction LR + WAIMO["WAIMO
        上游任务发起方"]:::external + REG["注册中心 / Registry Center
        AgentCard 发布与查询"]:::external + ORCH["编排中心 / Orchestration Center
        工作流检索与 PSOP 加载"]:::external + end + + subgraph HOST["宿主智能体:传输工作台 / Workbench Agent"] + direction TB + ENTRY["A2A 服务端入口
        接收 Task-T,解析诊断意图"]:::host + ADAPTER["宿主集成层
        准备 AgentCard、Workflow、配置和业务上下文"]:::host + + subgraph ENGINE["嵌入式 Workflow Execution Engine SDK"] + direction TB + LOAD["发现辅助 API
        RegistryClient / LoadPsop"]:::sdk + RUN["工作流协议调度
        ExecutePsop → WorkflowExecutor
        Task-T + Negotiation-T"]:::sdk + CALLBACK["宿主回调接口
        ControlPoint / EventCallback / onFinish"]:::sdk + EXT["流程外协议操作
        ExtensionSender
        Authorization-T / Notification-T"]:::sdk + end + + BIZ["工作台业务实现
        业务接管、路由、汇总、持久化、通知处理"]:::host + end + + subgraph SAMPLE["Demo 本地替代路径(非生产数据源)"] + direction LR + LOCALCARD["本地 AgentCard JSON
        WorkbenchAgentCatalog"]:::local + LOCALPSOP["本地 PSOP fallback
        仅用于离线演示"]:::local + end + + subgraph DOWNSTREAM["下游业务智能体"] + OMCS["地市 OMC 智能体群
        地市 1 OMC | 地市 2 OMC"]:::agent + end + + WAIMO <-->|"Task-T 诊断任务 / 汇总 artifact"| ENTRY + ENTRY -->|"运行意图"| ADAPTER + REG -->|"生产:查询 AgentCard"| LOAD + ORCH -->|"search / load PSOP"| LOAD + LOCALCARD -.->|"Demo 替代注册中心"| ADAPTER + LOCALPSOP -.->|"Demo 检索失败时"| ADAPTER + LOAD -->|"发现结果交给宿主"| ADAPTER + ADAPTER -->|"Workflow + AgentCards"| RUN + RUN <-->|"业务决策与执行结果"| CALLBACK + CALLBACK <-->|"回调"| BIZ + ADAPTER -->|"独立业务时机"| EXT + + RUN <-->|"向两地市并行 Task-T
        必要时 Negotiation-T / 返回诊断结果"| OMCS + EXT <-->|"Authorization-T 一次性授权
        Notification-T 长连接订阅与结果 SSE"| OMCS + BIZ -->|"汇总结果"| ENTRY diff --git a/docs/en/DESIGN.md b/docs/en/DESIGN.md index 39a8a569..22bd4bc4 100644 --- a/docs/en/DESIGN.md +++ b/docs/en/DESIGN.md @@ -27,7 +27,35 @@ The guiding principle is a clean separation between **mechanics the SDK owns** a --- -## 2. Layered Architecture +## 2. Host Integration and Surrounding Systems + +![Workflow engine and surrounding-system dependencies](../images/workflow-engine-surrounding-systems.png) + +The SDK in this diagram is a library embedded in the host-agent process, not a separately deployed +orchestration service. In `SpringSpnDemo`, the transport workbench is the host agent: + +1. WAIMO invokes the workbench's A2A server endpoint with a Task-T diagnosis request. +2. The workbench prepares execution inputs. In production it obtains downstream AgentCards from the + registry center and searches/loads a PSOP from the orchestration center. The SDK offers optional + `RegistryClient` and `LoadPsop` helpers, while discovery timing, caching, and failure policy remain + host responsibilities. +3. The workbench passes the `Workflow`, AgentCards, runtime intent, and business callbacks to + `ExecutePsop`. The engine walks the DAG, dispatches both city tasks in parallel, and handles + Negotiation-T when needed. `ControlPoint` returns control to the workbench for local aggregation, + routing, and clarification decisions. +4. The workbench returns the aggregate as a Task-T artifact and terminal status to WAIMO. +5. Authorization-T and Notification-T are invoked by the workbench at independent business times + through `ExtensionSender`. They are outside the PSOP DAG and do not share a transport, runtime, or + context with workflow tasks. + +For offline execution, the demo loads AgentCards from the classpath through `WorkbenchAgentCatalog` +and uses a local PSOP fallback only if orchestration-center search or load fails. These are sample +substitutes, not production discovery or disaster-recovery rules. The editable diagram source is +[`docs/diagrams/workflow-engine-surrounding-systems.mmd`](../diagrams/workflow-engine-surrounding-systems.mmd). + +--- + +## 3. Layered Architecture The SDK is structured in four layers. Each layer builds on the one below; each has a single responsibility and a clear entry point. @@ -43,7 +71,7 @@ graph TD L0 -.-> F ``` -### 2.1 Layer 0 - Communication +### 3.1 Layer 0 - Communication This layer is the heart of the transport-facade split. @@ -74,7 +102,7 @@ orchestration concern isolated. The implementation is reusable, but instances ar Authorization-T, and Notification-T each require an independent transport/runtime/context. Authorization uses a one-shot lifetime; Notification uses a workbench-scoped long-lived lifetime. -### 2.2 Layer 1 - Traversal +### 3.2 Layer 1 - Traversal **`WorkflowExecutor`** walks the DAG. At each step it assembles upstream context (`ContextBuilder`), dispatches subtasks concurrently, applies the step's success policy, and determines the next step (s). It delegates every *decision* to @@ -88,7 +116,7 @@ Step dispatch rules: - `ANY_SUCCESS` - the first successful subtask wins; the rest are cancelled. - `SELF_LOOP` - the task is handled locally via `onSelfTask`, with no A2A-T message sent to the agent. -### 2.3 Layer 2 - Orchestration +### 3.3 Layer 2 - Orchestration **`ExecutePsop`** is the high-level runner. It wraps the executor with a lifecycle (start / complete / error / close), event serialization, client-disconnect cancellation, and an @@ -96,11 +124,11 @@ Step dispatch rules: --- -## 3. Decision Interfaces +## 4. Decision Interfaces The SDK exposes two user-implemented interfaces, split by responsibility. -### 3.1 ControlPoint - flow decisions +### 4.1 ControlPoint - flow decisions Drives the workflow forward. Each method is called by the executor or the auto-negotiate loop and makes exactly one decision: @@ -117,11 +145,11 @@ Authorization-T and Notification-T are independently triggered workbench protoco --- -## 4. A2A-T Extension Model +## 5. A2A-T Extension Model Four A2A-T extensions are supported. They divide into two groups by lifecycle. -### 4.1 In-workflow extensions +### 5.1 In-workflow extensions Participate in every `sendMessage` lifecycle through the extension handler chain (`ExtensionRegistry` pre-registers both): @@ -134,7 +162,7 @@ both): `ControlPoint.onNegotiation` for a clarification, resends the follow-up, and repeats up to a configured round limit. -### 4.2 Independent-lifecycle extensions +### 5.2 Independent-lifecycle extensions Triggered through `ExtensionSender` at a workbench-selected business time and decoupled from any single workflow transport: @@ -148,7 +176,7 @@ The subscription ACK is exposed by `NotificationSubscription.acknowledgement()` events flow through the subscription callback. They do not enter workflow `ControlPoint` callbacks or the global task-event stream. -### 4.3 Extension handler chain +### 5.3 Extension handler chain ```mermaid graph TD @@ -168,7 +196,7 @@ independent-lifecycle concern. --- -## 5. Condition Routing +## 6. Condition Routing A step's `next` list holds `JumpCondition(step, condition)` entries. The routing rule is: @@ -182,7 +210,7 @@ This makes conditional branches an N-choose-1 selection and keeps unconditional --- -## 6. Event Model +## 7. Event Model Events are emitted to an optional `EventCallback` as stable string types (`EventType`). They are grouped by origin: @@ -192,16 +220,20 @@ Events are emitted to an optional `EventCallback` as stable string types (`Event `workflow_complete` - **Agent traffic** - `agent_request`, `agent_response`, `agent_status_update`, `agent_artifact_update`, `agent_message_event` -- **A2A-T extensions** - `negotiation_request`, `negotiation_resolved`, - `negotiation_failed`, `authorization_request`, `authorization_resolved`, - `notification` +- **In-workflow A2A-T extensions** - `negotiation_request`, `negotiation_resolved`, + `negotiation_failed` - **Failure** - `error`, emitted on step failure by the executor and on final failure by the runner +`authorization_request`, `authorization_resolved`, and `notification` currently remain reserved +`EventType` constants; the workflow event stream does not emit them. Independent authorization +results are handled through the `ExtensionSender` result, and subscription events through the +`NotificationSubscription` callback. + --- -## 7. Interaction Sequences +## 8. Interaction Sequences -### 7.1 Workflow execution with negotiation +### 8.1 Workflow execution with negotiation ```mermaid sequenceDiagram @@ -224,7 +256,7 @@ sequenceDiagram E->>H: ExecutionResult ``` -### 7.2 Pre-positioning authorization +### 8.2 Independent authorization ```mermaid sequenceDiagram @@ -242,7 +274,7 @@ sequenceDiagram ES-->>H: result ``` -### 7.3 Notification subscription +### 8.3 Notification subscription ```mermaid sequenceDiagram @@ -251,28 +283,31 @@ sequenceDiagram participant T as Transport participant A as Agent - H->>ES: sendNotification - ES->>T: sendNotificationStream + H->>ES: openNotificationFromData + ES->>T: openNotificationStream T->>A: open long-lived SSE A-->>T: ack (working) - T-->>ES: first event -> future - ES-->>H: result + T-->>ES: ACK + ES-->>H: NotificationSubscription Note over T,A: later results stream back over the same connection ``` --- -## 8. Dependencies +## 9. Dependencies **Java SDK:** `org.a2aproject.sdk:a2a-java-sdk-client` (A2A protocol), `net.openan.a2at.sdk:a2a-t-client` (A2A-T extensions), Jackson, SLF4J, Lombok. -The SDK is standalone: it does not depend on the orchestration center. +The protocol send path has no hard dependency on an orchestration-center or registry-center client; +the host may pass locally constructed `Workflow` and AgentCard objects. In a standard production +integration, however, the host normally uses the SDK's `LoadPsop` and `RegistryClient` helpers, or its +own service clients, to access those centers before passing the discovered inputs to the engine. --- -## 9. Design Decisions Summary +## 10. Design Decisions Summary 1. **Reusable transport, two facades** - wire machinery written once on `A2ATransport`; `WorkflowEngineClient` and `ExtensionSender` each own one orchestration concern and delegate wire diff --git a/docs/images/workflow-engine-surrounding-systems.png b/docs/images/workflow-engine-surrounding-systems.png new file mode 100644 index 0000000000000000000000000000000000000000..6d501ab1fd177b9dfa42430f851634fd12dc3d4c GIT binary patch literal 179548 zcmbrlbx>R17Y9g7ODVLpxYGi~t$6WL+%>oqcXv{tEd`1Mm*Q^0iaWG8!QF!f2?U1} z-@tc&GrKc8JF~O=k-Ya3a^F4o+;cvk&p9ttRaxdG78w>A8rn-aSxI#?G|Zo9XivXj zK1N-cdYiqChV~jwPEuUcJ8f_2xdFiA_U_0DH;d*420AzEZHskmUcv3gtGkbii@$&L z1mSr&tBfVT#b+ecP-FPZ^n1~jfpEa#iIQR`ZnoI9bJwJ*;@_D3H&6b4wKmAP-C6_< zFgr3{`iioS9E%KC96{ZnTUQsNCksyP&r!qw_b2SFg8jc^v>L4Mr2jkls`cqT`u|Qw z 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 bkcROM2wPI7d!m&Qs(liovaj-m-+uUSUYkX6 literal 0 HcmV?d00001 diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index b6a55d60..a876c776 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -90,7 +90,7 @@ public interface WorkflowEngineClient { } ``` -> 前置操作位于 `ExtensionSender`,不在本接口:Authorization-T 是一次性请求,Notification-T 是长连接订阅。见下方 `ExtensionSender` 章节。 +> 流程外操作位于 `ExtensionSender`,不在本接口:Authorization-T 是一次性请求,Notification-T 是长连接订阅。见下方 `ExtensionSender` 章节。 #### sendMessage diff --git a/docs/zh/DESIGN.md b/docs/zh/DESIGN.md index cfcb6e50..ae5ca137 100644 --- a/docs/zh/DESIGN.md +++ b/docs/zh/DESIGN.md @@ -25,7 +25,32 @@ SDK 负责协议机制(消息发送、流式传输、认证、Task-T 提示词 --- -## 2. 分层架构 +## 2. 周边系统依赖与宿主集成 + +![工作流执行引擎与周边系统依赖架构](../images/workflow-engine-surrounding-systems.png) + +上图中的 SDK 是**嵌入宿主智能体进程的库**,不是独立部署的编排服务。当前 +`SpringSpnDemo` 的宿主是传输工作台智能体,实际责任链如下: + +1. WAIMO 通过 A2A Task-T 调用工作台的服务端入口。 +2. 工作台准备执行输入:生产环境从注册中心获取下游 AgentCard,并根据任务意图从编排中心 + 搜索、加载 PSOP;SDK 提供可选的 `RegistryClient` 和 `LoadPsop` 辅助 API,但何时发现、如何缓存、 + 失败策略均由宿主决定。 +3. 工作台把 `Workflow`、AgentCard、运行意图和业务回调交给 `ExecutePsop`。执行引擎遍历 DAG, + 并行向两个地市 OMC 下发任务,并在必要时处理 Negotiation-T;工作台通过 `ControlPoint` 接管 + 本地汇总、路由和澄清等业务操作。 +4. 工作台把汇总结果作为 Task-T artifact 和完成状态返回 WAIMO。 +5. Authorization-T 与 Notification-T 由工作台在各自业务时机通过 `ExtensionSender` 单独触发, + 不属于 PSOP DAG,也不与工作流任务复用 transport/runtime/context。 + +Demo 为保证离线可运行,`WorkbenchAgentCatalog` 从 classpath 加载 AgentCard;仅在编排中心搜索或 +加载失败时使用本地 PSOP fallback。这两项是样例替代路径,不代表生产环境的数据来源或容灾策略。 +图的可编辑源文件见 +[`docs/diagrams/workflow-engine-surrounding-systems.mmd`](../diagrams/workflow-engine-surrounding-systems.mmd)。 + +--- + +## 3. 分层架构 SDK 分为四层,每层构建在下一层之上,单一职责,入口清晰。 @@ -40,7 +65,7 @@ graph TD L0 -.-> F ``` -### 2.1 Layer 0 - 通信层 +### 3.1 Layer 0 - 通信层 **`A2ATransport`** 是共享通信层,只负责一件事:把字节发到远程智能体再收回来。 它拥有 A2A SDK 客户端运行时、认证管理器和拦截器、智能体卡片映射、流式响应消费者。 @@ -64,7 +89,7 @@ graph TD 必须各自创建 transport/runtime/context。Task-T 使用任务级实例,Authorization-T 一次请求后释放, Notification-T 使用工作台级长连接实例。 -### 2.2 Layer 1 - 遍历层 +### 3.2 Layer 1 - 遍历层 **`WorkflowExecutor`** 遍历 DAG。在每个步骤组装上游上下文(`ContextBuilder`), 并发下发子任务,应用步骤成功策略,确定下一步。所有决策委托给 `ControlPoint`, @@ -77,18 +102,18 @@ Notification-T 使用工作台级长连接实例。 - `ANY_SUCCESS` — 第一个成功的子任务胜出,其余取消 - `SELF_LOOP` — 任务由 `onSelfTask` 本地处理,不发送 A2A-T 消息 -### 2.3 Layer 2 - 编排层 +### 3.3 Layer 2 - 编排层 **`ExecutePsop`** 是高层运行器。包装执行器,提供生命周期管理(启动/完成/错误/关闭)、 事件序列化、客户端断连取消、`onFinish` 持久化钩子。大多数集成使用这一层。 --- -## 3. 决策接口 +## 4. 决策接口 SDK 暴露两个用户实现的接口,按职责拆分。 -### 3.1 ControlPoint — 流程决策 +### 4.1 ControlPoint — 流程决策 驱动工作流前进。每个方法由执行器或自动协商循环调用,做恰好一个决策: @@ -103,11 +128,11 @@ Authorization-T 和 Notification-T 是工作台独立触发的协议操作,不 --- -## 4. A2A-T 扩展模型 +## 5. A2A-T 扩展模型 支持四个 A2A-T 扩展,按生命周期分为两组。 -### 4.1 工作流内扩展 +### 5.1 工作流内扩展 参与每次 `sendMessage` 生命周期,通过扩展处理器链(`ExtensionRegistry` 自动注册): @@ -117,7 +142,7 @@ Authorization-T 和 Notification-T 是工作台独立触发的协议操作,不 提取协商上下文和消息。这驱动自动循环:引擎调用 `ControlPoint.onNegotiation` 获取澄清, 重发后续消息,重复直到达到配置的轮次上限。 -### 4.2 独立生命周期扩展 +### 5.2 独立生命周期扩展 在工作台选定的业务时机通过 `ExtensionSender` 触发,并与单次工作流 transport 解耦: @@ -127,7 +152,7 @@ Authorization-T 和 Notification-T 是工作台独立触发的协议操作,不 订阅 ACK 从 `NotificationSubscription.acknowledgement()` 获取,后续抢通事件通过订阅回调返回。 它们不进入工作流的 `ControlPoint` 或全局任务事件流。 -### 4.3 扩展处理器链 +### 5.3 扩展处理器链 ```mermaid graph TD @@ -146,7 +171,7 @@ graph TD --- -## 5. 条件路由 +## 6. 条件路由 步骤的 `next` 列表持有 `JumpCondition(step, condition)` 条目。路由规则: @@ -159,7 +184,7 @@ graph TD --- -## 6. 事件模型 +## 7. 事件模型 事件通过可选的 `EventCallback` 以稳定字符串类型(`EventType`)发射,按来源分组: @@ -168,15 +193,18 @@ graph TD `task_status_changed`、`route_decision`、`workflow_complete` - **智能体流量** — `agent_request`、`agent_response`、`agent_status_update`、 `agent_artifact_update`、`agent_message_event` -- **A2A-T 扩展** — `negotiation_request`、`negotiation_resolved`、`negotiation_failed`、 - `authorization_request`、`authorization_resolved`、`notification` +- **工作流内 A2A-T 扩展** — `negotiation_request`、`negotiation_resolved`、`negotiation_failed` - **失败** — `error`,由执行器在步骤失败时和运行器在最终失败时发射 +`authorization_request`、`authorization_resolved` 和 `notification` 目前只是 `EventType` 中的保留常量, +工作流事件流不会发射它们。独立授权结果由 `ExtensionSender` 的返回值处理,订阅事件由 +`NotificationSubscription` 回调处理。 + --- -## 7. 交互序列 +## 8. 交互序列 -### 7.1 带协商的工作流执行 +### 8.1 带协商的工作流执行 ```mermaid sequenceDiagram @@ -199,7 +227,7 @@ sequenceDiagram E->>H: ExecutionResult ``` -### 7.2 前置下发授权 +### 8.2 独立下发授权 ```mermaid sequenceDiagram @@ -217,7 +245,7 @@ sequenceDiagram ES-->>H: result ``` -### 7.3 Notification 订阅 +### 8.3 Notification 订阅 ```mermaid sequenceDiagram @@ -226,27 +254,29 @@ sequenceDiagram participant T as Transport participant A as 智能体 - H->>ES: sendNotification - ES->>T: sendNotificationStream + H->>ES: openNotificationFromData + ES->>T: openNotificationStream T->>A: open long-lived SSE A-->>T: ack (working) - T-->>ES: first event -> future - ES-->>H: result + T-->>ES: ACK + ES-->>H: NotificationSubscription Note over T,A: 后续结果通过同一连接流回 ``` --- -## 8. 依赖 +## 9. 依赖 **本 SDK:** `org.a2aproject.sdk:a2a-java-sdk-client`(A2A 协议)、 `net.openan.a2at.sdk:a2a-t-client`(A2A-T 扩展)、Jackson、SLF4J、Lombok。 -SDK 是独立的:不依赖编排中心。 +SDK 不要求编排中心或注册中心客户端成为底层协议发送的硬依赖;宿主也可以直接传入本地构造的 +`Workflow` 和 AgentCard。但在标准生产集成中,宿主通常通过 SDK 的 `LoadPsop`、`RegistryClient` +或自己的服务客户端访问编排中心和注册中心,再把发现结果交给执行引擎。 --- -## 9. 设计决策总结 +## 10. 设计决策总结 1. **复用 transport 实现,两个门面** — 通信层机制在 `A2ATransport` 上写一次; `WorkflowEngineClient` 和 `ExtensionSender` 各自拥有一个编排职责,委托通信工作, diff --git a/docs/zh/INTEGRATION_GUIDE.md b/docs/zh/INTEGRATION_GUIDE.md index 28569ae7..3bb7b706 100644 --- a/docs/zh/INTEGRATION_GUIDE.md +++ b/docs/zh/INTEGRATION_GUIDE.md @@ -144,7 +144,11 @@ public class MyControlPoint extends DefaultControlPoint { `onNegotiation` 默认返回通用文本。只需覆盖你关心的方法。 -**前置操作(Authorization-T / Notification-T)**:两者都在工作流开始前通过 `ExtensionSender` 建立,但生命周期不同。Authorization-T 是响应完成即结束的一次性请求;Notification-T 是独立于单次工作流 transport 的长连接订阅,首个事件或 ACK 通过返回的 `SendMessageResult` 获取,后续事件通过回调持续接收,直到显式关闭订阅 transport。 +**流程外操作(Authorization-T / Notification-T)**:两者都由宿主在各自业务时机通过 +`ExtensionSender` 独立触发,与工作流没有固定先后关系。Authorization-T 是响应完成即结束的一次性请求; +Notification-T 是独立于单次工作流 transport 的长连接订阅,ACK 通过 +`NotificationSubscription.acknowledgement()` 获取,后续事件通过回调持续接收,直到收到目标结果、 +显式取消或宿主关闭订阅。 **自环节点(SelfLoop)**:当一个步骤是工作流执行智能体自身的任务(例如汇总多个智能体的诊断结果),把 `stepType` 设为 `SELF_LOOP`。引擎会调用 `onSelfTask` 本地处理,而不是通过 A2A-T 协议给智能体自己发消息。`onSelfTask` 不接收 `engineClient` diff --git "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" index c4580542..daaf8714 100644 --- "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" @@ -1,4 +1,4 @@ -# 编排中心工作流执行引擎 SDK 工作台集成指南 +# 工作流执行引擎 SDK 工作台集成指南 > 面向传输工作台研发团队。本文档以 SPN 专线故障跨城协同诊断联创场景为例,从工作台视角说明接入Workflow执行引擎 SDK 的完整路径。 @@ -8,19 +8,15 @@ ### 1.1 业务拓扑 -```mermaid -graph TD - UPPER["上层故障系统
        (上层业务系统)"] - WB["传输工作台智能体 (工作台)
        接入 workflow-engine-java SDK
        本文档的集成方"] - OMC1["SPN Domain Agent
        (城市1 OMC)"] - OMC2["SPN Domain Agent
        (城市2 OMC)"] - - UPPER -->|"A2A-T Task-T 下发原始诊断任务"| WB - WB -->|"A2A-T 下发诊断"| OMC1 - WB -->|"A2A-T 下发诊断"| OMC2 -``` +![工作流执行引擎与周边系统依赖架构](../images/workflow-engine-surrounding-systems.png) + +工作台是运行时编排宿主,执行引擎 SDK 嵌入工作台进程,不是独立部署的服务。编排中心负责 +PSOP 的存储、检索和加载,注册中心负责 AgentCard 的发布和查询;工作台负责在适当时机获取这些 +输入并交给 SDK。它接收 WAIMO 通过 A2A-T 下发的原始诊断任务,并行编排下游多地市 +SPN Domain Agent(OMC)执行故障根因诊断,汇总结果后上报。 -工作台是编排方。它接收 上层故障系统通过 A2A-T 下发的原始诊断任务,编排下游多地市 SPN Domain Agent (OMC) 执行故障根因诊断,汇总结果并上报。 +`SpringSpnDemo` 为便于离线运行,从 classpath 加载 AgentCard,并在编排中心不可用时使用本地 +PSOP fallback。这是样例特例,生产集成应以注册中心和编排中心为数据源,并自行定义受控的失败策略。 ### 1.2 四类 A2A-T 交互 @@ -61,7 +57,7 @@ graph LR S2["步骤 2
        准备配置文件
        AgentCard + 认证 + .env"] S3["步骤 3
        实现 AgentExecutor
        A2A 服务端入口"] S4["步骤 4
        实现 ControlPoint
        工作流决策回调"] - S5["步骤 5
        组装编排管线
        加载卡片 -> 预定位 -> 执行"] + S5["步骤 5
        组装运行管线
        发现输入 -> 执行工作流 / 独立扩展"] S1 --> S2 --> S3 --> S4 --> S5 ``` @@ -744,7 +740,9 @@ String psopId = results.get(0).getWorkflowId(); Workflow workflow = LoadPsop.load(orchUrl, psopId, null, sslVerify); ``` -如果搜索失败或编排中心不可用,应提供 fallback 工作流 ID 或本地加载。 +生产环境应明确编排中心不可用时的业务失败策略,不能默认执行可能过期的本地流程。 +`SpringSpnDemo` 为离线演示提供本地 PSOP fallback;接入方只有在完成版本一致性、审计和变更治理后, +才应自行设计受控的本地兜底。 #### 5.3 EventCallback -- 事件追踪 From 1dcb86eb6140680cf0c1fa30473b636ceeb4ff28 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Thu, 27 Aug 2026 18:35:33 +0800 Subject: [PATCH 32/49] feat(a2at): adopt SDK 1.0.0-841c575 typed negotiation and task dispatch Pin the A2A-T SDK to revision 841c575 and migrate the engine onto its current APIs: - NegotiationContext now carries a mandatory performative; context parsing rejects malformed or performative-less wire contexts - Replace the clarification control-string contract (data:/reject:/ abort: prefixes) with the typed NegotiationDecision model routed to the exact SDK fromText/fromData method; abortData validates the termination reason before invoking the SDK - Introduce NegotiationRequest as the typed business view of an INPUT_REQUIRED negotiation, carrying session/round/performative and the propose template kind - Introduce TaskDispatcher and TaskSubmission so business callbacks dispatch natural-language or structured Task-T input without manipulating protocol metadata - Update samples and bilingual docs to the typed APIs Signed-off-by: Zhoujie628 --- README.md | 28 +- docs/en/API_REFERENCE.md | 62 ++-- docs/en/DESIGN.md | 14 +- docs/en/DEVELOPER_GUIDE.md | 38 ++- docs/en/INTEGRATION_GUIDE.md | 31 +- docs/zh/A2AT-SDK-DEPENDENCY.md | 10 +- docs/zh/API_REFERENCE.md | 57 ++-- docs/zh/DESIGN.md | 17 +- docs/zh/DEVELOPER_GUIDE.md | 38 ++- docs/zh/INTEGRATION_GUIDE.md | 28 +- ...06\346\210\220\346\214\207\345\215\227.md" | 106 ++++--- pom.xml | 4 +- .../agents/NegotiationBaseAgentExecutor.java | 5 +- .../negotiation/NegotiationStrategy.java | 44 +-- .../negotiation/NegotiationUtils.java | 2 +- .../workbench/WorkbenchControlPoint.java | 26 +- .../workbench/WorkbenchOrchestrator.java | 2 +- .../engine/client/A2ATContentFacade.java | 18 +- .../workflow/engine/client/A2ATExtension.java | 2 +- .../client/DefaultWorkflowEngineClient.java | 273 +++++++++--------- .../engine/client/ExtensionHandler.java | 2 +- .../engine/client/NegotiationTHandler.java | 2 +- .../engine/client/WorkflowEngineClient.java | 32 +- .../workflow/engine/control/ControlPoint.java | 23 +- .../engine/control/DefaultControlPoint.java | 26 +- .../engine/control/NegotiationStrategy.java | 21 +- .../engine/control/TaskDispatcher.java | 33 +++ .../engine/model/NegotiationDecision.java | 126 ++++++++ .../engine/model/NegotiationRequest.java | 87 ++++++ .../workflow/engine/model/TaskSubmission.java | 112 +++++++ .../engine/client/A2ATContentFacadeTest.java | 33 ++- ...ltWorkflowEngineClientNegotiationTest.java | 49 ++-- .../engine/core/WorkflowExecutorTest.java | 51 ++-- .../engine/model/NegotiationDecisionTest.java | 49 ++++ .../engine/model/NegotiationRequestTest.java | 94 ++++++ .../engine/model/TaskSubmissionTest.java | 67 +++++ .../engine/runner/ExecutePsopTest.java | 16 +- 37 files changed, 1173 insertions(+), 455 deletions(-) create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/control/TaskDispatcher.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationDecision.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationRequest.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskSubmission.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationDecisionTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationRequestTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/model/TaskSubmissionTest.java diff --git a/README.md b/README.md index a8b2a688..3ebd0853 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ Central. Install the pinned SDK revision first (do not use an older locally cach ```bash git clone https://github.com/project-openan/a2a-t-sdk-java.git cd a2a-t-sdk-java -git checkout 34485504675800a436d68cfaa913f14931086506 -mvn -B -Drevision=1.0.0-3448550 -DskipTests \ +git checkout 841c575054d7aecc532e3fd312e004241727951b +mvn -B -Drevision=1.0.0-841c575 -DskipTests \ -pl a2a-t-client,a2a-t-server -am install ``` @@ -93,13 +93,19 @@ WorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); ControlPoint controlPoint = new DefaultControlPoint() { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - return engineClient - .sendMessage(request.getAgentName(), request.getMessage()) - .thenApply(r -> TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + TaskRequest request, TaskDispatcher dispatcher) { + return dispatcher + .dispatch(TaskSubmission.fromText( + request.getAgentName(), request.getMessage())) + .thenApply(r -> { + String state = r.getTaskState(); + boolean success = state == null || state.isBlank() + || "TASK_STATE_COMPLETED".equals(state); + return TaskResponse.builder() + .success(success) + .output(r.getText()) + .build(); + }); } }; @@ -161,9 +167,9 @@ graph TD | 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 | +| `control` | `ControlPoint`, `DefaultControlPoint`, `TaskDispatcher`, `EventCallback`, `EventType`, `NegotiationStrategy` | User-facing decision interfaces | | `core` | `WorkflowExecutor`, `ContextBuilder` | DAG traversal, context assembly | -| `model` | `Workflow`, `WorkflowStep`, `Task`, `TaskRequest`, `TaskResponse`, `StepType`, `JumpCondition`, `RouteDecision`, `ExecutionResult`, `SendMessageResult`, `TaskStatus`, `WorkflowSearchResult` | Data models | +| `model` | `Workflow`, `WorkflowStep`, `Task`, `TaskRequest`, `TaskSubmission`, `TaskResponse`, `NegotiationRequest`, `NegotiationDecision`, `ExecutionResult` | Data models | | `registry` | `LoadPsop`, `RegistryClient` | PSOP loading and AgentCard registry | | `runner` | `ExecutePsop` | Entry point for workflow execution | diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index 082d741b..6a8a7d45 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -153,23 +153,23 @@ Notification-T entry points require an explicit SDK template so callers can sele - **Trigger**: the response metadata carries the Negotiation-T extension key (the agent opened a negotiation via `Message(ROLE_AGENT)` + `INPUT_REQUIRED`) together with SDK-generated - `templateUri` and `negotiationContext={id,round,maxRounds}`. Missing fields fail closed. + `templateUri` and `negotiationContext={id,round,maxRounds,performative}`. Missing fields fail closed. - **Each round**: `validateProposePromptAndDataFilling` validates and extracts the Propose → `ControlPoint.onNegotiation` decides → the SDK content layer renders Accept / Reject / Abort → Accept is checked with `validateAcceptPromptAndDataFilling` before sending → the engine copies the ending message preserves the received Propose `NegotiationContext` and round → send → recurse. -- **Clarification conventions** (the onNegotiation return value): - - `data:{...json...}` — deterministic fromData Accept rendering (no LLM call; the JSON body is - the filled-parameter map) - - `reject:` / `abort:` — Reject / Abort terminal templates - - any other text — fromText Accept rendering (one LLM extraction step) +- **Typed decisions** (the `onNegotiation` return value): + - `NegotiationDecision.acceptText/acceptData` — Accept through SDK fromText/fromData + - `NegotiationDecision.rejectText/rejectData` — Reject through SDK fromText/fromData + - `NegotiationDecision.abortText/abortData` — Abort through SDK fromText/fromData + - String control prefixes such as `data:`, `reject:`, and `abort:` are not accepted. - **Round exhaustion**: beyond `maxNegotiationRounds` the loop stops, a terminal message is sent via the SDK abort template (best effort — delivery failures are logged only), a `NEGOTIATION_FAILED` event fires, and the last agent reply stands as the final response. - **Negotiation context**: the engine does not support the legacy `startNegotiation`, `receiveNegotiation`, or `continueNegotiation` state-machine entry points. It accepts only canonical `negotiationContext` with - `{id, round, maxRounds}` and does not read legacy `negotiation_context` metadata. + `{id, round, maxRounds, performative}` and does not read legacy `negotiation_context` metadata. #### Template queries @@ -342,7 +342,7 @@ message-generation / validation / template-query surface, one method per `A2ATCl |-------|---------|-------| | fromData generation | `generateProposeFromData` / `generateAcceptFromData` / `generateRejectFromData` / `generateAbortFromData` | deterministic rendering from typed data, no LLM | | fromText generation | `generateProposeFromText` / `generateAcceptFromText` / `generateRejectFromText` / `generateAbortFromText` | free text + one LLM extraction step | -| Stateless context | `MetadataContent.buildMetadataContent` / `NegotiationContext` | carry and advance `{id, round, maxRounds}` | +| Stateless context | `MetadataContent.buildMetadataContent` / `NegotiationContext` | carry `{id, round, maxRounds, performative}`; generation stamps the output performative | | Validate and fill | `validatePropose` / `validateAccept` / `validateReject` / `validateAbort` | rule gate + LLM semantic validation + param merge; requires a `NegotiationContext` (null = not a negotiation message) | | Template queries | `getPrompts` / `getNegotiationPrompts` / `getPrompt` / `getNegotiationPrompt` | catalog enumeration and lookup | | Utility | `toMetadata(MetadataContent)` | generated message → A2A metadata map (with `templateUri`, `negotiationContext` keys) | @@ -421,15 +421,29 @@ Map normalized = AgentCardNormalizer.normalize(rawMap); ## dev.openan.workflow.engine.control +### TaskDispatcher / TaskSubmission + +`onTask` receives only the narrow `TaskDispatcher` capability. Business code uses: + +```java +dispatcher.dispatch(TaskSubmission.fromText(agentName, text)); +dispatcher.dispatch(TaskSubmission.fromData( + agentName, instruction, data, schema, templateUri)); +``` + +The first form invokes SDK natural-language scenario recognition; the second invokes schema-aware +fromData. `TaskSubmission` validates the target, instruction, schema, and Task-T template and +defensively copies business data before it reaches the protocol layer. + ### ControlPoint User-facing decision interface. Each method has a single responsibility. ```java public interface ControlPoint { - // Send a Task-T message to an agent. Just call sendMessage. + // Take over a remote task and submit natural-language or structured Task-T input. CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient); + TaskRequest request, TaskDispatcher taskDispatcher); // Self-loop step: handled locally, no A2A-T message to self. default CompletableFuture onSelfTask(TaskRequest request); @@ -439,10 +453,9 @@ public interface ControlPoint { String stepName, Map results, List conditions); - // Provide clarification for negotiation. Default: generic text. - default CompletableFuture onNegotiation( - String agentName, String negotiationText, - Map receiveResult); + // Return a typed Accept / Reject / Abort decision. + default CompletableFuture onNegotiation( + NegotiationRequest request); } ``` @@ -451,16 +464,23 @@ public interface ControlPoint { | `onTask` | A step dispatches a task to another agent | `TaskResponse` (success + output) | | `onSelfTask` | A `SELF_LOOP` step runs locally (no A2A-T) | `TaskResponse` (success + output) | | `onRoute` | After step completes, before next step | `RouteDecision` (nextStep) | -| `onNegotiation` | When agent returns `INPUT_REQUIRED` | `String` clarification text | +| `onNegotiation` | When agent returns `INPUT_REQUIRED` | `NegotiationDecision` | + +`NegotiationRequest` exposes `agentName`, `concern`, `sessionId`, `round`, `maxRounds`, the typed +`NegotiationPerformative`, negotiation `kind`, `templateUri`, and read-only `metadata`. Only a +`PROPOSE` with a complete context reaches the callback; missing context, invalid rounds, or an +unsupported template fail closed. Business code only returns `acceptText/acceptData`, +`rejectText/rejectData`, or `abortText/abortData`; it does not send a message or construct protocol +metadata. ### DefaultControlPoint Default implementation with sensible defaults: -- `onTask`: calls `sendMessage()`, returns success/output +- `onTask`: dispatches `TaskSubmission.fromText`, returns success/output - `onSelfTask`: echoes the task message back (override for local logic) - `onRoute`: picks first non-terminal branch -- `onNegotiation`: returns generic clarification +- `onNegotiation`: returns a generic `acceptText` decision Extend this class and override only the methods you need. @@ -708,7 +728,7 @@ Task lifecycle status, used in `TASK_STATUS_CHANGED` events for cross-SDK consis | Notification-T | `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1` | Canonical negotiation metadata is `templateUri` plus -`negotiationContext={id,round,maxRounds}`. Legacy state-machine keys are not accepted. +`negotiationContext={id,round,maxRounds,performative}`. Legacy state-machine keys are not accepted. --- @@ -724,9 +744,9 @@ Canonical negotiation metadata is `templateUri` plus - Negotiation beyond `maxNegotiationRounds` rounds: a `NEGOTIATION_FAILED` event fires, a terminal message is sent to the agent via the SDK abort template (best effort), and the last agent reply stands as the final response; the loop does not continue. -- SDK content-generation failures for negotiation follow-ups degrade to the raw clarification text - (placed under the Negotiation-T key), logged at `WARN`. -- Auth failures (401) are logged as `ERROR` and auth headers are not set; the request proceeds without auth. +- SDK content-generation failures for negotiation follow-ups fail closed; raw business input is never + disguised as Negotiation-T metadata. +- Missing required credentials or authentication failures fail the request; it is never sent unauthenticated. - SSE stream errors after terminal events are logged at `DEBUG` level (expected behavior). --- diff --git a/docs/en/DESIGN.md b/docs/en/DESIGN.md index 22bd4bc4..d0bf5bfb 100644 --- a/docs/en/DESIGN.md +++ b/docs/en/DESIGN.md @@ -135,10 +135,10 @@ decision: | Method | Called by | Decision | |-----------------|------------------|----------------------------------------------| -| `onTask` | executor | Send a task to an agent (call `sendMessage`) | +| `onTask` | executor | Submit a natural-language or structured `TaskSubmission` | | `onSelfTask` | executor | Handle a self-loop task locally (no A2A-T) | | `onRoute` | executor | Choose a branch at a conditional step | -| `onNegotiation` | client auto-loop | Supply clarification on INPUT_REQUIRED | +| `onNegotiation` | client auto-loop | Return a typed decision on INPUT_REQUIRED | Authorization-T and Notification-T are independently triggered workbench protocol operations handled via `ExtensionSender`; they are not workflow DAG nodes. @@ -159,8 +159,8 @@ both): receive: pass-through. - **Negotiation-T** - On receive, when the agent returns `INPUT_REQUIRED` and declares the extension, extracts the negotiation context and message. This feeds the auto-loop: the engine calls - `ControlPoint.onNegotiation` - for a clarification, resends the follow-up, and repeats up to a configured round limit. + `ControlPoint.onNegotiation` for a `NegotiationDecision`, selects the matching SDK + fromText/fromData API, resends the follow-up, and repeats up to a configured round limit. ### 5.2 Independent-lifecycle extensions @@ -247,8 +247,8 @@ sequenceDiagram C->>A: before_send: Task-T A-->>C: INPUT_REQUIRED (Negotiation-T) C->>E: negotiation result - E->>H: onNegotiation (host supplies clarification) - H->>E: clarification + E->>H: onNegotiation(NegotiationRequest) + H->>E: NegotiationDecision E->>C: follow-up send C->>A: send follow-up A-->>C: final result @@ -318,7 +318,7 @@ own service clients, to access those centers before passing the discovered input Notification-T is a workbench-scoped long-lived subscription. The registry auto-registers only the in-workflow pair. -3. **Auto-negotiation loop** - the engine owns the resend loop so hosts only supply clarification text +3. **Auto-negotiation loop** - the engine owns the resend loop so hosts only supply a typed decision (`onNegotiation`), never the protocol mechanics of resending. 4. **Condition routing semantics** - empty conditions mean fan-out (parallel), conditional branches mean N-choose-1 via diff --git a/docs/en/DEVELOPER_GUIDE.md b/docs/en/DEVELOPER_GUIDE.md index f6ab1f56..b01a3208 100644 --- a/docs/en/DEVELOPER_GUIDE.md +++ b/docs/en/DEVELOPER_GUIDE.md @@ -35,12 +35,18 @@ Only two methods are required: public class MyControlPoint implements ControlPoint { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient client) { - return client.sendMessage(request.getAgentName(), request.getMessage()) - .thenApply(result -> TaskResponse.builder() - .success(true) - .output(result.getText()) - .build()); + TaskRequest request, TaskDispatcher dispatcher) { + return dispatcher.dispatch(TaskSubmission.fromText( + request.getAgentName(), request.getMessage())) + .thenApply(result -> { + String state = result.getTaskState(); + boolean success = state == null || state.isBlank() + || "TASK_STATE_COMPLETED".equals(state); + return TaskResponse.builder() + .success(success) + .output(result.getText()) + .build(); + }); } @Override @@ -56,7 +62,7 @@ public class MyControlPoint implements ControlPoint { } ``` -`onNegotiation` has a default that returns a generic clarification. Authorization and notification are independent protocol operations sent via dedicated `ExtensionSender`/transport instances at a workbench-selected business time, not `ControlPoint` callbacks or workflow DAG nodes. +`onNegotiation` has a default that returns a generic `acceptText` decision. Authorization and notification are independent protocol operations sent via dedicated `ExtensionSender`/transport instances at a workbench-selected business time, not `ControlPoint` callbacks or workflow DAG nodes. ## 4. Execute via Builder (recommended) @@ -132,10 +138,10 @@ ExecutionResult result = executor.run().join(); ### 6.1 Negotiation Auto-Loop -The engine client's `sendMessage()` automatically handles negotiation: +After dispatch, the engine client automatically handles negotiation: when the agent returns `INPUT_REQUIRED`, the engine extracts the negotiation text from response metadata, calls `ControlPoint.onNegotiation()` -for a clarification, and sends it back as a follow-up message. The loop repeats up to `maxNegotiationRounds` (default +for a typed decision, and sends it back as a follow-up message. The loop repeats up to `maxNegotiationRounds` (default 3). Override `onNegotiation()` in your `ControlPoint` to provide business-specific clarifications: @@ -143,15 +149,17 @@ Override `onNegotiation()` in your `ControlPoint` to provide business-specific c ```java @Override -public CompletableFuture onNegotiation( - String agentName, String negotiationText, - Map receiveResult) { - return myLlm.generate("Agent " + agentName + " needs: " + negotiationText) - .thenApply(Response::text); +public CompletableFuture onNegotiation( + NegotiationRequest request) { + return myLlm.generate( + "Agent " + request.agentName() + " needs: " + request.concern()) + .thenApply(Response::text) + .thenApply(NegotiationDecision::acceptText); } ``` -Return an empty/null string to fail the round (a `negotiation_failed` event is emitted and the loop retries). +Returning `null` fails the round. Use `acceptData/rejectData/abortData` for structured business values; invalid +names, blank values, and the literal string `"null"` fail before SDK invocation. ### 6.2 Workflow Model Fields diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index 7f60e77e..9f3ecefb 100644 --- a/docs/en/INTEGRATION_GUIDE.md +++ b/docs/en/INTEGRATION_GUIDE.md @@ -94,9 +94,9 @@ Extend `DefaultControlPoint` and override the methods you need: public class MyControlPoint extends DefaultControlPoint { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - return engineClient.sendMessage( - request.getAgentName(), request.getMessage()) + TaskRequest request, TaskDispatcher dispatcher) { + return dispatcher.dispatch(TaskSubmission.fromText( + request.getAgentName(), request.getMessage())) .thenApply(r -> { String state = r.getTaskState(); boolean success = state == null || state.isBlank() @@ -129,23 +129,24 @@ public class MyControlPoint extends DefaultControlPoint { } @Override - public CompletableFuture onNegotiation( - String agentName, String negotiationText, - Map receiveResult) { + public CompletableFuture onNegotiation( + NegotiationRequest request) { return CompletableFuture.completedFuture( - "Please proceed with available information."); + NegotiationDecision.acceptText( + "Please proceed with available information.")); } } ``` | Method | When Called | What You Do | |-------------------|-------------------------------------------|--------------------------------------------------| -| `onTask` | A step dispatches a task to another agent | Call `engineClient.sendMessage()`, return result | +| `onTask` | A step dispatches a task to another agent | Submit a typed `TaskSubmission` to `TaskDispatcher` | | `onSelfTask` | A `SELF_LOOP` step runs locally | Handle locally, return result (no A2A-T message) | | `onRoute` | After step completes, before next step | Pick the next step from candidates | -| `onNegotiation` | Agent returns `INPUT_REQUIRED` | Return clarification text | +| `onNegotiation` | Agent returns `INPUT_REQUIRED` | Return a typed `NegotiationDecision` | -`onNegotiation` defaults to a generic clarification. Override only what you need. +`onNegotiation` defaults to a generic `acceptText` decision. For structured business data use +`acceptData/rejectData/abortData`; never encode a decision in a string prefix. **Pre-positioning (Authorization-T / Notification-T)**: Both are established through `ExtensionSender` before a workflow starts, but they have different lifecycles. Authorization-T is a one-shot request that ends after its response. Notification-T is a long-lived subscription on a transport independent of any single workflow; its first event or acknowledgement is returned as `SendMessageResult`, and later events are delivered to the callback until that subscription transport is explicitly closed. @@ -402,14 +403,14 @@ The engine handles four A2A-T extensions automatically. You do not need to deal ### Task-T (automatic) -When sending a message to an agent, the engine generates a structured task prompt and places it in the message metadata. -In `onTask`, you just call -`sendMessage()` -- prompt generation is transparent. +In `onTask`, select `TaskSubmission.fromText(...)` or `TaskSubmission.fromData(...)` and pass it to +`TaskDispatcher.dispatch(...)`. The engine calls the matching A2A-T SDK API and injects the generated metadata. ### Negotiation-T (automatic) -When an agent returns `INPUT_REQUIRED`, the engine extracts the negotiation text, calls your `onNegotiation()` for a -clarification, and sends it back. Auto-loops up to `maxNegotiationRounds` (default 3). +When an agent returns `INPUT_REQUIRED`, the engine creates a `NegotiationRequest`, calls your +`onNegotiation()` for a typed Accept/Reject/Abort decision, invokes the matching SDK fromText/fromData API, +and sends the rendered follow-up. Auto-loops up to `maxNegotiationRounds` (default 3). ### Authorization-T (pre-positioning) diff --git a/docs/zh/A2AT-SDK-DEPENDENCY.md b/docs/zh/A2AT-SDK-DEPENDENCY.md index aaa7d248..d9a4224a 100644 --- a/docs/zh/A2AT-SDK-DEPENDENCY.md +++ b/docs/zh/A2AT-SDK-DEPENDENCY.md @@ -3,8 +3,8 @@ 执行引擎首发版本只支持下列最新 A2A-T SDK 源码基线,不提供旧 SDK 兼容层: - 仓库:`project-openan/a2a-t-sdk-java` -- Git commit:`34485504675800a436d68cfaa913f14931086506` -- 本地 Maven 版本:`1.0.0-3448550`(精确提交构建的不可变本地版本) +- Git commit:`841c575054d7aecc532e3fd312e004241727951b` +- 本地 Maven 版本:`1.0.0-841c575`(精确提交构建的不可变本地版本) - 使用组件:`a2a-t-client`、`a2a-t-server` 及其传递依赖 ## 已知上游生命周期缺口 @@ -17,7 +17,7 @@ Task-T、Authorization-T、Notification-T 和直连业务链路均可正常完 上游 SDK 应在新提交中补充幂等 `close()`,由 `A2ATClient/A2ATServer` 级联关闭其拥有的 LLM provider;执行引擎届时应升级到该新提交对应的新 Maven 版本。禁止在不改变版本号的 -情况下覆盖 `1.0.0-3448550`,否则 IDEA 与 CI 将得到不可复现的同坐标异内容制品。 +情况下覆盖 `1.0.0-841c575`,否则 IDEA 与 CI 将得到不可复现的同坐标异内容制品。 该 SDK 尚未发布到 Maven Central。引擎不探测、不加载也不降级到其他 A2A-T SDK 版本; 本机与 CI 都必须安装这里锁定的提交派生坐标。 @@ -27,8 +27,8 @@ LLM provider;执行引擎届时应升级到该新提交对应的新 Maven 版 ```powershell git clone https://github.com/project-openan/a2a-t-sdk-java.git Set-Location a2a-t-sdk-java -git checkout 34485504675800a436d68cfaa913f14931086506 -mvn -B "-Drevision=1.0.0-3448550" -DskipTests ` +git checkout 841c575054d7aecc532e3fd312e004241727951b +mvn -B "-Drevision=1.0.0-841c575" -DskipTests ` -pl a2a-t-client,a2a-t-server -am install ``` diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index a876c776..dd89ddbd 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -150,20 +150,21 @@ SDK 配置的 LLM);持有自然语言 - **触发条件**:响应 metadata 携带 Negotiation-T 扩展 key(agent 通过 `Message(ROLE_AGENT)` + `INPUT_REQUIRED` 发起协商),并同时携带 SDK 生成的 `templateUri` 与 - `negotiationContext={id,round,maxRounds}`。缺少任一字段均按非法协商报文失败关闭。 + `negotiationContext={id,round,maxRounds,performative}`。缺少任一字段均按非法协商报文失败关闭。 - **每轮**:`NegotiationTHandler` 用 `validateProposePromptAndDataFilling` 按 `templateUri` 校验 Propose 并抽取参数 → `ControlPoint.onNegotiation` 生成决策 → SDK 内容层渲染 Accept / Reject / Abort → Accept 在发出前再用 `validateAcceptPromptAndDataFilling` 校验 → 结束报文保留收到的 Propose `NegotiationContext` 及轮次 → 发送给 agent → 递归检查下一轮。 -- **澄清文本约定**(onNegotiation 返回值): - - `data:{...json...}` — 确定性 fromData 渲染 Accept(无 LLM 调用,JSON 为补充参数 map) - - `reject:原因` / `abort:原因` — 渲染 Reject / Abort 终态模板 - - 其他文本 — fromText 渲染 Accept(一次 LLM 抽取) +- **强类型决策**(`onNegotiation` 返回值): + - `NegotiationDecision.acceptText/acceptData` — 接受;分别走 SDK fromText/fromData + - `NegotiationDecision.rejectText/rejectData` — 拒绝;分别走 SDK fromText/fromData + - `NegotiationDecision.abortText/abortData` — 终止;分别走 SDK fromText/fromData + - 不接受 `data:`、`reject:`、`abort:` 等字符串控制前缀。 - **轮次耗尽**:超过 `maxNegotiationRounds` 后不再继续循环,通过 SDK abort 模板发送终止消息 (尽力而为,发送失败仅记日志),发出 `NEGOTIATION_FAILED` 事件,agent 最后一次回复作为最终响应。 - **协商上下文**:执行引擎不支持 `startNegotiation` / `receiveNegotiation` / `continueNegotiation` 旧状态机入口。引擎只接受规范键 `negotiationContext`,结构固定为 - `{id, round, maxRounds}`;不再读取 `negotiation_context` 等旧键。 + `{id, round, maxRounds, performative}`;不再读取 `negotiation_context` 等旧键。 #### 模板查询 @@ -331,7 +332,7 @@ SDK 消息生成/校验/模板查询的完整能力面,方法与 SDK `A2ATClie |------|------|------| | fromData 生成 | `generateProposeFromData` / `generateAcceptFromData` / `generateRejectFromData` / `generateAbortFromData` | 类型化数据确定性渲染,无 LLM | | fromText 生成 | `generateProposeFromText` / `generateAcceptFromText` / `generateRejectFromText` / `generateAbortFromText` | 自由文本 + 一步 LLM 抽取 | -| 无状态上下文 | `MetadataContent.buildMetadataContent` / `NegotiationContext` | 生成并传递 `{id, round, maxRounds}`;引擎负责复制和递增轮次 | +| 无状态上下文 | `MetadataContent.buildMetadataContent` / `NegotiationContext` | 生成并传递 `{id, round, maxRounds, performative}`;SDK 为输出消息标记 performative | | 校验提取 | `validatePropose` / `validateAccept` / `validateReject` / `validateAbort` | 规则门 + LLM 语义校验 + 参数合并;需传 `NegotiationContext`(null = 非协商消息) | | 模板查询 | `getPrompts` / `getNegotiationPrompts` / `getPrompt` / `getNegotiationPrompt` | 模板目录枚举与查询 | | 工具 | `toMetadata(MetadataContent)` | 生成消息转 A2A metadata map(含 `templateUri`、`negotiationContext` key) | @@ -410,15 +411,28 @@ Map normalized = AgentCardNormalizer.normalize(rawMap); ## dev.openan.workflow.engine.control +### TaskDispatcher / TaskSubmission + +`onTask` 只获得窄化的 `TaskDispatcher` 能力。业务使用: + +```java +dispatcher.dispatch(TaskSubmission.fromText(agentName, text)); +dispatcher.dispatch(TaskSubmission.fromData( + agentName, instruction, data, schema, templateUri)); +``` + +第一种调用 SDK 自然语言场景识别;第二种调用 schema-aware fromData。`TaskSubmission` 在进入 +协议层前校验 agent、instruction、schema 和模板扩展类型,并防御性复制业务数据。 + ### ControlPoint 用户决策接口。每个方法单一职责。 ```java public interface ControlPoint { - // 向智能体发送 Task-T 消息。直接调用 sendMessage。 + // 接管远程任务,提交自然语言或结构化 Task-T 输入。 CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient); + TaskRequest request, TaskDispatcher taskDispatcher); // 自环节点:本地处理,不发 A2A-T 消息给自己。 default CompletableFuture onSelfTask(TaskRequest request); @@ -428,10 +442,9 @@ public interface ControlPoint { String stepName, Map results, List conditions); - // 提供协商补充信息。默认:返回通用文本。 - default CompletableFuture onNegotiation( - String agentName, String negotiationText, - Map receiveResult); + // 返回强类型 Accept / Reject / Abort 决策。 + default CompletableFuture onNegotiation( + NegotiationRequest request); } ``` @@ -440,16 +453,22 @@ public interface ControlPoint { | `onTask` | 步骤向其他智能体分派任务时 | `TaskResponse`(成功 + 输出) | | `onSelfTask` | `SELF_LOOP` 步骤本地执行(不走 A2A-T) | `TaskResponse`(成功 + 输出) | | `onRoute` | 步骤完成后、下一步前 | `RouteDecision`(nextStep) | -| `onNegotiation` | 智能体返回 `INPUT_REQUIRED` 时 | `String` 补充信息文本 | +| `onNegotiation` | 智能体返回 `INPUT_REQUIRED` 时 | `NegotiationDecision` | + +`NegotiationRequest` 向业务方提供 `agentName`、`concern`、`sessionId`、`round`、 +`maxRounds`、强类型 `NegotiationPerformative`、协商 `kind`、`templateUri` 和只读 `metadata`。 +只有带完整上下文的 `PROPOSE` 才会进入回调;上下文缺失、轮次非法或模板不受支持时均失败关闭。 +业务方只需根据这些字段返回 `acceptText/acceptData`、`rejectText/rejectData` 或 +`abortText/abortData`,无需发送消息或构造协议 metadata。 ### DefaultControlPoint 默认实现,提供合理默认值: -- `onTask`:调用 `sendMessage()`,返回 success/output +- `onTask`:用 `TaskSubmission.fromText` 交给 `TaskDispatcher`,返回 success/output - `onSelfTask`:原样回传任务消息(本地逻辑请覆盖实现) - `onRoute`:选第一个非终止分支 -- `onNegotiation`:返回通用补充信息 +- `onNegotiation`:返回通用的 `acceptText` 决策 继承此类,只需覆盖需要自定义的方法。 @@ -698,7 +717,7 @@ ExecutionResult result = executor.run().join(); > **协商相关 metadata key 约定:** > -> - `negotiationContext`(SDK 契约):SDK 生成协商消息时写入,携带 `{id, round, maxRounds}`。 +> - `negotiationContext`(SDK 契约):SDK 生成协商消息时写入,携带 `{id, round, maxRounds, performative}`。 > validate API 依赖它区分协商消息与普通消息。 > - `templateUri`(SDK 契约):SDK 生成消息时写入,标识渲染所用模板。 > - `negotiation_message` / `negotiation_params`(引擎内部):接收校验后供自动循环和 @@ -717,8 +736,8 @@ ExecutionResult result = executor.run().join(); - 智能体调用失败抛出 `RuntimeException`(包装原始异常)。 - 协商超过 `maxNegotiationRounds` 轮后:发出 `NEGOTIATION_FAILED` 事件,通过 SDK abort 模板向 agent 发送终止消息(尽力而为),agent 最后一次回复作为最终响应;循环不再继续。 -- 协商 follow-up 的 SDK 内容生成失败时降级为裸澄清文本(原文放入 Negotiation-T key),记 `WARN`。 -- 认证失败(401)记为 `ERROR` 日志,认证头不设置,请求继续发出。 +- 协商 follow-up 的 SDK 内容生成失败时直接失败;不会把裸业务输入伪装成 Negotiation-T metadata。 +- 必需认证信息缺失或获取失败时请求失败关闭,不会无认证继续发送。 - SSE 流在终态事件后的连接关闭日志为 `DEBUG` 级别(预期行为)。 --- diff --git a/docs/zh/DESIGN.md b/docs/zh/DESIGN.md index ae5ca137..4a5a4119 100644 --- a/docs/zh/DESIGN.md +++ b/docs/zh/DESIGN.md @@ -1,8 +1,7 @@ # A2A-T 工作流执行引擎 - 架构设计 > A2A-T 工作流执行引擎的架构设计与设计原理。 -> 本文档描述 v1.0 发布版本。面向集成或扩展 SDK 的工程师, -> 不是某个 bug 修复的历史记录。 +> 本文档描述 v1.0 发布版本。面向集成或扩展 SDK 的工程师 --- @@ -119,10 +118,10 @@ SDK 暴露两个用户实现的接口,按职责拆分。 | 方法 | 调用方 | 决策 | |------------------|-------------|-------------------------------------------| -| `onTask` | 执行器 | 向智能体发送任务(调用 `sendMessage`) | +| `onTask` | 执行器 | 提交自然语言或结构化 `TaskSubmission` | | `onSelfTask` | 执行器 | 本地处理自环任务(不走 A2A-T) | | `onRoute` | 执行器 | 在条件步骤选择分支 | -| `onNegotiation` | 客户端自动循环 | 在 INPUT_REQUIRED 时提供澄清文本 | +| `onNegotiation` | 客户端自动循环 | 在 INPUT_REQUIRED 时返回强类型业务决策 | Authorization-T 和 Notification-T 是工作台独立触发的协议操作,不是工作流 DAG 节点。 @@ -139,8 +138,8 @@ Authorization-T 和 Notification-T 是工作台独立触发的协议操作,不 - **Task-T** — 发送时,调用 A2A-T SDK 从自然语言消息生成结构化任务提示词,注入消息 metadata。 协商后续和调用方预设提示词时跳过。接收时:透传。 - **Negotiation-T** — 接收时,当智能体返回 `INPUT_REQUIRED` 并声明该扩展, - 提取协商上下文和消息。这驱动自动循环:引擎调用 `ControlPoint.onNegotiation` 获取澄清, - 重发后续消息,重复直到达到配置的轮次上限。 + 提取协商上下文和消息。这驱动自动循环:引擎调用 `ControlPoint.onNegotiation` 获取 + `NegotiationDecision`,按 action 与 input 类型选择 SDK fromText/fromData API,重发后续消息。 ### 5.2 独立生命周期扩展 @@ -218,8 +217,8 @@ sequenceDiagram C->>A: before_send: Task-T A-->>C: INPUT_REQUIRED (Negotiation-T) C->>E: negotiation result - E->>H: onNegotiation (宿主提供澄清) - H->>E: clarification + E->>H: onNegotiation(NegotiationRequest) + H->>E: NegotiationDecision E->>C: follow-up send C->>A: send follow-up A-->>C: final result @@ -287,7 +286,7 @@ SDK 不要求编排中心或注册中心客户端成为底层协议发送的硬 Authorization-T 是工作台独立触发的一次性请求,Notification-T 是工作台级长连接订阅。 注册表只自动注册工作流内的一对。 -3. **自动协商循环** — 引擎拥有重发循环,宿主只提供澄清文本(`onNegotiation`), +3. **自动协商循环** — 引擎拥有重发循环,宿主只提供强类型决策(`onNegotiation`), 不需要关心重发的协议机制。 4. **条件路由语义** — 空条件意味着扇出(并行),条件分支意味着通过 `onRoute` 做 N 选 1。 diff --git a/docs/zh/DEVELOPER_GUIDE.md b/docs/zh/DEVELOPER_GUIDE.md index 05614de5..5536cc12 100644 --- a/docs/zh/DEVELOPER_GUIDE.md +++ b/docs/zh/DEVELOPER_GUIDE.md @@ -33,12 +33,18 @@ public class MyControlPoint implements ControlPoint { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient client) { - return client.sendMessage(request.getAgentName(), request.getMessage()) - .thenApply(result -> TaskResponse.builder() - .success(true) - .output(result.getText()) - .build()); + TaskRequest request, TaskDispatcher dispatcher) { + return dispatcher.dispatch(TaskSubmission.fromText( + request.getAgentName(), request.getMessage())) + .thenApply(result -> { + String state = result.getTaskState(); + boolean success = state == null || state.isBlank() + || "TASK_STATE_COMPLETED".equals(state); + return TaskResponse.builder() + .success(success) + .output(result.getText()) + .build(); + }); } @Override @@ -54,7 +60,7 @@ public class MyControlPoint implements ControlPoint { } ``` -`onNegotiation` 有默认实现,返回通用澄清文本。授权和通知是独立协议操作,由工作台在业务时机通过各自专用的 `ExtensionSender`/transport 发起;它们不在 `ControlPoint` 上,也不是工作流 DAG 节点。 +`onNegotiation` 有默认实现,返回通用的 `acceptText` 决策。授权和通知是独立协议操作,由工作台在业务时机通过各自专用的 `ExtensionSender`/transport 发起;它们不在 `ControlPoint` 上,也不是工作流 DAG 节点。 ## 4. 通过 Builder 执行(推荐) @@ -129,23 +135,25 @@ try (var client = new DefaultWorkflowEngineClient(agentCards, a2aRuntime, ### 6.1 协商自动循环 -引擎客户端的 `sendMessage()` 自动处理协商:当智能体返回 `INPUT_REQUIRED` 时, -引擎从响应 metadata 中提取协商文本,调用 `ControlPoint.onNegotiation()` 获取澄清, +任务分派后的引擎客户端自动处理协商:当智能体返回 `INPUT_REQUIRED` 时, +引擎从响应 metadata 中构造 `NegotiationRequest`,调用 `ControlPoint.onNegotiation()` 获取强类型决策, 然后作为后续消息发回。循环重复直到 `maxNegotiationRounds`(默认 3)。 覆盖 `onNegotiation()` 提供业务特定的澄清: ```java @Override -public CompletableFuture onNegotiation( - String agentName, String negotiationText, - Map receiveResult) { - return myLlm.generate("Agent " + agentName + " needs: " + negotiationText) - .thenApply(Response::text); +public CompletableFuture onNegotiation( + NegotiationRequest request) { + return myLlm.generate( + "Agent " + request.agentName() + " needs: " + request.concern()) + .thenApply(Response::text) + .thenApply(NegotiationDecision::acceptText); } ``` -返回空/null 字符串会失败该轮(发射 `negotiation_failed` 事件,循环重试)。 +返回 `null` 会失败该轮。结构化业务参数使用 `acceptData/rejectData/abortData`;输入对象会在调用 SDK +之前拒绝空键、空值和字符串 `"null"`。 ### 6.2 工作流模型字段 diff --git a/docs/zh/INTEGRATION_GUIDE.md b/docs/zh/INTEGRATION_GUIDE.md index 3bb7b706..a316032d 100644 --- a/docs/zh/INTEGRATION_GUIDE.md +++ b/docs/zh/INTEGRATION_GUIDE.md @@ -92,9 +92,9 @@ List> cards = registry.fetchAgentCards(); public class MyControlPoint extends DefaultControlPoint { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - return engineClient.sendMessage( - request.getAgentName(), request.getMessage()) + TaskRequest request, TaskDispatcher dispatcher) { + return dispatcher.dispatch(TaskSubmission.fromText( + request.getAgentName(), request.getMessage())) .thenApply(r -> { String state = r.getTaskState(); boolean success = state == null || state.isBlank() @@ -127,22 +127,23 @@ public class MyControlPoint extends DefaultControlPoint { } @Override - public CompletableFuture onNegotiation( - String agentName, String negotiationText, - Map receiveResult) { - return CompletableFuture.completedFuture("请使用现有信息继续执行。"); + public CompletableFuture onNegotiation( + NegotiationRequest request) { + return CompletableFuture.completedFuture( + NegotiationDecision.acceptText("请使用现有信息继续执行。")); } } ``` | 方法 | 何时调用 | 你需要做什么 | |-------------------|--------------------------------|----------------------------------------------------------| -| `onTask` | 步骤向其他智能体分派任务时 | 调用 `engineClient.sendMessage()` 发送消息,返回执行结果 | +| `onTask` | 步骤向其他智能体分派任务时 | 向 `TaskDispatcher` 提交强类型 `TaskSubmission` | | `onSelfTask` | `SELF_LOOP` 步骤本地执行时 | 本地处理并返回结果(不发 A2A-T 消息) | | `onRoute` | 步骤完成后、决定下一步前 | 从候选分支中选择下一步 | -| `onNegotiation` | 智能体返回 `INPUT_REQUIRED` 时 | 返回补充说明文本 | +| `onNegotiation` | 智能体返回 `INPUT_REQUIRED` 时 | 返回强类型 `NegotiationDecision` | -`onNegotiation` 默认返回通用文本。只需覆盖你关心的方法。 +`onNegotiation` 默认返回通用的 `acceptText` 决策。结构化业务数据应使用 +`acceptData/rejectData/abortData`,不要拼接字符串控制前缀。 **流程外操作(Authorization-T / Notification-T)**:两者都由宿主在各自业务时机通过 `ExtensionSender` 独立触发,与工作流没有固定先后关系。Authorization-T 是响应完成即结束的一次性请求; @@ -402,12 +403,13 @@ AgentCard 通过 `capabilities.extensions` 声明扩展点: ### Task-T(自动) -给智能体发消息时,引擎自动调用 A2A-T SDK 生成结构化任务提示词,放入消息 metadata。你在 `onTask` 里只需调用 `sendMessage()` -,提示词生成是透明的。 +`onTask` 通过 `TaskSubmission.fromText(...)` 或 `TaskSubmission.fromData(...)` 明确选择输入轨道, +再交给 `TaskDispatcher.dispatch(...)`。引擎自动调用匹配的 A2A-T SDK 方法并注入 metadata。 ### Negotiation-T(自动) -智能体返回 `INPUT_REQUIRED` 时,引擎自动提取协商文本,调用你的 `onNegotiation()` 获取补充信息,然后发回后续消息。自动循环最多 +智能体返回 `INPUT_REQUIRED` 时,引擎构造 `NegotiationRequest`,调用你的 `onNegotiation()` 获取 +Accept/Reject/Abort 强类型决策,然后调用对应 SDK fromText/fromData 方法并发回后续消息。自动循环最多 `maxNegotiationRounds` 次(默认 3)。 ### Authorization-T(前置下发) diff --git "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" index daaf8714..0377a2b3 100644 --- "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" @@ -1,6 +1,15 @@ # 工作流执行引擎 SDK 工作台集成指南 -> 面向传输工作台研发团队。本文档以 SPN 专线故障跨城协同诊断联创场景为例,从工作台视角说明接入Workflow执行引擎 SDK 的完整路径。 +> 交付基线:workflow engine `1.0.0`,A2A-T SDK +> `1.0.0-841c575`(commit `841c575054d7aecc532e3fd312e004241727951b`), +> A2A Java SDK `1.0.0.Beta1`。本执行引擎是首发版本, +> 只支持该当前 A2A-T API/资源基线,不提供旧 SDK 状态机接口或旧报文 fallback。 + +> 面向传输工作台研发团队。本文档以 SPN 专线故障跨城协同诊断联创场景为例,从工作台视角说明接入 Workflow 执行引擎 SDK 的完整路径。 +> +> 业务基线:上游为 WAIMO;工作台接收 Task-T 后加载工作流,并行调度两地市 OMC, +> 两路完成后汇总一次并返回 WAIMO。Authorization-T 和 Notification-T 是工作台在 +> 独立时机触发的协议操作,不是工作流 DAG 节点。 --- @@ -441,10 +450,10 @@ public class TransportWorkbenchExecutor implements AgentExecutor { ... } ```java public interface ControlPoint { - CompletableFuture onTask(TaskRequest request, WorkflowEngineClient engineClient); + CompletableFuture onTask(TaskRequest request, TaskDispatcher taskDispatcher); default CompletableFuture onSelfTask(TaskRequest request) { ... } CompletableFuture onRoute(String stepName, Map results, List conditions); - default CompletableFuture onNegotiation(String agentName, String negotiationText, Map receiveResult) { ... } + default CompletableFuture onNegotiation(NegotiationRequest request) { ... } } ``` @@ -456,21 +465,26 @@ SDK 在工作流走到一个需要向下游 Agent 发送任务的步骤时调用 `request` 包含: `agentName` (目标 Agent 名称)、`stepName` (当前步骤名)、`message` (SDK 组装的完整消息,含上游上下文)。 -关键设计: 给每个 OMC 发送的任务只包含该 OMC 相关的参数,不混入其他地市的信息。示例中 `buildTargetedTaskMessage` 依据步骤名构造针对性消息: +关键设计:给每个 OMC 发送的任务只包含该 OMC 相关的结构化参数,不混入其他地市的信息。 +有结构化业务数据时使用 `TaskSubmission.fromData`;只有自由文本时才使用 `fromText`: ```java public class WorkbenchControlPoint extends DefaultControlPoint { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { + TaskRequest request, TaskDispatcher dispatcher) { String step = request.getStepName(); String agentName = request.getAgentName(); - - // 依据步骤构造针对性任务消息 (只含该 OMC 相关参数) - String targetedMessage = buildTargetedTaskMessage(step); - - return engineClient.sendMessage(agentName, targetedMessage) + Map data = buildCityTaskData(step); + TaskSubmission submission = TaskSubmission.fromData( + agentName, + "创建专线业务投诉诊断任务", + data, + SpnCasePrompts.privateLineComplaintSchema(), + StandardTemplates.PRIVATE_LINE_COMPLAINT); + + return dispatcher.dispatch(submission) .thenApply(r -> { String state = r.getTaskState(); boolean success = state == null || state.isBlank() @@ -487,36 +501,18 @@ public class WorkbenchControlPoint extends DefaultControlPoint { .build()); } - private static String buildTargetedTaskMessage(String step) { + private static Map buildCityTaskData(String step) { return switch (step) { - case "diagnosis_city1" -> """ - ## 任务 - SPN 专线故障诊断 - 城市1 OMC 侧 - - ## 故障参数 - - 端口状态: port-7 = DOWN - - 光功率: -28dBm (阈值 -20dBm) - - 所属单板: line-card-03 - - 端口号: port-7 - - 请针对城市1 OMC 侧端口故障进行诊断。"""; - case "diagnosis_city2" -> """ - ## 任务 - SPN 专线故障诊断 - 城市2 OMC 侧 - - ## 排查要求 - - 排查城市2 OMC 端口状态是否正常 - - 确认光功率是否在正常范围 - - 确认是否存在异常告警 - - 请针对城市2 OMC 侧进行排查。"""; - default -> "请执行 SPN 专线故障诊断任务。"; + case "diagnosis_city1" -> SpnCasePrompts.privateLineComplaintData(); + case "diagnosis_city2" -> SpnCasePrompts.privateLineComplaintDataCity2(); + default -> throw new IllegalArgumentException("未知诊断步骤: " + step); }; } } ``` -`engineClient.sendMessage(agentName, message)` 内部自动完成: Task-T 提示词生成 -> Bearer 认证 -> SSE 流式发送 -> 事件提取 -> Negotiation-T 协商循环。工作台只需调用一行。 +`TaskDispatcher.dispatch(submission)` 内部根据输入类型选择 A2A-T SDK fromText/fromData API,并自动完成: +Task-T 生成 -> Bearer 认证 -> SSE 流式发送 -> 事件提取 -> Negotiation-T 协商循环。 #### 4.2 onSelfTask -- 本地汇总 (自环节点) @@ -569,25 +565,24 @@ public CompletableFuture onRoute( #### 4.4 onNegotiation -- 协商澄清 -当 OMC 返回 `INPUT_REQUIRED` (Negotiation-T) 时,SDK 自动进入协商循环。SDK 调用此方法获取澄清文本,然后自动重发给 OMC。工作台只需返回澄清内容,不需要自行发送消息。 +当 OMC 返回 `INPUT_REQUIRED` (Negotiation-T) 时,SDK 自动进入协商循环。SDK 传入包含协商类型、 +会话轮次和 concern 的 `NegotiationRequest`;工作台返回强类型决策,不需要自行发送消息。 触发条件: OMC 发现任务参数有误或缺失 (必选参数缺失、取值不在枚举范围、语义校验错误等)。 ```java @Override -public CompletableFuture onNegotiation( - String agentName, String negotiationText, Map receiveResult) { - // negotiationText: OMC 的协商请求内容 (明确指出缺失/错误的参数及修正建议) - // 返回: 工作台补全或修正后的澄清文本 - - String clarification = "城市1-城市2间 SPN 专线中断," - + "城市1 OMC 告警端口 Down,光功率 -28dBm。" - + "请根据此上下文修正参数后重新执行诊断。"; - return CompletableFuture.completedFuture(clarification); +public CompletableFuture onNegotiation( + NegotiationRequest request) { + return CompletableFuture.completedFuture( + NegotiationDecision.acceptData(Map.of( + "接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", + "投诉分类", "专线质差"))); } ``` -SDK 自动将返回的澄清文本封装为 Negotiation-T follow-up 消息重发给 OMC,最多重试 3 轮 (可配置)。如果 OMC 在 3 轮内收敛 (返回正常结果),工作流继续。如果未收敛,`sendMessage` 返回的 `SendMessageResult` 中 `taskState` 仍为 `INPUT_REQUIRED`。 +业务可返回 `acceptText/acceptData/rejectText/rejectData/abortText/abortData`。SDK 自动选择对应的 +Negotiation-T fromText/fromData API 并重发,最多 3 轮(可配置);不再支持 `data:` 等字符串控制前缀。 协商循环时序: @@ -597,12 +592,12 @@ sequenceDiagram participant SDK as SDK 引擎客户端 participant OMC as OMC - WB->>SDK: onTask -> sendMessage + WB->>SDK: onTask -> dispatch(TaskSubmission) SDK->>OMC: Task-T 下发诊断任务 OMC-->>SDK: INPUT_REQUIRED (Negotiation-T) SDK->>WB: onNegotiation() - WB-->>SDK: 返回澄清文本 - SDK->>OMC: follow-up 重发 (含澄清) + WB-->>SDK: 返回 NegotiationDecision + SDK->>OMC: SDK 渲染并重发 follow-up OMC-->>SDK: 诊断结果 (COMPLETED) SDK-->>WB: TaskResponse ``` @@ -847,8 +842,8 @@ sequenceDiagram Note over EP: WorkflowExecutor DAG 遍历 Note over EP: step_start: diagnosis_city1 - EP->>WB: ControlPoint.onTask(request, engineClient) - WB->>EC: engineClient.sendMessage(OMC, 诊断任务) + EP->>WB: ControlPoint.onTask(request, taskDispatcher) + WB->>EC: taskDispatcher.dispatch(TaskSubmission) EC->>OMC: Task-T (诊断请求) OMC-->>EC: 诊断结果 (或 INPUT_REQUIRED) Note over EC,OMC: (若 INPUT_REQUIRED: 自动 Negotiation-T 协商循环) @@ -857,8 +852,8 @@ sequenceDiagram Note over EP: step_complete: diagnosis_city1 Note over EP: step_start: diagnosis_city2 (并行) - EP->>WB: ControlPoint.onTask(request, engineClient) - WB->>EC: engineClient.sendMessage(OMC, 诊断任务) + EP->>WB: ControlPoint.onTask(request, taskDispatcher) + WB->>EC: taskDispatcher.dispatch(TaskSubmission) EC->>OMC: Task-T (诊断请求) OMC-->>EC: 诊断结果 EC-->>WB: SendMessageResult @@ -900,7 +895,7 @@ sequenceDiagram **工作流执行阶段 (工作台调用 ExecutePsop):** - `ExecutePsop` 内部创建 `WorkflowExecutor`,执行 DAG 遍历 - 每个步骤: `WorkflowExecutor` 调用 `ControlPoint.onTask()`,工作台决定发什么内容 -- 工作台在 `onTask` 中调用 `engineClient.sendMessage()`,SDK 自动处理 Task-T 提示词生成 + 认证头注入 + SSE 流式发送 + Negotiation-T 协商循环 +- 工作台在 `onTask` 中提交强类型 `TaskSubmission`,SDK 自动选择 fromText/fromData 并处理认证、SSE 与协商循环 - `SELF_LOOP` 步骤调用 `onSelfTask()`,工作台本地处理,不发送 A2A-T 消息 **后续通知阶段 (SSE 长连接):** @@ -1011,10 +1006,10 @@ SDK 的 `WorkflowExecutor` 自动并行调度同一层的步骤 (无依赖关系 |------|------|-------------|---------| | `AgentExecutor` | `execute` | `void execute(RequestContext ctx, AgentEmitter emitter)` | 上层系统 下发任务时 | | `AgentExecutor` | `cancel` | `void cancel(RequestContext ctx, AgentEmitter emitter)` | 上层系统 取消任务时 | -| `ControlPoint` | `onTask` | `CompletableFuture onTask(TaskRequest, WorkflowEngineClient)` | 步骤需要向下游发任务 | +| `ControlPoint` | `onTask` | `CompletableFuture onTask(TaskRequest, TaskDispatcher)` | 步骤需要向下游发任务 | | `ControlPoint` | `onSelfTask` | `CompletableFuture onSelfTask(TaskRequest)` | 自环节点本地处理 | | `ControlPoint` | `onRoute` | `CompletableFuture onRoute(String, Map, List)` | 条件分支选择 | -| `ControlPoint` | `onNegotiation` | `CompletableFuture onNegotiation(String, String, Map)` | OMC 返回 INPUT_REQUIRED | +| `ControlPoint` | `onNegotiation` | `CompletableFuture onNegotiation(NegotiationRequest)` | OMC 返回 INPUT_REQUIRED | | `AuthProvider` | `applyAuth` | `void applyAuth(String agentName, AgentCard agentCard, Map headers)` | 每次消息发送前注入认证头 (可选) | ### SDK 提供的接口 @@ -1052,6 +1047,7 @@ SDK 的 `WorkflowExecutor` 自动并行调度同一层的步骤 (无依赖关系 ## 9. 集成自查清单 +- Maven 是否解析到 `a2a-t-client:1.0.0-841c575`,且 jar 来自固定 commit 而非旧缓存 - AgentCard 是否包含 `name`、`description`、`version`、`capabilities`、`defaultInputModes`、`defaultOutputModes`、`skills`、`supportedInterfaces` - 下游 AgentCard 的 `securitySchemes` / `securityRequirements` 是否按实际认证方式配置;AuthProvider-only 推荐 `securityRequirements: []` - `AuthProvider` 是否已同时配置到前置预定位 transport 和工作流任务 transport diff --git a/pom.xml b/pom.xml index a5de2d1e..5ff892f2 100644 --- a/pom.xml +++ b/pom.xml @@ -53,8 +53,8 @@ UTF-8 1.0.0.Beta1 - 1.0.0-3448550 - 34485504675800a436d68cfaa913f14931086506 + 1.0.0-841c575 + 841c575054d7aecc532e3fd312e004241727951b 1.18.38 2.21.1 2.0.17 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 6bcab925..a099404d 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 @@ -387,7 +387,8 @@ private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, String 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.NegotiationContext.DEFAULT_MAX_ROUNDS, + net.openan.a2at.sdk.core.model.NegotiationPerformative.PROPOSE); Map metadata = renderProposeMetadata(negotiationContext, input); Message proposeMessage = Message.builder() @@ -400,7 +401,7 @@ private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, String .build(); emitter.requiresInput(proposeMessage); log.info( - "[{}] Requested negotiation via INPUT_REQUIRED status message (context=id/round/maxRounds)", + "[{}] Requested negotiation via INPUT_REQUIRED status message (context=id/round/maxRounds/performative)", getClass().getSimpleName()); } 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 40fa4c63..dd86d56c 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 @@ -22,16 +22,20 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; +import dev.openan.workflow.engine.model.NegotiationDecision; +import dev.openan.workflow.engine.model.NegotiationRequest; + /** - * Negotiation clarification strategy for the SPN cross-city workbench. + * Negotiation decision strategy for the SPN cross-city workbench. * *

        Single responsibility: when a downstream agent returns INPUT_REQUIRED (Negotiation-T), - * generate typed clarification data that supplements the missing parameters. The returned - * {@code data:{...}} value selects the latest SDK's deterministic typed Accept renderer; it is - * never treated as a hand-written protocol prompt. + * generate typed decision data that supplements the missing parameters. The returned + * structured Accept decision selects the latest SDK's deterministic typed renderer; it is never + * treated as a hand-written protocol prompt. * *

        The Accept payload follows the SDK scenario data for the private-line complaint case: * the two parameters the agent negotiated for (接入端口名称, 投诉分类), filled with the values @@ -52,26 +56,22 @@ public NegotiationStrategy(String a2atEnvPath) { } /** - * Generate a clarification for the given negotiation request. + * Make an Accept decision for the given negotiation request. * - * @param agentName the agent requesting negotiation - * @param negotiationText the concern/question raised by the agent - * @param receiveResult the response metadata, carrying the SDK negotiation context under the - * {@code negotiationContext} key (id/round/maxRounds) when present - * @return clarification text to send back to the agent + * @param request typed negotiation request, including concern and session information + * @return typed business decision to send back to the agent */ - public CompletableFuture resolve( - String agentName, String negotiationText, Map receiveResult) { - log.info("[NegotiationStrategy] agent={}: {}", agentName, negotiationText); - // The data: contract is consumed by DefaultWorkflowEngineClient and rendered with - // generateNegotiationAcceptPromptFromData. The negotiationContext remains metadata. - String accept = - "data:{\"接入端口名称\":\"" - + FILLED_PORT_NAME - + "\",\"投诉分类\":\"" - + FILLED_COMPLAINT_CATEGORY - + "\"}"; - log.info("[NegotiationStrategy] Accept generated for agent={}", agentName); + public CompletableFuture resolve(NegotiationRequest request) { + log.info( + "[NegotiationStrategy] agent={}, round={}: {}", + request.agentName(), + request.round(), + request.concern()); + Map values = new LinkedHashMap<>(); + values.put("接入端口名称", FILLED_PORT_NAME); + values.put("投诉分类", FILLED_COMPLAINT_CATEGORY); + NegotiationDecision accept = NegotiationDecision.acceptData(values); + log.info("[NegotiationStrategy] Accept generated for agent={}", request.agentName()); return CompletableFuture.completedFuture(accept); } } 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 86907bb0..df7ed634 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 @@ -58,7 +58,7 @@ public static boolean hasTaskMetadata(Map metadata) { /** * Metadata key carrying the negotiation session context map ({@code id} / {@code round} / - * {@code maxRounds}). + * {@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 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 18aee249..b243296c 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 @@ -20,12 +20,15 @@ package dev.openan.workflow.engine.examples.workbench; import dev.openan.workflow.engine.examples.util.LlmHelper; -import dev.openan.workflow.engine.client.WorkflowEngineClient; import dev.openan.workflow.engine.control.DefaultControlPoint; +import dev.openan.workflow.engine.control.TaskDispatcher; import dev.openan.workflow.engine.model.JumpCondition; +import dev.openan.workflow.engine.model.NegotiationDecision; +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.TaskResponse; +import dev.openan.workflow.engine.model.TaskSubmission; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -121,7 +124,7 @@ private static java.util.Map buildTaskData(String step) { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { + TaskRequest request, TaskDispatcher taskDispatcher) { String step = request.getStepName(); String agentName = request.getAgentName(); // Structured-data track: hand over the city-scoped complaint fields + schema; the SDK @@ -129,7 +132,7 @@ public CompletableFuture onTask( String partsText = "创建专线业务投诉诊断任务"; Map taskData = buildTaskData(step); return sendTaskWithRenderingRetry( - engineClient, agentName, partsText, taskData, 1) + taskDispatcher, agentName, partsText, taskData, 1) .thenApply( r -> { boolean success = @@ -160,19 +163,21 @@ public CompletableFuture onTask( private CompletableFuture sendTaskWithRenderingRetry( - WorkflowEngineClient engineClient, + TaskDispatcher taskDispatcher, String agentName, String message, Map taskData, int attempt) { - return engineClient - .sendMessageFromData( + TaskSubmission submission = + TaskSubmission.fromData( agentName, message, taskData, dev.openan.workflow.engine.examples.demo.SpnCasePrompts .privateLineComplaintSchema(), - net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT) + net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT); + return taskDispatcher + .dispatch(submission) .exceptionallyCompose(error -> { Throwable cause = unwrap(error); if (attempt < 3 @@ -184,7 +189,7 @@ public CompletableFuture onTask( attempt + 1, cause.getMessage()); return sendTaskWithRenderingRetry( - engineClient, agentName, message, taskData, attempt + 1); + taskDispatcher, agentName, message, taskData, attempt + 1); } return CompletableFuture.failedFuture(cause); }); @@ -227,8 +232,7 @@ public CompletableFuture onRoute( } @Override - public CompletableFuture onNegotiation( - String agentName, String negotiationText, Map receiveResult) { - return negotiationStrategy.resolve(agentName, negotiationText, receiveResult); + public CompletableFuture onNegotiation(NegotiationRequest request) { + return negotiationStrategy.resolve(request); } } 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 14a6892b..419b4f2b 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 @@ -55,7 +55,7 @@ * *

          *
        • {@link WorkbenchControlPoint} -- workflow decision callbacks (task dispatch, routing) - *
        • {@link NegotiationStrategy} -- negotiation clarification (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 diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java index a5aedbc5..fce06eb4 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java @@ -27,6 +27,7 @@ import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; @@ -167,7 +168,7 @@ public MetadataContent generateAbortFromData(NegotiationAbortData data) { * step constrained by the template URI, then renders deterministically. * * @param text free-text description of the message content - * @param context negotiation session context (id/round/maxRounds) + * @param context negotiation session context (id/round/maxRounds/performative) * @param templateUri propose template * @return rendered message with template URI and negotiation extension URI */ @@ -245,12 +246,13 @@ public static Map contextPayload(NegotiationContext context) { payload.put("id", context.id()); payload.put("round", context.round()); payload.put("maxRounds", context.maxRounds()); + payload.put("performative", context.performative().name()); return payload; } /** * Parses a negotiation session context from its canonical {@code negotiationContext} wire - * serialization ({@code id}/{@code round}/{@code maxRounds}). + * serialization ({@code id}/{@code round}/{@code maxRounds}/{@code performative}). * * @param map the metadata value under the {@code negotiationContext} key * @return the session context, or null when malformed @@ -263,12 +265,20 @@ public static NegotiationContext contextFromMap(Map map) { Object round = map.get("round"); if (id instanceof String s && isPositiveInteger(round)) { Object maxRounds = map.get("maxRounds"); - if (!isPositiveInteger(maxRounds)) { + Object performative = map.get("performative"); + if (!isPositiveInteger(maxRounds) || !(performative instanceof String value)) { + return null; + } + var parsedPerformative = NegotiationPerformative.tryParse(value); + if (parsedPerformative.isEmpty()) { return null; } try { return new NegotiationContext( - s, ((Number) round).intValue(), ((Number) maxRounds).intValue()); + s, + ((Number) round).intValue(), + ((Number) maxRounds).intValue(), + parsedPerformative.get()); } catch (IllegalArgumentException ignored) { // fall through } 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 5ef6f73d..16338e96 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 @@ -51,7 +51,7 @@ public enum A2ATExtension { /** Result notification subscription. Pre-positioned before workflow starts. */ NOTIFICATION_T(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); - /** Canonical SDK metadata key carrying {@code id}/{@code round}/{@code maxRounds}. */ + /** 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; 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 b58e7f99..c4af60c8 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java @@ -22,6 +22,8 @@ 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.NegotiationDecision; +import dev.openan.workflow.engine.model.NegotiationRequest; import dev.openan.workflow.engine.model.SendMessageResult; import net.openan.a2at.sdk.client.A2ATClient; @@ -289,15 +291,17 @@ private CompletableFuture autoNegotiate( emit( EventType.NEGOTIATION_REQUEST, Map.of("agent", agentName, "round", round, "concern", negText)); - CompletableFuture clarFuture = + NegotiationRequest request = toNegotiationRequest(agentName, negText, negMeta); + CompletableFuture decisionFuture = controlPoint != null - ? controlPoint.onNegotiation(agentName, negText, negMeta) + ? controlPoint.onNegotiation(request) : CompletableFuture.completedFuture( - "Please proceed with the original task using available information."); - return clarFuture.thenCompose( - clarification -> - clarification == null || clarification.isEmpty() - ? failWithoutClarification(agentName, result, round) + NegotiationDecision.acceptText( + "Please proceed with the original task using available information.")); + return decisionFuture.thenCompose( + decision -> + decision == null + ? failWithoutDecision(agentName, result, round) : continueNegotiationRound( agentCard, agentName, @@ -305,7 +309,7 @@ private CompletableFuture autoNegotiate( contextId, result, negMeta, - clarification, + decision, round)); } @@ -319,14 +323,14 @@ private void emitAgentResponse(String agentName, SendMessageResult result) { } /** - * Terminal path when the control point produces no clarification: negotiation failed, the + * Terminal path when the control point produces no decision: negotiation failed, the * agent's reply stands as the final response. */ - private CompletableFuture failWithoutClarification( + private CompletableFuture failWithoutDecision( String agentName, SendMessageResult result, int round) { emit( EventType.NEGOTIATION_FAILED, - Map.of("agent", agentName, "round", round, "reason", "no clarification")); + Map.of("agent", agentName, "round", round, "reason", "no decision")); emitAgentResponse(agentName, result); return CompletableFuture.completedFuture(result); } @@ -353,7 +357,10 @@ private CompletableFuture abortNegotiation( return buildNegotiationFollowUpMeta( agentName, result.getMetadata() != null ? result.getMetadata() : new HashMap<>(), - "abort: negotiation round budget exhausted (" + maxNegotiationRounds + ")") + NegotiationDecision.abortData( + "negotiation round budget exhausted (" + + maxNegotiationRounds + + ")")) .thenCompose( abortMeta -> { Object rendered = abortMeta.get(A2ATExtension.NEGOTIATION_T.uri()); @@ -388,7 +395,7 @@ private CompletableFuture abortNegotiation( } /** - * One negotiation round with a clarification: renders the follow-up metadata via the SDK + * One negotiation round with a typed decision: renders the follow-up metadata via the SDK * content layer, sends it, and recurses into the next round. */ private CompletableFuture continueNegotiationRound( @@ -398,18 +405,19 @@ private CompletableFuture continueNegotiationRound( String contextId, SendMessageResult result, Map negMeta, - String clarification, + NegotiationDecision decision, int round) { log.info( - "[Negotiation] Clarification for '{}' round {}: {}", + "[Negotiation] Decision for '{}' round {}: {} via {}", agentName, round, - clarification); + decision.action(), + decision.input().getClass().getSimpleName()); emit( EventType.NEGOTIATION_RESOLVED, - Map.of("agent", agentName, "round", round, "clarification", clarification)); + Map.of("agent", agentName, "round", round, "decision", decision.action().name())); String taskId = result.getTask() != null ? result.getTask().id() : null; - return buildNegotiationFollowUpMeta(agentName, negMeta, clarification) + return buildNegotiationFollowUpMeta(agentName, negMeta, decision) .thenCompose( followUpMeta -> { // The message body is the rendered negotiation text itself: the SDK @@ -420,7 +428,7 @@ private CompletableFuture continueNegotiationRound( String followUp = rendered instanceof String s && !s.isEmpty() ? s - : clarification; + : throwMissingNegotiationContent(agentName); return runBeforeSendHandlers(agentCard, followUp, followUpMeta) .thenCompose( meta -> { @@ -459,15 +467,14 @@ private CompletableFuture continueNegotiationRound( /** * Builds the follow-up metadata for one negotiation round using the SDK content layer. * - *

        The clarification text is classified: {@code reject:...} / {@code abort:...} prefixes - * select the Reject or Abort terminal templates, anything else renders an Accept message via - * {@code generateNegotiationAcceptPromptFromText} (one LLM extraction step). When the - * negotiation context is available, the engine advances the stateless context so the - * typed ending message preserves the received session/round context. Rendering failures are - * fatal: raw clarifications are never disguised as protocol messages. + *

        The decision's action and input type select the exact current SDK method. The typed ending + * message preserves the received stateless session/round context. Rendering failures are fatal: + * raw business input is never disguised as protocol metadata. */ private CompletableFuture> buildNegotiationFollowUpMeta( - String agentName, Map negMeta, String clarification) { + String agentName, + Map negMeta, + NegotiationDecision decision) { A2ATContentFacade content = transport.getContentFacade(); if (content == null) { return CompletableFuture.failedFuture( @@ -479,7 +486,8 @@ private CompletableFuture> buildNegotiationFollowUpMeta( return CompletableFuture.supplyAsync( () -> { try { - MetadataContent mc = renderFollowUpMessage(content, agentName, negMeta, clarification); + MetadataContent mc = + renderFollowUpMessage(content, agentName, negMeta, decision); if (mc.promptText() != null && !mc.promptText().isEmpty()) { return A2ATContentFacade.toMetadata(mc); } @@ -493,85 +501,69 @@ private CompletableFuture> buildNegotiationFollowUpMeta( } /** - * Renders the follow-up message matching the clarification's decision prefix: - * - *

          - *
        • {@code reject:...} / {@code abort:...} — Reject / Abort terminal templates - *
        • {@code data:{...}} — deterministic fromData Accept rendering (JSON body carries the - * filled parameter map; no LLM call) - *
        • anything else — Accept via fromText (one LLM extraction step) - *
        + * Renders the follow-up through the SDK method selected by the typed business decision. */ private MetadataContent renderFollowUpMessage( A2ATContentFacade content, String agentName, Map negMeta, - String clarification) { + NegotiationDecision decision) { net.openan.a2at.sdk.core.model.NegotiationContext contentCtx = extractContentContext(negMeta); net.openan.a2at.sdk.core.model.TemplateUri endingTemplate = endingTemplateFor(negMeta); - String decision = clarification.strip().toLowerCase(java.util.Locale.ROOT); - if (decision.startsWith("data:")) { - // Deterministic fromData Accept: the JSON body carries the filled parameter map. - return renderAcceptFromData( - content, - agentName, - contentCtx, - endingTemplate, - clarification.substring("data:".length())); - } - if (decision.startsWith("reject:")) { - log.info("[Negotiation] SDK generateNegotiationRejectPrompt for '{}'", agentName); - return content.generateRejectFromText( - clarification.substring("reject:".length()).strip(), - requireContext(contentCtx, agentName), - endingTemplate); - } - if (decision.startsWith("abort:")) { - log.info("[Negotiation] SDK generateNegotiationAbortPrompt for '{}'", agentName); - return content.generateAbortFromText( - clarification.substring("abort:".length()).strip(), - requireContext(contentCtx, agentName)); - } - MetadataContent mc = - content.generateAcceptFromText( - clarification, - requireContext(contentCtx, agentName), - endingTemplate); - log.info( - "[Negotiation] SDK generateNegotiationAcceptPrompt: templateUri={}", - mc.templateUri()); - return mc; + var context = requireContext(contentCtx, agentName); + if (decision.input() instanceof NegotiationDecision.NaturalLanguage natural) { + return renderFromText(content, decision.action(), natural.text(), context, endingTemplate); + } + NegotiationDecision.StructuredData structured = + (NegotiationDecision.StructuredData) decision.input(); + return renderFromData( + content, decision.action(), structured.values(), context, endingTemplate); } /** - * Deterministic fromData Accept rendering: parses the JSON payload into {@code NegotiationItem} - * name/value pairs and renders through the SDK's fromData pipeline (no LLM call). + * Renders one natural-language decision through the corresponding SDK fromText method. */ - private MetadataContent renderAcceptFromData( + private MetadataContent renderFromText( A2ATContentFacade content, - String agentName, + NegotiationDecision.Action action, + String text, net.openan.a2at.sdk.core.model.NegotiationContext contentCtx, - net.openan.a2at.sdk.core.model.TemplateUri endingTemplate, - String jsonBody) { - requireContext(contentCtx, agentName); - try { - net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent endingContent = - buildAcceptEndingContent(endingTemplate, jsonBody); - MetadataContent mc = - content.generateAcceptFromData( - new net.openan.a2at.sdk.negotiation.content.NegotiationEndingData( - contentCtx, endingContent), - endingTemplate); - log.info( - "[Negotiation] SDK generateNegotiationAcceptPromptFromData: templateUri={}, contentType={}", - mc.templateUri(), - endingContent.getClass().getSimpleName()); - return mc; - } catch (Exception e) { - throw new IllegalStateException("fromData Accept rendering failed: " + e.getMessage(), e); - } + net.openan.a2at.sdk.core.model.TemplateUri endingTemplate) { + return switch (action) { + case ACCEPT -> content.generateAcceptFromText(text, contentCtx, endingTemplate); + case REJECT -> content.generateRejectFromText(text, contentCtx, endingTemplate); + case ABORT -> content.generateAbortFromText(text, contentCtx); + }; + } + + /** Renders one deterministic typed decision through the corresponding SDK fromData method. */ + private MetadataContent renderFromData( + A2ATContentFacade content, + NegotiationDecision.Action action, + Map values, + net.openan.a2at.sdk.core.model.NegotiationContext contentCtx, + net.openan.a2at.sdk.core.model.TemplateUri endingTemplate) { + if (action == NegotiationDecision.Action.ABORT) { + String reason = requireOnlyField(values, "terminationReason", "Abort"); + return content.generateAbortFromData( + new net.openan.a2at.sdk.negotiation.content.NegotiationAbortData( + contentCtx, + new net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent( + reason))); + } + var conclusion = + action == NegotiationDecision.Action.ACCEPT + ? net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.ACCEPT + : net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.REJECT; + var endingContent = buildEndingContent(endingTemplate, values, conclusion); + var data = + new net.openan.a2at.sdk.negotiation.content.NegotiationEndingData( + contentCtx, endingContent); + return action == NegotiationDecision.Action.ACCEPT + ? content.generateAcceptFromData(data, endingTemplate) + : content.generateRejectFromData(data, endingTemplate); } static net.openan.a2at.sdk.core.model.TemplateUri endingTemplateFor( @@ -596,77 +588,92 @@ static net.openan.a2at.sdk.core.model.TemplateUri endingTemplateFor( } static net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent - buildAcceptEndingContent( + buildEndingContent( net.openan.a2at.sdk.core.model.TemplateUri endingTemplate, - String jsonBody) - throws com.fasterxml.jackson.core.JsonProcessingException { - Map data = parseNegotiationData(jsonBody); - var accept = net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.ACCEPT; + Map data, + net.openan.a2at.sdk.negotiation.content.NegotiationConclusion conclusion) { if (StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.equals(endingTemplate)) { var items = data.entrySet().stream() .map(entry -> new net.openan.a2at.sdk.negotiation.content.NegotiationItem( entry.getKey(), entry.getValue())) .toList(); return new net.openan.a2at.sdk.negotiation.content.InformationEndingContent( - accept, items); + conclusion, items); } if (StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.equals(endingTemplate)) { return new net.openan.a2at.sdk.negotiation.content.TargetEndingContent( - accept, requireOnlyField(data, "confirmedIntent"), null); + conclusion, + requireOnlyField(data, "confirmedIntent", actionName(conclusion)), + null); } if (StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.equals(endingTemplate)) { return new net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent( - accept, requireOnlyField(data, "feasibilitySummary")); + conclusion, + requireOnlyField(data, "feasibilitySummary", actionName(conclusion))); } throw new IllegalArgumentException( "Unsupported Negotiation-T ending template: " + endingTemplate.uri()); } - static java.util.List - parseNegotiationItems(String jsonBody) throws com.fasterxml.jackson.core.JsonProcessingException { - return parseNegotiationData(jsonBody).entrySet().stream() - .map(entry -> new net.openan.a2at.sdk.negotiation.content.NegotiationItem( - entry.getKey(), entry.getValue())) - .toList(); + private static String actionName( + net.openan.a2at.sdk.negotiation.content.NegotiationConclusion conclusion) { + return conclusion == net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.ACCEPT + ? "Accept" + : "Reject"; } - private static Map parseNegotiationData(String jsonBody) - throws com.fasterxml.jackson.core.JsonProcessingException { - if (jsonBody == null || jsonBody.isBlank()) { - throw new IllegalArgumentException("fromData Accept JSON must not be blank"); - } - @SuppressWarnings("unchecked") - Map params = - new com.fasterxml.jackson.databind.ObjectMapper() - .readValue(jsonBody.strip(), Map.class); - if (params.isEmpty()) { - throw new IllegalArgumentException( - "fromData Accept must contain at least one filled parameter"); - } - Map data = new java.util.LinkedHashMap<>(); - for (var entry : params.entrySet()) { - String name = entry.getKey() != null ? entry.getKey().strip() : ""; - String value = entry.getValue() instanceof String text ? text.strip() : ""; - if (name.isEmpty() || value.isEmpty() || "null".equalsIgnoreCase(value)) { - throw new IllegalArgumentException( - "fromData Accept items require non-blank string names and values"); - } - data.put(name, value); - } - return java.util.Collections.unmodifiableMap(data); + private static String throwMissingNegotiationContent(String agentName) { + throw new IllegalStateException( + "A2A-T SDK returned no rendered negotiation content for '" + agentName + "'"); } - private static String requireOnlyField(Map data, String field) { + private static String requireOnlyField( + Map data, String field, String action) { if (data.size() != 1 || !data.containsKey(field)) { throw new IllegalArgumentException( - "fromData Accept requires exactly the field '" + field + "'"); + "fromData " + action + " requires exactly the field '" + field + "'"); } return data.get(field); } + private static NegotiationRequest toNegotiationRequest( + String agentName, String concern, Map metadata) { + var context = + requireContext( + extractContentContext(metadata), agentName); + Object rawTemplate = + metadata.get( + net.openan.a2at.sdk.core.model.MetadataContent.TEMPLATE_URI_METADATA_KEY); + return new NegotiationRequest( + agentName, + concern, + context.id(), + context.round(), + context.maxRounds(), + context.performative(), + negotiationKind(rawTemplate), + rawTemplate instanceof String text ? text : "", + metadata); + } + + private static NegotiationRequest.Kind negotiationKind(Object rawTemplate) { + String uri = rawTemplate instanceof String text ? text : ""; + if (StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri().equals(uri)) { + return NegotiationRequest.Kind.INFORMATION; + } + if (StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri().equals(uri)) { + return NegotiationRequest.Kind.TARGET; + } + if (StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri().equals(uri)) { + return NegotiationRequest.Kind.FEASIBILITY; + } + throw new IllegalArgumentException( + "Missing or unsupported Negotiation-T propose templateUri: " + rawTemplate); + } + /** - * Extracts the negotiation session context (id/round/maxRounds) from the received - * metadata. The latest SDK's canonical {@code negotiationContext} entry carries all three + * Extracts the negotiation session context (id/round/maxRounds/performative) from the received + * metadata. The latest SDK's canonical {@code negotiationContext} entry carries all four * values; no stateful negotiation API or legacy metadata shape is involved. */ private static net.openan.a2at.sdk.core.model.NegotiationContext extractContentContext( diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java index 3260af1b..a8658a1c 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java @@ -86,7 +86,7 @@ CompletableFuture afterReceive( * messages: the business parameters this handler wants extracted. Declares the shape in * terms of the caller's domain, so the engine core never hardcodes business fields. * - *

        Default: an empty object schema (context params id/round/maxRounds only). + *

        Default: an empty object schema (context fields are handled by the SDK). * * @return parameter JSON schema map, never null */ diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java index 9652663c..b5800dac 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java @@ -48,7 +48,7 @@ class NegotiationTHandler implements ExtensionHandler { private static final Logger log = LoggerFactory.getLogger(NegotiationTHandler.class); - /** Empty object schema: context params (id/round/maxRounds) only. */ + /** Empty object schema: negotiation context fields are handled by the SDK. */ private static final Map DEFAULT_SCHEMA = Map.of("type", "object"); private final Map paramSchema; 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 555f03d7..2b7e8829 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClient.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClient.java @@ -21,7 +21,9 @@ import dev.openan.workflow.engine.control.ControlPoint; import dev.openan.workflow.engine.control.EventCallback; +import dev.openan.workflow.engine.control.TaskDispatcher; import dev.openan.workflow.engine.model.SendMessageResult; +import dev.openan.workflow.engine.model.TaskSubmission; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -47,7 +49,35 @@ * and the global {@link EventCallback}. * */ -public interface WorkflowEngineClient { +public interface WorkflowEngineClient extends TaskDispatcher { + + /** + * Dispatches the strongly typed submission through the matching Task-T generation path. + * Business callbacks should depend on this narrow operation rather than selecting raw client + * overloads themselves. + */ + @Override + default CompletableFuture dispatch(TaskSubmission submission) { + java.util.Objects.requireNonNull(submission, "Task submission is required"); + if (submission.input() instanceof TaskSubmission.NaturalLanguage) { + return sendMessage( + submission.agentName(), + submission.instruction(), + submission.contextId(), + submission.metadata()); + } + TaskSubmission.StructuredData structured = + (TaskSubmission.StructuredData) submission.input(); + Map metadata = new java.util.LinkedHashMap<>(submission.metadata()); + metadata.put(A2ATExtension.TASK_DATA_META_KEY, structured.data()); + metadata.put(A2ATExtension.TASK_SCHEMA_META_KEY, structured.schema()); + metadata.put(A2ATExtension.TASK_TEMPLATE_META_KEY, structured.templateUri().uri()); + return sendMessage( + submission.agentName(), + submission.instruction(), + submission.contextId(), + metadata); + } /** * Send a message to an agent via SSE streaming. Used during workflow execution. The engine diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/ControlPoint.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/ControlPoint.java index 3628be0e..31833ba2 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 @@ -24,7 +24,8 @@ import dev.openan.workflow.engine.model.RouteDecision; import dev.openan.workflow.engine.model.TaskRequest; import dev.openan.workflow.engine.model.TaskResponse; -import dev.openan.workflow.engine.client.WorkflowEngineClient; +import dev.openan.workflow.engine.model.NegotiationDecision; +import dev.openan.workflow.engine.model.NegotiationRequest; import java.util.List; import java.util.Map; @@ -41,14 +42,15 @@ public interface ControlPoint { /** - * Send a task to an agent. Call {@code engineClient.sendMessage(...)}. + * Take over a remote workflow task and dispatch a typed submission. * *

        The SDK handles Task-T prompt generation, the Negotiation-T auto-loop (calling {@link - * #onNegotiation} on INPUT_REQUIRED), auth, and extension header injection. Just send the - * message. + * #onNegotiation} on INPUT_REQUIRED), auth, and extension header injection. Submit through the + * dispatcher. Business code chooses natural-language or structured input by creating a {@link + * dev.openan.workflow.engine.model.TaskSubmission}; it does not manipulate protocol metadata. */ CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient); + TaskRequest request, TaskDispatcher taskDispatcher); /** * Handle a self-loop task locally. Called when a workflow step is marked SELF_LOOP: the agent @@ -74,13 +76,12 @@ CompletableFuture onRoute( String stepName, Map results, List conditions); /** - * Provide supplementary data when an agent returns INPUT_REQUIRED (Negotiation-T). Return the - * clarification text - the SDK internally resends the follow-up message. Do NOT send messages - * here. Default: returns a generic clarification. + * Make a typed business decision when an agent returns INPUT_REQUIRED (Negotiation-T). The SDK + * internally renders and sends the follow-up message. Do not send messages here. */ - default CompletableFuture onNegotiation( - String agentName, String negotiationText, Map receiveResult) { + default CompletableFuture onNegotiation(NegotiationRequest request) { return CompletableFuture.completedFuture( - "Please proceed with the original task using available information."); + NegotiationDecision.acceptText( + "Please proceed with the original task using available information.")); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/DefaultControlPoint.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/DefaultControlPoint.java index 13f8cbe6..65ef8a39 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/DefaultControlPoint.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/DefaultControlPoint.java @@ -19,12 +19,13 @@ package dev.openan.workflow.engine.control; -import dev.openan.workflow.engine.client.WorkflowEngineClient; - import dev.openan.workflow.engine.model.JumpCondition; +import dev.openan.workflow.engine.model.NegotiationDecision; +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.TaskResponse; +import dev.openan.workflow.engine.model.TaskSubmission; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,7 +37,7 @@ * Default ControlPoint with single-responsibility methods. * *

        Negotiation-T auto-loop delegates to an injected {@link NegotiationStrategy} (or returns a - * generic clarification if none is provided). Override onNegotiation directly when a full strategy + * generic Accept decision if none is provided). Override onNegotiation directly when a full strategy * object is unnecessary. */ public class DefaultControlPoint implements ControlPoint { @@ -54,13 +55,13 @@ public DefaultControlPoint(NegotiationStrategy negotiationStrategy) { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { + TaskRequest request, TaskDispatcher taskDispatcher) { log.info( "[DefaultCP] onTask: agent={}, step={}", request.getAgentName(), request.getStepName()); - return engineClient - .sendMessage(request.getAgentName(), request.getMessage()) + return taskDispatcher + .dispatch(TaskSubmission.fromText(request.getAgentName(), request.getMessage())) .thenApply( r -> { boolean success = isSuccessful(r); @@ -127,13 +128,16 @@ public CompletableFuture onRoute( } @Override - public CompletableFuture onNegotiation( - String agentName, String negotiationText, Map receiveResult) { + public CompletableFuture onNegotiation(NegotiationRequest request) { if (negotiationStrategy != null) { - return negotiationStrategy.resolve(agentName, negotiationText, receiveResult); + return negotiationStrategy.resolve(request); } - log.info("[DefaultCP] onNegotiation: agent={}, concern={}", agentName, negotiationText); + log.info( + "[DefaultCP] onNegotiation: agent={}, concern={}", + request.agentName(), + request.concern()); return CompletableFuture.completedFuture( - "Please proceed with the original task using available information."); + NegotiationDecision.acceptText( + "Please proceed with the original task using available information.")); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/NegotiationStrategy.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/NegotiationStrategy.java index 233a91f9..78429919 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/NegotiationStrategy.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/NegotiationStrategy.java @@ -19,15 +19,17 @@ package dev.openan.workflow.engine.control; -import java.util.Map; +import dev.openan.workflow.engine.model.NegotiationDecision; +import dev.openan.workflow.engine.model.NegotiationRequest; + import java.util.concurrent.CompletableFuture; /** - * Strategy for generating negotiation clarifications. + * Strategy for making typed negotiation decisions. * *

        Single responsibility: when an agent returns INPUT_REQUIRED (Negotiation-T), produce the - * clarification text to send back. This is a separate concern from workflow orchestration - * (task dispatch, routing). Users who need custom negotiation logic (LLM-based clarification, + * typed decision to send back. This is a separate concern from workflow orchestration + * (task dispatch, routing). Users who need custom negotiation logic (LLM-based decisions, * DAG-predecessor forwarding, etc.) implement this interface and inject it into {@link * DefaultControlPoint} rather than mixing negotiation policy into their ControlPoint class. * @@ -38,13 +40,10 @@ public interface NegotiationStrategy { /** - * Generate a clarification for the given negotiation request. + * Make a business decision for the given negotiation request. * - * @param agentName the agent requesting negotiation - * @param negotiationText the concern/question raised by the agent - * @param receiveResult the raw negotiation context (may be null/empty) - * @return future completing with the clarification text + * @param request typed business view of the received negotiation request + * @return future completing with an Accept, Reject, or Abort decision */ - CompletableFuture resolve( - String agentName, String negotiationText, Map receiveResult); + CompletableFuture resolve(NegotiationRequest request); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/TaskDispatcher.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/TaskDispatcher.java new file mode 100644 index 00000000..effe2654 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/TaskDispatcher.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.control; + +import dev.openan.workflow.engine.model.SendMessageResult; +import dev.openan.workflow.engine.model.TaskSubmission; + +import java.util.concurrent.CompletableFuture; + +/** Narrow capability exposed to business task callbacks for dispatching one typed remote task. */ +@FunctionalInterface +public interface TaskDispatcher { + + /** Dispatches a natural-language or structured Task-T submission. */ + CompletableFuture dispatch(TaskSubmission submission); +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationDecision.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationDecision.java new file mode 100644 index 00000000..c8c025b6 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationDecision.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Strongly typed business decision for one Negotiation-T round. + * + *

        Text decisions use the SDK's fromText APIs. Structured decisions use its deterministic typed + * fromData APIs. This replaces control strings such as {@code data:}, {@code reject:}, and {@code + * abort:}. + * + * @param action protocol outcome selected by the business + * @param input natural-language or structured decision input + */ +public record NegotiationDecision(Action action, Input input) { + + /** Supported terminal decisions for a received proposal. */ + public enum Action { + ACCEPT, + REJECT, + ABORT + } + + /** Creates an Accept decision from natural language. */ + public static NegotiationDecision acceptText(String text) { + return new NegotiationDecision(Action.ACCEPT, new NaturalLanguage(text)); + } + + /** Creates an Accept decision from deterministic structured values. */ + public static NegotiationDecision acceptData(Map values) { + return new NegotiationDecision(Action.ACCEPT, new StructuredData(normalize(values))); + } + + /** Creates a Reject decision from natural language. */ + public static NegotiationDecision rejectText(String text) { + return new NegotiationDecision(Action.REJECT, new NaturalLanguage(text)); + } + + /** Creates a Reject decision from deterministic structured values. */ + public static NegotiationDecision rejectData(Map values) { + return new NegotiationDecision(Action.REJECT, new StructuredData(normalize(values))); + } + + /** Creates an Abort decision whose reason is extracted by the SDK from natural language. */ + public static NegotiationDecision abortText(String text) { + return new NegotiationDecision(Action.ABORT, new NaturalLanguage(text)); + } + + /** Creates a deterministic typed Abort decision. */ + public static NegotiationDecision abortData(String terminationReason) { + return new NegotiationDecision( + Action.ABORT, + new StructuredData(normalize(Map.of("terminationReason", terminationReason)))); + } + + /** Validates the selected action and input. */ + public NegotiationDecision { + action = Objects.requireNonNull(action, "Negotiation action is required"); + input = Objects.requireNonNull(input, "Negotiation input is required"); + } + + /** Supported business input forms for negotiation content generation. */ + public sealed interface Input permits NaturalLanguage, StructuredData {} + + /** Natural-language decision input. */ + public record NaturalLanguage(String text) implements Input { + /** Rejects blank decision text before invoking the SDK. */ + public NaturalLanguage { + if (text == null || text.isBlank()) { + throw new IllegalArgumentException("Negotiation text must not be blank"); + } + } + } + + /** Structured decision values for the SDK typed fromData path. */ + public record StructuredData(Map values) implements Input { + /** Validates names and values and creates an immutable copy. */ + public StructuredData { + Objects.requireNonNull(values, "Negotiation structured values are required"); + if (values.isEmpty()) { + throw new IllegalArgumentException("Negotiation structured values must not be empty"); + } + values = Collections.unmodifiableMap(new LinkedHashMap<>(values)); + } + } + + private static Map normalize(Map values) { + Objects.requireNonNull(values, "Negotiation structured values are required"); + Map copy = new LinkedHashMap<>(); + values.forEach( + (name, rawValue) -> { + String normalizedName = name == null ? "" : name.strip(); + String normalizedValue = rawValue == null ? "" : rawValue.toString().strip(); + if (normalizedName.isEmpty() + || normalizedValue.isEmpty() + || "null".equalsIgnoreCase(normalizedValue)) { + throw new IllegalArgumentException( + "Negotiation values require non-blank names and values"); + } + copy.put(normalizedName, normalizedValue); + }); + return copy; + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationRequest.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationRequest.java new file mode 100644 index 00000000..bc6118ec --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationRequest.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import net.openan.a2at.sdk.core.model.NegotiationPerformative; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Immutable business view of an INPUT_REQUIRED Negotiation-T request. + * + * @param agentName agent requesting negotiation + * @param concern rendered concern or question + * @param sessionId negotiation session identifier + * @param round current negotiation round + * @param maxRounds negotiated round limit + * @param performative communicative intent carried by the received context + * @param kind semantic negotiation kind inferred from the current SDK template + * @param templateUri received propose template URI, or empty when absent + * @param metadata immutable received metadata for application-specific inspection + */ +public record NegotiationRequest( + String agentName, + String concern, + String sessionId, + int round, + int maxRounds, + NegotiationPerformative performative, + Kind kind, + String templateUri, + Map metadata) { + + /** Negotiation domains supported by the current A2A-T SDK. */ + public enum Kind { + INFORMATION, + TARGET, + FEASIBILITY + } + + /** Validates and defensively copies the request. */ + public NegotiationRequest { + if (agentName == null || agentName.isBlank()) { + throw new IllegalArgumentException("Negotiation agentName must not be blank"); + } + concern = concern == null ? "" : concern; + if (sessionId == null || sessionId.isBlank()) { + throw new IllegalArgumentException("Negotiation sessionId must not be blank"); + } + if (round < 1 || maxRounds < 1 || round > maxRounds) { + throw new IllegalArgumentException( + "Negotiation rounds must satisfy 1 <= round <= maxRounds"); + } + performative = Objects.requireNonNull(performative, "Negotiation performative is required"); + if (performative != NegotiationPerformative.PROPOSE) { + throw new IllegalArgumentException( + "Negotiation callback requires a PROPOSE performative, got " + performative); + } + if (kind == null) { + throw new IllegalArgumentException("Negotiation kind is required"); + } + templateUri = templateUri == null ? "" : templateUri; + metadata = + metadata == null + ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(metadata)); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskSubmission.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskSubmission.java new file mode 100644 index 00000000..e5e14717 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskSubmission.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Immutable business-level description of a remote workflow task dispatch. + * + *

        The submission explicitly distinguishes natural-language input from structured input. The + * workflow engine maps the selected form to the matching current A2A-T SDK generation API; host + * applications never need to put engine-private markers into metadata. + * + * @param agentName target agent name + * @param instruction A2A message text and task instruction + * @param input protocol-content input selected by the business callback + * @param contextId optional A2A conversation context ID + * @param metadata optional non-protocol application metadata + */ +public record TaskSubmission( + String agentName, + String instruction, + Input input, + String contextId, + Map metadata) { + + /** Creates a natural-language Task-T submission. */ + public static TaskSubmission fromText(String agentName, String text) { + return new TaskSubmission(agentName, text, new NaturalLanguage(), null, Map.of()); + } + + /** Creates a structured Task-T submission rendered with a caller-selected Task-T template. */ + public static TaskSubmission fromData( + String agentName, + String instruction, + Map data, + Map schema, + TemplateUri templateUri) { + return new TaskSubmission( + agentName, + instruction, + new StructuredData(data, schema, templateUri), + null, + Map.of()); + } + + /** Validates and defensively copies the submission. */ + public TaskSubmission { + if (agentName == null || agentName.isBlank()) { + throw new IllegalArgumentException("Task target agentName must not be blank"); + } + if (instruction == null || instruction.isBlank()) { + throw new IllegalArgumentException("Task instruction must not be blank"); + } + input = Objects.requireNonNull(input, "Task input is required"); + metadata = + metadata == null + ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(metadata)); + } + + /** Supported business input forms for Task-T generation. */ + public sealed interface Input permits NaturalLanguage, StructuredData {} + + /** Natural-language input: the instruction itself is passed to SDK scenario recognition. */ + public record NaturalLanguage() implements Input {} + + /** Structured input: data and schema are rendered by the SDK's schema-aware fromData API. */ + public record StructuredData( + Map data, Map schema, TemplateUri templateUri) + implements Input { + + /** Validates and defensively copies structured input. */ + public StructuredData { + Objects.requireNonNull(data, "Task structured data is required"); + Objects.requireNonNull(schema, "Task data schema is required"); + Objects.requireNonNull(templateUri, "Task template URI is required"); + if (schema.isEmpty()) { + throw new IllegalArgumentException("Task data schema must not be empty"); + } + if (!StandardTemplates.TASK_EXTENSION_NAME.equals(templateUri.extensionName())) { + throw new IllegalArgumentException( + "Structured task template is not Task-T: " + templateUri.uri()); + } + data = Collections.unmodifiableMap(new LinkedHashMap<>(data)); + schema = Collections.unmodifiableMap(new LinkedHashMap<>(schema)); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java index c98af8e1..b9a728ef 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java @@ -72,7 +72,11 @@ private A2ATContentFacade facade() { } private static NegotiationContext context() { - return new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5); + return new NegotiationContext( + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + 1, + 5, + net.openan.a2at.sdk.core.model.NegotiationPerformative.PROPOSE); } @Test @@ -106,6 +110,7 @@ void rendersInformationProposeFromData() { assertTrue(ctx instanceof Map, "context must travel in metadata"); assertEquals("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", ((Map) ctx).get("id")); assertEquals(1, ((Map) ctx).get("round")); + assertEquals("PROPOSE", ((Map) ctx).get("performative")); } @Test @@ -123,14 +128,16 @@ void rendersAcceptFromData() { // The round travels in metadata, not in the rendered text. assertFalse(mc.promptText().contains("round: 1")); Map metadata = A2ATContentFacade.toMetadata(mc); + Map endingContext = + (Map) + metadata.get( + net.openan.a2at.sdk.core.model.MetadataContent + .NEGOTIATION_CONTEXT_METADATA_KEY); assertEquals( 1, - ((Map) - metadata.get( - net.openan.a2at.sdk.core.model.MetadataContent - .NEGOTIATION_CONTEXT_METADATA_KEY)) - .get("round"), + endingContext.get("round"), "an ending message must preserve the received propose round"); + assertEquals("ACCEPT", endingContext.get("performative")); } @Test @@ -147,6 +154,20 @@ void rejectsFractionalOrOutOfRangeNegotiationContextNumbers() { 1, "maxRounds", (long) Integer.MAX_VALUE + 1))); + assertNull( + A2ATContentFacade.contextFromMap( + Map.of("id", "context", "round", 1, "maxRounds", 5))); + assertNull( + A2ATContentFacade.contextFromMap( + Map.of( + "id", + "context", + "round", + 1, + "maxRounds", + 5, + "performative", + "propose"))); } @Test 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 2b07b21e..4a1311c1 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 @@ -11,6 +11,8 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.StandardTemplates; import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; import org.junit.jupiter.api.Test; @@ -20,24 +22,19 @@ class DefaultWorkflowEngineClientNegotiationTest { @Test - void currentFromDataAcceptRequiresAtLeastOneConcreteStringItem() throws Exception { - var items = DefaultWorkflowEngineClient.parseNegotiationItems( - "{\"任务对象\":\"P781-17\"}"); + void currentFromDataEndingUsesConcreteTypedItems() { + InformationEndingContent content = + assertInstanceOf( + InformationEndingContent.class, + DefaultWorkflowEngineClient.buildEndingContent( + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, + Map.of("任务对象", "P781-17"), + NegotiationConclusion.ACCEPT)); - assertEquals(1, items.size()); - assertEquals("任务对象", items.get(0).name()); - assertEquals("P781-17", items.get(0).value()); - assertThrows( - IllegalArgumentException.class, - () -> DefaultWorkflowEngineClient.parseNegotiationItems("{}")); - assertThrows( - IllegalArgumentException.class, - () -> DefaultWorkflowEngineClient.parseNegotiationItems( - "{\"任务对象\":null}")); - assertThrows( - IllegalArgumentException.class, - () -> DefaultWorkflowEngineClient.parseNegotiationItems( - "{\"任务对象\":\"null\"}")); + assertEquals(NegotiationConclusion.ACCEPT, content.conclusion()); + assertEquals(1, content.items().size()); + assertEquals("任务对象", content.items().get(0).name()); + assertEquals("P781-17", content.items().get(0).value()); } @Test @@ -63,26 +60,30 @@ void endingTemplateMatchesTheReceivedCurrentProposeType() { } @Test - void targetAndFeasibilityFromDataAcceptUseTheirTypedContent() throws Exception { + void targetAndFeasibilityFromDataUseTheirTypedContent() { TargetEndingContent target = assertInstanceOf( TargetEndingContent.class, - DefaultWorkflowEngineClient.buildAcceptEndingContent( + DefaultWorkflowEngineClient.buildEndingContent( StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, - "{\"confirmedIntent\":\"诊断指定专线\"}")); + Map.of("confirmedIntent", "诊断指定专线"), + NegotiationConclusion.REJECT)); FeasibilityEndingContent feasibility = assertInstanceOf( FeasibilityEndingContent.class, - DefaultWorkflowEngineClient.buildAcceptEndingContent( + DefaultWorkflowEngineClient.buildEndingContent( StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - "{\"feasibilitySummary\":\"资源与权限满足\"}")); + Map.of("feasibilitySummary", "资源与权限满足"), + NegotiationConclusion.ACCEPT)); + assertEquals(NegotiationConclusion.REJECT, target.conclusion()); assertEquals("诊断指定专线", target.confirmedIntent()); assertEquals("资源与权限满足", feasibility.feasibilitySummary()); assertThrows( IllegalArgumentException.class, - () -> DefaultWorkflowEngineClient.buildAcceptEndingContent( + () -> DefaultWorkflowEngineClient.buildEndingContent( StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, - "{\"任务对象\":\"P781\"}")); + Map.of("任务对象", "P781"), + NegotiationConclusion.ACCEPT)); } } 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 e16fd5e4..0cdef416 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java @@ -22,8 +22,8 @@ import static org.junit.jupiter.api.Assertions.*; import dev.openan.workflow.engine.StubWorkflowEngineClient; -import dev.openan.workflow.engine.client.WorkflowEngineClient; import dev.openan.workflow.engine.control.ControlPoint; +import dev.openan.workflow.engine.control.TaskDispatcher; import dev.openan.workflow.engine.control.EventCallback; import dev.openan.workflow.engine.control.EventType; @@ -69,9 +69,10 @@ private ControlPoint autoCp() { return new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - return engineClient - .sendMessage(request.getAgentName(), request.getMessage()) + TaskRequest request, TaskDispatcher taskDispatcher) { + return taskDispatcher + .dispatch(dev.openan.workflow.engine.model.TaskSubmission.fromText( + request.getAgentName(), request.getMessage())) .thenApply( r -> TaskResponse.builder() @@ -197,7 +198,7 @@ void parallelBranchesScheduleSharedMergeExactlyOnce() { new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { + TaskRequest request, TaskDispatcher taskDispatcher) { return CompletableFuture.supplyAsync( () -> { awaitBarrier(diagnosisBarrier); @@ -299,8 +300,10 @@ void conditionalRouteOnRouteCalled() { new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest req, WorkflowEngineClient ec) { - return ec.sendMessage(req.getAgentName(), req.getMessage()) + TaskRequest req, TaskDispatcher dispatcher) { + return dispatcher.dispatch( + dev.openan.workflow.engine.model.TaskSubmission.fromText( + req.getAgentName(), req.getMessage())) .thenApply( r -> TaskResponse.builder() @@ -348,8 +351,10 @@ void onRouteInvalidStepFailsWorkflow() { new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest req, WorkflowEngineClient ec) { - return ec.sendMessage(req.getAgentName(), req.getMessage()) + TaskRequest req, TaskDispatcher dispatcher) { + return dispatcher.dispatch( + dev.openan.workflow.engine.model.TaskSubmission.fromText( + req.getAgentName(), req.getMessage())) .thenApply( r -> TaskResponse.builder() @@ -400,7 +405,7 @@ void taskFailurePropagatesAndStopsWorkflow() { new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest req, WorkflowEngineClient ec) { + TaskRequest req, TaskDispatcher dispatcher) { if (req.getAgentName().equals("A")) { return CompletableFuture.completedFuture( TaskResponse.builder() @@ -408,7 +413,9 @@ public CompletableFuture onTask( .error("A failed") .build()); } - return ec.sendMessage(req.getAgentName(), req.getMessage()) + return dispatcher.dispatch( + dev.openan.workflow.engine.model.TaskSubmission.fromText( + req.getAgentName(), req.getMessage())) .thenApply( r -> TaskResponse.builder() @@ -481,7 +488,7 @@ void anySuccessAllFailReturnsFailure() { new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest req, WorkflowEngineClient ec) { + TaskRequest req, TaskDispatcher dispatcher) { return CompletableFuture.completedFuture( TaskResponse.builder().success(false).error("failed").build()); } @@ -551,7 +558,7 @@ void runtimeIntentPassedToContext() { new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest req, WorkflowEngineClient ec) { + TaskRequest req, TaskDispatcher dispatcher) { messages.add(req.getMessage()); return CompletableFuture.completedFuture( TaskResponse.builder().success(true).output("ok").build()); @@ -684,8 +691,10 @@ void mergeDoesNotWaitForUnselectedConditionalBranch() { new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - return engineClient.sendMessage(request.getAgentName(), request.getMessage()) + TaskRequest request, TaskDispatcher taskDispatcher) { + return taskDispatcher.dispatch( + dev.openan.workflow.engine.model.TaskSubmission.fromText( + request.getAgentName(), request.getMessage())) .thenApply( response -> TaskResponse.builder() @@ -750,8 +759,10 @@ void selfLoopStepCallsOnSelfTaskNotOnTask() { new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest req, WorkflowEngineClient ec) { - return ec.sendMessage(req.getAgentName(), req.getMessage()) + TaskRequest req, TaskDispatcher dispatcher) { + return dispatcher.dispatch( + dev.openan.workflow.engine.model.TaskSubmission.fromText( + req.getAgentName(), req.getMessage())) .thenApply( r -> TaskResponse.builder() @@ -804,7 +815,7 @@ void repeatedDescriptionsInParallelStepDoNotOverwriteResults() { new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { + TaskRequest request, TaskDispatcher taskDispatcher) { return CompletableFuture.completedFuture( TaskResponse.builder() .success(true) @@ -858,7 +869,7 @@ void nullTaskOutputAndNullExceptionMessageAreNormalized() { new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { + TaskRequest request, TaskDispatcher taskDispatcher) { return CompletableFuture.completedFuture( TaskResponse.builder().success(true).output(null).build()); } @@ -889,7 +900,7 @@ public CompletableFuture onRoute( new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { + TaskRequest request, TaskDispatcher taskDispatcher) { return CompletableFuture.failedFuture(new RuntimeException((String) null)); } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationDecisionTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationDecisionTest.java new file mode 100644 index 00000000..c99c14aa --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationDecisionTest.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +class NegotiationDecisionTest { + + @Test + void factoriesPreserveActionAndInputMode() { + var accept = NegotiationDecision.acceptData(Map.of("port", "P1")); + var reject = NegotiationDecision.rejectText("policy denied"); + var abort = NegotiationDecision.abortData("round limit"); + + assertEquals(NegotiationDecision.Action.ACCEPT, accept.action()); + assertEquals( + "P1", + assertInstanceOf(NegotiationDecision.StructuredData.class, accept.input()) + .values() + .get("port")); + assertEquals(NegotiationDecision.Action.REJECT, reject.action()); + assertEquals( + "policy denied", + assertInstanceOf(NegotiationDecision.NaturalLanguage.class, reject.input()).text()); + assertEquals(NegotiationDecision.Action.ABORT, abort.action()); + } + + @Test + void invalidBusinessValuesFailBeforeSdkInvocation() { + assertThrows(IllegalArgumentException.class, () -> NegotiationDecision.acceptText(" ")); + assertThrows(IllegalArgumentException.class, () -> NegotiationDecision.acceptData(Map.of())); + assertThrows( + IllegalArgumentException.class, + () -> NegotiationDecision.acceptData(java.util.Collections.singletonMap("port", null))); + assertThrows( + IllegalArgumentException.class, + () -> NegotiationDecision.rejectData(Map.of("port", "null"))); + assertThrows(IllegalArgumentException.class, () -> NegotiationDecision.abortData(" ")); + assertThrows(IllegalArgumentException.class, () -> NegotiationDecision.abortData("null")); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationRequestTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationRequestTest.java new file mode 100644 index 00000000..0a812952 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationRequestTest.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import net.openan.a2at.sdk.core.model.NegotiationPerformative; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +class NegotiationRequestTest { + + @Test + void retainsTypedPerformativeAndDefensivelyCopiesMetadata() { + Map metadata = new LinkedHashMap<>(); + metadata.put("trace", "one"); + var request = + new NegotiationRequest( + "SPN", + "confirm port", + "session-1", + 1, + 3, + NegotiationPerformative.PROPOSE, + NegotiationRequest.Kind.INFORMATION, + "urn:template", + metadata); + + metadata.put("trace", "two"); + assertEquals(NegotiationPerformative.PROPOSE, request.performative()); + assertEquals("one", request.metadata().get("trace")); + assertThrows(UnsupportedOperationException.class, () -> request.metadata().put("x", "y")); + } + + @Test + void rejectsInvalidSessionAndRounds() { + assertThrows( + IllegalArgumentException.class, + () -> request("", 1, 3)); + assertThrows( + IllegalArgumentException.class, + () -> request("session-1", 0, 3)); + assertThrows( + IllegalArgumentException.class, + () -> request("session-1", 4, 3)); + assertThrows( + IllegalArgumentException.class, + () -> + new NegotiationRequest( + "SPN", + "confirm port", + "session-1", + 1, + 3, + NegotiationPerformative.ACCEPT, + NegotiationRequest.Kind.INFORMATION, + "urn:template", + Map.of())); + } + + private static NegotiationRequest request(String sessionId, int round, int maxRounds) { + return new NegotiationRequest( + "SPN", + "confirm port", + sessionId, + round, + maxRounds, + NegotiationPerformative.PROPOSE, + NegotiationRequest.Kind.INFORMATION, + "urn:template", + Map.of()); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/model/TaskSubmissionTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/TaskSubmissionTest.java new file mode 100644 index 00000000..2792cc83 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/TaskSubmissionTest.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.openan.workflow.engine.StubWorkflowEngineClient; +import dev.openan.workflow.engine.client.A2ATExtension; + +import net.openan.a2at.sdk.core.model.StandardTemplates; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +class TaskSubmissionTest { + + @Test + void naturalLanguageSubmissionUsesPlainSendPath() { + StubWorkflowEngineClient client = new StubWorkflowEngineClient("agent"); + + client.dispatch(TaskSubmission.fromText("agent", "diagnose link")).join(); + + var sent = client.getSentMessages().get(0); + assertEquals("diagnose link", sent.message); + assertEquals(Map.of(), sent.metadata); + } + + @Test + void structuredSubmissionAddsOnlyEngineOwnedTaskInputMetadata() { + StubWorkflowEngineClient client = new StubWorkflowEngineClient("agent"); + TaskSubmission submission = + TaskSubmission.fromData( + "agent", + "create diagnosis", + Map.of("port", "P1"), + Map.of("type", "object"), + StandardTemplates.PRIVATE_LINE_COMPLAINT); + + client.dispatch(submission).join(); + + var metadata = client.getSentMessages().get(0).metadata; + assertEquals(Map.of("port", "P1"), metadata.get(A2ATExtension.TASK_DATA_META_KEY)); + assertEquals( + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri(), + metadata.get(A2ATExtension.TASK_TEMPLATE_META_KEY)); + assertInstanceOf(TaskSubmission.StructuredData.class, submission.input()); + } + + @Test + void invalidSubmissionFailsAtBusinessBoundary() { + assertThrows(IllegalArgumentException.class, () -> TaskSubmission.fromText("", "task")); + assertThrows( + IllegalArgumentException.class, + () -> + TaskSubmission.fromData( + "agent", + "task", + Map.of(), + Map.of(), + StandardTemplates.PRIVATE_LINE_COMPLAINT)); + } +} 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 27d69f11..a0873267 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/runner/ExecutePsopTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/runner/ExecutePsopTest.java @@ -22,8 +22,8 @@ import static org.junit.jupiter.api.Assertions.*; import dev.openan.workflow.engine.StubWorkflowEngineClient; -import dev.openan.workflow.engine.client.WorkflowEngineClient; import dev.openan.workflow.engine.control.ControlPoint; +import dev.openan.workflow.engine.control.TaskDispatcher; import dev.openan.workflow.engine.control.EventCallback; import dev.openan.workflow.engine.control.EventType; @@ -51,9 +51,10 @@ private ControlPoint autoCp() { return new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { - return engineClient - .sendMessage(request.getAgentName(), request.getMessage()) + TaskRequest request, TaskDispatcher taskDispatcher) { + return taskDispatcher + .dispatch(dev.openan.workflow.engine.model.TaskSubmission.fromText( + request.getAgentName(), request.getMessage())) .thenApply( r -> TaskResponse.builder() @@ -337,13 +338,14 @@ void errorLifecycleOnTaskFailure() { new ControlPoint() { @Override public CompletableFuture onTask( - TaskRequest request, WorkflowEngineClient engineClient) { + TaskRequest request, TaskDispatcher taskDispatcher) { if (request.getAgentName().equals("A")) { return CompletableFuture.completedFuture( TaskResponse.builder().success(false).error("A broke").build()); } - return engineClient - .sendMessage(request.getAgentName(), request.getMessage()) + return taskDispatcher + .dispatch(dev.openan.workflow.engine.model.TaskSubmission.fromText( + request.getAgentName(), request.getMessage())) .thenApply( r -> TaskResponse.builder() From b78cdf5648ee6639fb5dd7ec84dd4d2a17f6cf9b Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Thu, 27 Aug 2026 18:39:29 +0800 Subject: [PATCH 33/49] docs(en): sync integration guide generic sections with current protocol Apply the generic parts of the dev-side integration guide updates to main: independent Authorization-T/Notification-T operation sections, the Negotiation-T/v1 extension URI, openNotificationFromData subscription lifecycle, and the extension sender API table row. The Eastcom Order SDK 1.1.18 ByteBuf troubleshooting section stays dev-only. Signed-off-by: Zhoujie628 --- docs/en/INTEGRATION_GUIDE.md | 65 ++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 37 deletions(-) diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index 9f3ecefb..beaf02a5 100644 --- a/docs/en/INTEGRATION_GUIDE.md +++ b/docs/en/INTEGRATION_GUIDE.md @@ -148,7 +148,7 @@ public class MyControlPoint extends DefaultControlPoint { `onNegotiation` defaults to a generic `acceptText` decision. For structured business data use `acceptData/rejectData/abortData`; never encode a decision in a string prefix. -**Pre-positioning (Authorization-T / Notification-T)**: Both are established through `ExtensionSender` before a workflow starts, but they have different lifecycles. Authorization-T is a one-shot request that ends after its response. Notification-T is a long-lived subscription on a transport independent of any single workflow; its first event or acknowledgement is returned as `SendMessageResult`, and later events are delivered to the callback until that subscription transport is explicitly closed. +**Independent operations (Authorization-T / Notification-T)**: Both use `ExtensionSender` at a workbench-selected business time and have no ordering dependency on workflow execution. Authorization-T is a one-shot request whose transport closes after a successful acknowledgement. Notification-T is a long-lived subscription on a transport independent of any single workflow; `openNotificationFromData` returns a `NotificationSubscription`, and later events are delivered to its callback until the expected result arrives, it is canceled, or it is explicitly closed. **Self-loop steps (SelfLoop)**: When a step is the workflow-executing agent's own task (e.g. merging multiple agents' diagnostic results), set `stepType` to `SELF_LOOP`. The engine calls `onSelfTask` locally instead of sending an A2A-T @@ -354,7 +354,7 @@ AgentCards declare extensions via `capabilities.extensions`: "required": false }, { - "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T", + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", "description": "Negotiation text exchange", "required": false }, @@ -412,55 +412,46 @@ When an agent returns `INPUT_REQUIRED`, the engine creates a `NegotiationRequest `onNegotiation()` for a typed Accept/Reject/Abort decision, invokes the matching SDK fromText/fromData API, and sends the rendered follow-up. Auto-loops up to `maxNegotiationRounds` (default 3). -### Authorization-T (pre-positioning) +### Authorization-T (independent authorization operation) -Before the workflow starts, send a whitelist authorization strategy to SPN agents. Pre-positioning uses the -`ExtensionSender` facade over the same transport, not the workflow client: +Invoke this when the workbench needs to manage a recovery whitelist; it is not a workflow node. +Authorization-T uses a dedicated transport/runtime/context: ```java -ExtensionSender sender = new DefaultExtensionSender(transport); -sender.sendAuthorization( +ExtensionSender authorizationSender = new DefaultExtensionSender(authorizationTransport); +authorizationSender.sendExtensionMessageFromData( "SPN Domain Agent", - "Authorization-T pre-positioning", - "Task type: new authorization, operation: service recovery, ..." -); + "Authorization-T structured operation", + authorizationData, + authorizationSchema, + A2ATExtension.AUTHORIZATION_T).join(); ``` `A2ATExtension.AUTHORIZATION_T` is used internally; never hardcode the URI. The SPN agent stores the strategy and compares subsequent operations against the whitelist. Operations within the whitelist are executed; others are rejected. -### Notification-T (pre-positioning) +### Notification-T (independent long-lived subscription) -Before the workflow starts, subscribe to recovery result notifications: +Invoke this when the workbench needs recovery-result notifications. It uses a transport/runtime/context +separate from Task-T and Authorization-T: ```java -sender.sendNotification( +NotificationSubscription subscription = notificationSender.openNotificationFromData( "SPN Domain Agent", - "Notification-T subscription", - "Topic: service-recovery-execution-result, ..." -); -``` - -`A2ATExtension.NOTIFICATION_T` opens a long-lived SSE stream. To receive subsequent recovery results, pass a -`Consumer>` callback as the fourth parameter: - -```java -sender.sendNotification( - "SPN Domain Agent", - "Notification-T subscription", - "Topic: service-recovery-execution-result, ...", + "Notification-T subscription", + notificationData, + notificationSchema, event -> { - // event contains agent, text, metadata, state - Object text = event.get("text"); - if (text != null) { - System.out.println("Recovery result: " + text); + if ("recovery-result".equals(event.get("artifact_name"))) { + persistRecoveryResult(event); } - } -); + }).join(); +SendMessageResult ack = subscription.acknowledgement().join(); ``` -Without a callback (null), subsequent events are dropped. The SPN agent reports recovery results through the -notification channel. +The ACK only confirms that the subscription was established. Retain the `NotificationSubscription` and +call its idempotent `close()` after the expected `recovery-result`, cancellation, or workbench shutdown. +Normal workflow completion does not close this channel. ## 8. HTTPS Configuration @@ -488,7 +479,7 @@ so mTLS and `crlPath` cannot be combined with that mode and fail fast instead of The dedicated `PROTOCOL` logger emits protocol request/response summaries. Bodies are disabled by default. Sensitive headers such as Authorization, cookies, API keys, tokens, and secrets are -redacted by default. Enable DEBUG and bodies only in a controlled integration environment: +redacted by default. Enable DEBUG and bodies only temporarily in a controlled diagnostic environment: ```properties logger.protocol.name=PROTOCOL @@ -591,7 +582,7 @@ WorkflowEngineClientConfig.builder() ### Symptom -In Order mode, when sending Task-T diagnosis tasks to two different NEs concurrently (or after pre-positioning Notification-T to City1, sending Authorization-T to City2), one NE's request fails with `Timeout on blocking read for 10000000000 NANOSECONDS` in `WrapperHttpClient.loadNeResource`. +In Order mode, when sending Task-T diagnosis tasks to two different NEs concurrently (or while independent Notification-T and Authorization-T channels target different NEs), one NE's request fails with `Timeout on blocking read for 10000000000 NANOSECONDS` in `WrapperHttpClient.loadNeResource`. ### Root Cause 1: SDK Shared RSocket Connection @@ -643,7 +634,7 @@ The simulator's `connection.setReadTimeout(65_000)` only controls `HttpURLConnec | `ExecutePsop.Builder` | Workflow execution entry point | | `ControlPoint` / `DefaultControlPoint` | Business decisions (onTask, onSelfTask, onRoute, onNegotiation, etc.) | | `WorkflowEngineClient` / `DefaultWorkflowEngineClient` | Workflow send (sendMessage, auth, extensions) | -| `ExtensionSender` / `DefaultExtensionSender` | One-shot pre-positioning (sendAuthorization, sendNotification) | +| `ExtensionSender` / `DefaultExtensionSender` | Independent Authorization-T operations and Notification-T subscriptions | | `A2ATransport` | Shared wire layer (httpx runtime, auth, SSE consumer) | | `WorkflowEngineClientConfig` | Configuration (SSL, auth, A2A-T, negotiation rounds, custom handlers) | | `AuthProvider` | Custom authentication | From 5b155cfc21472ca0b0c0de8346e22a442f7e2fec Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Thu, 27 Aug 2026 18:44:28 +0800 Subject: [PATCH 34/49] docs: remove vendor SDK references from main Strip vendor-specific content from the main branch: - drop the vendor-SDK concurrent NE timeout troubleshooting section from the English integration guide (it stays in the dev-only docs) - drop the vendor transport-mode and instruction-platform env block from .env.example - reduce the dual-transport verification wording in the SDK dependency doc and the business-flow doc to the direct-transport path Main carries only the generic direct-transport engine; vendor adapter material lives on dev. Signed-off-by: Zhoujie628 --- .env.example | 21 +------- docs/en/INTEGRATION_GUIDE.md | 51 ------------------- docs/zh/A2AT-SDK-DEPENDENCY.md | 9 ++-- .../\344\270\232\345\212\241\346\265\201.md" | 3 +- 4 files changed, 5 insertions(+), 79 deletions(-) diff --git a/.env.example b/.env.example index 717dc1bb..cb39f118 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ # A2A-T SDK Configuration # Copy this file to .env and fill in your values -# Full A2A/Order protocol messages are emitted by the dedicated PROTOCOL DEBUG logger. +# Full A2A protocol messages are emitted by the dedicated PROTOCOL DEBUG logger. # Keep sensitive values hidden by default; set true only during controlled local debugging. WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=false # Set false to omit request/response bodies; large bodies are truncated at this character count. @@ -54,22 +54,3 @@ A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory # Credential encryption key (32-byte hex, used for enc: prefixed passwords in credentials JSON) # Generate with: openssl rand -hex 32 A2AT_CRED_KEY=your-32-byte-hex-key-here - -# Workbench outbound transport: order (Eastcom SDK), mock, or direct -A2A_TRANSPORT_MODE=order -A2A_MOCK_GATEWAY_URL=http://127.0.0.1:26400 - -# Eastcom instruction platform. Configure either a default NE or per-agent NE routes. -# true starts the local RSocket-RPC protocol simulator; never enable it in production. -EASTCOM_ORDER_SIMULATOR_ENABLED=false -EASTCOM_ORDER_HOST=10.x.x.x -EASTCOM_ORDER_PORT=0 -EASTCOM_ORDER_USERNAME=your-username -EASTCOM_ORDER_PASSWORD=your-password -EASTCOM_ORDER_CLIENT_ID= -EASTCOM_ORDER_CLIENT_SECRET= -EASTCOM_ORDER_DEFAULT_NE= -EASTCOM_ORDER_CITY1_NE=city1-omc -EASTCOM_ORDER_CITY2_NE=city2-omc -EASTCOM_ORDER_LOGIN_TIMEOUT_SECONDS=15 -EASTCOM_ORDER_TIMEOUT_SECONDS=600 diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index beaf02a5..b6419ec1 100644 --- a/docs/en/INTEGRATION_GUIDE.md +++ b/docs/en/INTEGRATION_GUIDE.md @@ -576,57 +576,6 @@ WorkflowEngineClientConfig.builder() .build(); ``` -## 14. Troubleshooting: Eastcom Order Mode Concurrent NE Timeout - -> **Critical pitfall — read before modifying the Order simulator or gateway adapter.** - -### Symptom - -In Order mode, when sending Task-T diagnosis tasks to two different NEs concurrently (or while independent Notification-T and Authorization-T channels target different NEs), one NE's request fails with `Timeout on blocking read for 10000000000 NANOSECONDS` in `WrapperHttpClient.loadNeResource`. - -### Root Cause 1: SDK Shared RSocket Connection - -The Eastcom SDK's `ServiceReference` caches RSocket connections in a `static final Map INVOKERS` keyed by `className + host + port`. All requests to the same platform address share one RSocket connection, regardless of the NE name. - -`WrapperHttpClient.loadNeResource()` calls `Mono.block(Duration.ofSeconds(10))` — a hardcoded 10-second timeout that cannot be changed via configuration. - -### Root Cause 2: Blocking I/O on RSocket Event Loop - -The `EastcomOrderSimulatorServer.execute()` method (handling RSocket `requestChannel`) calls `forwardParsed()` which uses `HttpURLConnection` with blocking `reader.read()`. When City1's Notification-T stream is long-lived (waiting for OMC to push recovery events), `reader.read()` blocks indefinitely. If this runs on the RSocket event loop thread (`reactor-tcp-nio-*`), the thread is occupied and cannot process City2's `loadNeResource` (RSocket `requestResponse`) on the same connection — causing the 10-second timeout. - -### Fix 1: Move Forwarding I/O Off the Event Loop Thread - -Add `.subscribeOn(Schedulers.boundedElastic())` to the `forwardParsed` call so blocking I/O runs on worker threads, freeing the RSocket event loop: - -```java -forwardParsed(httpMethod, httpPath, parsedHeaders, body, forwardUrl, streaming) - .subscribeOn(Schedulers.boundedElastic()) - .subscribe(sink::next, sink::error, () -> sink.complete()); -``` - -### Fix 2: Use HTTP Host Header Instead of Shared `lastResolvedTarget` - -The original simulator used a `volatile String lastResolvedTarget` field set by `loadNeResource` and read by `execute`. When two NEs call `loadNeResource` concurrently, the second overwrites the first's target. The SDK's `configureHeaderHost()` sets the HTTP `Host` header to the full `neUrl` per request, so each request carries its own target: - -```java -String hostHeader = findHeader(parsedHeaders, "Host"); -String targetBase = (hostHeader != null && hostHeader.startsWith("http")) - ? withoutTrailingSlash(hostHeader) - : (lastResolvedTarget != null ? lastResolvedTarget : defaultUrl); -``` - -### Why `readTimeout=65s` Alone Doesn't Help - -The simulator's `connection.setReadTimeout(65_000)` only controls `HttpURLConnection`'s read timeout. It does not affect the SDK's internal `Mono.block(10s)` in `loadNeResource`. The root cause is the RSocket event loop being blocked, not HTTP read timeout. - -### Diagnostic Checklist - -1. Confirm timeout location: `WrapperHttpClient.loadNeResource` in stack trace = RSocket timeout, not HTTP -2. Confirm thread: `FORWARD_CHUNK`/`FORWARD_DONE` logs should show `boundedElastic-*`, not `reactor-tcp-nio-*` -3. Confirm target: `FORWARD_START target=` should match `LOAD_NE_ACCEPTED target=` for the same NE -4. Confirm session reuse: `SESSION_REUSE` in logs, not `SESSION_OPEN` every time -5. Confirm singleton: `ClientRuntimeFactory.create()` returns singleton in ORDER mode - ## 13. Interface Reference | Interface/Class | Purpose | diff --git a/docs/zh/A2AT-SDK-DEPENDENCY.md b/docs/zh/A2AT-SDK-DEPENDENCY.md index d9a4224a..b4c6295b 100644 --- a/docs/zh/A2AT-SDK-DEPENDENCY.md +++ b/docs/zh/A2AT-SDK-DEPENDENCY.md @@ -47,13 +47,10 @@ mvn -B clean verify 3. 本文及 README 的安装命令。 禁止只覆盖本地同版本 jar。新版本至少要验证 Task-T 生成/校验、无状态 Negotiation-T -metadata、Authorization-T、Notification-T 长连接,以及两条现行传输路径: +metadata、Authorization-T、Notification-T 长连接,以及现行传输路径: +验证直连 OMC 的 SpringSpnDemo 端到端流程。 -1. `main` 验证直连 OMC 的 SpringSpnDemo 端到端流程; -2. `dev` 同时验证直连 OMC 和东信 Order 指令平台模拟器的 SpringSpnDemo 端到端流程。 - -“只支持最新 A2A-T SDK”仅表示不兼容旧 SDK 版本和旧协议格式,绝不表示取消直连或 -Order 任一传输能力,也不允许省略双传输链路测试。 +“只支持最新 A2A-T SDK”仅表示不兼容旧 SDK 版本和旧协议格式。 ## 单一资源基线 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 6d713bb3..3491ce6c 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" @@ -6,8 +6,7 @@ 投诉参数,搜索/加载工作流。 2. 工作流向两个不同地市 OMC 并行下发含地市诊断输入的 Task-T。两路结果都完成后, 工作台 join 节点仅执行一次汇总分析,把真实汇总结果返回 WAIMO。 -3. 工作台到 OMC 支持直连与东信 Order SDK/指令平台转发;两种通道上层协议和业务回调一致, - 现网优先使用东信转发。 +3. 工作台到 OMC 通过直连通道下发 Task-T 诊断任务。 4. Authorization-T 和 Notification-T 由工作台在各自业务时机独立触发,不是工作流 DAG 节点。Task、Authorization、Notification 使用不同的 transport/runtime/context/通道。 5. OMC 对诊断后的抢通方案做白名单精确匹配;命中后自动执行,并通过已建立的 From 638049c17456c2232a29948835e0fc7a9cfa4a09 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Fri, 28 Aug 2026 17:38:54 +0800 Subject: [PATCH 35/49] chore(a2a): upgrade Java SDK to 1.2.0.Final Signed-off-by: Zhoujie628 --- .github/workflows/ci.yml | 8 +- pom.xml | 15 ++- samples/pom.xml | 2 +- .../examples/server/JdkHttpA2AServer.java | 18 +-- .../engine/spring/A2AAutoConfiguration.java | 12 +- .../workflow/engine/spring/A2AController.java | 35 +++--- .../SpringCompatibleRequestHandler.java | 110 ++++++++++++++++++ .../spring/A2AAutoConfigurationTest.java | 22 ++-- .../engine/client/AgentCardJacksonModule.java | 4 +- .../client/A2ATransportResponseTest.java | 2 +- 10 files changed, 182 insertions(+), 46 deletions(-) create mode 100644 spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/SpringCompatibleRequestHandler.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99194828..9e4d932e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,8 @@ name: CI env: A2AT_SDK_REPOSITORY: project-openan/a2a-t-sdk-java - A2AT_SDK_REF: 34485504675800a436d68cfaa913f14931086506 - A2AT_SDK_VERSION: 1.0.0-3448550 + A2AT_SDK_REF: 0de5a2751781419820436e5eb17cffc39b9db47d + A2AT_SDK_VERSION: 1.0.0-0de5a27 on: push: @@ -15,7 +15,7 @@ jobs: build-and-test: runs-on: ubuntu-latest name: Build and Test (workflow-engine, spring-boot-starter) - + steps: - name: Checkout code uses: actions/checkout@v4 @@ -64,7 +64,7 @@ jobs: runs-on: ubuntu-latest name: Samples Compilation Check (optional) continue-on-error: true - + steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/pom.xml b/pom.xml index 5ff892f2..7a67cae9 100644 --- a/pom.xml +++ b/pom.xml @@ -51,10 +51,12 @@ 1.0.0 17 UTF-8 - 1.0.0.Beta1 + 1.2.0.Final + + 4.35.0 - 1.0.0-841c575 - 841c575054d7aecc532e3fd312e004241727951b + 1.0.0-0de5a27 + 0de5a2751781419820436e5eb17cffc39b9db47d 1.18.38 2.21.1 2.0.17 @@ -70,6 +72,13 @@ + + org.a2aproject.sdk + a2a-java-sdk-bom + ${a2a.java.sdk.version} + pom + import + com.fasterxml.jackson jackson-bom diff --git a/samples/pom.xml b/samples/pom.xml index 52b2d0a5..49919228 100644 --- a/samples/pom.xml +++ b/samples/pom.xml @@ -41,7 +41,7 @@ com.google.protobuf protobuf-java-util - 3.25.5 + ${protobuf.java.util.version} 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 b12695e6..b7638b10 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/server/JdkHttpA2AServer.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/server/JdkHttpA2AServer.java @@ -357,14 +357,15 @@ private RestHandler initServerComponents( new MainEventBusProcessor(mainEventBus, taskStore, pushSender, queueManager); startEventBus(eventBusProc); RequestHandler requestHandler = - DefaultRequestHandler.create( - agentExecutor, - taskStore, - queueManager, - pushStore, - eventBusProc, - agentExecutorService, - agentExecutorService); + DefaultRequestHandler.builder() + .agentExecutor(agentExecutor) + .taskStore(taskStore) + .queueManager(queueManager) + .pushConfigStore(pushStore) + .mainEventBusProcessor(eventBusProc) + .executor(agentExecutorService) + .eventConsumerExecutor(agentExecutorService) + .build(); return new RestHandler( typedCard, new AgentCardCacheMetadata(typedCard, null), @@ -533,7 +534,6 @@ private void handleStream(HttpExchange exchange, RestHandler restHandler, String SendMessageRequest.Builder builder = SendMessageRequest.newBuilder(); JsonFormat.parser().merge(requestBody, builder); var request = ProtoUtils.FromProto.messageSendParams(builder.build()); - inner.validateRequestedTask(request.message().taskId()); Flow.Publisher publisher = inner.onMessageSendStream(request, buildCallContext(exchange)); exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); diff --git a/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 e83032e3..a0d705f5 100644 --- a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java @@ -191,7 +191,17 @@ public RequestHandler requestHandler( PushNotificationConfigStore pushStore, MainEventBusProcessor proc, ExecutorService pool) { - return DefaultRequestHandler.create(executor, store, qm, pushStore, proc, pool, pool); + RequestHandler delegate = + DefaultRequestHandler.builder() + .agentExecutor(executor) + .taskStore(store) + .queueManager(qm) + .pushConfigStore(pushStore) + .mainEventBusProcessor(proc) + .executor(pool) + .eventConsumerExecutor(pool) + .build(); + return new SpringCompatibleRequestHandler(delegate); } @Bean 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 4d1624d6..3efb4b99 100644 --- a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java @@ -41,6 +41,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.LinkedHashMap; import java.util.Locale; @@ -99,14 +100,13 @@ public ResponseEntity sendMessage(HttpServletRequest req, @RequestBody S @PostMapping( value = "${a2at.server.path-prefix}/message:stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public ResponseBodyEmitter streamMessage(HttpServletRequest req, @RequestBody String body) { - ResponseBodyEmitter emitter = track(new ResponseBodyEmitter(0L)); + public SseEmitter streamMessage(HttpServletRequest req, @RequestBody String body) { + SseEmitter emitter = track(new SseEmitter(0L)); try { SendMessageRequest.Builder builder = SendMessageRequest.newBuilder(); JsonFormat.parser().merge(body, builder); MessageSendParams params = ProtoUtils.FromProto.messageSendParams(builder.build()); - requestHandler.validateRequestedTask(params.message().taskId()); var ctx = buildContext(req); Flow.Publisher publisher = requestHandler.onMessageSendStream(params, ctx); @@ -130,12 +130,10 @@ public void onNext(StreamingEventKind item) { JsonFormat.printer() .omittingInsignificantWhitespace() .print(sr); - String sse = - String.format(Locale.ROOT, "id:%d%n", seq.incrementAndGet()) - + "data:" - + compact - + "\n\n"; - emitter.send(sse); + 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(); @@ -197,10 +195,10 @@ public ResponseEntity cancelTask( @PostMapping( value = "${a2at.server.path-prefix}/tasks/{id}:subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public ResponseBodyEmitter subscribeToTask( + public SseEmitter subscribeToTask( HttpServletRequest req, @PathVariable("id") String taskId) { - ResponseBodyEmitter emitter = track(new ResponseBodyEmitter(0L)); + SseEmitter emitter = track(new SseEmitter(0L)); try { var ctx = buildContext(req); var resp = restHandler.subscribeToTask(ctx, "", taskId); @@ -217,10 +215,10 @@ public void onSubscribe(Flow.Subscription s) { @Override public void onNext(String item) { try { - String sse = - String.format(Locale.ROOT, "id:%d%n", seq.incrementAndGet()) - + "data:" + item + "\n\n"; - emitter.send(sse); + 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(); @@ -240,7 +238,10 @@ public void onComplete() { } }); } else { - emitter.send(resp.getBody()); + emitter.send( + SseEmitter.event() + .id("1") + .data(resp.getBody(), MediaType.APPLICATION_JSON)); emitter.complete(); } } catch (Exception e) { @@ -283,7 +284,7 @@ public int activeStreamCount() { return activeStreams.get(); } - private ResponseBodyEmitter track(ResponseBodyEmitter emitter) { + private T track(T emitter) { activeStreams.incrementAndGet(); AtomicBoolean completed = new AtomicBoolean(); Runnable finish = diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/SpringCompatibleRequestHandler.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/SpringCompatibleRequestHandler.java new file mode 100644 index 00000000..d7219486 --- /dev/null +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/SpringCompatibleRequestHandler.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.spring; + +import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; +import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.auth.TaskOperation; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.EventKind; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.spec.ListTasksParams; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; + +import java.util.Objects; +import java.util.concurrent.Flow; + +/** + * Keeps the A2A request handler's CDI-only injection points outside Spring bean processing. + * + *

        A2A Java SDK 1.2 supports both builder-created handlers and CDI-managed handlers. Its concrete + * {@code DefaultRequestHandler} therefore contains optional CDI {@code Instance} fields. Spring + * treats those fields as mandatory injection points when the concrete object itself is registered + * as a bean. This typed delegate exposes only the public {@link RequestHandler} contract to Spring; + * the builder-created delegate remains framework-neutral. + */ +final class SpringCompatibleRequestHandler implements RequestHandler { + private final RequestHandler delegate; + + SpringCompatibleRequestHandler(RequestHandler delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + } + + @Override + public Task onGetTask(TaskQueryParams params, ServerCallContext context) throws A2AError { + return delegate.onGetTask(params, context); + } + + @Override + public ListTasksResult onListTasks(ListTasksParams params, ServerCallContext context) + throws A2AError { + return delegate.onListTasks(params, context); + } + + @Override + public Task onCancelTask(CancelTaskParams params, ServerCallContext context) throws A2AError { + return delegate.onCancelTask(params, context); + } + + @Override + public EventKind onMessageSend(MessageSendParams params, ServerCallContext context) + throws A2AError { + return delegate.onMessageSend(params, context); + } + + @Override + public Flow.Publisher onMessageSendStream( + MessageSendParams params, ServerCallContext context) throws A2AError { + return delegate.onMessageSendStream(params, context); + } + + @Override + public TaskPushNotificationConfig onCreateTaskPushNotificationConfig( + TaskPushNotificationConfig config, ServerCallContext context) throws A2AError { + return delegate.onCreateTaskPushNotificationConfig(config, context); + } + + @Override + public TaskPushNotificationConfig onGetTaskPushNotificationConfig( + GetTaskPushNotificationConfigParams params, ServerCallContext context) throws A2AError { + return delegate.onGetTaskPushNotificationConfig(params, context); + } + + @Override + public Flow.Publisher onSubscribeToTask( + TaskIdParams params, ServerCallContext context) throws A2AError { + return delegate.onSubscribeToTask(params, context); + } + + @Override + public ListTaskPushNotificationConfigsResult onListTaskPushNotificationConfigs( + ListTaskPushNotificationConfigsParams params, ServerCallContext context) + throws A2AError { + return delegate.onListTaskPushNotificationConfigs(params, context); + } + + @Override + public void onDeleteTaskPushNotificationConfig( + DeleteTaskPushNotificationConfigParams params, ServerCallContext context) + throws A2AError { + delegate.onDeleteTaskPushNotificationConfig(params, context); + } + + @Override + public void authorizeTaskAccess( + String taskId, ServerCallContext context, TaskOperation operation) throws A2AError { + delegate.authorizeTaskAccess(taskId, context, operation); + } +} diff --git a/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java index 05d2524d..8ae00dc1 100644 --- a/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java +++ b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java @@ -19,6 +19,7 @@ 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; @@ -48,15 +49,22 @@ void everyTaskRouteUsesConfiguredA2aPathPrefix() throws Exception { 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", - A2AController.class - .getMethod( - "subscribeToTask", - jakarta.servlet.http.HttpServletRequest.class, - String.class) - .getAnnotation(PostMapping.class) - .value()[0]); + subscribeMethod.getAnnotation(PostMapping.class).value()[0]); + assertEquals(SseEmitter.class, subscribeMethod.getReturnType()); + + var streamMethod = + A2AController.class.getMethod( + "streamMessage", + jakarta.servlet.http.HttpServletRequest.class, + String.class); + assertEquals(SseEmitter.class, streamMethod.getReturnType()); } @Test diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardJacksonModule.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardJacksonModule.java index 6d3338a7..44123816 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardJacksonModule.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardJacksonModule.java @@ -157,9 +157,7 @@ public org.a2aproject.sdk.spec.SecurityRequirement deserialize( JsonNode node = p.getCodec().readTree(p); var schemes = new java.util.LinkedHashMap>(); if (node.has("schemes") && node.get("schemes").isObject()) { - var fields = node.get("schemes").fields(); - while (fields.hasNext()) { - var entry = fields.next(); + for (var entry : node.get("schemes").properties()) { schemes.put(entry.getKey(), java.util.List.of()); } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportResponseTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportResponseTest.java index e693297e..63e08dc4 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 @@ -83,7 +83,7 @@ void fallsBackToDistinctStatusOrMessageTextWhenNoArtifactExists() { .metadata(Map.of()) .build(); TaskStatusUpdateEvent update = - new TaskStatusUpdateEvent("task-1", status, "context-1", false, Map.of()); + new TaskStatusUpdateEvent("task-1", status, "context-1", Map.of()); assertEquals( "need parameter", From 26eeaa2bf1e9f7d97cfef158b13ce1146faa9dc3 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Fri, 28 Aug 2026 17:39:23 +0800 Subject: [PATCH 36/49] feat(engine): expose current A2A-T business inputs Signed-off-by: Zhoujie628 --- .../engine/client/A2ATContentFacade.java | 5 ++ .../client/DefaultA2AJavaClientRuntime.java | 76 ++++++++++++++++++- .../client/DefaultWorkflowEngineClient.java | 7 ++ .../engine/client/ProtocolLogger.java | 2 +- .../workflow/engine/client/TaskTHandler.java | 54 +++++++++++++ .../engine/client/WorkflowEngineClient.java | 11 ++- .../engine/control/DefaultControlPoint.java | 4 +- .../engine/model/NegotiationRequest.java | 20 +++-- .../workflow/engine/model/TaskSubmission.java | 36 +++++++-- .../engine/core/WorkflowExecutorTest.java | 12 +-- .../engine/model/NegotiationRequestTest.java | 12 ++- .../engine/model/TaskSubmissionTest.java | 27 ++++++- .../engine/runner/ExecutePsopTest.java | 4 +- 13 files changed, 241 insertions(+), 29 deletions(-) diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java index fce06eb4..41a51f80 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java @@ -84,6 +84,11 @@ public MetadataContent generateTaskFromData( return client.generateTaskPromptFromDataWithSchema(data, schema, templateUri); } + /** Renders a Task-T prompt from natural language for a caller-selected template. */ + public MetadataContent generateTaskFromText(String text, TemplateUri templateUri) { + return client.generateTaskPromptFromText(text, templateUri); + } + /** * Renders an Authorization-T prompt from structured data and a schema. The SDK's schema-aware * slot extractor may invoke the configured LLM. 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 7556d2a3..8a6b6a11 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 @@ -78,7 +78,8 @@ * for clients created by this runtime. Hostname verification remains enabled and no JVM-wide TLS * property is changed. */ -public class DefaultA2AJavaClientRuntime implements A2AJavaClientRuntime { +public class DefaultA2AJavaClientRuntime + implements A2AJavaClientRuntime, ConversationScopedA2AJavaClientRuntime { private static final Logger log = LoggerFactory.getLogger(DefaultA2AJavaClientRuntime.class); @@ -92,6 +93,8 @@ public class DefaultA2AJavaClientRuntime implements A2AJavaClientRuntime { private final String preferredProtocol; private final java.util.concurrent.ExecutorService httpClientExecutor; private final Map clientCache = new ConcurrentHashMap<>(); + /** Notification-T streams need a client that can be closed without disrupting task traffic. */ + private final Map streamClients = new ConcurrentHashMap<>(); private final AtomicBoolean closed = new AtomicBoolean(); /** @@ -320,7 +323,12 @@ public Iterable sendMessage( Consumer logSink) { if (closed.get()) throw new IllegalStateException("A2A client runtime is closed"); String agentUrl = extractAgentUrl(agentCard); - Client client = getOrCreateClient(agentCard, agentUrl); + Client client = + isNotificationStream(params) + ? getOrCreateStreamClient( + agentCard, + params.message() != null ? params.message().contextId() : null) + : getOrCreateClient(agentCard, agentUrl); List events = Collections.synchronizedList(new ArrayList<>()); CountDownLatch done = new CountDownLatch(1); AtomicReference errorRef = new AtomicReference<>(); @@ -399,6 +407,57 @@ private Client getOrCreateClient(AgentCard agentCard, String agentUrl) { }); } + private Client getOrCreateStreamClient(AgentCard agentCard, String contextId) { + if (contextId == null || contextId.isBlank()) { + throw new IllegalArgumentException("Notification-T contextId must not be blank"); + } + StreamClientKey key = new StreamClientKey(agentCard.name(), contextId); + return streamClients.computeIfAbsent( + key, + ignored -> { + AgentInterface selected = selectInterface(agentCard); + try { + Client client = + buildClientWithTransport( + agentCard, selected.protocolBinding(), createHttpClient()); + log.info( + "[A2ARuntime] Created conversation-scoped Notification-T transport for '{}' (contextId={})", + agentCard.name(), + contextId); + return client; + } catch (A2AClientException e) { + throw new IllegalStateException( + "Failed to create Notification-T client for " + + agentCard.name() + + " context " + + contextId, + e); + } + }); + } + + private static boolean isNotificationStream( + org.a2aproject.sdk.spec.MessageSendParams params) { + return params.message() != null + && params.message().metadata() != null + && params.message().metadata().containsKey(A2ATExtension.NOTIFICATION_T.uri()); + } + + @Override + public void closeConversation(AgentCard agentCard, String contextId) { + if (agentCard == null || contextId == null || contextId.isBlank()) { + return; + } + Client client = streamClients.remove(new StreamClientKey(agentCard.name(), contextId)); + if (client != null) { + client.close(); + log.info( + "[A2ARuntime] Closed conversation-scoped Notification-T transport for '{}' (contextId={})", + agentCard.name(), + contextId); + } + } + /** Select the best AgentInterface based on preferredProtocol or first available. */ private AgentInterface selectInterface(AgentCard agentCard) { List interfaces = agentCard.supportedInterfaces(); @@ -552,10 +611,23 @@ public void close() { } }); clientCache.clear(); + streamClients.values().forEach( + client -> { + try { + client.close(); + } catch (RuntimeException e) { + log.warn( + "[A2ARuntime] Failed to close Notification-T client: {}", + e.getMessage()); + } + }); + streamClients.clear(); httpClientExecutor.shutdownNow(); log.info("[A2ARuntime] Closed cached clients and HTTP executor"); } + private record StreamClientKey(String agentName, String contextId) {} + /** * Create a gRPC channel with SSL settings matching the engine config. * 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 c4af60c8..1dd82ebc 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 @@ -653,9 +653,16 @@ private static NegotiationRequest toNegotiationRequest( context.performative(), negotiationKind(rawTemplate), rawTemplate instanceof String text ? text : "", + negotiationParameters(metadata), metadata); } + @SuppressWarnings("unchecked") + private static Map negotiationParameters(Map metadata) { + Object raw = metadata.get(A2ATExtension.NEGOTIATION_PARAMS_META_KEY); + return raw instanceof Map values ? (Map) values : Map.of(); + } + private static NegotiationRequest.Kind negotiationKind(Object rawTemplate) { String uri = rawTemplate instanceof String text ? text : ""; if (StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri().equals(uri)) { 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 e1fce808..43b54b41 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 @@ -76,7 +76,7 @@ final class ProtocolLogger { new ObjectMapper() .enable(SerializationFeature.INDENT_OUTPUT) .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL) .registerModule( new SimpleModule() .addSerializer( diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java index ed35d520..168f0b32 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java @@ -144,6 +144,17 @@ public CompletableFuture> beforeSend( taskTemplate instanceof String s ? s : null, result)); } + if (taskTemplate instanceof String templateUriStr && !templateUriStr.isBlank()) { + return CompletableFuture.supplyAsync( + () -> + renderFromText( + a2atClient, + agentCard, + taskTUri, + messageText, + templateUriStr, + result)); + } // Free-text track: scenario recognition on the message text. return CompletableFuture.supplyAsync( () -> { @@ -186,6 +197,49 @@ public CompletableFuture> beforeSend( }); } + /** SDK explicit fromText rendering for a caller-selected Task-T template. */ + private Map renderFromText( + A2ATClient a2atClient, + AgentCard agentCard, + String taskTUri, + String text, + String templateUriStr, + Map result) { + try { + net.openan.a2at.sdk.core.model.TemplateUri templateUri = + net.openan.a2at.sdk.core.model.TemplateUri.parse(templateUriStr) + .orElseThrow( + () -> + new IllegalArgumentException( + "Invalid Task-T template URI: " + + templateUriStr)); + if (!net.openan.a2at.sdk.core.model.StandardTemplates.TASK_EXTENSION_NAME.equals( + templateUri.extensionName())) { + throw new IllegalArgumentException( + "Natural-language task template is not Task-T: " + templateUri.uri()); + } + net.openan.a2at.sdk.core.model.MetadataContent content = + a2atClient.generateTaskPromptFromText(text, templateUri); + if (content == null || content.promptText() == null || content.promptText().isEmpty()) { + throw new IllegalStateException("A2A-T SDK returned empty Task-T content"); + } + result.putAll(content.buildMetadataContent()); + log.info( + "[Task-T] Rendered prompt from natural language for '{}': {} chars (template={})", + getAgentName(agentCard), + content.promptText().length(), + content.templateUri()); + return result; + } catch (Exception e) { + throw new IllegalStateException( + "Task-T fromText rendering failed for '" + + getAgentName(agentCard) + + "': " + + e.getMessage(), + e); + } + } + /** SDK schema-aware fromData rendering. Failure is fatal; never send an unrendered task. */ private Map renderFromData( A2ATClient a2atClient, 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 2b7e8829..6c0fd366 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 @@ -59,12 +59,19 @@ public interface WorkflowEngineClient extends TaskDispatcher { @Override default CompletableFuture dispatch(TaskSubmission submission) { java.util.Objects.requireNonNull(submission, "Task submission is required"); - if (submission.input() instanceof TaskSubmission.NaturalLanguage) { + if (submission.input() instanceof TaskSubmission.NaturalLanguage naturalLanguage) { + Map metadata = + new java.util.LinkedHashMap<>(submission.metadata()); + if (naturalLanguage.templateUri() != null) { + metadata.put( + A2ATExtension.TASK_TEMPLATE_META_KEY, + naturalLanguage.templateUri().uri()); + } return sendMessage( submission.agentName(), submission.instruction(), submission.contextId(), - submission.metadata()); + metadata); } TaskSubmission.StructuredData structured = (TaskSubmission.StructuredData) submission.input(); 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 65ef8a39..f4ab4bf2 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 @@ -61,7 +61,9 @@ public CompletableFuture onTask( request.getAgentName(), request.getStepName()); return taskDispatcher - .dispatch(TaskSubmission.fromText(request.getAgentName(), request.getMessage())) + .dispatch( + TaskSubmission.fromUnclassifiedText( + request.getAgentName(), request.getMessage())) .thenApply( r -> { boolean success = isSuccessful(r); 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 bc6118ec..895586bc 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 @@ -36,7 +36,8 @@ * @param maxRounds negotiated round limit * @param performative communicative intent carried by the received context * @param kind semantic negotiation kind inferred from the current SDK template - * @param templateUri received propose template URI, or empty when absent + * @param templateUri received propose template URI + * @param parameters immutable business parameters extracted by the SDK validate-and-fill pipeline * @param metadata immutable received metadata for application-specific inspection */ public record NegotiationRequest( @@ -48,6 +49,7 @@ public record NegotiationRequest( NegotiationPerformative performative, Kind kind, String templateUri, + Map parameters, Map metadata) { /** Negotiation domains supported by the current A2A-T SDK. */ @@ -78,10 +80,16 @@ public enum Kind { if (kind == null) { throw new IllegalArgumentException("Negotiation kind is required"); } - templateUri = templateUri == null ? "" : templateUri; - metadata = - metadata == null - ? Map.of() - : Collections.unmodifiableMap(new LinkedHashMap<>(metadata)); + if (templateUri == null || templateUri.isBlank()) { + throw new IllegalArgumentException("Negotiation templateUri must not be blank"); + } + parameters = immutableCopy(parameters); + metadata = immutableCopy(metadata); + } + + private static Map immutableCopy(Map values) { + return values == null + ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(values)); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskSubmission.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskSubmission.java index e5e14717..4263fc39 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskSubmission.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskSubmission.java @@ -47,9 +47,23 @@ public record TaskSubmission( String contextId, Map metadata) { - /** Creates a natural-language Task-T submission. */ - public static TaskSubmission fromText(String agentName, String text) { - return new TaskSubmission(agentName, text, new NaturalLanguage(), null, Map.of()); + /** + * Creates a natural-language Task-T submission for a known template. This uses the SDK's + * explicit fromText API and bypasses scenario recognition. + */ + public static TaskSubmission fromText( + String agentName, String text, TemplateUri templateUri) { + return new TaskSubmission( + agentName, text, new NaturalLanguage(templateUri), null, Map.of()); + } + + /** + * Creates a natural-language submission whose Task-T template is genuinely unknown. The SDK + * performs scenario recognition before prompt generation, so callers should prefer {@link + * #fromText(String, String, TemplateUri)} whenever the workflow already selected a template. + */ + public static TaskSubmission fromUnclassifiedText(String agentName, String text) { + return new TaskSubmission(agentName, text, new NaturalLanguage(null), null, Map.of()); } /** Creates a structured Task-T submission rendered with a caller-selected Task-T template. */ @@ -85,8 +99,20 @@ public static TaskSubmission fromData( /** Supported business input forms for Task-T generation. */ public sealed interface Input permits NaturalLanguage, StructuredData {} - /** Natural-language input: the instruction itself is passed to SDK scenario recognition. */ - public record NaturalLanguage() implements Input {} + /** + * Natural-language input. A non-null template selects the explicit SDK fromText pipeline; + * null deliberately selects scenario recognition. + */ + public record NaturalLanguage(TemplateUri templateUri) implements Input { + /** Validates that an explicitly selected template belongs to Task-T. */ + public NaturalLanguage { + if (templateUri != null + && !StandardTemplates.TASK_EXTENSION_NAME.equals(templateUri.extensionName())) { + throw new IllegalArgumentException( + "Natural-language task template is not Task-T: " + templateUri.uri()); + } + } + } /** Structured input: data and schema are rendered by the SDK's schema-aware fromData API. */ public record StructuredData( 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 0cdef416..26320670 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 @@ -71,7 +71,7 @@ private ControlPoint autoCp() { public CompletableFuture onTask( TaskRequest request, TaskDispatcher taskDispatcher) { return taskDispatcher - .dispatch(dev.openan.workflow.engine.model.TaskSubmission.fromText( + .dispatch(dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( request.getAgentName(), request.getMessage())) .thenApply( r -> @@ -302,7 +302,7 @@ void conditionalRouteOnRouteCalled() { public CompletableFuture onTask( TaskRequest req, TaskDispatcher dispatcher) { return dispatcher.dispatch( - dev.openan.workflow.engine.model.TaskSubmission.fromText( + dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( req.getAgentName(), req.getMessage())) .thenApply( r -> @@ -353,7 +353,7 @@ void onRouteInvalidStepFailsWorkflow() { public CompletableFuture onTask( TaskRequest req, TaskDispatcher dispatcher) { return dispatcher.dispatch( - dev.openan.workflow.engine.model.TaskSubmission.fromText( + dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( req.getAgentName(), req.getMessage())) .thenApply( r -> @@ -414,7 +414,7 @@ public CompletableFuture onTask( .build()); } return dispatcher.dispatch( - dev.openan.workflow.engine.model.TaskSubmission.fromText( + dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( req.getAgentName(), req.getMessage())) .thenApply( r -> @@ -693,7 +693,7 @@ void mergeDoesNotWaitForUnselectedConditionalBranch() { public CompletableFuture onTask( TaskRequest request, TaskDispatcher taskDispatcher) { return taskDispatcher.dispatch( - dev.openan.workflow.engine.model.TaskSubmission.fromText( + dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( request.getAgentName(), request.getMessage())) .thenApply( response -> @@ -761,7 +761,7 @@ void selfLoopStepCallsOnSelfTaskNotOnTask() { public CompletableFuture onTask( TaskRequest req, TaskDispatcher dispatcher) { return dispatcher.dispatch( - dev.openan.workflow.engine.model.TaskSubmission.fromText( + dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( req.getAgentName(), req.getMessage())) .thenApply( r -> 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 0a812952..af5b8091 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 @@ -32,9 +32,11 @@ class NegotiationRequestTest { @Test - void retainsTypedPerformativeAndDefensivelyCopiesMetadata() { + void retainsTypedPerformativeAndDefensivelyCopiesBusinessData() { Map metadata = new LinkedHashMap<>(); metadata.put("trace", "one"); + Map parameters = new LinkedHashMap<>(); + parameters.put("接入端口名称", "举例:P533"); var request = new NegotiationRequest( "SPN", @@ -45,10 +47,16 @@ void retainsTypedPerformativeAndDefensivelyCopiesMetadata() { NegotiationPerformative.PROPOSE, NegotiationRequest.Kind.INFORMATION, "urn:template", + parameters, metadata); metadata.put("trace", "two"); + parameters.put("接入端口名称", "changed"); assertEquals(NegotiationPerformative.PROPOSE, request.performative()); + assertEquals("举例:P533", request.parameters().get("接入端口名称")); + assertThrows( + UnsupportedOperationException.class, + () -> request.parameters().put("x", "y")); assertEquals("one", request.metadata().get("trace")); assertThrows(UnsupportedOperationException.class, () -> request.metadata().put("x", "y")); } @@ -76,6 +84,7 @@ void rejectsInvalidSessionAndRounds() { NegotiationPerformative.ACCEPT, NegotiationRequest.Kind.INFORMATION, "urn:template", + Map.of(), Map.of())); } @@ -89,6 +98,7 @@ private static NegotiationRequest request(String sessionId, int round, int maxRo NegotiationPerformative.PROPOSE, NegotiationRequest.Kind.INFORMATION, "urn:template", + Map.of(), Map.of()); } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/model/TaskSubmissionTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/TaskSubmissionTest.java index 2792cc83..8fecd59b 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/model/TaskSubmissionTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/TaskSubmissionTest.java @@ -20,16 +20,33 @@ class TaskSubmissionTest { @Test - void naturalLanguageSubmissionUsesPlainSendPath() { + void unclassifiedNaturalLanguageSubmissionUsesScenarioRecognitionPath() { StubWorkflowEngineClient client = new StubWorkflowEngineClient("agent"); - client.dispatch(TaskSubmission.fromText("agent", "diagnose link")).join(); + client.dispatch(TaskSubmission.fromUnclassifiedText("agent", "diagnose link")).join(); var sent = client.getSentMessages().get(0); assertEquals("diagnose link", sent.message); assertEquals(Map.of(), sent.metadata); } + @Test + void templatedNaturalLanguageSubmissionSelectsExplicitFromTextPath() { + StubWorkflowEngineClient client = new StubWorkflowEngineClient("agent"); + + client.dispatch( + TaskSubmission.fromText( + "agent", + "diagnose link", + StandardTemplates.PRIVATE_LINE_COMPLAINT)) + .join(); + + var sent = client.getSentMessages().get(0); + assertEquals( + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri(), + sent.metadata.get(A2ATExtension.TASK_TEMPLATE_META_KEY)); + } + @Test void structuredSubmissionAddsOnlyEngineOwnedTaskInputMetadata() { StubWorkflowEngineClient client = new StubWorkflowEngineClient("agent"); @@ -53,7 +70,11 @@ void structuredSubmissionAddsOnlyEngineOwnedTaskInputMetadata() { @Test void invalidSubmissionFailsAtBusinessBoundary() { - assertThrows(IllegalArgumentException.class, () -> TaskSubmission.fromText("", "task")); + assertThrows( + IllegalArgumentException.class, + () -> + TaskSubmission.fromText( + "", "task", StandardTemplates.PRIVATE_LINE_COMPLAINT)); assertThrows( IllegalArgumentException.class, () -> 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 a0873267..513096f5 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 @@ -53,7 +53,7 @@ private ControlPoint autoCp() { public CompletableFuture onTask( TaskRequest request, TaskDispatcher taskDispatcher) { return taskDispatcher - .dispatch(dev.openan.workflow.engine.model.TaskSubmission.fromText( + .dispatch(dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( request.getAgentName(), request.getMessage())) .thenApply( r -> @@ -344,7 +344,7 @@ public CompletableFuture onTask( TaskResponse.builder().success(false).error("A broke").build()); } return taskDispatcher - .dispatch(dev.openan.workflow.engine.model.TaskSubmission.fromText( + .dispatch(dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( request.getAgentName(), request.getMessage())) .thenApply( r -> From 9aafcee5fa3888a26d8bb398ab09a6265da433f6 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Fri, 28 Aug 2026 21:41:23 +0800 Subject: [PATCH 37/49] fix(samples): align SPN protocol business lifecycle Signed-off-by: Zhoujie628 --- .env.example | 2 +- .../agents/NegotiationBaseAgentExecutor.java | 99 +++++++++--- .../agents/SpnDomainAgentCity1Executor.java | 100 +++++++----- .../agents/SpnDomainAgentCity2Executor.java | 25 ++- .../examples/demo/A2ATProtocolCases.java | 150 +++++++++++++++--- .../engine/examples/demo/SpnCasePrompts.java | 9 ++ .../PrePositionedExtensionHandler.java | 138 +++++++++++++--- .../engine/examples/util/LlmHelper.java | 57 ++++++- .../workbench/WorkbenchControlPoint.java | 4 +- .../engine/examples/SpnCrossCityE2ETest.java | 44 +++-- .../PrePositionedExtensionHandlerTest.java | 39 ++++- 11 files changed, 528 insertions(+), 139 deletions(-) diff --git a/.env.example b/.env.example index cb39f118..30edd071 100644 --- a/.env.example +++ b/.env.example @@ -41,7 +41,7 @@ A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_API_KEY=your-api-key-here A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_MAX_TOKENS=2000 +A2AT_LLM_MAX_TOKENS=4000 A2AT_LLM_TEMPERATURE=0 A2AT_LLM_TIMEOUT_SECONDS=60 A2AT_LLM_HISTORY_WINDOW=10 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 a099404d..fae1eed4 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 @@ -73,7 +73,9 @@ public abstract class NegotiationBaseAgentExecutor extends BaseAgentExecutor { // 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 BlockingQueue notificationQueue = new LinkedBlockingQueue<>(); + private final java.util.concurrent.ConcurrentMap pendingNegotiationTasks = + new java.util.concurrent.ConcurrentHashMap<>(); private volatile A2ATClient a2atClient; protected NegotiationBaseAgentExecutor() { @@ -94,24 +96,45 @@ protected final NotificationPolicy getNotificationSubscription() { return prePositionedHandler.getNotificationSubscription(); } - protected void reportRecoveryResult(AgentEmitter emitter, String result) { - String notifUri = A2ATExtension.NOTIFICATION_T.uri(); - Map notifMeta = new LinkedHashMap<>(); - notifMeta.put(notifUri, result); - List> rParts = List.of(new TextPart(result)); - emitter.addArtifact(rParts, "recovery-result", "recovery-result", notifMeta, false, true); - log.info("[{}] RECOVERY_REPORTED resultChars={}", getClass().getSimpleName(), result.length()); - } - - protected void pushNotificationResult(String result) { + /** + * Enqueues one business-recovery notification for the independent Notification-T stream. + * The artifact part remains the protocol-defined event summary; the full structured prompt is + * carried under the Notification-T metadata URI. + */ + protected void pushRecoveryNotification(String artifactName, String content) { + if (artifactName == null || artifactName.isBlank()) { + throw new IllegalArgumentException("Notification artifact name must not be blank"); + } + if (content == null || content.isBlank()) { + throw new IllegalArgumentException("Notification content must not be blank"); + } if (getNotificationSubscription() == null) { log.warn( - "[{}] Dropping recovery result because no validated Notification-T subscription is active", - getClass().getSimpleName()); + "[{}] Dropping {} because no validated Notification-T subscription is active", + getClass().getSimpleName(), + artifactName); return; } - if (!notificationQueue.offer(result)) { - log.warn("[{}] Notification queue rejected a result", getClass().getSimpleName()); + 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"); } } @@ -268,20 +291,27 @@ private void handleNotificationSubscription(RequestContext ctx, AgentEmitter emi taskId, contextId); List> ackParts = List.of(new TextPart("订阅成功,启动业务抢通事件上报任务")); + Map subscriptionMetadata = + Map.of(notifUri, "## 订阅结果\n订阅结果:成功"); emitter.addArtifact( - ackParts, "subscription", agentTag + " subscription", Map.of(), false, true); + ackParts, + "subscription", + agentTag + " subscription", + subscriptionMetadata, + false, + true); emitStatus( emitter, TaskState.TASK_STATE_WORKING, contextId, taskId, "订阅成功,启动业务抢通事件上报任务", - Map.of()); + subscriptionMetadata); while (!Thread.currentThread().isInterrupted()) { long heartbeatSeconds = Math.max( 1L, Long.getLong("a2at.notification.heartbeat.seconds", 30L)); - String result = notificationQueue.poll(heartbeatSeconds, TimeUnit.SECONDS); - if (result == null) { + NotificationEvent event = notificationQueue.poll(heartbeatSeconds, TimeUnit.SECONDS); + if (event == null) { emitStatus( emitter, TaskState.TASK_STATE_WORKING, @@ -294,16 +324,22 @@ private void handleNotificationSubscription(RequestContext ctx, AgentEmitter emi continue; } log.info( - "[{}] NOTIFICATION_PUSH taskId={}, contextId={}, resultChars={}", + "[{}] NOTIFICATION_PUSH taskId={}, contextId={}, artifactName={}, contentChars={}", agentTag, taskId, contextId, - result.length()); + event.artifactName(), + event.content().length()); Map notifMeta = new LinkedHashMap<>(); - notifMeta.put(notifUri, result); - List> resultParts = List.of(new TextPart(result)); + notifMeta.put(notifUri, event.content()); + List> resultParts = List.of(new TextPart("业务抢通事件")); emitter.addArtifact( - resultParts, "recovery-result", "recovery-result", notifMeta, false, true); + resultParts, + event.artifactName(), + event.artifactName(), + notifMeta, + false, + true); } } @@ -363,8 +399,16 @@ cleanInput, negotiationCtx, buildNegotiationParamSchema(), return; } } - log.info("[{}] Follow-up received, re-executing business", getClass().getSimpleName()); - runBusinessAndComplete(ctx, emitter, cleanInput); + String originalInput = pendingNegotiationTasks.remove(ctx.getTaskId()); + String businessInput = + originalInput == null + ? cleanInput + : originalInput + "\n\n## Negotiation-T 补充信息\n" + cleanInput; + log.info( + "[{}] Follow-up received, re-executing business with originalTask={}", + getClass().getSimpleName(), + originalInput != null); + runBusinessAndComplete(ctx, emitter, businessInput); } /** @@ -383,6 +427,9 @@ cleanInput, negotiationCtx, buildNegotiationParamSchema(), private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, String input) { String taskId = ctx.getTaskId(); String contextId = ctx.getContextId(); + if (taskId != null && input != null) { + pendingNegotiationTasks.put(taskId, input); + } net.openan.a2at.sdk.core.model.NegotiationContext negotiationContext = new net.openan.a2at.sdk.core.model.NegotiationContext( java.util.UUID.randomUUID().toString(), 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 a069f583..83871400 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 @@ -39,10 +39,10 @@ * *

        Server-side negotiation-capable (extends {@link NegotiationBaseAgentExecutor}): on a new task * it replies INPUT_REQUIRED to start a Negotiation-T round, and on the follow-up it runs the - * diagnosis/recovery business. City1 side has a FAULT (port Down, optical power -28dBm). - * Diagnosis/recovery text is LLM-generated (deepseek) when the A2A-T .env is configured, else - * deterministic. Authorization-T and Notification-T are initiated on independent channels, - * so this agent no longer injects them in response metadata. + * diagnosis business. City1 side has a FAULT (port Down, optical power -28dBm). Diagnosis text is + * LLM-generated when the A2A-T .env is configured, otherwise deterministic. The recovery event + * payload is formatted deterministically so required protocol fields and identifiers cannot be + * hallucinated. Authorization-T and Notification-T use independent channels. */ public class SpnDomainAgentCity1Executor extends NegotiationBaseAgentExecutor { private static final Logger log = LoggerFactory.getLogger(SpnDomainAgentCity1Executor.class); @@ -54,18 +54,10 @@ public class SpnDomainAgentCity1Executor extends NegotiationBaseAgentExecutor { + "判断原因为对端设备掉电或端口关闭\n" + "3. 修复建议:恢复供电后重新启动网元,或者重新开启端口"; - // Recovery (business-preemption) result reported via Notification-T, mirroring spec case 7.9. - private static final String RECOVERY_RESULT = - "### 业务抢通事件\n" - + "1. 业务抢通方案执行状态:已结束\n" - + "2. 投诉诊断任务流水号:9de168c0-6179-4778-8b72-4279582c0a3f\n" - + "3. OSS侧事件流水号:event-id-202606250128\n" - + "4. 接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17\n" - + "5. 是否已授权OMC自动抢通:是\n" - + "6. 业务抢通方案名称:隧道调优\n" - + "7. 业务抢通方案详情:将受影响的隧道列表调优到新的路径列表,业务恢复。\n" - + "8. 业务抢通方案执行结束时间:2026-05-11T08:31:46Z\n" - + "9. 业务抢通方案执行结果:成功"; + private static final String OSS_EVENT_ID = "event-id-20260511-09013"; + private static final String ACCESS_PORT = "P781-珠江新城-PTN7900-23-TPA1EG24-17"; + private static final String RECOVERY_DETAIL = + "将时延越限和丢包越限的受影响隧道调优到新的可用路径,保留原路径用于回退。"; public SpnDomainAgentCity1Executor() { super(); @@ -83,14 +75,6 @@ private static String llmDiagnosisResult(String input, String fallback) { return LlmHelper.text(env, sys, user, fallback); } - private static String llmRecoveryResult(String input, String fallback) { - String env = EnvResolver.resolveEnvPath(); - String sys = "你是SPN领域OMC业务抢通执行专家。按业务抢通事件数据格式输出结果," - + "包含执行状态、方案名称、详情、执行结果。中文。"; - String user = "输入:\n" + input + "\n\n已执行隧道调优,业务恢复。请输出业务抢通结果。"; - return LlmHelper.text(env, sys, user, fallback); - } - @Override protected String resolveEnvPath() { return EnvResolver.resolveEnvPath(); @@ -99,16 +83,17 @@ protected String resolveEnvPath() { @Override protected String executeBusiness(RequestContext ctx, AgentEmitter emitter, String input) { String diagnosisResult = llmDiagnosisResult(input, FAULT_DIAGNOSIS_RESULT); - String recoveryResult = selfTriggerRecovery(ctx, diagnosisResult); - return diagnosisResult + "\n\n" + recoveryResult; + publishRecoveryLifecycle(ctx); + // Task-T returns diagnosis only. Recovery plan/result belong exclusively to the + // independently established Notification-T subscription. + return diagnosisResult; } /** - * After diagnosis, check the active Authorization-T whitelist policy. If the repair - * action matches the whitelist, execute recovery and return the result (reported as - * Notification-T metadata by the base class). If not in whitelist, return a refusal message. + * After diagnosis, publish the recovery plan. If it matches the active Authorization-T + * whitelist, execute it automatically and publish the final result as a second event. */ - private String selfTriggerRecovery(RequestContext ctx, String diagnosisResult) { + private void publishRecoveryLifecycle(RequestContext ctx) { AuthorizationPolicy policy = getAuthorizationPolicy(); boolean inWhitelist = policy != null @@ -117,19 +102,60 @@ private String selfTriggerRecovery(RequestContext ctx, String diagnosisResult) { "业务抢通", "隧道调优", LocalDate.now(ZoneId.of("Asia/Shanghai"))); + String taskId = ctx.getTaskId() != null ? ctx.getTaskId() : "unknown-task"; + pushRecoveryPlan(formatRecoveryEvent("未启动", taskId, inWhitelist, null, null)); if (inWhitelist) { log.info("[SPN-Domain-Agent] Fault in whitelist, self-triggering recovery"); - String recoveryResult = llmRecoveryResult(diagnosisResult, RECOVERY_RESULT); + String recoveryResult = + formatRecoveryEvent( + "已结束", + taskId, + true, + "成功", + java.time.Instant.now().toString()); log.info( "[SPN-Domain-Agent] Recovery result reported via Notification-T: {}", recoveryResult); - pushNotificationResult(recoveryResult); - return recoveryResult; + pushRecoveryResult(recoveryResult); + return; + } + log.info( + "[SPN-Domain-Agent] Recovery plan reported but automatic execution was not started because it is outside the active whitelist"); + } + + private static String formatRecoveryEvent( + String state, + String taskId, + boolean authorized, + String executionResult, + String finishedAt) { + StringBuilder event = + new StringBuilder("### 业务抢通事件\n") + .append("1. 业务抢通方案执行状态:").append(state).append('\n') + .append("2. 投诉诊断任务流水号:").append(taskId).append('\n') + .append("3. OSS侧事件流水号:").append(OSS_EVENT_ID).append('\n') + .append("4. 接入端口名称:").append(ACCESS_PORT).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 boolean needsNegotiation(String input) { + // Case 7.4 represents an inventory-level semantic error that a generic JSON schema cannot + // express: this OMC knows port ...-18 is not a valid complaint access port. + if (input != null && input.contains("P781-珠江新城-PTN7900-23-TPA1EG24-18")) { + log.info("[SPN-Domain-Agent] Unknown access port; Negotiation-T correction required"); + return true; } - log.info("[SPN-Domain-Agent] Fault not in whitelist, refusing recovery"); - String refusalResult = "## 授权操作执行结果\n 授权操作执行结果:失败\n\n## 失败原因\n操作不在授权白名单内,拒绝执行业务抢通。"; - pushNotificationResult(refusalResult); - return refusalResult; + return super.needsNegotiation(input); } @Override diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java index 1cf0e720..c9a7a651 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 @@ -35,8 +35,8 @@ * SPN Domain Agent for City2 (City2 OMC). * *

        Server-side negotiation-capable (extends {@link NegotiationBaseAgentExecutor}). City2 side is - * NORMAL. Diagnosis/recovery text is LLM-generated when the A2A-T .env is configured, else - * deterministic. Authorization-T and Notification-T are initiated on independent channels. + * NORMAL. Diagnosis text is LLM-generated when the A2A-T .env is configured, else deterministic. + * Authorization-T and Notification-T are initiated on independent channels. */ public class SpnDomainAgentCity2Executor extends NegotiationBaseAgentExecutor { private static final Logger log = LoggerFactory.getLogger(SpnDomainAgentCity2Executor.class); @@ -57,10 +57,23 @@ public SpnDomainAgentCity2Executor(PrePositionedExtensionHandler extensionHandle private static String llmDiagnosisResult(String input, String fallback) { String env = EnvResolver.resolveEnvPath(); - String sys = "你是SPN领域OMC故障诊断专家。按如下结构输出:1. 诊断结果(成功/失败);" - + "2. 诊断结果详情;3. 修复建议。城市2侧端口正常、无故障。简洁专业,中文。"; - String user = "输入:\n" + input + "\n\n城市2OMC端口port-3=UP,光功率-17dBm,无告警。请输出诊断结论。"; - return LlmHelper.text(env, sys, user, fallback); + 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 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 27738dd3..d590bcc4 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 @@ -21,8 +21,11 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; /** @@ -47,8 +50,12 @@ public class A2ATProtocolCases { private DefaultExtensionSender notificationSender; private final AtomicReference lastTaskId = new AtomicReference<>(); private final AtomicReference notificationTaskId = new AtomicReference<>(); + private final AtomicBoolean recoveryPlanReceived = new AtomicBoolean(); private final AtomicBoolean recoveryNotificationReceived = new AtomicBoolean(); + private final AtomicInteger negotiationRequests = new AtomicInteger(); + private final CountDownLatch reconnectEvent = new CountDownLatch(1); private NotificationSubscription notificationSubscription; + private CompletableFuture reconnectedSubscription; public static void main(String[] args) throws Exception { String caseId = "all"; @@ -65,7 +72,7 @@ public void run(String caseId) throws Exception { 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_10(); case_7_11(); + case_7_9(); case_7_11(); case_7_10(); } else { switch (caseId) { case "0" -> case_0(); @@ -109,6 +116,20 @@ private void setup() throws Exception { authorizationTransport = new A2ATransport(cards, null, config); notificationTransport = new A2ATransport(cards, null, config); client = new DefaultWorkflowEngineClient(taskTransport); + 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); @@ -166,25 +187,49 @@ private void case_7_2() throws Exception { } private void case_7_3() { - log.info("[Case 7.3] Negotiation - param missing (Task-T fromData + Negotiation-T)"); + log.info("[Case 7.3] Negotiation - missing required parameter"); + int before = negotiationRequests.get(); Map metadata = new LinkedHashMap<>(); - metadata.put(A2ATExtension.TASK_DATA_META_KEY, SpnCasePrompts.privateLineComplaintDataBlankObject()); - metadata.put(A2ATExtension.TASK_SCHEMA_META_KEY, SpnCasePrompts.privateLineComplaintSchema()); + metadata.put( + A2ATExtension.TASK_T.uri(), + SpnCasePrompts.privateLineComplaintPromptBlankObject()); metadata.put(NEGOTIATION_T_URI, ""); // activate Negotiation-T extension SendMessageResult result = client.sendMessage(AGENT_NAME, SpnCasePrompts.TASK_TEXT + "(参数缺失)", null, metadata).join(); + requireCompletedNegotiation("7.3", result, before); log.info("[Case 7.3] state={}, metaKeys={}", result.getTaskState(), result.getMetadata() != null ? result.getMetadata().keySet() : "none"); } private void case_7_4() { log.info("[Case 7.4] Negotiation - semantic error (Task-T fromData + Negotiation-T)"); + int before = negotiationRequests.get(); Map metadata = new LinkedHashMap<>(); metadata.put(A2ATExtension.TASK_DATA_META_KEY, SpnCasePrompts.privateLineComplaintDataUnknownPort()); metadata.put(A2ATExtension.TASK_SCHEMA_META_KEY, SpnCasePrompts.privateLineComplaintSchema()); + metadata.put( + A2ATExtension.TASK_TEMPLATE_META_KEY, + net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); metadata.put(NEGOTIATION_T_URI, ""); // activate Negotiation-T extension SendMessageResult result = client.sendMessage(AGENT_NAME, SpnCasePrompts.TASK_TEXT + "(语义错误)", null, metadata).join(); + requireCompletedNegotiation("7.4", result, before); log.info("[Case 7.4] state={}, metaKeys={}", result.getTaskState(), result.getMetadata() != null ? result.getMetadata().keySet() : "none"); } + private void requireCompletedNegotiation( + String caseId, SendMessageResult result, int requestCountBefore) { + if (result == null + || result.getTaskState() == null + || !result.getTaskState().endsWith("COMPLETED") + || negotiationRequests.get() <= requestCountBefore) { + throw new IllegalStateException( + "Case " + + caseId + + " did not complete a Negotiation-T round: state=" + + (result != null ? result.getTaskState() : "null") + + ", negotiationRequests=" + + negotiationRequests.get()); + } + } + private void case_7_5() { log.info("[Case 7.5] Add authorization (Authorization-T)"); SendMessageResult result = @@ -197,6 +242,11 @@ private void case_7_5() { .AUTHORIZATION_POLICY_MANAGEMENT, A2ATExtension.AUTHORIZATION_T) .join(); + requireExtensionResponse( + result, + A2ATExtension.AUTHORIZATION_T.uri(), + "授权操作执行结果:成功", + "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"); log.info("[Case 7.5] state={}", result.getTaskState()); } @@ -216,6 +266,10 @@ private void case_7_6() { .AUTHORIZATION_POLICY_MANAGEMENT, A2ATExtension.AUTHORIZATION_T) .join(); + requireExtensionResponse( + result, + A2ATExtension.AUTHORIZATION_T.uri(), + "授权操作执行结果:成功"); log.info("[Case 7.6] state={}", result.getTaskState()); } @@ -235,11 +289,17 @@ private void case_7_7() { .AUTHORIZATION_POLICY_MANAGEMENT, A2ATExtension.AUTHORIZATION_T) .join(); + requireExtensionResponse( + result, + A2ATExtension.AUTHORIZATION_T.uri(), + "授权操作执行结果:成功", + "无匹配授权策略"); log.info("[Case 7.7] state={}", result.getTaskState()); } private void case_7_8() { log.info("[Case 7.8] Subscribe notification (Notification-T)"); + recoveryPlanReceived.set(false); recoveryNotificationReceived.set(false); notificationSubscription = notificationSender.openNotificationFromData( @@ -250,36 +310,69 @@ private void case_7_8() { net.openan.a2at.sdk.core.model.StandardTemplates.SERVICE_RECOVERY, data -> { log.info("[Case 7.8] callback: keys={}", data.keySet()); + if ("recovery-plan".equals(data.get("artifact_name"))) { + recoveryPlanReceived.set(true); + } if ("recovery-result".equals(data.get("artifact_name"))) { recoveryNotificationReceived.set(true); } }) .join(); SendMessageResult result = notificationSubscription.acknowledgement().join(); + requireExtensionResponse( + result, + A2ATExtension.NOTIFICATION_T.uri(), + "订阅结果:成功"); if (result.getTask() != null) { notificationTaskId.set(result.getTask().id()); } log.info("[Case 7.8] ack state={}", result.getTaskState()); } + private static void requireExtensionResponse( + SendMessageResult result, String extensionUri, String... expectedFragments) { + Map metadata = result != null ? result.getMetadata() : null; + Object value = metadata != null ? metadata.get(extensionUri) : null; + String response = value instanceof String text ? text : ""; + for (String fragment : expectedFragments) { + if (!response.contains(fragment)) { + throw new IllegalStateException( + "Protocol response metadata for " + + extensionUri + + " is missing '" + + fragment + + "': " + + response); + } + } + } + private void case_7_9() throws Exception { log.info("[Case 7.9] Notification report (Notification-T)"); if (notificationTaskId.get() == null) { log.info("[Case 7.9] No notification task, running 7.8 first"); case_7_8(); } - log.info("[Case 7.9] Waiting 5s for pushed events..."); - TimeUnit.SECONDS.sleep(5); - if (recoveryNotificationReceived.get() && notificationSubscription != null) { - notificationSubscription.close(); - notificationSubscription = null; - log.info("[Case 7.9] Recovery result received; Notification-T stream closed"); - } else { - log.info("[Case 7.9] No recovery result yet; Notification-T stream remains active"); + // 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() { + 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(); @@ -287,6 +380,16 @@ private void case_7_10() { 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; @@ -294,7 +397,7 @@ private void case_7_10() { log.info("[Case 7.10] state={}", result.getTaskState()); } - private void case_7_11() { + 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"); @@ -306,15 +409,22 @@ private void case_7_11() { notificationSubscription = null; } log.info("[Case 7.11] Reconnect: {}", taskId); - SendMessageResult result = + reconnectedSubscription = notificationControlClient .subscribeToTask( AGENT_NAME, taskId, - data -> - log.info( - "[Case 7.11] callback: keys={}", data.keySet())) - .join(); - log.info("[Case 7.11] state={}", result.getTaskState()); + data -> { + reconnectEvent.countDown(); + log.info("[Case 7.11] callback: keys={}", data.keySet()); + }); + if (!reconnectEvent.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Case 7.11 reconnect produced no protocol event"); + } + if (reconnectedSubscription.isDone()) { + throw new IllegalStateException( + "Case 7.11 reconnect stream ended before explicit cancellation"); + } + log.info("[Case 7.11] reconnected stream is active"); } } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpnCasePrompts.java b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpnCasePrompts.java index 8adb8205..c9a5b033 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 @@ -95,6 +95,15 @@ public static Map privateLineComplaintDataBlankObject() { 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<>(); 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 21679da1..3a394d58 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 @@ -34,6 +34,7 @@ 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; @@ -55,7 +56,7 @@ * *

          *
        1. Store the payload for later use during business execution - *
        2. Acknowledge receipt with a short artifact + *
        3. Return the protocol-defined structured response artifact under the same extension URI *
        4. Complete the task immediately (no negotiation, no business logic) *
        * @@ -67,6 +68,9 @@ public class PrePositionedExtensionHandler { 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"; @@ -186,10 +190,11 @@ public void handle( 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); - applyAuthorization(filled.data(), templateUri, agentTag); + operationResult = applyAuthorization(filled.data(), templateUri, agentTag); } catch (Exception validationError) { log.warn( "[{}] Authorization-T rejected; existing policy unchanged: {}", @@ -201,6 +206,10 @@ public void handle( } 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: {}", @@ -218,62 +227,129 @@ public void handle( agentTag, extKeyword, payloadText.length()); - String ackText = extKeyword + " operation acknowledged"; - List> parts = List.of(new TextPart(ackText)); - emitter.addArtifact(parts, "result", agentTag + " ack", Map.of(), false, true); + 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, - extKeyword + " operation applied successfully", - Map.of()); + operationResult.text(), + responseMetadata); emitter.complete(BaseAgentExecutor.buildStatusMessage(contextId, taskId, "Completed")); log.info("[{}] {} operation completed", agentTag, extKeyword); } - synchronized void applyAuthorization( + 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 -> { - // This sample models one active whitelist per OMC agent. A production callback - // should use the validated selector/list to delete matching persisted policies. - AuthorizationPolicy.requirePolicyList(validatedData); + String selector = AuthorizationPolicy.requirePolicyList(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 -> { + log.info( + "[{}] Authorization-T accepted, templateUri={}, operation={}, activeRules={}", + agentTag, + templateUri, + operation, + authorizationPolicy == null ? 0 : authorizationPolicy.rules().size()); + return authorizationResult( + "符合查询条件的动网操作的授权策略列表", authorizationPolicy); } - case AuthorizationPolicy.QUERY -> - // Query is side-effect free. The current sample returns the normal protocol ACK; - // an integration callback can render its persisted policies in the artifact. - log.info( - "[{}] Authorization-T accepted, templateUri={}, operation={}, activeRules={}", - agentTag, - templateUri, - operation, - authorizationPolicy == null ? 0 : authorizationPolicy.rules().size()); default -> throw new IllegalArgumentException( "Unsupported Authorization-T operation: " + operation); } } + private AuthorizationOperationResult authorizationResult( + String text, AuthorizationPolicy policy) { + StringBuilder metadata = + new StringBuilder("## 授权操作执行结果\n授权操作执行结果:成功"); + metadata.append("\n\n## 动网操作的授权策略列表\n"); + if (policy == null) { + metadata.append("(无匹配授权策略)"); + } else { + int index = 1; + for (AuthorizationPolicy.Rule rule : policy.rules()) { + metadata.append("- 动网操作的授权策略").append(index++).append('\n'); + metadata.append(" - 动网操作的授权策略标识:") + .append(authorizationPolicyId) + .append('\n'); + metadata.append(" - 动网操作的业务场景:") + .append(rule.businessScenario()) + .append('\n'); + metadata.append(" - 动网操作的处置类型:") + .append(rule.disposalType()) + .append('\n'); + metadata.append(" - 动网操作名称:").append(rule.actionName()).append('\n'); + metadata.append(" - 有效期:") + .append(formatValidity(rule)) + .append('\n'); + metadata.append(" - 创建时间:") + .append(authorizationCreatedAt) + .append('\n'); + metadata.append(" - 最后修改时间:") + .append(authorizationModifiedAt); + } + } + return new AuthorizationOperationResult(text, metadata.toString()); + } + + private static String formatValidity(AuthorizationPolicy.Rule rule) { + if (java.time.LocalDate.MIN.equals(rule.validFrom()) + && java.time.LocalDate.MAX.equals(rule.validUntil())) { + return "永久生效"; + } + return rule.validFrom() + " ~ " + rule.validUntil(); + } + + record AuthorizationOperationResult(String text, String metadataText) { + AuthorizationOperationResult { + Objects.requireNonNull(text, "Authorization response text"); + Objects.requireNonNull(metadataText, "Authorization response metadata"); + } + } + private FilledParamData validateAuthorization(String prompt, Object receivedTemplateUri) { FilledParamData filled; if (authorizationValidator != null) { @@ -290,10 +366,17 @@ private FilledParamData validateAuthorization(String prompt, Object receivedTemp if (server == null) { throw new IllegalStateException("A2A-T server validator is unavailable"); } - filled = server.validateAuthPromptAndDataFilling( - prompt, - validationSchema(StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT), - StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + 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, @@ -303,6 +386,13 @@ private FilledParamData validateAuthorization(String prompt, Object receivedTemp return filled; } + private FilledParamData validateAuthorizationWithSdk(A2ATServer server, String prompt) { + return server.validateAuthPromptAndDataFilling( + prompt, + validationSchema(StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT), + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + } + /** Validates and stores a Notification-T subscription before the server opens its SSE loop. */ public NotificationPolicy acceptNotification( String prompt, Object receivedTemplateUri, String agentTag) { 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 bd9437a3..a21ae860 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 @@ -55,27 +55,62 @@ 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] HTTP {}: {}", response.statusCode, response.body); + log.warn( + "[LlmHelper] LLM_FALLBACK reason=http_status, status={}, elapsedMs={}", + response.statusCode, + elapsedMillis(started)); return fallback; } - String content = extractContent(response.body); + ModelReply reply = extractReply(response.body); + String content = reply != null ? reply.content() : null; if (content == null || content.isBlank()) { - log.warn("[LlmHelper] empty model reply, using fallback"); + log.warn( + "[LlmHelper] LLM_FALLBACK reason=empty_reply, elapsedMs={}", + elapsedMillis(started)); return fallback; } + if (reply.finishReason() != null && !"stop".equalsIgnoreCase(reply.finishReason())) { + log.warn( + "[LlmHelper] LLM_FALLBACK reason=incomplete_reply, finishReason={}, outputChars={}, elapsedMs={}", + reply.finishReason(), + content.length(), + elapsedMillis(started)); + return fallback; + } + log.info( + "[LlmHelper] LLM_CALL_DONE model={}, finishReason={}, outputChars={}, elapsedMs={}", + config.model, + reply.finishReason(), + content.length(), + elapsedMillis(started)); return content; } catch (Exception e) { - log.warn("[LlmHelper] LLM call failed, using fallback: {}", e.getMessage()); + log.warn( + "[LlmHelper] LLM_FALLBACK reason=call_failed, errorType={}, elapsedMs={}, message={}", + e.getClass().getSimpleName(), + elapsedMillis(started), + e.getMessage()); return fallback; } } + private static long elapsedMillis(long startedNanos) { + return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis( + System.nanoTime() - startedNanos); + } + private static HttpResponse post(Config config, String body) throws IOException { HttpURLConnection connection = (HttpURLConnection) @@ -222,13 +257,19 @@ private static String buildRequestBody(Config config, String system, String user } } - private static String extractContent(String responseBody) { + private static ModelReply extractReply(String responseBody) { try { JsonNode choices = MAPPER.readTree(responseBody).path("choices"); if (choices.isArray() && !choices.isEmpty()) { - JsonNode message = choices.get(0).path("message").path("content"); + JsonNode choice = choices.get(0); + JsonNode message = choice.path("message").path("content"); if (!message.isMissingNode()) { - return message.asText(); + JsonNode finishReason = choice.path("finish_reason"); + return new ModelReply( + message.asText(), + finishReason.isMissingNode() || finishReason.isNull() + ? null + : finishReason.asText()); } } return null; @@ -260,4 +301,6 @@ private Config(String envPath, String apiKey, String baseUrl, String model, } private record HttpResponse(int statusCode, String body) {} + + private record ModelReply(String content, String finishReason) {} } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchControlPoint.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchControlPoint.java index b243296c..e418c97a 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 @@ -214,7 +214,9 @@ public CompletableFuture onSelfTask(TaskRequest request) { request.getAgentName()); String message = request.getMessage(); String fallback = analyzeFaultLocation(message); - String sys = "你是SPN跨城故障协同诊断汇总专家。按如下结构输出:1. 诊断结果;2. 诊断结果详情;3. 修复建议。简洁专业,中文。"; + String sys = "你是SPN跨城故障协同诊断汇总专家。仅依据两地市结果进行汇总,不补造网元和端口。" + + "按1.诊断结果、2.诊断结果详情、3.修复建议输出;明确城市1存在无收光故障、城市2诊断成功且本地正常。" + + "全文不超过450个中文字符,不使用表格,最后一句必须完整结束。"; String result = LlmHelper.text(a2atEnvPath, sys, message, fallback); log.info("[onSelfTask] Merge result ({} chars): {}", result.length(), result); return CompletableFuture.completedFuture( 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 d98fbabc..f58a9c9c 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 @@ -251,13 +251,26 @@ void fullBusinessPathDiagnosisRecoveryAndSelfLoopMerge() { .join(); } - CountDownLatch recoveryNotification = new CountDownLatch(1); + CountDownLatch recoveryPlanNotification = new CountDownLatch(1); + CountDownLatch recoveryResultNotification = new CountDownLatch(1); + AtomicReference recoveryPlanNotificationText = new AtomicReference<>(); AtomicReference recoveryNotificationText = new AtomicReference<>(); java.util.function.Consumer> notificationCallback = event -> { + 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(String.valueOf(event.get("text"))); - recoveryNotification.countDown(); + recoveryNotificationText.set(protocolContent); + recoveryResultNotification.countDown(); } }; for (String agent : List.of("SPN Domain Agent City1", "SPN Domain Agent City2")) { @@ -275,7 +288,7 @@ void fullBusinessPathDiagnosisRecoveryAndSelfLoopMerge() { } Map allOutputs = new ConcurrentHashMap<>(); - AtomicBoolean sawRecovery = new AtomicBoolean(false); + AtomicBoolean workflowTaskContainedRecovery = new AtomicBoolean(false); AtomicBoolean sawSelfLoop = new AtomicBoolean(false); AtomicBoolean sawNegotiation = new AtomicBoolean(false); EventCallback cb = @@ -287,10 +300,9 @@ public void onEvent(String type, Map data) { data.get("text") != null ? String.valueOf(data.get("text")) : ""; - if (text.contains("抢通") - || text.contains("恢复") - || text.contains("Notification")) { - sawRecovery.set(true); + if (text.contains("业务抢通方案执行状态") + || text.contains("Notification-T")) { + workflowTaskContainedRecovery.set(true); } } if (EventType.TASK_RESPONSE.equals(type) @@ -330,11 +342,21 @@ public void onEvent(String type, Map data) { "The complete-input diagnosis workflow must not trigger Negotiation-T"); // Self-loop merge step executed locally (no A2A-T to self) assertTrue(sawSelfLoop.get(), "Self-loop merge must run via onSelfTask"); - // SPN agents reported recovery via Notification-T channel + // Recovery is outside the workflow task channel: first the plan, then the result. + assertFalse( + workflowTaskContainedRecovery.get(), + "Task-T diagnosis artifacts must not contain Notification-T recovery output"); + assertTrue( + await(recoveryPlanNotification, 5), + "Recovery plan must arrive first on the isolated Notification-T stream"); assertTrue( - sawRecovery.get(), "SPN must self-trigger recovery and report via Notification-T"); + recoveryPlanNotificationText.get() != null + && recoveryPlanNotificationText.get().contains("业务抢通方案执行状态:未启动") + && recoveryPlanNotificationText.get().contains("是否已授权OMC自动抢通:是"), + "Recovery plan must contain the required protocol fields: " + + recoveryPlanNotificationText.get()); assertTrue( - await(recoveryNotification, 5), + await(recoveryResultNotification, 5), "Recovery result must arrive on the isolated Notification-T stream"); assertTrue( recoveryNotificationText.get() != null 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 58d25362..eeab8b80 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 @@ -121,22 +121,30 @@ void sdkExtractionCannotHallucinateAnEmptyNotificationCondition() { @Test void queryIsSideEffectFreeAndDeleteClearsTheDemoWhitelist() { PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); - handler.applyAuthorization(SpnCasePrompts.addAuthorizationData(), "template", "test"); + 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")); - handler.applyAuthorization( - Map.of(AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.QUERY), - "template", - "test"); + PrePositionedExtensionHandler.AuthorizationOperationResult queryResult = + handler.applyAuthorization( + Map.of(AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.QUERY), + "template", + "test"); assertSame(added, handler.getAuthorizationPolicy()); + assertTrue(queryResult.metadataText().contains("动网操作的授权策略列表")); handler.applyAuthorization( Map.of( AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.DELETE, AuthorizationPolicy.POLICY_LIST_FIELD, - "policy-id"), + "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"), "template", "test"); assertNull(handler.getAuthorizationPolicy()); @@ -159,6 +167,25 @@ void deleteWithoutASelectorFailsClosedAndKeepsTheExistingWhitelist() { assertSame(existing, handler.getAuthorizationPolicy()); } + @Test + void deleteMustTargetTheExactActivePolicyIdentifier() { + PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); + handler.applyAuthorization(SpnCasePrompts.addAuthorizationData(), "template", "test"); + AuthorizationPolicy existing = handler.getAuthorizationPolicy(); + + assertThrows( + IllegalArgumentException.class, + () -> handler.applyAuthorization( + Map.of( + AuthorizationPolicy.OPERATION_TYPE_FIELD, + AuthorizationPolicy.DELETE, + AuthorizationPolicy.POLICY_LIST_FIELD, + "different-policy-id"), + "template", + "test")); + assertSame(existing, handler.getAuthorizationPolicy()); + } + @Test void modifyRequiresAnIntegratingPolicyStoreAndKeepsTheExistingWhitelist() { PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); From c15f632160c004ac0e5173d6dc9d9d597d1e1f64 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Fri, 28 Aug 2026 21:42:44 +0800 Subject: [PATCH 38/49] docs: refresh current protocol integration guidance Signed-off-by: Zhoujie628 --- README.md | 7 +- docs/en/API_REFERENCE.md | 25 ++- docs/en/DESIGN.md | 8 +- docs/en/DEVELOPER_GUIDE.md | 5 +- docs/en/INTEGRATION_GUIDE.md | 16 +- docs/zh/A2AT-SDK-DEPENDENCY.md | 21 ++- docs/zh/API_REFERENCE.md | 21 ++- docs/zh/DESIGN.md | 5 +- docs/zh/DEVELOPER_GUIDE.md | 5 +- docs/zh/INTEGRATION_GUIDE.md | 15 +- .../\344\270\232\345\212\241\346\265\201.md" | 18 +-- ...06\346\210\220\346\214\207\345\215\227.md" | 38 +++-- samples/DESIGN-NOTES.md | 143 ------------------ 13 files changed, 128 insertions(+), 199 deletions(-) delete mode 100644 samples/DESIGN-NOTES.md diff --git a/README.md b/README.md index 3ebd0853..4cb0db30 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ Central. Install the pinned SDK revision first (do not use an older locally cach ```bash git clone https://github.com/project-openan/a2a-t-sdk-java.git cd a2a-t-sdk-java -git checkout 841c575054d7aecc532e3fd312e004241727951b -mvn -B -Drevision=1.0.0-841c575 -DskipTests \ +git checkout 0de5a2751781419820436e5eb17cffc39b9db47d +mvn -B -Drevision=1.0.0-0de5a27 -DskipTests \ -pl a2a-t-client,a2a-t-server -am install ``` @@ -96,7 +96,8 @@ ControlPoint controlPoint = new DefaultControlPoint() { TaskRequest request, TaskDispatcher dispatcher) { return dispatcher .dispatch(TaskSubmission.fromText( - request.getAgentName(), request.getMessage())) + request.getAgentName(), request.getMessage(), + StandardTemplates.PRIVATE_LINE_COMPLAINT)) .thenApply(r -> { String state = r.getTaskState(); boolean success = state == null || state.isBlank() diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index 6a8a7d45..7996e731 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -163,6 +163,9 @@ Notification-T entry points require an explicit SDK template so callers can sele - `NegotiationDecision.rejectText/rejectData` — Reject through SDK fromText/fromData - `NegotiationDecision.abortText/abortData` — Abort through SDK fromText/fromData - String control prefixes such as `data:`, `reject:`, and `abort:` are not accepted. + - Information Reject is itemized: every `rejectData` key names an unavailable requested item and + its value gives that item's concrete non-provision reason. Do not merge several requested items + into one aggregate rejection reason. - **Round exhaustion**: beyond `maxNegotiationRounds` the loop stops, a terminal message is sent via the SDK abort template (best effort — delivery failures are logged only), a `NEGOTIATION_FAILED` event fires, and the last agent reply stands as the final response. @@ -426,13 +429,15 @@ Map normalized = AgentCardNormalizer.normalize(rawMap); `onTask` receives only the narrow `TaskDispatcher` capability. Business code uses: ```java -dispatcher.dispatch(TaskSubmission.fromText(agentName, text)); +dispatcher.dispatch(TaskSubmission.fromText(agentName, text, templateUri)); +dispatcher.dispatch(TaskSubmission.fromUnclassifiedText(agentName, text)); dispatcher.dispatch(TaskSubmission.fromData( agentName, instruction, data, schema, templateUri)); ``` -The first form invokes SDK natural-language scenario recognition; the second invokes schema-aware -fromData. `TaskSubmission` validates the target, instruction, schema, and Task-T template and +The first form invokes the SDK's explicit natural-language API for a known Task-T template without +reclassifying the scenario; the second invokes SDK scenario recognition only when the template is +genuinely unknown; the third invokes schema-aware fromData. `TaskSubmission` validates the target, instruction, schema, and Task-T template and defensively copies business data before it reaches the protocol layer. ### ControlPoint @@ -467,17 +472,27 @@ public interface ControlPoint { | `onNegotiation` | When agent returns `INPUT_REQUIRED` | `NegotiationDecision` | `NegotiationRequest` exposes `agentName`, `concern`, `sessionId`, `round`, `maxRounds`, the typed -`NegotiationPerformative`, negotiation `kind`, `templateUri`, and read-only `metadata`. Only a +`NegotiationPerformative`, negotiation `kind`, `templateUri`, read-only SDK-extracted business +`parameters`, and read-only `metadata`. Business decisions should prefer `parameters`; `metadata` +is retained for advanced diagnostics. Only a `PROPOSE` with a complete context reaches the callback; missing context, invalid rounds, or an unsupported template fail closed. Business code only returns `acceptText/acceptData`, `rejectText/rejectData`, or `abortText/abortData`; it does not send a message or construct protocol metadata. +For an Information Reject, provide a separate reason for every unavailable requested item, for example: + +```java +NegotiationDecision.rejectData(Map.of( + "access-port name", "The current account cannot query the resource system", + "complaint category", "The current account cannot query the resource system")); +``` ### DefaultControlPoint Default implementation with sensible defaults: -- `onTask`: dispatches `TaskSubmission.fromText`, returns success/output +- `onTask`: dispatches `TaskSubmission.fromUnclassifiedText` by default and returns success/output; + hosts with a known template should override it and use `fromText(..., templateUri)` or `fromData(...)` - `onSelfTask`: echoes the task message back (override for local logic) - `onRoute`: picks first non-terminal branch - `onNegotiation`: returns a generic `acceptText` decision diff --git a/docs/en/DESIGN.md b/docs/en/DESIGN.md index d0bf5bfb..d4914747 100644 --- a/docs/en/DESIGN.md +++ b/docs/en/DESIGN.md @@ -154,9 +154,11 @@ Four A2A-T extensions are supported. They divide into two groups by lifecycle. Participate in every `sendMessage` lifecycle through the extension handler chain (`ExtensionRegistry` pre-registers both): -- **Task-T** - On send, calls the A2A-T SDK to generate a structured task prompt from the natural-language message and - injects it into the message metadata. Skipped for negotiation follow-ups and when the caller pre-sets the prompt. On - receive: pass-through. +- **Task-T** - On send, calls the A2A-T SDK to generate a canonical prompt from structured data, + natural language with a caller-known template, or unclassified natural language that needs scenario + recognition. It injects the prompt into message metadata. Negotiation follow-ups and caller-supplied + prompts skip generation; the receiver validates and extracts business fields through the matching SDK + validate-and-fill pipeline. - **Negotiation-T** - On receive, when the agent returns `INPUT_REQUIRED` and declares the extension, extracts the negotiation context and message. This feeds the auto-loop: the engine calls `ControlPoint.onNegotiation` for a `NegotiationDecision`, selects the matching SDK diff --git a/docs/en/DEVELOPER_GUIDE.md b/docs/en/DEVELOPER_GUIDE.md index b01a3208..abf1a5fd 100644 --- a/docs/en/DEVELOPER_GUIDE.md +++ b/docs/en/DEVELOPER_GUIDE.md @@ -37,7 +37,8 @@ public class MyControlPoint implements ControlPoint { public CompletableFuture onTask( TaskRequest request, TaskDispatcher dispatcher) { return dispatcher.dispatch(TaskSubmission.fromText( - request.getAgentName(), request.getMessage())) + request.getAgentName(), request.getMessage(), + StandardTemplates.PRIVATE_LINE_COMPLAINT)) .thenApply(result -> { String state = result.getTaskState(); boolean success = state == null || state.isBlank() @@ -109,7 +110,7 @@ Events come from three layers: the runner (lifecycle bracket), the executor (ste | `agent_status_update` | engine client | Agent SSE status update | `agent`, `state`, `is_final` | | `agent_artifact_update` | engine client | Agent SSE artifact update | `agent`, `artifact_name`, `text` | | `negotiation_request` | engine client | Agent needs clarification | `agent`, `round`, `concern` | -| `negotiation_resolved` | engine client | Clarification provided | `agent`, `round`, `clarification` | +| `negotiation_resolved` | engine client | Negotiation decision generated | `agent`, `round`, `decision` | | `negotiation_failed` | engine client | Negotiation failed | `agent`, `round`, `reason` | | `complete` | runner | Workflow succeeded | `history`, `step_outputs` | | `error` | runner or executor | Workflow failed | runner: `error`, `history`; executor: `step`, `results` | diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index b6419ec1..d05c2767 100644 --- a/docs/en/INTEGRATION_GUIDE.md +++ b/docs/en/INTEGRATION_GUIDE.md @@ -96,7 +96,8 @@ public class MyControlPoint extends DefaultControlPoint { public CompletableFuture onTask( TaskRequest request, TaskDispatcher dispatcher) { return dispatcher.dispatch(TaskSubmission.fromText( - request.getAgentName(), request.getMessage())) + request.getAgentName(), request.getMessage(), + StandardTemplates.PRIVATE_LINE_COMPLAINT)) .thenApply(r -> { String state = r.getTaskState(); boolean success = state == null || state.isBlank() @@ -146,7 +147,9 @@ public class MyControlPoint extends DefaultControlPoint { | `onNegotiation` | Agent returns `INPUT_REQUIRED` | Return a typed `NegotiationDecision` | `onNegotiation` defaults to a generic `acceptText` decision. For structured business data use -`acceptData/rejectData/abortData`; never encode a decision in a string prefix. +`acceptData/rejectData/abortData`; never encode a decision in a string prefix. For an information-negotiation +`rejectData`, provide one map entry per unavailable requested item, using the item name as the key and its concrete +non-provision reason as the value. Do not collapse multiple unavailable items into one aggregate rejection reason. **Independent operations (Authorization-T / Notification-T)**: Both use `ExtensionSender` at a workbench-selected business time and have no ordering dependency on workflow execution. Authorization-T is a one-shot request whose transport closes after a successful acknowledgement. Notification-T is a long-lived subscription on a transport independent of any single workflow; `openNotificationFromData` returns a `NotificationSubscription`, and later events are delivered to its callback until the expected result arrives, it is canceled, or it is explicitly closed. @@ -403,8 +406,11 @@ The engine handles four A2A-T extensions automatically. You do not need to deal ### Task-T (automatic) -In `onTask`, select `TaskSubmission.fromText(...)` or `TaskSubmission.fromData(...)` and pass it to -`TaskDispatcher.dispatch(...)`. The engine calls the matching A2A-T SDK API and injects the generated metadata. +In `onTask`, select `TaskSubmission.fromText(..., templateUri)`, +`TaskSubmission.fromUnclassifiedText(...)`, or `TaskSubmission.fromData(...)` and pass it to +`TaskDispatcher.dispatch(...)`. Pass the known complaint template when the workflow has already +classified the task; use `fromUnclassifiedText` only when the host genuinely needs SDK scenario +recognition. The engine calls the matching A2A-T SDK API and injects the generated metadata. ### Negotiation-T (automatic) @@ -584,7 +590,7 @@ WorkflowEngineClientConfig.builder() | `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 (httpx runtime, auth, SSE consumer) | +| `A2ATransport` | Shared wire layer (A2A Java client runtime, auth, SSE consumer) | | `WorkflowEngineClientConfig` | Configuration (SSL, auth, A2A-T, negotiation rounds, custom handlers) | | `AuthProvider` | Custom authentication | | `ExtensionHandler` | Custom extension handler | diff --git a/docs/zh/A2AT-SDK-DEPENDENCY.md b/docs/zh/A2AT-SDK-DEPENDENCY.md index b4c6295b..0d6cadef 100644 --- a/docs/zh/A2AT-SDK-DEPENDENCY.md +++ b/docs/zh/A2AT-SDK-DEPENDENCY.md @@ -3,8 +3,8 @@ 执行引擎首发版本只支持下列最新 A2A-T SDK 源码基线,不提供旧 SDK 兼容层: - 仓库:`project-openan/a2a-t-sdk-java` -- Git commit:`841c575054d7aecc532e3fd312e004241727951b` -- 本地 Maven 版本:`1.0.0-841c575`(精确提交构建的不可变本地版本) +- Git commit:`0de5a2751781419820436e5eb17cffc39b9db47d` +- 本地 Maven 版本:`1.0.0-0de5a27`(精确提交构建的不可变本地版本) - 使用组件:`a2a-t-client`、`a2a-t-server` 及其传递依赖 ## 已知上游生命周期缺口 @@ -17,7 +17,7 @@ Task-T、Authorization-T、Notification-T 和直连业务链路均可正常完 上游 SDK 应在新提交中补充幂等 `close()`,由 `A2ATClient/A2ATServer` 级联关闭其拥有的 LLM provider;执行引擎届时应升级到该新提交对应的新 Maven 版本。禁止在不改变版本号的 -情况下覆盖 `1.0.0-841c575`,否则 IDEA 与 CI 将得到不可复现的同坐标异内容制品。 +情况下覆盖 `1.0.0-0de5a27`,否则 IDEA 与 CI 将得到不可复现的同坐标异内容制品。 该 SDK 尚未发布到 Maven Central。引擎不探测、不加载也不降级到其他 A2A-T SDK 版本; 本机与 CI 都必须安装这里锁定的提交派生坐标。 @@ -27,8 +27,8 @@ LLM provider;执行引擎届时应升级到该新提交对应的新 Maven 版 ```powershell git clone https://github.com/project-openan/a2a-t-sdk-java.git Set-Location a2a-t-sdk-java -git checkout 841c575054d7aecc532e3fd312e004241727951b -mvn -B "-Drevision=1.0.0-841c575" -DskipTests ` +git checkout 0de5a2751781419820436e5eb17cffc39b9db47d +mvn -B "-Drevision=1.0.0-0de5a27" -DskipTests ` -pl a2a-t-client,a2a-t-server -am install ``` @@ -52,6 +52,17 @@ metadata、Authorization-T、Notification-T 长连接,以及现行传输路径 “只支持最新 A2A-T SDK”仅表示不兼容旧 SDK 版本和旧协议格式。 +## A2A Java SDK 基线 + +执行引擎使用 Maven Central 已正式发布的 `org.a2aproject.sdk` `1.2.0.Final`,不采用尚未 +发布制品的源码标签或 SNAPSHOT。该版本将 `DefaultRequestHandler` 构造迁移到 Builder、 +将任务校验收进请求处理器,并把 `Utils` 移到 `org.a2aproject.sdk.spec.util`。同时必须让 +`protobuf-java-util` 与其传递引入的 `protobuf-java:4.35.0` 对齐,避免 REST JSON 转换时 +出现 `NoSuchMethodError`。Spring starter 通过仅暴露 `RequestHandler` 接口的委托隔离 +`DefaultRequestHandler` 中 CDI 专用注入点;SSE 端点使用 `SseEmitter` 输出规范事件,不能把 +手工拼接的 `id:/data:` 文本再次作为普通响应体发送。升级 A2A Java SDK 后同样要跑直连和 +Order 两条 E2E。 + ## 单一资源基线 模板、slot schema、slot extraction 和 content validation 提示词全部来自锁定的 SDK jar。 diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index dd89ddbd..64244cf6 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -160,6 +160,8 @@ SDK 配置的 LLM);持有自然语言 - `NegotiationDecision.rejectText/rejectData` — 拒绝;分别走 SDK fromText/fromData - `NegotiationDecision.abortText/abortData` — 终止;分别走 SDK fromText/fromData - 不接受 `data:`、`reject:`、`abort:` 等字符串控制前缀。 + - Information Reject 必须逐项表达:`rejectData` 的每个 key 是一个无法提供的信息项名称, + value 是该项无法提供的具体原因;多个请求项不可合并成单一“拒绝原因”。 - **轮次耗尽**:超过 `maxNegotiationRounds` 后不再继续循环,通过 SDK abort 模板发送终止消息 (尽力而为,发送失败仅记日志),发出 `NEGOTIATION_FAILED` 事件,agent 最后一次回复作为最终响应。 - **协商上下文**:执行引擎不支持 `startNegotiation` / `receiveNegotiation` / @@ -416,12 +418,14 @@ Map normalized = AgentCardNormalizer.normalize(rawMap); `onTask` 只获得窄化的 `TaskDispatcher` 能力。业务使用: ```java -dispatcher.dispatch(TaskSubmission.fromText(agentName, text)); +dispatcher.dispatch(TaskSubmission.fromText(agentName, text, templateUri)); +dispatcher.dispatch(TaskSubmission.fromUnclassifiedText(agentName, text)); dispatcher.dispatch(TaskSubmission.fromData( agentName, instruction, data, schema, templateUri)); ``` -第一种调用 SDK 自然语言场景识别;第二种调用 schema-aware fromData。`TaskSubmission` 在进入 +第一种对已知 Task-T 模板调用 SDK 显式自然语言接口且不重复识别场景;第二种仅在模板未知时 +调用 SDK 自然语言场景识别;第三种调用 schema-aware fromData。`TaskSubmission` 在进入 协议层前校验 agent、instruction、schema 和模板扩展类型,并防御性复制业务数据。 ### ControlPoint @@ -456,16 +460,25 @@ public interface ControlPoint { | `onNegotiation` | 智能体返回 `INPUT_REQUIRED` 时 | `NegotiationDecision` | `NegotiationRequest` 向业务方提供 `agentName`、`concern`、`sessionId`、`round`、 -`maxRounds`、强类型 `NegotiationPerformative`、协商 `kind`、`templateUri` 和只读 `metadata`。 +`maxRounds`、强类型 `NegotiationPerformative`、协商 `kind`、`templateUri`、SDK 校验抽取后的只读 +`parameters` 和只读 `metadata`。业务判断优先使用 `parameters`;`metadata` 只用于高级诊断。 只有带完整上下文的 `PROPOSE` 才会进入回调;上下文缺失、轮次非法或模板不受支持时均失败关闭。 业务方只需根据这些字段返回 `acceptText/acceptData`、`rejectText/rejectData` 或 `abortText/abortData`,无需发送消息或构造协议 metadata。 +对于 Information Reject,业务方必须对 Propose 中每个无法提供的请求项分别给出原因,例如: + +```java +NegotiationDecision.rejectData(Map.of( + "接入端口名称", "当前账号没有资源系统查询权限", + "投诉分类", "当前账号没有资源系统查询权限")); +``` ### DefaultControlPoint 默认实现,提供合理默认值: -- `onTask`:用 `TaskSubmission.fromText` 交给 `TaskDispatcher`,返回 success/output +- `onTask`:默认用 `TaskSubmission.fromUnclassifiedText` 交给 `TaskDispatcher`,返回 success/output; + 已知业务模板的宿主应覆盖此方法并使用 `fromText(..., templateUri)` 或 `fromData(...)` - `onSelfTask`:原样回传任务消息(本地逻辑请覆盖实现) - `onRoute`:选第一个非终止分支 - `onNegotiation`:返回通用的 `acceptText` 决策 diff --git a/docs/zh/DESIGN.md b/docs/zh/DESIGN.md index 4a5a4119..6bc6b443 100644 --- a/docs/zh/DESIGN.md +++ b/docs/zh/DESIGN.md @@ -135,8 +135,9 @@ Authorization-T 和 Notification-T 是工作台独立触发的协议操作,不 参与每次 `sendMessage` 生命周期,通过扩展处理器链(`ExtensionRegistry` 自动注册): -- **Task-T** — 发送时,调用 A2A-T SDK 从自然语言消息生成结构化任务提示词,注入消息 metadata。 - 协商后续和调用方预设提示词时跳过。接收时:透传。 +- **Task-T** — 发送时,调用 A2A-T SDK 从结构化数据、已知模板的自然语言或待识别场景的 + 自然语言生成规范任务提示词并注入消息 metadata。协商后续和调用方预设提示词时跳过。 + 接收方再用同一模板的 SDK validate-and-fill 校验和提取业务字段。 - **Negotiation-T** — 接收时,当智能体返回 `INPUT_REQUIRED` 并声明该扩展, 提取协商上下文和消息。这驱动自动循环:引擎调用 `ControlPoint.onNegotiation` 获取 `NegotiationDecision`,按 action 与 input 类型选择 SDK fromText/fromData API,重发后续消息。 diff --git a/docs/zh/DEVELOPER_GUIDE.md b/docs/zh/DEVELOPER_GUIDE.md index 5536cc12..99157741 100644 --- a/docs/zh/DEVELOPER_GUIDE.md +++ b/docs/zh/DEVELOPER_GUIDE.md @@ -35,7 +35,8 @@ public class MyControlPoint implements ControlPoint { public CompletableFuture onTask( TaskRequest request, TaskDispatcher dispatcher) { return dispatcher.dispatch(TaskSubmission.fromText( - request.getAgentName(), request.getMessage())) + request.getAgentName(), request.getMessage(), + StandardTemplates.PRIVATE_LINE_COMPLAINT)) .thenApply(result -> { String state = result.getTaskState(); boolean success = state == null || state.isBlank() @@ -106,7 +107,7 @@ ExecutionResult result = ExecutePsop.builder() | `agent_status_update` | 引擎客户端 | 智能体 SSE 状态更新 | `agent`、`state`、`is_final` | | `agent_artifact_update` | 引擎客户端 | 智能体 SSE 产物更新 | `agent`、`artifact_name`、`text` | | `negotiation_request` | 引擎客户端 | 智能体需要澄清 | `agent`、`round`、`concern` | -| `negotiation_resolved` | 引擎客户端 | 澄清已提供 | `agent`、`round`、`clarification` | +| `negotiation_resolved` | 引擎客户端 | 协商决策已生成 | `agent`、`round`、`decision` | | `negotiation_failed` | 引擎客户端 | 协商失败 | `agent`、`round`、`reason` | | `complete` | 运行器 | 工作流成功 | `history`、`step_outputs` | | `error` | 运行器或执行器 | 工作流失败 | 运行器:`error`、`history`;执行器:`step`、`results` | diff --git a/docs/zh/INTEGRATION_GUIDE.md b/docs/zh/INTEGRATION_GUIDE.md index a316032d..e9ee3f46 100644 --- a/docs/zh/INTEGRATION_GUIDE.md +++ b/docs/zh/INTEGRATION_GUIDE.md @@ -94,7 +94,8 @@ public class MyControlPoint extends DefaultControlPoint { public CompletableFuture onTask( TaskRequest request, TaskDispatcher dispatcher) { return dispatcher.dispatch(TaskSubmission.fromText( - request.getAgentName(), request.getMessage())) + request.getAgentName(), request.getMessage(), + StandardTemplates.PRIVATE_LINE_COMPLAINT)) .thenApply(r -> { String state = r.getTaskState(); boolean success = state == null || state.isBlank() @@ -143,7 +144,9 @@ public class MyControlPoint extends DefaultControlPoint { | `onNegotiation` | 智能体返回 `INPUT_REQUIRED` 时 | 返回强类型 `NegotiationDecision` | `onNegotiation` 默认返回通用的 `acceptText` 决策。结构化业务数据应使用 -`acceptData/rejectData/abortData`,不要拼接字符串控制前缀。 +`acceptData/rejectData/abortData`,不要拼接字符串控制前缀。信息协商使用 `rejectData` +时,Map 必须以每个无法提供的信息项名称为 key、具体原因为 value 逐项填写;不能只传一个汇总的 +“拒绝原因”。例如同时无法提供端口和投诉分类时,应分别传入 `接入端口名称` 与 `投诉分类` 两项。 **流程外操作(Authorization-T / Notification-T)**:两者都由宿主在各自业务时机通过 `ExtensionSender` 独立触发,与工作流没有固定先后关系。Authorization-T 是响应完成即结束的一次性请求; @@ -403,8 +406,10 @@ AgentCard 通过 `capabilities.extensions` 声明扩展点: ### Task-T(自动) -`onTask` 通过 `TaskSubmission.fromText(...)` 或 `TaskSubmission.fromData(...)` 明确选择输入轨道, -再交给 `TaskDispatcher.dispatch(...)`。引擎自动调用匹配的 A2A-T SDK 方法并注入 metadata。 +`onTask` 通过 `TaskSubmission.fromText(..., templateUri)`、 +`TaskSubmission.fromUnclassifiedText(...)` 或 `TaskSubmission.fromData(...)` 明确选择输入轨道, +再交给 `TaskDispatcher.dispatch(...)`。工作流已经知道投诉模板时必须传入模板;只有宿主尚未完成 +场景分类时才使用 `fromUnclassifiedText` 让 SDK 识别场景。引擎自动调用匹配的 A2A-T SDK 方法并注入 metadata。 ### Negotiation-T(自动) @@ -586,7 +591,7 @@ WorkflowEngineClientConfig.builder() | `ControlPoint` / `DefaultControlPoint` | 业务决策实现(onTask、onSelfTask、onRoute、onNegotiation 等) | | `WorkflowEngineClient` / `DefaultWorkflowEngineClient` | 工作流发送(sendMessage、认证、扩展) | | `ExtensionSender` / `DefaultExtensionSender` | 前置授权请求与长连接通知订阅 | -| `A2ATransport` | 共享通信层(httpx runtime、认证、SSE 消费) | +| `A2ATransport` | 共享通信层(A2A Java 客户端 runtime、认证、SSE 消费) | | `WorkflowEngineClientConfig` | 配置(SSL、认证、A2A-T、协商轮数、自定义 Handler) | | `AuthProvider` | 自定义认证 | | `ExtensionHandler` | 自定义扩展 | 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 3491ce6c..607b8c02 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,6 +1,6 @@ ---- +# SPN 专线投诉诊断业务流 -## 工作台业务边界(2026-08-25 校准) +## 工作台业务边界(2026-08-27 校准) 1. WAIMO 通过 Task-T 将一个投诉诊断任务下发给工作台。工作台使用 SDK 校验并提取 投诉参数,搜索/加载工作流。 @@ -21,8 +21,8 @@ | **协议/模式** | A2A-T Task-T | | **调用方向** | 工作台智能体 -> SPN Domain Agent(OMC) | | **功能描述** | 工作台智能体下发诊断请求,OMC完成网络侧故障根因诊断并返回结果。 | -| **请求参数** | 1. 投诉发生时间
        2. 业务投诉故障现象
        3. 专线分段业务标识
        4. 故障场景(专线业务质差 / 专线中断) | -| **响应内容** | **一期必须返回:**
        1. 故障根因
        2. 根因点位置
        3. 根因描述
        4. 修复建议

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

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

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

        **请求内容:** 明确指出缺失/错误的参数及修正建议。 | +| **协商响应(工作台→OMC)** | 工作台智能体理解协商请求,返回强类型 Accept / Reject / Abort。Information Reject 必须对每个无法提供的请求信息项逐项说明原因,不能只返回一个汇总拒绝原因。 | | **协商完成(OMC)** | OMC确认协商信息完成后,继续执行诊断任务,并最终返回诊断结果(同表格一响应内容)。 | ### 表格三:业务抢通消息订阅与上报接口(结果通知) @@ -44,8 +44,8 @@ | **协议/模式** | A2A-T Notification-T | | **功能描述** | 用于工作台订阅抢通结果,以及OMC通过SSE流主动上报抢通结果消息。 | | **订阅请求(工作台→OMC)** | **包含内容:**
        1. 消息主题
        2. 上报消息数据格式(需包括:投诉事件流水号、分段业务标识、抢通方案执行结果、抢通方案详情) | -| **订阅响应(OMC→工作台)** | **成功:** 返回订阅成功。
        **失败:** 返回订阅失败及失败原因描述。 | -| **消息上报(OMC→工作台)** | 基于订阅时指定的数据格式,通过**SSE流**主动向工作台推送业务抢通结果消息。 | +| **订阅响应(OMC→工作台)** | **成功:** Notification-T metadata 返回“订阅结果:成功”,任务进入 `WORKING`。
        **失败:** 返回 `FAILED` 及失败原因。 | +| **消息上报(OMC→工作台)** | 基于订阅时指定的数据格式,通过 **SSE** 先推送 `recovery-plan`(未启动),白名单命中并执行后再推送 `recovery-result`(已结束);结果、取消或宿主关闭才结束通道。 | ### 表格四:专线业务抢通授权接口(自动执行授权) @@ -55,5 +55,5 @@ | **协议/模式** | A2A-T Authorization-T | | **功能描述** | 工作台向 OMC 新增、修改、删除或查询抢通授权,OMC 据有效白名单决定是否自动执行抢通。 | | **授权请求(工作台→OMC)** | **包含内容:**
        1. 操作类型:新增/修改/删除/查询授权策略
        2. SDK 模板所定义的策略列表或查询条件 | -| **授权响应(OMC→工作台)** | **成功:** 返回授权成功。
        **失败:** 返回授权失败及详细的失败原因。 | +| **授权响应(OMC→工作台)** | artifact 的 Authorization-T metadata 返回“授权操作执行结果”;新增/查询成功时还返回策略标识和策略列表,删除必须精确匹配策略标识。失败返回 `FAILED` 且保留原策略。 | | **后置操作(OMC内部)** | 授权成功后,当OMC在执行诊断后的业务抢通时,若匹配到已授权项,则**自动执行**业务抢通,并依据**表格三**的订阅通道上报抢通结果消息。 | diff --git "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" index 0377a2b3..9d1aa8bb 100644 --- "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" @@ -1,8 +1,8 @@ # 工作流执行引擎 SDK 工作台集成指南 > 交付基线:workflow engine `1.0.0`,A2A-T SDK -> `1.0.0-841c575`(commit `841c575054d7aecc532e3fd312e004241727951b`), -> A2A Java SDK `1.0.0.Beta1`。本执行引擎是首发版本, +> `1.0.0-0de5a27`(commit `0de5a2751781419820436e5eb17cffc39b9db47d`), +> A2A Java SDK `1.2.0.Final`。本执行引擎是首发版本, > 只支持该当前 A2A-T API/资源基线,不提供旧 SDK 状态机接口或旧报文 fallback。 > 面向传输工作台研发团队。本文档以 SPN 专线故障跨城协同诊断联创场景为例,从工作台视角说明接入 Workflow 执行引擎 SDK 的完整路径。 @@ -466,7 +466,9 @@ SDK 在工作流走到一个需要向下游 Agent 发送任务的步骤时调用 `request` 包含: `agentName` (目标 Agent 名称)、`stepName` (当前步骤名)、`message` (SDK 组装的完整消息,含上游上下文)。 关键设计:给每个 OMC 发送的任务只包含该 OMC 相关的结构化参数,不混入其他地市的信息。 -有结构化业务数据时使用 `TaskSubmission.fromData`;只有自由文本时才使用 `fromText`: +有结构化业务数据时使用 `TaskSubmission.fromData`;自由文本且工作流已知投诉模板时使用 +`TaskSubmission.fromText(agent, text, StandardTemplates.PRIVATE_LINE_COMPLAINT)`;只有模板确实 +未知时才使用 `fromUnclassifiedText` 触发 SDK 场景识别: ```java public class WorkbenchControlPoint extends DefaultControlPoint { @@ -511,7 +513,8 @@ public class WorkbenchControlPoint extends DefaultControlPoint { } ``` -`TaskDispatcher.dispatch(submission)` 内部根据输入类型选择 A2A-T SDK fromText/fromData API,并自动完成: +`TaskDispatcher.dispatch(submission)` 内部根据输入类型选择 A2A-T SDK 显式 fromText、场景识别或 +fromData API,并自动完成: Task-T 生成 -> Bearer 认证 -> SSE 流式发送 -> 事件提取 -> Negotiation-T 协商循环。 #### 4.2 onSelfTask -- 本地汇总 (自环节点) @@ -566,7 +569,8 @@ public CompletableFuture onRoute( #### 4.4 onNegotiation -- 协商澄清 当 OMC 返回 `INPUT_REQUIRED` (Negotiation-T) 时,SDK 自动进入协商循环。SDK 传入包含协商类型、 -会话轮次和 concern 的 `NegotiationRequest`;工作台返回强类型决策,不需要自行发送消息。 +会话轮次、concern 和 SDK 校验抽取后的只读 `parameters` 的 `NegotiationRequest`;工作台返回强类型 +决策,不需要自行发送消息。业务判断优先使用 `parameters`,无需解析协议 metadata。 触发条件: OMC 发现任务参数有误或缺失 (必选参数缺失、取值不在枚举范围、语义校验错误等)。 @@ -583,6 +587,16 @@ public CompletableFuture onNegotiation( 业务可返回 `acceptText/acceptData/rejectText/rejectData/abortText/abortData`。SDK 自动选择对应的 Negotiation-T fromText/fromData API 并重发,最多 3 轮(可配置);不再支持 `data:` 等字符串控制前缀。 +若拒绝 Information Propose,必须对每个无法提供的请求项逐项给出原因。例如: + +```java +NegotiationDecision.rejectData(Map.of( + "接入端口名称", "当前账号没有资源系统查询权限", + "投诉分类", "当前账号没有资源系统查询权限")); +``` + +不能把两项合并成 `Map.of("拒绝原因", "...")`;最新 A2A-T SDK 会把逐项内容渲染为 +Information Negotiation Reject 报文。 协商循环时序: @@ -895,13 +909,15 @@ sequenceDiagram **工作流执行阶段 (工作台调用 ExecutePsop):** - `ExecutePsop` 内部创建 `WorkflowExecutor`,执行 DAG 遍历 - 每个步骤: `WorkflowExecutor` 调用 `ControlPoint.onTask()`,工作台决定发什么内容 -- 工作台在 `onTask` 中提交强类型 `TaskSubmission`,SDK 自动选择 fromText/fromData 并处理认证、SSE 与协商循环 +- 工作台在 `onTask` 中提交强类型 `TaskSubmission`,SDK 自动选择显式 fromText、场景识别或 + fromData,并处理认证、SSE 与协商循环 - `SELF_LOOP` 步骤调用 `onSelfTask()`,工作台本地处理,不发送 A2A-T 消息 **后续通知阶段 (SSE 长连接):** -- OMC 在诊断后自动执行抢通操作 (如光模块更换) -- 抢通结果通过预定位阶段建立的 SSE 长连接推送 -- 回调在 `sendNotification` 时注册,与 `ExecutePsop` 无关 +- OMC 在诊断后先上报抢通方案;只有方案精确命中有效 Authorization-T 白名单时,才自动执行 + 本场景的隧道调优 +- 抢通结果通过独立 Notification-T SSE 长连接推送 +- 回调在 `openNotificationFromData` 时注册,与 `ExecutePsop` 无关 --- @@ -1040,14 +1056,14 @@ SDK 的 `WorkflowExecutor` 自动并行调度同一层的步骤 (无依赖关系 | TLS | sslVerify=false | 生产证书校验 | | PSOP | fallback 固定 ID | 编排中心语义搜索 + 动态加载 | | 事件 | 日志打印 | SSE 推送前端 + 持久化数据库 | -| 协商 | 固定澄清文本 | LLM 生成或业务规则引擎 | +| 协商 | 结构化规则示例(`acceptData`) | LLM 生成或业务规则引擎返回强类型决策 | | 抢通结果 | 不处理 | 通过 Notification-T SSE 通道接收并入库 | --- ## 9. 集成自查清单 -- Maven 是否解析到 `a2a-t-client:1.0.0-841c575`,且 jar 来自固定 commit 而非旧缓存 +- Maven 是否解析到 `a2a-t-client:1.0.0-0de5a27`,且 jar 来自固定 commit 而非旧缓存 - AgentCard 是否包含 `name`、`description`、`version`、`capabilities`、`defaultInputModes`、`defaultOutputModes`、`skills`、`supportedInterfaces` - 下游 AgentCard 的 `securitySchemes` / `securityRequirements` 是否按实际认证方式配置;AuthProvider-only 推荐 `securityRequirements: []` - `AuthProvider` 是否已同时配置到前置预定位 transport 和工作流任务 transport diff --git a/samples/DESIGN-NOTES.md b/samples/DESIGN-NOTES.md deleted file mode 100644 index 7a431ca5..00000000 --- a/samples/DESIGN-NOTES.md +++ /dev/null @@ -1,143 +0,0 @@ -# workflow-exec-engine-java 架构改造要点 - -## 一、引擎职责边界 - -**引擎层 (`workflow-engine`) 只提供 workflow 执行调度能力,不包含任何 Agent server 代码。** - -- 引擎依赖只保留 a2a-java SDK 客户端侧(`a2a-java-sdk-client`、`a2a-java-sdk-client-transport-rest`) -- 不依赖服务端侧(`a2a-java-sdk-server-common`、`a2a-java-sdk-transport-rest`) -- 之前错误地放在引擎里的 `WorkbenchAgentServer.java` 已删除 -- 引擎提供:`WorkflowExecutor`、`ExecutePsop`、`LoadPsop`、`RegistryClient`、`DefaultWorkflowEngineClient` - -## 二、Agent 全部放在 samples - -每个 Agent 是独立的 A2A server(既是客户端也是服务端),通过 agentcard JSON 配置文件启动。 - -### 文件结构 - -```mermaid -graph TD - root["samples/src/main/java/.../examples/"] - srv["server/JdkHttpA2AServer.java
        可复用 A2A server(JDK HttpServer)"] - launch["server/OmcAgentLauncher.java
        从 agentcard JSON 启动并跟踪 Agent"] - base["agents/BaseAgentExecutor.java
        基类:extractText / buildStatusMessage"] - spn1["agents/SpnDomainAgentCity1Executor.java
        城市1 OMC Agent"] - spn2["agents/SpnDomainAgentCity2Executor.java
        城市2 OMC Agent"] - wb["workbench/SpringWorkbenchExecutor.java
        Spring 管理的工作台 Agent"] - cp["workbench/WorkbenchControlPoint.java
        ControlPoint 决策逻辑(SRP 拆分)"] - prompts["demo/SpnCasePrompts.java
        专线投诉场景 Task-T 结构化提示词"] - demo["demo/SpringSpnDemo.java
        Demo 入口(direct 直连传输)"] - - root --> srv & launch & base & demo - base --> spn1 & spn2 & wb - demo --> prompts - wb --> cp -``` - -**资源文件:** - -- `samples/src/main/resources/agentcard/spn_domain_agent.json` — AgentCard 配置(标准格式,URL 带 /a2a/json 前缀) -- `samples/src/main/resources/agentcard/spn_domain_agent_city2.json` -- `samples/src/main/resources/agentcard/transport_workbench_agent.json` -- `samples/src/main/resources/spn_agent_credentials.json` — Bearer 认证配置 - -## 三、AgentCard URL 必须用标准格式 - -**不要改编排中心定义的 AgentCard JSON 格式,尤其是 URL 的路径前缀。** - -现网 AgentCard 的 URL 格式是 `http://host:port/a2a/json`,路径前缀 `/a2a/json` 是标准做法。A2A -REST 端点实际是 `http://host:port/a2a/json/message:stream`。 - -`JdkHttpA2AServer` 必须从 AgentCard URL 提取路径前缀,在该前缀下注册路由: - -```java -// 从 AgentCard URL 提取路径前缀 -this.pathPrefix = extractPathPrefix(agentCard); -// 在前缀下注册(如 /a2a/json) -this.server.createContext(pathPrefix.isEmpty() ? "/" : pathPrefix, handler); -// handleExchange 里 strip 掉前缀再匹配端点 -String path = fullPath.startsWith(pathPrefix) ? fullPath.substring(pathPrefix.length()) : fullPath; -``` - -## 四、Agent card 从配置文件加载,不依赖注册中心 - -**工作台执行工作流时,从 classpath 的 agentcard JSON 加载 agent card,不从注册中心 fetch。** - -原因:注册中心可能残留 之前注册的旧 card(端口 8904、URL 不带前缀等),与 Java 版的 agent 不匹配。注册返回 409 duplicate -时注册中心不会更新,导致引擎拿到错误的 URL。 - -```java -private static List> loadAgentCardsFromConfig() { - // 从 agentcard/*.json 加载,确保 URL 指向我们自己的 Java agent -} -``` - -## 五、SSE 响应解析必须支持 a2a-java SDK 事件格式 - -a2a-java SDK 的 `RestHandler` 返回的 SSE 事件有 4 种格式,引擎客户端必须全部处理: - -| SSE 事件 key | 内容 | 提取方式 | -|------------------|----------------|-----------------------------------------------------------------------------| -| `task` | 完整 Task 对象 | `task.artifacts[].parts[].text` | -| `message` | Message 事件 | `message.parts[].text` | -| `statusUpdate` | 状态更新 | `statusUpdate.status.state` + `statusUpdate.parts[].text` | -| `artifactUpdate` | 产物更新 | `artifactUpdate.artifact.parts[].text` + `artifactUpdate.artifact.metadata` | - -`extractTextFromResultMap` 必须处理三种来源: - -- `artifacts`(复数,list 形式,task 响应中) -- `artifact`(单数,artifactUpdate 事件中) -- 顶层 `parts`(statusUpdate 事件中 status.message.parts) - -## 六、Demo 用 sendMessage 接口,不要手写 HTTP - -```java -// 正确:用引擎提供的 WorkflowEngineClient.sendMessage() -DefaultWorkflowEngineClient client = new DefaultWorkflowEngineClient( - List.of(agentCard), null, - WorkflowEngineClientConfig.builder().sslVerify(false).build()); -SendMessageResult result = client.sendMessage("Transport Workbench Agent", taskText).join(); - -// 错误:手写 HttpClient + JSON 拼接(绕过了 A2A-T 协议处理) -``` - -`sendMessage` 内部自动处理:Task-T 消息封装、SSE 流式响应解析、Bearer 认证、协商循环等。 - -## 七、SSL 主机名验证 - -`jdk.internal.httpclient.disableHostnameVerification` 是 JVM 级、JDK 内部且可能被静态缓存的开关。SDK -不会修改它;只有使用仓库公开 demo 证书的独立样例进程才在入口类、创建任何 `HttpClient` 之前设置: - -```java -// demo 入口类(SpringSpnDemo 等)的 main 方法开头 -static { - System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); -} -``` - -该设置会影响同一 JVM 的其他 JDK `HttpClient`,不能用于共享进程或生产环境。生产应使用带正确 SAN -的服务端证书并保持 `sslVerify=true`;需要私有 CA 时配置 `caCertsPath`。 - -## 八、SOLID 原则 - -- **SRP**:`WorkbenchControlPoint` 只管工作流决策(路由、授权、通知),`SpringWorkbenchExecutor` 只管消息 I/O -- **DRY**:`BaseAgentExecutor` 提供共享的 `extractText` / `buildStatusMessage`,3 个 Agent executor 都继承它 -- **无 Unicode 转义**:Java 源文件中的中文字符串直接用字面中文,不用 `\uXXXX` - - 用 `apply_patch` 写文件(保持 UTF-8),不用 PowerShell here-string(会损坏中文) - -## 九、PSOP 工作流定义 - -编排中心的 `psop_spn_cross_city_diagnosis.json` 已经正确定义: - -- `merge_analysis` 节点分配给 `"Transport Workbench Agent"`,skill 为 `cross-city-fault-diagnosis` -- `layer=1`,`context_from=["diagnosis_city1", "diagnosis_city2"]` -- `merge_analysis` 是 `SelfLoop` 类型:工作台 Agent 通过 `WorkbenchControlPoint.onSelfTask` 本地汇总,不走 A2A-T 给自己发消息 -- 只有 `diagnosis_city1`/`diagnosis_city2` 走 A2A-T 给 SPN Domain Agent -- 工作台 Agent 收到顶层任务时走 `handleTopLevelTask`:搜索 PSOP → 加载工作流 → 执行(merge_analysis 由引擎自环节点回调处理) - -## 十、验证清单 - -- `mvn verify` 在无供应商私有依赖的公共环境中通过 -- `EmbeddedA2AServerTest` 验证:启动 server → sendMessage → 从 SSE 流提取诊断结果 -- `SpnCrossCityE2ETest` 端到端:2 个 OMC Agent 启动 → 前置预定位 → 3 步工作流全部成功 -- `SpringSpnDemo` 端到端:Spring 工作台 + 2 个 OMC Agent → Task-T → 跨城诊断工作流执行成功 From 620266e74e742c47c833b8b4d6d329e079b177d1 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Sat, 29 Aug 2026 10:28:42 +0800 Subject: [PATCH 39/49] feat(auth): support reusable credential profiles Signed-off-by: Zhoujie628 --- .../engine/client/AgentAuthManager.java | 324 ++++++++---------- .../engine/client/CredentialConfigLoader.java | 161 +++++++++ .../engine/client/AgentAuthManagerTest.java | 95 ++--- .../resources/test-profile-credentials.json | 30 ++ 4 files changed, 385 insertions(+), 225 deletions(-) create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialConfigLoader.java create mode 100644 workflow-engine/src/test/resources/test-profile-credentials.json 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 febad9db..cad3eee5 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java @@ -19,20 +19,15 @@ package dev.openan.workflow.engine.client; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; import java.net.http.HttpClient; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Loads agent credentials from config, creates per-agent CredentialService, and builds @@ -42,179 +37,142 @@ */ class AgentAuthManager { - private static final Logger log = LoggerFactory.getLogger(AgentAuthManager.class); - private static final ObjectMapper mapper = new ObjectMapper(); - - private final Map>> config; - private final HttpClient credentialHttpClient; - private final CredentialHttpTransport credentialHttpTransport; - private final String credentialEncryptionKey; - private final Map services = new ConcurrentHashMap<>(); - - /** Create with a config map (agent name -> scheme name -> scheme config). */ - public AgentAuthManager(Map>> config) { - this(config, null); - } - - /** Create with a config map and an explicitly configured login HTTP client. */ - public AgentAuthManager( - Map>> config, - HttpClient credentialHttpClient) { - this(config, credentialHttpClient, null); - } - - AgentAuthManager( - Map>> config, - HttpClient credentialHttpClient, - String credentialEncryptionKey) { - this(config, credentialHttpClient, null, credentialEncryptionKey); - } - - AgentAuthManager( - Map>> config, - CredentialHttpTransport credentialHttpTransport, - String credentialEncryptionKey) { - this(config, null, credentialHttpTransport, credentialEncryptionKey); - } - - private AgentAuthManager( - Map>> config, - HttpClient credentialHttpClient, - CredentialHttpTransport credentialHttpTransport, - String credentialEncryptionKey) { - this.config = config != null ? config : new HashMap<>(); - this.credentialHttpClient = credentialHttpClient; - this.credentialHttpTransport = credentialHttpTransport; - this.credentialEncryptionKey = credentialEncryptionKey; - validateEncryptedCredentials(this.config, credentialEncryptionKey); - if (!this.config.isEmpty()) { - log.info( - "[Auth] Loaded credentials for {} agent(s): {}", - this.config.size(), - new ArrayList<>(this.config.keySet())); - } - } - - /** Create by loading credentials from a JSON file. */ - public AgentAuthManager(String configPath) { - this(loadFromFile(configPath), null); - } - - /** Create from a JSON file and use the supplied client for login requests. */ - public AgentAuthManager(String configPath, HttpClient credentialHttpClient) { - this(loadFromFile(configPath), credentialHttpClient); - } - - AgentAuthManager( - String configPath, - HttpClient credentialHttpClient, - String credentialEncryptionKey) { - this(loadFromFile(configPath), credentialHttpClient, credentialEncryptionKey); - } - - AgentAuthManager( - String configPath, - CredentialHttpTransport credentialHttpTransport, - String credentialEncryptionKey) { - this(loadFromFile(configPath), credentialHttpTransport, credentialEncryptionKey); - } - - /** Create with no credentials (auth disabled). */ - public AgentAuthManager() { - this(new HashMap<>()); - } - - private static Map>> loadFromFile(String path) { - if (path == null) { - return new HashMap<>(); - } - try { - Map>> loaded; - if (path.startsWith("classpath:")) { - String resource = path.substring("classpath:".length()); - java.io.InputStream is = - AgentAuthManager.class.getClassLoader().getResourceAsStream(resource); - if (is == null) { - throw new IllegalStateException( - "Credentials classpath resource not found: " + resource); - } - loaded = - mapper.readValue( - is, - new TypeReference< - Map>>>() {}); - } else { - File file = new File(path); - if (!file.exists()) { - throw new IllegalStateException("Credentials file not found: " + path); - } - loaded = - mapper.readValue( - file, - new TypeReference< - Map>>>() {}); - } - log.info("[Auth] Loaded credentials for {} agent(s) from {}", loaded.size(), path); - return loaded; - } catch (Exception e) { - throw new IllegalStateException( - "Failed to load credentials from " + path + ": " + e.getMessage(), e); - } - } - - private static List extractExtensionUris(AgentCard agentCard) { - List uris = new ArrayList<>(); - var extensions = agentCard.capabilities().extensions(); - if (extensions == null) { - return uris; - } - for (var ext : extensions) { - String uri = ext.uri(); - if (!uri.isEmpty()) { - uris.add(uri); - } - } - return uris; - } - - /** Get or create a credential service for the given agent. */ - public AgentCredentialService getService(String agentName) { - return services.computeIfAbsent( - agentName, - name -> { - Map> agentCreds = config.get(name); - if (agentCreds == null) { - return null; - } - log.info("[Auth] Created credential service for agent: {}", name); - return new AgentCredentialService( - name, - agentCreds, - credentialHttpClient, - credentialHttpTransport, - credentialEncryptionKey); - }); - } - - /** Get the raw config for an agent. */ - public Map> getConfig(String agentName) { - return config.get(agentName); - } - - private static void validateEncryptedCredentials( - Map values, String credentialEncryptionKey) { - for (Object value : values.values()) { - if (value instanceof Map nested) { - validateEncryptedCredentials(nested, credentialEncryptionKey); - } else if (value instanceof String text && text.startsWith("enc:")) { - CredentialCrypto.decryptIfNeeded(text, credentialEncryptionKey); - } - } - } - - /** Build the extension-header contributor for an AgentCard, if it declares extensions. */ - public ExtensionInterceptor buildExtensionInterceptor(AgentCard agentCard) { - List extUris = extractExtensionUris(agentCard); - return extUris.isEmpty() ? null : new ExtensionInterceptor(extUris); - } + private static final Logger log = LoggerFactory.getLogger(AgentAuthManager.class); + private final Map>> config; + private final HttpClient credentialHttpClient; + private final CredentialHttpTransport credentialHttpTransport; + private final String credentialEncryptionKey; + private final Map services = new ConcurrentHashMap<>(); + + /** Create with a config map (agent name -> scheme name -> scheme config). */ + public AgentAuthManager(Map>> config) { + this(config, null); + } + + /** Create with a config map and an explicitly configured login HTTP client. */ + public AgentAuthManager( + Map>> config, HttpClient credentialHttpClient) { + this(config, credentialHttpClient, null); + } + + AgentAuthManager( + Map>> config, + HttpClient credentialHttpClient, + String credentialEncryptionKey) { + this(config, credentialHttpClient, null, credentialEncryptionKey); + } + + AgentAuthManager( + Map>> config, + CredentialHttpTransport credentialHttpTransport, + String credentialEncryptionKey) { + this(config, null, credentialHttpTransport, credentialEncryptionKey); + } + + private AgentAuthManager( + Map>> config, + HttpClient credentialHttpClient, + CredentialHttpTransport credentialHttpTransport, + String credentialEncryptionKey) { + this.config = config != null ? config : new HashMap<>(); + this.credentialHttpClient = credentialHttpClient; + this.credentialHttpTransport = credentialHttpTransport; + this.credentialEncryptionKey = credentialEncryptionKey; + validateEncryptedCredentials(this.config, credentialEncryptionKey); + if (!this.config.isEmpty()) { + log.info( + "[Auth] Loaded credentials for {} agent(s): {}", + this.config.size(), + new ArrayList<>(this.config.keySet())); + } + } + + /** Create by loading credentials from a JSON file. */ + public AgentAuthManager(String configPath) { + this(loadFromFile(configPath), null); + } + + /** Create from a JSON file and use the supplied client for login requests. */ + public AgentAuthManager(String configPath, HttpClient credentialHttpClient) { + this(loadFromFile(configPath), credentialHttpClient); + } + + AgentAuthManager( + String configPath, HttpClient credentialHttpClient, String credentialEncryptionKey) { + this(loadFromFile(configPath), credentialHttpClient, credentialEncryptionKey); + } + + AgentAuthManager( + String configPath, + CredentialHttpTransport credentialHttpTransport, + String credentialEncryptionKey) { + this(loadFromFile(configPath), credentialHttpTransport, credentialEncryptionKey); + } + + /** Create with no credentials (auth disabled). */ + public AgentAuthManager() { + this(new HashMap<>()); + } + + private static Map>> loadFromFile(String path) { + Map>> loaded = CredentialConfigLoader.load(path); + log.info("[Auth] Loaded credentials for {} agent(s) from {}", loaded.size(), path); + return loaded; + } + + private static List extractExtensionUris(AgentCard agentCard) { + List uris = new ArrayList<>(); + var extensions = agentCard.capabilities().extensions(); + if (extensions == null) { + return uris; + } + for (var ext : extensions) { + String uri = ext.uri(); + if (!uri.isEmpty()) { + uris.add(uri); + } + } + return uris; + } + + /** Get or create a credential service for the given agent. */ + public AgentCredentialService getService(String agentName) { + return services.computeIfAbsent( + agentName, + name -> { + Map> agentCreds = config.get(name); + if (agentCreds == null) { + return null; + } + log.info("[Auth] Created credential service for agent: {}", name); + return new AgentCredentialService( + name, + agentCreds, + credentialHttpClient, + credentialHttpTransport, + credentialEncryptionKey); + }); + } + + /** Get the raw config for an agent. */ + public Map> getConfig(String agentName) { + return config.get(agentName); + } + + private static void validateEncryptedCredentials( + Map values, String credentialEncryptionKey) { + for (Object value : values.values()) { + if (value instanceof Map nested) { + validateEncryptedCredentials(nested, credentialEncryptionKey); + } else if (value instanceof String text && text.startsWith("enc:")) { + CredentialCrypto.decryptIfNeeded(text, credentialEncryptionKey); + } + } + } + + /** Build the extension-header contributor for an AgentCard, if it declares extensions. */ + public ExtensionInterceptor buildExtensionInterceptor(AgentCard agentCard) { + List extUris = extractExtensionUris(agentCard); + return extUris.isEmpty() ? null : new ExtensionInterceptor(extUris); + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialConfigLoader.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialConfigLoader.java new file mode 100644 index 00000000..cc5f16dd --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialConfigLoader.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.File; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Loads and resolves reusable per-agent authentication profiles. */ +final class CredentialConfigLoader { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference> ROOT_TYPE = new TypeReference<>() {}; + private static final TypeReference>>> RESOLVED_TYPE = + new TypeReference<>() {}; + + private CredentialConfigLoader() {} + + /** + * Loads either the original agent-keyed form or the profile form: {@code {"profiles": {...}, + * "agents": {"agent": {"profile": "name"}}}}. + */ + static Map>> load(String path) { + if (path == null || path.isBlank()) { + return Map.of(); + } + try { + Map root = readRoot(path); + if (root.containsKey("profiles") || root.containsKey("agents")) { + return resolveProfiles(root); + } + return MAPPER.convertValue(root, RESOLVED_TYPE); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to load credentials from " + path + ": " + e.getMessage(), e); + } + } + + private static Map readRoot(String path) throws Exception { + if (path.startsWith("classpath:")) { + String resource = path.substring("classpath:".length()); + try (InputStream input = + CredentialConfigLoader.class.getClassLoader().getResourceAsStream(resource)) { + if (input == null) { + throw new IllegalStateException("Credentials classpath resource not found: " + resource); + } + return MAPPER.readValue(input, ROOT_TYPE); + } + } + File file = new File(path); + if (!file.isFile()) { + throw new IllegalStateException("Credentials file not found: " + path); + } + return MAPPER.readValue(file, ROOT_TYPE); + } + + private static Map>> resolveProfiles( + Map root) { + Map>> profiles = + convertProfiles(requiredMap(root.get("profiles"), "profiles")); + Map agents = requiredMap(root.get("agents"), "agents"); + Map>> resolved = new LinkedHashMap<>(); + for (Map.Entry entry : agents.entrySet()) { + Map binding = requiredMap(entry.getValue(), "agents." + entry.getKey()); + String profileName = requiredText(binding.get("profile"), "profile for " + entry.getKey()); + Map> profile = profiles.get(profileName); + if (profile == null) { + throw new IllegalArgumentException( + "Unknown credential profile '" + profileName + "' for agent " + entry.getKey()); + } + Map> agentConfig = deepCopy(profile); + Object overridesValue = binding.get("overrides"); + if (overridesValue != null) { + Map overrides = + requiredMap(overridesValue, "agents." + entry.getKey() + ".overrides"); + for (Map.Entry override : overrides.entrySet()) { + Map schemeConfig = + agentConfig.computeIfAbsent(override.getKey(), ignored -> new LinkedHashMap<>()); + mergeInto( + schemeConfig, + requiredMap( + override.getValue(), + "agents." + entry.getKey() + ".overrides." + override.getKey())); + } + } + resolved.put(entry.getKey(), agentConfig); + } + return resolved; + } + + private static Map>> convertProfiles( + Map rawProfiles) { + Map>> result = new LinkedHashMap<>(); + for (Map.Entry entry : rawProfiles.entrySet()) { + Map rawSchemes = requiredMap(entry.getValue(), "profiles." + entry.getKey()); + Map> schemes = new LinkedHashMap<>(); + for (Map.Entry scheme : rawSchemes.entrySet()) { + schemes.put( + scheme.getKey(), + new LinkedHashMap<>( + requiredMap( + scheme.getValue(), "profiles." + entry.getKey() + "." + scheme.getKey()))); + } + result.put(entry.getKey(), schemes); + } + return result; + } + + private static Map> deepCopy( + Map> source) { + Map> copy = new LinkedHashMap<>(); + source.forEach((name, config) -> copy.put(name, deepCopyMap(config))); + return copy; + } + + private static void mergeInto(Map target, Map overrides) { + overrides.forEach( + (key, value) -> { + Object existing = target.get(key); + if (existing instanceof Map existingMap && value instanceof Map overrideMap) { + Map merged = deepCopyMap(requiredMap(existingMap, key)); + mergeInto(merged, requiredMap(overrideMap, key)); + target.put(key, merged); + } else { + target.put(key, deepCopyValue(value)); + } + }); + } + + private static Map deepCopyMap(Map source) { + Map copy = new LinkedHashMap<>(); + source.forEach((key, value) -> copy.put(key, deepCopyValue(value))); + return copy; + } + + private static Object deepCopyValue(Object value) { + if (value instanceof Map map) { + return deepCopyMap(requiredMap(map, "nested value")); + } + return value; + } + + @SuppressWarnings("unchecked") + private static Map requiredMap(Object value, String name) { + if (!(value instanceof Map map)) { + throw new IllegalArgumentException(name + " must be an object"); + } + return (Map) map; + } + + private static String requiredText(Object value, String name) { + if (value == null || String.valueOf(value).isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return String.valueOf(value).trim(); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentAuthManagerTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentAuthManagerTest.java index 87fd7d42..18952e40 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentAuthManagerTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentAuthManagerTest.java @@ -11,50 +11,61 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import org.junit.jupiter.api.Test; - import java.net.URL; +import org.junit.jupiter.api.Test; class AgentAuthManagerTest { - @Test - void loadsCredentialsFromClasspathLocation() throws Exception { - URL resource = getClass().getClassLoader().getResource("test-agent-credentials.json"); - assertNotNull(resource, "test resource must exist on classpath"); - AgentAuthManager manager = new AgentAuthManager(resource.getPath()); - - var config = manager.getConfig("Test Agent"); - - assertNotNull(config); - assertEquals( - "https://auth.example.test/token", - config.get("bearerAuth").get("login_url")); - } - - @Test - void loadsCredentialsFromClasspathPrefix() { - AgentAuthManager manager = - new AgentAuthManager("classpath:test-agent-credentials.json"); - - var config = manager.getConfig("Test Agent"); - - assertNotNull(config); - assertEquals( - "https://auth.example.test/token", - config.get("bearerAuth").get("login_url")); - } - - @Test - void missingFileFailsClosed() { - assertThrows( - IllegalStateException.class, - () -> new AgentAuthManager("/nonexistent/missing-agent-credentials.json")); - } - - @Test - void missingClasspathResourceFailsClosed() { - assertThrows( - IllegalStateException.class, - () -> new AgentAuthManager("classpath:nonexistent-credentials.json")); - } + @Test + void loadsCredentialsFromClasspathLocation() throws Exception { + URL resource = getClass().getClassLoader().getResource("test-agent-credentials.json"); + assertNotNull(resource, "test resource must exist on classpath"); + AgentAuthManager manager = new AgentAuthManager(resource.getPath()); + + var config = manager.getConfig("Test Agent"); + + assertNotNull(config); + assertEquals("https://auth.example.test/token", config.get("bearerAuth").get("login_url")); + } + + @Test + void loadsCredentialsFromClasspathPrefix() { + AgentAuthManager manager = new AgentAuthManager("classpath:test-agent-credentials.json"); + + var config = manager.getConfig("Test Agent"); + + assertNotNull(config); + assertEquals("https://auth.example.test/token", config.get("bearerAuth").get("login_url")); + } + + @Test + void resolvesSharedProfilesAndPerAgentOverrides() { + AgentAuthManager manager = new AgentAuthManager("classpath:test-profile-credentials.json"); + + var city1 = manager.getConfig("City 1").get("bearerAuth"); + var city2 = manager.getConfig("City 2").get("bearerAuth"); + + assertEquals("https://city1.example.test/token", city1.get("login_url")); + assertEquals("https://shared.example.test/token", city2.get("login_url")); + assertEquals( + "shared-user", ((java.util.Map) city1.get("request_fields")).get("userName")); + assertEquals( + "city1-password", ((java.util.Map) city1.get("request_fields")).get("value")); + assertEquals( + "shared-password", ((java.util.Map) city2.get("request_fields")).get("value")); + } + + @Test + void missingFileFailsClosed() { + assertThrows( + IllegalStateException.class, + () -> new AgentAuthManager("/nonexistent/missing-agent-credentials.json")); + } + + @Test + void missingClasspathResourceFailsClosed() { + assertThrows( + IllegalStateException.class, + () -> new AgentAuthManager("classpath:nonexistent-credentials.json")); + } } diff --git a/workflow-engine/src/test/resources/test-profile-credentials.json b/workflow-engine/src/test/resources/test-profile-credentials.json new file mode 100644 index 00000000..1ace62db --- /dev/null +++ b/workflow-engine/src/test/resources/test-profile-credentials.json @@ -0,0 +1,30 @@ +{ + "profiles": { + "shared": { + "bearerAuth": { + "login_url": "https://shared.example.test/token", + "request_fields": { + "userName": "shared-user", + "value": "shared-password" + }, + "token_field": "accessSession" + } + } + }, + "agents": { + "City 1": { + "profile": "shared", + "overrides": { + "bearerAuth": { + "login_url": "https://city1.example.test/token", + "request_fields": { + "value": "city1-password" + } + } + } + }, + "City 2": { + "profile": "shared" + } + } +} From 5393eca8f53550c6c7538d474f2f85d83d96695d Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Sat, 29 Aug 2026 10:28:55 +0800 Subject: [PATCH 40/49] fix(samples): isolate pre-workflow protocol failures Signed-off-by: Zhoujie628 --- .../extension/ExtensionPrePositioner.java | 364 ++++++++++-------- .../SpringWorkbenchExtensionLifecycle.java | 38 +- .../WorkbenchExtensionLifecycle.java | 65 ++-- .../extension/ExtensionPrePositionerTest.java | 152 ++++++++ 4 files changed, 385 insertions(+), 234 deletions(-) create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositionerTest.java 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 c1c0c0ef..2bd32ffd 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 @@ -23,16 +23,14 @@ import dev.openan.workflow.engine.client.NotificationSubscription; import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; import dev.openan.workflow.engine.model.SendMessageResult; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.util.List; import java.util.Map; -import java.util.function.Consumer; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import net.openan.a2at.sdk.core.model.StandardTemplates; +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Initializes independent Authorization-T and Notification-T operations for downstream agents. @@ -43,181 +41,211 @@ */ public class ExtensionPrePositioner { - private static final Logger log = LoggerFactory.getLogger(ExtensionPrePositioner.class); - private static final int AUTHORIZATION_VALIDATION_ATTEMPTS = 3; + private static final Logger log = LoggerFactory.getLogger(ExtensionPrePositioner.class); + private static final int AUTHORIZATION_VALIDATION_ATTEMPTS = 3; - private final Map authData; - private final Map authSchema; - private final Map notifData; - private final Map notifSchema; + private final Map authData; + private final Map authSchema; + private final Map notifData; + private final Map notifSchema; - public ExtensionPrePositioner() { - // Structured-data track: the raw policy / subscription fields; the SDK renders the - // Authorization-T and Notification-T prompts through the SDK schema-aware pipelines - // (spec cases 7.5/7.8; slot extraction may use the SDK-configured LLM). - this.authData = SpnCasePrompts.addAuthorizationData(); - this.authSchema = SdkSlotSchemaLoader.loadConfigured( - StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); - this.notifData = SpnCasePrompts.subscribeServiceRecoveryData(); - this.notifSchema = SdkSlotSchemaLoader.loadConfigured(StandardTemplates.SERVICE_RECOVERY); - } + public ExtensionPrePositioner() { + // Structured-data track: the raw policy / subscription fields; the SDK renders the + // Authorization-T and Notification-T prompts through the SDK schema-aware pipelines + // (spec cases 7.5/7.8; slot extraction may use the SDK-configured LLM). + this.authData = SpnCasePrompts.addAuthorizationData(); + this.authSchema = + SdkSlotSchemaLoader.loadConfigured(StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + this.notifData = SpnCasePrompts.subscribeServiceRecoveryData(); + this.notifSchema = SdkSlotSchemaLoader.loadConfigured(StandardTemplates.SERVICE_RECOVERY); + } - /** - * Pre-position Authorization-T + Notification-T to every non-workbench agent. - * - *

        When {@code notificationCallback} is non-null, subsequent events pushed by agents - * through the Notification-T SSE stream (e.g. recovery results) are forwarded to the - * callback. The callback receives a Map with keys: agent, text, metadata, state. - */ - public List prePosition( - ExtensionSender authorizationSender, - ExtensionSender notificationSender, - List agentCards, - Consumer> notificationCallback) { - long allStarted = System.nanoTime(); - int targetCount = 0; - for (AgentCard card : agentCards) { - if (!card.name().contains("Workbench")) { - targetCount++; - } - } + /** + * Pre-position Authorization-T + Notification-T to every non-workbench agent. + * + *

        When {@code notificationCallback} is non-null, subsequent events pushed by agents through + * the Notification-T SSE stream (e.g. recovery results) are forwarded to the callback. The + * callback receives a Map with keys: agent, text, metadata, state. + */ + public List prePosition( + ExtensionSender authorizationSender, + ExtensionSender notificationSender, + List agentCards, + Consumer> notificationCallback) { + return prePosition( + authorizationSender, notificationSender, agentCards, notificationCallback, null); + } + + /** + * Pre-position extensions and transfer each opened subscription to its lifecycle owner before + * waiting for the subscription acknowledgement. + */ + public List prePosition( + ExtensionSender authorizationSender, + ExtensionSender notificationSender, + List agentCards, + Consumer> notificationCallback, + Consumer subscriptionOpened) { + long allStarted = System.nanoTime(); + int targetCount = 0; + for (AgentCard card : agentCards) { + if (!card.name().contains("Workbench")) { + targetCount++; + } + } + log.info( + "[PrePosition] START targetAgents={}, notificationCallback={}", + targetCount, + notificationCallback != null); + java.util.ArrayList subscriptions = new java.util.ArrayList<>(); + int authorizationFailures = 0; + int notificationFailures = 0; + for (AgentCard card : agentCards) { + String name = card.name(); + if (name.contains("Workbench")) { + continue; + } + long agentStarted = System.nanoTime(); + long operationStarted = System.nanoTime(); + try { + log.info("[PrePosition] SEND extension=Authorization-T, agent={}", name); + SendMessageResult authResult = sendAuthorization(authorizationSender, name); log.info( - "[PrePosition] START targetAgents={}, notificationCallback={}", - targetCount, - notificationCallback != null); - java.util.ArrayList subscriptions = new java.util.ArrayList<>(); - for (AgentCard card : agentCards) { - String name = card.name(); - if (name.contains("Workbench")) { - continue; - } - long agentStarted = System.nanoTime(); - try { - long operationStarted = System.nanoTime(); - log.info("[PrePosition] SEND extension=Authorization-T, agent={}", name); - SendMessageResult authResult = sendAuthorization(authorizationSender, name); - log.info( - "[PrePosition] ACK extension=Authorization-T, agent={}, state={}, " - + "responseChars={}, elapsedMs={}", - name, - authResult.getTaskState(), - authResult.getText() != null ? authResult.getText().length() : 0, - elapsedMillis(operationStarted)); - requireState( - authResult, - "Authorization-T", - name, - "TASK_STATE_COMPLETED"); + "[PrePosition] ACK extension=Authorization-T, agent={}, state={}, " + + "responseChars={}, elapsedMs={}", + name, + authResult.getTaskState(), + authResult.getText() != null ? authResult.getText().length() : 0, + elapsedMillis(operationStarted)); + requireState(authResult, "Authorization-T", name, "TASK_STATE_COMPLETED"); + } catch (RuntimeException e) { + authorizationFailures++; + log.warn( + "[PrePosition] OPERATION_FAILED extension=Authorization-T, agent={}, " + + "elapsedMs={}, errorType={}, message={}, action=continue", + name, + elapsedMillis(operationStarted), + e.getClass().getSimpleName(), + e.getMessage()); + } - operationStarted = System.nanoTime(); - log.info("[PrePosition] SEND extension=Notification-T, agent={}", name); - NotificationSubscription subscription = - notificationSender.openNotificationFromData( - name, - "订阅业务抢通事件", - notifData, - notifSchema, - net.openan.a2at.sdk.core.model.StandardTemplates - .SERVICE_RECOVERY, - notificationCallback) - .join(); - subscriptions.add(subscription); - var notificationResult = subscription.acknowledgement().join(); - requireState( - notificationResult, - "Notification-T", - name, - "TASK_STATE_WORKING", - "TASK_STATE_COMPLETED"); - log.info( - "[PrePosition] ACK extension=Notification-T, agent={}, state={}, " - + "responseChars={}, elapsedMs={}", - name, - notificationResult.getTaskState(), - notificationResult.getText() != null - ? notificationResult.getText().length() - : 0, - elapsedMillis(operationStarted)); - log.info( - "[PrePosition] AGENT_DONE agent={}, elapsedMs={}", - name, - elapsedMillis(agentStarted)); - } catch (RuntimeException e) { - log.error( - "[PrePosition] AGENT_FAILED agent={}, elapsedMs={}, errorType={}, message={}", - name, - elapsedMillis(agentStarted), - e.getClass().getSimpleName(), - e.getMessage(), - e); - throw e; - } + operationStarted = System.nanoTime(); + NotificationSubscription subscription = null; + try { + log.info("[PrePosition] SEND extension=Notification-T, agent={}", name); + subscription = + notificationSender + .openNotificationFromData( + name, + "订阅业务抢通事件", + notifData, + notifSchema, + net.openan.a2at.sdk.core.model.StandardTemplates.SERVICE_RECOVERY, + notificationCallback) + .join(); + if (subscriptionOpened != null) { + subscriptionOpened.accept(subscription); } + var notificationResult = subscription.acknowledgement().join(); + requireState( + notificationResult, + "Notification-T", + name, + "TASK_STATE_WORKING", + "TASK_STATE_COMPLETED"); + subscriptions.add(subscription); + log.info( + "[PrePosition] ACK extension=Notification-T, agent={}, state={}, " + + "responseChars={}, elapsedMs={}", + name, + notificationResult.getTaskState(), + notificationResult.getText() != null ? notificationResult.getText().length() : 0, + elapsedMillis(operationStarted)); log.info( - "[PrePosition] DONE targetAgents={}, elapsedMs={}", - targetCount, - elapsedMillis(allStarted)); - return List.copyOf(subscriptions); + "[PrePosition] AGENT_DONE agent={}, elapsedMs={}", name, elapsedMillis(agentStarted)); + } catch (RuntimeException e) { + notificationFailures++; + if (subscription != null) { + subscription.close(); + } + log.warn( + "[PrePosition] OPERATION_FAILED extension=Notification-T, agent={}, " + + "elapsedMs={}, errorType={}, message={}, action=continue", + name, + elapsedMillis(operationStarted), + e.getClass().getSimpleName(), + e.getMessage()); + } } + log.info( + "[PrePosition] DONE targetAgents={}, activeSubscriptions={}, " + + "authorizationFailures={}, notificationFailures={}, elapsedMs={}", + targetCount, + subscriptions.size(), + authorizationFailures, + notificationFailures, + elapsedMillis(allStarted)); + return List.copyOf(subscriptions); + } - private SendMessageResult sendAuthorization(ExtensionSender sender, String agentName) { - SendMessageResult result = null; - for (int attempt = 1; attempt <= AUTHORIZATION_VALIDATION_ATTEMPTS; attempt++) { - result = sender.sendExtensionMessageFromData( - agentName, - "新增动网操作授权", - authData, - authSchema, - net.openan.a2at.sdk.core.model.StandardTemplates - .AUTHORIZATION_POLICY_MANAGEMENT, - dev.openan.workflow.engine.client.A2ATExtension.AUTHORIZATION_T) - .join(); - boolean retryableValidationFailure = - "TASK_STATE_FAILED".equals(result.getTaskState()) - && "Authorization-T validation failed".equals(result.getText()); - if (!retryableValidationFailure || attempt == AUTHORIZATION_VALIDATION_ATTEMPTS) { - return result; - } - log.warn( - "[PrePosition] RETRY extension=Authorization-T, agent={}, attempt={}/{}, reason=validator_mismatch", - agentName, - attempt + 1, - AUTHORIZATION_VALIDATION_ATTEMPTS); - } + private SendMessageResult sendAuthorization(ExtensionSender sender, String agentName) { + SendMessageResult result = null; + for (int attempt = 1; attempt <= AUTHORIZATION_VALIDATION_ATTEMPTS; attempt++) { + result = + sender + .sendExtensionMessageFromData( + agentName, + "新增动网操作授权", + authData, + authSchema, + net.openan.a2at.sdk.core.model.StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT, + dev.openan.workflow.engine.client.A2ATExtension.AUTHORIZATION_T) + .join(); + boolean retryableValidationFailure = + "TASK_STATE_FAILED".equals(result.getTaskState()) + && "Authorization-T validation failed".equals(result.getText()); + if (!retryableValidationFailure || attempt == AUTHORIZATION_VALIDATION_ATTEMPTS) { return result; + } + log.warn( + "[PrePosition] RETRY extension=Authorization-T, agent={}, attempt={}/{}," + + " reason=validator_mismatch", + agentName, + attempt + 1, + AUTHORIZATION_VALIDATION_ATTEMPTS); } + return result; + } - /** Pre-position through explicitly isolated Authorization-T and Notification-T senders. */ - public List prePosition( - ExtensionSender authorizationSender, - ExtensionSender notificationSender, - List agentCards) { - return prePosition(authorizationSender, notificationSender, agentCards, null); - } + /** Pre-position through explicitly isolated Authorization-T and Notification-T senders. */ + public List prePosition( + ExtensionSender authorizationSender, + ExtensionSender notificationSender, + List agentCards) { + return prePosition(authorizationSender, notificationSender, agentCards, null); + } - private static long elapsedMillis(long startedNanos) { - return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); - } + private static 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")); - } + 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/workbench/SpringWorkbenchExtensionLifecycle.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExtensionLifecycle.java index 283ce015..94d1c113 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 @@ -1,37 +1,19 @@ /* * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. */ - package dev.openan.workflow.engine.examples.workbench; -import dev.openan.workflow.engine.examples.util.EnvResolver; import dev.openan.workflow.engine.examples.config.WorkbenchClientProperties; -import dev.openan.workflow.engine.examples.workbench.WorkbenchExtensionLifecycle; +import dev.openan.workflow.engine.examples.util.EnvResolver; import jakarta.annotation.PreDestroy; - +import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; -import java.util.Map; - /** Keeps Authorization-T/Notification-T protocol lifecycles independent from individual tasks. */ @Component public final class SpringWorkbenchExtensionLifecycle { @@ -39,7 +21,6 @@ public final class SpringWorkbenchExtensionLifecycle { LoggerFactory.getLogger(SpringWorkbenchExtensionLifecycle.class); private final WorkbenchClientProperties properties; - private WorkbenchExtensionLifecycle lifecycle; public SpringWorkbenchExtensionLifecycle(WorkbenchClientProperties properties) { @@ -58,8 +39,19 @@ public synchronized void start() { resolveEnvPath(), null, this::onNotification); - candidate.start(); - lifecycle = candidate; + try { + candidate.start(); + lifecycle = candidate; + } catch (RuntimeException e) { + candidate.close(); + lifecycle = null; + log.warn( + "[ExtensionLifecycle] PREPOSITION_FAILED errorType={}, message={}, " + + "action=continue-workflow", + e.getClass().getSimpleName(), + e.getMessage(), + e); + } } @PreDestroy 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 99bb52ee..9cc184cc 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 @@ -9,25 +9,19 @@ import dev.openan.workflow.engine.client.DefaultExtensionSender; import dev.openan.workflow.engine.client.NotificationSubscription; import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - +import dev.openan.workflow.engine.examples.extension.ExtensionPrePositioner; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import dev.openan.workflow.engine.examples.extension.ExtensionPrePositioner; -/** - * Owns workbench-scoped Authorization-T and Notification-T resources outside the workflow DAG. - * - *

        Authorization-T is sent once and its request session is released after its response. - * Notification-T streams remain owned by this lifecycle until {@link #close()} is called by the - * workbench service lifecycle; task completion never closes them. - */ +/** 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); @@ -36,9 +30,7 @@ public final class WorkbenchExtensionLifecycle implements AutoCloseable { private final String a2atEnvPath; private final Supplier runtimeSupplier; private final Consumer> notificationCallback; - - private final Map subscriptions = - new ConcurrentHashMap<>(); + private final Map subscriptions = new ConcurrentHashMap<>(); private A2ATransport notificationTransport; public WorkbenchExtensionLifecycle( @@ -61,7 +53,6 @@ public synchronized void start() { notificationTransport.getContextId()); return; } - List agentCards = new WorkbenchAgentCatalog().load(); A2AJavaClientRuntime authorizationRuntime = runtimeSupplier != null ? runtimeSupplier.get() : null; @@ -78,10 +69,7 @@ public synchronized void start() { .credentialsConfigPath(credentialsPath) .build(); A2ATransport authorizationTransport = - new A2ATransport( - agentCards, - authorizationRuntime, - config); + new A2ATransport(agentCards, authorizationRuntime, config); A2ATransport notificationCandidate = new A2ATransport(agentCards, notificationRuntime, config); long started = System.nanoTime(); @@ -92,13 +80,19 @@ public synchronized void start() { try { List opened = new ExtensionPrePositioner() - .prePosition( - new DefaultExtensionSender(authorizationTransport), - new DefaultExtensionSender(notificationCandidate), - agentCards, - this::handleNotification); - opened.forEach(subscription -> subscriptions.put( - subscription.agentName(), subscription)); + .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( @@ -111,13 +105,6 @@ public synchronized void start() { subscriptions.clear(); authorizationTransport.close(); notificationCandidate.close(); - log.error( - "[ExtensionLifecycle] START_FAILED authorizationContextId={}, notificationContextId={}, errorType={}, message={}", - authorizationTransport.getContextId(), - notificationCandidate.getContextId(), - e.getClass().getSimpleName(), - e.getMessage(), - e); throw e; } } @@ -133,16 +120,8 @@ public synchronized void close() { if (active == null) { return; } - log.info( - "[ExtensionLifecycle] CLOSE_START contextId={}, reason=workbench_shutdown", - active.getContextId()); - // Let the transport own cancellation and wait for the underlying stream threads before - // the Order simulator or web container can be stopped. active.close(); subscriptions.clear(); - log.info( - "[ExtensionLifecycle] CLOSE_DONE contextId={}, reason=workbench_shutdown", - active.getContextId()); } private void handleNotification(Map data) { diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositionerTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositionerTest.java new file mode 100644 index 00000000..e14d6ebc --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositionerTest.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openan.workflow.engine.client.ExtensionSender; +import dev.openan.workflow.engine.client.NotificationSubscription; +import dev.openan.workflow.engine.examples.workbench.WorkbenchAgentCatalog; +import dev.openan.workflow.engine.model.SendMessageResult; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class ExtensionPrePositionerTest { + + @Test + void notificationFailureDoesNotBecomeAWorkflowStartupCondition() { + AtomicBoolean authorizationAttempted = new AtomicBoolean(); + ExtensionSender authorizationSender = + sender( + (method, args) -> { + if ("sendExtensionMessageFromData".equals(method.getName())) { + authorizationAttempted.set(true); + return CompletableFuture.completedFuture( + SendMessageResult.builder() + .taskState("TASK_STATE_COMPLETED") + .text("authorized") + .build()); + } + throw new UnsupportedOperationException(method.getName()); + }); + ExtensionSender notificationSender = + sender( + (method, args) -> { + if ("openNotificationFromData".equals(method.getName())) { + return CompletableFuture.failedFuture( + new IllegalStateException("subscription rejected")); + } + throw new UnsupportedOperationException(method.getName()); + }); + + List subscriptions = + new ExtensionPrePositioner() + .prePosition( + authorizationSender, + notificationSender, + List.of(city1Card()), + ignored -> {}); + + assertTrue(authorizationAttempted.get()); + assertTrue(subscriptions.isEmpty()); + } + + @Test + void authorizationFailureDoesNotBlockSubscriptionOrWorkflowStartup() throws Exception { + NotificationSubscription subscription = newSubscription("SPN Domain Agent City1"); + AtomicBoolean notificationAttempted = new AtomicBoolean(); + ExtensionSender authorizationSender = + sender( + (method, args) -> { + if ("sendExtensionMessageFromData".equals(method.getName())) { + return CompletableFuture.failedFuture( + new IllegalStateException("authorization rejected")); + } + throw new UnsupportedOperationException(method.getName()); + }); + ExtensionSender notificationSender = + sender( + (method, args) -> { + if ("openNotificationFromData".equals(method.getName())) { + notificationAttempted.set(true); + return CompletableFuture.completedFuture(subscription); + } + throw new UnsupportedOperationException(method.getName()); + }); + CountDownLatch registered = new CountDownLatch(1); + AtomicReference registeredSubscription = new AtomicReference<>(); + + CompletableFuture> result = + CompletableFuture.supplyAsync( + () -> + new ExtensionPrePositioner() + .prePosition( + authorizationSender, + notificationSender, + List.of(city1Card()), + ignored -> {}, + opened -> { + registeredSubscription.set(opened); + registered.countDown(); + })); + + assertTrue(registered.await(5, TimeUnit.SECONDS)); + assertFalse(result.isDone(), "registration must happen before waiting for the ACK"); + acknowledge(subscription, "TASK_STATE_WORKING"); + + assertEquals(List.of(subscription), result.get(5, TimeUnit.SECONDS)); + assertTrue(notificationAttempted.get()); + assertSame(subscription, registeredSubscription.get()); + } + + private static org.a2aproject.sdk.spec.AgentCard city1Card() { + return new WorkbenchAgentCatalog() + .load().stream() + .filter(card -> "SPN Domain Agent City1".equals(card.name())) + .findFirst() + .orElseThrow(); + } + + private static NotificationSubscription newSubscription(String agentName) throws Exception { + Constructor constructor = + NotificationSubscription.class.getDeclaredConstructor( + String.class, String.class, Runnable.class); + constructor.setAccessible(true); + return constructor.newInstance(agentName, "notification-context", (Runnable) () -> {}); + } + + private static void acknowledge(NotificationSubscription subscription, String state) + throws Exception { + Method acknowledge = + NotificationSubscription.class.getDeclaredMethod("acknowledge", SendMessageResult.class); + acknowledge.setAccessible(true); + acknowledge.invoke( + subscription, SendMessageResult.builder().taskState(state).text("subscribed").build()); + } + + private static ExtensionSender sender(Invocation invocation) { + return (ExtensionSender) + Proxy.newProxyInstance( + ExtensionSender.class.getClassLoader(), + new Class[] {ExtensionSender.class}, + (proxy, method, args) -> invocation.invoke(method, args)); + } + + @FunctionalInterface + private interface Invocation { + Object invoke(Method method, Object[] args) throws Throwable; + } +} From 8e268385d22926738e8ebf837729673361f0966e Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Sat, 29 Aug 2026 10:34:15 +0800 Subject: [PATCH 41/49] docs(samples): clarify independent pre-workflow operations Signed-off-by: Zhoujie628 --- ...06\346\210\220\346\214\207\345\215\227.md" | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" index 9d1aa8bb..a3c4c699 100644 --- "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" @@ -38,7 +38,7 @@ SDK 在该场景中承载四类 A2A-T 交互,对应业务流文档的四个接 | 3 | Authorization-T | 抢通授权 | 工作台 -> OMC | 工作流启动前,前置预定位 | | 4 | Notification-T | 抢通结果订阅/上报 | 工作台 -> OMC (订阅) / OMC -> 工作台 (SSE 上报) | 工作流启动前订阅,执行中上报 | -序号 1、2 在工作流执行过程中发生,由 SDK 的 `WorkflowEngineClient` 承载。序号 3、4 是前置动作,在工作流启动前完成,由 SDK 的 `ExtensionSender` 承载。两条路径使用同一类底层传输组件 `A2ATransport`,但生命周期不同:工作流发送使用任务级 transport,Notification-T 订阅使用工作台级 transport,避免工作流结束时关闭长连接。 +序号 1、2 在工作流执行过程中发生,由 SDK 的 `WorkflowEngineClient` 承载。序号 3、4 是流程外的独立操作,Demo 只是在工作流启动前尝试,由 SDK 的 `ExtensionSender` 承载;这个时间顺序不构成因果依赖,授权或订阅失败均不得阻止后续 Task-T 工作流。各路径使用同一类底层传输组件 `A2ATransport`,但 runtime、context 和生命周期互相隔离:工作流发送使用任务级 transport,Notification-T 订阅使用工作台级 transport,避免工作流结束时关闭长连接。 ### 1.3 SDK 自动处理 vs 工作台实现 @@ -709,24 +709,28 @@ private void prePositionExtensions(ExtensionSender sender, List agent + "溢权策略 OMC 自动执行," + "触发执行条件业务投诉诊断确认故障," + "预期输出返回是否成功。"; - sender.sendAuthorization(name, "下发授权放行策略", authInput).join(); + try { + sender.sendAuthorization(name, "下发授权放行策略", authInput).join(); + } catch (RuntimeException e) { + recordAuthorizationFailure(name, e); // 只影响本次授权,不阻止订阅或工作流 + } // 订阅预定位: 向 OMC 订阅业务抢通结果通知 String notifInput = "通知主题为 service-recovery-execution-result," + "订阅条件为业务抢通方案执行结果," + "上报通知数据格式为 TextPart。"; - sender.sendNotification(name, "订阅业务抢通结果通知", notifInput, event -> { - // SSE 长连接回调: OMC 后续推送的抢通结果通过此通道接收 - Object text = event.get("text"); - Object state = event.get("state"); - log.info("[抢通结果] agent={}, state={}, text={} chars", - event.get("agent"), state, - text != null ? String.valueOf(text).length() : 0); - if (text != null) { - // 生产环境: 写入数据库 / 推送前端 / 触发后续流程 - log.info("[抢通结果] {}: {}", event.get("agent"), text); - } - }).join(); + try { + sender.sendNotification(name, "订阅业务抢通结果通知", notifInput, event -> { + // SSE 长连接回调: OMC 后续推送的抢通结果通过此通道接收 + Object text = event.get("text"); + Object state = event.get("state"); + log.info("[抢通结果] agent={}, state={}, text={} chars", + event.get("agent"), state, + text != null ? String.valueOf(text).length() : 0); + }).join(); + } catch (RuntimeException e) { + recordSubscriptionFailure(name, e); // 只影响该订阅,不阻止工作流 + } } } ``` @@ -736,6 +740,8 @@ private void prePositionExtensions(ExtensionSender sender, List agent 这两个方法返回 `CompletableFuture`,`.join()` 阻塞等待首个事件 (订阅确认) 完成。SDK 内部自动处理 Authorization-T / Notification-T 报文构造和认证头注入(来自 `AuthProvider` 或 credentials 配置)。对于 `sendNotification`,`.join()` 返回后 SSE 长连接仍然存活,后续事件持续推送到回调,直到工作台主动关闭 transport。 +授权和订阅必须分别捕获、分别记录结果:授权失败仍要尝试订阅,订阅失败不回滚授权;两者任何一种结果都不能作为工作流启动条件。已经打开的订阅应在等待 ACK 前登记到工作台生命周期,确保后续步骤异常时仍能精确关闭资源。 + #### 5.2 PSOP 工作流搜索与加载 工作流 (PSOP) 存储在编排中心。SDK 提供搜索和加载方法: From 25b0ec50a4252c1859ffb840e1320f9e46bf27b9 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Sat, 29 Aug 2026 16:32:18 +0800 Subject: [PATCH 42/49] feat(samples): support external AgentCard configuration Signed-off-by: Zhoujie628 --- .env.example | 6 + .../workbench/WorkbenchAgentCatalog.java | 133 ++++++++++++------ samples/src/main/resources/application.yml | 2 +- .../workbench/WorkbenchAgentCatalogTest.java | 43 ++++++ 4 files changed, 142 insertions(+), 42 deletions(-) create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalogTest.java diff --git a/.env.example b/.env.example index 30edd071..7945f4e7 100644 --- a/.env.example +++ b/.env.example @@ -54,3 +54,9 @@ A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory # Credential encryption key (32-byte hex, used for enc: prefixed passwords in credentials JSON) # Generate with: openssl rand -hex 32 A2AT_CRED_KEY=your-32-byte-hex-key-here + +# Keep true for production HTTPS. Set false only for local/self-signed integration testing. +A2A_SSL_VERIFY=false +# Comma-separated classpath:, file: URI, or absolute file locations. Leave empty for bundled cards. +# For a live OMC, point the city AgentCards at files exported by the registry center. +A2A_AGENT_CARD_LOCATIONS= diff --git a/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 d83b228b..0601c7e1 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 @@ -6,55 +6,106 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.openan.workflow.engine.client.AgentCardJacksonModule; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - +import java.io.InputStream; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.a2aproject.sdk.spec.AgentCard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** Loads the downstream AgentCards shared by task execution and extension subscriptions. */ public final class WorkbenchAgentCatalog { - private static final Logger log = LoggerFactory.getLogger(WorkbenchAgentCatalog.class); - private static final ObjectMapper mapper = - new ObjectMapper().registerModule(new AgentCardJacksonModule()); - private static final List AGENT_CARD_RESOURCES = - List.of( - "agentcard/spn_domain_agent_city1.json", - "agentcard/spn_domain_agent_city2.json", - "agentcard/transport_workbench_agent.json"); - - public List load() { - Map byName = new LinkedHashMap<>(); - for (String resource : AGENT_CARD_RESOURCES) { - try { - try (var input = getClass().getClassLoader().getResourceAsStream(resource)) { - if (input == null) { - log.warn("[AgentCatalog] CARD_MISSING resource={}", resource); - continue; - } - AgentCard card = mapper.readValue(input, AgentCard.class); - byName.put(card.name(), card); - log.info( - "[AgentCatalog] CARD_LOADED resource={}, agent={}, interfaces={}", - resource, - card.name(), - card.supportedInterfaces() != null - ? card.supportedInterfaces().size() - : 0); - } - } catch (Exception e) { - log.warn( - "[AgentCatalog] CARD_FAILED resource={}, errorType={}, message={}", - resource, - e.getClass().getSimpleName(), - e.getMessage(), - e); + private static final Logger log = LoggerFactory.getLogger(WorkbenchAgentCatalog.class); + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(new AgentCardJacksonModule()); + private static final String LOCATIONS_PROPERTY = "A2A_AGENT_CARD_LOCATIONS"; + private static final List DEFAULT_AGENT_CARD_LOCATIONS = + List.of( + "classpath:agentcard/spn_domain_agent_city1.json", + "classpath:agentcard/spn_domain_agent_city2.json", + "classpath:agentcard/transport_workbench_agent.json"); + + private final List locations; + private final boolean explicitlyConfigured; + + public WorkbenchAgentCatalog() { + String configured = configuredLocations(); + this.explicitlyConfigured = configured != null; + this.locations = + configured == null + ? DEFAULT_AGENT_CARD_LOCATIONS + : Arrays.stream(configured.split("\\s*[,;]\\s*")) + .filter(value -> !value.isBlank()) + .toList(); + if (locations.isEmpty()) { + throw new IllegalArgumentException(LOCATIONS_PROPERTY + " must contain a location"); + } + } + + WorkbenchAgentCatalog(List locations) { + this.locations = List.copyOf(locations); + this.explicitlyConfigured = true; + } + + public List load() { + Map byName = new LinkedHashMap<>(); + for (String location : locations) { + try { + try (InputStream input = open(location)) { + if (input == null) { + if (explicitlyConfigured) { + throw new IllegalArgumentException( + "Configured AgentCard does not exist: " + location); } + log.warn("[AgentCatalog] CARD_MISSING location={}", location); + continue; + } + AgentCard card = mapper.readValue(input, AgentCard.class); + byName.put(card.name(), card); + log.info( + "[AgentCatalog] CARD_LOADED location={}, agent={}, interfaces={}", + location, + card.name(), + card.supportedInterfaces() != null ? card.supportedInterfaces().size() : 0); + } + } catch (Exception e) { + if (explicitlyConfigured) { + throw new IllegalStateException("Failed to load configured AgentCard " + location, e); } - return new ArrayList<>(byName.values()); + log.warn( + "[AgentCatalog] CARD_FAILED location={}, errorType={}, message={}", + location, + e.getClass().getSimpleName(), + e.getMessage(), + e); + } + } + return new ArrayList<>(byName.values()); + } + + private InputStream open(String location) throws Exception { + if (location.startsWith("classpath:")) { + String resource = location.substring("classpath:".length()); + return getClass().getClassLoader().getResourceAsStream(resource); + } + Path path = + location.startsWith("file:") + ? Path.of(URI.create(location)) + : Path.of(location).toAbsolutePath().normalize(); + return Files.isRegularFile(path) ? Files.newInputStream(path) : null; + } + + private static String configuredLocations() { + String value = System.getProperty(LOCATIONS_PROPERTY); + if (value == null || value.isBlank()) { + value = System.getenv(LOCATIONS_PROPERTY); } + return value == null || value.isBlank() ? null : value.trim(); + } } diff --git a/samples/src/main/resources/application.yml b/samples/src/main/resources/application.yml index 377db843..d13bab05 100644 --- a/samples/src/main/resources/application.yml +++ b/samples/src/main/resources/application.yml @@ -19,4 +19,4 @@ a2at: a2a: orch-url: https://127.0.0.1:5001 - ssl-verify: false + ssl-verify: ${A2A_SSL_VERIFY:false} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalogTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalogTest.java new file mode 100644 index 00000000..6f91b279 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchAgentCatalogTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.workbench; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class WorkbenchAgentCatalogTest { + + @TempDir Path tempDir; + + @Test + void loadsAnExternalAgentCard() throws Exception { + Path externalCard = tempDir.resolve("city1.json"); + try (var input = + getClass().getClassLoader().getResourceAsStream("agentcard/spn_domain_agent_city1.json")) { + Files.copy(input, externalCard, StandardCopyOption.REPLACE_EXISTING); + } + + var cards = new WorkbenchAgentCatalog(List.of(externalCard.toString())).load(); + + assertEquals(1, cards.size()); + assertEquals("SPN Domain Agent City1", cards.get(0).name()); + } + + @Test + void failsFastWhenAnExplicitCardDoesNotExist() { + Path missing = tempDir.resolve("missing.json"); + + assertThrows( + IllegalStateException.class, + () -> new WorkbenchAgentCatalog(List.of(missing.toString())).load()); + } +} From 639b7f7ee02ab5e8cb77d15332678f69c4a7ba36 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Sat, 29 Aug 2026 16:32:26 +0800 Subject: [PATCH 43/49] docs(samples): document external AgentCard loading Signed-off-by: Zhoujie628 --- ...\260\351\233\206\346\210\220\346\214\207\345\215\227.md" | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" index a3c4c699..abf3fbb2 100644 --- "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" @@ -24,8 +24,10 @@ PSOP 的存储、检索和加载,注册中心负责 AgentCard 的发布和查 输入并交给 SDK。它接收 WAIMO 通过 A2A-T 下发的原始诊断任务,并行编排下游多地市 SPN Domain Agent(OMC)执行故障根因诊断,汇总结果后上报。 -`SpringSpnDemo` 为便于离线运行,从 classpath 加载 AgentCard,并在编排中心不可用时使用本地 -PSOP fallback。这是样例特例,生产集成应以注册中心和编排中心为数据源,并自行定义受控的失败策略。 +`SpringSpnDemo` 为便于离线运行,默认从 classpath 加载 AgentCard,并在编排中心不可用时使用本地 +PSOP fallback。真实 OMC 联调可用 `A2A_AGENT_CARD_LOCATIONS` 指定逗号或分号分隔的 +`classpath:`、`file:` URI 或绝对路径;显式文件缺失或内容无效时启动立即失败。这是样例特例, +生产集成应以注册中心和编排中心为数据源,并自行定义受控的失败策略。 ### 1.2 四类 A2A-T 交互 From 377973d83d22347288c342ee8883951c4139773b Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 31 Aug 2026 09:40:11 +0800 Subject: [PATCH 44/49] chore(deps): use A2A-T SDK 1.1.0 from Maven Central Signed-off-by: Zhoujie628 --- .github/workflows/ci.yml | 37 ------------------------------------- pom.xml | 5 ++--- 2 files changed, 2 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e4d932e..0f7cc79a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,5 @@ name: CI -env: - A2AT_SDK_REPOSITORY: project-openan/a2a-t-sdk-java - A2AT_SDK_REF: 0de5a2751781419820436e5eb17cffc39b9db47d - A2AT_SDK_VERSION: 1.0.0-0de5a27 - on: push: branches: [main] @@ -20,13 +15,6 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Checkout pinned unreleased A2A-T SDK - uses: actions/checkout@v4 - with: - repository: ${{ env.A2AT_SDK_REPOSITORY }} - ref: ${{ env.A2AT_SDK_REF }} - path: .dependencies/a2a-t-sdk-java - - name: Set up JDK 17 uses: actions/setup-java@v4 with: @@ -34,15 +22,6 @@ jobs: distribution: 'temurin' cache: 'maven' - - name: Install pinned A2A-T SDK components - working-directory: .dependencies/a2a-t-sdk-java - run: >- - mvn -B - -Drevision=${A2AT_SDK_VERSION} - -DskipTests - -pl a2a-t-client,a2a-t-server - -am install - - name: Build and test core modules run: | mvn -B clean verify \ @@ -69,13 +48,6 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Checkout pinned unreleased A2A-T SDK - uses: actions/checkout@v4 - with: - repository: ${{ env.A2AT_SDK_REPOSITORY }} - ref: ${{ env.A2AT_SDK_REF }} - path: .dependencies/a2a-t-sdk-java - - name: Set up JDK 17 uses: actions/setup-java@v4 with: @@ -83,14 +55,5 @@ jobs: distribution: 'temurin' cache: 'maven' - - name: Install pinned A2A-T SDK components - working-directory: .dependencies/a2a-t-sdk-java - run: >- - mvn -B - -Drevision=${A2AT_SDK_VERSION} - -DskipTests - -pl a2a-t-client,a2a-t-server - -am install - - name: Build and test samples run: mvn -B -pl samples -am verify diff --git a/pom.xml b/pom.xml index 7a67cae9..0f5bb3cb 100644 --- a/pom.xml +++ b/pom.xml @@ -54,9 +54,8 @@ 1.2.0.Final 4.35.0 - - 1.0.0-0de5a27 - 0de5a2751781419820436e5eb17cffc39b9db47d + + 1.1.0 1.18.38 2.21.1 2.0.17 From f8acfc003944859ac1ed0a32b9c45a0d199a716a Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 31 Aug 2026 09:40:34 +0800 Subject: [PATCH 45/49] feat(logging): observe real protocol traffic with pretty redacted output Signed-off-by: Zhoujie628 --- samples/src/main/resources/log4j2.properties | 1 + workflow-engine/pom.xml | 7 + .../client/DefaultA2AJavaClientRuntime.java | 6 +- .../engine/client/ObservedHttpClient.java | 157 ++++++++++++ .../engine/client/ProtocolLogger.java | 53 +--- .../engine/client/WireGrpcInterceptor.java | 97 +++++++ .../workflow/engine/client/WireLog.java | 237 ++++++++++++++++++ .../engine/client/WireLogFormatter.java | 149 +++++++++++ .../client/JsonRpcWireObservationTest.java | 87 +++++++ .../engine/client/ObservedHttpClientTest.java | 122 +++++++++ .../engine/client/ProtocolLoggerTest.java | 5 +- .../client/WireGrpcInterceptorTest.java | 86 +++++++ .../engine/client/WireLogFormatterTest.java | 118 +++++++++ .../engine/client/WireLogRedactionTest.java | 38 +++ 14 files changed, 1114 insertions(+), 49 deletions(-) create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/ObservedHttpClient.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireGrpcInterceptor.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLog.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLogFormatter.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/JsonRpcWireObservationTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/ObservedHttpClientTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireGrpcInterceptorTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogFormatterTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogRedactionTest.java diff --git a/samples/src/main/resources/log4j2.properties b/samples/src/main/resources/log4j2.properties index 57074708..122b3138 100644 --- a/samples/src/main/resources/log4j2.properties +++ b/samples/src/main/resources/log4j2.properties @@ -15,6 +15,7 @@ appender.file.fileName=logs/spn-demo.log appender.file.filePattern=logs/spn-demo-%d{yyyy-MM-dd}-%i.log.gz appender.file.append=true appender.file.layout.type=PatternLayout +appender.file.layout.charset=UTF-8 appender.file.layout.pattern=${LOG_PATTERN} appender.file.policies.type=Policies appender.file.policies.time.type=TimeBasedTriggeringPolicy diff --git a/workflow-engine/pom.xml b/workflow-engine/pom.xml index d1bf3d9e..f8b05d2a 100644 --- a/workflow-engine/pom.xml +++ b/workflow-engine/pom.xml @@ -41,6 +41,13 @@ a2a-java-sdk-client-transport-grpc ${a2a.java.sdk.version} + + + io.grpc + grpc-netty-shaded + 1.81.0 + runtime + net.openan.a2a-t.sdk 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 8a6b6a11..ed724a6d 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 @@ -645,7 +645,7 @@ private io.grpc.Channel createGrpcChannel(String url) { throw new IllegalArgumentException( "sslVerify=false uses plaintext gRPC and cannot check a CRL"); } - return ManagedChannelBuilder.forTarget(url).usePlaintext().build(); + return ManagedChannelBuilder.forTarget(url).usePlaintext().intercept(new WireGrpcInterceptor()).build(); } if (crlPath != null && !crlPath.isBlank()) { throw new IllegalArgumentException( @@ -673,7 +673,7 @@ private io.grpc.Channel createGrpcChannel(String url) { clientKeyPassword); } } - return io.grpc.Grpc.newChannelBuilder(url, credentials.build()).build(); + return io.grpc.Grpc.newChannelBuilder(url, credentials.build()).intercept(new WireGrpcInterceptor()).build(); } catch (java.io.IOException e) { throw new IllegalStateException("Failed to configure gRPC TLS", e); } @@ -690,7 +690,7 @@ private A2AHttpClient createHttpClient() { crlPath, Duration.ofSeconds(60), httpClientExecutor); - return new JdkA2AHttpClient(httpClient); + return new JdkA2AHttpClient(new ObservedHttpClient(httpClient)); } private record ClientCacheKey( diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ObservedHttpClient.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ObservedHttpClient.java new file mode 100644 index 00000000..ec387a52 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ObservedHttpClient.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.client; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import java.io.IOException; +import java.net.*; +import java.net.http.*; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Consumer; + +/** Observes the JDK boundary after the A2A SDK has serialized and added its application headers. */ +final class ObservedHttpClient extends HttpClient { + private final HttpClient delegate; + private final Consumer observer; + + ObservedHttpClient(HttpClient delegate) { this(delegate, null); } + ObservedHttpClient(HttpClient delegate, Consumer observer) { + this.delegate = Objects.requireNonNull(delegate); + this.observer = observer; + } + + private final class Exchange { + final String id = UUID.randomUUID().toString(); + final HttpRequest request; + final Map context = WireLog.context(); + Exchange(HttpRequest request) { + this.request = request; + emit("REQUEST_HEADERS", null, request.headers().map(), "headers", "", ""); + } + void emit(String direction, Integer status, Map> headers, + String representation, String body, String detail) { + WireLog.emit(observer, "DIRECT_HTTP", direction, id, request.uri().toString(), request.method(), + status, headers, representation, body, + "application-boundary; automatic network headers/TLS/framing=unobserved; " + detail, context); + } + void failure(Throwable error) { + emit("FAILURE", null, Map.of(), "transport-error", "", + "errorType=" + error.getClass().getSimpleName()); + } + HttpRequest observedRequest() { + return new HttpRequest() { + @Override public Optional bodyPublisher() { + return request.bodyPublisher().map(original -> new BodyPublisher() { + @Override public long contentLength() { return original.contentLength(); } + @Override public void subscribe(Flow.Subscriber downstream) { + WireLog.Body body = new WireLog.Body(false, true, + value -> emit("REQUEST_BODY", null, Map.of(), "serialized-utf8", + value[0], value[1])); + original.subscribe(new Flow.Subscriber() { + @Override public void onSubscribe(Flow.Subscription subscription) { downstream.onSubscribe(subscription); } + @Override public void onNext(ByteBuffer item) { body.accept(item); downstream.onNext(item); } + @Override public void onError(Throwable error) { body.end(true); downstream.onError(error); } + @Override public void onComplete() { body.end(false); downstream.onComplete(); } + }); + } + }); + } + @Override public String method() { return request.method(); } + @Override public Optional timeout() { return request.timeout(); } + @Override public boolean expectContinue() { return request.expectContinue(); } + @Override public URI uri() { return request.uri(); } + @Override public Optional version() { return request.version(); } + @Override public HttpHeaders headers() { return request.headers(); } + }; + } + HttpResponse.BodyHandler handler(HttpResponse.BodyHandler handler) { + return info -> { + emit("RESPONSE_HEADERS", info.statusCode(), info.headers().map(), "headers", "", ""); + String type = info.headers().firstValue("Content-Type").orElse("").toLowerCase(Locale.ROOT); + boolean sse = type.contains("text/event-stream"); + WireLog.Body body = new WireLog.Body(sse, type.isEmpty() || type.contains("json") + || type.startsWith("text/") || type.contains("xml"), + value -> emit("RESPONSE_BODY", info.statusCode(), Map.of(), + sse ? "raw-sse-frame" : "raw-body", value[0], value[1])); + HttpResponse.BodySubscriber target = handler.apply(info); + return new HttpResponse.BodySubscriber() { + @Override public CompletionStage getBody() { return target.getBody(); } + @Override public void onSubscribe(Flow.Subscription subscription) { + target.onSubscribe(new Flow.Subscription() { + @Override public void request(long count) { subscription.request(count); } + @Override public void cancel() { body.end(true); subscription.cancel(); } + }); + } + @Override public void onNext(List items) { + items.forEach(body::accept); + target.onNext(items); + } + @Override public void onError(Throwable error) { body.end(true); failure(error); target.onError(error); } + @Override public void onComplete() { body.end(false); target.onComplete(); } + }; + }; + } + } + + @Override public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler handler) + throws IOException, InterruptedException { + Exchange exchange = new Exchange(request); + try { return delegate.send(exchange.observedRequest(), exchange.handler(handler)); } + catch (IOException | InterruptedException | RuntimeException error) { exchange.failure(error); throw error; } + } + + @Override public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler handler) { + Exchange exchange = new Exchange(request); + try { + CompletableFuture> future = + delegate.sendAsync(exchange.observedRequest(), exchange.handler(handler)); + future.whenComplete((result, error) -> { if (error != null) exchange.failure(error); }); + return future; + } catch (RuntimeException error) { exchange.failure(error); throw error; } + } + + @Override public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler handler, + HttpResponse.PushPromiseHandler pushHandler) { + Exchange exchange = new Exchange(request); + try { + CompletableFuture> future = delegate.sendAsync(exchange.observedRequest(), + exchange.handler(handler), pushHandler); + future.whenComplete((result, error) -> { if (error != null) exchange.failure(error); }); + return future; + } catch (RuntimeException error) { exchange.failure(error); throw error; } + } + + @Override public Optional cookieHandler() { return delegate.cookieHandler(); } + @Override public Optional connectTimeout() { return delegate.connectTimeout(); } + @Override public Redirect followRedirects() { return delegate.followRedirects(); } + @Override public Optional proxy() { return delegate.proxy(); } + @Override public SSLContext sslContext() { return delegate.sslContext(); } + @Override public SSLParameters sslParameters() { return delegate.sslParameters(); } + @Override public Optional authenticator() { return delegate.authenticator(); } + @Override public Version version() { return delegate.version(); } + @Override public Optional executor() { return delegate.executor(); } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java index 43b54b41..762757ad 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java @@ -36,41 +36,15 @@ import org.slf4j.LoggerFactory; import java.time.OffsetDateTime; -import java.util.Locale; import java.util.Map; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; -/** - * Logs A2A protocol messages for controlled protocol-level diagnostics. Uses a dedicated - * "PROTOCOL" logger so output can be independently enabled or suppressed. Full payloads are only - * emitted at DEBUG and body logging is opt-in because Task-T and extension metadata can contain - * customer or network data. - * - *

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

        Response side: serializes each {@link ClientEvent} payload (Task, TaskStatusUpdateEvent, - * TaskArtifactUpdateEvent, Message) to JSON. - */ +/** Optional model preview only; actual transport evidence is emitted by WireLog. */ final class ProtocolLogger { private static final Logger log = LoggerFactory.getLogger("PROTOCOL"); - private static final String INCLUDE_SENSITIVE_HEADERS = - "WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS"; private static final String INCLUDE_BODY = "WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY"; private static final String MAX_BODY_CHARS = "WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS"; private static final int DEFAULT_MAX_BODY_CHARS = 100_000; - private static final Set SENSITIVE_HEADERS = - Set.of( - "authorization", - "proxy-authorization", - "cookie", - "set-cookie", - "x-api-key", - "api-key"); - private static final AtomicBoolean sensitiveWarningLogged = new AtomicBoolean(); private static final ObjectMapper mapper = new ObjectMapper() @@ -97,13 +71,13 @@ static void logRequest( String endpoint, MessageSendParams params, Map headers) { - if (!log.isDebugEnabled()) { + if (!log.isDebugEnabled() || !booleanSetting("WORKFLOW_ENGINE_PROTOCOL_MODEL_PREVIEW", false)) { return; } try { String bodyJson = formatBody(mapper.writeValueAsString(params)); log.debug( - ">>> [{}] REQUEST to {}\n=== Headers ===\n{}\n=== Body ===\n{}", + "MODEL_PREVIEW >>> [{}] REQUEST to {}\n=== Headers ===\n{}\n=== Body ===\n{}", agentName, endpoint, formatHeaders(headers), @@ -120,7 +94,7 @@ static void logRequest( * @param event the received client event */ static void logResponseEvent(String agentName, ClientEvent event) { - if (!log.isDebugEnabled()) { + if (!log.isDebugEnabled() || !booleanSetting("WORKFLOW_ENGINE_PROTOCOL_MODEL_PREVIEW", false)) { return; } try { @@ -128,11 +102,11 @@ static void logResponseEvent(String agentName, ClientEvent event) { String eventType = event.getClass().getSimpleName(); if (payload == null) { log.debug( - "<<< [{}] RESPONSE [{}]: (no serializable payload)", agentName, eventType); + "MODEL_PREVIEW <<< [{}] RESPONSE [{}]: (no serializable payload)", agentName, eventType); return; } String json = formatBody(mapper.writeValueAsString(payload)); - log.debug("<<< [{}] RESPONSE [{}]\n{}", agentName, eventType, json); + log.debug("MODEL_PREVIEW <<< [{}] RESPONSE [{}]\n{}", agentName, eventType, json); } catch (Exception e) { log.warn("<<< [{}] Failed to serialize response event: {}", agentName, e.getMessage()); } @@ -167,29 +141,20 @@ static String formatHeaders(Map headers) { if (headers == null || headers.isEmpty()) { return "(none)"; } - boolean includeSensitive = booleanSetting(INCLUDE_SENSITIVE_HEADERS, false); - if (includeSensitive && sensitiveWarningLogged.compareAndSet(false, true)) { - log.warn( - "PROTOCOL sensitive-header logging is enabled; protect logs and disable it outside controlled troubleshooting"); - } StringBuilder sb = new StringBuilder(); headers.forEach( (k, v) -> { - String normalized = k.toLowerCase(Locale.ROOT); - boolean sensitive = - SENSITIVE_HEADERS.contains(normalized) - || normalized.contains("token") - || normalized.contains("secret"); sb.append(k) .append(": ") - .append(sensitive && !includeSensitive ? "***" : v) + .append(WireLog.sensitive(k) ? "***" : v) .append("\n"); }); return sb.toString().trim(); } private static String formatBody(String body) { - if (!booleanSetting(INCLUDE_BODY, false)) return "(body logging disabled)"; + if (!booleanSetting(INCLUDE_BODY, true)) return "(body logging disabled)"; + body = WireLog.redact(body); int maxChars = intSetting(MAX_BODY_CHARS, DEFAULT_MAX_BODY_CHARS); if (body.length() <= maxChars) return body; return body.substring(0, maxChars) diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireGrpcInterceptor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireGrpcInterceptor.java new file mode 100644 index 00000000..5a93ee2a --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireGrpcInterceptor.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.client; + +import com.google.protobuf.MessageOrBuilder; +import com.google.protobuf.util.JsonFormat; +import io.grpc.*; +import java.util.*; +import java.util.function.Consumer; + +/** Captures actual gRPC metadata and protobuf values, never labels them HTTP JSON. */ +final class WireGrpcInterceptor implements ClientInterceptor { + private final Consumer observer; + WireGrpcInterceptor() { this(null); } + WireGrpcInterceptor(Consumer observer) { this.observer = observer; } + + @Override public ClientCall interceptCall( + MethodDescriptor method, CallOptions options, Channel channel) { + String requestId = UUID.randomUUID().toString(); + Map context = WireLog.context(); + return new ForwardingClientCall.SimpleForwardingClientCall(channel.newCall(method, options)) { + private void emit(String direction, Metadata metadata, Object value, String detail) { + String body = ""; + String representation = "grpc-metadata"; + if (value instanceof MessageOrBuilder protobuf) { + representation = "protobuf-json-view"; + try { body = JsonFormat.printer().print(protobuf); } + catch (com.google.protobuf.InvalidProtocolBufferException error) { + detail += "; protobuf-view=unavailable"; + } + } else if (value != null) { + representation = "unobserved-message"; + detail += "; non-protobuf message=" + value.getClass().getName(); + } + WireLog.emit(observer, "DIRECT_GRPC", direction, requestId, channel.authority(), + method.getFullMethodName(), null, headers(metadata), representation, body, + "grpc-call-boundary; HTTP/2 frames/TLS=unobserved; " + detail, context); + } + @Override public void start(Listener listener, Metadata headers) { + emit("REQUEST_HEADERS", headers, null, ""); + super.start(new ForwardingClientCallListener.SimpleForwardingClientCallListener(listener) { + @Override public void onHeaders(Metadata headers) { + emit("RESPONSE_HEADERS", headers, null, ""); + super.onHeaders(headers); + } + @Override public void onMessage(RespT value) { + emit("RESPONSE_BODY", null, value, ""); + super.onMessage(value); + } + @Override public void onClose(Status status, Metadata trailers) { + emit("RESPONSE_TRAILERS", trailers, null, "grpcStatus=" + status.getCode()); + super.onClose(status, trailers); + } + }, headers); + } + @Override public void sendMessage(ReqT value) { + emit("REQUEST_BODY", null, value, ""); + super.sendMessage(value); + } + }; + } + + private static Map> headers(Metadata metadata) { + Map> result = new LinkedHashMap<>(); + if (metadata == null) return result; + for (String name : metadata.keys()) { + List values = new ArrayList<>(); + if (name.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + Iterable raw = metadata.getAll(Metadata.Key.of(name, Metadata.BINARY_BYTE_MARSHALLER)); + if (raw != null) raw.forEach(bytes -> values.add(Base64.getEncoder().encodeToString(bytes))); + } else { + Iterable raw = metadata.getAll(Metadata.Key.of(name, Metadata.ASCII_STRING_MARSHALLER)); + if (raw != null) raw.forEach(values::add); + } + result.put(name, values); + } + return result; + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLog.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLog.java new file mode 100644 index 00000000..58008e35 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLog.java @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.client; + +import org.slf4j.LoggerFactory; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +/** Application-boundary evidence, not a TLS/HTTP packet capture. Never logs secrets verbatim. */ +public final class WireLog { + private WireLog() {} + private static final org.slf4j.Logger LOG = LoggerFactory.getLogger("PROTOCOL"); + private static final ThreadLocal> CONTEXT = ThreadLocal.withInitial(Map::of); + private static final com.fasterxml.jackson.databind.ObjectMapper JSON = + new com.fasterxml.jackson.databind.ObjectMapper(); + private static final Pattern SECRET_FORM = Pattern.compile( + "(?i)((?:password|passwd|pwd|[\\w-]*token|[\\w-]*secret|accessSession|api[-_]?key)=)[^&\\s]*"); + + /** Values are already redacted, including for test/custom observers. */ + public record Entry(String boundary, String direction, String requestId, String target, String method, + Integer status, Map> headers, String representation, + String body, String visibility, Map correlation) {} + + public static Map context() { return CONTEXT.get(); } + + public static T call(Map context, java.util.function.Supplier action) { + Map previous = CONTEXT.get(); + CONTEXT.set(Map.copyOf(context)); + try { return action.get(); } finally { CONTEXT.set(previous); } + } + + /** Local-only correlation; never inserted into a protocol header or message metadata. */ + public static void inContext(Map context, Runnable action) { + Map previous = CONTEXT.get(); + CONTEXT.set(Map.copyOf(context)); + try { action.run(); } finally { CONTEXT.set(previous); } + } + + static boolean setting(String key, boolean fallback) { + String value = System.getProperty(key); + if (value == null) value = System.getenv(key); + return value == null ? fallback : Boolean.parseBoolean(value); + } + + static int limit() { + String value = System.getProperty("WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS", + System.getenv("WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS")); + try { return Math.max(256, Integer.parseInt(value)); } + catch (RuntimeException ignored) { return 100_000; } + } + + public static String redact(String text) { + if (text == null) return ""; + StringBuilder result = new StringBuilder(); + int copied = 0; + for (int cursor = 0; cursor < text.length(); cursor++) { + if (text.charAt(cursor) != '"') continue; + int endOfString = valueEnd(text, cursor); + if (endOfString <= cursor || text.charAt(endOfString - 1) != '"') break; + int colon = endOfString; + while (colon < text.length() && Character.isWhitespace(text.charAt(colon))) colon++; + if (colon == text.length() || text.charAt(colon) != ':') { + cursor = endOfString - 1; + continue; + } + String key; + try { key = JSON.readValue(text.substring(cursor, endOfString), String.class); } + catch (com.fasterxml.jackson.core.JsonProcessingException invalid) { + cursor = endOfString - 1; + continue; + } + cursor = colon; + if (!sensitive(key)) continue; + int start = colon + 1; + while (start < text.length() && Character.isWhitespace(text.charAt(start))) start++; + int end = valueEnd(text, start); + result.append(text, copied, start).append("\"***\""); + copied = end; + cursor = end - 1; + } + result.append(text, copied, text.length()); + return SECRET_FORM.matcher(result).replaceAll("$1***"); + } + + /** Skip a complete JSON value without reserializing the surrounding wire text. */ + private static int valueEnd(String text, int start) { + boolean quoted = false; + boolean escaped = false; + int depth = 0; + for (int i = start; i < text.length(); i++) { + char c = text.charAt(i); + if (quoted) { + if (escaped) escaped = false; + else if (c == '\\') escaped = true; + else if (c == '"') { + quoted = false; + if (depth == 0) return i + 1; + } + } else if (c == '"') quoted = true; + else if (c == '{' || c == '[') depth++; + else if (c == '}' || c == ']') { + if (depth == 0) return i; + if (--depth == 0) return i + 1; + } else if (depth == 0 && (c == ',' || Character.isWhitespace(c))) return i; + } + // Incomplete secret values are redacted to the end, never logged as a raw prefix. + return text.length(); + } + + static boolean sensitive(String name) { + String key = name.toLowerCase(Locale.ROOT); + return key.contains("authorization") || key.contains("cookie") || key.contains("token") + || key.contains("secret") || key.contains("password") || key.equals("pwd") + || key.equals("passwd") || key.equals("accesssession") + || key.equals("x-api-key") || key.equals("api-key") || key.equals("apikey"); + } + + public static Map> safeHeaders(Map> headers) { + Map> result = new LinkedHashMap<>(); + headers.forEach((key, values) -> result.put(key, + sensitive(key) ? List.of("***") : List.copyOf(values))); + return Collections.unmodifiableMap(result); + } + + /** Actual serialized data only. SDK-decoded data must explicitly say decoded-data. */ + public static void record(String boundary, String direction, String requestId, String target, + String method, Integer status, Map> headers, + String representation, String body, String visibility) { + emit(null, boundary, direction, requestId, target, method, status, headers, + representation, body, visibility, context()); + } + + static void emit(Consumer observer, String boundary, String direction, String requestId, + String target, String method, Integer status, Map> headers, + String representation, String body, String visibility, Map context) { + try { + String safeBody = redact(body); + if (!setting("WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY", true)) { + safeBody = "(body logging disabled)"; + visibility += "; body=disabled"; + } else if (safeBody.length() > limit()) { + safeBody = safeBody.substring(0, limit()) + "\n(body truncated)"; + visibility += "; body=truncated"; + } + Entry entry = new Entry(boundary, direction, requestId, redact(target), method, status, + safeHeaders(headers), representation, safeBody, visibility, Map.copyOf(context)); + if (observer != null) observer.accept(entry); + else if (LOG.isDebugEnabled()) LOG.debug("{}", WireLogFormatter.format(entry, + setting("WORKFLOW_ENGINE_PROTOCOL_PRETTY", true))); + } catch (RuntimeException ignored) { + // Logging must never change delivery, backpressure or application state. + LOG.warn("Protocol observation failed; record dropped"); + } + } + + /** Bounded raw-byte collector. SSE emits each frame as it arrives, not at stream termination. */ + public static final class Body { + private final Consumer sink; + private final boolean sse; + private final boolean textual; + private final int capacity = limit(); + private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + private long size; + private boolean dropped; + private boolean ended; + private boolean pendingCr; + private int lineBytes; + public Body(boolean sse, boolean textual, Consumer sink) { + this.sse = sse; + this.textual = textual; + this.sink = sink; + } + public synchronized void accept(ByteBuffer input) { + if (ended) return; + ByteBuffer copy = input.asReadOnlyBuffer(); + while (copy.hasRemaining()) { + int value = copy.get() & 255; + if (sse && pendingCr && value != 10) { + lineEnd(); + pendingCr = false; + } + size++; + if (bytes.size() < capacity && !dropped) bytes.write(value); + else { dropped = true; bytes.reset(); } + if (sse) { + // CR, LF, and CRLF are line endings. A blank line terminates a frame. + if (value == 13) pendingCr = true; + else if (value == 10) { lineEnd(); pendingCr = false; } + else lineBytes++; + } + } + } + public synchronized void end(boolean interrupted) { + if (ended) return; + ended = true; + if (pendingCr) { lineEnd(); pendingCr = false; } + if (size > 0 || !sse) flush(interrupted); + } + private void lineEnd() { + if (lineBytes == 0) flush(false); + lineBytes = 0; + } + private void flush(boolean interrupted) { + String content = dropped ? "(body/frame dropped: capacity exceeded)" + : !textual ? "(binary body omitted)" + : new String(bytes.toByteArray(), StandardCharsets.UTF_8); + // Decode only complete frames/body, keeping UTF-8 code points intact across chunks. + String visible = "observedBytes=" + size + (dropped ? "; body=dropped-capacity" : "") + + (interrupted ? "; stream=interrupted" : ""); + try { sink.accept(new String[]{content, visible}); } + catch (RuntimeException ignored) { LOG.warn("Protocol observation failed; record dropped"); } + bytes.reset(); size = 0; dropped = false; + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLogFormatter.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLogFormatter.java new file mode 100644 index 00000000..9024970e --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLogFormatter.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.client; + +import com.fasterxml.jackson.core.JsonFactory; + +/** Display only: transport bytes and observer entries are never reformatted. */ +final class WireLogFormatter { + private static final JsonFactory JSON = new JsonFactory(); + + private WireLogFormatter() {} + + static String format(WireLog.Entry entry, boolean pretty) { + StringBuilder out = new StringBuilder() + .append("[").append(entry.boundary()).append("] ").append(entry.direction()) + .append(" requestId=").append(entry.requestId()) + .append("\nTarget: ").append(entry.method()).append(" ").append(entry.target()) + .append("\nStatus: ").append(entry.status() == null ? "(not observed)" : entry.status()) + .append("\nRepresentation: ").append(entry.representation()) + .append("\nVisibility: ").append(entry.visibility()) + .append("\nDisplay: ").append(pretty ? "pretty (not wire bytes)" : "raw observed text") + .append("\n=== Correlation ===\n"); + entry.correlation().forEach((key, value) -> out.append(key).append(": ").append(value).append('\n')); + out.append("=== Headers ===\n"); + if (entry.headers().isEmpty()) out.append("(none observed)\n"); + entry.headers().forEach((key, values) -> + values.forEach(value -> out.append(key).append(": ").append(value).append('\n'))); + return out.append("=== Body ===\n") + .append(pretty ? prettyBody(entry.body(), entry.representation()) : entry.body()).toString(); + } + + static String prettyBody(String body, String representation) { + if (body == null || body.isBlank()) return body == null ? "" : body; + String result = representation.toLowerCase(java.util.Locale.ROOT).contains("sse") + ? prettySse(body) : prettyJson(body); + return result.length() <= WireLog.limit() ? result + : body + "\n(pretty skipped: display capacity exceeded)"; + } + + private static String prettySse(String body) { + String[] lines = body.split("\\r\\n|\\r|\\n", -1); + StringBuilder out = new StringBuilder(); + for (int i = 0; i < lines.length; i++) { + if (!lines[i].startsWith("data:")) { + out.append(lines[i]); + } else { + int first = i; + StringBuilder data = new StringBuilder(); + do { + String value = lines[i].substring(5); + if (value.startsWith(" ")) value = value.substring(1); + if (i > first) data.append('\n'); + data.append(value); + i++; + } while (i < lines.length && lines[i].startsWith("data:")); + String formatted = prettyJson(data.toString()); + if (!isJsonContainer(data.toString().strip())) { + for (int j = first; j < i; j++) { + if (j > first) out.append('\n'); + out.append(lines[j]); + } + } else { + out.append("=== SSE data (JSON display; not wire text) ===\n") + .append(formatted).append("\n=== End SSE data ==="); + } + i--; + } + if (i < lines.length - 1) out.append('\n'); + if (out.length() > WireLog.limit()) return body + "\n(pretty skipped: display capacity exceeded)"; + } + return out.toString(); + } + + private static boolean isJsonContainer(String text) { + if (!text.startsWith("{") && !text.startsWith("[")) return false; + try (var parser = JSON.createParser(text)) { + parser.nextToken(); + parser.skipChildren(); + return parser.nextToken() == null; + } catch (java.io.IOException | RuntimeException invalid) { + return false; + } + } + + /** Insert whitespace only; preserve duplicate keys, numbers and escaped string tokens exactly. */ + private static String prettyJson(String text) { + String trimmed = text.strip(); + if (!isJsonContainer(trimmed)) return text; + StringBuilder out = new StringBuilder(); + boolean quoted = false; + boolean escaped = false; + int depth = 0; + char previous = 0; + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (quoted) { + out.append(c); + if (escaped) escaped = false; + else if (c == '\\') escaped = true; + else if (c == '"') quoted = false; + } else if (c == '"') { + quoted = true; + out.append(c); + } else if (!Character.isWhitespace(c)) { + switch (c) { + case '{', '[' -> { + out.append(c); + if (++depth > 64) return text; + int next = i + 1; + while (next < trimmed.length() && Character.isWhitespace(trimmed.charAt(next))) next++; + if (next < trimmed.length() && trimmed.charAt(next) != '}' && trimmed.charAt(next) != ']') + newline(out, depth); + } + case '}', ']' -> { + depth--; + if (previous != '{' && previous != '[') newline(out, depth); + out.append(c); + } + case ',' -> { out.append(c); newline(out, depth); } + case ':' -> out.append(": "); + default -> out.append(c); + } + } + if (!Character.isWhitespace(c) || quoted) previous = c; + if (out.length() > WireLog.limit()) return text + "\n(pretty skipped: display capacity exceeded)"; + } + return out.toString(); + } + + private static void newline(StringBuilder out, int depth) { + out.append('\n').append(" ".repeat(depth)); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/JsonRpcWireObservationTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/JsonRpcWireObservationTest.java new file mode 100644 index 00000000..609c798c --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/JsonRpcWireObservationTest.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import com.sun.net.httpserver.HttpServer; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.http.JdkA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.*; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.*; +import org.junit.jupiter.api.Test; +import java.net.*; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; + +class JsonRpcWireObservationTest { + @Test void recordsSdkSerializedJsonRpcRequestAndRealErrorResponse() throws Exception { + var json = new ObjectMapper().registerModule(new AgentCardJacksonModule()); + List logs = new CopyOnWriteArrayList<>(); + AtomicReference received = new AtomicReference<>(); + AtomicReference> version = new AtomicReference<>(); + AtomicReference returned = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/rpc", exchange -> { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + received.set(body); + version.set(exchange.getRequestHeaders().get("A2A-Version")); + String error = "{\"jsonrpc\":\"2.0\",\"id\":" + json.readTree(body).get("id") + + ",\"error\":{\"code\":-32602,\"message\":\"invalid input\"}}"; + returned.set(error); + byte[] bytes = error.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + Client client = null; + try { + AgentCard card = json.readValue(""" + {"name":"rpc","description":"rpc","version":"1", + "capabilities":{"streaming":false,"extensions":[]}, + "defaultInputModes":["text/plain"],"defaultOutputModes":["text/plain"],"skills":[], + "supportedInterfaces":[{"protocolBinding":"JSONRPC","protocolVersion":"1.0","tenant":"", + "url":"http://127.0.0.1:%d/rpc"}]} + """.formatted(server.getAddress().getPort()), AgentCard.class); + client = Client.builder(card).withTransport(JSONRPCTransport.class, + new JSONRPCTransportConfig(new JdkA2AHttpClient( + new ObservedHttpClient(HttpClient.newHttpClient(), logs::add)))).build(); + CountDownLatch done = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + try { + client.sendMessage(MessageSendParams.builder().message(Message.builder() + .messageId(UUID.randomUUID().toString()).role(Message.Role.ROLE_USER) + .parts(new TextPart("诊断输入")).build()).build(), + List.of((event, agent) -> done.countDown()), + error -> { failure.set(error); done.countDown(); }, + new ClientCallContext(Map.of(), Map.of("Authorization", "Bearer hidden"))); + } catch (A2AClientException error) { + failure.set(error); done.countDown(); + } + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertNotNull(failure.get()); + var request = json.readTree(received.get()); + assertEquals("2.0", request.get("jsonrpc").asText()); + assertEquals(A2AMethods.SEND_MESSAGE_METHOD, request.get("method").asText()); + assertTrue(received.get().contains("诊断输入")); + var body = logs.stream().filter(e -> e.direction().equals("REQUEST_BODY")).findFirst().orElseThrow(); + assertEquals(received.get(), body.body()); + var headers = logs.stream().filter(e -> e.direction().equals("REQUEST_HEADERS")).findFirst().orElseThrow(); + assertEquals(version.get(), headers.headers().get("A2A-Version")); + assertNotNull(version.get()); + assertTrue(logs.stream().anyMatch(e -> e.direction().equals("RESPONSE_BODY") && returned.get().equals(e.body()))); + assertFalse(logs.toString().contains("hidden")); + } finally { + if (client != null) client.close(); + server.stop(0); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ObservedHttpClientTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ObservedHttpClientTest.java new file mode 100644 index 00000000..cef21698 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ObservedHttpClientTest.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.client; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; +import java.net.*; +import java.net.http.*; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +class ObservedHttpClientTest { + @AfterEach void clearSettings() { + System.clearProperty("WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY"); + System.clearProperty("WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS"); + } + + @Test void logsActualRequestAndResponseWithoutReencodingOrExposingCredentials() throws Exception { + String body = "{ \"message\": \"任务\", \"password\": \"login-secret\" }"; + String response = "{\"ok\":true,\"accessSession\":\"omc-secret\"}"; + List records = new CopyOnWriteArrayList<>(); + List capturedBody = new CopyOnWriteArrayList<>(); + List> capturedVersion = new CopyOnWriteArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/message", exchange -> { + capturedBody.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + capturedVersion.add(exchange.getRequestHeaders().get("A2A-Version")); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.getResponseHeaders().add("X-Multi", "one"); + exchange.getResponseHeaders().add("X-Multi", "two"); + exchange.getResponseHeaders().add("bearToken", "header-secret"); + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + try { + HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + + server.getAddress().getPort() + "/message")) + .header("A2A-Version", "1.0").header("Authorization", "Bearer request-secret") + .header("X-Multi", "a").header("X-Multi", "b") + .POST(HttpRequest.BodyPublishers.ofString(body)).build(); + HttpResponse result = new ObservedHttpClient(HttpClient.newHttpClient(), records::add) + .send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(response, result.body()); + assertEquals(List.of(body), capturedBody); + WireLog.Entry sent = records.stream().filter(e -> e.direction().equals("REQUEST_BODY")).findFirst().orElseThrow(); + assertEquals(body.replace("login-secret", "***"), sent.body()); + WireLog.Entry header = records.stream().filter(e -> e.direction().equals("REQUEST_HEADERS")).findFirst().orElseThrow(); + assertEquals(capturedVersion.get(0), header.headers().get("A2A-Version")); + assertEquals(List.of("a", "b"), header.headers().get("X-Multi")); + WireLog.Entry received = records.stream().filter(e -> e.direction().equals("RESPONSE_HEADERS")).findFirst().orElseThrow(); + assertEquals(List.of("one", "two"), received.headers().get("x-multi")); + assertEquals(List.of("***"), received.headers().get("beartoken")); + assertEquals(1, records.stream().map(WireLog.Entry::requestId).distinct().count()); + String logs = records.toString(); + assertFalse(logs.contains("login-secret")); + assertFalse(logs.contains("omc-secret")); + assertFalse(logs.contains("header-secret")); + assertFalse(logs.contains("request-secret")); + } finally { server.stop(0); } + } + + @Test void streamingFramesKeepUtf8ControlLinesAndArriveBeforeCompletion() { + List frames = new ArrayList<>(); + WireLog.Body collector = new WireLog.Body(true, true, frames::add); + String first = ": heartbeat\r\nevent: result\r\nid: 7\r\nretry: 100\r\ndata: 中文\r\n\r\n"; + for (byte value : first.getBytes(StandardCharsets.UTF_8)) collector.accept(ByteBuffer.wrap(new byte[]{value})); + assertEquals(1, frames.size()); + assertEquals(first, frames.get(0)[0]); + String second = "data: 第二帧\n\n"; + collector.accept(ByteBuffer.wrap(second.getBytes(StandardCharsets.UTF_8))); + assertEquals(second, frames.get(1)[0]); + collector.end(false); + assertEquals(2, frames.size()); + } + + @Test void oversizedFramesAreExplicitlyDroppedWithoutLeakingPartialSecrets() { + System.setProperty("WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS", "256"); + List frames = new ArrayList<>(); + WireLog.Body collector = new WireLog.Body(true, true, frames::add); + collector.accept(ByteBuffer.wrap(("data: {\"password\":\"" + "secret".repeat(100) + "\"}\n\n" + + "data: ok\n\n").getBytes(StandardCharsets.UTF_8))); + assertTrue(frames.get(0)[1].contains("dropped-capacity")); + assertFalse(frames.get(0)[0].contains("secret")); + assertEquals("data: ok\n\n", frames.get(1)[0]); + } + + @Test void loggingFailureCannotChangeBodyDeliveryAndBodySwitchIsExplicit() throws Exception { + List entries = new ArrayList<>(); + System.setProperty("WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY", "false"); + WireLog.emit(entries::add, "DIRECT_HTTP", "REQUEST", "1", "/test", "POST", + null, Map.of(), "serialized-utf8", "private", "", Map.of()); + assertTrue(entries.get(0).visibility().contains("disabled")); + assertFalse(entries.get(0).body().contains("private")); + assertDoesNotThrow(() -> WireLog.emit(e -> { throw new IllegalStateException("observer"); }, + "DIRECT_HTTP", "REQUEST", "1", "/test", "POST", null, Map.of(), "serialized-utf8", "", "", Map.of())); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolLoggerTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolLoggerTest.java index b34195fd..78c2d640 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolLoggerTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolLoggerTest.java @@ -35,10 +35,11 @@ void sensitiveHeadersAreRedactedByDefault() { } @Test - void sensitiveHeadersCanBeExplicitlyEnabledForControlledDiagnostics() { + void obsoleteSettingCannotDisableMandatoryRedaction() { System.setProperty(SETTING, "true"); String formatted = ProtocolLogger.formatHeaders(Map.of("Authorization", "Bearer visible")); - assertTrue(formatted.contains("Bearer visible")); + assertFalse(formatted.contains("Bearer visible")); + assertTrue(formatted.contains("Authorization: ***")); } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireGrpcInterceptorTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireGrpcInterceptorTest.java new file mode 100644 index 00000000..6ad0e564 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireGrpcInterceptorTest.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import com.google.protobuf.StringValue; +import io.grpc.*; +import io.grpc.protobuf.ProtoUtils; +import io.grpc.stub.ServerCalls; +import org.junit.jupiter.api.Test; +import java.net.InetSocketAddress; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; + +class WireGrpcInterceptorTest { + @Test void capturesRealGrpcMetadataAndProtobufWithoutInventingHttpBody() throws Exception { + var method = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY).setFullMethodName("test.Protocol/Echo") + .setRequestMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())) + .setResponseMarshaller(ProtoUtils.marshaller(StringValue.getDefaultInstance())).build(); + AtomicReference captured = new AtomicReference<>(); + AtomicReference capturedHeaders = new AtomicReference<>(); + var service = ServerServiceDefinition.builder("test.Protocol").addMethod(method, + ServerCalls.asyncUnaryCall((StringValue request, io.grpc.stub.StreamObserver response) -> { + captured.set(request); + response.onNext(request); + response.onCompleted(); + })).build(); + ServerInterceptor receiver = new ServerInterceptor() { + public ServerCall.Listener interceptCall(ServerCall call, Metadata headers, + ServerCallHandler next) { + capturedHeaders.set(headers); + return next.startCall(new ForwardingServerCall.SimpleForwardingServerCall(call) { + public void sendHeaders(Metadata headers) { + var key = Metadata.Key.of("x-multi", Metadata.ASCII_STRING_MARSHALLER); + headers.put(key, "one"); headers.put(key, "two"); + super.sendHeaders(headers); + } + }, headers); + } + }; + Server server = ServerBuilder.forPort(0) + .addService(ServerInterceptors.intercept(service, receiver)).build().start(); + List logs = new CopyOnWriteArrayList<>(); + ManagedChannel channel = ManagedChannelBuilder.forAddress("127.0.0.1", server.getPort()).usePlaintext() + .intercept(new WireGrpcInterceptor(logs::add)).build(); + try { + Metadata headers = new Metadata(); + var version = Metadata.Key.of("a2a-version", Metadata.ASCII_STRING_MARSHALLER); + headers.put(version, "1.0"); + headers.put(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER), "Bearer hidden"); + CountDownLatch done = new CountDownLatch(1); + AtomicReference status = new AtomicReference<>(); + AtomicReference received = new AtomicReference<>(); + ClientCall call = WireLog.call(Map.of("executionId", "exec-1"), + () -> channel.newCall(method, CallOptions.DEFAULT.withDeadlineAfter(5, TimeUnit.SECONDS))); + call.start(new ClientCall.Listener<>() { + public void onMessage(StringValue value) { received.set(value); } + public void onClose(Status value, Metadata trailers) { status.set(value); done.countDown(); } + }, headers); + call.request(1); + StringValue payload = StringValue.of("诊断输入"); + call.sendMessage(payload); call.halfClose(); + assertTrue(done.await(6, TimeUnit.SECONDS)); + assertTrue(status.get().isOk()); + assertEquals(payload, captured.get()); + assertEquals(payload, received.get()); + assertEquals("1.0", capturedHeaders.get().get(version)); + assertTrue(logs.stream().allMatch(e -> e.boundary().equals("DIRECT_GRPC"))); + assertTrue(logs.stream().allMatch(e -> e.correlation().get("executionId").equals("exec-1"))); + assertEquals(1, logs.stream().map(WireLog.Entry::requestId).distinct().count()); + var request = logs.stream().filter(e -> e.direction().equals("REQUEST_BODY")).findFirst().orElseThrow(); + assertEquals("protobuf-json-view", request.representation()); + assertEquals("\"诊断输入\"", request.body()); + var response = logs.stream().filter(e -> e.direction().equals("RESPONSE_HEADERS")).findFirst().orElseThrow(); + assertEquals(List.of("one", "two"), response.headers().get("x-multi")); + assertFalse(logs.toString().contains("hidden")); + } finally { + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogFormatterTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogFormatterTest.java new file mode 100644 index 00000000..a1897774 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogFormatterTest.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.client; + +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class WireLogFormatterTest { + @Test void prettyJsonPreservesDuplicateKeysNumericAndEscapedStringTokens() { + String raw = "{\"n\":1e+02,\"n\":-0.0,\"text\":\"x\\n\\\"中文\\\"\",\"a\":[{},1]}"; + String pretty = WireLogFormatter.prettyBody(raw, "serialized-utf8"); + assertTrue(pretty.contains("\n \"n\": 1e+02,")); + assertTrue(pretty.contains("\n \"n\": -0.0,")); + assertTrue(pretty.contains("\"x\\n\\\"中文\\\"\"")); + assertTrue(pretty.contains("\n {},\n 1\n ]")); + } + + @Test void prettySseKeepsEventControlFieldsAndMakesDataReadable() { + String raw = "id: 12\r\nevent: task\r\n: heartbeat\r\ndata: {\"metadata\":{\r\ndata: \"performative\":\"PROPOSE\"}}\r\n\r\n"; + String pretty = WireLogFormatter.prettyBody(raw, "raw-sse-frame"); + assertTrue(pretty.startsWith("id: 12\nevent: task\n: heartbeat\n=== SSE data (JSON display; not wire text) ===\n{\n")); + assertTrue(pretty.contains(" \"performative\": \"PROPOSE\"")); + assertFalse(pretty.contains("\ndata: ")); + assertTrue(pretty.contains("=== End SSE data ===")); + assertTrue(pretty.endsWith("\n\n")); + assertEquals(pretty, WireLogFormatter.prettyBody(raw, "sdk-sse-text")); + } + + + @Test void sseDisplaySeparatesEventsAndKeepsRawObservationAvailable() { + String raw = "id: 1\ndata: {\n" + "data: \"n\": 1\n" + "data: }\n\n" + + "id: 2\ndata: {}\n\n"; + List observed = new ArrayList<>(); + WireLog.emit(observed::add, "DIRECT_HTTP", "RESPONSE_BODY", "sse-1", "/", "POST", 200, + Map.of(), "raw-sse-frame", raw, "observed", Map.of()); + assertEquals(raw, observed.get(0).body()); + assertTrue(WireLogFormatter.format(observed.get(0), false).endsWith(raw)); + String pretty = WireLogFormatter.format(observed.get(0), true); + assertTrue(pretty.contains("id: 1\n=== SSE data (JSON display; not wire text) ===\n{\n \"n\": 1\n}")); + assertTrue(pretty.contains("id: 2\n=== SSE data (JSON display; not wire text) ===\n{}")); + assertFalse(pretty.contains("\ndata:")); + assertEquals("data: {\"unfinished\":\n\n", + WireLogFormatter.prettyBody("data: {\"unfinished\":\n\n", "raw-sse-frame")); + } + + @Test void invalidTruncatedAndNonJsonContentAreNotRepaired() { + for (String raw : List.of("{\"x\":", "{\"x\":1} trailing", "[DONE]", "plain text")) + assertEquals(raw, WireLogFormatter.prettyBody(raw, "serialized-utf8")); + assertEquals("data: [DONE]\n\n", WireLogFormatter.prettyBody("data: [DONE]\n\n", "raw-sse-frame")); + } + + @Test void logLayoutIsPrettyButObserversKeepExactRedactedWireText() { + List entries = new ArrayList<>(); + String raw = "{\"password\":\"hidden\",\"negotiationContext\":{\"performative\":\"ACCEPT\"}}"; + WireLog.emit(entries::add, "DIRECT_HTTP", "REQUEST_BODY", "req-1", "/send", "POST", null, + Map.of("Authorization", List.of("hidden"), "A2A-Extensions", List.of("Negotiation-T/v1"), + "X-Trace", List.of("one", "two")), "serialized-utf8", raw, "observed", Map.of()); + WireLog.Entry entry = entries.get(0); + assertEquals(raw.replace("hidden", "***"), entry.body()); + String pretty = WireLogFormatter.format(entry, true); + assertTrue(pretty.contains("=== Headers ===\n")); + assertTrue(pretty.contains("A2A-Extensions: Negotiation-T/v1\n")); + assertTrue(pretty.contains("X-Trace: one\nX-Trace: two\n")); + assertTrue(pretty.contains("\n \"performative\": \"ACCEPT\"")); + assertFalse(pretty.contains("hidden")); + assertTrue(WireLogFormatter.format(entry, false).endsWith(entry.body())); + assertEquals(raw.replace("hidden", "***"), entry.body()); + } + + @Test void displayExpansionIsBoundedAndDoesNotRecurseOnDeepJson() { + String key = "WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS"; + String previous = System.getProperty(key); + System.setProperty(key, "256"); + try { + String raw = "{\"a\":[" + "1,".repeat(80) + "2]}"; + String display = WireLogFormatter.prettyBody(raw, "serialized-utf8"); + assertTrue(display.startsWith(raw)); + assertTrue(display.contains("pretty skipped")); + String deep = "[".repeat(70) + "0" + "]".repeat(70); + assertEquals(deep, WireLogFormatter.prettyBody(deep, "serialized-utf8").split("\n\\(pretty")[0]); + } finally { + if (previous == null) System.clearProperty(key); else System.setProperty(key, previous); + } + } + + @Test void disabledBodyIsNotReconstructedByPrettyDisplay() { + String key = "WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY"; + String previous = System.getProperty(key); + System.setProperty(key, "false"); + try { + List entries = new ArrayList<>(); + WireLog.emit(entries::add, "DIRECT_HTTP", "REQUEST_BODY", "req", "/", "POST", null, + Map.of(), "serialized-utf8", "{\"privateBusiness\":42}", "observed", Map.of()); + String display = WireLogFormatter.format(entries.get(0), true); + assertTrue(display.contains("(body logging disabled)")); + assertFalse(display.contains("privateBusiness")); + } finally { + if (previous == null) System.clearProperty(key); else System.setProperty(key, previous); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogRedactionTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogRedactionTest.java new file mode 100644 index 00000000..0b8ffe84 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogRedactionTest.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import org.junit.jupiter.api.Test; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class WireLogRedactionTest { + @Test void largeEscapedContentDoesNotUseRecursiveRegexBacktracking() { + String payload = "{\"result\":\"" + "escaped \\\"text\\\" ".repeat(8000) + + "\",\"token\":\"hidden\"}"; + assertEquals(payload.replace("hidden", "***"), WireLog.redact(payload)); + } + + @Test void redactsNestedValuesEscapedKeysAndIncompleteSecretsWithoutReserializingOtherFields() { + String original = "{ \"password\": {\"nested\":[\"secret\",{\"value\":\"hidden\"}]}, " + + "\"to\\u006ben\":\"hidden\", \"ok\" : [1, 2] }"; + assertEquals("{ \"password\": \"***\", \"to\\u006ben\":\"***\", \"ok\" : [1, 2] }", WireLog.redact(original)); + assertEquals("data: {\"accessSession\":\"***\"", WireLog.redact("data: {\"accessSession\":\"partial")); + assertEquals("password=***&ok=1", WireLog.redact("password=hidden&ok=1")); + } + + @Test void splitSdkTextChunksDoNotExposeFragmentsOfCredentials() { + List observed = new ArrayList<>(); + WireLog.Body body = new WireLog.Body(true, true, frame -> observed.add(WireLog.redact(frame[0]))); + for (String chunk : List.of("id: 1\ndata: {\"to", "ken\":\"sensitive", "-tail\",\"ok\":true}\n", "\n")) { + body.accept(ByteBuffer.wrap(chunk.getBytes(StandardCharsets.UTF_8))); + } + assertEquals(List.of("id: 1\ndata: {\"token\":\"***\",\"ok\":true}\n\n"), observed); + body.end(false); + assertEquals(1, observed.size()); + } +} From 47005e9e1377fbcde75f2c55c56e04729b574690 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 31 Aug 2026 09:41:21 +0800 Subject: [PATCH 46/49] refactor(engine): separate protocol scheduling from host business callbacks Move A2A-T content generation and validation into sample business callbacks; preserve structured upstream outputs and independent extension lifecycles. Cover default single-city negotiation and current SDK flows. Signed-off-by: Zhoujie628 --- samples/pom.xml | 6 + .../agents/NegotiationBaseAgentExecutor.java | 407 +++----- .../agents/SpnDomainAgentCity1Executor.java | 49 +- .../agents/SpnDomainAgentCity2Executor.java | 13 +- .../engine/examples/agents/SpnTaskInput.java | 73 ++ .../config/WorkbenchClientProperties.java | 7 + .../examples/demo/A2ATProtocolCases.java | 127 ++- .../engine/examples/demo/SpnCasePrompts.java | 50 +- .../engine/examples/demo/SpringSpnDemo.java | 40 +- .../extension/ExtensionPrePositioner.java | 49 +- .../PrePositionedExtensionHandler.java | 8 +- .../extension/RecoveryNotification.java | 56 ++ .../negotiation/NegotiationStrategy.java | 141 ++- .../examples/util/A2ATInitialization.java | 74 ++ .../examples/util/BusinessSdkErrors.java | 38 + .../workbench/SpringWorkbenchExecutor.java | 2 +- .../SpringWorkbenchExtensionLifecycle.java | 43 +- .../workbench/WorkbenchControlPoint.java | 213 ++-- .../WorkbenchExtensionLifecycle.java | 37 +- .../workbench/WorkbenchOrchestrator.java | 11 +- .../workbench/WorkbenchTaskInputParser.java | 8 +- .../engine/examples/SpnCrossCityE2ETest.java | 41 +- .../demo/DemoNegotiationSettingsTest.java | 71 ++ .../demo/DocumentedCallbackExampleTest.java | 69 ++ .../examples/demo/SpringSpnDemoE2ETest.java | 176 ++++ .../extension/ExtensionPrePositionerTest.java | 27 +- .../extension/RecoveryNotificationTest.java | 60 ++ .../negotiation/NegotiationStrategyTest.java | 103 ++ .../server/EmbeddedA2AServerTest.java | 196 +++- .../testsupport/OfflineA2ATLlmClient.java | 201 ++-- .../examples/util/A2ATInitializationTest.java | 47 + .../examples/util/BusinessSdkErrorsTest.java | 39 + .../test/resources/spn_agent_credentials.json | 38 + workflow-engine/pom.xml | 2 +- .../engine/client/A2ATContentFacade.java | 421 -------- .../workflow/engine/client/A2ATExtension.java | 42 +- .../workflow/engine/client/A2ATransport.java | 241 ++--- .../workflow/engine/client/A2atMessages.java | 87 ++ .../engine/client/AgentAuthManager.java | 5 - .../client/DefaultA2AJavaClientRuntime.java | 1 + .../engine/client/DefaultExtensionSender.java | 491 +--------- .../client/DefaultWorkflowEngineClient.java | 907 +++++------------- .../engine/client/ExtensionHandler.java | 96 -- .../client/ExtensionHeaderContributor.java | 41 - .../engine/client/ExtensionInterceptor.java | 100 -- .../engine/client/ExtensionRegistry.java | 120 --- .../engine/client/ExtensionSender.java | 136 +-- .../engine/client/NegotiationTHandler.java | 261 ----- .../client/NotificationSubscription.java | 7 +- .../engine/client/ProtocolResponses.java | 120 +++ .../workflow/engine/client/TaskTHandler.java | 304 ------ .../engine/client/WorkflowEngineClient.java | 192 +--- .../client/WorkflowEngineClientConfig.java | 55 +- .../workflow/engine/control/ControlPoint.java | 140 +-- .../engine/control/DefaultControlPoint.java | 122 +-- .../engine/control/NegotiationStrategy.java | 6 +- .../workflow/engine/core/ContextBuilder.java | 68 +- .../workflow/engine/core/FailureMapping.java | 44 + .../engine/core/ProtocolResultAdapter.java | 43 + .../engine/core/WorkflowExecutor.java | 302 ++++-- .../engine/model/BusinessFailure.java | 42 + .../workflow/engine/model/BusinessInput.java | 43 + .../workflow/engine/model/BusinessValues.java | 64 ++ .../workflow/engine/model/MessageContent.java | 88 ++ .../engine/model/NegotiationDecision.java | 126 --- .../engine/model/NegotiationReply.java | 41 + .../engine/model/NegotiationRequest.java | 86 +- .../engine/model/ReceivedMessage.java | 71 ++ .../workflow/engine/model/RouteRequest.java | 38 + .../engine/model/SendMessageResult.java | 38 +- .../openan/workflow/engine/model/Task.java | 1 + .../engine/model/TaskExecutionResult.java | 51 + .../workflow/engine/model/TaskRequest.java | 29 +- .../workflow/engine/model/TaskResponse.java | 38 - .../workflow/engine/model/TaskResult.java | 67 ++ .../workflow/engine/model/TaskSubmission.java | 138 --- .../UpstreamStepResult.java} | 20 +- .../workflow/engine/model/WorkflowInput.java | 41 + .../workflow/engine/runner/ExecutePsop.java | 15 +- .../engine/StubWorkflowEngineClient.java | 27 +- .../engine/client/A2ATContentFacadeTest.java | 212 ---- .../engine/client/A2ATransportHeaderTest.java | 122 +-- .../client/A2ATransportResponseTest.java | 25 + .../client/ContentDependencyBoundaryTest.java | 56 ++ ...ltWorkflowEngineClientNegotiationTest.java | 283 ++++-- .../engine/client/ExtensionRegistryTest.java | 154 --- .../engine/client/ExtensionSenderTest.java | 426 ++------ .../client/NotificationSubscriptionTest.java | 3 +- .../engine/client/ProtocolResponsesTest.java | 85 ++ .../WorkflowEngineClientConfigTest.java | 64 +- .../control/DefaultControlPointTest.java | 65 +- .../engine/core/BusinessBoundaryTest.java | 127 +++ .../engine/core/ContextBuilderTest.java | 176 ++-- .../core/ProtocolResultAdapterTest.java | 122 +++ .../engine/core/WorkflowExecutorTest.java | 423 +++++--- .../engine/model/MessageContentTest.java | 85 ++ .../engine/model/NegotiationDecisionTest.java | 49 - .../engine/model/NegotiationRequestTest.java | 96 +- .../engine/model/TaskSubmissionTest.java | 88 -- .../engine/runner/ExecutePsopTest.java | 159 +-- 100 files changed, 4716 insertions(+), 6042 deletions(-) create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnTaskInput.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/extension/RecoveryNotification.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/util/A2ATInitialization.java create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrors.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/demo/DemoNegotiationSettingsTest.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/demo/DocumentedCallbackExampleTest.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/extension/RecoveryNotificationTest.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategyTest.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/util/A2ATInitializationTest.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrorsTest.java create mode 100644 samples/src/test/resources/spn_agent_credentials.json delete mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2atMessages.java delete mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java delete mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHeaderContributor.java delete mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionInterceptor.java delete mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java delete mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/NegotiationTHandler.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolResponses.java delete mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/core/FailureMapping.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/core/ProtocolResultAdapter.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessFailure.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessInput.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessValues.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/MessageContent.java delete mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationDecision.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationReply.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/ReceivedMessage.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/RouteRequest.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskExecutionResult.java delete mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResponse.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResult.java delete mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskSubmission.java rename workflow-engine/src/main/java/dev/openan/workflow/engine/{control/TaskDispatcher.java => model/UpstreamStepResult.java} (57%) create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowInput.java delete mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/ContentDependencyBoundaryTest.java delete mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolResponsesTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/core/BusinessBoundaryTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/core/ProtocolResultAdapterTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/model/MessageContentTest.java delete mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationDecisionTest.java delete mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/model/TaskSubmissionTest.java diff --git a/samples/pom.xml b/samples/pom.xml index 49919228..b52b81e4 100644 --- a/samples/pom.xml +++ b/samples/pom.xml @@ -31,6 +31,12 @@ a2a-java-sdk-spec-grpc ${a2a.java.sdk.version} + + + net.openan.a2a-t.sdk + a2a-t-client + ${a2a.t.sdk.version} + net.openan.a2a-t.sdk 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 fae1eed4..b9f0f002 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationBaseAgentExecutor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/NegotiationBaseAgentExecutor.java @@ -27,7 +27,6 @@ import net.openan.a2at.sdk.core.validation.ContentValidationException; import org.a2aproject.sdk.server.agentexecution.RequestContext; -import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.Message; @@ -43,7 +42,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.UUID; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; @@ -59,11 +57,12 @@ * (orchestration-center/samples/agents/negotiation_base_agent.py). * *

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

        Negotiation is triggered by the extension header or incomplete SDK-validated task data. A + *

        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. */ @@ -74,7 +73,7 @@ public abstract class NegotiationBaseAgentExecutor extends BaseAgentExecutor { // 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 = + private final java.util.concurrent.ConcurrentMap pendingNegotiationTasks = new java.util.concurrent.ConcurrentHashMap<>(); private volatile A2ATClient a2atClient; @@ -143,16 +142,18 @@ private record NotificationEvent(String artifactName, String content) { */ protected abstract String resolveEnvPath(); - private A2ATClient a2at() { + private synchronized A2ATClient a2at() { if (a2atClient != null) { return a2atClient; } String env = resolveEnvPath(); if (env == null || env.isBlank()) { - return null; + throw new IllegalStateException("A2A-T SDK configuration is required for OMC content validation"); } try { - a2atClient = new A2ATClient(Path.of(env)); + a2atClient = + dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new A2ATClient(Path.of(env))); log.info("[{}] A2ATClient ready for negotiation", getClass().getSimpleName()); return a2atClient; } catch (Exception e) { @@ -163,17 +164,19 @@ private A2ATClient a2at() { private volatile net.openan.a2at.sdk.server.A2ATServer a2atServer; - /** Server-side SDK facade for task-prompt validation; null when unavailable. */ - protected 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()) { - return null; + throw new IllegalStateException("A2A-T SDK configuration is required for OMC content validation"); } try { - a2atServer = new net.openan.a2at.sdk.server.A2ATServer(Path.of(env)); + 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( @@ -207,7 +210,7 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { if (ctx.getMessage() != null && ctx.getMessage().metadata() != null) { Object taskTPrompt = ctx.getMessage().metadata().get(NegotiationUtils.TASK_PROMPT_KEY); if (taskTPrompt instanceof String taskTText - && taskTText.length() > input.length()) { + && !taskTText.isBlank()) { log.info("[{}] Using Task-T prompt from message metadata", getClass().getSimpleName()); input = taskTText; } @@ -231,9 +234,9 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { ctx, emitter, prePositionedExt, getClass().getSimpleName()); } } else if (isNegotiationReply(ctx)) { - handleFollowUp(ctx, emitter, input); + handleFollowUp(ctx, emitter); } else { - handleNewTask(ctx, emitter, input); + handleNewTask(ctx, emitter); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -244,6 +247,7 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { contextId, elapsedMillis(started)); } catch (Exception e) { + pendingNegotiationTasks.remove(taskId); log.error( "[{}] TASK_FAILED taskId={}, contextId={}, elapsedMs={}, errorType={}, message={}", getClass().getSimpleName(), @@ -343,152 +347,153 @@ private void handleNotificationSubscription(RequestContext ctx, AgentEmitter emi } } - /** New task: start negotiation and request input. Business runs on the follow-up. */ - private void handleNewTask(RequestContext ctx, AgentEmitter emitter, String input) { - if (isNegotiationRequested(ctx) || needsNegotiation(input)) { - requestNegotiation(ctx, emitter, input); + /** New task: consume SDK-filled data, not the incoming protocol text. */ + private void handleNewTask(RequestContext ctx, AgentEmitter emitter) { + requireTemplate(ctx.getMessage().metadata(), taskTemplateUri().uri()); + String input = requiredText(ctx.getMessage().metadata(), A2ATExtension.TASK_T.uri()); + Map data; + try { + FilledParamData filled = a2atServer().validateTaskPromptAndDataFilling( + input, buildTaskParamSchema(), taskTemplateUri().uri()); + data = SpnTaskInput.selected(java.util.Objects.requireNonNull(filled, "Task validator returned null").data()); + } catch (ContentValidationException error) { + if (!A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED.equals(error.getCode()) + && !A2ATErrorCodes.SLOT_VALIDATION_ERROR.equals(error.getCode())) throw error; + // A rejected prompt supplies no trusted partial data. Ask for the complete business fields. + data = Map.of(); + } + List missing = invalidTaskFields(data); + if (!missing.isEmpty()) { + requestNegotiation(ctx, emitter, data, missing); } else { - log.info( - "[{}] Parameters sufficient, skipping negotiation", getClass().getSimpleName()); - runBusinessAndComplete(ctx, emitter, input); + log.info("[{}] Parameters sufficient, skipping negotiation", getClass().getSimpleName()); + runBusinessAndComplete(ctx, emitter, new SpnTaskInput(data)); } } - private boolean isNegotiationRequested(RequestContext ctx) { - ServerCallContext callContext = ctx.getCallContext(); - if (callContext == null) return false; - String negUri = NegotiationUtils.NEGOTIATION_T_URI; - return callContext.isExtensionRequested(negUri) - || callContext.getRequestedExtensions().contains(negUri); - } - private boolean isNegotiationReply(RequestContext ctx) { Map meta = ctx.getMessage() != null ? ctx.getMessage().metadata() : null; return NegotiationUtils.hasNegotiationMetadata(meta) && !NegotiationUtils.hasTaskMetadata(meta); } - /** - * Follow-up: validate the negotiation reply, then re-execute the business with the Accept - * text attached to the result artifact metadata (the a2a-java SDK closes the SSE stream - * right after delivering a standalone agent Message, so the Accept must not be sent as one - * before the final artifact). - */ - private void handleFollowUp(RequestContext ctx, AgentEmitter emitter, String input) { - String cleanInput = input; - A2ATClient client = a2at(); - Map meta = - ctx.getMessage() != null ? ctx.getMessage().metadata() : null; - net.openan.a2at.sdk.core.model.NegotiationContext negotiationCtx = - parseNegotiationContext(meta); - if (client != null) { - try { - FilledParamData filled = client.validateAcceptPromptAndDataFilling( - cleanInput, negotiationCtx, buildNegotiationParamSchema(), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); - if (filled != null && filled.data() != null && !filled.data().isEmpty()) { - log.info("[{}] Negotiation params validated and filled: {} keys", - getClass().getSimpleName(), filled.data().keySet()); - } - } catch (Exception e) { - log.warn("[{}] validateAcceptPromptAndDataFilling rejected the follow-up: {}", - getClass().getSimpleName(), e.getMessage()); - emitter.fail(buildStatusMessage( - ctx.getContextId(), - ctx.getTaskId(), - "Negotiation-T Accept validation failed")); - return; + /** 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"); } - String originalInput = pendingNegotiationTasks.remove(ctx.getTaskId()); - String businessInput = - originalInput == null - ? cleanInput - : originalInput + "\n\n## Negotiation-T 补充信息\n" + cleanInput; - log.info( - "[{}] Follow-up received, re-executing business with originalTask={}", - getClass().getSimpleName(), - originalInput != null); - runBusinessAndComplete(ctx, emitter, businessInput); } - /** - * Sends the Negotiation-T propose as an INPUT_REQUIRED status with the propose text and - * metadata attached to the status message. - * - *

        An earlier version sent a standalone Message(ROLE_AGENT) followed by {@code - * requiresInput()}; the a2a-java SDK closes the SSE stream right after delivering a Message - * (per spec §3.1.2 a Message is the complete response), so the queued INPUT_REQUIRED status - * was never delivered and the client hung waiting for a terminal event. Carrying the propose - * inside the status message keeps the stream semantics intact: INPUT_REQUIRED is an - * interrupted (non-final) state, the client's runtime treats it as terminal for the - * sendMessage call, and the engine's negotiation auto-loop picks the propose text up from - * the merged response metadata. - */ - private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, String input) { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - if (taskId != null && input != null) { - pendingNegotiationTasks.put(taskId, input); - } - net.openan.a2at.sdk.core.model.NegotiationContext negotiationContext = - 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); - Map metadata = renderProposeMetadata(negotiationContext, input); - Message proposeMessage = - Message.builder() - .messageId(java.util.UUID.randomUUID().toString()) - .contextId(contextId) - .taskId(taskId) - .role(Message.Role.ROLE_AGENT) - .parts(List.of(new TextPart("存在信息缺失,请补充信息"))) - .metadata(metadata) - .build(); - emitter.requiresInput(proposeMessage); - log.info( - "[{}] Requested negotiation via INPUT_REQUIRED status message (context=id/round/maxRounds/performative)", - getClass().getSimpleName()); + 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); } - /** - * Renders the propose text through the SDK content engine: the subclass's - * {@link #proposeTemplateUri()} picks the template, {@link #buildProposeContent(String)} - * supplies the typed content, and the SDK renders deterministically (no LLM). Missing SDK - * configuration, missing typed content, or rendering failure rejects the task. - */ - private Map renderProposeMetadata( - net.openan.a2at.sdk.core.model.NegotiationContext negotiationContext, - String input) { - A2ATClient client = a2at(); - net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent content = - buildProposeContent(input); - if (client == null) { - throw new IllegalStateException("A2A-T client is required for Negotiation-T propose"); - } - if (content == null) { - throw new IllegalStateException("Typed Negotiation-T propose content is required"); - } - net.openan.a2at.sdk.core.model.MetadataContent mc = - client.generateNegotiationProposePromptFromData( - new net.openan.a2at.sdk.negotiation.content.NegotiationProposeData( - negotiationContext, content), - proposeTemplateUri()); - if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { - throw new IllegalStateException("A2A-T SDK returned empty Negotiation-T propose"); - } - log.info( - "[{}] SDK-rendered propose via {}: {} chars", - getClass().getSimpleName(), - mc.templateUri(), - mc.promptText().length()); - return new LinkedHashMap<>(mc.buildMetadataContent()); + private void endNegotiation(RequestContext ctx, AgentEmitter emitter, String phase, FilledParamData filled) { + String reason = requiredText(filled.data(), "reason"); + log.info("[{}] NEGOTIATION_ENDED taskId={}, performative={}, action=no-diagnosis", + getClass().getSimpleName(), ctx.getTaskId(), phase); + emitter.fail(buildStatusMessage(ctx.getContextId(), ctx.getTaskId(), + "Negotiation-T " + phase + ": " + reason)); + } + + private static Map endingReasonSchema() { + return Map.of("type", "object", "properties", Map.of("reason", + Map.of("type", "string", "minLength", 1, "description", "从拒绝说明或终止原因中提取实际原因,不臆造")), + "required", List.of("reason")); + } + + private Map replySchema(List fields) { + Map properties = new LinkedHashMap<>(); + for (String field : fields) properties.put(field, Map.of("type", "string", "minLength", 1, + "description", field.equals("任务对象") ? "当前地市的任务对象,必须包含实际接入端口名称" + : "完整投诉上下文,必须包含投诉分类及OSS侧事件流水号,保留其他已有信息")); + return Map.of("type", "object", "properties", properties, "required", fields); + } + + private static String requiredText(Map metadata, String key) { + Object value = metadata == null ? null : metadata.get(key); + if (!(value instanceof String text) || text.isBlank() || "null".equalsIgnoreCase(text.strip())) + throw new IllegalArgumentException("Missing nonblank " + key); + return text; + } + + private static void requireTemplate(Map metadata, String expected) { + if (!expected.equals(requiredText(metadata, net.openan.a2at.sdk.core.model.MetadataContent.TEMPLATE_URI_METADATA_KEY))) + throw new IllegalArgumentException("Unexpected templateUri; expected " + expected); + } + + /** Ask for only unresolved business fields; both cities use the same SDK generation path. */ + private void requestNegotiation(RequestContext ctx, AgentEmitter emitter, + Map data, List missing) { + var context = new net.openan.a2at.sdk.core.model.NegotiationContext( + java.util.UUID.randomUUID().toString(), 1, + net.openan.a2at.sdk.core.model.NegotiationContext.DEFAULT_MAX_ROUNDS, + net.openan.a2at.sdk.core.model.NegotiationPerformative.PROPOSE); + var items = missing.stream().map(field -> new net.openan.a2at.sdk.negotiation.content.NegotiationItem( + field, field.equals("任务对象") ? "请提供本地市实际接入端口名称,不能使用其他地市的端口" + : "请提供投诉分类及本次投诉的完整上下文,保留事件流水号")).toList(); + var generated = a2at().generateNegotiationProposePromptFromData( + new net.openan.a2at.sdk.negotiation.content.NegotiationProposeData(context, + new net.openan.a2at.sdk.negotiation.content.InformationProposeContent( + items, "AND:所列字段均为本次诊断必需,必须全部提供")), + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri()); + PendingNegotiation pending = new PendingNegotiation(ctx.getContextId(), context, Map.copyOf(data), List.copyOf(missing)); + if (pendingNegotiationTasks.putIfAbsent(ctx.getTaskId(), pending) != null) + throw new IllegalStateException("Task already waiting for negotiation"); + emitter.requiresInput(Message.builder().messageId(java.util.UUID.randomUUID().toString()) + .contextId(ctx.getContextId()).taskId(ctx.getTaskId()).role(Message.Role.ROLE_AGENT) + .parts(List.of(new TextPart("存在信息缺失,请补充信息"))) + .metadata(generated.buildMetadataContent()) + .extensions(List.of(A2ATExtension.NEGOTIATION_T.uri())).build()); + log.info("[{}] Requested negotiation via INPUT_REQUIRED fields={}", getClass().getSimpleName(), missing); + } + + private record PendingNegotiation(String a2aContextId, + net.openan.a2at.sdk.core.model.NegotiationContext context, + Map validatedData, List requestedFields) {} + + /** Sample-specific semantic checks after SDK filling; no raw protocol text is inspected. */ + protected List invalidTaskFields(Map data) { + return SpnTaskInput.invalidFields(data); } /** Run business logic, emit artifact, and complete the task. */ private void runBusinessAndComplete( - RequestContext ctx, AgentEmitter emitter, String input) { + RequestContext ctx, AgentEmitter emitter, SpnTaskInput input) { String taskId = ctx.getTaskId(); String contextId = ctx.getContextId(); long started = System.nanoTime(); @@ -497,7 +502,7 @@ private void runBusinessAndComplete( getClass().getSimpleName(), taskId, contextId, - input != null ? input.length() : 0); + input.diagnosisInput().length()); String response = executeBusiness(ctx, emitter, input); Map metadata = buildResponseMetadata(ctx, response); List> parts = List.of(new TextPart(response)); @@ -533,96 +538,12 @@ private void emitStatus( emitter.emitEvent(event); } - // ---- subclass extension points ---- - - /** - * The propose template this agent's negotiation starts with. The template URI encodes the - * negotiation type (information / target / feasibility) in its URI segment and selects the - * typed content model expected from {@link #buildProposeContent(String)}. Default: - * information negotiation (missing parameters). Override for target negotiation (intent - * alignment) or feasibility negotiation (whether the request can be fulfilled). - */ - protected net.openan.a2at.sdk.core.model.TemplateUri proposeTemplateUri() { - return StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; - } - - /** - * Typed propose content rendered by the SDK content engine. The default returns {@code null} - * to signal "no typed content"; subclasses override together with {@link - * #proposeTemplateUri()}: - * - *

          - *
        • INFORMATION - {@code InformationProposeContent(items, relationship)} - *
        • TARGET - {@code TargetProposeContent(description, intent, alignment, clarification)} - *
        • FEASIBILITY - {@code FeasibilityProposeContent(description, action, ...)} - *
        - * - * @param input the task input text - * @return typed content; null rejects negotiation because raw fallbacks are not protocol-safe - */ - protected net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent buildProposeContent( - String input) { - return null; - } - - /** - * Whether the incoming task parameters are incomplete/wrong and require a Negotiation-T round - * before proceeding. - * - *

        Default: SDK-driven check — the task prompt runs through the server-side validate-and-fill - * pipeline ({@code A2ATServer.validateTaskPromptAndDataFilling}) against {@link - * #taskTemplateUri()} with {@link #buildTaskParamSchema()}; a validation failure or any blank - * required slot triggers negotiation. Override for a domain-specific heuristic. - */ - protected boolean needsNegotiation(String input) { - A2ATServer server = a2atServer(); - if (server == null) { - return false; - } - try { - FilledParamData filled = server.validateTaskPromptAndDataFilling( - input, buildTaskParamSchema(), taskTemplateUri()); - Map data = filled != null ? filled.data() : null; - if (data == null) { - log.info("[{}] Task validation returned no data; negotiation needed", - getClass().getSimpleName()); - return true; - } - Object properties = buildTaskParamSchema().get("properties"); - java.util.Set expectedFields = - properties instanceof Map propertyMap - ? propertyMap.keySet().stream().map(String::valueOf) - .collect(java.util.stream.Collectors.toCollection( - java.util.LinkedHashSet::new)) - : data.keySet(); - for (String field : expectedFields) { - Object value = data.get(field); - if (value == null || (value instanceof String s && s.isBlank())) { - log.info("[{}] Task param '{}' is blank; negotiation needed", - getClass().getSimpleName(), field); - return true; - } - } - return false; - } catch (ContentValidationException e) { - if (!A2ATErrorCodes.VALIDATION_SEMANTIC_REJECTED.equals(e.getCode()) - && !A2ATErrorCodes.SLOT_VALIDATION_ERROR.equals(e.getCode())) { - throw e; - } - // A semantic/slot rejection is resolvable through Negotiation-T. Infrastructure and - // configuration failures are propagated instead of misreported as missing data. - log.info("[{}] Task validation failed ({}); negotiation needed", - getClass().getSimpleName(), e.getMessage()); - return true; - } - } - /** * Run the agent's actual business logic; return the response text. May emit intermediate * events. */ protected abstract String executeBusiness( - RequestContext ctx, AgentEmitter emitter, String input); + RequestContext ctx, AgentEmitter emitter, SpnTaskInput input); /** Build the task metadata for the completed task (e.g. Authorization-T / Notification-T). */ protected Map buildResponseMetadata(RequestContext ctx, String response) { @@ -639,26 +560,6 @@ protected String buildArtifactName() { return getClass().getSimpleName() + " result"; } - /** - * Parameter JSON Schema for the SDK validate-and-fill pipeline: the business parameters this - * agent expects the negotiation follow-up to carry. Mirrors the typed information-propose - * items returned by {@link #buildProposeContent(String)}. - */ - protected Map buildNegotiationParamSchema() { - Map properties = new LinkedHashMap<>(); - properties.put("接入端口名称", Map.of("type", "string")); - properties.put("投诉分类", Map.of("type", "string")); - Map schema = new LinkedHashMap<>(); - schema.put("type", "object"); - schema.put("properties", properties); - return schema; - } - - /** Default negotiation concern. */ - protected String defaultNegotiationConcern() { - return "参数缺失,需协商补充"; - } - /** * Parses the SDK-carried negotiation context for the validate APIs. * @@ -674,7 +575,10 @@ protected static net.openan.a2at.sdk.core.model.NegotiationContext parseNegotiat 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.A2ATContentFacade.contextFromMap(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; } @@ -684,6 +588,7 @@ private static long elapsedMillis(long startedNanos) { @Override public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { + pendingNegotiationTasks.remove(ctx.getTaskId()); emitter.cancel(); } } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java index 83871400..62cdf79b 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity1Executor.java @@ -37,7 +37,7 @@ /** * SPN Domain Agent for City1 (City1 OMC). * - *

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

        Server-side negotiation-capable (extends {@link NegotiationBaseAgentExecutor}): on an incomplete task * it replies INPUT_REQUIRED to start a Negotiation-T round, and on the follow-up it runs the * diagnosis 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 @@ -54,7 +54,6 @@ public class SpnDomainAgentCity1Executor extends NegotiationBaseAgentExecutor { + "判断原因为对端设备掉电或端口关闭\n" + "3. 修复建议:恢复供电后重新启动网元,或者重新开启端口"; - private static final String OSS_EVENT_ID = "event-id-20260511-09013"; private static final String ACCESS_PORT = "P781-珠江新城-PTN7900-23-TPA1EG24-17"; private static final String RECOVERY_DETAIL = "将时延越限和丢包越限的受影响隧道调优到新的可用路径,保留原路径用于回退。"; @@ -81,9 +80,9 @@ protected String resolveEnvPath() { } @Override - protected String executeBusiness(RequestContext ctx, AgentEmitter emitter, String input) { - String diagnosisResult = llmDiagnosisResult(input, FAULT_DIAGNOSIS_RESULT); - publishRecoveryLifecycle(ctx); + 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; @@ -93,7 +92,7 @@ protected String executeBusiness(RequestContext ctx, AgentEmitter emitter, Strin * 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) { + private void publishRecoveryLifecycle(RequestContext ctx, SpnTaskInput input) { AuthorizationPolicy policy = getAuthorizationPolicy(); boolean inWhitelist = policy != null @@ -103,7 +102,7 @@ private void publishRecoveryLifecycle(RequestContext ctx) { "隧道调优", LocalDate.now(ZoneId.of("Asia/Shanghai"))); String taskId = ctx.getTaskId() != null ? ctx.getTaskId() : "unknown-task"; - pushRecoveryPlan(formatRecoveryEvent("未启动", taskId, inWhitelist, null, null)); + pushRecoveryPlan(formatRecoveryEvent("未启动", taskId, inWhitelist, null, null, input)); if (inWhitelist) { log.info("[SPN-Domain-Agent] Fault in whitelist, self-triggering recovery"); String recoveryResult = @@ -112,7 +111,7 @@ private void publishRecoveryLifecycle(RequestContext ctx) { taskId, true, "成功", - java.time.Instant.now().toString()); + java.time.Instant.now().toString(), input); log.info( "[SPN-Domain-Agent] Recovery result reported via Notification-T: {}", recoveryResult); @@ -128,13 +127,13 @@ private static String formatRecoveryEvent( String taskId, boolean authorized, String executionResult, - String finishedAt) { + 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(OSS_EVENT_ID).append('\n') - .append("4. 接入端口名称:").append(ACCESS_PORT).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); @@ -148,28 +147,12 @@ private static String formatRecoveryEvent( } @Override - protected boolean needsNegotiation(String input) { - // Case 7.4 represents an inventory-level semantic error that a generic JSON schema cannot - // express: this OMC knows port ...-18 is not a valid complaint access port. - if (input != null && input.contains("P781-珠江新城-PTN7900-23-TPA1EG24-18")) { - log.info("[SPN-Domain-Agent] Unknown access port; Negotiation-T correction required"); - return true; - } - return super.needsNegotiation(input); - } - - @Override - protected net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent buildProposeContent( - String input) { - // Information negotiation: the two missing parameters required to start diagnosis. - return new net.openan.a2at.sdk.negotiation.content.InformationProposeContent( - java.util.List.of( - new net.openan.a2at.sdk.negotiation.content.NegotiationItem( - "接入端口名称", - "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1"), - new net.openan.a2at.sdk.negotiation.content.NegotiationItem( - "投诉分类", "举例:专线质差")), - "以上参数均为必选,缺少无法启动诊断"); + 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 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 c9a7a651..b9e4c7fd 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnDomainAgentCity2Executor.java @@ -82,13 +82,22 @@ protected String resolveEnvPath() { } @Override - protected String executeBusiness(RequestContext ctx, AgentEmitter emitter, String input) { - String result = llmDiagnosisResult(input, NORMAL_DIAGNOSIS_RESULT); + protected String executeBusiness(RequestContext ctx, AgentEmitter emitter, SpnTaskInput input) { + String result = llmDiagnosisResult(input.diagnosisInput(), NORMAL_DIAGNOSIS_RESULT); log.info( "[SPN-Domain-Agent-City2] Diagnosis complete (City2), no fault, no recovery needed"); return result; } + @Override + protected 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<>(); diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnTaskInput.java b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnTaskInput.java new file mode 100644 index 00000000..304eb39b --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/agents/SpnTaskInput.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.agents; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** Sample business input, constructed only from SDK-validated task or negotiation fields. */ +public record SpnTaskInput(Map parameters) { + public SpnTaskInput { + parameters = Map.copyOf(parameters); + List invalid = invalidFields(parameters); + if (!invalid.isEmpty()) throw new IllegalArgumentException("Incomplete complaint parameters: " + invalid); + } + + /** Fields requiring clarification; this is SPN business validation, not an engine constraint. */ + public static List invalidFields(Map parameters) { + List fields = new ArrayList<>(); + if (field(parameters, "任务对象", "接入端口名称").isBlank()) fields.add("任务对象"); + if (field(parameters, "任务上下文", "投诉分类").isBlank() + || field(parameters, "任务上下文", "OSS侧事件流水号").isBlank()) fields.add("任务上下文"); + return fields; + } + + /** The access port belonging to this validated city task. */ + public String accessPort() { return field(parameters, "任务对象", "接入端口名称"); } + + /** The original business incident identifier, not the A2A task identifier. */ + public String incidentId() { return field(parameters, "任务上下文", "OSS侧事件流水号"); } + + /** Business-only diagnostic input; never concatenate raw protocol or unvalidated replies here. */ + public String diagnosisInput() { + return "已校验投诉参数\n任务对象:" + parameters.get("任务对象") + + "\n任务上下文:" + parameters.get("任务上下文"); + } + + static String field(Map parameters, String section, String name) { + Object raw = parameters.get(section); + if (!(raw instanceof String text)) return ""; + var match = Pattern.compile(Pattern.quote(name) + "[::]\\s*[\\\"“]?([^;;\\\"”\\r\\n]+)").matcher(text); + String value = match.find() ? match.group(1).strip() : ""; + return "null".equalsIgnoreCase(value) ? "" : value; + } + + static Map selected(Map values) { + Map result = new LinkedHashMap<>(); + for (String field : List.of("任务对象", "任务上下文")) { + Object value = values.get(field); + if (value instanceof String text) result.put(field, text); + } + return Map.copyOf(result); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java b/samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java index dda1e394..a9764df8 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 @@ -15,6 +15,13 @@ public class WorkbenchClientProperties { private String credentialsPath = ""; private boolean sslVerify; private String a2atEnvPath = ""; + private boolean demoNegotiationEnabled; + + /** Internal sample-host setting; SpringSpnDemo supplies its local-only default per application context. */ + public boolean isDemoNegotiationEnabled() { return demoNegotiationEnabled; } + + /** Enables missing-input demonstration for this host only, never a JVM-wide switch. */ + public void setDemoNegotiationEnabled(boolean enabled) { demoNegotiationEnabled = enabled; } public String getOrchUrl() { return orchUrl; 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 d590bcc4..71a39553 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 @@ -45,6 +45,7 @@ public class A2ATProtocolCases { 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; @@ -106,7 +107,7 @@ private void setup() throws Exception { WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .sslVerify(false) - .a2atEnvPath(EnvResolver.resolveEnvPath()) + .credentialsConfigPath( OmcAgentLauncher.resourcePath("spn_agent_credentials.json")) .build(); @@ -116,6 +117,8 @@ private void setup() throws Exception { 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( @@ -145,39 +148,48 @@ private void teardown() { omc.close(); } - /** Case 0: enumerate available A2A-T templates via the engine's template-query surface. */ + /** 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 = client.getPrompts(); + 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 = client.getNegotiationPrompts(); + 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 = client.getPrompt(net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT); + var one = contentClient.getPrompt(net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); log.info("[Case 0] getTaskTemplate(private-line-complaint): present={}", one.isPresent()); } private void case_7_1() throws Exception { log.info("[Case 7.1] Create diagnosis task (Task-T, fromData track)"); SendMessageResult result = - client.sendMessageFromData( - AGENT_NAME, - SpnCasePrompts.TASK_TEXT, - SpnCasePrompts.privateLineComplaintData(), - SpnCasePrompts.privateLineComplaintSchema(), - net.openan.a2at.sdk.core.model.StandardTemplates - .PRIVATE_LINE_COMPLAINT) + sendDiagnosis(dev.openan.workflow.engine.client.A2atMessages.from( + contentClient.generateTaskPromptFromDataWithSchema(SpnCasePrompts.privateLineComplaintData(), SpnCasePrompts.privateLineComplaintSchema(), net.openan.a2at.sdk.core.model.StandardTemplates + .PRIVATE_LINE_COMPLAINT.uri()), + List.of(new org.a2aproject.sdk.spec.TextPart(SpnCasePrompts.TASK_TEXT)))) .join(); log.info("[Case 7.1] state={}, textLen={}", result.getTaskState(), result.getText() != null ? result.getText().length() : 0); if (result.getTask() != null) { lastTaskId.set(result.getTask().id()); log.info("[Case 7.1] taskId={}", result.getTask().id()); } } + private CompletableFuture sendDiagnosis(dev.openan.workflow.engine.model.MessageContent content) { + // Fault-injection cases omit/mutate the transmitted field; the host keeps authoritative input + // to demonstrate a legitimate correction, never a hardcoded engine negotiation fallback. + var request = dev.openan.workflow.engine.model.TaskRequest.builder().agentName(AGENT_NAME) + .executionId(java.util.UUID.randomUUID().toString()).taskId(java.util.UUID.randomUUID().toString()) + .stepName("diagnosis_city1").instruction(SpnCasePrompts.TASK_TEXT) + .input(dev.openan.workflow.engine.model.BusinessInput.data(SpnCasePrompts.privateLineComplaintData())).build(); + var callbacks = new dev.openan.workflow.engine.control.DefaultControlPoint( + new dev.openan.workflow.engine.examples.negotiation.NegotiationStrategy(EnvResolver.resolveEnvPath())); + return client.dispatch(request, content, callbacks); + } + private void case_7_2() throws Exception { String taskId = lastTaskId.get(); if (taskId == null) { log.info("[Case 7.2] No task, running 7.1 first"); case_7_1(); taskId = lastTaskId.get(); } @@ -189,12 +201,11 @@ private void case_7_2() throws Exception { private void case_7_3() { log.info("[Case 7.3] Negotiation - missing required parameter"); int before = negotiationRequests.get(); - Map metadata = new LinkedHashMap<>(); - metadata.put( - A2ATExtension.TASK_T.uri(), + Map metadata = SpnCasePrompts.taskTMetadata( SpnCasePrompts.privateLineComplaintPromptBlankObject()); - metadata.put(NEGOTIATION_T_URI, ""); // activate Negotiation-T extension - SendMessageResult result = client.sendMessage(AGENT_NAME, SpnCasePrompts.TASK_TEXT + "(参数缺失)", null, metadata).join(); + 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"); } @@ -203,13 +214,12 @@ 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.put(A2ATExtension.TASK_DATA_META_KEY, SpnCasePrompts.privateLineComplaintDataUnknownPort()); - metadata.put(A2ATExtension.TASK_SCHEMA_META_KEY, SpnCasePrompts.privateLineComplaintSchema()); - metadata.put( - A2ATExtension.TASK_TEMPLATE_META_KEY, - net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); - metadata.put(NEGOTIATION_T_URI, ""); // activate Negotiation-T extension - SendMessageResult result = client.sendMessage(AGENT_NAME, SpnCasePrompts.TASK_TEXT + "(语义错误)", null, metadata).join(); + 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"); } @@ -233,14 +243,11 @@ private void requireCompletedNegotiation( private void case_7_5() { log.info("[Case 7.5] Add authorization (Authorization-T)"); SendMessageResult result = - authorizationSender.sendExtensionMessageFromData( - AGENT_NAME, - "新增动网操作授权", - SpnCasePrompts.addAuthorizationData(), - SpnCasePrompts.authorizationSchema(), - net.openan.a2at.sdk.core.model.StandardTemplates - .AUTHORIZATION_POLICY_MANAGEMENT, - A2ATExtension.AUTHORIZATION_T) + 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, @@ -253,18 +260,15 @@ private void case_7_5() { private void case_7_6() { log.info("[Case 7.6] Delete authorization (Authorization-T)"); SendMessageResult result = - authorizationSender.sendExtensionMessageFromData( - AGENT_NAME, - "删除动网操作的授权", - Map.of( + authorizationSender.sendAuthorization(AGENT_NAME, + dev.openan.workflow.engine.client.A2atMessages.from( + contentClient.generateAuthPromptFromDataWithSchema(Map.of( "授权策略的操作类型", "删除授权策略", "动网操作的授权策略列表", - "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"), - SpnCasePrompts.authorizationSchema(), - net.openan.a2at.sdk.core.model.StandardTemplates - .AUTHORIZATION_POLICY_MANAGEMENT, - A2ATExtension.AUTHORIZATION_T) + "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, @@ -276,18 +280,15 @@ private void case_7_6() { private void case_7_7() { log.info("[Case 7.7] Query authorization (Authorization-T)"); SendMessageResult result = - authorizationSender.sendExtensionMessageFromData( - AGENT_NAME, - "查询业务抢通操作授权信息", - Map.of( + 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, - A2ATExtension.AUTHORIZATION_T) + "业务场景:业务投诉诊断,处置类型:业务抢通,操作名称:隧道调优"), SpnCasePrompts.authorizationSchema(), net.openan.a2at.sdk.core.model.StandardTemplates + .AUTHORIZATION_POLICY_MANAGEMENT.uri()), + List.of(new org.a2aproject.sdk.spec.TextPart("查询业务抢通操作授权信息")))) .join(); requireExtensionResponse( result, @@ -302,22 +303,16 @@ private void case_7_8() { recoveryPlanReceived.set(false); recoveryNotificationReceived.set(false); notificationSubscription = - notificationSender.openNotificationFromData( - AGENT_NAME, - "订阅业务抢通事件", - SpnCasePrompts.subscribeServiceRecoveryData(), - SpnCasePrompts.serviceRecoverySchema(), - net.openan.a2at.sdk.core.model.StandardTemplates.SERVICE_RECOVERY, - data -> { - log.info("[Case 7.8] callback: keys={}", data.keySet()); - if ("recovery-plan".equals(data.get("artifact_name"))) { - recoveryPlanReceived.set(true); - } - if ("recovery-result".equals(data.get("artifact_name"))) { - recoveryNotificationReceived.set(true); - } - }) - .join(); + 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, 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 c9a5b033..e6451fef 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 @@ -32,7 +32,7 @@ * 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 - * engine's fromData track ({@code WorkflowEngineClient.sendMessageFromData}) and the A2A-T SDK + * 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. @@ -71,6 +71,21 @@ public static List privateLineComplaintSchemaProperties() { 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<>(); @@ -135,9 +150,42 @@ public static Map privateLineComplaintDataCity2() { 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) // ------------------------------------------------------------------ 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 9bc6c80d..f652d5d8 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 @@ -41,7 +41,6 @@ import org.springframework.context.ConfigurableApplicationContext; import java.util.List; -import java.util.Map; import java.util.concurrent.TimeUnit; /** @@ -77,7 +76,11 @@ public static void main(String[] args) throws Exception { public void run(String... applicationArgs) throws Exception { long demoStarted = System.nanoTime(); ConfigurableApplicationContext ctx = null; - boolean success = false; + 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]"); @@ -98,7 +101,11 @@ public void run(String... applicationArgs) throws Exception { stageStarted = System.nanoTime(); log.info("[Demo] STAGE_START stage=start-spring-workbench"); - ctx = SpringApplication.run(SpringWorkbenchApplication.class, applicationArgs); + 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)); @@ -122,6 +129,14 @@ public void run(String... applicationArgs) throws Exception { 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={}", @@ -161,6 +176,9 @@ public void run(String... applicationArgs) throws Exception { 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); @@ -194,7 +212,7 @@ private SendMessageResult sendTaskToWorkbench() throws Exception { null, WorkflowEngineClientConfig.builder() .sslVerify(false) - .a2atEnvPath(envPath) + .credentialsConfigPath(credPath) .build()); DefaultWorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); @@ -208,13 +226,13 @@ private SendMessageResult sendTaskToWorkbench() throws Exception { : wbCard.supportedInterfaces().get(0).url(), SpnCasePrompts.TASK_TEXT.length()); SendMessageResult result = - client.sendMessageFromData( - WB_AGENT_NAME, - SpnCasePrompts.TASK_TEXT, - SpnCasePrompts.privateLineComplaintData(), - SpnCasePrompts.privateLineComplaintSchema(), - net.openan.a2at.sdk.core.model.StandardTemplates - .PRIVATE_LINE_COMPLAINT) + 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={}", 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 2bd32ffd..533b97b5 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,20 +36,26 @@ * 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. The historical class name is - * retained for sample source compatibility; 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 { private static final Logger log = LoggerFactory.getLogger(ExtensionPrePositioner.class); private static final int AUTHORIZATION_VALIDATION_ATTEMPTS = 3; + private final net.openan.a2at.sdk.client.A2ATClient contentClient; private final Map authData; private final Map authSchema; private final Map notifData; private final Map notifSchema; public ExtensionPrePositioner() { + this(dev.openan.workflow.engine.examples.util.EnvResolver.resolveEnvPath()); + } + + public ExtensionPrePositioner(String envPath) { + contentClient = dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new net.openan.a2at.sdk.client.A2ATClient(java.nio.file.Path.of(envPath))); // Structured-data track: the raw policy / subscription fields; the SDK renders the // Authorization-T and Notification-T prompts through the SDK schema-aware pipelines // (spec cases 7.5/7.8; slot extraction may use the SDK-configured LLM). @@ -65,13 +71,13 @@ public ExtensionPrePositioner() { * *

        When {@code notificationCallback} is non-null, subsequent events pushed by agents through * the Notification-T SSE stream (e.g. recovery results) are forwarded to the callback. The - * callback receives a Map with keys: agent, text, metadata, state. + * callback receives the registered handle and complete ReceivedMessage snapshot. */ public List prePosition( ExtensionSender authorizationSender, ExtensionSender notificationSender, List agentCards, - Consumer> notificationCallback) { + java.util.function.BiConsumer notificationCallback) { return prePosition( authorizationSender, notificationSender, agentCards, notificationCallback, null); } @@ -84,7 +90,7 @@ public List prePosition( ExtensionSender authorizationSender, ExtensionSender notificationSender, List agentCards, - Consumer> notificationCallback, + java.util.function.BiConsumer notificationCallback, Consumer subscriptionOpened) { long allStarted = System.nanoTime(); int targetCount = 0; @@ -133,17 +139,13 @@ public List prePosition( NotificationSubscription subscription = null; try { log.info("[PrePosition] SEND extension=Notification-T, agent={}", name); - subscription = - notificationSender - .openNotificationFromData( - name, - "订阅业务抢通事件", - notifData, - notifSchema, - net.openan.a2at.sdk.core.model.StandardTemplates.SERVICE_RECOVERY, - notificationCallback) - .join(); - if (subscriptionOpened != null) { + var notificationContent = contentClient.generateNotificationPromptFromDataWithSchema( + notifData, notifSchema, StandardTemplates.SERVICE_RECOVERY.uri()); + subscription = notificationSender.openNotification(name, + dev.openan.workflow.engine.client.A2atMessages.from(notificationContent, + List.of(new org.a2aproject.sdk.spec.TextPart("订阅业务抢通事件"))), + notificationCallback == null ? (handle, event) -> {} : notificationCallback); + if (subscriptionOpened != null && subscription.isActive()) { subscriptionOpened.accept(subscription); } var notificationResult = subscription.acknowledgement().join(); @@ -191,16 +193,11 @@ public List prePosition( private SendMessageResult sendAuthorization(ExtensionSender sender, String agentName) { SendMessageResult result = null; for (int attempt = 1; attempt <= AUTHORIZATION_VALIDATION_ATTEMPTS; attempt++) { - result = - sender - .sendExtensionMessageFromData( - agentName, - "新增动网操作授权", - authData, - authSchema, - net.openan.a2at.sdk.core.model.StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT, - dev.openan.workflow.engine.client.A2ATExtension.AUTHORIZATION_T) - .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()); 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 3a394d58..40288c17 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 @@ -109,7 +109,9 @@ private synchronized A2ATServer a2atServer() { try { Path envPath = Path.of(env); validationLanguage = A2ATConfig.load(envPath).prompt().language(); - a2atServer = new A2ATServer(envPath); + a2atServer = + dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new A2ATServer(envPath)); return a2atServer; } catch (Exception e) { log.warn("A2ATServer init failed: {}", e.getMessage()); @@ -390,7 +392,7 @@ private FilledParamData validateAuthorizationWithSdk(A2ATServer server, String p return server.validateAuthPromptAndDataFilling( prompt, validationSchema(StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT), - StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT); + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT.uri()); } /** Validates and stores a Notification-T subscription before the server opens its SSE loop. */ @@ -413,7 +415,7 @@ public NotificationPolicy acceptNotification( filled = server.validateNotificationPromptAndDataFilling( prompt, validationSchema(StandardTemplates.SERVICE_RECOVERY), - StandardTemplates.SERVICE_RECOVERY); + StandardTemplates.SERVICE_RECOVERY.uri()); } requireExtractedSectionsMatch( prompt, diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/extension/RecoveryNotification.java b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/RecoveryNotification.java new file mode 100644 index 00000000..ac53a6d8 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/extension/RecoveryNotification.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.examples.extension; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.model.ReceivedMessage; +import java.util.List; +import java.util.Map; +import java.util.LinkedHashMap; +import java.util.regex.Pattern; + +/** + * SPN business completion policy, not an engine or SDK-wide notification schema. + * SDK notification generation/validation describes subscription requests, not recovery events. + */ +public final class RecoveryNotification { + private RecoveryNotification() {} + + /** Close only for a complete recovery result in formal Notification-T artifact metadata. */ + public static boolean hasCompletedResult(ReceivedMessage received) { + return received.artifacts().stream().filter(a -> "recovery-result".equals(a.name())) + .anyMatch(a -> isCompletedResult(a.metadata())); + } + + private static boolean isCompletedResult(Map metadata) { + Object payload = metadata == null ? null : metadata.get(A2ATExtension.NOTIFICATION_T.uri()); + if (!(payload instanceof String text)) return false; + Map fields = new LinkedHashMap<>(); + var matcher = Pattern.compile("(?m)^\\s*\\d+[.、]\\s*([^::\\r\\n]+)[::]\\s*(.*)$").matcher(text); + while (matcher.find()) { + if (fields.putIfAbsent(matcher.group(1).strip(), matcher.group(2).strip()) != null) return false; + } + List required = List.of("业务抢通方案执行状态", "投诉诊断任务流水号", "OSS侧事件流水号", + "接入端口名称", "是否已授权OMC自动抢通", "业务抢通方案名称", "业务抢通方案详情", + "业务抢通方案执行结束时间", "业务抢通方案执行结果"); + if (required.stream().anyMatch(key -> fields.getOrDefault(key, "").isBlank())) return false; + return "已结束".equals(fields.get("业务抢通方案执行状态")) + && List.of("成功", "失败").contains(fields.get("业务抢通方案执行结果")); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategy.java b/samples/src/main/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategy.java index dd86d56c..71384d63 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 @@ -4,14 +4,14 @@ * * SPDX-License-Identifier: Apache-2.0 * - * Licensed under the Apache License, Version 2.0 (the License); you may + * 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 + * 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. @@ -19,59 +19,110 @@ package dev.openan.workflow.engine.examples.negotiation; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.LinkedHashMap; -import java.util.Map; +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.client.A2atMessages; +import dev.openan.workflow.engine.examples.util.A2ATInitialization; +import dev.openan.workflow.engine.model.*; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.*; +import net.openan.a2at.sdk.negotiation.content.*; +import org.a2aproject.sdk.spec.TextPart; +import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.regex.Pattern; -import dev.openan.workflow.engine.model.NegotiationDecision; -import dev.openan.workflow.engine.model.NegotiationRequest; - -/** - * Negotiation decision strategy for the SPN cross-city workbench. - * - *

        Single responsibility: when a downstream agent returns INPUT_REQUIRED (Negotiation-T), - * generate typed decision data that supplements the missing parameters. The returned - * structured Accept decision selects the latest SDK's deterministic typed renderer; it is never - * treated as a hand-written protocol prompt. - * - *

        The Accept payload follows the SDK scenario data for the private-line complaint case: - * the two parameters the agent negotiated for (接入端口名称, 投诉分类), filled with the values - * from the task context. - */ +/** 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 static final Logger log = LoggerFactory.getLogger(NegotiationStrategy.class); + /** The host owns SDK initialization and its provider configuration. */ + public NegotiationStrategy(String a2atEnvPath) { + client = A2ATInitialization.create(() -> new A2ATClient(java.nio.file.Path.of(a2atEnvPath))); + } - /** Filled values for the two negotiated parameters (from the task context, spec case 7.3). */ - private static final String FILLED_PORT_NAME = "P533-珠江旧城-PTN3900-23-TPA1EG24-1"; + @Override + public CompletableFuture resolve(NegotiationRequest request) { + return CompletableFuture.supplyAsync(() -> dev.openan.workflow.engine.examples.util.BusinessSdkErrors.call( + "negotiation-reply", () -> answer(request))); + } - private static final String FILLED_COMPLAINT_CATEGORY = "专线质差"; + 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", "OMC要求补充或修正的投诉诊断字段名称;仅提取实际请求的字段,不臆造字段"), + "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, "当前任务没有可核实的结构化补充信息"); } + List answers = new ArrayList<>(); + for (Object item : requested) { + if (!(item instanceof String name) || name.isBlank()) return abort(context, "协商字段名称无效"); + 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("补充诊断信息")))); + } - public NegotiationStrategy(String a2atEnvPath) { - // Kept in the constructor contract so applications can use one strategy factory for - // environment-aware implementations. This deterministic strategy needs no LLM itself. + 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("终止本次协商")))); } /** - * Make an Accept decision for the given negotiation request. - * - * @param request typed negotiation request, including concern and session information - * @return typed business decision to send back to the agent + * 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. */ - public CompletableFuture resolve(NegotiationRequest request) { - log.info( - "[NegotiationStrategy] agent={}, round={}: {}", - request.agentName(), - request.round(), - request.concern()); - Map values = new LinkedHashMap<>(); - values.put("接入端口名称", FILLED_PORT_NAME); - values.put("投诉分类", FILLED_COMPLAINT_CATEGORY); - NegotiationDecision accept = NegotiationDecision.acceptData(values); - log.info("[NegotiationStrategy] Accept generated for agent={}", request.agentName()); - return CompletableFuture.completedFuture(accept); + protected boolean mayProvideField(TaskRequest task, String fieldName) { + return true; + } + + private NegotiationReply reject(NegotiationContext context, String reason) { + MetadataContent reply = client.generateNegotiationRejectPromptFromData( + new NegotiationEndingData(context, new InformationEndingContent(NegotiationConclusion.REJECT, + List.of(new NegotiationItem("拒绝原因", reason)))), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()); + return new NegotiationReply.Send(A2atMessages.from(reply, List.of(new TextPart("拒绝本次信息请求")))); + } + + private static Map negotiationMetadata(ReceivedMessage received) { + if (received.message() != null && received.message().metadata().containsKey(A2ATExtension.NEGOTIATION_T.uri())) + return received.message().metadata(); + if (received.taskMetadata().containsKey(A2ATExtension.NEGOTIATION_T.uri())) return received.taskMetadata(); + return received.artifacts().stream().map(org.a2aproject.sdk.spec.Artifact::metadata) + .filter(Objects::nonNull).filter(m -> m.containsKey(A2ATExtension.NEGOTIATION_T.uri())) + .findFirst().orElseThrow(() -> new IllegalArgumentException("Missing negotiation content")); + } + + private static String field(Object raw, String label) { + if (!(raw instanceof String text)) return null; + var matcher = + Pattern.compile(Pattern.quote(label) + "[::]\\s*[\\\"“]?([^;;\\\"”\\r\\n]+)") + .matcher(text); + return matcher.find() ? matcher.group(1).strip() : null; } + } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/util/A2ATInitialization.java b/samples/src/main/java/dev/openan/workflow/engine/examples/util/A2ATInitialization.java new file mode 100644 index 00000000..23e7f9f6 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/util/A2ATInitialization.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.util; + +import java.io.*; +import java.net.*; +import java.util.*; +import java.util.function.Supplier; + +/** + * Initializes SDK facades with privately owned jar connections during catalog discovery. The + * current SDK closes the catalog's JarFile; disabling caching on those connections prevents it from + * closing another facade's shared handle. No global URL cache setting is changed. + */ +public final class A2ATInitialization { + private A2ATInitialization() {} + + /** + * Temporarily isolates catalog URLs on the current thread and always restores its classloader. + */ + public static T create(Supplier factory) { + Thread thread = Thread.currentThread(); + ClassLoader original = thread.getContextClassLoader(); + ClassLoader parent = + original == null ? A2ATInitialization.class.getClassLoader() : original; + thread.setContextClassLoader( + new ClassLoader(parent) { + @Override + public Enumeration getResources(String name) throws IOException { + List urls = Collections.list(parent.getResources(name)); + List owned = new ArrayList<>(); + for (URL url : urls) owned.add(uncachedJar(url)); + return Collections.enumeration(owned); + } + }); + try { + return factory.get(); + } finally { + thread.setContextClassLoader(original); + } + } + + private static URL uncachedJar(URL original) throws MalformedURLException { + if (!"jar".equals(original.getProtocol())) return original; + return new URL( + null, + original.toExternalForm(), + new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL ignored) throws IOException { + URLConnection connection = original.openConnection(); + connection.setUseCaches(false); + return connection; + } + }); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrors.java b/samples/src/main/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrors.java new file mode 100644 index 00000000..86c6ffeb --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrors.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.examples.util; + +import dev.openan.workflow.engine.model.BusinessFailure; +import net.openan.a2at.sdk.core.exception.A2ATError; +import java.util.Map; +import java.util.function.Supplier; + +/** Host-owned SDK error translation. Raw provider errors/input/configuration never cross the callback boundary. */ +public final class BusinessSdkErrors { + private BusinessSdkErrors() {} + public static T call(String operation, Supplier action) { + try { return action.get(); } + catch (A2ATError error) { + throw new BusinessFailure(error.getCode(), "A2A-T content operation failed: " + operation, + Map.of("operation", operation)); + } + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExecutor.java index 90674d58..8725db27 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 @@ -109,7 +109,7 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { resolveCredentialsPath(), properties.isSslVerify(), resolveEnvPath()) - .run(input); + .run(input, properties.isDemoNegotiationEnabled()); Map metadata = new LinkedHashMap<>(); metadata.put(A2ATExtension.TASK_T.uri(), result); List> parts = List.of(new TextPart(result)); 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 94d1c113..fbb62e32 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 @@ -22,6 +22,8 @@ public final class SpringWorkbenchExtensionLifecycle { 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; @@ -32,6 +34,7 @@ public synchronized void start() { if (lifecycle != null && lifecycle.isActive()) { return; } + firstRecovery = new java.util.concurrent.CompletableFuture<>(); WorkbenchExtensionLifecycle candidate = new WorkbenchExtensionLifecycle( resolveCredentialsPath(), @@ -63,17 +66,37 @@ public synchronized void close() { lifecycle = null; } - private void onNotification(Map data) { - Object text = data.get("text"); - log.info( - "[Notification] EVENT scope=workbench, agent={}, state={}, textChars={}, metadata={}", - data.get("agent"), - data.get("state"), - text != null ? String.valueOf(text).length() : 0, - data.containsKey("metadata") ? "yes" : "no"); - if (text != null) { - log.debug("[Notification] Recovery result from {}: {}", data.get("agent"), text); + /** + * Bounded observation for the local demo before host shutdown, never a workflow prerequisite. + * Only the first recovery is expected: a healthy city may never publish a recovery result. + */ + public boolean awaitFirstRecovery(java.time.Duration timeout) { + if (timeout.isNegative()) + throw new IllegalArgumentException("timeout must not be negative"); + java.util.concurrent.CompletableFuture observed; + synchronized (this) { + if (lifecycle == null) return false; + observed = firstRecovery; } + try { + observed.get(timeout.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS); + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } catch (java.util.concurrent.ExecutionException + | java.util.concurrent.TimeoutException e) { + return false; + } + } + + private void onNotification(dev.openan.workflow.engine.client.NotificationSubscription handle, + dev.openan.workflow.engine.model.ReceivedMessage received) { + if (dev.openan.workflow.engine.examples.extension.RecoveryNotification.hasCompletedResult(received)) + firstRecovery.complete(null); + log.info("[Notification] EVENT scope=workbench, agent={}, artifacts={}", + handle.agentName(), received.artifacts().size()); + log.debug("[Notification] Content from {}: {}", handle.agentName(), received); } private String resolveEnvPath() { 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 e418c97a..b1ed88b0 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 @@ -21,14 +21,13 @@ import dev.openan.workflow.engine.examples.util.LlmHelper; import dev.openan.workflow.engine.control.DefaultControlPoint; -import dev.openan.workflow.engine.control.TaskDispatcher; -import dev.openan.workflow.engine.model.JumpCondition; -import dev.openan.workflow.engine.model.NegotiationDecision; +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.TaskResponse; -import dev.openan.workflow.engine.model.TaskSubmission; +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.RouteRequest; +import dev.openan.workflow.engine.model.TaskResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,6 +38,9 @@ import dev.openan.workflow.engine.examples.negotiation.NegotiationStrategy; import dev.openan.workflow.engine.examples.util.EnvResolver; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + /** * ControlPoint for the SPN cross-city diagnosis workflow. * @@ -51,9 +53,12 @@ */ 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); @@ -64,177 +69,89 @@ public WorkbenchControlPoint(String a2atEnvPath) { } 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 analyzeFaultLocation(String messageText) { - // Identify City1 fault by spec case 7.1 diagnostic-detail fault signatures, since the - // diagnosis text now follows the document format (no longer contains the "城市1" keyword). - boolean hasCity1Fault = - messageText.contains("无收光") - || messageText.contains("掉电") - || messageText.contains("端口关闭") - || messageText.contains("端口Down") - || (messageText.contains("城市1") - && (messageText.contains("故障") || messageText.contains("Down"))); - boolean hasCity2Fault = - messageText.contains("城市2") - && (messageText.contains("故障") || messageText.contains("Down")); - if (hasCity1Fault) { - // Merge result mirroring spec case 7.1 diagnosis summary in Task-T metadata. - return "1. 诊断结果:成功\n" - + "2. 诊断结果详情:汇总分析完成。故障定位:城市1地市OMC," - + "P33206-YWHJ-业务汇聚机房1(990)的12-TPJ1EM8F-4端口出现无收光问题," - + "判断原因为对端设备掉电或端口关闭\n" - + "3. 修复建议:恢复供电后重新启动网元,或者重新开启端口"; - } - if (hasCity2Fault) { - return "1. 诊断结果:成功\n" - + "2. 诊断结果详情:汇总分析完成。城市2地市OMC端口状态正常," - + "光功率-17dBm(正常范围),无异常告警,故障不在此地市\n" - + "3. 修复建议:城市2无需修复"; - } - return "1. 诊断结果:成功\n2. 诊断结果详情:汇总分析完成。两地市均未见异常。\n3. 修复建议:无需修复"; - } - - /** - * Build the city-scoped structured data for the target agent's step. The upstream context - * (which mixes both cities' info) is NOT forwarded — each sub-agent receives a clean, - * self-contained complaint with only its own scope. Both production workflow branches carry - * complete diagnosis input. Negotiation-T protocol cases are exercised independently and do - * not alter this main business flow. - */ - private static java.util.Map buildTaskData(String step) { - return switch (step) { - case "diagnosis_city1" -> - dev.openan.workflow.engine.examples.demo.SpnCasePrompts - .privateLineComplaintData(); - case "diagnosis_city2" -> - dev.openan.workflow.engine.examples.demo.SpnCasePrompts - .privateLineComplaintDataCity2(); - default -> - dev.openan.workflow.engine.examples.demo.SpnCasePrompts - .privateLineComplaintData(); - }; - } - @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher taskDispatcher) { - String step = request.getStepName(); - String agentName = request.getAgentName(); - // Structured-data track: hand over the city-scoped complaint fields + schema; the SDK - // renders the Task-T prompt through its schema-aware pipeline (no hand-written prompt). - String partsText = "创建专线业务投诉诊断任务"; - Map taskData = buildTaskData(step); - return sendTaskWithRenderingRetry( - taskDispatcher, agentName, partsText, taskData, 1) - .thenApply( - r -> { - boolean success = - r.getTaskState() != null - && r.getTaskState().contains("TASK_STATE_COMPLETED"); - log.info( - "[onTask] Response from {}: {} chars, success={}", - agentName, - r.getText() != null ? r.getText().length() : 0, - success); - return TaskResponse.builder() - .success(success) - .output(r.getText()) - .error(success - ? null - : "Agent returned state=" + r.getTaskState()) - .build(); - }) - .exceptionally( - e -> { - log.error("[onTask] Failed for {}: {}", agentName, e.getMessage()); - return TaskResponse.builder() - .success(false) - .error("Agent call failed: " + e.getMessage()) - .build(); - }); - } - - private CompletableFuture - sendTaskWithRenderingRetry( - TaskDispatcher taskDispatcher, - String agentName, - String message, - Map taskData, - int attempt) { - TaskSubmission submission = - TaskSubmission.fromData( - agentName, - message, - taskData, - dev.openan.workflow.engine.examples.demo.SpnCasePrompts - .privateLineComplaintSchema(), - net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT); - return taskDispatcher - .dispatch(submission) - .exceptionallyCompose(error -> { - Throwable cause = unwrap(error); - if (attempt < 3 - && cause.getMessage() != null - && cause.getMessage().contains("Task-T fromData rendering failed")) { - log.warn( - "[onTask] SDK Task-T rendering failed for {}, retry={}/3: {}", - agentName, - attempt + 1, - cause.getMessage()); - return sendTaskWithRenderingRetry( - taskDispatcher, agentName, message, taskData, attempt + 1); - } - return CompletableFuture.failedFuture(cause); - }); - } - - private static Throwable unwrap(Throwable error) { - Throwable current = error; - while ((current instanceof java.util.concurrent.CompletionException - || current instanceof java.util.concurrent.ExecutionException) - && current.getCause() != null) { - current = current.getCause(); - } - return current; + public CompletableFuture onTask( + TaskRequest request) { + Map data = dev.openan.workflow.engine.examples.demo.SpnCasePrompts.complaintData(request); + return CompletableFuture.supplyAsync(() -> dev.openan.workflow.engine.examples.util.BusinessSdkErrors.call("task-generation", () -> { + var generated = contentClient.generateTaskPromptFromDataWithSchema( + data, dev.openan.workflow.engine.examples.demo.SpnCasePrompts.privateLineComplaintSchema(), + net.openan.a2at.sdk.core.model.StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + MessageContent content = dev.openan.workflow.engine.client.A2atMessages.from(generated, + List.of(new org.a2aproject.sdk.spec.TextPart(request.getInstruction()))); + if (dev.openan.workflow.engine.examples.demo.SpnCasePrompts.injectNegotiation(request.getStepName(), demoNegotiationEnabled)) { + // Explicit local-demo fault injection, not a production generation fallback. + Map metadata = new java.util.LinkedHashMap<>(content.metadata()); + metadata.put(dev.openan.workflow.engine.client.A2ATExtension.TASK_T.uri(), + dev.openan.workflow.engine.examples.demo.SpnCasePrompts.withoutTaskObject( + generated.buildMetadataContent().get(dev.openan.workflow.engine.client.A2ATExtension.TASK_T.uri()).toString())); + log.info("[onTask] DEMO_NEGOTIATION agent={}, fault=missing-port, source=explicit-sample-switch", + request.getAgentName()); + return new MessageContent(content.parts(), metadata, content.extensions()); + } + return content; + })); } @Override - public CompletableFuture onSelfTask(TaskRequest request) { + public CompletableFuture onSelfTask(TaskRequest request) { String step = request.getStepName(); log.info( "[onSelfTask] Self-loop step={}, agent={} (local merge, no A2A-T)", step, request.getAgentName()); - String message = request.getMessage(); - String fallback = analyzeFaultLocation(message); - String sys = "你是SPN跨城故障协同诊断汇总专家。仅依据两地市结果进行汇总,不补造网元和端口。" - + "按1.诊断结果、2.诊断结果详情、3.修复建议输出;明确城市1存在无收光故障、城市2诊断成功且本地正常。" - + "全文不超过450个中文字符,不使用表格,最后一句必须完整结束。"; - String result = LlmHelper.text(a2atEnvPath, sys, message, fallback); + 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( - TaskResponse.builder().success(true).output(result).build()); + 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( - String stepName, Map results, List conditions) { + 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(stepName, results, conditions); + return super.onRoute(request); } @Override - public CompletableFuture onNegotiation(NegotiationRequest request) { + 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 9cc184cc..6f1f53fe 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 @@ -29,7 +29,7 @@ public final class WorkbenchExtensionLifecycle implements AutoCloseable { private final boolean sslVerify; private final String a2atEnvPath; private final Supplier runtimeSupplier; - private final Consumer> notificationCallback; + private final java.util.function.BiConsumer notificationCallback; private final Map subscriptions = new ConcurrentHashMap<>(); private A2ATransport notificationTransport; @@ -38,7 +38,7 @@ public WorkbenchExtensionLifecycle( boolean sslVerify, String a2atEnvPath, Supplier runtimeSupplier, - Consumer> notificationCallback) { + java.util.function.BiConsumer notificationCallback) { this.credentialsPath = credentialsPath; this.sslVerify = sslVerify; this.a2atEnvPath = a2atEnvPath; @@ -65,7 +65,7 @@ public synchronized void start() { WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .sslVerify(sslVerify) - .a2atEnvPath(a2atEnvPath) + .credentialsConfigPath(credentialsPath) .build(); A2ATransport authorizationTransport = @@ -79,7 +79,7 @@ public synchronized void start() { notificationCandidate.getContextId()); try { List opened = - new ExtensionPrePositioner() + new ExtensionPrePositioner(a2atEnvPath) .prePosition( new DefaultExtensionSender(authorizationTransport), new DefaultExtensionSender(notificationCandidate), @@ -124,28 +124,17 @@ public synchronized void close() { subscriptions.clear(); } - private void handleNotification(Map data) { - if ("recovery-result".equals(data.get("artifact_name"))) { - String agent = String.valueOf(data.get("agent")); - NotificationSubscription completed = subscriptions.remove(agent); - if (completed != null) { - log.info( - "[ExtensionLifecycle] NOTIFICATION_COMPLETE agent={}, contextId={}, events={}, action=close-stream", - agent, - completed.contextId(), - completed.heartbeat().eventCount()); - completed.close(); - } + 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(data); - } catch (RuntimeException callbackError) { - log.warn( - "[ExtensionLifecycle] Notification observer failed: {}", - callbackError.getMessage(), - callbackError); - } + 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 419b4f2b..0ec95d21 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 @@ -141,6 +141,11 @@ private static String outputText(Map output) { /** Run the full orchestration pipeline and return the result text. */ public String run(String messageText) throws Exception { + return run(messageText, Boolean.getBoolean("a2at.samples.negotiation")); + } + + /** Runs with an explicit sample-host negotiation setting, without changing global properties. */ + public String run(String messageText, boolean demoNegotiationEnabled) throws Exception { long runStarted = System.nanoTime(); String runtimeName = clientRuntime != null ? clientRuntime.getClass().getSimpleName() : "DefaultA2AJavaClientRuntime"; @@ -183,7 +188,7 @@ public String run(String messageText) throws Exception { clientRuntime, WorkflowEngineClientConfig.builder() .sslVerify(sslVerify) - .a2atEnvPath(a2atEnvPath) + .credentialsConfigPath(credentialsPath) .build()); log.info( @@ -201,7 +206,7 @@ public String run(String messageText) throws Exception { psopId, workflow.getName()); WorkbenchControlPoint controlPoint = - new WorkbenchControlPoint(a2atEnvPath, new NegotiationStrategy(a2atEnvPath)); + new WorkbenchControlPoint(a2atEnvPath, new NegotiationStrategy(a2atEnvPath), demoNegotiationEnabled); ExecutionResult result = ExecutePsop.builder() .psop(workflow) @@ -212,7 +217,7 @@ public String run(String messageText) throws Exception { .lang("zh") .sslVerify(sslVerify) .credentialsConfigPath(credentialsPath) - .a2atEnvPath(a2atEnvPath) + .eventCallback(createLogCallback()) .onFinish( (r, events) -> { 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 887cdbdd..81f78fed 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 @@ -60,8 +60,12 @@ static WorkbenchTaskInputParser fromEnv(String envPath) { throw new IllegalStateException( "A2A-T SDK env path is required to validate inbound Workbench Task-T messages"); } - A2ATServer server = new A2ATServer(Path.of(envPath)); - return new WorkbenchTaskInputParser(server::validateTaskPromptAndDataFilling); + A2ATServer server = + dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new A2ATServer(Path.of(envPath))); + return new WorkbenchTaskInputParser( + (prompt, schema, uri) -> + server.validateTaskPromptAndDataFilling(prompt, schema, uri.uri())); } ParsedTask parse(String userText, Map metadata) { 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 f58a9c9c..6e761e99 100644 --- a/samples/src/test/java/dev/openan/workflow/engine/examples/SpnCrossCityE2ETest.java +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/SpnCrossCityE2ETest.java @@ -77,6 +77,7 @@ class SpnCrossCityE2ETest { private int port1; private int port2; private String sdkEnvPath; + private net.openan.a2at.sdk.client.A2ATClient contentClient; @BeforeAll static void registerMockProvider() { @@ -165,12 +166,14 @@ void setUp() throws Exception { WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .sslVerify(false) - .a2atEnvPath(sdkEnvPath) + .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); } @@ -239,15 +242,10 @@ void fullBusinessPathDiagnosisRecoveryAndSelfLoopMerge() { 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 - .sendExtensionMessageFromData( - agent, - "下发授权放行策略", - SpnCasePrompts.addAuthorizationData(), - SpnCasePrompts.authorizationSchema(), - net.openan.a2at.sdk.core.model.StandardTemplates - .AUTHORIZATION_POLICY_MANAGEMENT, - dev.openan.workflow.engine.client.A2ATExtension.AUTHORIZATION_T) + 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(); } @@ -255,8 +253,12 @@ void fullBusinessPathDiagnosisRecoveryAndSelfLoopMerge() { CountDownLatch recoveryResultNotification = new CountDownLatch(1); AtomicReference recoveryPlanNotificationText = new AtomicReference<>(); AtomicReference recoveryNotificationText = new AtomicReference<>(); - java.util.function.Consumer> notificationCallback = - event -> { + 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) { @@ -272,17 +274,13 @@ void fullBusinessPathDiagnosisRecoveryAndSelfLoopMerge() { recoveryNotificationText.set(protocolContent); recoveryResultNotification.countDown(); } + } }; for (String agent : List.of("SPN Domain Agent City1", "SPN Domain Agent City2")) { NotificationSubscription subscription = - notificationSender.openNotificationFromData( - agent, - "订阅业务抢通结果通知", - SpnCasePrompts.subscribeServiceRecoveryData(), - SpnCasePrompts.serviceRecoverySchema(), - net.openan.a2at.sdk.core.model.StandardTemplates.SERVICE_RECOVERY, - notificationCallback) - .join(); + 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); } @@ -371,7 +369,8 @@ public void onEvent(String type, Map data) { Map mergeOut = result.getStepOutputs().get("merge_analysis"); assertNotNull(mergeOut, "merge_analysis output must exist"); String mergeText = String.valueOf(mergeOut.values().iterator().next()); - assertTrue(mergeText.contains("城市1"), "Merge must locate fault in City1: " + mergeText); + assertTrue(mergeText.contains("SPN Domain Agent City1") && mergeText.contains("无收光"), + "Offline merge must preserve actual City1 identity and diagnosis: " + mergeText); } private static boolean await(CountDownLatch latch, long seconds) { diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DemoNegotiationSettingsTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DemoNegotiationSettingsTest.java new file mode 100644 index 00000000..13aec24a --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DemoNegotiationSettingsTest.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.examples.demo; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import static org.junit.jupiter.api.Assertions.*; + +class DemoNegotiationSettingsTest { + private String enabled; + private String city; + + @BeforeEach void isolateSettings() { + enabled = System.getProperty("a2at.samples.negotiation"); + city = System.getProperty("a2at.samples.negotiation.city"); + System.clearProperty("a2at.samples.negotiation"); + System.clearProperty("a2at.samples.negotiation.city"); + } + + @AfterEach void restoreSettings() { + if (enabled == null) System.clearProperty("a2at.samples.negotiation"); + else System.setProperty("a2at.samples.negotiation", enabled); + if (city == null) System.clearProperty("a2at.samples.negotiation.city"); + else System.setProperty("a2at.samples.negotiation.city", city); + } + + @Test void localDefaultSelectsOnlyCity1AndDoesNotMutateGlobalSettings() { + assertTrue(SpnCasePrompts.demoNegotiationEnabled(true)); + assertTrue(SpnCasePrompts.injectNegotiation("diagnosis_city1", true)); + assertFalse(SpnCasePrompts.injectNegotiation("diagnosis_city2", true)); + assertFalse(SpnCasePrompts.injectNegotiation("merge_analysis", true)); + assertNull(System.getProperty("a2at.samples.negotiation")); + } + + @Test void explicitDisableKeepsCompleteInputs() { + System.setProperty("a2at.samples.negotiation", "false"); + assertFalse(SpnCasePrompts.demoNegotiationEnabled(true)); + assertFalse(SpnCasePrompts.injectNegotiation("diagnosis_city1", false)); + } + + @Test void externalOmcNeverGetsDefaultInjectionAndRejectsExplicitInjection() { + assertFalse(SpnCasePrompts.demoNegotiationEnabled(false)); + System.setProperty("a2at.samples.negotiation", "true"); + assertThrows(IllegalArgumentException.class, () -> SpnCasePrompts.demoNegotiationEnabled(false)); + } + + @Test void invalidSwitchOrCityFailsBeforeStartingServers() { + System.setProperty("a2at.samples.negotiation", "typo"); + assertThrows(IllegalArgumentException.class, () -> SpnCasePrompts.demoNegotiationEnabled(true)); + System.clearProperty("a2at.samples.negotiation"); + System.setProperty("a2at.samples.negotiation.city", "unknown"); + assertThrows(IllegalArgumentException.class, () -> SpnCasePrompts.demoNegotiationEnabled(true)); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DocumentedCallbackExampleTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DocumentedCallbackExampleTest.java new file mode 100644 index 00000000..01002090 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DocumentedCallbackExampleTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.demo; + +import org.junit.jupiter.api.Test; +import java.net.URI; +import java.nio.file.*; +import java.util.*; +import javax.tools.*; +import static org.junit.jupiter.api.Assertions.*; + +class DocumentedCallbackExampleTest { + @Test void bilingualCallbackAndSdkExamplesCompileAgainstCurrentPublicApi() throws Exception { + Path root = Path.of("").toAbsolutePath(); + if (!Files.isDirectory(root.resolve("docs"))) root = root.getParent(); + for (String language : List.of("en", "zh")) { + String doc = Files.readString(root.resolve("docs/" + language + "/BUSINESS_CALLBACKS.md")); + String callbacks = block(doc, "ControlPoint callbacks ="); + String generated = block(doc, "MetadataContent generated ="); + String source = """ + import java.util.*; + import java.util.concurrent.*; + import dev.openan.workflow.engine.control.*; + import dev.openan.workflow.engine.model.*; + import dev.openan.workflow.engine.client.*; + import net.openan.a2at.sdk.client.A2ATClient; + import net.openan.a2at.sdk.core.model.*; + import org.a2aproject.sdk.spec.TextPart; + class DocumentedCallbacks { + ControlPoint callbacks() { + """ + callbacks + "\nreturn callbacks;\n}\n" + + "MessageContent generate(A2ATClient sdk, Map data, Map schema) {\n" + + generated + "\nreturn outgoing;\n}\n}"; + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "Tests require a JDK, not a JRE"); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + JavaFileObject input = new SimpleJavaFileObject(URI.create("string:///DocumentedCallbacks.java"), + JavaFileObject.Kind.SOURCE) { + @Override public CharSequence getCharContent(boolean ignoreEncodingErrors) { return source; } + }; + try (StandardJavaFileManager standard = compiler.getStandardFileManager(diagnostics, null, null); + var output = new ForwardingJavaFileManager(standard) { + @Override public JavaFileObject getJavaFileForOutput(Location location, String name, + JavaFileObject.Kind kind, FileObject sibling) { + return new SimpleJavaFileObject(URI.create("bytes:///" + name + kind.extension), kind) { + @Override public java.io.OutputStream openOutputStream() { + return new java.io.ByteArrayOutputStream(); + } + }; + } + }) { + boolean ok = compiler.getTask(null, output, diagnostics, + List.of("-proc:none", "-classpath", System.getProperty("java.class.path")), + null, List.of(input)).call(); + assertTrue(ok, () -> language + ": " + diagnostics.getDiagnostics()); + } + } + } + + private static String block(String doc, String marker) { + int begin = doc.indexOf(marker); + assertTrue(begin >= 0, marker); + int end = doc.indexOf("\n\u0060\u0060\u0060", begin); + assertTrue(end > begin, marker); + return doc.substring(begin, end); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java new file mode 100644 index 00000000..f9aea3bc --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.demo; + +import dev.openan.workflow.engine.examples.testsupport.OfflineA2ATLlmClient; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +/** Runs the actual northbound Spring demo with local OMCs and the offline SDK provider. */ +class SpringSpnDemoE2ETest { + + @Test + @Timeout(120) + void defaultLocalDemoNegotiatesOnlyCity1WithoutChangingGlobalSwitch() throws Exception { + String enabled = System.getProperty("a2at.samples.negotiation"); + String city = System.getProperty("a2at.samples.negotiation.city"); + java.nio.file.Path log = java.nio.file.Path.of("logs/spn-demo.log"); + long offset = java.nio.file.Files.exists(log) ? java.nio.file.Files.size(log) : 0; + System.clearProperty("a2at.samples.negotiation"); + System.clearProperty("a2at.samples.negotiation.city"); + try { + runDemo(); + try (var input = java.nio.file.Files.newInputStream(log)) { + input.skipNBytes(offset); + String evidence = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("NEGOTIATION_DEMO enabled=true, city=city1")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("[SpnDomainAgentCity1Executor] NEGOTIATION_APPLIED")); + org.junit.jupiter.api.Assertions.assertFalse(evidence.contains("[SpnDomainAgentCity2Executor] NEGOTIATION_APPLIED")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("[SpnDomainAgentCity2Executor] Parameters sufficient, skipping negotiation")); + assertNegotiationWireEvidence(evidence); + org.junit.jupiter.api.Assertions.assertFalse(evidence.matches("(?s).*\\ndata: +\\\"performative\\\".*")); + } + org.junit.jupiter.api.Assertions.assertNull(System.getProperty("a2at.samples.negotiation")); + } finally { + if (enabled == null) System.clearProperty("a2at.samples.negotiation"); + else System.setProperty("a2at.samples.negotiation", enabled); + if (city == null) System.clearProperty("a2at.samples.negotiation.city"); + else System.setProperty("a2at.samples.negotiation.city", city); + } + } + + @Test + @Timeout(120) + void bothCitiesNegotiateIndependentlyAndComplete() throws Exception { + String previous = System.getProperty("a2at.samples.negotiation.city"); + java.nio.file.Path log = java.nio.file.Path.of("logs/spn-demo.log"); + long offset = java.nio.file.Files.exists(log) ? java.nio.file.Files.size(log) : 0; + System.setProperty("a2at.samples.negotiation.city", "both"); + try { + missingPortNegotiatesAndCompletesThroughTheSameSpringWorkflow(); + try (var input = java.nio.file.Files.newInputStream(log)) { + input.skipNBytes(offset); + String evidence = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + for (String city : java.util.List.of("City1", "City2")) { + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains( + "[SpnDomainAgent" + city + "Executor] NEGOTIATION_APPLIED"), city); + } + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("port=P781-珠江新城-PTN7900-23-TPA1EG24-17")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("port=P882-珠江新城-PTN7900-23-TPA1EG24-11")); + } + } finally { + if (previous == null) System.clearProperty("a2at.samples.negotiation.city"); + else System.setProperty("a2at.samples.negotiation.city", previous); + } + } + + @Test + @Timeout(120) + void missingPortNegotiatesAndCompletesThroughTheSameSpringWorkflow() throws Exception { + java.nio.file.Path log = java.nio.file.Path.of("logs/spn-demo.log"); + long offset = java.nio.file.Files.exists(log) ? java.nio.file.Files.size(log) : 0; + String previous = System.getProperty("a2at.samples.negotiation"); + System.setProperty("a2at.samples.negotiation", "true"); + try { + runDemo(); + try (var input = java.nio.file.Files.newInputStream(log)) { + input.skipNBytes(offset); + String evidence = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("DEMO_NEGOTIATION")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("TASK_STATE_INPUT_REQUIRED")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.matches("(?s).*\\\"performative\\\"\\s*:\\s*\\\"PROPOSE\\\".*")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.matches("(?s).*\\\"performative\\\"\\s*:\\s*\\\"ACCEPT\\\".*")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("NORTHBOUND_DONE")); + assertNegotiationWireEvidence(evidence); + } + } finally { + if (previous == null) System.clearProperty("a2at.samples.negotiation"); + else System.setProperty("a2at.samples.negotiation", previous); + } + } + + protected String negotiationWireBoundary() { return "DIRECT_HTTP"; } + + private void assertNegotiationWireEvidence(String evidence) { + String uri = dev.openan.workflow.engine.client.A2ATExtension.NEGOTIATION_T.uri(); + var records = java.util.Arrays.asList(evidence.split("(?m)^.*? PROTOCOL - ")); + String request = records.stream() + .filter(record -> record.startsWith("[" + negotiationWireBoundary() + "] REQUEST")) + .filter(record -> record.contains("\"performative\": \"ACCEPT\"")) + .findFirst().orElseThrow(() -> new AssertionError("No actual ACCEPT request log")); + var id = java.util.regex.Pattern.compile("requestId=([^\\r\\n]+)").matcher(request); + org.junit.jupiter.api.Assertions.assertTrue(id.find()); + String requestId = id.group(1); + org.junit.jupiter.api.Assertions.assertTrue(records.stream().anyMatch(record -> + record.startsWith("[" + negotiationWireBoundary() + "] REQUEST") + && record.contains("requestId=" + requestId) + && record.contains("A2A-Extensions: " + uri)), "Missing actual negotiation activation header"); + org.junit.jupiter.api.Assertions.assertTrue(request.contains("\"" + uri + "\":")); + org.junit.jupiter.api.Assertions.assertTrue(request.contains("\"negotiationContext\": {")); + org.junit.jupiter.api.Assertions.assertTrue(request.contains("=== Body ===\n{\n")); + org.junit.jupiter.api.Assertions.assertTrue(records.stream().anyMatch(record -> + record.contains("RESPONSE_BODY") && record.contains("=== SSE data (JSON display; not wire text) ===") + && record.contains("\"performative\": \"PROPOSE\"")), "Missing observed PROPOSE SSE data"); + } + + protected String[] arguments() { + return new String[] { + "--a2a.transport-mode=direct", + "--a2a.orch-url=http://127.0.0.1:1", + "--a2a.a2at-env-path=" + OfflineA2ATLlmClient.envPath() + }; + } + + @Test + @Timeout(120) + void northboundComplaintCompletesAndResourcesClose() throws Exception { + String previous = System.getProperty("a2at.samples.negotiation"); + java.nio.file.Path log = java.nio.file.Path.of("logs/spn-demo.log"); + long offset = java.nio.file.Files.exists(log) ? java.nio.file.Files.size(log) : 0; + System.setProperty("a2at.samples.negotiation", "false"); + try { + runDemo(); + try (var input = java.nio.file.Files.newInputStream(log)) { + input.skipNBytes(offset); + String evidence = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("NEGOTIATION_DEMO enabled=false")); + org.junit.jupiter.api.Assertions.assertFalse(evidence.contains("DEMO_NEGOTIATION agent=")); + org.junit.jupiter.api.Assertions.assertFalse(evidence.contains("NEGOTIATION_APPLIED")); + } + } finally { + if (previous == null) System.clearProperty("a2at.samples.negotiation"); + else System.setProperty("a2at.samples.negotiation", previous); + } + } + + private void runDemo() throws Exception { + OfflineA2ATLlmClient.install(); + String previous = System.getProperty("a2at.env.path"); + System.setProperty("a2at.env.path", OfflineA2ATLlmClient.envPath()); + try { + // run() propagates failure unless the northbound Task reaches COMPLETED. + new SpringSpnDemo().run(arguments()); + } finally { + if (previous == null) System.clearProperty("a2at.env.path"); + else System.setProperty("a2at.env.path", previous); + } + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositionerTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/ExtensionPrePositionerTest.java index e14d6ebc..d16ffcd1 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,6 +25,16 @@ import org.junit.jupiter.api.Test; class ExtensionPrePositionerTest { + @org.junit.jupiter.api.BeforeEach + void configureOfflineContentSdk() throws Exception { + dev.openan.workflow.engine.examples.testsupport.OfflineA2ATLlmClient.install(); + System.setProperty("a2at.env.path", java.nio.file.Path.of(getClass().getResource("/a2at-e2e.env").toURI()).toString()); + } + + @org.junit.jupiter.api.AfterEach + void clearOfflineConfiguration() { + System.clearProperty("a2at.env.path"); + } @Test void notificationFailureDoesNotBecomeAWorkflowStartupCondition() { @@ -32,7 +42,7 @@ void notificationFailureDoesNotBecomeAWorkflowStartupCondition() { ExtensionSender authorizationSender = sender( (method, args) -> { - if ("sendExtensionMessageFromData".equals(method.getName())) { + if ("sendAuthorization".equals(method.getName())) { authorizationAttempted.set(true); return CompletableFuture.completedFuture( SendMessageResult.builder() @@ -45,9 +55,8 @@ void notificationFailureDoesNotBecomeAWorkflowStartupCondition() { ExtensionSender notificationSender = sender( (method, args) -> { - if ("openNotificationFromData".equals(method.getName())) { - return CompletableFuture.failedFuture( - new IllegalStateException("subscription rejected")); + if ("openNotification".equals(method.getName())) { + throw new IllegalStateException("subscription rejected"); } throw new UnsupportedOperationException(method.getName()); }); @@ -58,7 +67,7 @@ void notificationFailureDoesNotBecomeAWorkflowStartupCondition() { authorizationSender, notificationSender, List.of(city1Card()), - ignored -> {}); + (handle, received) -> {}); assertTrue(authorizationAttempted.get()); assertTrue(subscriptions.isEmpty()); @@ -71,7 +80,7 @@ void authorizationFailureDoesNotBlockSubscriptionOrWorkflowStartup() throws Exce ExtensionSender authorizationSender = sender( (method, args) -> { - if ("sendExtensionMessageFromData".equals(method.getName())) { + if ("sendAuthorization".equals(method.getName())) { return CompletableFuture.failedFuture( new IllegalStateException("authorization rejected")); } @@ -80,9 +89,9 @@ void authorizationFailureDoesNotBlockSubscriptionOrWorkflowStartup() throws Exce ExtensionSender notificationSender = sender( (method, args) -> { - if ("openNotificationFromData".equals(method.getName())) { + if ("openNotification".equals(method.getName())) { notificationAttempted.set(true); - return CompletableFuture.completedFuture(subscription); + return subscription; } throw new UnsupportedOperationException(method.getName()); }); @@ -97,7 +106,7 @@ void authorizationFailureDoesNotBlockSubscriptionOrWorkflowStartup() throws Exce authorizationSender, notificationSender, List.of(city1Card()), - ignored -> {}, + (handle, received) -> {}, opened -> { registeredSubscription.set(opened); registered.countDown(); diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/extension/RecoveryNotificationTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/RecoveryNotificationTest.java new file mode 100644 index 00000000..aa65a11c --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/extension/RecoveryNotificationTest.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.examples.extension; + +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.model.ReceivedMessage; +import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.TextPart; +import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class RecoveryNotificationTest { + private static final String RESULT = "### 业务抢通事件\n" + + "1. 业务抢通方案执行状态:已结束\n2. 投诉诊断任务流水号:task-42\n" + + "3. OSS侧事件流水号:event-42\n4. 接入端口名称:port-42\n" + + "5. 是否已授权OMC自动抢通:是\n6. 业务抢通方案名称:隧道调优\n" + + "7. 业务抢通方案详情:执行指定调优策略\n8. 业务抢通方案执行结束时间:2026-08-31T00:00:00Z\n" + + "9. 业务抢通方案执行结果:成功"; + + private ReceivedMessage received(String name, String formal, String summary) { + return new ReceivedMessage(null, Map.of(), List.of(Artifact.builder().artifactId("event").name(name) + .parts(List.of(new TextPart(summary))).metadata(formal == null ? Map.of() + : Map.of(A2ATExtension.NOTIFICATION_T.uri(), formal)).build())); + } + + @Test void completedSuccessAndFailureAreBothTerminalRecoveryResults() { + assertTrue(RecoveryNotification.hasCompletedResult(received("recovery-result", RESULT, "summary"))); + assertTrue(RecoveryNotification.hasCompletedResult(received("recovery-result", RESULT.replace("结果:成功", "结果:失败"), "summary"))); + } + + @Test void artifactNameOrPartsAloneNeverCompletesSubscription() { + assertFalse(RecoveryNotification.hasCompletedResult(received("recovery-result", null, RESULT))); + assertFalse(RecoveryNotification.hasCompletedResult(received("recovery-result", "订阅成功", RESULT))); + assertFalse(RecoveryNotification.hasCompletedResult(received("recovery-plan", RESULT, "summary"))); + } + + @Test void incompleteOrAmbiguousResultKeepsSubscriptionOpen() { + assertFalse(RecoveryNotification.hasCompletedResult(received("recovery-result", RESULT.replace("已结束", "未启动"), "summary"))); + assertFalse(RecoveryNotification.hasCompletedResult(received("recovery-result", RESULT.replace("OSS侧事件流水号:event-42", "OSS侧事件流水号:"), "summary"))); + assertFalse(RecoveryNotification.hasCompletedResult(received("recovery-result", RESULT + "\n10. 接入端口名称:other", "summary"))); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategyTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategyTest.java new file mode 100644 index 00000000..aa3f604b --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/negotiation/NegotiationStrategyTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.negotiation; + +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; +import dev.openan.workflow.engine.examples.testsupport.OfflineA2ATLlmClient; +import dev.openan.workflow.engine.examples.util.A2ATInitialization; +import dev.openan.workflow.engine.client.*; +import dev.openan.workflow.engine.model.*; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.*; +import net.openan.a2at.sdk.negotiation.content.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class NegotiationStrategyTest { + private String env; + @BeforeEach void configure() throws Exception { + OfflineA2ATLlmClient.install(); + env = java.nio.file.Path.of(getClass().getResource("/a2at-e2e.env").toURI()).toString(); + } + + @Test void twoCitiesUseTheirOwnInputs() { + String first = answer(SpnCasePrompts.privateLineComplaintData()); + String second = answer(SpnCasePrompts.privateLineComplaintDataCity2()); + assertTrue(first.contains("P781-")); + assertTrue(second.contains("P882-")); + assertNotEquals(first, second); + } + + @Test void missingInformationSendsAbortWithoutInventingPort() { + var reply = assertInstanceOf(NegotiationReply.Send.class, + new NegotiationStrategy(env).resolve(request(SpnCasePrompts.privateLineComplaintDataBlankObject())).join()); + var received = new ReceivedMessage(reply.content(), Map.of(), List.of()); + assertEquals(NegotiationPerformative.ABORT, A2atMessages.contextOf(received).performative()); + } + + @Test void hostDisclosurePolicyUsesSdkReject() { + var strategy = new NegotiationStrategy(env) { + @Override protected boolean mayProvideField(TaskRequest task, String fieldName) { return false; } + }; + var reply = assertInstanceOf(NegotiationReply.Send.class, + strategy.resolve(request(SpnCasePrompts.privateLineComplaintData())).join()); + assertEquals(NegotiationPerformative.REJECT, + A2atMessages.contextOf(new ReceivedMessage(reply.content(), Map.of(), List.of())).performative()); + assertTrue(reply.content().metadata().get(A2ATExtension.NEGOTIATION_T.uri()).toString().contains("业务策略不允许提供")); + } + + 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) { + 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(List.of(new NegotiationItem("接入端口名称", "补充实际端口"), + new NegotiationItem("投诉分类", "补充实际分类")), "AND")), + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri()); + var received = new ReceivedMessage(A2atMessages.from(generated, List.of()), Map.of(), List.of()); + return new NegotiationRequest(TaskRequest.builder().taskId(UUID.randomUUID().toString()).agentName("city") + .input(BusinessInput.data(input)).build(), MessageContent.text("diagnose"), received, + List.of(), java.time.Duration.ofSeconds(10)); + } + + @Test void hostCanChooseNaturalLanguageOrStructuredTaskGeneration() { + var sdk = A2ATInitialization.create(() -> new A2ATClient(java.nio.file.Path.of(env))); + var data = SpnCasePrompts.privateLineComplaintData(); + String text = "请进行投诉诊断。任务对象:" + data.get("任务对象") + + ";任务上下文:" + data.get("任务上下文"); + for (var generated : List.of( + sdk.generateTaskPromptFromText(text, StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()), + sdk.generateTaskPromptFromDataWithSchema(data, SpnCasePrompts.privateLineComplaintSchema(), + StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()))) { + MessageContent content = A2atMessages.from(generated, + List.of(new org.a2aproject.sdk.spec.TextPart("business-owned accompanying text"))); + assertEquals(generated.buildMetadataContent(), content.metadata()); + assertEquals(Set.of(A2ATExtension.TASK_T.uri()), content.extensions()); + assertTrue(generated.promptText().contains("P781-")); + } + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/server/EmbeddedA2AServerTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/server/EmbeddedA2AServerTest.java index 52ee58d4..8405bbaf 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 @@ -32,6 +32,15 @@ import dev.openan.workflow.engine.examples.server.JdkHttpA2AServer; import dev.openan.workflow.engine.examples.server.OmcAgentLauncher; import dev.openan.workflow.engine.model.SendMessageResult; +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.ReceivedMessage; +import dev.openan.workflow.engine.client.A2atMessages; +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.agents.SpnTaskInput; +import dev.openan.workflow.engine.examples.demo.SpnCasePrompts; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.*; +import net.openan.a2at.sdk.negotiation.content.*; import org.a2aproject.sdk.spec.AgentCard; import org.junit.jupiter.api.AfterEach; @@ -60,6 +69,19 @@ class EmbeddedA2AServerTest { 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() { @@ -155,7 +177,8 @@ void setUp() throws Exception { "tenant", ""))); server = - new OmcAgentLauncher().startFromCard(card, new SpnDomainAgentCity1Executor()); + new OmcAgentLauncher().startFromCard(card, new RecordingCity1()); + agentCard = mapper.convertValue(card, AgentCard.class); Thread.sleep(500); transport = new A2ATransport( @@ -163,12 +186,19 @@ void setUp() throws Exception { null, WorkflowEngineClientConfig.builder() .sslVerify(false) - .a2atEnvPath(sdkEnvPath) + .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(); @@ -193,7 +223,7 @@ void testGetAgentCard() throws Exception { @Test void testSendMessage() throws Exception { - SendMessageResult result = client.sendMessage(AGENT_NAME, "diagnose SPN fault").join(); + SendMessageResult result = client.dispatch(request(), new WorkbenchControlPoint(sdkEnvPath).onTask(request()).join(), new WorkbenchControlPoint(sdkEnvPath)).join(); assertNotNull(result); assertFalse( result.getText().isEmpty(), @@ -207,16 +237,14 @@ void testSendMessage() throws Exception { } /** - * Negotiation path: when the message metadata carries a Task-T prompt plus the Negotiation-T - * extension key, the agent starts a negotiation (propose + INPUT_REQUIRED); the engine's + * 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 prompt with a blank task object so server-side validation would also flag it; - // the Negotiation-T key activates the extension on the A2A-Extensions header. + // 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" @@ -225,12 +253,12 @@ void testNegotiationAutoLoopCompletes() { metadata.put( "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1", taskPrompt); - metadata.put( - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", - ""); + metadata.put(MetadataContent.TEMPLATE_URI_METADATA_KEY, StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); SendMessageResult result = client - .sendMessage(AGENT_NAME, "diagnose SPN fault", null, metadata) + .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); @@ -242,6 +270,152 @@ void testNegotiationAutoLoopCompletes() { "Final response must be the completed diagnosis, got: " + result.getText()); } + + private A2ATClient contentSdk() { + return dev.openan.workflow.engine.examples.util.A2ATInitialization.create( + () -> new A2ATClient(java.nio.file.Path.of(sdkEnvPath))); + } + + private SendMessageResult startIncomplete() { + var content = new MessageContent(List.of(new org.a2aproject.sdk.spec.TextPart("diagnose")), + SpnCasePrompts.taskTMetadata(SpnCasePrompts.privateLineComplaintPromptBlankObject()), + java.util.Set.of(A2ATExtension.TASK_T.uri())); + var result = transport.send(agentCard, AGENT_NAME, content, java.util.UUID.randomUUID().toString(), null, null).join(); + assertEquals("TASK_STATE_INPUT_REQUIRED", result.getTaskState()); + assertEquals(0, diagnosisCount); + return result; + } + + private NegotiationContext negotiationContext(SendMessageResult pending) { + return pending.getReceivedMessages().stream().map(A2atMessages::contextOf) + .filter(java.util.Objects::nonNull).findFirst().orElseThrow(); + } + + private MetadataContent accept(NegotiationContext context, Map data) { + return contentSdk().generateNegotiationAcceptPromptFromData(new NegotiationEndingData(context, + new InformationEndingContent(NegotiationConclusion.ACCEPT, data.entrySet().stream() + .map(e -> new NegotiationItem(e.getKey(), e.getValue().toString())).toList())), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()); + } + + private SendMessageResult followUp(SendMessageResult pending, MessageContent content) { + return transport.send(agentCard, AGENT_NAME, content, pending.getTask().contextId(), + pending.getTask().id(), null).orTimeout(30, java.util.concurrent.TimeUnit.SECONDS).join(); + } + + @Test void acceptUsesValidatedMetadataAndPreservesActualIncidentNotPartsSummary() { + var pending = startIncomplete(); + var data = new java.util.LinkedHashMap<>(SpnCasePrompts.privateLineComplaintData()); + data.put("任务上下文", data.get("任务上下文").toString().replace("event-id-20260511-09013", "incident-actual-42")); + var reply = A2atMessages.from(accept(negotiationContext(pending), data), + List.of(new org.a2aproject.sdk.spec.TextPart("misleading summary P999-wrong"))); + var result = followUp(pending, reply); + assertEquals("TASK_STATE_COMPLETED", result.getTaskState()); + assertEquals(1, diagnosisCount); + assertEquals("incident-actual-42", diagnosed.incidentId()); + assertEquals(data, diagnosed.parameters()); + assertFalse(diagnosed.diagnosisInput().contains("misleading summary")); + } + + @Test void normalTaskUsesFilledInputWithoutNegotiation() { + var data = new java.util.LinkedHashMap<>(SpnCasePrompts.privateLineComplaintData()); + data.put("任务上下文", data.get("任务上下文").toString().replace("event-id-20260511-09013", "incident-normal-19")); + var generated = contentSdk().generateTaskPromptFromDataWithSchema(data, + SpnCasePrompts.privateLineComplaintSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); + var result = transport.send(agentCard, AGENT_NAME, A2atMessages.from(generated, List.of(new org.a2aproject.sdk.spec.TextPart("business summary"))), + java.util.UUID.randomUUID().toString(), null, null).join(); + assertEquals("TASK_STATE_COMPLETED", result.getTaskState()); + assertEquals(data, diagnosed.parameters()); + assertEquals("incident-normal-19", diagnosed.incidentId()); + assertEquals(1, diagnosisCount); + } + + + @Test void unknownPortNegotiatesOnlyObjectAndKeepsValidatedContext() { + var original = new java.util.LinkedHashMap<>(SpnCasePrompts.privateLineComplaintDataUnknownPort()); + original.put("任务上下文", original.get("任务上下文").toString().replace("event-id-20260511-09013", "incident-preserved-73")); + var generated = contentSdk().generateTaskPromptFromDataWithSchema(original, + SpnCasePrompts.privateLineComplaintSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); + var pending = transport.send(agentCard, AGENT_NAME, A2atMessages.from(generated, + List.of(new org.a2aproject.sdk.spec.TextPart("diagnose unknown port"))), + java.util.UUID.randomUUID().toString(), null, null).join(); + assertEquals("TASK_STATE_INPUT_REQUIRED", pending.getTaskState()); + var reply = accept(negotiationContext(pending), + Map.of("任务对象", SpnCasePrompts.privateLineComplaintData().get("任务对象"))); + var result = followUp(pending, A2atMessages.from(reply, List.of(new org.a2aproject.sdk.spec.TextPart("correct port")))); + assertEquals("TASK_STATE_COMPLETED", result.getTaskState()); + assertEquals(original.get("任务上下文"), diagnosed.parameters().get("任务上下文")); + assertEquals("incident-preserved-73", diagnosed.incidentId()); + assertEquals(1, diagnosisCount); + } + + @Test void taskPartsCannotReplaceMissingFormalTaskMetadata() { + var generated = contentSdk().generateTaskPromptFromDataWithSchema(SpnCasePrompts.privateLineComplaintData(), + SpnCasePrompts.privateLineComplaintSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); + var metadata = new java.util.LinkedHashMap<>(generated.buildMetadataContent()); + metadata.remove(A2ATExtension.TASK_T.uri()); + var content = new MessageContent(List.of(new org.a2aproject.sdk.spec.TextPart(generated.promptText())), + metadata, java.util.Set.of(A2ATExtension.TASK_T.uri())); + var result = transport.send(agentCard, AGENT_NAME, content, + java.util.UUID.randomUUID().toString(), null, null).join(); + assertEquals("TASK_STATE_FAILED", result.getTaskState()); + assertEquals(0, diagnosisCount); + } + + @Test void partsCannotRescueInvalidFormalNegotiationBody() { + var pending = startIncomplete(); + var generated = accept(negotiationContext(pending), SpnCasePrompts.privateLineComplaintData()); + var metadata = new java.util.LinkedHashMap<>(generated.buildMetadataContent()); + metadata.put(A2ATExtension.NEGOTIATION_T.uri(), "补充诊断信息"); + var result = followUp(pending, new MessageContent( + List.of(new org.a2aproject.sdk.spec.TextPart(generated.promptText())), metadata, + java.util.Set.of(A2ATExtension.NEGOTIATION_T.uri()))); + assertEquals("TASK_STATE_FAILED", result.getTaskState()); + assertEquals(0, diagnosisCount); + } + + @Test void incompleteAcceptDoesNotDiagnose() { + var pending = startIncomplete(); + var generated = accept(negotiationContext(pending), Map.of("任务对象", "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17")); + var result = followUp(pending, A2atMessages.from(generated, List.of(new org.a2aproject.sdk.spec.TextPart("business summary")))); + assertEquals("TASK_STATE_FAILED", result.getTaskState()); + assertEquals(0, diagnosisCount); + } + + @Test void rejectIsValidatedAndEndsWithoutDiagnosis() { + var pending = startIncomplete(); + var generated = contentSdk().generateNegotiationRejectPromptFromData( + new NegotiationEndingData(negotiationContext(pending), + new InformationEndingContent(NegotiationConclusion.REJECT, + List.of(new NegotiationItem("拒绝原因", "当前调用方无权提供该端口资料")))), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri()); + var result = followUp(pending, A2atMessages.from(generated, List.of(new org.a2aproject.sdk.spec.TextPart("business summary")))); + assertEquals("TASK_STATE_FAILED", result.getTaskState()); + assertTrue(result.getText().contains("Negotiation-T REJECT"), result.getText()); + assertEquals(0, diagnosisCount); + } + + @Test void abortIsValidatedAndEndsWithoutDiagnosis() { + var pending = startIncomplete(); + var generated = contentSdk().generateNegotiationAbortPromptFromData( + new NegotiationAbortData(negotiationContext(pending), new NegotiationAbortContent("业务取消本次诊断")), + StandardTemplates.NEGOTIATION_ABORT.uri()); + var result = followUp(pending, A2atMessages.from(generated, List.of(new org.a2aproject.sdk.spec.TextPart("business summary")))); + assertEquals("TASK_STATE_FAILED", result.getTaskState()); + assertTrue(result.getText().contains("Negotiation-T ABORT"), result.getText()); + assertEquals(0, diagnosisCount); + } + + @Test void replyFromAnotherNegotiationDoesNotDiagnose() { + var pending = startIncomplete(); + var context = negotiationContext(pending); + var other = new NegotiationContext(java.util.UUID.randomUUID().toString(), context.round(), + context.maxRounds(), context.performative()); + var result = followUp(pending, A2atMessages.from(accept(other, SpnCasePrompts.privateLineComplaintData()), List.of(new org.a2aproject.sdk.spec.TextPart("reply")))); + assertEquals("TASK_STATE_FAILED", result.getTaskState()); + assertEquals(0, diagnosisCount); + } + @Test void taskRouteParserDecodesExactlyOnePathSegment() { assertEquals("task id+/city", JdkHttpA2AServer.taskIdFromPath("/tasks/task%20id%2B%2Fcity", null)); 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 f3429859..975cffea 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 @@ -63,12 +63,16 @@ public LLMResponse structured( if (propertiesValue instanceof Map properties && properties.containsKey("semantic_verdict")) { Map response = new LinkedHashMap<>(); - response.put("semantic_verdict", true); + 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", List.of()); - response.put("params", validationParams(properties, prompt)); + 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 @@ -84,8 +88,8 @@ public LLMResponse structured( "name", "接入端口名称", "value", - "P533-珠江旧城-PTN3900-23-TPA1EG24-1"), - Map.of("name", "投诉分类", "value", "专线质差")))); + portValue(validationInput(prompt))), + Map.of("name", "投诉分类", "value", label(validationInput(prompt), "投诉分类"))))); } Object slotNames = jsonSchema.get("slotNames"); @@ -93,7 +97,7 @@ public LLMResponse structured( Map slots = new LinkedHashMap<>(); for (Object rawName : names) { String name = String.valueOf(rawName); - slots.put(name, slotValue(name, prompt)); + slots.put(name, slotValue(name, validationInput(prompt))); } return response(Map.of("slots", slots, "slot_errors", List.of())); } @@ -101,92 +105,129 @@ public LLMResponse structured( "Unsupported offline A2A-T LLM schema: " + jsonSchema.keySet()); } - private static Map validationParams( - Map outputProperties, String prompt) { - Object paramsValue = outputProperties.get("params"); - if (paramsValue instanceof Map paramsSchema - && paramsSchema.get("properties") instanceof Map businessProperties - && !businessProperties.isEmpty()) { - Map params = new LinkedHashMap<>(); - for (Object rawName : businessProperties.keySet()) { - String name = String.valueOf(rawName); - params.put(name, slotValue(name, prompt)); + 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)) { + values.put(name, negotiationItems(source).keySet().stream().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 params; } - if (prompt.contains("授权策略") || prompt.contains("白名单")) { - return authorizationData(prompt); + 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 (prompt.contains("订阅条件") || prompt.contains("上报通知数据格式")) { - return SpnCasePrompts.subscribeServiceRecoveryData(); + 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(); } - Map task = new LinkedHashMap<>(); - task.put("任务对象", slotValue("任务对象", prompt)); - task.put("任务上下文", slotValue("任务上下文", prompt)); - return task; + return true; } - private static Map authorizationData(String prompt) { - Map data = new LinkedHashMap<>(); - data.put("授权策略的操作类型", authorizationOperation(prompt)); - int ruleIndex = prompt.indexOf("业务投诉诊断,业务抢通,隧道调优,"); - int idIndex = prompt.indexOf("7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"); - data.put( - "动网操作的授权策略列表", - idIndex >= 0 && (ruleIndex < 0 || idIndex < ruleIndex) - ? "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3" - : "业务投诉诊断,业务抢通,隧道调优,2026-06-01~2030-06-18"); - return data; + 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 prompt) { - if (name.contains("授权策略的操作类型") || name.contains("操作类型")) { - return authorizationOperation(prompt); - } - if (name.contains("动网操作的授权策略列表") || name.contains("策略列表")) { - return authorizationData(prompt).get("动网操作的授权策略列表"); - } - if (name.contains("订阅条件")) return ""; - if (name.contains("上报通知数据格式")) { - return SpnCasePrompts.subscribeServiceRecoveryData().get("上报通知数据格式"); - } - if (name.contains("接入端口名称")) { - return "P533-珠江旧城-PTN3900-23-TPA1EG24-1"; - } - if (name.contains("投诉分类")) return "专线质差"; - if (name.contains("任务对象")) { - if (prompt.contains("P882-")) { - return "接入端口名称:P882-珠江新城-PTN7900-23-TPA1EG24-11"; - } - if (prompt.contains("\"任务对象\":\"\"") - || prompt.contains("'任务对象': ''")) { - return ""; + 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 "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17"; } - if (name.contains("任务上下文")) { - return "投诉分类:专线质差;OSS侧事件流水号:event-id-20260511-09013"; - } - return "test-value"; + return ""; } - private static String authorizationOperation(String prompt) { - Map candidates = - Map.of( - "新增授权策略", "新增授权策略", - "修改授权策略", "修改授权策略", - "删除授权策略", "删除授权策略", - "查询授权策略", "查询授权策略"); - int earliest = Integer.MAX_VALUE; - String operation = "新增授权策略"; - for (Map.Entry candidate : candidates.entrySet()) { - int index = prompt.indexOf(candidate.getKey()); - if (index >= 0 && index < earliest) { - earliest = index; - operation = candidate.getValue(); - } - } - return operation; + private static String section(String source, String name) { + var heading = java.util.regex.Pattern.compile("(?m)^##\\s+" + java.util.regex.Pattern.quote(name) + + "(?:\\([^\\r\\n]*\\))?[ \\t]*\\r?\\n").matcher(source); + if (!heading.find()) return ""; + int end = source.indexOf("\n## ", heading.end()); + String body = source.substring(heading.end(), end < 0 ? source.length() : end); + int guidance = body.indexOf("\n要求"); + if (guidance >= 0) body = body.substring(0, guidance); + return body.replace("(必选)", "").replace("(必填)", "").strip(); + } + + private static String label(String source, String name) { + var matcher = java.util.regex.Pattern.compile(java.util.regex.Pattern.quote(name) + + "[::]\\s*[\\\"“]?([^;;\\\"”\\r\\n]+)").matcher(source); + return matcher.find() ? matcher.group(1).strip() : ""; + } + + /** Only actual inputs, never explanatory examples or template guidance. */ + private static String validationInput(String prompt) { + int start = prompt.indexOf("[input]\n"); + if (start >= 0) { + start += "[input]\n".length(); + int end = prompt.indexOf("\n\n[slots]", start); + return prompt.substring(start, end < 0 ? prompt.length() : end); + } + start = prompt.indexOf("输入内容:"); + if (start >= 0) { + int end = prompt.indexOf("\n模板标识:", start); + return prompt.substring(start + "输入内容:".length(), end < 0 ? prompt.length() : end); + } + start = prompt.indexOf("待校验的协商报文:\n"); + if (start >= 0) { + int end = prompt.indexOf("\n\n请按系统提示词", start); + return prompt.substring(start + "待校验的协商报文:\n".length(), end < 0 ? prompt.length() : end); + } + return prompt; + } + + private static String portValue(String prompt) { + var matcher = java.util.regex.Pattern.compile("P\\d+-[\\p{L}\\p{N}-]+").matcher(prompt); + return matcher.find() ? matcher.group() : ""; } private LLMResponse response(Map body) { diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/util/A2ATInitializationTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/util/A2ATInitializationTest.java new file mode 100644 index 00000000..de848e34 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/util/A2ATInitializationTest.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.util; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class A2ATInitializationTest { + @Test + void restoresContextClassLoaderAfterSuccessAndFailure() { + var original = Thread.currentThread().getContextClassLoader(); + assertEquals( + "created", + A2ATInitialization.create( + () -> { + assertNotSame(original, Thread.currentThread().getContextClassLoader()); + return "created"; + })); + assertSame(original, Thread.currentThread().getContextClassLoader()); + assertThrows( + IllegalStateException.class, + () -> + A2ATInitialization.create( + () -> { + throw new IllegalStateException("failure"); + })); + assertSame(original, Thread.currentThread().getContextClassLoader()); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrorsTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrorsTest.java new file mode 100644 index 00000000..9832670f --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/util/BusinessSdkErrorsTest.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.examples.util; + +import dev.openan.workflow.engine.model.BusinessFailure; +import net.openan.a2at.sdk.core.exception.A2ATError; +import org.junit.jupiter.api.Test; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class BusinessSdkErrorsTest { + @Test void exposesSdkCodeAndOnlyHostSelectedDetails() { + BusinessFailure failure = assertThrows(BusinessFailure.class, () -> BusinessSdkErrors.call( + "task-generation", () -> { throw new A2ATError("slot.validation_error", + "password=must-not-leak accessSession=secret"); })); + assertEquals("slot.validation_error", failure.code()); + assertEquals(Map.of("operation", "task-generation"), failure.details()); + assertNull(failure.getCause()); + assertFalse(failure.toString().contains("must-not-leak")); + } +} diff --git a/samples/src/test/resources/spn_agent_credentials.json b/samples/src/test/resources/spn_agent_credentials.json new file mode 100644 index 00000000..1e035169 --- /dev/null +++ b/samples/src/test/resources/spn_agent_credentials.json @@ -0,0 +1,38 @@ +{ + "profiles": { + "local-demo": { + "bearerAuth": { + "login_url": "https://127.0.0.1/rest/plat/smapp/v1/oauth/token", + "method": "PUT", + "content_type": "application/json", + "request_fields": { + "grantType": "password", + "userName": "admin", + "value": "Admin@123", + "ipaddr": "*" + }, + "token_field": "accessSession", + "order_token_header": "bearToken", + "token_ttl": 3600 + } + } + }, + "agents": { + "SPN Domain Agent City1": { + "profile": "local-demo", + "overrides": { + "bearerAuth": { + "login_url": "https://127.0.0.1:26335/rest/plat/smapp/v1/oauth/token" + } + } + }, + "SPN Domain Agent City2": { + "profile": "local-demo", + "overrides": { + "bearerAuth": { + "login_url": "https://127.0.0.1:26336/rest/plat/smapp/v1/oauth/token" + } + } + } + } +} diff --git a/workflow-engine/pom.xml b/workflow-engine/pom.xml index f8b05d2a..258878bc 100644 --- a/workflow-engine/pom.xml +++ b/workflow-engine/pom.xml @@ -51,7 +51,7 @@ net.openan.a2a-t.sdk - a2a-t-client + a2a-t-core ${a2a.t.sdk.version} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java deleted file mode 100644 index 41a51f80..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATContentFacade.java +++ /dev/null @@ -1,421 +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 net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.model.FilledParamData; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; - -/** - * Engine-facing facade over the A2A-T SDK's negotiation content layer. - * - *

        Bridges the engine to the SDK's full content-generation and validation surface: propose / - * accept / reject / abort message rendering from typed data or free text, the validate-and-fill - * pipeline, and the template queries. Callers get compile-time {@link TemplateUri} constants from - * {@link StandardTemplates} instead of hand-written URI strings. - * - *

        All methods throw {@link net.openan.a2at.sdk.core.exception.A2ATError} subtypes on failure. - * Callers propagate a protocol failure rather than manufacturing legacy metadata or raw-text - * fallbacks. - * - *

        Instances are cheap views over the shared {@link A2ATClient}; create one per transport or - * reuse freely. - */ -public final class A2ATContentFacade { - - private final A2ATClient client; - - /** @param client the shared SDK client; must not be null */ - public A2ATContentFacade(A2ATClient client) { - this.client = Objects.requireNonNull(client, "client"); - } - - /** The underlying SDK client. */ - public A2ATClient client() { - return client; - } - - // ------------------------------------------------------------------ - // Task/Authorization/Notification generation from schema-aware data (may use LLM) - // ------------------------------------------------------------------ - - /** - * Renders a Task-T prompt from structured data and a schema. Scenario recognition is bypassed; - * the SDK's schema-aware slot extractor may still invoke the configured LLM. - * - * @param data structured task input (string-to-object map) - * @param schema schema describing the meaning of each data field; must not be empty - * @param templateUri task template, e.g. {@link StandardTemplates#PRIVATE_LINE_COMPLAINT} - * @return rendered prompt with template URI and Task-T extension URI - */ - public MetadataContent generateTaskFromData( - Map data, Map schema, TemplateUri templateUri) { - return client.generateTaskPromptFromDataWithSchema(data, schema, templateUri); - } - - /** Renders a Task-T prompt from natural language for a caller-selected template. */ - public MetadataContent generateTaskFromText(String text, TemplateUri templateUri) { - return client.generateTaskPromptFromText(text, templateUri); - } - - /** - * Renders an Authorization-T prompt from structured data and a schema. The SDK's schema-aware - * slot extractor may invoke the configured LLM. - * - * @param data structured authorization input - * @param schema schema describing the data fields; must not be empty - * @param templateUri authorization template, e.g. {@link - * StandardTemplates#AUTHORIZATION_POLICY_MANAGEMENT} - * @return rendered prompt with template URI and Authorization-T extension URI - */ - public MetadataContent generateAuthFromData( - Map data, Map schema, TemplateUri templateUri) { - return client.generateAuthPromptFromDataWithSchema(data, schema, templateUri); - } - - /** - * Renders a Notification-T prompt from structured data and a schema. The SDK's schema-aware - * slot extractor may invoke the configured LLM. - * - * @param data structured notification input - * @param schema schema describing the data fields; must not be empty - * @param templateUri notification template, e.g. {@link - * StandardTemplates#SUBSCRIBE_INCIDENT} - * @return rendered prompt with template URI and Notification-T extension URI - */ - public MetadataContent generateNotificationFromData( - Map data, Map schema, TemplateUri templateUri) { - return client.generateNotificationPromptFromDataWithSchema(data, schema, templateUri); - } - - /** - * Renders a propose-phase negotiation message from typed data. Deterministic, no LLM call. - * - * @param data typed propose input (context + content matching the template's type) - * @param templateUri propose template, e.g. {@link StandardTemplates#INFORMATION_NEGOTIATION_PROPOSE} - * @return rendered message with template URI and negotiation extension URI - */ - public MetadataContent generateProposeFromData(NegotiationProposeData data, TemplateUri templateUri) { - return client.generateNegotiationProposePromptFromData(data, templateUri); - } - - /** - * Renders an accept-phase negotiation message from typed data. Deterministic, no LLM call. - * - * @param data typed ending input whose content conclusion must be {@code ACCEPT} - * @param templateUri accept-reject template, e.g. {@link - * StandardTemplates#INFORMATION_NEGOTIATION_ACCEPT_REJECT} - * @return rendered message with template URI and negotiation extension URI - */ - public MetadataContent generateAcceptFromData(NegotiationEndingData data, TemplateUri templateUri) { - return client.generateNegotiationAcceptPromptFromData(data, templateUri); - } - - /** - * Renders a reject-phase negotiation message from typed data. Deterministic, no LLM call. - * - * @param data typed ending input whose content conclusion must be {@code REJECT} - * @param templateUri accept-reject template, e.g. {@link - * StandardTemplates#INFORMATION_NEGOTIATION_ACCEPT_REJECT} - * @return rendered message with template URI and negotiation extension URI - */ - public MetadataContent generateRejectFromData(NegotiationEndingData data, TemplateUri templateUri) { - return client.generateNegotiationRejectPromptFromData(data, templateUri); - } - - /** - * Renders an abort negotiation message from typed data. Deterministic, no LLM call. - * - * @param data typed abort input carrying the context and termination reason - * @return rendered abort message ({@link StandardTemplates#NEGOTIATION_ABORT} is implied) - */ - public MetadataContent generateAbortFromData(NegotiationAbortData data) { - return client.generateNegotiationAbortPromptFromData(data, StandardTemplates.NEGOTIATION_ABORT); - } - - // ------------------------------------------------------------------ - // Message generation: from free text (one LLM extraction step) - // ------------------------------------------------------------------ - - /** - * Renders a propose-phase negotiation message from free text. Runs one LLM content-extraction - * step constrained by the template URI, then renders deterministically. - * - * @param text free-text description of the message content - * @param context negotiation session context (id/round/maxRounds/performative) - * @param templateUri propose template - * @return rendered message with template URI and negotiation extension URI - */ - public MetadataContent generateProposeFromText( - String text, - NegotiationContext context, - TemplateUri templateUri) { - return client.generateNegotiationProposePromptFromText(text, context, templateUri); - } - - /** - * Renders an accept-phase negotiation message from free text; extracted conclusion must be - * {@code Accept}. - * - * @param text free-text description of the accept content - * @param context negotiation session context - * @param templateUri accept-reject template - * @return rendered message - */ - public MetadataContent generateAcceptFromText( - String text, - NegotiationContext context, - TemplateUri templateUri) { - return client.generateNegotiationAcceptPromptFromText(text, context, templateUri); - } - - /** - * Renders a reject-phase negotiation message from free text; extracted conclusion must be - * {@code Reject}. - * - * @param text free-text description of the reject reason - * @param context negotiation session context - * @param templateUri accept-reject template - * @return rendered message - */ - public MetadataContent generateRejectFromText( - String text, - NegotiationContext context, - TemplateUri templateUri) { - return client.generateNegotiationRejectPromptFromText(text, context, templateUri); - } - - /** - * Renders an abort negotiation message from free text stating the termination reason. - * - * @param text free-text termination reason - * @param context negotiation session context - * @return rendered abort message - */ - public MetadataContent generateAbortFromText( - String text, NegotiationContext context) { - return client.generateNegotiationAbortPromptFromText(text, context, StandardTemplates.NEGOTIATION_ABORT); - } - - // ------------------------------------------------------------------ - // Negotiation session context (engine-held, content-layer contract) - // ------------------------------------------------------------------ - - /** - * Builds the transport serialization of a negotiation session context: the {@code - * negotiationContext} metadata value carrying {@code id} / {@code round} / {@code - * maxRounds}. - * - *

        The content layer is stateless by design — session identity and round tracking stay - * with the caller (see SDK developer guide §1.10). The engine owns the context: it - * serializes it here for the wire. An ending message preserves the received Propose context; - * only an initiator creating a genuinely new round owns a {@link - * NegotiationContext#nextRound()} transition. - * - * @param context the engine-held session context - * @return wire map for the canonical {@code negotiationContext} metadata key - */ - public static Map contextPayload(NegotiationContext context) { - Map payload = new LinkedHashMap<>(); - payload.put("id", context.id()); - payload.put("round", context.round()); - payload.put("maxRounds", context.maxRounds()); - payload.put("performative", context.performative().name()); - return payload; - } - - /** - * Parses a negotiation session context from its canonical {@code negotiationContext} wire - * serialization ({@code id}/{@code round}/{@code maxRounds}/{@code performative}). - * - * @param map the metadata value under the {@code negotiationContext} key - * @return the session context, or null when malformed - */ - public static NegotiationContext contextFromMap(Map map) { - if (map == null) { - return null; - } - Object id = map.get("id"); - Object round = map.get("round"); - if (id instanceof String s && isPositiveInteger(round)) { - Object maxRounds = map.get("maxRounds"); - Object performative = map.get("performative"); - if (!isPositiveInteger(maxRounds) || !(performative instanceof String value)) { - return null; - } - var parsedPerformative = NegotiationPerformative.tryParse(value); - if (parsedPerformative.isEmpty()) { - return null; - } - try { - return new NegotiationContext( - s, - ((Number) round).intValue(), - ((Number) maxRounds).intValue(), - parsedPerformative.get()); - } catch (IllegalArgumentException ignored) { - // fall through - } - } - return null; - } - - private static boolean isPositiveInteger(Object value) { - if (!(value instanceof Number number)) { - return false; - } - double decimal = number.doubleValue(); - return Double.isFinite(decimal) - && decimal > 0 - && decimal == Math.rint(decimal) - && decimal <= Integer.MAX_VALUE; - } - - // ------------------------------------------------------------------ - // Validate and fill (rule gate + LLM semantic validation + param merge) - // ------------------------------------------------------------------ - - /** - * Validates a propose-phase negotiation message and extracts parameters. - * - * @param prompt rendered negotiation message text - * @param context negotiation context carried alongside the message; null is reported as - * not-a-negotiation-message - * @param schema caller-provided parameter JSON schema - * @param templateUri propose template declaring the expected type and phase - * @return filled parameter data (context params + extracted params) - */ - public FilledParamData validatePropose( - String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) { - return client.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates an accept-phase negotiation message and extracts parameters. - * - * @param prompt rendered negotiation message text - * @param context negotiation context carried alongside the message; null is reported as - * not-a-negotiation-message - * @param schema caller-provided parameter JSON schema - * @param templateUri accept-reject template - * @return filled parameter data - */ - public FilledParamData validateAccept( - String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) { - return client.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates a reject-phase negotiation message and extracts parameters. - * - * @param prompt rendered negotiation message text - * @param context negotiation context carried alongside the message; null is reported as - * not-a-negotiation-message - * @param schema caller-provided parameter JSON schema - * @param templateUri accept-reject template - * @return filled parameter data - */ - public FilledParamData validateReject( - String prompt, NegotiationContext context, Map schema, TemplateUri templateUri) { - return client.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); - } - - /** - * Validates an abort negotiation message and extracts parameters. - * - * @param prompt rendered abort message text - * @param context negotiation context carried alongside the message - * @param schema caller-provided parameter JSON schema - * @return filled parameter data - */ - public FilledParamData validateAbort( - String prompt, NegotiationContext context, Map schema) { - return client.validateAbortPromptAndDataFilling( - prompt, context, schema, StandardTemplates.NEGOTIATION_ABORT); - } - - // ------------------------------------------------------------------ - // Template queries - // ------------------------------------------------------------------ - - /** - * Lists every template of the configured language across all A2A-T extensions. - * - * @return loadable templates sorted by template URI; empty when none can be loaded - */ - public List getPrompts() { - return client.getPrompts(); - } - - /** - * Loads one template by URI regardless of extension. - * - * @param templateUri template URI such as {@link StandardTemplates#PRIVATE_LINE_COMPLAINT} - * @return the addressed template, or empty when it does not exist for the language - */ - public Optional getPrompt(TemplateUri templateUri) { - return client.getPrompt(templateUri); - } - - /** - * Lists every negotiation template of the configured language. - * - * @return loadable negotiation templates in fixed type and phase order - */ - public List getNegotiationPrompts() { - return client.getNegotiationPrompts(); - } - - /** - * Loads one negotiation template by URI. - * - * @param templateUri template URI such as {@link - * StandardTemplates#INFORMATION_NEGOTIATION_PROPOSE} - * @return the addressed template, or empty when it does not exist - */ - public Optional getNegotiationPrompt(TemplateUri templateUri) { - return client.getNegotiationPrompt(templateUri); - } - - /** - * Builds the metadata map for a generated message: the extension URI mapping to the rendered - * text plus the {@code templateUri} marker key. - * - * @param content generated message content - * @return metadata map ready to attach to an A2A message - */ - public static Map toMetadata(MetadataContent content) { - return new LinkedHashMap<>(content.buildMetadataContent()); - } -} 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 16338e96..723482cd 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATExtension.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATExtension.java @@ -25,12 +25,12 @@ * A2A-T extension types supported by the workflow execution engine. * *

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

        The engine handles these extensions automatically: * *

          - *
        • {@link #TASK_T} - structured task prompt generation (in-workflow) + *
        • {@link #TASK_T} - host-generated task content (in-workflow) *
        • {@link #NEGOTIATION_T} - negotiation auto-loop (in-workflow) *
        • {@link #AUTHORIZATION_T} - independent whitelist authorization operation *
        • {@link #NOTIFICATION_T} - independent result subscription @@ -39,7 +39,7 @@ */ public enum A2ATExtension { - /** Structured task prompt. Handled automatically during workflow execution. */ + /** 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. */ @@ -55,42 +55,6 @@ public enum A2ATExtension { public static final String NEGOTIATION_CONTEXT_META_KEY = net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY; - /** - * Internal metadata key under which {@code NegotiationTHandler} stores the extracted - * negotiation message text. Engine-internal, not an A2A-T protocol key. - */ - public static final String NEGOTIATION_MESSAGE_META_KEY = "negotiation_message"; - - /** - * Internal metadata key under which {@code NegotiationTHandler} stores the parameters - * extracted by the validate-and-fill pipeline. Engine-internal, not an A2A-T protocol key. - */ - public static final String NEGOTIATION_PARAMS_META_KEY = "negotiation_params"; - - /** - * Engine-internal metadata key carrying structured task input (a string-to-object map) for - * the SDK's {@code generateTaskPromptFromDataWithSchema} pipeline. When present in the - * caller-supplied metadata, {@code TaskTHandler} renders the Task-T prompt from the data - * through the schema-aware path instead of running scenario recognition on the message text. Consumed - * (removed) before the A2A message goes on the wire; the rendered prompt travels under the - * Task-T extension URI. - */ - public static final String TASK_DATA_META_KEY = "a2at.taskData"; - - /** - * Engine-internal metadata key carrying the JSON schema describing {@link - * #TASK_DATA_META_KEY} fields. Required when task data is present; the schema tells the - * SDK renderer what each field means. Engine-internal, never sent on the wire. - */ - public static final String TASK_SCHEMA_META_KEY = "a2at.taskSchema"; - - /** - * Engine-internal metadata key carrying the template URI string for the fromData rendering - * track (e.g. {@code "Task-T/network-layer/private-line-complaint/v1"}). Required for - * structured Task-T rendering. Engine-internal, never sent on the wire. - */ - public static final String TASK_TEMPLATE_META_KEY = "a2at.taskTemplate"; - private final String uri; A2ATExtension(String uri) { diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java index 462d04d1..5be94324 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java @@ -21,7 +21,8 @@ import dev.openan.workflow.engine.model.SendMessageResult; -import net.openan.a2at.sdk.client.A2ATClient; +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.ReceivedMessage; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; @@ -37,6 +38,7 @@ 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; @@ -80,12 +82,11 @@ public class A2ATransport implements AutoCloseable { private final A2AJavaClientRuntime a2aClientRuntime; private final AgentAuthManager authManager; private final AuthProvider authProvider; - private final A2ATClient a2atClient; private final String contextId; - private volatile A2ATContentFacade contentFacade; 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(); @@ -113,6 +114,7 @@ public A2ATransport( config.getSendExecutorQueueCapacity()); this.contextId = UUID.randomUUID().toString(); this.notificationAckTimeoutSeconds = config.getNotificationAckTimeoutSeconds(); + this.sendTimeoutSeconds = config.getSendTimeoutSeconds(); this.asyncExecutor = new ThreadPoolExecutor( config.getSendExecutorCoreSize(), @@ -126,16 +128,8 @@ public A2ATransport( return t; }, new ThreadPoolExecutor.CallerRunsPolicy()); - // Do not copy SDK .env values into global JVM properties. Each transport constructs its - // own SDK client from the explicit path, which keeps Task/Authorization/Notification - // channels independently configurable and prevents cross-instance credential leakage. CredentialHttpTransport credentialHttpTransport = null; - String credentialEncryptionKey = null; - if (config.getA2atEnvPath() != null) { - credentialEncryptionKey = - EnvFileLoader.read(java.nio.file.Path.of(config.getA2atEnvPath())) - .get("A2AT_CRED_KEY"); - } + String credentialEncryptionKey = config.getCredentialEncryptionKey(); if (config.getCredentialsConfigPath() != null || config.getCredentialsConfig() != null) { credentialHttpTransport = @@ -163,18 +157,13 @@ public A2ATransport( } else { this.authManager = new AgentAuthManager(); } - this.a2atClient = initA2atClient(config.getA2atEnvPath()); this.authProvider = config.getAuthProvider(); this.clientCallContextFactory = new ClientCallContextFactory( new AuthProviderHeaderContributor(authProvider), - new CredentialHeaderContributor(authManager, authProvider), - new ExtensionHeaderContributor(authManager)); + new CredentialHeaderContributor(authManager, authProvider)); cardMap = java.util.Collections.unmodifiableMap(validatedCards); - log.info( - "[Transport] Initialized with {} agent(s), a2at={}", - cardMap.size(), - a2atClient != null); + log.info("[Transport] Initialized with {} agent(s)", cardMap.size()); } public static String extractResponseText(Iterable events) { @@ -354,22 +343,6 @@ public static List extractExtensionUris(AgentCard agentCard) { return uris; } - private A2ATClient initA2atClient(String a2atEnvPath) { - if (a2atEnvPath == null || a2atEnvPath.isEmpty()) { - return null; - } - try { - return new A2ATClient(java.nio.file.Path.of(a2atEnvPath)); - } catch (Exception e) { - throw new IllegalArgumentException( - "Failed to initialize A2A-T client from " + a2atEnvPath, e); - } - } - - // ------------------------------------------------------------------ - // ClientEvent extraction (static helpers, shared by facades) - // ------------------------------------------------------------------ - public AgentCard getCard(String agentName) { return cardMap.get(agentName); } @@ -378,32 +351,8 @@ public List getAgentNames() { return new ArrayList<>(cardMap.keySet()); } - public A2ATClient getA2atClient() { - return a2atClient; - } - - /** - * Content-layer facade over the SDK client: negotiation message generation (propose / accept / - * reject / abort, from data or text), validate-and-fill, and template queries. Returns null - * when no A2A-T env path was configured. - */ - public A2ATContentFacade getContentFacade() { - A2ATClient local = a2atClient; - if (local == null) { - return null; - } - A2ATContentFacade facade = contentFacade; - if (facade == null) { - synchronized (this) { - facade = contentFacade; - if (facade == null) { - facade = new A2ATContentFacade(local); - contentFacade = facade; - } - } - } - return facade; - } + /** Time budget shared by sending and business interaction callbacks. */ + public long sendTimeoutSeconds() { return sendTimeoutSeconds; } public String getContextId() { return contextId; @@ -444,39 +393,31 @@ private static Map validateAgentCards(List agentCa * wires it to its event callback, the one-shot sender passes {@code null}. */ public CompletableFuture send( - AgentCard agentCard, - String agentName, - String message, - String contextId, - Map metadata, - Consumer eventSink) { - if (closed.get()) { - return CompletableFuture.failedFuture( - new IllegalStateException("A2A transport is closed")); - } - return send(agentCard, agentName, message, contextId, null, metadata, eventSink); + AgentCard agentCard, String agentName, MessageContent content, String contextId, + String taskId, Consumer eventSink) { + return send(agentCard, agentName, content, contextId, taskId, eventSink, () -> true); } - /** Send a continuation message for an existing A2A task. */ - public CompletableFuture send( - AgentCard agentCard, - String agentName, - String message, - String contextId, - String taskId, - Map metadata, - Consumer eventSink) { + CompletableFuture send( + AgentCard agentCard, String agentName, MessageContent content, String contextId, + String taskId, Consumer eventSink, java.util.function.BooleanSupplier active) { if (closed.get()) { return CompletableFuture.failedFuture( new IllegalStateException("A2A transport is closed")); } + Map trace = new HashMap<>(WireLog.context()); + trace.put("agent", agentName); + trace.put("contextId", contextId); + if (taskId != null) trace.put("remoteTaskId", taskId); + trace.put("channel", content.extensions().contains(A2ATExtension.AUTHORIZATION_T.uri()) + ? "authorization" : "task"); return CompletableFuture.supplyAsync( - () -> { + () -> WireLog.call(trace, () -> { try { - MessageSendParams params = - buildMessageSendParams(message, contextId, taskId, metadata); - ClientCallContext callContext = - buildClientCallContext(agentCard, agentName, metadata); + if (!active.getAsBoolean()) throw new java.util.concurrent.CancellationException("Late send ignored"); + MessageSendParams params = buildMessageSendParams(content, contextId, taskId); + ClientCallContext callContext = buildContentCallContext(agentCard, agentName, content); + if (!active.getAsBoolean()) throw new java.util.concurrent.CancellationException("Late authenticated send ignored"); String endpoint = agentCard.supportedInterfaces().isEmpty() ? "?" @@ -505,6 +446,7 @@ public CompletableFuture send( .task(task) .metadata(respMetadata) .taskState(taskState) + .receivedMessages(ProtocolResponses.assemble(events)) .build(); } catch (Exception e) { log.error( @@ -517,7 +459,7 @@ public CompletableFuture send( } throw new RuntimeException("Agent call failed: " + e.getMessage(), e); } - }, + }), asyncExecutor); } @@ -543,30 +485,13 @@ public void closeConversation(AgentCard agentCard, String contextId) { * ack + later recovery results). The returned future completes on the first event carrying a * concrete task state; an artifact alone is application data, not a protocol acknowledgement. */ - public CompletableFuture sendNotificationStream( - AgentCard agentCard, - String agentName, - String message, - String contextId, - Map metadata, - Consumer eventSink) { - try { - return openNotificationStream( - agentCard, agentName, message, contextId, metadata, eventSink) - .acknowledgement(); - } catch (RuntimeException e) { - return CompletableFuture.failedFuture(e); - } - } - /** Opens a long-lived Notification-T stream and returns its explicit lifecycle handle. */ public NotificationSubscription openNotificationStream( AgentCard agentCard, String agentName, - String message, + MessageContent content, String contextId, - Map metadata, - Consumer eventSink) { + java.util.function.BiConsumer eventSink) { if (closed.get()) { throw new IllegalStateException("A2A transport is closed"); } @@ -583,14 +508,19 @@ public NotificationSubscription openNotificationStream( } }); notificationSubscriptions.add(subscription); + Map trace = new HashMap<>(WireLog.context()); + trace.put("agent", agentName); + trace.put("contextId", contextId); + trace.put("channel", "notification"); + ProtocolResponses.Accumulator responses = new ProtocolResponses.Accumulator(); Thread streamThread = new Thread( - () -> { + () -> WireLog.inContext(trace, () -> { try { MessageSendParams params = - buildMessageSendParams(message, contextId, metadata); + buildMessageSendParams(content, contextId, null); ClientCallContext callContext = - buildClientCallContext(agentCard, agentName, metadata); + buildContentCallContext(agentCard, agentName, content); String endpoint = agentCard.supportedInterfaces().isEmpty() ? "?" @@ -611,7 +541,10 @@ public NotificationSubscription openNotificationStream( event.getClass().getSimpleName()); subscription.recordEvent(); if (eventSink != null) { - eventSink.accept(event); + responses.accept(event); + for (ReceivedMessage received : responses.snapshots()) { + eventSink.accept(subscription, received); + } } SendMessageResult acknowledgement = eventResult(event); @@ -635,11 +568,8 @@ public NotificationSubscription openNotificationStream( "[Transport] Notification-T stream closed for {}", agentName); if (!subscription.acknowledgement().isDone()) { - subscription.acknowledge( - SendMessageResult.builder() - .text("Stream closed before acknowledgement") - .taskState("TASK_STATE_COMPLETED") - .build()); + subscription.failAcknowledgement( + new IllegalStateException("Stream closed before acknowledgement")); } subscription.completeStream(); } catch (Exception e) { @@ -673,7 +603,7 @@ public NotificationSubscription openNotificationStream( notificationSubscriptions.remove(subscription); subscription.markStreamTerminated(); } - }, + }), "notif-t-" + agentName); streamThread.setDaemon(true); streamThreadRef.set(streamThread); @@ -683,13 +613,11 @@ public NotificationSubscription openNotificationStream( if (subscription.isActive() && !subscription.acknowledgement().isDone()) { log.warn( - "[Transport] Notification-T subscription: no event in {}s, assuming active (stream stays open)", + "[Transport] Notification-T subscription: no acknowledgement in {}s; closing unconfirmed stream", notificationAckTimeoutSeconds); - subscription.acknowledge( - SendMessageResult.builder() - .text("Subscribed (no-ack)") - .taskState("TASK_STATE_WORKING") - .build()); + subscription.failAcknowledgement( + new java.util.concurrent.TimeoutException("No subscription acknowledgement received")); + subscription.close(); } }, CompletableFuture.delayedExecutor( @@ -704,6 +632,7 @@ private static SendMessageResult eventResult(ClientEvent event) { .task(extractResponseTask(eventList)) .metadata(extractResponseMetadata(eventList)) .taskState(extractResponseTaskState(eventList)) + .receivedMessages(ProtocolResponses.assemble(eventList)) .build(); } @@ -731,22 +660,36 @@ private static boolean causedByInterruption(Throwable error) { } private MessageSendParams buildMessageSendParams( - String message, String contextId, Map metadata) { - return buildMessageSendParams(message, contextId, null, metadata); + MessageContent content, String contextId, String taskId) { + Message msg = Message.builder() + .messageId(UUID.randomUUID().toString()) + .contextId(contextId).taskId(taskId).role(Message.Role.ROLE_USER) + .parts(content.parts()).metadata(content.metadata()) + .extensions(new ArrayList<>(content.extensions())).build(); + return MessageSendParams.builder().message(msg).build(); } - private MessageSendParams buildMessageSendParams( - String message, String contextId, String taskId, Map metadata) { - Message msg = - Message.builder() - .messageId(UUID.randomUUID().toString()) - .contextId(contextId) - .taskId(taskId) - .role(Message.Role.ROLE_USER) - .parts(new TextPart(message)) - .metadata(metadata != null ? metadata : Map.of()) - .build(); - return MessageSendParams.builder().message(msg).build(); + private ClientCallContext buildContentCallContext( + AgentCard card, String agentName, MessageContent content) { + ClientCallContext context = buildClientCallContext(card, agentName, content.metadata()); + Map headers = new HashMap<>(context.getHeaders()); + Set extensions = new LinkedHashSet<>(content.extensions()); + String existingKey = headers.keySet().stream() + .filter(key -> key.equalsIgnoreCase("A2A-Extensions")).findFirst().orElse(null); + if (existingKey != null) { + for (String value : headers.remove(existingKey).split(",")) { + if (!value.isBlank()) extensions.add(value.trim()); + } + } + if (card.capabilities() != null && card.capabilities().extensions() != null) { + for (var extension : card.capabilities().extensions()) { + if (Boolean.TRUE.equals(extension.required()) && !extensions.contains(extension.uri())) { + throw new IllegalArgumentException("Required extension not activated: " + extension.uri()); + } + } + } + if (!extensions.isEmpty()) headers.put("A2A-Extensions", String.join(",", extensions)); + return new ClientCallContext(new HashMap<>(), headers); } private ClientCallContext buildClientCallContext( @@ -763,7 +706,8 @@ public CompletableFuture getTask( if (closed.get()) { return CompletableFuture.failedFuture(new IllegalStateException("A2A transport is closed")); } - return CompletableFuture.supplyAsync(() -> { + 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); @@ -776,11 +720,12 @@ public CompletableFuture getTask( .task(task) .metadata(metadata) .taskState(taskState(task)) - .build(); + .receivedMessages(ProtocolResponses.assemble(List.of(new TaskEvent(task)))) + .build(); } catch (Exception e) { throw new RuntimeException("getTask failed for " + agentName + ": " + e.getMessage(), e); } - }, asyncExecutor); + }), asyncExecutor); } public CompletableFuture cancelTask( @@ -788,7 +733,8 @@ public CompletableFuture cancelTask( if (closed.get()) { return CompletableFuture.failedFuture(new IllegalStateException("A2A transport is closed")); } - return CompletableFuture.supplyAsync(() -> { + 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); @@ -801,11 +747,12 @@ public CompletableFuture cancelTask( .task(task) .metadata(metadata) .taskState(taskState(task)) + .receivedMessages(ProtocolResponses.assemble(List.of(new TaskEvent(task)))) .build(); } catch (Exception e) { throw new RuntimeException("cancelTask failed for " + agentName + ": " + e.getMessage(), e); } - }, asyncExecutor); + }), asyncExecutor); } public CompletableFuture subscribeToTask( @@ -815,7 +762,7 @@ public CompletableFuture subscribeToTask( return CompletableFuture.failedFuture(new IllegalStateException("A2A transport is closed")); } ClientCallContext ctx = buildClientCallContext(agentCard, agentName, Map.of()); - return a2aClientRuntime.subscribeToTask(agentCard, taskId, ctx, + return WireLog.call(taskTrace(agentName, taskId), () -> a2aClientRuntime.subscribeToTask(agentCard, taskId, ctx, event -> { if (eventSink != null) { try { @@ -828,7 +775,15 @@ public CompletableFuture subscribeToTask( 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) { diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2atMessages.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2atMessages.java new file mode 100644 index 00000000..2c83f780 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2atMessages.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.client; + +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.ReceivedMessage; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import org.a2aproject.sdk.spec.Part; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Thin A2A-T core conversion. Does not generate, validate or interpret business content. */ +public final class A2atMessages { + private A2atMessages() {} + + /** Preserves SDK metadata and activates only this generated extension; parts stay explicit. */ + public static MessageContent from(MetadataContent generated, List> parts) { + Objects.requireNonNull(generated, "generated"); + return new MessageContent(parts, generated.buildMetadataContent(), Set.of(generated.extensionUri())); + } + + /** Reads the canonical context, rejecting conflicting contexts instead of flattening metadata. */ + public static NegotiationContext contextOf(ReceivedMessage received) { + Objects.requireNonNull(received, "received"); + List> layers = new ArrayList<>(); + if (received.message() != null) layers.add(received.message().metadata()); + layers.add(received.taskMetadata()); + received.artifacts().forEach(a -> { if (a.metadata() != null) layers.add(a.metadata()); }); + NegotiationContext context = null; + for (Map layer : layers) { + if (!layer.containsKey(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY)) continue; + NegotiationContext candidate = contextOf(layer); + if (context != null && !context.equals(candidate)) { + throw new IllegalArgumentException("Conflicting negotiation contexts in response"); + } + context = candidate; + } + if (context == null) throw new IllegalArgumentException("Missing negotiationContext"); + return context; + } + + /** Reads canonical wire fields only, without coercing missing or fractional rounds. */ + static NegotiationContext contextOf(Map metadata) { + Object raw = metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + if (!(raw instanceof Map fields) + || !(fields.get("id") instanceof String id) + || !(fields.get("performative") instanceof String action)) { + throw new IllegalArgumentException("Invalid negotiationContext"); + } + return new NegotiationContext(id, integer(fields.get("round")), integer(fields.get("maxRounds")), + NegotiationPerformative.tryParse(action) + .orElseThrow(() -> new IllegalArgumentException("Invalid negotiation performative"))); + } + + private static int integer(Object value) { + if (!(value instanceof Number)) throw new IllegalArgumentException("Negotiation round must be an integer"); + try { + return new BigDecimal(value.toString()).intValueExact(); + } catch (ArithmeticException | NumberFormatException e) { + throw new IllegalArgumentException("Negotiation round must be an integer", e); + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java index cad3eee5..3fd03335 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java @@ -170,9 +170,4 @@ private static void validateEncryptedCredentials( } } - /** Build the extension-header contributor for an AgentCard, if it declares extensions. */ - public ExtensionInterceptor buildExtensionInterceptor(AgentCard agentCard) { - List extUris = extractExtensionUris(agentCard); - return extUris.isEmpty() ? null : new ExtensionInterceptor(extUris); - } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java index ed724a6d..635d0e66 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java @@ -581,6 +581,7 @@ public java.util.concurrent.CompletableFuture subscribeToTask .task(A2ATransport.extractResponseTask(snapshot)) .taskState(A2ATransport.extractResponseTaskState(snapshot)) .metadata(A2ATransport.extractResponseMetadata(snapshot)) + .receivedMessages(ProtocolResponses.assemble(snapshot)) .build()); } }), 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 8d9650a8..f676f3af 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultExtensionSender.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultExtensionSender.java @@ -19,484 +19,55 @@ package dev.openan.workflow.engine.client; +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.ReceivedMessage; import dev.openan.workflow.engine.model.SendMessageResult; - -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; - import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.a2aproject.sdk.client.ClientEvent; -import org.a2aproject.sdk.client.MessageEvent; -import org.a2aproject.sdk.client.TaskUpdateEvent; -import org.a2aproject.sdk.spec.Message; -import org.a2aproject.sdk.spec.Part; -import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; -import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; -import org.a2aproject.sdk.spec.TextPart; - -import java.util.HashMap; -import java.util.Map; +import java.util.Objects; +import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.function.Consumer; - -/** - * Default {@link ExtensionSender} built on a caller-owned {@link A2ATransport}. - * - *

          Uses the A2A-T SDK to render Authorization-T and Notification-T metadata. All wire-level work - * delegates to the transport. The facade does not close that transport; the caller owns it. - */ -public record DefaultExtensionSender(A2ATransport transport) - implements ExtensionSender, AutoCloseable { - - private static final Logger log = LoggerFactory.getLogger(DefaultExtensionSender.class); +import java.util.function.BiConsumer; +/** Final-content sender on a caller-owned transport, independent of the workflow client. */ +public record DefaultExtensionSender(A2ATransport transport) implements ExtensionSender, AutoCloseable { public DefaultExtensionSender { - java.util.Objects.requireNonNull(transport, "transport"); - } - - @Override - public CompletableFuture sendExtensionMessage( - String agentName, - String instruction, - String naturalLanguageInput, - TemplateUri templateUri, - A2ATExtension extension) { - CompletableFuture invalid = - validateRequest(agentName, instruction, naturalLanguageInput, extension); - if (invalid != null) return invalid; - IllegalArgumentException templateError = validateTemplate(extension, templateUri); - if (templateError != null) return CompletableFuture.failedFuture(templateError); - AgentCard agentCard = transport.getCard(agentName); - if (agentCard == null) { - log.error("[ExtensionSender] Agent not found: {}", agentName); - return CompletableFuture.failedFuture( - new RuntimeException("Agent not found: " + agentName)); - } - if (!supports(agentCard, extension)) { - return CompletableFuture.failedFuture( - new IllegalArgumentException( - "Agent '" + agentName + "' does not declare " + extension.uri())); - } - return CompletableFuture.supplyAsync( - () -> generateExtensionPrompt(extension, naturalLanguageInput, templateUri)) - .thenCompose( - mc -> { - if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { - return CompletableFuture.failedFuture( - new IllegalStateException( - "A2A-T SDK prompt generation failed for " - + extension.displayName())); - } - String value = mc.promptText(); - Map metadata = new HashMap<>(); - metadata.putAll(mc.buildMetadataContent()); - log.info( - "[ExtensionSender] sendExtensionMessage to {}: extension={}, metadataValue={} chars", - agentName, - extension.displayName(), - value.length()); - CompletableFuture send = - transport - .send( - agentCard, - agentName, - instruction, - transport.getContextId(), - metadata, - null) - .thenApply( - result -> { - log.info( - "[ExtensionSender] Extension response from {}: state={}", - agentName, - result.getTaskState()); - return result; - }); - return send.whenComplete( - (ignored, error) -> - transport.closeConversation( - agentCard, transport.getContextId())); - }); + Objects.requireNonNull(transport, "transport"); } @Override - public CompletableFuture sendExtensionMessageFromData( - String agentName, - String instruction, - Map data, - Map schema, - TemplateUri templateUri, - A2ATExtension extension) { - if (agentName == null || agentName.isBlank() || instruction == null || instruction.isBlank()) { - return CompletableFuture.failedFuture( - new IllegalArgumentException("agentName and instruction must not be blank")); + public CompletableFuture sendAuthorization(String agentName, MessageContent content) { + try { + AgentCard card = requireExtension(agentName, content, A2ATExtension.AUTHORIZATION_T); + String contextId = UUID.randomUUID().toString(); + return transport.send(card, agentName, content, contextId, null, null) + .whenComplete((result, error) -> transport.closeConversation(card, contextId)); + } catch (RuntimeException error) { + return CompletableFuture.failedFuture(error); } - if (data == null || schema == null || schema.isEmpty()) { - return CompletableFuture.failedFuture( - new IllegalArgumentException("fromData requires non-null data and a non-empty schema")); - } - if (extension != A2ATExtension.AUTHORIZATION_T) { - return CompletableFuture.failedFuture( - new IllegalArgumentException( - "One-shot fromData rendering supports AUTHORIZATION_T only; " - + "use openNotificationFromData for Notification-T")); - } - IllegalArgumentException templateError = validateTemplate(extension, templateUri); - if (templateError != null) return CompletableFuture.failedFuture(templateError); - AgentCard agentCard = transport.getCard(agentName); - if (agentCard == null) { - log.error("[ExtensionSender] Agent not found: {}", agentName); - return CompletableFuture.failedFuture( - new RuntimeException("Agent not found: " + agentName)); - } - if (!supports(agentCard, extension)) { - return CompletableFuture.failedFuture( - new IllegalArgumentException( - "Agent '" + agentName + "' does not declare " + extension.uri())); - } - return CompletableFuture.supplyAsync( - () -> generateExtensionPromptFromData(extension, data, schema, templateUri)) - .thenCompose( - mc -> { - if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { - log.error( - "[ExtensionSender] fromData rendering unavailable for {} ({}); refusing to send raw data", - agentName, - extension.displayName()); - return CompletableFuture.failedFuture( - new RuntimeException( - "fromData rendering failed for " + extension)); - } - Map metadata = new HashMap<>(); - metadata.putAll(mc.buildMetadataContent()); - log.info( - "[ExtensionSender] sendExtensionMessageFromData to {}: extension={}, renderedChars={}", - agentName, - extension.displayName(), - mc.promptText().length()); - return transport - .send( - agentCard, - agentName, - instruction, - transport.getContextId(), - metadata, - null) - .whenComplete( - (ignored, error) -> - transport.closeConversation( - agentCard, transport.getContextId())); - }); - } - - /** SDK schema-aware fromData rendering for Authorization-T / Notification-T. */ - private MetadataContent generateExtensionPromptFromData( - A2ATExtension extension, - Map data, - Map schema, - TemplateUri templateUri) { - net.openan.a2at.sdk.client.A2ATClient a2atClient = transport.getA2atClient(); - if (a2atClient == null) { - return null; - } - return switch (extension) { - case AUTHORIZATION_T -> a2atClient.generateAuthPromptFromDataWithSchema( - data, schema, templateUri); - case NOTIFICATION_T -> a2atClient.generateNotificationPromptFromDataWithSchema( - data, schema, templateUri); - default -> throw new IllegalArgumentException( - "Unsupported schema-aware extension: " + extension); - }; } @Override - public CompletableFuture sendNotification( - String agentName, - String instruction, - String naturalLanguageInput, - TemplateUri templateUri, - Consumer> eventCallback) { - return openNotification( - agentName, - instruction, - naturalLanguageInput, - templateUri, - eventCallback) - .thenCompose(NotificationSubscription::acknowledgement); - } - - @Override - public CompletableFuture openNotification( - String agentName, - String instruction, - String naturalLanguageInput, - TemplateUri templateUri, - Consumer> eventCallback) { - if (agentName == null - || agentName.isBlank() - || instruction == null - || instruction.isBlank() - || naturalLanguageInput == null - || naturalLanguageInput.isBlank()) { - return CompletableFuture.failedFuture( - new IllegalArgumentException( - "agentName, instruction and naturalLanguageInput must not be blank")); - } - IllegalArgumentException templateError = - validateTemplate(A2ATExtension.NOTIFICATION_T, templateUri); - if (templateError != null) return CompletableFuture.failedFuture(templateError); - AgentCard agentCard = transport.getCard(agentName); - if (agentCard == null) { - log.error("[ExtensionSender] Agent not found: {}", agentName); - return CompletableFuture.failedFuture( - new RuntimeException("Agent not found: " + agentName)); - } - if (!supports(agentCard, A2ATExtension.NOTIFICATION_T)) { - return CompletableFuture.failedFuture( - new IllegalArgumentException( - "Agent '" - + agentName - + "' does not declare " - + A2ATExtension.NOTIFICATION_T.uri())); - } - return CompletableFuture.supplyAsync( - () -> - generateExtensionPrompt( - A2ATExtension.NOTIFICATION_T, - naturalLanguageInput, - templateUri)) - .thenCompose( - mc -> { - if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { - return CompletableFuture.failedFuture( - new IllegalStateException( - "A2A-T SDK prompt generation failed for Notification-T")); - } - String value = mc.promptText(); - Map metadata = new HashMap<>(); - metadata.putAll(mc.buildMetadataContent()); - return sendNotificationMetadata( - agentCard, agentName, instruction, metadata, eventCallback, value); - }); + public NotificationSubscription openNotification(String agentName, MessageContent content, + BiConsumer listener) { + AgentCard card = requireExtension(agentName, content, A2ATExtension.NOTIFICATION_T); + return transport.openNotificationStream(card, agentName, content, UUID.randomUUID().toString(), + Objects.requireNonNull(listener, "listener")); } - @Override - public CompletableFuture sendNotificationFromData( - String agentName, - String instruction, - Map data, - Map schema, - TemplateUri templateUri, - Consumer> eventCallback) { - return openNotificationFromData( - agentName, instruction, data, schema, templateUri, eventCallback) - .thenCompose(NotificationSubscription::acknowledgement); - } - - @Override - public CompletableFuture openNotificationFromData( - String agentName, - String instruction, - Map data, - Map schema, - TemplateUri templateUri, - Consumer> eventCallback) { - if (agentName == null - || agentName.isBlank() - || instruction == null - || instruction.isBlank() - || data == null - || schema == null - || schema.isEmpty()) { - return CompletableFuture.failedFuture( - new IllegalArgumentException( - "fromData notification requires agentName, instruction, data and schema")); - } - IllegalArgumentException templateError = - validateTemplate(A2ATExtension.NOTIFICATION_T, templateUri); - if (templateError != null) return CompletableFuture.failedFuture(templateError); - AgentCard agentCard = transport.getCard(agentName); - if (agentCard == null) { - log.error("[ExtensionSender] Agent not found: {}", agentName); - return CompletableFuture.failedFuture( - new RuntimeException("Agent not found: " + agentName)); - } - if (!supports(agentCard, A2ATExtension.NOTIFICATION_T)) { - return CompletableFuture.failedFuture( - new IllegalArgumentException( - "Agent '" - + agentName - + "' does not declare " - + A2ATExtension.NOTIFICATION_T.uri())); - } - return CompletableFuture.supplyAsync( - () -> - generateExtensionPromptFromData( - A2ATExtension.NOTIFICATION_T, - data, - schema, - templateUri)) - .thenCompose( - mc -> { - if (mc == null || mc.promptText() == null || mc.promptText().isEmpty()) { - log.error( - "[ExtensionSender] Notification-T fromData rendering" - + " unavailable for {}; refusing to send raw data", - agentName); - return CompletableFuture.failedFuture( - new RuntimeException( - "Notification-T fromData rendering failed")); - } - Map metadata = new HashMap<>(); - metadata.putAll(mc.buildMetadataContent()); - return sendNotificationMetadata( - agentCard, - agentName, - instruction, - metadata, - eventCallback, - mc.promptText()); - }); - } - - /** Shared Notification-T stream-open path used by both the text and fromData tracks. */ - private CompletableFuture sendNotificationMetadata( - AgentCard agentCard, - String agentName, - String instruction, - Map metadata, - Consumer> eventCallback, - String renderedValue) { - Consumer eventSink = - eventCallback != null - ? event -> forwardNotificationEvent(event, agentName, eventCallback) - : null; - log.info( - "[ExtensionSender] sendNotification to {}: metadataValue={} chars, callback={}", - agentName, - renderedValue.length(), - eventCallback != null); - return CompletableFuture.completedFuture( - transport.openNotificationStream( - agentCard, - agentName, - instruction, - transport.getContextId(), - metadata, - eventSink)); - } - - private void forwardNotificationEvent( - ClientEvent event, String agentName, Consumer> callback) { - Map data = ClientEventMapper.toMap(event, agentName); - if (!data.isEmpty()) { - log.info("[ExtensionSender] Notification-T callback for {}: {} keys", agentName, data.keySet()); - try { - callback.accept(data); - } catch (RuntimeException callbackError) { - log.warn( - "[ExtensionSender] Notification-T callback failed for {}: {}", - agentName, - callbackError.getMessage(), - callbackError); - } - } - } - - // ------------------------------------------------------------------ - // Extension prompt generation dispatch - // ------------------------------------------------------------------ - - /** - * Generates a structured extension prompt via the A2A-T SDK. - * - *

          The template is addressed by a {@link TemplateUri}; the message language comes from the - * SDK's own {@code A2AT_LANGUAGE} configuration, no longer from a caller-supplied language - * string. - * - *

          The caller selects the current SDK template explicitly. This keeps the generic sender - * capable of both Notification-T scenarios ({@code SERVICE_RECOVERY} and {@code - * SUBSCRIBE_INCIDENT}) without a scenario-specific default. - * - * @return {@link MetadataContent} with prompt text and template URI, or null if SDK unavailable - */ - MetadataContent generateExtensionPrompt( - A2ATExtension extension, - String naturalLanguageInput, - TemplateUri templateUri) { - A2ATClient a2atClient = transport.getA2atClient(); - if (a2atClient == null) { - return null; - } - return switch (extension) { - case TASK_T -> a2atClient.generateTaskPromptFromText( - naturalLanguageInput, templateUri); - case AUTHORIZATION_T -> a2atClient.generateAuthPromptFromText( - naturalLanguageInput, templateUri); - case NOTIFICATION_T -> a2atClient.generateNotificationPromptFromText( - naturalLanguageInput, templateUri); - case NEGOTIATION_T -> throw new IllegalArgumentException( - "Negotiation-T uses the dedicated negotiation flow"); - }; - } - - private static boolean supports(AgentCard card, A2ATExtension extension) { - return A2ATransport.extractExtensionUris(card).contains(extension.uri()); - } - - private static CompletableFuture validateRequest( - String agentName, - String instruction, - String naturalLanguageInput, - A2ATExtension extension) { - if (agentName == null - || agentName.isBlank() - || instruction == null - || instruction.isBlank() - || naturalLanguageInput == null - || naturalLanguageInput.isBlank()) { - return CompletableFuture.failedFuture( - new IllegalArgumentException( - "agentName, instruction and naturalLanguageInput must not be blank")); - } - if (extension != A2ATExtension.AUTHORIZATION_T) { - return CompletableFuture.failedFuture( - new IllegalArgumentException( - "One-shot sending supports Authorization-T only; " - + "use openNotification for Notification-T")); - } - return null; - } - - private static IllegalArgumentException validateTemplate( - A2ATExtension extension, TemplateUri templateUri) { - if (templateUri == null) { - return new IllegalArgumentException("A2A-T template URI is required"); - } - String expectedExtension = - switch (extension) { - case TASK_T -> StandardTemplates.TASK_EXTENSION_NAME; - case NEGOTIATION_T -> StandardTemplates.NEGOTIATION_EXTENSION_NAME; - case AUTHORIZATION_T -> StandardTemplates.AUTHORIZATION_EXTENSION_NAME; - case NOTIFICATION_T -> StandardTemplates.NOTIFICATION_EXTENSION_NAME; - }; - if (!expectedExtension.equals(templateUri.extensionName())) { - return new IllegalArgumentException( - "Template " - + templateUri.uri() - + " does not belong to " - + extension.displayName()); + private AgentCard requireExtension(String agentName, MessageContent content, A2ATExtension extension) { + Objects.requireNonNull(content, "content"); + AgentCard card = transport.getCard(agentName); + if (card == null) throw new IllegalArgumentException("Agent not found: " + agentName); + if (!A2ATransport.extractExtensionUris(card).contains(extension.uri()) + || !content.extensions().contains(extension.uri()) + || !content.metadata().containsKey(extension.uri())) { + throw new IllegalArgumentException("Target capability and content must use " + extension.uri()); } - return null; + return card; } @Override public void close() { - // Transport is owned by the caller; do not close it here. + // Caller owns the independent transport and its subscriptions. } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java index 1dd82ebc..ab1a1d92 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClient.java @@ -22,16 +22,9 @@ import dev.openan.workflow.engine.control.ControlPoint; import dev.openan.workflow.engine.control.EventCallback; import dev.openan.workflow.engine.control.EventType; -import dev.openan.workflow.engine.model.NegotiationDecision; -import dev.openan.workflow.engine.model.NegotiationRequest; -import dev.openan.workflow.engine.model.SendMessageResult; - -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.PromptTemplate; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; - +import dev.openan.workflow.engine.model.*; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.MessageEvent; import org.a2aproject.sdk.client.TaskUpdateEvent; @@ -41,718 +34,273 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.time.Duration; +import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; -/** - * Workflow-execution send facade built on a shared {@link A2ATransport}. - * - *

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

          Independent Authorization-T operations and Notification-T subscriptions live on {@link - * DefaultExtensionSender}. - */ +/** Coordinates A2A task interaction; all content generation and semantic validation belong to the host. */ public class DefaultWorkflowEngineClient implements WorkflowEngineClient, AutoCloseable { - private static final Logger log = LoggerFactory.getLogger(DefaultWorkflowEngineClient.class); - private final A2ATransport transport; - private final ExtensionRegistry extensionRegistry; - private final int maxNegotiationRounds; + private final int maxNegotiationExchanges; private final boolean closeTransportOnClose; - private final java.util.concurrent.atomic.AtomicBoolean closed = - new java.util.concurrent.atomic.AtomicBoolean(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final Set invocations = java.util.concurrent.ConcurrentHashMap.newKeySet(); private EventCallback eventCallback = new EventCallback(); private ControlPoint controlPoint; - public DefaultWorkflowEngineClient( - A2ATransport transport, - int maxNegotiationRounds, - List customHandlers, - Map negotiationParamSchema) { - this( - transport, - maxNegotiationRounds, - customHandlers, - negotiationParamSchema, - false); - } - - private DefaultWorkflowEngineClient( - A2ATransport transport, - int maxNegotiationRounds, - List customHandlers, - Map negotiationParamSchema, - boolean closeTransportOnClose) { - this.transport = java.util.Objects.requireNonNull(transport, "transport"); - if (maxNegotiationRounds < 1) { - throw new IllegalArgumentException("maxNegotiationRounds must be positive"); - } - this.closeTransportOnClose = closeTransportOnClose; - this.extensionRegistry = new ExtensionRegistry(negotiationParamSchema); - if (customHandlers != null) { - for (ExtensionHandler h : customHandlers) { - extensionRegistry.register(h); - } - } - this.maxNegotiationRounds = maxNegotiationRounds; - log.info( - "[EngineClient] Initialized over transport ({} agent(s)), maxNeg={}", - transport.getAgentNames().size(), - maxNegotiationRounds); - } - - public DefaultWorkflowEngineClient( - A2ATransport transport, - int maxNegotiationRounds, - List customHandlers) { - this(transport, maxNegotiationRounds, customHandlers, null); + /** Creates a caller-owned transport facade. */ + public DefaultWorkflowEngineClient(A2ATransport transport) { + this(transport, 3, false); } - public DefaultWorkflowEngineClient(A2ATransport transport) { - this(transport, 3, null, null); + /** Uses the configured resource exchange budget, not an SDK protocol round counter. */ + public DefaultWorkflowEngineClient(A2ATransport transport, WorkflowEngineClientConfig config) { + this(transport, config.getMaxNegotiationExchanges(), false); } - /** Applies negotiation and extension settings from the same config used by the transport. */ - public DefaultWorkflowEngineClient( - A2ATransport transport, WorkflowEngineClientConfig config) { - this( - transport, - java.util.Objects.requireNonNull(config, "config").getMaxNegotiationRounds(), - config.getCustomHandlers(), - config.getNegotiationParamSchema()); + 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 a client which closes the supplied transport when the client is closed. */ + /** Creates a facade owning the supplied transport. */ public static DefaultWorkflowEngineClient owning(A2ATransport transport) { - return new DefaultWorkflowEngineClient(transport, 3, null, null, true); + return new DefaultWorkflowEngineClient(transport, 3, true); } - /** Owning variant which also applies all client-level configuration. */ - public static DefaultWorkflowEngineClient owning( - A2ATransport transport, WorkflowEngineClientConfig config) { - java.util.Objects.requireNonNull(config, "config"); - return new DefaultWorkflowEngineClient( - transport, - config.getMaxNegotiationRounds(), - config.getCustomHandlers(), - config.getNegotiationParamSchema(), - true); - } - - // ------------------------------------------------------------------ - // Wiring - // ------------------------------------------------------------------ - - /** - * Whether the agent's reply opens a negotiation: metadata carries the Negotiation-T key AND - * the task is not already completed with a business result. A completed reply carrying both - * keys (e.g. a follow-up round whose artifact includes the negotiation Accept) ends the - * loop — the business already ran. - */ - private static boolean isNegotiationNeeded(SendMessageResult result) { - Map meta = result.getMetadata(); - if (meta == null || !meta.containsKey(A2ATExtension.NEGOTIATION_T.uri())) { - return false; - } - boolean completed = - result.getTaskState() != null - && result.getTaskState().contains("TASK_STATE_COMPLETED"); - boolean hasBusinessResult = meta.containsKey(A2ATExtension.TASK_T.uri()); - return !(completed && hasBusinessResult); + /** Creates an owning facade with explicit resource settings. */ + public static DefaultWorkflowEngineClient owning(A2ATransport transport, WorkflowEngineClientConfig config) { + return new DefaultWorkflowEngineClient(transport, config.getMaxNegotiationExchanges(), true); } @Override - public void setControlPoint(ControlPoint controlPoint) { - this.controlPoint = controlPoint; - } + public void setControlPoint(ControlPoint callbacks) { this.controlPoint = callbacks; } @Override public void setEventCallback(EventCallback callback) { - this.eventCallback = callback != null ? callback : new EventCallback(); + this.eventCallback = callback == null ? new EventCallback() : callback; } - // ------------------------------------------------------------------ - // A2A-T template queries - // ------------------------------------------------------------------ - @Override - public List getPrompts() { - A2ATContentFacade content = transport.getContentFacade(); - return content != null ? content.getPrompts() : List.of(); - } + public long callbackTimeoutSeconds() { return transport.sendTimeoutSeconds(); } @Override - public List getNegotiationPrompts() { - A2ATContentFacade content = transport.getContentFacade(); - return content != null ? content.getNegotiationPrompts() : List.of(); + public CompletableFuture sendMessage(String agentName, MessageContent content) { + TaskRequest request = TaskRequest.builder().agentName(agentName) + .executionId(UUID.randomUUID().toString()).taskId(UUID.randomUUID().toString()).build(); + return dispatch(request, content, controlPoint); } - @Override - public Optional getPrompt(TemplateUri templateUri) { - A2ATContentFacade content = transport.getContentFacade(); - return content != null ? content.getPrompt(templateUri) : Optional.empty(); - } + private final class Invocation { + final TaskRequest task; + final MessageContent original; + final ControlPoint callbacks; + final String contextId = UUID.randomUUID().toString(); + final String attempt = UUID.randomUUID().toString(); + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(callbackTimeoutSeconds()); + final CompletableFuture completion = new CompletableFuture<>(); + final Map> history = new HashMap<>(); + final Set answered = new HashSet<>(); + final Map contexts = new HashMap<>(); + String remoteTaskId; + int exchanges; - // ------------------------------------------------------------------ - // Workflow send path - // ------------------------------------------------------------------ - private void emit(String type, Map data) { - try { - eventCallback.onEvent(type, data); - } catch (RuntimeException callbackError) { - log.warn( - "[EngineClient] Event callback failed for type={}: {}", - type, - callbackError.getMessage(), - callbackError); + Invocation(TaskRequest task, MessageContent content, ControlPoint callbacks) { + this.task = task; + this.original = content; + this.callbacks = callbacks; } - } - // ------------------------------------------------------------------ - // Auto-negotiation - // ------------------------------------------------------------------ - @Override - public CompletableFuture sendMessage( - String agentName, String message, String contextId, Map metadata) { - if (agentName == null || agentName.isBlank() || message == null || message.isBlank()) { - return CompletableFuture.failedFuture( - new IllegalArgumentException("agentName and message must not be blank")); - } - AgentCard agentCard = transport.getCard(agentName); - if (agentCard == null) { - log.error("[EngineClient] Agent not found: {}", agentName); - return CompletableFuture.failedFuture( - new RuntimeException("Agent not found: " + agentName)); + Duration remaining() { + return Duration.ofNanos(Math.max(0, deadline - System.nanoTime())); } - log.info("[EngineClient] send_message to {}: {} chars", agentName, message.length()); - String effectiveContextId = contextId != null ? contextId : transport.getContextId(); - return runBeforeSendHandlers(agentCard, message, metadata) - .thenCompose( - processedMetadata -> { - emit( - EventType.AGENT_REQUEST, - Map.of( - "agent", agentName, - "request", message, - "metadata", - processedMetadata != null - ? processedMetadata - : Map.of())); - return transport - .send( - agentCard, - agentName, - message, - effectiveContextId, - processedMetadata, - event -> forwardIntermediateEvent(event, agentName)) - .thenCompose( - result -> runAfterReceiveHandlers(agentCard, result)) - .thenCompose( - result -> - autoNegotiate( - agentCard, - agentName, - message, - effectiveContextId, - result, 1)); - }) - .whenComplete( - (ignored, error) -> - transport.closeConversation(agentCard, effectiveContextId)); - } - - private CompletableFuture autoNegotiate( - AgentCard agentCard, - String agentName, - String originalMessage, - String contextId, - SendMessageResult result, - int round) { - if (!isNegotiationNeeded(result)) { - emitAgentResponse(agentName, result); - return CompletableFuture.completedFuture(result); - } - if (round > maxNegotiationRounds) { - return abortNegotiation(agentCard, agentName, contextId, result, round); - } - Map negMeta = - result.getMetadata() != null ? result.getMetadata() : new HashMap<>(); - Object negVal = negMeta.get(A2ATExtension.NEGOTIATION_T.uri()); - String negText = negVal instanceof String s ? s : ""; - log.info("[Negotiation] Round {} for '{}': {}", round, agentName, negText); - emit( - EventType.NEGOTIATION_REQUEST, - Map.of("agent", agentName, "round", round, "concern", negText)); - NegotiationRequest request = toNegotiationRequest(agentName, negText, negMeta); - CompletableFuture decisionFuture = - controlPoint != null - ? controlPoint.onNegotiation(request) - : CompletableFuture.completedFuture( - NegotiationDecision.acceptText( - "Please proceed with the original task using available information.")); - return decisionFuture.thenCompose( - decision -> - decision == null - ? failWithoutDecision(agentName, result, round) - : continueNegotiationRound( - agentCard, - agentName, - originalMessage, - contextId, - result, - negMeta, - decision, - round)); - } - /** Emits the terminal AGENT_RESPONSE event for an agent's reply. */ - private void emitAgentResponse(String agentName, SendMessageResult result) { - Map data = new HashMap<>(); - data.put("agent", agentName); - data.put("response", result.getText() != null ? result.getText() : ""); - data.put("metadata", result.getMetadata() != null ? result.getMetadata() : Map.of()); - emit(EventType.AGENT_RESPONSE, data); + boolean active() { return !closed.get() && !completion.isDone() && !remaining().isZero(); } } - /** - * Terminal path when the control point produces no decision: negotiation failed, the - * agent's reply stands as the final response. - */ - private CompletableFuture failWithoutDecision( - String agentName, SendMessageResult result, int round) { - emit( - EventType.NEGOTIATION_FAILED, - Map.of("agent", agentName, "round", round, "reason", "no decision")); - emitAgentResponse(agentName, result); - return CompletableFuture.completedFuture(result); - } - - /** - * Round-budget-exhaustion path: terminates via the SDK abort flow. The abort message is sent - * to the agent (best effort \u2014 send failures are logged, not propagated, since the negotiation - * is over either way) and the last agent reply stands as the final response. The loop does - * NOT continue on the abort reply. - */ - private CompletableFuture abortNegotiation( - AgentCard agentCard, - String agentName, - String contextId, - SendMessageResult result, - int round) { - log.warn( - "[Negotiation] Round budget exhausted for '{}' ({} rounds); aborting", - agentName, - maxNegotiationRounds); - emit( - EventType.NEGOTIATION_FAILED, - Map.of("agent", agentName, "round", round, "reason", "round budget exhausted")); - return buildNegotiationFollowUpMeta( - agentName, - result.getMetadata() != null ? result.getMetadata() : new HashMap<>(), - NegotiationDecision.abortData( - "negotiation round budget exhausted (" - + maxNegotiationRounds - + ")")) - .thenCompose( - abortMeta -> { - Object rendered = abortMeta.get(A2ATExtension.NEGOTIATION_T.uri()); - String followUp = - rendered instanceof String s && !s.isEmpty() - ? s - : "\u534f\u5546\u5df2\u7ec8\u6b62"; - return runBeforeSendHandlers(agentCard, followUp, abortMeta) - .thenCompose( - meta -> - transport.send( - agentCard, - agentName, - followUp, - contextId, - meta, - event -> - forwardIntermediateEvent( - event, agentName))); - }) - .handle( - (ignored, error) -> { - if (error != null) { - log.warn( - "[Negotiation] Abort message delivery to '{}' failed: {}", - agentName, - error.getMessage()); - } - emitAgentResponse(agentName, result); - return result; - }); - } - - /** - * One negotiation round with a typed decision: renders the follow-up metadata via the SDK - * content layer, sends it, and recurses into the next round. - */ - private CompletableFuture continueNegotiationRound( - AgentCard agentCard, - String agentName, - String originalMessage, - String contextId, - SendMessageResult result, - Map negMeta, - NegotiationDecision decision, - int round) { - log.info( - "[Negotiation] Decision for '{}' round {}: {} via {}", - agentName, - round, - decision.action(), - decision.input().getClass().getSimpleName()); - emit( - EventType.NEGOTIATION_RESOLVED, - Map.of("agent", agentName, "round", round, "decision", decision.action().name())); - String taskId = result.getTask() != null ? result.getTask().id() : null; - return buildNegotiationFollowUpMeta(agentName, negMeta, decision) - .thenCompose( - followUpMeta -> { - // The message body is the rendered negotiation text itself: the SDK - // template output. The same text also travels in metadata under the - // Negotiation-T key; agents read either. - Object rendered = - followUpMeta.get(A2ATExtension.NEGOTIATION_T.uri()); - String followUp = - rendered instanceof String s && !s.isEmpty() - ? s - : throwMissingNegotiationContent(agentName); - return runBeforeSendHandlers(agentCard, followUp, followUpMeta) - .thenCompose( - meta -> { - String ctx = - contextId != null - ? contextId - : transport.getContextId(); - return transport - .send( - agentCard, - agentName, - followUp, - ctx, - taskId, - meta, - event -> - forwardIntermediateEvent( - event, agentName)) - .thenCompose( - r -> - runAfterReceiveHandlers( - agentCard, r)) - .thenCompose( - r -> - autoNegotiate( - agentCard, - agentName, - originalMessage, - contextId, - r, - round + 1)); - }); - }); - } - - /** - * Builds the follow-up metadata for one negotiation round using the SDK content layer. - * - *

          The decision's action and input type select the exact current SDK method. The typed ending - * message preserves the received stateless session/round context. Rendering failures are fatal: - * raw business input is never disguised as protocol metadata. - */ - private CompletableFuture> buildNegotiationFollowUpMeta( - String agentName, - Map negMeta, - NegotiationDecision decision) { - A2ATContentFacade content = transport.getContentFacade(); - if (content == null) { - return CompletableFuture.failedFuture( - new IllegalStateException( - "A2A-T client is required to render a Negotiation-T follow-up for '" - + agentName - + "'")); - } - return CompletableFuture.supplyAsync( - () -> { - try { - MetadataContent mc = - renderFollowUpMessage(content, agentName, negMeta, decision); - if (mc.promptText() != null && !mc.promptText().isEmpty()) { - return A2ATContentFacade.toMetadata(mc); - } - throw new IllegalStateException("A2A-T SDK returned empty negotiation content"); - } catch (Exception e) { - throw new IllegalStateException( - "Negotiation-T content generation failed for '" + agentName + "'", - e); - } + @Override + public CompletableFuture dispatch( + TaskRequest request, MessageContent content, ControlPoint callbacks) { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(content, "onTask returned null content"); + AgentCard card = transport.getCard(request.getAgentName()); + if (closed.get() || card == null) return CompletableFuture.failedFuture( + new IllegalStateException(closed.get() ? "Workflow client closed" : "Agent not found: " + request.getAgentName())); + Invocation invocation = new Invocation(request, content, callbacks); + invocations.add(invocation); + invocation.completion.orTimeout(callbackTimeoutSeconds(), TimeUnit.SECONDS); + invocation.completion.whenComplete((result, error) -> { + invocations.remove(invocation); + transport.closeConversation(card, invocation.contextId); + if (error != null && invocation.exchanges > 0) emit(EventType.NEGOTIATION_FAILED, + Map.of("agent", request.getAgentName(), "exchange", invocation.exchanges, + "errorType", error.getClass().getSimpleName())); + }); + if (closed.get()) invocation.completion.cancel(true); + send(card, invocation, content, null) + .thenCompose(result -> advance(card, invocation, result)) + .whenComplete((result, error) -> { + if (error != null) invocation.completion.completeExceptionally(error); + else invocation.completion.complete(result); }); - } - - /** - * Renders the follow-up through the SDK method selected by the typed business decision. - */ - private MetadataContent renderFollowUpMessage( - A2ATContentFacade content, - String agentName, - Map negMeta, - NegotiationDecision decision) { - net.openan.a2at.sdk.core.model.NegotiationContext contentCtx = - extractContentContext(negMeta); - net.openan.a2at.sdk.core.model.TemplateUri endingTemplate = - endingTemplateFor(negMeta); - var context = requireContext(contentCtx, agentName); - if (decision.input() instanceof NegotiationDecision.NaturalLanguage natural) { - return renderFromText(content, decision.action(), natural.text(), context, endingTemplate); - } - NegotiationDecision.StructuredData structured = - (NegotiationDecision.StructuredData) decision.input(); - return renderFromData( - content, decision.action(), structured.values(), context, endingTemplate); - } - - /** - * Renders one natural-language decision through the corresponding SDK fromText method. - */ - private MetadataContent renderFromText( - A2ATContentFacade content, - NegotiationDecision.Action action, - String text, - net.openan.a2at.sdk.core.model.NegotiationContext contentCtx, - net.openan.a2at.sdk.core.model.TemplateUri endingTemplate) { - return switch (action) { - case ACCEPT -> content.generateAcceptFromText(text, contentCtx, endingTemplate); - case REJECT -> content.generateRejectFromText(text, contentCtx, endingTemplate); - case ABORT -> content.generateAbortFromText(text, contentCtx); - }; - } - - /** Renders one deterministic typed decision through the corresponding SDK fromData method. */ - private MetadataContent renderFromData( - A2ATContentFacade content, - NegotiationDecision.Action action, - Map values, - net.openan.a2at.sdk.core.model.NegotiationContext contentCtx, - net.openan.a2at.sdk.core.model.TemplateUri endingTemplate) { - if (action == NegotiationDecision.Action.ABORT) { - String reason = requireOnlyField(values, "terminationReason", "Abort"); - return content.generateAbortFromData( - new net.openan.a2at.sdk.negotiation.content.NegotiationAbortData( - contentCtx, - new net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent( - reason))); - } - var conclusion = - action == NegotiationDecision.Action.ACCEPT - ? net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.ACCEPT - : net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.REJECT; - var endingContent = buildEndingContent(endingTemplate, values, conclusion); - var data = - new net.openan.a2at.sdk.negotiation.content.NegotiationEndingData( - contentCtx, endingContent); - return action == NegotiationDecision.Action.ACCEPT - ? content.generateAcceptFromData(data, endingTemplate) - : content.generateRejectFromData(data, endingTemplate); - } - - static net.openan.a2at.sdk.core.model.TemplateUri endingTemplateFor( - Map negotiationMetadata) { - Object raw = negotiationMetadata.get( - net.openan.a2at.sdk.core.model.MetadataContent.TEMPLATE_URI_METADATA_KEY); - net.openan.a2at.sdk.core.model.TemplateUri propose = - raw instanceof String text - ? net.openan.a2at.sdk.core.model.TemplateUri.parse(text).orElse(null) - : null; - if (StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.equals(propose)) { - return StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; - } - if (StandardTemplates.TARGET_NEGOTIATION_PROPOSE.equals(propose)) { - return StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; - } - if (StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.equals(propose)) { - return StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; - } - throw new IllegalArgumentException( - "Missing or unsupported Negotiation-T propose templateUri: " + raw); - } - - static net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent - buildEndingContent( - net.openan.a2at.sdk.core.model.TemplateUri endingTemplate, - Map data, - net.openan.a2at.sdk.negotiation.content.NegotiationConclusion conclusion) { - if (StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.equals(endingTemplate)) { - var items = data.entrySet().stream() - .map(entry -> new net.openan.a2at.sdk.negotiation.content.NegotiationItem( - entry.getKey(), entry.getValue())) - .toList(); - return new net.openan.a2at.sdk.negotiation.content.InformationEndingContent( - conclusion, items); - } - if (StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.equals(endingTemplate)) { - return new net.openan.a2at.sdk.negotiation.content.TargetEndingContent( - conclusion, - requireOnlyField(data, "confirmedIntent", actionName(conclusion)), - null); - } - if (StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.equals(endingTemplate)) { - return new net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent( - conclusion, - requireOnlyField(data, "feasibilitySummary", actionName(conclusion))); - } - throw new IllegalArgumentException( - "Unsupported Negotiation-T ending template: " + endingTemplate.uri()); - } - - private static String actionName( - net.openan.a2at.sdk.negotiation.content.NegotiationConclusion conclusion) { - return conclusion == net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.ACCEPT - ? "Accept" - : "Reject"; - } - - private static String throwMissingNegotiationContent(String agentName) { - throw new IllegalStateException( - "A2A-T SDK returned no rendered negotiation content for '" + agentName + "'"); - } - - private static String requireOnlyField( - Map data, String field, String action) { - if (data.size() != 1 || !data.containsKey(field)) { - throw new IllegalArgumentException( - "fromData " + action + " requires exactly the field '" + field + "'"); - } - return data.get(field); - } - - private static NegotiationRequest toNegotiationRequest( - String agentName, String concern, Map metadata) { - var context = - requireContext( - extractContentContext(metadata), agentName); - Object rawTemplate = - metadata.get( - net.openan.a2at.sdk.core.model.MetadataContent.TEMPLATE_URI_METADATA_KEY); - return new NegotiationRequest( - agentName, - concern, - context.id(), - context.round(), - context.maxRounds(), - context.performative(), - negotiationKind(rawTemplate), - rawTemplate instanceof String text ? text : "", - negotiationParameters(metadata), - metadata); - } - - @SuppressWarnings("unchecked") - private static Map negotiationParameters(Map metadata) { - Object raw = metadata.get(A2ATExtension.NEGOTIATION_PARAMS_META_KEY); - return raw instanceof Map values ? (Map) values : Map.of(); - } - - private static NegotiationRequest.Kind negotiationKind(Object rawTemplate) { - String uri = rawTemplate instanceof String text ? text : ""; - if (StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri().equals(uri)) { - return NegotiationRequest.Kind.INFORMATION; + return invocation.completion; + } + + private CompletableFuture send( + AgentCard card, Invocation invocation, MessageContent content, String taskId) { + if (!invocation.active()) return CompletableFuture.failedFuture( + new java.util.concurrent.CancellationException("Task interaction is no longer active")); + emit(EventType.AGENT_REQUEST, Map.of("agent", invocation.task.getAgentName(), "content", content)); + Map trace = invocationTrace(invocation); + return WireLog.call(trace, () -> transport.send(card, invocation.task.getAgentName(), content, + invocation.contextId, taskId, event -> forwardIntermediateEvent(event, invocation.task.getAgentName()), + invocation::active)); + } + + private Map invocationTrace(Invocation invocation) { + Map trace = new HashMap<>(); + if (invocation.task.getExecutionId() != null) trace.put("executionId", invocation.task.getExecutionId()); + if (invocation.task.getTaskId() != null) trace.put("logicalTaskId", invocation.task.getTaskId()); + trace.put("attempt", invocation.attempt); + trace.put("contextId", invocation.contextId); + return trace; + } + + private CompletableFuture 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 (StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri().equals(uri)) { - return NegotiationRequest.Kind.TARGET; + if ("TASK_STATE_SUBMITTED".equals(result.getTaskState()) + || "TASK_STATE_WORKING".equals(result.getTaskState())) { + return observeTask(card, invocation).thenCompose(next -> advance(card, invocation, next)); } - if (StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri().equals(uri)) { - return NegotiationRequest.Kind.FEASIBILITY; + if (!"TASK_STATE_INPUT_REQUIRED".equals(result.getTaskState())) { + emitAgentResponse(invocation.task.getAgentName(), result); + return CompletableFuture.completedFuture(result); } - throw new IllegalArgumentException( - "Missing or unsupported Negotiation-T propose templateUri: " + rawTemplate); - } - - /** - * Extracts the negotiation session context (id/round/maxRounds/performative) from the received - * metadata. The latest SDK's canonical {@code negotiationContext} entry carries all four - * values; no stateful negotiation API or legacy metadata shape is involved. - */ - private static net.openan.a2at.sdk.core.model.NegotiationContext extractContentContext( - Map negMeta) { - Object stateful = negMeta.get(A2ATExtension.NEGOTIATION_CONTEXT_META_KEY); - if (stateful instanceof Map stateMap) { - net.openan.a2at.sdk.core.model.NegotiationContext ctx = - A2ATContentFacade.contextFromMap(stateMap); - if (ctx != null) { - return ctx; + if (result.getTask() == null || result.getTask().id() == null) { + return CompletableFuture.failedFuture(new IllegalArgumentException("INPUT_REQUIRED has no remote task identity")); + } + String remoteTask = result.getTask().id(); + ReceivedMessage received = negotiationResponse(result); + NegotiationContext context = A2atMessages.contextOf(received); + if (context.performative() != NegotiationPerformative.PROPOSE || context.isExhausted()) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Expected a valid Negotiation-T Propose")); + } + NegotiationContext previous = invocation.contexts.put(context.id(), context); + if (previous != null && (context.round() < previous.round() || context.maxRounds() != previous.maxRounds())) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Negotiation round regressed or maxRounds changed")); + } + String key = remoteTask + ":" + context.id() + ":" + context.round(); + if (!invocation.answered.add(key)) { + return observeTask(card, invocation).thenCompose(next -> advance(card, invocation, next)); + } + if (++invocation.exchanges > maxNegotiationExchanges) { + return CompletableFuture.failedFuture(new IllegalStateException("Negotiation exchange budget exhausted; no Abort generated")); + } + List history = + invocation.history.computeIfAbsent(context.id(), ignored -> new ArrayList<>()); + NegotiationRequest request = new NegotiationRequest(invocation.task, invocation.original, + received, history, invocation.remaining()); + emit(EventType.NEGOTIATION_REQUEST, Map.of("agent", invocation.task.getAgentName(), + "request", request, "exchange", invocation.exchanges)); + CompletableFuture answer; + try { + answer = Objects.requireNonNull( + Objects.requireNonNull(invocation.callbacks, "onNegotiation handler is required").onNegotiation(request), + "onNegotiation returned null future"); + } catch (RuntimeException error) { return CompletableFuture.failedFuture(error); } + return answer.thenCompose(reply -> { + Objects.requireNonNull(reply, "onNegotiation returned null reply"); + if (!invocation.active()) return CompletableFuture.failedFuture( + new java.util.concurrent.CancellationException("Late negotiation reply ignored")); + history.add(new NegotiationRequest.Exchange(received, reply)); + if (reply instanceof NegotiationReply.Stop stop) { + return CompletableFuture.failedFuture(new BusinessFailure(stop.code(), stop.reason(), Map.of())); } - } - return null; - } - - private static net.openan.a2at.sdk.core.model.NegotiationContext requireContext( - net.openan.a2at.sdk.core.model.NegotiationContext context, String agentName) { - if (context == null) { - throw new IllegalStateException( - "No negotiation context from agent '" + agentName + "'; cannot render SDK message"); - } - return context; + 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); + }); + }); + } + + /** A send ACK is not a task result. Observe without resending the business command. */ + private CompletableFuture observeTask(AgentCard card, Invocation invocation) { + if (invocation.remoteTaskId == null) return CompletableFuture.failedFuture( + new IllegalArgumentException("Non-terminal response has no remote task identity")); + return CompletableFuture.supplyAsync(() -> { + if (!invocation.active()) throw new java.util.concurrent.CancellationException("Task wait ended"); + return Boolean.TRUE; + }, CompletableFuture.delayedExecutor(250, TimeUnit.MILLISECONDS)).thenCompose(ignored -> { + if (!invocation.active()) return CompletableFuture.failedFuture( + new java.util.concurrent.CancellationException("Task wait ended")); + return WireLog.call(invocationTrace(invocation), () -> + transport.getTask(card, invocation.task.getAgentName(), invocation.remoteTaskId)); + }); + } + + 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"); + } + + 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; } - // ------------------------------------------------------------------ - // Extension handler chain - // ------------------------------------------------------------------ - - private CompletableFuture> runBeforeSendHandlers( - AgentCard agentCard, String message, Map presetMetadata) { - Map metadata = - presetMetadata != null ? new HashMap<>(presetMetadata) : new HashMap<>(); - List extUris = A2ATransport.extractExtensionUris(agentCard); - List handlers = extensionRegistry.getHandlersForExtensions(extUris); - CompletableFuture> future = CompletableFuture.completedFuture(metadata); - for (ExtensionHandler handler : handlers) { - future = - future.thenCompose( - m -> - handler.beforeSend( - agentCard, - message, - m, - transport.getA2atClient(), - controlPoint)); - } - return future; + private void emit(String type, Map data) { + try { eventCallback.onEvent(type, data); } + catch (RuntimeException error) { log.warn("Event callback failed for {}", type, error); } } - private CompletableFuture runAfterReceiveHandlers( - AgentCard agentCard, SendMessageResult result) { - List extUris = A2ATransport.extractExtensionUris(agentCard); - List handlers = extensionRegistry.getHandlersForExtensions(extUris); - CompletableFuture future = CompletableFuture.completedFuture(result); - for (ExtensionHandler handler : handlers) { - future = - future.thenCompose( - r -> - handler.afterReceive( - agentCard, - r, - transport.getA2atClient(), - controlPoint, - eventCallback)); - } - return future; + private void emitAgentResponse(String agentName, SendMessageResult result) { + Map data = new HashMap<>(); + data.put("agent", agentName); + data.put("response", result.getText()); + data.put("receivedMessages", result.getReceivedMessages()); + emit(EventType.AGENT_RESPONSE, data); } - // ------------------------------------------------------------------ - // Intermediate event forwarding - // ------------------------------------------------------------------ - private void forwardIntermediateEvent(ClientEvent event, String agentName) { if (event instanceof TaskUpdateEvent tue) { if (tue.getUpdateEvent() instanceof TaskStatusUpdateEvent sue) { @@ -811,12 +359,9 @@ private void forwardIntermediateEvent(ClientEvent event, String agentName) { } } - // ------------------------------------------------------------------ - // Lifecycle - // ------------------------------------------------------------------ - @Override public CompletableFuture getTask(String agentName, String taskId) { + if (closed.get()) return CompletableFuture.failedFuture(new IllegalStateException("Workflow client closed")); if (agentName == null || agentName.isBlank() || taskId == null || taskId.isBlank()) { return CompletableFuture.failedFuture( new IllegalArgumentException("agentName and taskId must not be blank")); @@ -830,6 +375,7 @@ public CompletableFuture getTask(String agentName, String tas @Override public CompletableFuture cancelTask(String agentName, String taskId) { + if (closed.get()) return CompletableFuture.failedFuture(new IllegalStateException("Workflow client closed")); if (agentName == null || agentName.isBlank() || taskId == null || taskId.isBlank()) { return CompletableFuture.failedFuture( new IllegalArgumentException("agentName and taskId must not be blank")); @@ -845,6 +391,7 @@ public CompletableFuture cancelTask(String agentName, String public CompletableFuture subscribeToTask( String agentName, String taskId, java.util.function.Consumer> eventCallback) { + if (closed.get()) return CompletableFuture.failedFuture(new IllegalStateException("Workflow client closed")); if (agentName == null || agentName.isBlank() || taskId == null || taskId.isBlank()) { return CompletableFuture.failedFuture( new IllegalArgumentException("agentName and taskId must not be blank")); @@ -864,8 +411,8 @@ public CompletableFuture subscribeToTask( @Override public void close() { - if (closeTransportOnClose && closed.compareAndSet(false, true)) { - transport.close(); - } + if (!closed.compareAndSet(false, true)) return; + invocations.forEach(invocation -> invocation.completion.cancel(true)); + if (closeTransportOnClose) transport.close(); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java deleted file mode 100644 index a8658a1c..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHandler.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.client; - -import dev.openan.workflow.engine.control.ControlPoint; -import dev.openan.workflow.engine.control.EventCallback; -import dev.openan.workflow.engine.model.SendMessageResult; - -import net.openan.a2at.sdk.client.A2ATClient; - -import org.a2aproject.sdk.spec.AgentCard; - -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -/** - * Extension handler for A2A-T extensions (SDK-internal). - * - *

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

          Authorization-T / Notification-T are independent operations (via {@link ExtensionSender}), - * so they are not part of this in-workflow chain. - */ -public interface ExtensionHandler { - - /** The extension keyword (e.g. "Task-T", "Negotiation-T"). */ - String extensionKeyword(); - - /** - * Called before sending a message to an agent. May modify the metadata (e.g. inject Task-T - * prompt). - * - * @param agentCard the agent's card as a map - * @param messageText the message being sent - * @param metadata current metadata (mutable) - * @param a2atClient optional A2ATClient (may be null) - * @param controlPoint optional ControlPoint (may be null) - * @return updated metadata - */ - CompletableFuture> beforeSend( - AgentCard agentCard, - String messageText, - Map metadata, - A2ATClient a2atClient, - ControlPoint controlPoint); - - /** - * Called after receiving a response from an agent. May inspect/modify the result (e.g. handle - * negotiation, authorization). - * - * @param agentCard the agent's card as a map - * @param result the received result - * @param a2atClient optional A2ATClient (may be null) - * @param controlPoint optional ControlPoint (may be null) - * @param eventCallback optional event callback (may be null) - * @return updated result - */ - CompletableFuture afterReceive( - AgentCard agentCard, - SendMessageResult result, - A2ATClient a2atClient, - ControlPoint controlPoint, - EventCallback eventCallback); - - /** - * Parameter JSON Schema for the SDK validate-and-fill pipeline on received negotiation - * messages: the business parameters this handler wants extracted. Declares the shape in - * terms of the caller's domain, so the engine core never hardcodes business fields. - * - *

          Default: an empty object schema (context fields are handled by the SDK). - * - * @return parameter JSON schema map, never null - */ - default Map negotiationParamSchema() { - return Map.of("type", "object"); - } -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHeaderContributor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHeaderContributor.java deleted file mode 100644 index dd7e15ad..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHeaderContributor.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package dev.openan.workflow.engine.client; - -import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; -import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; -import org.a2aproject.sdk.spec.AgentCard; - -import java.util.HashMap; -import java.util.Map; - -/** Adds headers required by extensions declared by the target AgentCard. */ -final class ExtensionHeaderContributor implements HeaderContributor { - private final AgentAuthManager authManager; - - ExtensionHeaderContributor(AgentAuthManager authManager) { - this.authManager = authManager; - } - - @Override - public Map contribute( - AgentCard agentCard, - String agentName, - Map messageMetadata, - Map currentHeaders) { - ExtensionInterceptor interceptor = authManager.buildExtensionInterceptor(agentCard); - if (interceptor == null) { - return Map.of(); - } - Map headers = new HashMap<>(currentHeaders); - ClientCallContext interceptContext = new ClientCallContext(new HashMap<>(), headers); - PayloadAndHeaders payloadAndHeaders = - interceptor.intercept( - "message/send", messageMetadata, headers, agentCard, interceptContext); - return payloadAndHeaders.getHeaders(); - } -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionInterceptor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionInterceptor.java deleted file mode 100644 index 5cb5c887..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionInterceptor.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.client; - -import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; -import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; -import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; -import org.a2aproject.sdk.spec.AgentCard; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -/** - * Injects A2A extension URIs into HTTP headers - but only the extensions that are actually present - * in the current message's metadata. - * - *

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

          The workflow engine only handles Task-T (task prompt generation) and Negotiation-T (auto - * negotiation loop). Authorization-T and Notification-T are independent protocol operations (see - * {@link ExtensionSender}), so they are NOT part of the workflow's extension handler chain. - */ -class ExtensionRegistry { - - private static final Logger log = LoggerFactory.getLogger(ExtensionRegistry.class); - - private final Map handlers = new LinkedHashMap<>(); - - public ExtensionRegistry() { - register(new TaskTHandler()); - register(new NegotiationTHandler()); - } - - /** - * Creates a registry whose built-in Negotiation-T handler validates received propose messages - * against the given business parameter schema. Use this instead of the no-arg constructor when - * the caller's domain declares concrete negotiation parameters. - * - * @param negotiationParamSchema parameter JSON schema for the validate-and-fill pipeline; null - * keeps the default empty schema - */ - public ExtensionRegistry(Map negotiationParamSchema) { - register(new TaskTHandler()); - register(new NegotiationTHandler(negotiationParamSchema)); - } - - public void register(ExtensionHandler handler) { - handlers.put(handler.extensionKeyword(), handler); - } - - /** - * Find handlers matching the given extension URIs. - * - * @param extensionUris list of extension URIs from the AgentCard - * @return matched handlers (deduplicated) - */ - public List getHandlersForExtensions(List extensionUris) { - List matched = new ArrayList<>(); - java.util.Set seen = new java.util.HashSet<>(); - if (extensionUris == null) { - return matched; - } - for (String uri : extensionUris) { - if (uri == null) { - continue; - } - java.util.Set pathSegments = extensionPathSegments(uri); - for (Map.Entry entry : handlers.entrySet()) { - if (entry.getValue() instanceof TaskTHandler - && !A2ATExtension.TASK_T.uri().equals(uri)) { - continue; - } - if (entry.getValue() instanceof NegotiationTHandler - && !A2ATExtension.NEGOTIATION_T.uri().equals(uri)) { - continue; - } - String keyword = entry.getKey().toLowerCase(Locale.ROOT); - if (pathSegments.contains(keyword) - && !seen.contains(entry.getKey())) { - matched.add(entry.getValue()); - seen.add(entry.getKey()); - break; - } - } - } - return matched; - } - - private static java.util.Set extensionPathSegments(String value) { - try { - String path = new URI(value).getPath(); - if (path == null || path.isBlank()) return java.util.Set.of(); - java.util.Set segments = new java.util.HashSet<>(); - for (String segment : path.split("/")) { - if (!segment.isBlank()) segments.add(segment.toLowerCase(Locale.ROOT)); - } - return segments; - } catch (URISyntaxException e) { - log.warn("Ignoring malformed extension URI: {}", value); - return java.util.Set.of(); - } - } -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java index d1fbeb70..989fcde0 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java @@ -19,136 +19,18 @@ package dev.openan.workflow.engine.client; +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.ReceivedMessage; import dev.openan.workflow.engine.model.SendMessageResult; - -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; - -import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.function.Consumer; +import java.util.function.BiConsumer; -/** - * Facade for protocol operations that are independent of workflow DAG execution. - * - *

          Single responsibility: send Authorization-T operations and establish Notification-T - * subscriptions. These operations may happen before, during, or after a workflow according to the - * workbench business lifecycle. They bypass Task-T prompt generation and the Negotiation-T - * auto-loop. The returned future carries the first response; later Notification-T events flow - * through the subscription callback. - * - *

          Kept separate from {@link WorkflowEngineClient} so protocol channels and lifecycles cannot be - * accidentally coupled to a workflow task. - */ +/** Operations independent of workflow execution; all content is supplied by the host. */ public interface ExtensionSender { + /** Sends final authorization content on this sender's independent transport. */ + CompletableFuture sendAuthorization(String agentName, MessageContent content); - /** - * Send a one-shot Authorization-T message. - * - * @param agentName target agent name - * @param instruction short instruction text (becomes message parts) - * @param naturalLanguageInput input for SDK prompt generation - * @param templateUri current SDK template used to render the input - * @param extension must be {@link A2ATExtension#AUTHORIZATION_T}; Notification-T has a - * different, long-lived lifecycle and must use {@link #openNotification} - */ - CompletableFuture sendExtensionMessage( - String agentName, - String instruction, - String naturalLanguageInput, - TemplateUri templateUri, - A2ATExtension extension); - - /** Natural-language variant for a one-shot Authorization-T operation. */ - default CompletableFuture sendAuthorization( - String agentName, String instruction, String naturalLanguageInput) { - return sendExtensionMessage( - agentName, - instruction, - naturalLanguageInput, - StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT, - A2ATExtension.AUTHORIZATION_T); - } - - /** - * Structured-data send: renders the extension prompt from typed data via the SDK's - * schema-aware fromData pipeline. Scenario recognition is bypassed, but slot mapping may - * invoke the configured LLM. Rendering failures are never sent as raw extension metadata. - * - *

          Callers holding structured business data (e.g. an authorization policy as fields, not - * prose) should prefer this over the natural-language variants. - * - * @param agentName target agent name - * @param instruction short instruction text (becomes message parts) - * @param data structured extension input (string-to-object map) - * @param schema JSON schema describing the meaning of each data field - * @param templateUri current SDK template used to render the data - * @param extension must be {@link A2ATExtension#AUTHORIZATION_T}; Notification-T must use - * {@link #openNotificationFromData} - * @return future completing with the first response - */ - CompletableFuture sendExtensionMessageFromData( - String agentName, - String instruction, - Map data, - Map schema, - TemplateUri templateUri, - A2ATExtension extension); - - /** - * Structured-data Notification-T subscription: renders the service-recovery subscription - * prompt from typed data through the SDK schema-aware pipeline, then opens the long-lived - * stream. Slot mapping may invoke the configured LLM. - * Subsequent events pushed by the agent flow to {@code eventCallback}. - */ - CompletableFuture sendNotificationFromData( - String agentName, - String instruction, - Map data, - Map schema, - TemplateUri templateUri, - Consumer> eventCallback); - - /** - * Opens a structured-data Notification-T subscription and exposes its full stream lifecycle. - * Callers should retain and close the returned handle after the expected notification arrives. - */ - CompletableFuture openNotificationFromData( - String agentName, - String instruction, - Map data, - Map schema, - TemplateUri templateUri, - Consumer> eventCallback); - - /** - * Establish a Notification-T subscription. - * - *

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

          Mirrors the Python SDK's {@code NegotiationTHandler}. When an agent returns INPUT_REQUIRED and - * supports Negotiation-T, this handler calls the A2ATClient to extract the negotiation context and - * message. - */ -class NegotiationTHandler implements ExtensionHandler { - - private static final Logger log = LoggerFactory.getLogger(NegotiationTHandler.class); - - /** Empty object schema: negotiation context fields are handled by the SDK. */ - private static final Map DEFAULT_SCHEMA = Map.of("type", "object"); - - private final Map paramSchema; - - /** - * Creates the handler with the default empty parameter schema (context params only). - */ - NegotiationTHandler() { - this(null); - } - - /** - * Creates the handler with a caller-provided business parameter schema used by the - * validate-and-fill pipeline on received propose messages. - * - * @param paramSchema parameter JSON schema declaring the business fields to extract; null - * falls back to {@link #DEFAULT_SCHEMA} - */ - NegotiationTHandler(Map paramSchema) { - this.paramSchema = paramSchema != null ? paramSchema : DEFAULT_SCHEMA; - } - - @Override - public Map negotiationParamSchema() { - return paramSchema; - } - - /** - * Extracts the canonical stateless content-layer context map from reply metadata. - */ - @SuppressWarnings("unchecked") - private static Map extractNegotiationContext(Map metadata) { - if (metadata == null) { - return null; - } - Object contextValue = - metadata.get( - net.openan.a2at.sdk.core.model.MetadataContent - .NEGOTIATION_CONTEXT_METADATA_KEY); - if (contextValue instanceof Map contextMap) { - return (Map) contextMap; - } - return null; - } - - private static String extractNegotiationText(Map metadata) { - if (metadata == null) { - return null; - } - Object value = metadata.get(A2ATExtension.NEGOTIATION_T.uri()); - return value instanceof String text ? text : null; - } - - private static boolean supportsNegotiation(AgentCard agentCard) { - if (agentCard.capabilities() == null) { - return false; - } - var extensions = agentCard.capabilities().extensions(); - if (extensions == null) { - return false; - } - for (var ext : extensions) { - if (A2ATExtension.NEGOTIATION_T.uri().equals(ext.uri())) { - return true; - } - } - return false; - } - - private static boolean hasNegotiationMetadata(Map metadata) { - return metadata != null && metadata.containsKey(A2ATExtension.NEGOTIATION_T.uri()); - } - - private static String getAgentName(AgentCard agentCard) { - return agentCard.name(); - } - - /** - * Parses the negotiation session context for the validate APIs. - * - *

          The latest SDK carries all three fields in {@code negotiationContext}; the content layer - * is stateless and the engine advances this value between messages. - */ - private static net.openan.a2at.sdk.core.model.NegotiationContext parseNegotiationContext( - Map metadata) { - return A2ATContentFacade.contextFromMap(extractNegotiationContext(metadata)); - } - - /** - * Resolves the propose template URI carried by {@link MetadataContent#buildMetadataContent()}. - */ - private static TemplateUri proposeTemplateFor(Map metadata) { - Object raw = metadata.get( - net.openan.a2at.sdk.core.model.MetadataContent.TEMPLATE_URI_METADATA_KEY); - TemplateUri parsed = null; - if (raw instanceof String text) { - parsed = TemplateUri.parse(text).orElse(null); - } - if (parsed == null - || !java.util.List.of( - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE, - StandardTemplates.TARGET_NEGOTIATION_PROPOSE, - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE) - .contains(parsed)) { - throw new IllegalArgumentException("Missing or invalid Negotiation-T propose templateUri"); - } - return parsed; - } - - @Override - public String extensionKeyword() { - return "Negotiation-T"; - } - - @Override - public CompletableFuture> beforeSend( - AgentCard agentCard, - String messageText, - Map metadata, - A2ATClient a2atClient, - ControlPoint controlPoint) { - // Accept prompt generation is handled by buildNegotiationFollowUpMeta - // in DefaultWorkflowEngineClient, which sets the Negotiation-T URI key - // before this method is called. No additional generation needed here. - return CompletableFuture.completedFuture(metadata); - } - - @Override - public CompletableFuture afterReceive( - AgentCard agentCard, - SendMessageResult result, - A2ATClient a2atClient, - ControlPoint controlPoint, - EventCallback eventCallback) { - if (a2atClient == null - || !supportsNegotiation(agentCard)) { - return CompletableFuture.completedFuture(result); - } - boolean inputRequired = - result.getTaskState() != null - && result.getTaskState().contains("INPUT_REQUIRED"); - boolean hasNegMeta = hasNegotiationMetadata(result.getMetadata()); - if (!inputRequired && !hasNegMeta) { - return CompletableFuture.completedFuture(result); - } - return CompletableFuture.supplyAsync( - () -> { - Map metadata = - result.getMetadata() != null - ? new HashMap<>(result.getMetadata()) - : new HashMap<>(); - // The negotiation message travels in metadata under the Negotiation-T - // URI key (the parts text is a short placeholder), per the SDK demo - // convention. - String proposeText = extractNegotiationText(metadata); - if (proposeText == null || proposeText.isEmpty()) { - proposeText = result.getText(); - } - // The agent's reply already signals a pending negotiation: INPUT_REQUIRED - // task state plus a negotiation message in metadata. The engine answers - // directly (no SDK state machine involved — the content layer is stateless - // and the engine owns the session context). - if (proposeText != null && !proposeText.isEmpty()) { - metadata.put(A2ATExtension.NEGOTIATION_MESSAGE_META_KEY, proposeText); - log.info( - "[Negotiation-T] Agent '{}' requested negotiation: {}", - getAgentName(agentCard), - proposeText); - // Validate the received negotiation message and extract params against - // the propose template matching the declared type. The context travels - // in the negotiationContext metadata key; without it the SDK treats - // the text as a non-negotiation message. - TemplateUri proposeUri = proposeTemplateFor(metadata); - net.openan.a2at.sdk.core.model.NegotiationContext context = - parseNegotiationContext(metadata); - if (context == null) { - throw new IllegalArgumentException( - "Missing or invalid Negotiation-T negotiationContext"); - } - try { - FilledParamData paramData = - a2atClient.validateProposePromptAndDataFilling( - proposeText, context, negotiationParamSchema(), proposeUri); - if (paramData != null - && paramData.data() != null - && !paramData.data().isEmpty()) { - metadata.put( - A2ATExtension.NEGOTIATION_PARAMS_META_KEY, paramData.data()); - log.info( - "[Negotiation-T] Validated negotiation message" - + " for '{}' ({}), extracted {} params", - getAgentName(agentCard), - proposeUri.uri(), - paramData.data().keySet()); - } - } catch (Exception ve) { - throw new IllegalStateException( - "Negotiation-T propose validation failed for '" - + getAgentName(agentCard) - + "' (" - + proposeUri.uri() - + ")", - ve); - } - } - result.setMetadata(metadata); - return result; - }); - } -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NotificationSubscription.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/NotificationSubscription.java index 191fdf9a..c8b61fb3 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 @@ -35,6 +35,7 @@ public final class NotificationSubscription implements AutoCloseable { private final AtomicBoolean closed = new AtomicBoolean(); private final AtomicLong eventCount = new AtomicLong(); private final AtomicReference lastEventAt = new AtomicReference<>(); + private volatile Throwable streamFailure; NotificationSubscription(String agentName, String contextId, Runnable closeAction) { this.agentName = Objects.requireNonNull(agentName, "agentName"); @@ -95,12 +96,11 @@ void failAcknowledgement(Throwable error) { void completeStream() { closed.set(true); - completion.complete(null); } void failStream(Throwable error) { closed.set(true); - completion.completeExceptionally(error); + streamFailure = error; } /** Internal signal that the thread executing the transport call has actually exited. */ @@ -110,6 +110,8 @@ CompletableFuture streamTermination() { void markStreamTerminated() { streamTermination.complete(null); + if (streamFailure == null) completion.complete(null); + else completion.completeExceptionally(streamFailure); } @Override @@ -123,7 +125,6 @@ public void close() { acknowledgement.completeExceptionally( new CancellationException( "Notification-T subscription closed before acknowledgement")); - completion.complete(null); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolResponses.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolResponses.java new file mode 100644 index 00000000..0e034a4c --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolResponses.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.client; + +import dev.openan.workflow.engine.model.MessageContent; +import dev.openan.workflow.engine.model.ReceivedMessage; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.spec.*; + +import java.util.*; + +/** Assembles response snapshots without flattening task/message/artifact metadata. */ +public final class ProtocolResponses { + private ProtocolResponses() {} + + /** Reduces ordered SDK events to final response snapshots. */ + public static List assemble(Iterable events) { + Accumulator accumulator = new Accumulator(); + events.forEach(accumulator::accept); + return accumulator.snapshots(); + } + + /** Per-stream accumulator, never shared across tasks or subscriptions. */ + static final class Accumulator { + private final Map artifacts = new LinkedHashMap<>(); + private final Map messages = new LinkedHashMap<>(); + private Map taskMetadata = Map.of(); + private MessageContent statusMessage; + private boolean hasTask; + + void accept(ClientEvent event) { + if (event instanceof MessageEvent message) { + Message value = message.getMessage(); + messages.put(value.messageId(), content(value)); + } else if (event instanceof TaskEvent task) { + snapshot(task.getTask(), true); + } else if (event instanceof TaskUpdateEvent update) { + snapshot(update.getTask(), false); + if (update.getUpdateEvent() instanceof TaskArtifactUpdateEvent artifact) { + append(artifact); + } else if (update.getUpdateEvent() instanceof TaskStatusUpdateEvent status) { + statusMessage = content(status.status().message()); + // SDK task snapshots already include the delta. Seed missing artifacts, + // but never append that snapshot or overwrite raw delta fields with it. + if (update.getTask().artifacts() != null) { + update.getTask().artifacts().forEach(a -> artifacts.putIfAbsent(a.artifactId(), a)); + } + } + } + } + + private void snapshot(Task task, boolean authoritative) { + if (task == null) return; + hasTask = true; + if (task.metadata() != null) taskMetadata = task.metadata(); + if (task.status() != null) statusMessage = content(task.status().message()); + if (authoritative && task.artifacts() != null) { + artifacts.clear(); + for (Artifact artifact : task.artifacts()) { + artifacts.put(artifact.artifactId(), artifact); + } + } + } + + private void append(TaskArtifactUpdateEvent event) { + Artifact value = event.artifact(); + Artifact previous = artifacts.get(value.artifactId()); + if (Boolean.TRUE.equals(event.append()) && previous != null) { + List> parts = new ArrayList<>(previous.parts()); + parts.addAll(value.parts()); + Map metadata = new LinkedHashMap<>(); + if (previous.metadata() != null) metadata.putAll(previous.metadata()); + if (value.metadata() != null) metadata.putAll(value.metadata()); + value = new Artifact(value.artifactId(), + value.name() == null ? previous.name() : value.name(), + value.description() == null ? previous.description() : value.description(), + parts, metadata, value.extensions() == null ? previous.extensions() : value.extensions()); + } + artifacts.put(value.artifactId(), value); + } + + List snapshots() { + List result = new ArrayList<>(); + messages.values().forEach(m -> result.add(new ReceivedMessage(m, Map.of(), List.of()))); + if (hasTask) result.add(new ReceivedMessage(statusMessage, taskMetadata, new ArrayList<>(artifacts.values()))); + return List.copyOf(result); + } + } + + static MessageContent content(Message message) { + return message == null ? null : new MessageContent( + message.parts(), message.metadata(), + message.extensions() == null ? Set.of() : new LinkedHashSet<>(message.extensions())); + } + + static boolean terminal(TaskState state) { + return state == TaskState.TASK_STATE_COMPLETED || state == TaskState.TASK_STATE_FAILED + || state == TaskState.TASK_STATE_CANCELED || state == TaskState.TASK_STATE_REJECTED; + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java deleted file mode 100644 index 168f0b32..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/TaskTHandler.java +++ /dev/null @@ -1,304 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.client; - -import dev.openan.workflow.engine.control.ControlPoint; -import dev.openan.workflow.engine.control.EventCallback; -import dev.openan.workflow.engine.model.SendMessageResult; - -import net.openan.a2at.sdk.client.A2ATClient; -import net.openan.a2at.sdk.client.model.PromptGenerationFailure; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; - -import org.a2aproject.sdk.spec.AgentCard; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -/** - * Task-T extension handler. - * - *

          Mirrors the Python SDK's {@code TaskTHandler}. When an AgentCard declares the Task-T - * extension, this handler calls the A2ATClient to generate a structured task prompt and injects it - * into the message metadata. - */ -class TaskTHandler implements ExtensionHandler { - - private static final Logger log = LoggerFactory.getLogger(TaskTHandler.class); - - private static String findTaskTUri(AgentCard agentCard) { - if (agentCard.capabilities() == null) { - return null; - } - var extensions = agentCard.capabilities().extensions(); - if (extensions == null) { - return null; - } - for (var ext : extensions) { - String uri = ext.uri(); - if (A2ATExtension.TASK_T.uri().equals(uri)) { - return uri; - } - } - return null; - } - - private static String getAgentName(AgentCard agentCard) { - return agentCard.name(); - } - - @Override - public String extensionKeyword() { - return "Task-T"; - } - - @Override - public CompletableFuture> beforeSend( - AgentCard agentCard, - String messageText, - Map metadata, - A2ATClient a2atClient, - ControlPoint controlPoint) { - if (a2atClient == null) { - Map cleaned = - metadata != null ? new HashMap<>(metadata) : new HashMap<>(); - cleaned.remove(A2ATExtension.TASK_TEMPLATE_META_KEY); - Object taskData = cleaned.remove(A2ATExtension.TASK_DATA_META_KEY); - Object taskSchema = cleaned.remove(A2ATExtension.TASK_SCHEMA_META_KEY); - if (taskData != null || taskSchema != null) { - return CompletableFuture.failedFuture( - new IllegalStateException( - "A2A-T client is required for structured Task-T rendering")); - } - log.warn( - "[Task-T] A2A-T client unavailable for '{}'; sending plain A2A without forged Task-T metadata", - getAgentName(agentCard)); - return CompletableFuture.completedFuture(cleaned); - } - // Skip Task-T prompt generation when this is a Negotiation-T follow-up: the metadata - // carries a RENDERED follow-up message under the Negotiation-T key. An EMPTY - // Negotiation-T value only activates the A2A-Extensions header (first task send) and - // must NOT skip generation. - if (metadata != null) { - Object negotiation = metadata.get(A2ATExtension.NEGOTIATION_T.uri()); - if (negotiation instanceof String s && !s.isEmpty()) { - log.info("[Task-T] Skipping prompt generation for negotiation follow-up"); - // Still strip engine-internal fromData keys: they must never reach the wire. - Map cleaned = new HashMap<>(metadata); - cleaned.remove(A2ATExtension.TASK_DATA_META_KEY); - cleaned.remove(A2ATExtension.TASK_SCHEMA_META_KEY); - cleaned.remove(A2ATExtension.TASK_TEMPLATE_META_KEY); - return CompletableFuture.completedFuture(cleaned); - } - } - String taskTUri = findTaskTUri(agentCard); - if (taskTUri == null) { - return CompletableFuture.completedFuture(metadata); - } - Map result = metadata != null ? new HashMap<>(metadata) : new HashMap<>(); - if (result.containsKey(taskTUri)) { - log.info("[Task-T] Metadata already preset, skipping generation"); - return CompletableFuture.completedFuture(result); - } - // Structured-data track: caller supplied taskData + taskSchema. The SDK bypasses scenario - // recognition but its schema-aware slot extractor can still invoke the configured LLM. - Object taskData = result.remove(A2ATExtension.TASK_DATA_META_KEY); - Object taskSchema = result.remove(A2ATExtension.TASK_SCHEMA_META_KEY); - Object taskTemplate = result.remove(A2ATExtension.TASK_TEMPLATE_META_KEY); - if (taskData != null || taskSchema != null) { - if (!(taskData instanceof Map data) - || !(taskSchema instanceof Map schema) - || schema.isEmpty()) { - return CompletableFuture.failedFuture( - new IllegalArgumentException( - "Structured Task-T requires non-null data and a non-empty schema")); - } - return CompletableFuture.supplyAsync( - () -> - renderFromData( - a2atClient, - agentCard, - taskTUri, - data, - schema, - taskTemplate instanceof String s ? s : null, - result)); - } - if (taskTemplate instanceof String templateUriStr && !templateUriStr.isBlank()) { - return CompletableFuture.supplyAsync( - () -> - renderFromText( - a2atClient, - agentCard, - taskTUri, - messageText, - templateUriStr, - result)); - } - // Free-text track: scenario recognition on the message text. - return CompletableFuture.supplyAsync( - () -> { - try { - PromptGenerationResult promptResult = - a2atClient.generateTaskPrompt(messageText); - if (promptResult != null && promptResult.success()) { - String promptText = promptResult.promptText(); - if (promptText != null && !promptText.isEmpty()) { - result.put(taskTUri, promptText); - log.info( - "[Task-T] Generated prompt for '{}': {} chars", - getAgentName(agentCard), - promptText.length()); - log.debug("[Task-T] Prompt content: [{}]", promptText); - return result; - } - throw new IllegalStateException("A2A-T SDK returned an empty Task-T prompt"); - } else { - PromptGenerationFailure f = promptResult != null ? promptResult.failure() : null; - throw new IllegalStateException( - "Task-T prompt generation failed for '" - + getAgentName(agentCard) - + "': code=" - + (f != null ? f.code() : "unknown") - + ", stage=" - + (f != null ? f.stage() : "unknown") - + ", message=" - + (f != null ? f.message() : "unknown")); - } - } catch (Exception e) { - throw e instanceof IllegalStateException state - ? state - : new IllegalStateException( - "Task-T prompt generation failed for '" - + getAgentName(agentCard) - + "'", - e); - } - }); - } - - /** SDK explicit fromText rendering for a caller-selected Task-T template. */ - private Map renderFromText( - A2ATClient a2atClient, - AgentCard agentCard, - String taskTUri, - String text, - String templateUriStr, - Map result) { - try { - net.openan.a2at.sdk.core.model.TemplateUri templateUri = - net.openan.a2at.sdk.core.model.TemplateUri.parse(templateUriStr) - .orElseThrow( - () -> - new IllegalArgumentException( - "Invalid Task-T template URI: " - + templateUriStr)); - if (!net.openan.a2at.sdk.core.model.StandardTemplates.TASK_EXTENSION_NAME.equals( - templateUri.extensionName())) { - throw new IllegalArgumentException( - "Natural-language task template is not Task-T: " + templateUri.uri()); - } - net.openan.a2at.sdk.core.model.MetadataContent content = - a2atClient.generateTaskPromptFromText(text, templateUri); - if (content == null || content.promptText() == null || content.promptText().isEmpty()) { - throw new IllegalStateException("A2A-T SDK returned empty Task-T content"); - } - result.putAll(content.buildMetadataContent()); - log.info( - "[Task-T] Rendered prompt from natural language for '{}': {} chars (template={})", - getAgentName(agentCard), - content.promptText().length(), - content.templateUri()); - return result; - } catch (Exception e) { - throw new IllegalStateException( - "Task-T fromText rendering failed for '" - + getAgentName(agentCard) - + "': " - + e.getMessage(), - e); - } - } - - /** SDK schema-aware fromData rendering. Failure is fatal; never send an unrendered task. */ - private Map renderFromData( - A2ATClient a2atClient, - AgentCard agentCard, - String taskTUri, - Map data, - Map schema, - String templateUriStr, - Map result) { - try { - @SuppressWarnings("unchecked") - Map typedData = (Map) data; - @SuppressWarnings("unchecked") - Map typedSchema = (Map) schema; - if (templateUriStr == null || templateUriStr.isBlank()) { - throw new IllegalArgumentException("Structured Task-T template URI is required"); - } - net.openan.a2at.sdk.core.model.TemplateUri templateUri = - net.openan.a2at.sdk.core.model.TemplateUri.parse(templateUriStr) - .orElseThrow( - () -> - new IllegalArgumentException( - "Invalid Task-T template URI: " - + templateUriStr)); - if (!net.openan.a2at.sdk.core.model.StandardTemplates.TASK_EXTENSION_NAME.equals( - templateUri.extensionName())) { - throw new IllegalArgumentException( - "Structured task template is not Task-T: " + templateUri.uri()); - } - net.openan.a2at.sdk.core.model.MetadataContent content = - a2atClient.generateTaskPromptFromDataWithSchema( - typedData, typedSchema, templateUri); - if (content != null && content.promptText() != null && !content.promptText().isEmpty()) { - result.putAll(content.buildMetadataContent()); - log.info( - "[Task-T] Rendered prompt from task data for '{}': {} chars (template={})", - getAgentName(agentCard), - content.promptText().length(), - content.templateUri()); - return result; - } - throw new IllegalStateException("A2A-T SDK returned empty Task-T content"); - } catch (Exception e) { - throw new IllegalStateException( - "Task-T fromData rendering failed for '" - + getAgentName(agentCard) - + "': " - + e.getMessage(), - e); - } - } - - @Override - public CompletableFuture afterReceive( - AgentCard agentCard, - SendMessageResult result, - A2ATClient a2atClient, - ControlPoint controlPoint, - EventCallback eventCallback) { - return CompletableFuture.completedFuture(result); - } -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClient.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClient.java index 6c0fd366..a600ace4 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClient.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClient.java @@ -21,198 +21,38 @@ import dev.openan.workflow.engine.control.ControlPoint; import dev.openan.workflow.engine.control.EventCallback; -import dev.openan.workflow.engine.control.TaskDispatcher; +import dev.openan.workflow.engine.model.MessageContent; import dev.openan.workflow.engine.model.SendMessageResult; -import dev.openan.workflow.engine.model.TaskSubmission; +import dev.openan.workflow.engine.model.TaskRequest; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; -/** - * Workflow-execution send facade over a shared {@link A2ATransport}. - * - *

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

          Authorization-T operations and Notification-T subscriptions live on {@link ExtensionSender}. - * Task, Authorization and Notification protocol channels have independent - * lifecycles, so production integrations should give those facades separate transport/runtime - * instances even when all three ultimately target the same OMC. - * - *

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

            - *
          • {@link #sendMessage} - streaming send used during workflow execution (invoked from {@link - * ControlPoint#onTask}). Runs through Task-T prompt generation, the Negotiation-T auto-loop, - * and the global {@link EventCallback}. - *
          - */ -public interface WorkflowEngineClient extends TaskDispatcher { - - /** - * Dispatches the strongly typed submission through the matching Task-T generation path. - * Business callbacks should depend on this narrow operation rather than selecting raw client - * overloads themselves. - */ - @Override - default CompletableFuture dispatch(TaskSubmission submission) { - java.util.Objects.requireNonNull(submission, "Task submission is required"); - if (submission.input() instanceof TaskSubmission.NaturalLanguage naturalLanguage) { - Map metadata = - new java.util.LinkedHashMap<>(submission.metadata()); - if (naturalLanguage.templateUri() != null) { - metadata.put( - A2ATExtension.TASK_TEMPLATE_META_KEY, - naturalLanguage.templateUri().uri()); - } - return sendMessage( - submission.agentName(), - submission.instruction(), - submission.contextId(), - metadata); - } - TaskSubmission.StructuredData structured = - (TaskSubmission.StructuredData) submission.input(); - Map metadata = new java.util.LinkedHashMap<>(submission.metadata()); - metadata.put(A2ATExtension.TASK_DATA_META_KEY, structured.data()); - metadata.put(A2ATExtension.TASK_SCHEMA_META_KEY, structured.schema()); - metadata.put(A2ATExtension.TASK_TEMPLATE_META_KEY, structured.templateUri().uri()); - return sendMessage( - submission.agentName(), - submission.instruction(), - submission.contextId(), - metadata); - } - - /** - * Send a message to an agent via SSE streaming. Used during workflow execution. The engine - * handles Task-T prompt generation, Negotiation-T auto-loop, auth, and extension header - * injection automatically. - * - * @param agentName target agent name (must match AgentCard.name) - * @param message full assembled message text - * @param contextId optional context ID (null = auto-generated) - * @param metadata optional preset metadata - * @return future completing with response text, task, metadata, task state - */ - CompletableFuture sendMessage( - String agentName, String message, String contextId, Map metadata); +/** Sends final content and coordinates task interaction. Content generation belongs to the host. */ +public interface WorkflowEngineClient { + /** Dispatches a prepared workflow activation; target and protocol association remain internal. */ + CompletableFuture dispatch( + TaskRequest request, MessageContent content, ControlPoint callbacks); - /** Convenience: no context ID, no preset metadata. */ - default CompletableFuture sendMessage(String agentName, String message) { - return sendMessage(agentName, message, null, null); - } + /** Sends final content outside the DAG using explicitly configured interaction callbacks. */ + CompletableFuture sendMessage(String agentName, MessageContent content); - /** - * Structured-data send: renders the Task-T prompt from typed data via the SDK's schema-aware - * fromData pipeline. Scenario recognition is bypassed, but slot mapping may invoke the - * configured LLM. The result then runs through the normal send path - * (Negotiation-T auto-loop, auth, extension headers). - * - *

          Callers holding structured business data should prefer this over {@link #sendMessage}: - * the SDK renders the prompt from the data and schema instead of the caller hand-writing the - * rendered prompt text. - * - * @param agentName target agent name (must match AgentCard.name) - * @param message short accompanying message text (parts text of the A2A message) - * @param data structured task input (string-to-object map) - * @param schema JSON schema describing the meaning of each data field - * @param templateUri template the data renders through (e.g. {@code - * StandardTemplates.PRIVATE_LINE_COMPLAINT}); must not be null - * @return future completing with response text, task, metadata, task state - */ - default CompletableFuture sendMessageFromData( - String agentName, - String message, - Map data, - Map schema, - net.openan.a2at.sdk.core.model.TemplateUri templateUri) { - return sendMessageFromData(agentName, message, data, schema, templateUri, null); - } - - /** - * Structured-data send with additional preset metadata (e.g. the empty Negotiation-T key to - * activate the extension alongside the task). - * - * @param extraMetadata additional preset metadata merged into the message; engine-internal - * keys (a2at.taskData/taskSchema/taskTemplate) are added on top - */ - default CompletableFuture sendMessageFromData( - String agentName, - String message, - Map data, - Map schema, - net.openan.a2at.sdk.core.model.TemplateUri templateUri, - Map extraMetadata) { - java.util.Objects.requireNonNull(data, "Task-T structured data is required"); - java.util.Objects.requireNonNull(schema, "Task-T data schema is required"); - java.util.Objects.requireNonNull(templateUri, "Task-T template URI is required"); - if (schema.isEmpty()) { - throw new IllegalArgumentException("Task-T data schema must not be empty"); - } - if (!net.openan.a2at.sdk.core.model.StandardTemplates.TASK_EXTENSION_NAME.equals( - templateUri.extensionName())) { - throw new IllegalArgumentException( - "Structured task template is not Task-T: " + templateUri.uri()); - } - Map metadata = - extraMetadata != null - ? new java.util.LinkedHashMap<>(extraMetadata) - : new java.util.LinkedHashMap<>(); - metadata.put(A2ATExtension.TASK_DATA_META_KEY, data); - metadata.put(A2ATExtension.TASK_SCHEMA_META_KEY, schema); - metadata.put(A2ATExtension.TASK_TEMPLATE_META_KEY, templateUri.uri()); - return sendMessage(agentName, message, null, metadata); - } + /** Maximum wait for each business callback, including initial content preparation. */ + default long callbackTimeoutSeconds() { return 600; } void setControlPoint(ControlPoint controlPoint); - void setEventCallback(EventCallback callback); - /** - * Lists every A2A-T prompt template of the configured language across all extensions - * (Task-T / Notification-T / Authorization-T / Negotiation-T). Never throws. - * - * @return template list sorted by URI; empty when the A2A-T SDK is not configured - */ - default java.util.List getPrompts() { - return java.util.List.of(); - } - - /** - * Lists every negotiation template of the configured language (propose / accept-reject for - * information, target, feasibility, plus the common abort template). Never throws. - * - * @return negotiation template list; empty when the A2A-T SDK is not configured - */ - default java.util.List getNegotiationPrompts() { - return java.util.List.of(); - } - - /** - * Loads one template by URI regardless of extension. Never throws. - * - * @param templateUri template URI such as {@code StandardTemplates.PRIVATE_LINE_COMPLAINT} - * @return the addressed template, or empty when missing / SDK not configured - */ - default java.util.Optional getPrompt( - net.openan.a2at.sdk.core.model.TemplateUri templateUri) { - return java.util.Optional.empty(); - } - - - /** Query a task by ID (A2A GET tasks/{id}). */ + /** Queries an existing remote task. */ CompletableFuture getTask(String agentName, String taskId); - /** Cancel a task by ID (A2A POST tasks/{id}:cancel). */ + /** Cancels an existing remote task; this is not a Negotiation-T Abort. */ CompletableFuture cancelTask(String agentName, String taskId); - /** Subscribe to a task stream (A2A POST tasks/{id}:subscribe). */ + /** Subscribes to an existing task, preserving its identity. */ CompletableFuture subscribeToTask( - String agentName, String taskId, - java.util.function.Consumer> eventCallback); + String agentName, String taskId, Consumer> eventCallback); void close(); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java index 8b969df6..ad84a81f 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java @@ -49,12 +49,10 @@ public class WorkflowEngineClientConfig { private final int sendExecutorQueueCapacity; private final AuthProvider authProvider; private final String credentialsConfigPath; + private final String credentialEncryptionKey; private final Map>> credentialsConfig; - private final String a2atEnvPath; - private final int maxNegotiationRounds; - private final List customHandlers; + private final int maxNegotiationExchanges; private final String preferredProtocol; - private final Map negotiationParamSchema; private WorkflowEngineClientConfig(Builder b) { this.sslVerify = b.sslVerify; @@ -70,16 +68,11 @@ private WorkflowEngineClientConfig(Builder b) { 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.a2atEnvPath = b.a2atEnvPath; - this.maxNegotiationRounds = b.maxNegotiationRounds; - this.customHandlers = b.customHandlers != null ? List.copyOf(b.customHandlers) : null; + this.maxNegotiationExchanges = b.maxNegotiationExchanges; this.preferredProtocol = b.preferredProtocol; - this.negotiationParamSchema = - b.negotiationParamSchema != null - ? immutableObjectMap(b.negotiationParamSchema) - : null; } private static Map>> copyCredentials( @@ -140,12 +133,10 @@ public static class Builder { private int sendExecutorQueueCapacity = 256; private AuthProvider authProvider; private String credentialsConfigPath = null; + private String credentialEncryptionKey; private Map>> credentialsConfig = null; - private String a2atEnvPath = null; - private int maxNegotiationRounds = 3; - private List customHandlers = null; + private int maxNegotiationExchanges = 3; private String preferredProtocol = null; - private Map negotiationParamSchema = null; public Builder sslVerify(boolean v) { this.sslVerify = v; @@ -207,6 +198,12 @@ public Builder sendExecutorQueueCapacity(int v) { return this; } + /** Optional credential decryption key supplied by the host, not loaded from LLM config. */ + public Builder credentialEncryptionKey(String key) { + this.credentialEncryptionKey = key; + return this; + } + public Builder credentialsConfigPath(String v) { this.credentialsConfigPath = v; return this; @@ -217,13 +214,8 @@ public Builder credentialsConfig(Map>> v return this; } - public Builder a2atEnvPath(String v) { - this.a2atEnvPath = v; - return this; - } - - public Builder maxNegotiationRounds(int v) { - this.maxNegotiationRounds = v; + public Builder maxNegotiationExchanges(int v) { + this.maxNegotiationExchanges = v; return this; } @@ -232,21 +224,6 @@ public Builder preferredProtocol(String v) { return this; } - public Builder customHandlers(List v) { - this.customHandlers = v; - return this; - } - - /** - * Business parameter JSON schema for the Negotiation-T validate-and-fill pipeline. Declares - * the caller's domain fields so the engine core stays domain-agnostic; null keeps the - * default empty schema (negotiation context params only). - */ - public Builder negotiationParamSchema(Map v) { - this.negotiationParamSchema = v; - return this; - } - public WorkflowEngineClientConfig build() { if (sendTimeoutSeconds <= 0 || notificationAckTimeoutSeconds <= 0) { throw new IllegalArgumentException("Timeouts must be positive"); @@ -256,8 +233,8 @@ public WorkflowEngineClientConfig build() { || sendExecutorQueueCapacity <= 0) { throw new IllegalArgumentException("Invalid send executor configuration"); } - if (maxNegotiationRounds <= 0) { - throw new IllegalArgumentException("maxNegotiationRounds must be positive"); + 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 31833ba2..b6401c1d 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/ControlPoint.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/ControlPoint.java @@ -19,69 +19,93 @@ package dev.openan.workflow.engine.control; -import dev.openan.workflow.engine.core.WorkflowExecutor; -import dev.openan.workflow.engine.model.JumpCondition; -import dev.openan.workflow.engine.model.RouteDecision; -import dev.openan.workflow.engine.model.TaskRequest; -import dev.openan.workflow.engine.model.TaskResponse; -import dev.openan.workflow.engine.model.NegotiationDecision; -import dev.openan.workflow.engine.model.NegotiationRequest; - -import java.util.List; -import java.util.Map; +import dev.openan.workflow.engine.model.*; import java.util.concurrent.CompletableFuture; +import java.util.function.Function; -/** - * Workflow-control decision interface. - * - *

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

          The SDK handles Task-T prompt generation, the Negotiation-T auto-loop (calling {@link - * #onNegotiation} on INPUT_REQUIRED), auth, and extension header injection. Submit through the - * dispatcher. Business code chooses natural-language or structured input by creating a {@link - * dev.openan.workflow.engine.model.TaskSubmission}; it does not manipulate protocol metadata. - */ - CompletableFuture onTask( - TaskRequest request, TaskDispatcher taskDispatcher); - - /** - * Handle a self-loop task locally. Called when a workflow step is marked SELF_LOOP: the agent - * executing the workflow processes the task itself, WITHOUT sending an A2A-T message to itself. - * Only steps that dispatch to OTHER agents go through {@link #onTask} and the A2A-T protocol. - * - *

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

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

          Negotiation-T auto-loop delegates to an injected {@link NegotiationStrategy} (or returns a - * generic Accept decision if none is provided). Override onNegotiation directly when a full strategy - * object is unnecessary. - */ +/** Explicit business callbacks, with no implicit local success, branch selection or consent. */ public class DefaultControlPoint implements ControlPoint { - private static final Logger log = LoggerFactory.getLogger(DefaultControlPoint.class); - - private final NegotiationStrategy negotiationStrategy; + private final NegotiationStrategy strategy; public DefaultControlPoint() { - this.negotiationStrategy = null; + this(null); } - public DefaultControlPoint(NegotiationStrategy negotiationStrategy) { - this.negotiationStrategy = negotiationStrategy; - } - - @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher taskDispatcher) { - log.info( - "[DefaultCP] onTask: agent={}, step={}", - request.getAgentName(), - request.getStepName()); - return taskDispatcher - .dispatch( - TaskSubmission.fromUnclassifiedText( - request.getAgentName(), request.getMessage())) - .thenApply( - r -> { - boolean success = isSuccessful(r); - log.info( - "[DefaultCP] Response from {}: {} chars, state={}, success={}", - request.getAgentName(), - r.getText() != null ? r.getText().length() : 0, - r.getTaskState(), - success); - return TaskResponse.builder() - .success(success) - .output(r.getText()) - .build(); - }) - .exceptionally( - e -> { - log.error( - "[DefaultCP] Task failed for {}: {}", - request.getAgentName(), - e.getMessage()); - return TaskResponse.builder() - .success(false) - .error("Agent call failed: " + e.getMessage()) - .build(); - }); - } - - private static boolean isSuccessful( - dev.openan.workflow.engine.model.SendMessageResult result) { - String state = result.getTaskState(); - if (state == null || state.isBlank() || state.endsWith("UNSPECIFIED")) { - return result.getText() != null && !result.getText().isBlank(); - } - return state.endsWith("COMPLETED"); - } - - @Override - public CompletableFuture onSelfTask(TaskRequest request) { - log.info( - "[DefaultCP] onSelfTask: step={}, agent={} (local, no A2A-T)", - request.getStepName(), - request.getAgentName()); - return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output(request.getMessage()).build()); - } - - @Override - public CompletableFuture onRoute( - String stepName, Map results, List conditions) { - String nextStep = conditions.get(0).getStep(); - for (JumpCondition jc : conditions) { - String step = jc.getStep(); - if (!"end".equals(step) && !"retry".equals(step) && !"endNode".equals(step)) { - nextStep = step; - break; - } - } - log.info("[DefaultCP] onRoute: {} -> {}", stepName, nextStep); - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(nextStep) - .reason("default: first non-terminal branch") - .build()); + public DefaultControlPoint(NegotiationStrategy strategy) { + this.strategy = strategy; } @Override - public CompletableFuture onNegotiation(NegotiationRequest request) { - if (negotiationStrategy != null) { - return negotiationStrategy.resolve(request); - } - log.info( - "[DefaultCP] onNegotiation: agent={}, concern={}", - request.agentName(), - request.concern()); - return CompletableFuture.completedFuture( - NegotiationDecision.acceptText( - "Please proceed with the original task using available information.")); + public CompletableFuture onNegotiation(NegotiationRequest request) { + return strategy == null + ? ControlPoint.super.onNegotiation(request) + : strategy.resolve(request); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/NegotiationStrategy.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/NegotiationStrategy.java index 78429919..1c1e48c9 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/NegotiationStrategy.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/NegotiationStrategy.java @@ -19,7 +19,7 @@ package dev.openan.workflow.engine.control; -import dev.openan.workflow.engine.model.NegotiationDecision; +import dev.openan.workflow.engine.model.NegotiationReply; import dev.openan.workflow.engine.model.NegotiationRequest; import java.util.concurrent.CompletableFuture; @@ -43,7 +43,7 @@ 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 an Accept, Reject, or Abort decision + * @return future completing with final content to send, or a local stop */ - CompletableFuture resolve(NegotiationRequest request); + CompletableFuture resolve(NegotiationRequest request); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ContextBuilder.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ContextBuilder.java index 3987b4cc..2b6c6396 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ContextBuilder.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ContextBuilder.java @@ -21,6 +21,9 @@ import dev.openan.workflow.engine.model.Workflow; import dev.openan.workflow.engine.model.WorkflowStep; +import dev.openan.workflow.engine.model.TaskExecutionResult; +import dev.openan.workflow.engine.model.UpstreamStepResult; +import dev.openan.workflow.engine.model.WorkflowInput; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,7 +37,7 @@ import java.util.Map; import java.util.Set; -/** Context assembly from upstream step outputs. Mirrors Python ContextBuilder. */ +/** Selects typed upstream execution results according to the workflow graph. */ class ContextBuilder { private static final Logger log = LoggerFactory.getLogger(ContextBuilder.class); private final Workflow workflow; @@ -88,63 +91,38 @@ public List getAllPredecessors(String stepName) { return new ArrayList<>(ancestors); } - public String buildContext(WorkflowStep step, Map> stepOutputs) { - if (step.getLayer() <= 0) { - log.info("[Context] Step {}: layer 0, using runtime intent only", step.getName()); - return runtimeIntent.isEmpty() ? "" : "## Runtime Context\n\n" + runtimeIntent; - } - List parts = new ArrayList<>(); - if (!runtimeIntent.isEmpty()) { - parts.add("## Runtime Context\n\n" + runtimeIntent); - } - parts.add("## Previous Step Execution Results\n"); - List>> refPairs = new ArrayList<>(); + public WorkflowInput buildWorkflowInput(WorkflowStep step, Map> stepResults) { + List selectedSteps = new ArrayList<>(); if (step.getContextFrom() != null && step.getContextFrom().contains("*")) { for (String name : getAllPredecessors(step.getName())) { - if (stepOutputs.containsKey(name)) { - refPairs.add(Map.entry(name, stepOutputs.get(name))); + if (stepResults.containsKey(name)) { + selectedSteps.add(name); } } } else if (step.getContextFrom() != null && !step.getContextFrom().isEmpty()) { for (String name : step.getContextFrom()) { - if (stepOutputs.containsKey(name)) { - refPairs.add(Map.entry(name, stepOutputs.get(name))); + if (stepResults.containsKey(name)) { + selectedSteps.add(name); } } - } else { + } else if (step.getContextFrom() == null) { for (String name : getStepPredecessors(step.getName())) { - if (stepOutputs.containsKey(name)) { - refPairs.add(Map.entry(name, stepOutputs.get(name))); + if (stepResults.containsKey(name)) { + selectedSteps.add(name); } } } - for (var entry : refPairs) { - parts.add("### " + entry.getKey() + " Results\n"); - for (var taskEntry : entry.getValue().entrySet()) { - String text = - taskEntry.getValue() instanceof String - ? (String) taskEntry.getValue() - : String.valueOf(taskEntry.getValue()); - parts.add("**Task**: " + taskEntry.getKey() + "\n**Output**: " + text + "\n\n"); - } - } - String result = String.join("\n", parts).trim(); - log.info("[Context] Step {}: built context ({} chars)", step.getName(), result.length()); - return result; - } - - public String buildTaskMessage(String taskDescription, String contextMessage, String lang) { - String langHint = ""; - if ("en".equals(lang)) { - langHint = "\n\nPlease respond in English."; - } - if ("zh".equals(lang)) { - langHint = "\n\n请用中文回复。"; - } - if (contextMessage != null && !contextMessage.isEmpty()) { - return contextMessage + "\n\n## Current Task\n" + taskDescription + langHint; + List upstreamResults = new ArrayList<>(); + for (String name : selectedSteps) { + upstreamResults.add(new UpstreamStepResult(name, stepResults.get(name))); } - return taskDescription + langHint; + int resultCount = + upstreamResults.stream().mapToInt(result -> result.taskResults().size()).sum(); + log.info( + "[Context] Step {}: selected {} upstream step(s), {} task result(s)", step.getName(), + upstreamResults.size(), + resultCount); + return new WorkflowInput(runtimeIntent, upstreamResults); } public Integer findStepIndex(String stepName) { diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/FailureMapping.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/FailureMapping.java new file mode 100644 index 00000000..212c35a7 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/FailureMapping.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.core; + +import dev.openan.workflow.engine.model.BusinessFailure; +import dev.openan.workflow.engine.model.TaskResult; +import java.util.*; + +/** Maps only generic failures or explicitly safe host-selected business facts. */ +final class FailureMapping { + static TaskResult from(Throwable error) { + Throwable root = error; + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + while (root.getCause() != null && seen.add(root) && !(root instanceof BusinessFailure)) { + root = root.getCause(); + } + if (root instanceof BusinessFailure business) { + return TaskResult.builder().success(false).errorCode(business.code()) + .error(business.getMessage()).errorDetails(business.details()).build(); + } + String code = root instanceof java.util.concurrent.TimeoutException ? "workflow.timeout" + : root instanceof java.util.concurrent.CancellationException ? "workflow.cancelled" + : "workflow.execution_failed"; + // Raw SDK/provider exceptions may contain credentials; the host chooses safe business facts. + return TaskResult.failure(code, root.getClass().getSimpleName()); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ProtocolResultAdapter.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ProtocolResultAdapter.java new file mode 100644 index 00000000..0408883b --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/ProtocolResultAdapter.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.core; + +import dev.openan.workflow.engine.model.SendMessageResult; +import dev.openan.workflow.engine.model.TaskResult; + +/** Maps transport state independently from the presence or absence of business content. */ +final class ProtocolResultAdapter { + private ProtocolResultAdapter() {} + + static TaskResult toTaskResult(SendMessageResult result) { + if (result == null) return TaskResult.failure("remote.no_response", "Agent returned no response"); + String state = result.getTaskState(); + boolean standaloneMessage = result.getTask() == null && !result.getReceivedMessages().isEmpty(); + boolean success = "TASK_STATE_COMPLETED".equals(state) + || ((state == null || state.isBlank() || state.endsWith("UNSPECIFIED")) && standaloneMessage); + if (result.getFailureCode() != null) success = false; + return TaskResult.builder().success(success) + .receivedMessages(result.getReceivedMessages()) + .error(success ? null : result.getFailureCode() == null + ? "Agent returned state=" + state : result.getFailureMessage()) + .errorCode(success ? null : result.getFailureCode() == null ? "remote.task_failed" : result.getFailureCode()) + .build(); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java index 77e129ab..41a5ab58 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java @@ -28,10 +28,12 @@ import dev.openan.workflow.engine.model.StepType; import dev.openan.workflow.engine.model.Task; import dev.openan.workflow.engine.model.TaskRequest; -import dev.openan.workflow.engine.model.TaskResponse; import dev.openan.workflow.engine.model.TaskStatus; import dev.openan.workflow.engine.model.Workflow; import dev.openan.workflow.engine.model.WorkflowStep; +import dev.openan.workflow.engine.model.TaskExecutionResult; +import dev.openan.workflow.engine.model.TaskResult; +import dev.openan.workflow.engine.model.WorkflowInput; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,12 +60,17 @@ public class WorkflowExecutor { private static final Logger log = LoggerFactory.getLogger(WorkflowExecutor.class); private final Workflow workflow; + private final AtomicBoolean started = new AtomicBoolean(); + private final AtomicBoolean stopped = new AtomicBoolean(); + private final Set> activeTasks = ConcurrentHashMap.newKeySet(); private final ControlPoint controlPoint; private final WorkflowEngineClient engineClient; private final EventCallback eventCallback; private final ContextBuilder contextBuilder; private final String lang; + private final String executionId = java.util.UUID.randomUUID().toString(); private final Map> stepOutputs = new ConcurrentHashMap<>(); + private final Map> stepExecutionResults = new ConcurrentHashMap<>(); private final List> executionHistory = Collections.synchronizedList(new ArrayList<>()); @@ -74,11 +81,13 @@ public WorkflowExecutor( EventCallback eventCallback, String runtimeIntent, String lang) { - this.workflow = workflow; + this.workflow = + new com.fasterxml.jackson.databind.ObjectMapper() + .convertValue(workflow, Workflow.class); this.controlPoint = controlPoint; this.engineClient = engineClient; this.eventCallback = eventCallback != null ? eventCallback : new EventCallback(); - this.contextBuilder = new ContextBuilder(workflow, runtimeIntent); + this.contextBuilder = new ContextBuilder(this.workflow, runtimeIntent); this.lang = lang != null ? lang : "zh"; try { this.engineClient.setControlPoint(this.controlPoint); @@ -123,6 +132,9 @@ private void emit(String type, Map data) { } public CompletableFuture run() { + if (!started.compareAndSet(false, true)) + return CompletableFuture.failedFuture( + new IllegalStateException("WorkflowExecutor is single-use")); // NOTE: START lifecycle event is emitted by the runner (ExecutePsop), // not here. Mirrors Python SDK where the executor emits only // step/task/route events and the runner emits start/complete/error/close. @@ -156,7 +168,7 @@ public CompletableFuture run() { } Set executed = ConcurrentHashMap.newKeySet(); AtomicBoolean failed = new AtomicBoolean(); - return executeSteps(pending, scheduled, executed, activated, failed) + CompletableFuture execution = executeSteps(pending, scheduled, executed, activated, failed) .thenApply( v -> { emit(EventType.WORKFLOW_COMPLETE, Map.of()); @@ -182,6 +194,11 @@ public CompletableFuture run() { .error(e.getMessage()) .build(); }); + execution.whenComplete((value, error) -> { + stopped.set(true); + activeTasks.forEach(task -> task.cancel(true)); + }); + return execution; } private CompletableFuture executeSteps( @@ -190,7 +207,7 @@ private CompletableFuture executeSteps( Set executed, Set activated, AtomicBoolean failed) { - if (pending.isEmpty() || failed.get()) { + if (pending.isEmpty() || failed.get() || stopped.get()) { return CompletableFuture.completedFuture(null); } @@ -281,6 +298,7 @@ private CompletableFuture executeStep( .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( @@ -336,6 +354,35 @@ private void validateWorkflowGraph() { graph.get(i).add(target); } } + for (WorkflowStep step : workflow.getSteps()) { + List contextFrom = step.getContextFrom(); + if (contextFrom == null || contextFrom.isEmpty()) continue; + if (contextFrom.contains("*") && contextFrom.size() != 1) { + throw new IllegalArgumentException( + "Step " + + step.getName() + + " cannot combine '*' with named context sources"); + } + if (contextFrom.contains("*")) continue; + Set ancestors = Set.copyOf(contextBuilder.getAllPredecessors(step.getName())); + for (String source : contextFrom) { + if (!indices.containsKey(source)) { + throw new IllegalArgumentException( + "Step " + + step.getName() + + " references missing context source " + + source); + } + if (!ancestors.contains(source)) { + throw new IllegalArgumentException( + "Step " + + step.getName() + + " context source " + + source + + " is not an upstream dependency"); + } + } + } int[] state = new int[workflow.getSteps().size()]; for (int i = 0; i < state.length; i++) detectCycle(i, graph, state); } @@ -362,17 +409,18 @@ private record StepResult( String taskDesc, String agentName, int subtaskIndex, - String output, + List outputs, boolean success, - Map results) {} + Map results, + List taskResults) {} private CompletableFuture executeSubtasks(WorkflowStep step) { - String contextMessage = contextBuilder.buildContext(step, stepOutputs); + var workflowInput = contextBuilder.buildWorkflowInput(step, stepExecutionResults); List> futures = new ArrayList<>(); for (int i = 0; i < step.getSubtasks().size(); i++) { final int subtaskIndex = i; final var task = step.getSubtasks().get(i); - var request = buildTaskRequest(step, task, subtaskIndex, contextMessage); + var request = buildTaskRequest(step, task, subtaskIndex, workflowInput); emit( EventType.TASK_REQUEST, Map.of( @@ -382,10 +430,14 @@ private CompletableFuture executeSubtasks(WorkflowStep step) { task.getAgent(), "task", task.getDescription())); - futures.add( - dispatchTask(step, request) - .thenApply(r -> processTaskResponse(step, task, subtaskIndex, r)) - .exceptionally(e -> processTaskError(step, task, subtaskIndex, e))); + CompletableFuture dispatch = dispatchTask(step, request); + CompletableFuture processed = dispatch + .thenApply(r -> processTaskResult(step, task, subtaskIndex, r)) + .exceptionally(e -> processTaskError(step, task, subtaskIndex, e)); + processed.whenComplete((result, error) -> { + if (processed.isCancelled()) dispatch.cancel(true); + }); + futures.add(processed); } if (step.getStepType() == StepType.ANY_SUCCESS) { return anySuccess(futures); @@ -394,37 +446,78 @@ private CompletableFuture executeSubtasks(WorkflowStep step) { .thenApply(v -> collectAllSuccess(futures)); } + private String taskId(String stepName, int index) { + return java.util + .UUID + .nameUUIDFromBytes( + (executionId + ":" + stepName + ":" + index) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)) + .toString(); + } + private TaskRequest buildTaskRequest( - WorkflowStep step, Task task, int subtaskIndex, String contextMessage) { - String taskMessage = - contextBuilder.buildTaskMessage(task.getDescription(), contextMessage, lang); + WorkflowStep step, Task task, int subtaskIndex, WorkflowInput workflowInput) { log.info( "[Executor] Dispatching task: step={}, agent={}, subtask_index={}, desc={}", step.getName(), task.getAgent(), subtaskIndex, task.getDescription()); - log.debug("[Executor] Task message to {}: [{}]", task.getAgent(), taskMessage); return TaskRequest.builder() .agentName(task.getAgent()) .skill(task.getSkill()) - .message(taskMessage) - .description(task.getDescription()) - .context(contextMessage) + .instruction(task.getDescription()) + .language(lang) .stepName(step.getName()) - .subtaskIndex(subtaskIndex) + .executionId(executionId) + .taskId(taskId(step.getName(), subtaskIndex)) + .input( + task.getInput() == null + ? dev.openan.workflow.engine.model.BusinessInput.text( + task.getDescription()) + : task.getInput()) + .workflowInput(workflowInput) .build(); } - private CompletableFuture dispatchTask(WorkflowStep step, TaskRequest request) { - if (step.getStepType() == StepType.SELF_LOOP) { - log.info( - "[Executor] Self-loop task: step={}, agent={} (local, no A2A-T)", - step.getName(), - request.getAgentName()); - return controlPoint.onSelfTask(request); - } - return controlPoint.onTask(request, engineClient); + private CompletableFuture dispatchTask(WorkflowStep step, TaskRequest request) { + CompletableFuture result = new CompletableFuture<>(); + activeTasks.add(result); + result.orTimeout(engineClient.callbackTimeoutSeconds(), java.util.concurrent.TimeUnit.SECONDS); + result.whenComplete((value, error) -> activeTasks.remove(result)); + CompletableFuture.runAsync(() -> { + try { + if (result.isDone() || stopped.get()) return; + if (step.getStepType() == StepType.SELF_LOOP) { + completeFrom(result, java.util.Objects.requireNonNull(controlPoint.onSelfTask(request), + "onSelfTask returned null future")); + return; + } + var prepared = java.util.Objects.requireNonNull(controlPoint.onTask(request), "onTask returned null future"); + prepared.whenComplete((content, error) -> { + if (result.isDone() || stopped.get()) { + log.debug("[Executor] Late callback ignored step={}, taskId={}", request.getStepName(), request.getTaskId()); + return; + } + if (error != null) { result.completeExceptionally(error); return; } + try { + var sent = engineClient.dispatch(request, + java.util.Objects.requireNonNull(content, "onTask returned null content"), controlPoint); + result.whenComplete((value, failure) -> { if (!sent.isDone()) sent.cancel(true); }); + completeFrom(result, sent.thenApply(ProtocolResultAdapter::toTaskResult)); + } catch (RuntimeException failure) { result.completeExceptionally(failure); } + }); + } catch (RuntimeException error) { result.completeExceptionally(error); } + }); + return result; + } + + private static void completeFrom(CompletableFuture destination, CompletableFuture source) { + source.whenComplete((value, error) -> { + if (error != null) destination.completeExceptionally(error); + else if (value == null) destination.completeExceptionally(new IllegalStateException("Callback returned null")); + else destination.complete(value); + }); } private StepResult collectAllSuccess(List> futures) { @@ -438,7 +531,14 @@ private StepResult collectAllSuccess(List> futures } } return new StepResult( - null, null, -1, null, !anyFailed, collectTaskResults(completedResults)); + null, null, -1, + List.of(), !anyFailed, collectTaskResults(completedResults), + collectExecutionResults(completedResults)); + } + + private static List collectExecutionResults( + List completedResults) { + return completedResults.stream().flatMap(result -> result.taskResults().stream()).toList(); } /** Keeps every subtask result even when descriptions repeat within one parallel step. */ @@ -464,13 +564,13 @@ private static Map collectTaskResults(List completed + result.subtaskIndex() + "]" : result.taskDesc(); - results.put(key, result.output()); + results.put(key, result.outputs()); } return results; } - private StepResult processTaskResponse( - WorkflowStep step, Task task, int subtaskIndex, TaskResponse response) { + private StepResult processTaskResult( + WorkflowStep step, Task task, int subtaskIndex, TaskResult response) { task.setStatus(response.isSuccess() ? TaskStatus.SUCCESS : TaskStatus.FAILED); emit( EventType.TASK_STATUS_CHANGED, @@ -485,14 +585,12 @@ private StepResult processTaskResponse( task.getStatus().getValue())); String status = response.isSuccess() ? "success" : "failed"; log.info("[Executor] Task {} -> {}: {}", task.getDescription(), task.getAgent(), status); - if (response.isSuccess() && response.getOutput() != null) { + if (response.isSuccess() && !response.getOutputs().isEmpty()) { log.debug( - "[Executor] Task output from {}: [{}]", task.getAgent(), response.getOutput()); + "[Executor] Task outputs from {}: [{}]", task.getAgent(), response.getOutputs()); } - String output = - response.isSuccess() - ? (response.getOutput() != null ? response.getOutput() : "") - : (response.getError() != null ? response.getError() : ""); + List outputs = + response.getOutputs(); executionHistory.add( Map.of( "step", @@ -505,8 +603,14 @@ private StepResult processTaskResponse( task.getAgent(), "status", status, - "output", - output)); + "outputs", + outputs, + "error", + response.getError() == null ? "" : response.getError(), + "errorCode", + response.getErrorCode() == null ? "" : response.getErrorCode(), + "errorDetails", + response.getErrorDetails())); emit( EventType.TASK_RESPONSE, Map.of( @@ -518,61 +622,41 @@ private StepResult processTaskResponse( task.getAgent(), "task", task.getDescription(), - "output", - output)); + "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, - output, + outputs, response.isSuccess(), - null); + null, + List.of( + new TaskExecutionResult( + task.getAgent(), + task.getSkill(), + taskId(step.getName(), subtaskIndex), + task.getDescription(), + task.getStatus(), + outputs, + response.getReceivedMessages(), + response.getError(), + response.getErrorCode(), + response.getErrorDetails()))); } private StepResult processTaskError( WorkflowStep step, Task task, int subtaskIndex, Throwable e) { - task.setStatus(TaskStatus.FAILED); - emit( - EventType.TASK_STATUS_CHANGED, - Map.of( - "step", - step.getName(), - "subtask_index", - subtaskIndex, - "agent", - task.getAgent(), - "status", - TaskStatus.FAILED.getValue())); - String errorMessage = - e.getMessage() != null && !e.getMessage().isBlank() - ? e.getMessage() - : e.getClass().getSimpleName(); - log.error( - "[Executor] Task {} -> {}: exception: {}", - task.getDescription(), - task.getAgent(), - errorMessage); - executionHistory.add( - Map.of( - "step", - step.getName(), - "subtask_index", - subtaskIndex, - "task", - task.getDescription(), - "agent", - task.getAgent(), - "status", - "failed", - "output", - errorMessage)); - return new StepResult( - task.getDescription(), - task.getAgent(), - subtaskIndex, - errorMessage, - false, - null); + return processTaskResult(step, task, + subtaskIndex, FailureMapping.from(e)); } /** @@ -583,12 +667,13 @@ private StepResult processTaskError( private CompletableFuture anySuccess(List> futures) { if (futures.isEmpty()) { return CompletableFuture.completedFuture( - new StepResult(null, null, -1, null, true, Map.of())); + new StepResult(null, null, -1, List.of(), true, Map.of(), List.of())); } CompletableFuture result = new CompletableFuture<>(); int total = futures.size(); int[] completed = {0}; int[] failedCount = {0}; + boolean[] winnerChosen = {false}; for (CompletableFuture f : futures) { f.handle( @@ -597,6 +682,7 @@ private CompletableFuture anySuccess(List other : futures) { if (!other.isDone()) { @@ -622,10 +708,11 @@ private CompletableFuture anySuccess(List anySuccess(List> determineNextSteps( } return CompletableFuture.completedFuture(indices); } - // Build context for onRoute: upstream context_from results + current step results - Map routeContext = new HashMap<>(); - if (step.getContextFrom() != null) { - for (String ref : step.getContextFrom()) { - Map out = stepOutputs.get(ref); - if (out != null) { - routeContext.put(ref, out); - } - } - } - routeContext.put(step.getName(), stepResult); + var routeRequest = new dev.openan.workflow.engine.model.RouteRequest( + executionId, + step.getName(), + contextBuilder.buildWorkflowInput(step, stepExecutionResults), + stepExecutionResults.getOrDefault(step.getName(), List.of()), + step.getNext().stream() + .map( + option -> + new dev.openan.workflow.engine.model.RouteRequest + .RouteOption( + option.getStep(), option.getCondition())) + .toList()); return controlPoint - .onRoute(step.getName(), routeContext, step.getNext()) + .onRoute(routeRequest) + .orTimeout(engineClient.callbackTimeoutSeconds(), java.util.concurrent.TimeUnit.SECONDS) .thenApply( decision -> { log.info( diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessFailure.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessFailure.java new file mode 100644 index 00000000..76b3995b --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessFailure.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.Map; + +/** Host-supplied safe business failure. Do not include credentials or raw provider errors. */ +public final class BusinessFailure extends RuntimeException { + private final String code; + private final Map details; + + /** Constructs a failure with an application code and explicitly safe diagnostic fields. */ + public BusinessFailure(String code, String message, Map details) { + super(message); + if (code == null || code.isBlank()) throw new IllegalArgumentException("Failure code required"); + this.code = code; + this.details = details == null ? Map.of() : BusinessValues.map(details); + } + + /** Machine-readable business failure code. */ + public String code() { return code; } + + /** Safe business facts selected by the host. */ + public Map details() { return details; } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessInput.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessInput.java new file mode 100644 index 00000000..216b11ea --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessInput.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.Map; + +/** Business content, not a protocol message. Exactly one of text/data is present. */ +public record BusinessInput(String text, Object data) { + public BusinessInput { + if ((text == null) == (data == null)) + throw new IllegalArgumentException("Exactly one of text or data is required"); + if (text != null && text.isBlank()) + throw new IllegalArgumentException("Text must not be blank"); + data = data == null ? null : BusinessValues.snapshot(data); + } + + /** Natural-language input. */ + public static BusinessInput text(String text) { + return new BusinessInput(text, null); + } + + /** Arbitrary JSON input; any schema and interpretation belong to the host. */ + public static BusinessInput data(Object data) { + return new BusinessInput(null, data); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessValues.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessValues.java new file mode 100644 index 00000000..5a6cb7b4 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/BusinessValues.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.*; + +/** Defensive JSON snapshots: payloads are unrestricted JSON, never shared mutable objects. */ +public final class BusinessValues { + private static final ObjectMapper JSON = new ObjectMapper(); + + private BusinessValues() {} + + /** Snapshots any JSON value, preserving nested arrays as values rather than outputs. */ + public static Object snapshot(Object value) { + return freeze(JSON.convertValue(value, Object.class)); + } + + /** Snapshots an ordered JSON object. */ + @SuppressWarnings("unchecked") + public static Map map(Map value) { + return (Map) freeze(JSON.convertValue(value, Map.class)); + } + + /** Snapshots ordered outputs without flattening nested arrays. */ + @SuppressWarnings("unchecked") + public static List list(List value) { + return value == null + ? List.of() + : (List) freeze(JSON.convertValue(value, List.class)); + } + + private static Object freeze(Object value) { + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + map.forEach((k, v) -> copy.put(String.valueOf(k), freeze(v))); + return Collections.unmodifiableMap(copy); + } + if (value instanceof List list) { + List copy = new ArrayList<>(); + list.forEach(v -> copy.add(freeze(v))); + return Collections.unmodifiableList(copy); + } + return value; + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/MessageContent.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/MessageContent.java new file mode 100644 index 00000000..dc6adcf0 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/MessageContent.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.FilePart; +import org.a2aproject.sdk.spec.FileWithBytes; +import org.a2aproject.sdk.spec.FileWithUri; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.TextPart; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Final business content. The engine owns the A2A envelope and transport headers. */ +public record MessageContent( + List> parts, Map metadata, Set extensions) { + + public MessageContent { + parts = snapshotParts(Objects.requireNonNull(parts, "parts")); + metadata = metadata == null ? Map.of() : BusinessValues.map(metadata); + LinkedHashSet active = new LinkedHashSet<>(); + if (extensions != null) { + for (String uri : extensions) { + if (uri == null || uri.isBlank()) { + throw new IllegalArgumentException("Extension URI must not be blank"); + } + active.add(uri); + } + } + extensions = Collections.unmodifiableSet(active); + } + + /** Plain A2A text, without implicit extension activation or generation. */ + public static MessageContent text(String text) { + return parts(List.of(new TextPart(Objects.requireNonNull(text, "text")))); + } + + /** Ordered standard A2A parts without message-level metadata or extensions. */ + public static MessageContent parts(List> parts) { + return new MessageContent(parts, Map.of(), Set.of()); + } + + static List> snapshotParts(List> parts) { + return parts.stream().map(MessageContent::snapshotPart).toList(); + } + + private static Part snapshotPart(Part part) { + Objects.requireNonNull(part, "part"); + if (part instanceof TextPart text) { + return new TextPart(text.text(), snapshotMetadata(text.metadata())); + } + if (part instanceof DataPart data) { + return new DataPart(BusinessValues.snapshot(data.data()), snapshotMetadata(data.metadata())); + } + if (part instanceof FilePart file + && (file.file() instanceof FileWithUri || file.file() instanceof FileWithBytes)) { + // File references are never dereferenced by snapshotting. Byte sources are SDK-owned. + return new FilePart(file.file(), snapshotMetadata(file.metadata())); + } + throw new IllegalArgumentException("Unsupported A2A part: " + part.getClass().getName()); + } + + static Map snapshotMetadata(Map metadata) { + return metadata == null ? null : BusinessValues.map(metadata); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationDecision.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationDecision.java deleted file mode 100644 index c8c025b6..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationDecision.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.model; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; - -/** - * Strongly typed business decision for one Negotiation-T round. - * - *

          Text decisions use the SDK's fromText APIs. Structured decisions use its deterministic typed - * fromData APIs. This replaces control strings such as {@code data:}, {@code reject:}, and {@code - * abort:}. - * - * @param action protocol outcome selected by the business - * @param input natural-language or structured decision input - */ -public record NegotiationDecision(Action action, Input input) { - - /** Supported terminal decisions for a received proposal. */ - public enum Action { - ACCEPT, - REJECT, - ABORT - } - - /** Creates an Accept decision from natural language. */ - public static NegotiationDecision acceptText(String text) { - return new NegotiationDecision(Action.ACCEPT, new NaturalLanguage(text)); - } - - /** Creates an Accept decision from deterministic structured values. */ - public static NegotiationDecision acceptData(Map values) { - return new NegotiationDecision(Action.ACCEPT, new StructuredData(normalize(values))); - } - - /** Creates a Reject decision from natural language. */ - public static NegotiationDecision rejectText(String text) { - return new NegotiationDecision(Action.REJECT, new NaturalLanguage(text)); - } - - /** Creates a Reject decision from deterministic structured values. */ - public static NegotiationDecision rejectData(Map values) { - return new NegotiationDecision(Action.REJECT, new StructuredData(normalize(values))); - } - - /** Creates an Abort decision whose reason is extracted by the SDK from natural language. */ - public static NegotiationDecision abortText(String text) { - return new NegotiationDecision(Action.ABORT, new NaturalLanguage(text)); - } - - /** Creates a deterministic typed Abort decision. */ - public static NegotiationDecision abortData(String terminationReason) { - return new NegotiationDecision( - Action.ABORT, - new StructuredData(normalize(Map.of("terminationReason", terminationReason)))); - } - - /** Validates the selected action and input. */ - public NegotiationDecision { - action = Objects.requireNonNull(action, "Negotiation action is required"); - input = Objects.requireNonNull(input, "Negotiation input is required"); - } - - /** Supported business input forms for negotiation content generation. */ - public sealed interface Input permits NaturalLanguage, StructuredData {} - - /** Natural-language decision input. */ - public record NaturalLanguage(String text) implements Input { - /** Rejects blank decision text before invoking the SDK. */ - public NaturalLanguage { - if (text == null || text.isBlank()) { - throw new IllegalArgumentException("Negotiation text must not be blank"); - } - } - } - - /** Structured decision values for the SDK typed fromData path. */ - public record StructuredData(Map values) implements Input { - /** Validates names and values and creates an immutable copy. */ - public StructuredData { - Objects.requireNonNull(values, "Negotiation structured values are required"); - if (values.isEmpty()) { - throw new IllegalArgumentException("Negotiation structured values must not be empty"); - } - values = Collections.unmodifiableMap(new LinkedHashMap<>(values)); - } - } - - private static Map normalize(Map values) { - Objects.requireNonNull(values, "Negotiation structured values are required"); - Map copy = new LinkedHashMap<>(); - values.forEach( - (name, rawValue) -> { - String normalizedName = name == null ? "" : name.strip(); - String normalizedValue = rawValue == null ? "" : rawValue.toString().strip(); - if (normalizedName.isEmpty() - || normalizedValue.isEmpty() - || "null".equalsIgnoreCase(normalizedValue)) { - throw new IllegalArgumentException( - "Negotiation values require non-blank names and values"); - } - copy.put(normalizedName, normalizedValue); - }); - return copy; - } -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationReply.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationReply.java new file mode 100644 index 00000000..cf20e27c --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationReply.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.Objects; + +/** Business chooses a final protocol reply or a local stop; the engine never generates an Abort. */ +public sealed interface NegotiationReply { + /** Final content to send on the original remote task and context. */ + record Send(MessageContent content) implements NegotiationReply { + public Send { + Objects.requireNonNull(content, "content"); + } + } + + /** Ends local execution only. Does not imply a protocol Abort was sent or acknowledged. */ + record Stop(String code, String reason) implements NegotiationReply { + public Stop { + if (code == null || code.isBlank() || reason == null || reason.isBlank()) { + throw new IllegalArgumentException("Stop code and reason are required"); + } + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationRequest.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/NegotiationRequest.java index 895586bc..dddacdbd 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 @@ -19,77 +19,37 @@ package dev.openan.workflow.engine.model; -import net.openan.a2at.sdk.core.model.NegotiationPerformative; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; +import java.time.Duration; +import java.util.List; import java.util.Objects; -/** - * Immutable business view of an INPUT_REQUIRED Negotiation-T request. - * - * @param agentName agent requesting negotiation - * @param concern rendered concern or question - * @param sessionId negotiation session identifier - * @param round current negotiation round - * @param maxRounds negotiated round limit - * @param performative communicative intent carried by the received context - * @param kind semantic negotiation kind inferred from the current SDK template - * @param templateUri received propose template URI - * @param parameters immutable business parameters extracted by the SDK validate-and-fill pipeline - * @param metadata immutable received metadata for application-specific inspection - */ +/** Uninterpreted received content and history for one task's current negotiation session. */ public record NegotiationRequest( - String agentName, - String concern, - String sessionId, - int round, - int maxRounds, - NegotiationPerformative performative, - Kind kind, - String templateUri, - Map parameters, - Map metadata) { + TaskRequest task, + MessageContent originalSubmission, + ReceivedMessage received, + List previousExchanges, + Duration remainingWait) { - /** Negotiation domains supported by the current A2A-T SDK. */ - public enum Kind { - INFORMATION, - TARGET, - FEASIBILITY + /** 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"); + } } - /** Validates and defensively copies the request. */ public NegotiationRequest { - if (agentName == null || agentName.isBlank()) { - throw new IllegalArgumentException("Negotiation agentName must not be blank"); - } - concern = concern == null ? "" : concern; - if (sessionId == null || sessionId.isBlank()) { - throw new IllegalArgumentException("Negotiation sessionId must not be blank"); - } - if (round < 1 || maxRounds < 1 || round > maxRounds) { - throw new IllegalArgumentException( - "Negotiation rounds must satisfy 1 <= round <= maxRounds"); - } - performative = Objects.requireNonNull(performative, "Negotiation performative is required"); - if (performative != NegotiationPerformative.PROPOSE) { - throw new IllegalArgumentException( - "Negotiation callback requires a PROPOSE performative, got " + performative); - } - if (kind == null) { - throw new IllegalArgumentException("Negotiation kind is required"); - } - if (templateUri == null || templateUri.isBlank()) { - throw new IllegalArgumentException("Negotiation templateUri must not be blank"); - } - parameters = immutableCopy(parameters); - metadata = immutableCopy(metadata); + 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"); } - private static Map immutableCopy(Map values) { - return values == null - ? Map.of() - : Collections.unmodifiableMap(new LinkedHashMap<>(values)); + /** Counterpart identity, not a remote protocol task identifier. */ + public String agentName() { + return task.getAgentName(); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/ReceivedMessage.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/ReceivedMessage.java new file mode 100644 index 00000000..86334bd7 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/ReceivedMessage.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.TextPart; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Response snapshot preserving message, task and artifact metadata at their original levels. */ +public record ReceivedMessage( + MessageContent message, Map taskMetadata, List artifacts) { + + public ReceivedMessage { + taskMetadata = taskMetadata == null ? Map.of() : BusinessValues.map(taskMetadata); + artifacts = artifacts == null ? List.of() : artifacts.stream() + .map(a -> new Artifact(a.artifactId(), a.name(), a.description(), + MessageContent.snapshotParts(a.parts()), + MessageContent.snapshotMetadata(a.metadata()), + a.extensions() == null ? List.of() : List.copyOf(a.extensions()))) + .toList(); + } + + /** Projects deliverables, or the message when there are no artifacts; never parses text. */ + public List outputs() { return outputs(true); } + + /** Failed/non-final task status messages remain evidence, never business outputs. */ + public List outputs(boolean includeMessage) { + List values = new ArrayList<>(); + // A task's status message is still available in the complete view, but must not + // become an extra deliverable alongside its artifacts (e.g. "Completed"). + if (includeMessage && message != null && artifacts.isEmpty()) { + project(message.parts(), values); + } + for (Artifact artifact : artifacts) { + project(artifact.parts(), values); + } + return BusinessValues.list(values); + } + + private static void project(List> parts, List values) { + for (Part part : parts) { + if (part instanceof TextPart text) { + values.add(text.text()); + } else if (part instanceof DataPart data) { + values.add(data.data()); + } + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/RouteRequest.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/RouteRequest.java new file mode 100644 index 00000000..4496161d --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/RouteRequest.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.List; + +/** Conditional route input; upstream selection is identical to task callbacks. */ +public record RouteRequest( + String executionId, + String stepName, + WorkflowInput workflowInput, + List currentResults, + List candidates) { + public RouteRequest { + currentResults = List.copyOf(currentResults); + candidates = List.copyOf(candidates); + } + + /** A permitted target and its business condition. */ + public record RouteOption(String nextStep, String condition) {} +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/SendMessageResult.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/SendMessageResult.java index bad7b437..9f215d21 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/SendMessageResult.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/SendMessageResult.java @@ -19,7 +19,6 @@ package dev.openan.workflow.engine.model; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @@ -27,14 +26,43 @@ import org.a2aproject.sdk.spec.Task; import java.util.Map; +import java.util.List; @Data @NoArgsConstructor -@AllArgsConstructor -@Builder public class SendMessageResult { - @Builder.Default private String text = ""; + private String text = ""; private Task task; private Map metadata; - @Builder.Default private String taskState = ""; + private String taskState = ""; + /** Local interaction outcome, separate from the actual remote task state. */ + private String failureCode; + private String failureMessage; + + /** Lossless source of all convenience outputs. */ + private List receivedMessages = List.of(); + + @Builder + public SendMessageResult(String text, Task task, Map metadata, String taskState, + String failureCode, String failureMessage, List receivedMessages) { + this.text = text == null ? "" : text; + this.task = task; + this.metadata = metadata == null ? Map.of() : BusinessValues.map(metadata); + this.taskState = taskState == null ? "" : taskState; + this.failureCode = failureCode; + this.failureMessage = failureMessage; + this.receivedMessages = receivedMessages == null ? List.of() : List.copyOf(receivedMessages); + } + + /** Projects text/data values from the retained response, without fallback or semantic parsing. */ + public List getOutputs() { + boolean includeMessage = failureCode == null + && (task == null || "TASK_STATE_COMPLETED".equals(taskState)); + return receivedMessages.stream().flatMap(message -> message.outputs(includeMessage).stream()).toList(); + } + + /** Snapshots the list supplied by transport adapters. */ + public void setReceivedMessages(List messages) { + this.receivedMessages = List.copyOf(messages); + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/Task.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/Task.java index 2c1f0f7d..e96a5a25 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/Task.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/Task.java @@ -30,6 +30,7 @@ @Builder public class Task { private String agent; + private BusinessInput input; @Builder.Default private String skill = ""; @Builder.Default private String description = ""; @Builder.Default private TaskStatus status = TaskStatus.PENDING; diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskExecutionResult.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskExecutionResult.java new file mode 100644 index 00000000..30390cfa --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskExecutionResult.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.List; +import java.util.Objects; + +/** Immutable result of one upstream workflow subtask. */ +public record TaskExecutionResult( + String agentName, + String skill, + String taskId, + String taskDescription, + TaskStatus status, + List outputs, + List receivedMessages, + String error, + String errorCode, + java.util.Map errorDetails) { + + public TaskExecutionResult { + if (agentName == null || agentName.isBlank()) { + throw new IllegalArgumentException("Task result agentName must not be blank"); + } + skill = skill == null ? "" : skill; + Objects.requireNonNull(taskId, "Task id"); + taskDescription = taskDescription == null ? "" : taskDescription; + status = Objects.requireNonNull(status, "Task result status is required"); + receivedMessages = receivedMessages == null ? List.of() : List.copyOf(receivedMessages); + outputs = receivedMessages.isEmpty() ? BusinessValues.list(outputs) + : receivedMessages.stream().flatMap(message -> message.outputs().stream()).toList(); + errorDetails = errorDetails == null ? java.util.Map.of() : BusinessValues.map(errorDetails); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskRequest.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskRequest.java index f2fc1235..e970e627 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskRequest.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskRequest.java @@ -19,21 +19,30 @@ package dev.openan.workflow.engine.model; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.Value; -@Data -@NoArgsConstructor -@AllArgsConstructor +/** Immutable business input for one logical workflow task; protocol identifiers stay internal. */ +@Value @Builder public class TaskRequest { + /** Unique execution identity, separate from remote A2A context IDs. */ + private String executionId; + + /** Logical task identity, stable across self-loop re-entry within this execution. */ + private String taskId; + + /** Business text or structured data, independent of upstream evidence. */ + private BusinessInput input; + private String agentName; private String skill; - private String message; - @Builder.Default private String description = ""; - private String context; + + /** Current task instruction only; never contains rendered upstream results. */ + private String instruction; + @Builder.Default private String language = "zh"; private String stepName; - @Builder.Default private int subtaskIndex = 0; + + /** Engine-assembled, protocol-neutral input selected by the workflow definition. */ + @Builder.Default private WorkflowInput workflowInput = WorkflowInput.empty(); } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResponse.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResponse.java deleted file mode 100644 index a7c05db1..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResponse.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.model; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.Map; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class TaskResponse { - private boolean success; - @Builder.Default private String output = ""; - private String error; - private Map metadata; -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResult.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResult.java new file mode 100644 index 00000000..e5407feb --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskResult.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the License); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an AS IS BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import lombok.Builder; +import lombok.Value; + +import java.util.List; + +@Value +@Builder +public class TaskResult { + private boolean success; + + /** Ordered business outputs; values may be text or JSON-serializable structured data. */ + @Builder.Default private List outputs = List.of(); + /** Remote evidence; local onSelfTask outputs leave this empty. */ + @Builder.Default private List receivedMessages = List.of(); + + private String error; + private String errorCode; + @Builder.Default private java.util.Map errorDetails = java.util.Map.of(); + + /** Successful local business output, including an intentionally empty output list. */ + public static TaskResult success(List outputs) { + return builder().success(true).outputs(outputs).build(); + } + + /** Business failure; never mixes the error message into the output list. */ + public static TaskResult failure(String code, String message) { + return builder().success(false).errorCode(code).error(message).build(); + } + + public TaskResult( + boolean success, + List outputs, + List receivedMessages, + String error, + String errorCode, + java.util.Map errorDetails) { + this.success = success; + this.receivedMessages = receivedMessages == null ? List.of() : List.copyOf(receivedMessages); + this.outputs = this.receivedMessages.isEmpty() ? BusinessValues.list(outputs) + : this.receivedMessages.stream().flatMap(message -> message.outputs(success).stream()).toList(); + this.error = error; + this.errorCode = errorCode; + this.errorDetails = + BusinessValues.map(errorDetails == null ? java.util.Map.of() : errorDetails); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskSubmission.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskSubmission.java deleted file mode 100644 index 4263fc39..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/TaskSubmission.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.model; - -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.core.model.TemplateUri; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; - -/** - * Immutable business-level description of a remote workflow task dispatch. - * - *

          The submission explicitly distinguishes natural-language input from structured input. The - * workflow engine maps the selected form to the matching current A2A-T SDK generation API; host - * applications never need to put engine-private markers into metadata. - * - * @param agentName target agent name - * @param instruction A2A message text and task instruction - * @param input protocol-content input selected by the business callback - * @param contextId optional A2A conversation context ID - * @param metadata optional non-protocol application metadata - */ -public record TaskSubmission( - String agentName, - String instruction, - Input input, - String contextId, - Map metadata) { - - /** - * Creates a natural-language Task-T submission for a known template. This uses the SDK's - * explicit fromText API and bypasses scenario recognition. - */ - public static TaskSubmission fromText( - String agentName, String text, TemplateUri templateUri) { - return new TaskSubmission( - agentName, text, new NaturalLanguage(templateUri), null, Map.of()); - } - - /** - * Creates a natural-language submission whose Task-T template is genuinely unknown. The SDK - * performs scenario recognition before prompt generation, so callers should prefer {@link - * #fromText(String, String, TemplateUri)} whenever the workflow already selected a template. - */ - public static TaskSubmission fromUnclassifiedText(String agentName, String text) { - return new TaskSubmission(agentName, text, new NaturalLanguage(null), null, Map.of()); - } - - /** Creates a structured Task-T submission rendered with a caller-selected Task-T template. */ - public static TaskSubmission fromData( - String agentName, - String instruction, - Map data, - Map schema, - TemplateUri templateUri) { - return new TaskSubmission( - agentName, - instruction, - new StructuredData(data, schema, templateUri), - null, - Map.of()); - } - - /** Validates and defensively copies the submission. */ - public TaskSubmission { - if (agentName == null || agentName.isBlank()) { - throw new IllegalArgumentException("Task target agentName must not be blank"); - } - if (instruction == null || instruction.isBlank()) { - throw new IllegalArgumentException("Task instruction must not be blank"); - } - input = Objects.requireNonNull(input, "Task input is required"); - metadata = - metadata == null - ? Map.of() - : Collections.unmodifiableMap(new LinkedHashMap<>(metadata)); - } - - /** Supported business input forms for Task-T generation. */ - public sealed interface Input permits NaturalLanguage, StructuredData {} - - /** - * Natural-language input. A non-null template selects the explicit SDK fromText pipeline; - * null deliberately selects scenario recognition. - */ - public record NaturalLanguage(TemplateUri templateUri) implements Input { - /** Validates that an explicitly selected template belongs to Task-T. */ - public NaturalLanguage { - if (templateUri != null - && !StandardTemplates.TASK_EXTENSION_NAME.equals(templateUri.extensionName())) { - throw new IllegalArgumentException( - "Natural-language task template is not Task-T: " + templateUri.uri()); - } - } - } - - /** Structured input: data and schema are rendered by the SDK's schema-aware fromData API. */ - public record StructuredData( - Map data, Map schema, TemplateUri templateUri) - implements Input { - - /** Validates and defensively copies structured input. */ - public StructuredData { - Objects.requireNonNull(data, "Task structured data is required"); - Objects.requireNonNull(schema, "Task data schema is required"); - Objects.requireNonNull(templateUri, "Task template URI is required"); - if (schema.isEmpty()) { - throw new IllegalArgumentException("Task data schema must not be empty"); - } - if (!StandardTemplates.TASK_EXTENSION_NAME.equals(templateUri.extensionName())) { - throw new IllegalArgumentException( - "Structured task template is not Task-T: " + templateUri.uri()); - } - data = Collections.unmodifiableMap(new LinkedHashMap<>(data)); - schema = Collections.unmodifiableMap(new LinkedHashMap<>(schema)); - } - } -} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/TaskDispatcher.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/UpstreamStepResult.java similarity index 57% rename from workflow-engine/src/main/java/dev/openan/workflow/engine/control/TaskDispatcher.java rename to workflow-engine/src/main/java/dev/openan/workflow/engine/model/UpstreamStepResult.java index effe2654..7c8df2f1 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/TaskDispatcher.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/UpstreamStepResult.java @@ -17,17 +17,17 @@ * under the License. */ -package dev.openan.workflow.engine.control; +package dev.openan.workflow.engine.model; -import dev.openan.workflow.engine.model.SendMessageResult; -import dev.openan.workflow.engine.model.TaskSubmission; +import java.util.List; -import java.util.concurrent.CompletableFuture; +/** Immutable, step-scoped group of upstream task results selected by {@code contextFrom}. */ +public record UpstreamStepResult(String stepName, List taskResults) { -/** Narrow capability exposed to business task callbacks for dispatching one typed remote task. */ -@FunctionalInterface -public interface TaskDispatcher { - - /** Dispatches a natural-language or structured Task-T submission. */ - CompletableFuture dispatch(TaskSubmission submission); + public UpstreamStepResult { + if (stepName == null || stepName.isBlank()) { + throw new IllegalArgumentException("Upstream stepName must not be blank"); + } + taskResults = taskResults == null ? List.of() : List.copyOf(taskResults); + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowInput.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowInput.java new file mode 100644 index 00000000..ce6f269c --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/model/WorkflowInput.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import java.util.List; + +/** + * Protocol-neutral input assembled by the engine for one workflow node. + * + *

          The workflow definition decides which upstream steps participate through {@code contextFrom}. + * Agent Task-T/A2A objects are decoded inside the engine before values enter this model, so + * business callbacks only see ordered business outputs and their workflow provenance. + */ +public record WorkflowInput(String runtimeIntent, List upstreamResults) { + + public WorkflowInput { + runtimeIntent = runtimeIntent == null ? "" : runtimeIntent; + upstreamResults = upstreamResults == null ? List.of() : List.copyOf(upstreamResults); + } + + public static WorkflowInput empty() { + return new WorkflowInput("", List.of()); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/runner/ExecutePsop.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/runner/ExecutePsop.java index 8d8614f4..a6f367f0 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/runner/ExecutePsop.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/runner/ExecutePsop.java @@ -66,7 +66,6 @@ public class ExecutePsop { * @param engineClient optional pre-created client (null = auto-create) * @param runtimeIntent original user intent * @param lang language hint (zh/en) - * @param a2atEnvPath path to A2A-T .env file * @param credentialsConfigPath path to credentials JSON * @param sslVerify whether to verify TLS certificates * @param caCertsPath path to CA trust store @@ -83,7 +82,6 @@ public static CompletableFuture execute( WorkflowEngineClient engineClient, String runtimeIntent, String lang, - String a2atEnvPath, String credentialsConfigPath, boolean sslVerify, String caCertsPath, @@ -107,7 +105,6 @@ public static CompletableFuture execute( sslVerify, caCertsPath, credentialsConfigPath, - a2atEnvPath, collectingCallback); boolean closeClientOnFinish = engineClient == null; WorkflowExecutor executor = @@ -139,7 +136,7 @@ public static CompletableFuture execute( onFinish)); } - /** Simplified overload without SSL/auth/A2AT config (legacy compatibility). */ + /** Convenience overload using default transport/authentication configuration. */ public static CompletableFuture execute( Workflow psop, List agentCards, @@ -167,7 +164,6 @@ public static CompletableFuture execute( runtimeIntent, lang, null, - null, true, null, a2aClientRuntime, @@ -251,14 +247,12 @@ private static WorkflowEngineClient createEngineClient( boolean sslVerify, String caCertsPath, String credentialsConfigPath, - String a2atEnvPath, EventCallback callback) { WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .sslVerify(sslVerify) .caCertsPath(caCertsPath) .credentialsConfigPath(credentialsConfigPath) - .a2atEnvPath(a2atEnvPath) .build(); A2ATransport transport = new A2ATransport(agentCards, a2aClientRuntime, config); @@ -389,7 +383,6 @@ public static final class Builder { private WorkflowEngineClient engineClient; private String runtimeIntent = ""; private String lang = "zh"; - private String a2atEnvPath; private String credentialsConfigPath; private boolean sslVerify = true; private String caCertsPath; @@ -429,11 +422,6 @@ public Builder lang(String v) { return this; } - public Builder a2atEnvPath(String v) { - this.a2atEnvPath = v; - return this; - } - public Builder credentialsConfigPath(String v) { this.credentialsConfigPath = v; return this; @@ -492,7 +480,6 @@ public CompletableFuture execute() { engineClient, runtimeIntent, lang, - a2atEnvPath, credentialsConfigPath, sslVerify, caCertsPath, diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/StubWorkflowEngineClient.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/StubWorkflowEngineClient.java index 95759b85..c6a8331b 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/StubWorkflowEngineClient.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/StubWorkflowEngineClient.java @@ -43,7 +43,13 @@ public class StubWorkflowEngineClient implements WorkflowEngineClient { private EventCallback eventCallback = new EventCallback(); private ControlPoint controlPoint; private String defaultResponse = "stub-response"; - private String defaultTaskState = "COMPLETED"; + private String defaultTaskState = "TASK_STATE_COMPLETED"; + private List defaultOutputs = List.of(); + + public StubWorkflowEngineClient withDefaultOutputs(List outputs) { + defaultOutputs = outputs; + return this; + } public StubWorkflowEngineClient(String... agentNames) { this.agentNames.addAll(List.of(agentNames)); @@ -64,9 +70,21 @@ public StubWorkflowEngineClient withDefaultTaskState(String state) { return this; } + @Override + public CompletableFuture dispatch( + dev.openan.workflow.engine.model.TaskRequest request, + dev.openan.workflow.engine.model.MessageContent content, ControlPoint callbacks) { + return sendMessage(request.getAgentName(), content); + } + @Override public CompletableFuture sendMessage( - String agentName, String message, String contextId, Map metadata) { + String agentName, dev.openan.workflow.engine.model.MessageContent content) { + String message = content.parts().stream().filter(org.a2aproject.sdk.spec.TextPart.class::isInstance) + .map(org.a2aproject.sdk.spec.TextPart.class::cast).map(org.a2aproject.sdk.spec.TextPart::text) + .collect(java.util.stream.Collectors.joining()); + String contextId = null; + Map metadata = content.metadata(); sent.add(new SentMessage(agentName, message, contextId, metadata)); String text = cannedResponses.getOrDefault(agentName, defaultResponse); if (eventCallback != null) { @@ -82,6 +100,11 @@ public CompletableFuture sendMessage( } SendMessageResult result = SendMessageResult.builder() + .receivedMessages(List.of(new dev.openan.workflow.engine.model.ReceivedMessage( + dev.openan.workflow.engine.model.MessageContent.parts(defaultOutputs.isEmpty() + ? text.isEmpty() ? List.of() : List.of(new org.a2aproject.sdk.spec.TextPart(text)) + : defaultOutputs.stream().>map(value -> + new org.a2aproject.sdk.spec.DataPart(value)).toList()), Map.of(), List.of()))) .text(text) .taskState(defaultTaskState) .metadata(new HashMap<>()) diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java deleted file mode 100644 index b9a728ef..00000000 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATContentFacadeTest.java +++ /dev/null @@ -1,212 +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.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; -import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; -import net.openan.a2at.sdk.core.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.NegotiationItem; -import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; - -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Map; - -/** - * Tests for {@link A2ATContentFacade} against the real A2A-T SDK: deterministic from-data - * rendering (no LLM) for propose, accept, reject and abort, plus the metadata shape the engine - * attaches to A2A messages. - * - *

          These tests exercise the bundled classpath templates of a2a-t-resources; the .env-driven - * LLM paths (from-text generation, validate-and-fill) are covered by the SDK's own suite and the - * engine's E2E sample test. - */ -class A2ATContentFacadeTest { - - private static final String ENV_PATH = resolveTestEnv(); - - private static String resolveTestEnv() { - try { - java.net.URL resource = - A2ATContentFacadeTest.class.getClassLoader().getResource("a2at-test.env"); - if (resource == null) { - throw new IllegalStateException("Missing test resource a2at-test.env"); - } - return java.nio.file.Path.of(resource.toURI()).toString(); - } catch (java.net.URISyntaxException e) { - throw new IllegalStateException("Invalid a2at-test.env resource URI", e); - } - } - - private A2ATContentFacade facade() { - return new A2ATContentFacade(new net.openan.a2at.sdk.client.A2ATClient(java.nio.file.Path.of(ENV_PATH))); - } - - private static NegotiationContext context() { - return new NegotiationContext( - "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", - 1, - 5, - net.openan.a2at.sdk.core.model.NegotiationPerformative.PROPOSE); - } - - @Test - void rendersInformationProposeFromData() { - A2ATContentFacade facade = facade(); - net.openan.a2at.sdk.core.model.MetadataContent mc = - facade.generateProposeFromData( - new NegotiationProposeData( - context(), - new InformationProposeContent( - List.of( - new NegotiationItem("接入端口名称", "举例:P533-珠江旧城-PTN3900-23-TPA1EG24-1"), - new NegotiationItem("投诉分类", "举例:专线质差")), - null)), - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE); - assertNotNull(mc.promptText()); - // Since the SDK moved the context out of templates, the propose text carries only the - // business sections; the context travels in the negotiationContext metadata key. - assertTrue(mc.promptText().contains("信息协商"), "propose must render the info section"); - assertTrue(mc.promptText().contains("接入端口名称")); - assertFalse(mc.promptText().contains("协商上下文"), "context section must not render"); - assertEquals( - "Negotiation-T/information-negotiation/propose/v1", - mc.templateUri()); - Map meta = A2ATContentFacade.toMetadata(mc); - assertEquals(mc.promptText(), meta.get(net.openan.a2at.sdk.core.model.ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI)); - assertEquals( - "Negotiation-T/information-negotiation/propose/v1", - meta.get(net.openan.a2at.sdk.core.model.MetadataContent.TEMPLATE_URI_METADATA_KEY)); - Object ctx = meta.get(net.openan.a2at.sdk.core.model.MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); - assertTrue(ctx instanceof Map, "context must travel in metadata"); - assertEquals("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", ((Map) ctx).get("id")); - assertEquals(1, ((Map) ctx).get("round")); - assertEquals("PROPOSE", ((Map) ctx).get("performative")); - } - - @Test - void rendersAcceptFromData() { - A2ATContentFacade facade = facade(); - net.openan.a2at.sdk.core.model.MetadataContent mc = - facade.generateAcceptFromData( - new NegotiationEndingData( - context(), - new InformationEndingContent( - NegotiationConclusion.ACCEPT, - List.of(new NegotiationItem("接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1")))), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); - assertTrue(mc.promptText().contains("Accept"), "accept must carry the Accept conclusion"); - // The round travels in metadata, not in the rendered text. - assertFalse(mc.promptText().contains("round: 1")); - Map metadata = A2ATContentFacade.toMetadata(mc); - Map endingContext = - (Map) - metadata.get( - net.openan.a2at.sdk.core.model.MetadataContent - .NEGOTIATION_CONTEXT_METADATA_KEY); - assertEquals( - 1, - endingContext.get("round"), - "an ending message must preserve the received propose round"); - assertEquals("ACCEPT", endingContext.get("performative")); - } - - @Test - void rejectsFractionalOrOutOfRangeNegotiationContextNumbers() { - assertNull( - A2ATContentFacade.contextFromMap( - Map.of("id", "context", "round", 1.5, "maxRounds", 5))); - assertNull( - A2ATContentFacade.contextFromMap( - Map.of( - "id", - "context", - "round", - 1, - "maxRounds", - (long) Integer.MAX_VALUE + 1))); - assertNull( - A2ATContentFacade.contextFromMap( - Map.of("id", "context", "round", 1, "maxRounds", 5))); - assertNull( - A2ATContentFacade.contextFromMap( - Map.of( - "id", - "context", - "round", - 1, - "maxRounds", - 5, - "performative", - "propose"))); - } - - @Test - void rendersRejectFromData() { - A2ATContentFacade facade = facade(); - net.openan.a2at.sdk.core.model.MetadataContent mc = - facade.generateRejectFromData( - new NegotiationEndingData( - context(), - new InformationEndingContent( - NegotiationConclusion.REJECT, - List.of(new NegotiationItem("投诉分类", "无法提供,原因:数据源不可用")))), - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT); - assertTrue(mc.promptText().contains("Reject"), "reject must carry the Reject conclusion"); - } - - @Test - void rendersAbortFromData() { - A2ATContentFacade facade = facade(); - net.openan.a2at.sdk.core.model.MetadataContent mc = - facade.generateAbortFromData( - new NegotiationAbortData( - context(), new NegotiationAbortContent("轮次预算耗尽,协商终止"))); - assertEquals("Negotiation-T/common/abort/v1", mc.templateUri()); - assertTrue(mc.promptText().contains("轮次预算耗尽")); - } - - @Test - void negotiationTemplateQueriesResolve() { - A2ATContentFacade facade = facade(); - var prompts = facade.getNegotiationPrompts(); - // 3 types x 2 phases + common abort - assertEquals(7, prompts.size(), "bundled negotiation templates must be enumerable"); - assertTrue( - facade.getNegotiationPrompt(StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE).isPresent()); - assertFalse( - facade.getPrompt( - net.openan.a2at.sdk.core.model.TemplateUri.of("Task-T", "nonexistent-scenario")) - .isPresent(), - "unknown scenario must yield empty"); - } -} 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 f2cd77a7..08f204de 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java @@ -49,13 +49,7 @@ void forwardsAuthorizationAndActiveExtensionHeadersToRuntime() throws Exception .build(); try (A2ATransport transport = new A2ATransport(List.of(card), runtime, config)) { - transport.send( - card, - card.name(), - "diagnose", - "context-1", - Map.of(TASK_T_URI, "structured task"), - null) + transport.send(card, card.name(), content("diagnose", Map.of(TASK_T_URI, "structured task")), "context-1", null, null) .join(); } @@ -88,13 +82,7 @@ public void close() {} assertThrows( CompletionException.class, () -> - transport.sendNotificationStream( - card, - card.name(), - "subscribe", - "context-1", - Map.of(TASK_T_URI, "notification"), - null) + transport.openNotificationStream(card, card.name(), content("subscribe", Map.of(TASK_T_URI, "notification")), "context-1", null).acknowledgement() .join()); assertEquals("gateway unavailable", error.getCause().getMessage()); } @@ -125,7 +113,7 @@ public void close() {} assertThrows( CompletionException.class, () -> - transport.send(card, card.name(), "diagnose", "context-1", null, null) + transport.send(card, card.name(), content("diagnose", null), "context-1", null, null) .join()); assertTrue(error.getCause() instanceof SecurityException); assertTrue(error.getCause().getMessage().contains("none are configured")); @@ -145,7 +133,7 @@ void authProviderCanSatisfySecurityRequirementWithoutCredentials() throws Except .build(); try (A2ATransport transport = new A2ATransport(List.of(card), runtime, config)) { - transport.send(card, card.name(), "diagnose", "context-1", null, null).join(); + transport.send(card, card.name(), content("diagnose", null), "context-1", null, null).join(); } assertEquals("Bearer gateway-token", capturedHeaders.get().get("Authorization")); @@ -203,8 +191,7 @@ public void close() {} new A2ATransport( List.of(card), runtime, WorkflowEngineClientConfig.builder().build()); - transport.openNotificationStream( - card, card.name(), "subscribe", "context-1", Map.of(), null); + transport.openNotificationStream(card, card.name(), content("subscribe", Map.of()), "context-1", null); assertTrue(entered.await(1, TimeUnit.SECONDS)); transport.close(); @@ -245,90 +232,27 @@ void owningClientClosesTransportExactlyOnce() throws Exception { } @Test - void structuredTaskWithoutSdkFailsBeforeRuntimeInvocation() throws Exception { - AtomicInteger sendCalls = new AtomicInteger(); - A2AJavaClientRuntime runtime = new SendCountingRuntime(sendCalls); - try (A2ATransport transport = - new A2ATransport( - List.of(agentCard()), - runtime, - WorkflowEngineClientConfig.builder().build())) { - CompletionException error = - assertThrows( - CompletionException.class, - () -> - new DefaultWorkflowEngineClient(transport) - .sendMessageFromData( - "Test Agent", - "diagnose", - Map.of("任务对象", "port-1"), - Map.of( - "type", - "object", - "properties", - Map.of( - "任务对象", - Map.of("type", "string"))), - StandardTemplates.PRIVATE_LINE_COMPLAINT) - .join()); - - assertTrue(error.getCause().getMessage().contains("A2A-T client is required")); - assertEquals(0, sendCalls.get()); + void finalStructuredContentNeedsNoContentSdk() throws Exception { + AtomicReference captured = new AtomicReference<>(); + try (var transport = new A2ATransport(List.of(agentCard()), + DefaultWorkflowEngineClientNegotiationTest.runtime(params -> { + captured.set(params); + return List.of(); + }), WorkflowEngineClientConfig.builder().build())) { + var content = new dev.openan.workflow.engine.model.MessageContent( + List.of(new org.a2aproject.sdk.spec.DataPart(Map.of("any", List.of(1, 2)))), + Map.of("contextId", "business-only"), java.util.Set.of("urn:optional:custom")); + new DefaultWorkflowEngineClient(transport).sendMessage("Test Agent", content).join(); + assertEquals(content.parts(), captured.get().message().parts()); + assertEquals(content.metadata(), captured.get().message().metadata()); + assertTrue(!"business-only".equals(captured.get().message().contextId())); } } - @Test - void structuredTaskRequiresAnExplicitCurrentSdkTemplate() throws Exception { - AtomicInteger sendCalls = new AtomicInteger(); - try (A2ATransport transport = - new A2ATransport( - List.of(agentCard()), - new SendCountingRuntime(sendCalls), - WorkflowEngineClientConfig.builder().build())) { - NullPointerException error = - assertThrows( - NullPointerException.class, - () -> - new DefaultWorkflowEngineClient(transport) - .sendMessageFromData( - "Test Agent", - "diagnose", - Map.of("任务对象", "port-1"), - Map.of( - "type", - "object", - "properties", - Map.of()), - null)); - - assertTrue(error.getMessage().contains("template URI")); - assertEquals(0, sendCalls.get()); - } - } - - @Test - void structuredTaskRejectsTemplateFromAnotherExtensionFamily() throws Exception { - AtomicInteger sendCalls = new AtomicInteger(); - try (A2ATransport transport = - new A2ATransport( - List.of(agentCard()), - new SendCountingRuntime(sendCalls), - WorkflowEngineClientConfig.builder().build())) { - IllegalArgumentException error = - assertThrows( - IllegalArgumentException.class, - () -> - new DefaultWorkflowEngineClient(transport) - .sendMessageFromData( - "Test Agent", - "diagnose", - Map.of("任务对象", "port-1"), - Map.of("type", "object"), - StandardTemplates.SERVICE_RECOVERY)); - - assertTrue(error.getMessage().contains("not Task-T")); - assertEquals(0, sendCalls.get()); - } + private static dev.openan.workflow.engine.model.MessageContent content(String text, Map metadata) { + return new dev.openan.workflow.engine.model.MessageContent( + List.of(new org.a2aproject.sdk.spec.TextPart(text)), metadata, + metadata == null ? java.util.Set.of() : metadata.keySet()); } private static AgentCard agentCard() throws Exception { 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 63e08dc4..52d5cc19 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 @@ -123,6 +123,31 @@ private static Artifact artifact(String id, String text) { .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) diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ContentDependencyBoundaryTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ContentDependencyBoundaryTest.java new file mode 100644 index 00000000..c10270dc --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ContentDependencyBoundaryTest.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.client; + +import org.junit.jupiter.api.Test; +import java.nio.file.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class ContentDependencyBoundaryTest { + @Test void engineClasspathHasNoContentClientOrLlmModules() { + for (String name : List.of("net.openan.a2at.sdk.client.A2ATClient", + "net.openan.a2at.sdk.server.A2ATServer", "net.openan.a2at.sdk.llm.LlmClient")) { + assertThrows(ClassNotFoundException.class, () -> Class.forName(name), name); + } + String classpath = System.getProperty("java.class.path"); + for (String module : List.of("a2a-t-client-", "a2a-t-llm-", "a2a-t-prompt-", + "a2a-t-resources-", "a2a-t-negotiation-")) assertFalse(classpath.contains(module), module); + } + + @Test void sourcePackagesHaveOnlyAllowedDependencyDirection() throws Exception { + Path root = Path.of("src/main/java/dev/openan/workflow/engine"); + try (var paths = Files.walk(root)) { + for (Path file : paths.filter(p -> p.toString().endsWith(".java")).toList()) { + String source = Files.readString(file, StandardCharsets.UTF_8); + String relative = root.relativize(file).toString().replace('\\', '/'); + if (relative.startsWith("core/") || relative.startsWith("model/") || relative.startsWith("control/")) { + assertFalse(source.contains("net.openan.a2at"), relative); + } + for (String forbidden : List.of("net.openan.a2at.sdk.client", "net.openan.a2at.sdk.server", + "net.openan.a2at.sdk.llm", "net.openan.a2at.sdk.negotiation", "net.openan.a2at.sdk.prompt")) { + assertFalse(source.contains(forbidden), relative + " imports " + forbidden); + } + } + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClientNegotiationTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/DefaultWorkflowEngineClientNegotiationTest.java index 4a1311c1..ca9f01b2 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 @@ -1,89 +1,232 @@ /* * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. */ + package dev.openan.workflow.engine.client; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertThrows; +import dev.openan.workflow.engine.control.ControlPoint; +import dev.openan.workflow.engine.model.*; +import net.openan.a2at.sdk.core.model.*; +import org.a2aproject.sdk.client.*; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.*; +import org.junit.jupiter.api.Test; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.function.*; +import static org.junit.jupiter.api.Assertions.*; -import net.openan.a2at.sdk.core.model.MetadataContent; -import net.openan.a2at.sdk.core.model.StandardTemplates; -import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; -import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; -import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; -import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +class DefaultWorkflowEngineClientNegotiationTest { + static AgentCard card() throws Exception { + return new com.fasterxml.jackson.databind.ObjectMapper().registerModule(new AgentCardJacksonModule()) + .readValue(""" + {"name":"test","description":"test","version":"1", + "capabilities":{"streaming":true,"extensions":[]}, + "defaultInputModes":["text/plain"],"defaultOutputModes":["text/plain"], + "skills":[],"supportedInterfaces":[{"protocolBinding":"HTTP+JSON","protocolVersion":"1.0", + "url":"http://localhost:1","tenant":""}]} + """, AgentCard.class); + } -import org.junit.jupiter.api.Test; + 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"))); + } -import java.util.Map; + 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()); + } -class DefaultWorkflowEngineClientNegotiationTest { + static A2AJavaClientRuntime runtime(Function> send) { + return new A2AJavaClientRuntime() { + public Iterable sendMessage(AgentCard card, MessageSendParams params, + ClientCallContext context, Consumer sink, Consumer logs) { + List events = send.apply(params); + if (sink != null) events.forEach(sink); + return events; + } + public void close() {} + }; + } - @Test - void currentFromDataEndingUsesConcreteTypedItems() { - InformationEndingContent content = - assertInstanceOf( - InformationEndingContent.class, - DefaultWorkflowEngineClient.buildEndingContent( - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, - Map.of("任务对象", "P781-17"), - NegotiationConclusion.ACCEPT)); + @Test void answersFinalRoundAndKeepsOriginalRemoteAssociation() throws Exception { + List sent = new CopyOnWriteArrayList<>(); + var runtime = runtime(params -> { + sent.add(params); + return List.of(response(params, sent.size() == 1 ? TaskState.TASK_STATE_INPUT_REQUIRED : TaskState.TASK_STATE_COMPLETED, + sent.size() == 1 ? negotiation("city1", 3, NegotiationPerformative.PROPOSE) : MessageContent.text("done"))); + }); + AtomicReference seen = new AtomicReference<>(); + try (var transport = new A2ATransport(List.of(card()), runtime, WorkflowEngineClientConfig.builder().build())) { + var client = new DefaultWorkflowEngineClient(transport); + client.setControlPoint(ControlPoint.builder().onNegotiation(request -> { + seen.set(request); + return CompletableFuture.completedFuture(new NegotiationReply.Send( + negotiation("city1", 3, NegotiationPerformative.ACCEPT))); + }).build()); + assertEquals("TASK_STATE_COMPLETED", client.sendMessage("test", MessageContent.text("start")).join().getTaskState()); + } + assertEquals(2, sent.size()); + assertNull(sent.get(0).message().taskId()); + assertEquals("remote-task", sent.get(1).message().taskId()); + assertEquals(sent.get(0).message().contextId(), sent.get(1).message().contextId()); + assertTrue(seen.get().previousExchanges().isEmpty()); + assertFalse(seen.get().remainingWait().isZero()); + assertEquals(MessageContent.text("start"), seen.get().originalSubmission()); + } + + @Test void terminalStateNeverRestartsOldNegotiation() throws Exception { + AtomicInteger callbacks = new AtomicInteger(); + try (var transport = new A2ATransport(List.of(card()), + runtime(p -> List.of(response(p, TaskState.TASK_STATE_COMPLETED, + negotiation("old", 1, NegotiationPerformative.PROPOSE)))), + WorkflowEngineClientConfig.builder().build())) { + var client = new DefaultWorkflowEngineClient(transport); + client.setControlPoint(ControlPoint.builder().onNegotiation(q -> { + callbacks.incrementAndGet(); + return CompletableFuture.failedFuture(new AssertionError()); + }).build()); + client.sendMessage("test", MessageContent.text("start")).join(); + } + assertEquals(0, callbacks.get()); + } + + @Test void rejectsWrongContextNewProposeAndMissingCallbackWithoutSendingAgain() throws Exception { + for (NegotiationReply reply : List.of( + new NegotiationReply.Send(negotiation("other-city", 1, NegotiationPerformative.ACCEPT)), + new NegotiationReply.Send(negotiation("city1", 1, NegotiationPerformative.PROPOSE)), + new NegotiationReply.Stop("manual.required", "Need human"))) { + AtomicInteger sends = new AtomicInteger(); + try (var transport = new A2ATransport(List.of(card()), runtime(p -> { + sends.incrementAndGet(); + return List.of(response(p, TaskState.TASK_STATE_INPUT_REQUIRED, negotiation("city1", 1, NegotiationPerformative.PROPOSE))); + }), WorkflowEngineClientConfig.builder().build())) { + var client = new DefaultWorkflowEngineClient(transport); + client.setControlPoint(ControlPoint.builder().onNegotiation(q -> CompletableFuture.completedFuture(reply)).build()); + assertThrows(CompletionException.class, () -> client.sendMessage("test", MessageContent.text("start")).join()); + } + assertEquals(1, sends.get()); + } + } + + @Test void plainInputRequiredIsExplicitlyUnsupported() throws Exception { + try (var transport = new A2ATransport(List.of(card()), + runtime(p -> List.of(response(p, TaskState.TASK_STATE_INPUT_REQUIRED, MessageContent.text("question")))), + WorkflowEngineClientConfig.builder().build())) { + var client = new DefaultWorkflowEngineClient(transport); + assertThrows(CompletionException.class, () -> client.sendMessage("test", MessageContent.text("start")).join()); + } + } - assertEquals(NegotiationConclusion.ACCEPT, content.conclusion()); - assertEquals(1, content.items().size()); - assertEquals("任务对象", content.items().get(0).name()); - assertEquals("P781-17", content.items().get(0).value()); + @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 endingTemplateMatchesTheReceivedCurrentProposeType() { - assertEquals( - StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT, - DefaultWorkflowEngineClient.endingTemplateFor(Map.of( - MetadataContent.TEMPLATE_URI_METADATA_KEY, - StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri()))); - assertEquals( - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, - DefaultWorkflowEngineClient.endingTemplateFor(Map.of( - MetadataContent.TEMPLATE_URI_METADATA_KEY, - StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri()))); - assertEquals( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - DefaultWorkflowEngineClient.endingTemplateFor(Map.of( - MetadataContent.TEMPLATE_URI_METADATA_KEY, - StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri()))); - assertThrows( - IllegalArgumentException.class, - () -> DefaultWorkflowEngineClient.endingTemplateFor(Map.of())); + @Test void workingAckAndDuplicateProposeAreObservedWithoutRepeatedSubmissionOrCallback() throws Exception { + for (TaskState secondState : List.of(TaskState.TASK_STATE_WORKING, TaskState.TASK_STATE_INPUT_REQUIRED)) { + AtomicInteger sends = new AtomicInteger(); + AtomicInteger polls = new AtomicInteger(); + AtomicInteger callbacks = new AtomicInteger(); + AtomicReference last = new AtomicReference<>(); + A2AJavaClientRuntime runtime = new A2AJavaClientRuntime() { + public Iterable sendMessage(AgentCard card, MessageSendParams params, + ClientCallContext context, Consumer sink, Consumer logs) { + last.set(params); + return List.of(response(params, sends.incrementAndGet() == 1 + ? TaskState.TASK_STATE_INPUT_REQUIRED : secondState, + negotiation("city1", 1, NegotiationPerformative.PROPOSE))); + } + public org.a2aproject.sdk.spec.Task getTask(AgentCard card, String taskId, ClientCallContext context) { + polls.incrementAndGet(); + assertEquals("remote-task", taskId); + assertNotNull(WireLog.context().get("attempt")); + return response(last.get(), TaskState.TASK_STATE_COMPLETED, MessageContent.text("diagnosis")).getTask(); + } + public void close() {} + }; + try (var transport = new A2ATransport(List.of(card()), runtime, WorkflowEngineClientConfig.builder().build())) { + var client = new DefaultWorkflowEngineClient(transport); + client.setControlPoint(ControlPoint.builder().onNegotiation(q -> { + callbacks.incrementAndGet(); + return CompletableFuture.completedFuture(new NegotiationReply.Send( + negotiation("city1", 1, NegotiationPerformative.ACCEPT))); + }).build()); + assertEquals("TASK_STATE_COMPLETED", + client.sendMessage("test", MessageContent.text("start")).get(5, TimeUnit.SECONDS).getTaskState()); + assertEquals(2, sends.get()); + assertEquals(1, callbacks.get()); + assertEquals(1, polls.get()); + } + } } - @Test - void targetAndFeasibilityFromDataUseTheirTypedContent() { - TargetEndingContent target = - assertInstanceOf( - TargetEndingContent.class, - DefaultWorkflowEngineClient.buildEndingContent( - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, - Map.of("confirmedIntent", "诊断指定专线"), - NegotiationConclusion.REJECT)); - FeasibilityEndingContent feasibility = - assertInstanceOf( - FeasibilityEndingContent.class, - DefaultWorkflowEngineClient.buildEndingContent( - StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, - Map.of("feasibilitySummary", "资源与权限满足"), - NegotiationConclusion.ACCEPT)); + @Test void canceledOrTimedOutNegotiationNeverSendsLateReply() throws Exception { + for (boolean timeout : List.of(false, true)) { + AtomicInteger sends = new AtomicInteger(); + CompletableFuture answer = new CompletableFuture<>(); + CountDownLatch entered = new CountDownLatch(1); + try (var transport = new A2ATransport(List.of(card()), runtime(p -> { + sends.incrementAndGet(); + return List.of(response(p, TaskState.TASK_STATE_INPUT_REQUIRED, + negotiation("city1", 1, NegotiationPerformative.PROPOSE))); + }), WorkflowEngineClientConfig.builder().sendTimeoutSeconds(1).build())) { + var client = new DefaultWorkflowEngineClient(transport); + client.setControlPoint(ControlPoint.builder().onNegotiation(q -> { + entered.countDown(); return answer; + }).build()); + var result = client.sendMessage("test", MessageContent.text("start")); + assertTrue(entered.await(1, TimeUnit.SECONDS)); + if (timeout) assertThrows(ExecutionException.class, () -> result.get(3, TimeUnit.SECONDS)); + else assertTrue(result.cancel(true)); + answer.complete(new NegotiationReply.Send(negotiation("city1", 1, NegotiationPerformative.ACCEPT))); + assertEquals(1, sends.get()); + } + } + } - assertEquals(NegotiationConclusion.REJECT, target.conclusion()); - assertEquals("诊断指定专线", target.confirmedIntent()); - assertEquals("资源与权限满足", feasibility.feasibilitySummary()); - assertThrows( - IllegalArgumentException.class, - () -> DefaultWorkflowEngineClient.buildEndingContent( - StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT, - Map.of("任务对象", "P781"), - NegotiationConclusion.ACCEPT)); + @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/ExtensionRegistryTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java deleted file mode 100644 index 44a28118..00000000 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.client; - -import static org.junit.jupiter.api.Assertions.*; - -import dev.openan.workflow.engine.control.ControlPoint; -import dev.openan.workflow.engine.control.EventCallback; -import dev.openan.workflow.engine.model.SendMessageResult; -import org.junit.jupiter.api.Test; - -import java.util.List; - -/** Tests for ExtensionRegistry: URI matching, dedup, built-in handlers. */ -class ExtensionRegistryTest { - - @Test - void preRegistersTwoBuiltinHandlers() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = - reg.getHandlersForExtensions( - List.of( - A2ATExtension.TASK_T.uri(), - A2ATExtension.NEGOTIATION_T.uri(), - A2ATExtension.AUTHORIZATION_T.uri(), - A2ATExtension.NOTIFICATION_T.uri())); - // Authorization-T and Notification-T are pre-positioning operations, - // not part of the workflow engine's extension handler chain. - assertEquals(2, handlers.size()); - } - - @Test - void matchesTaskTHandler() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = - reg.getHandlersForExtensions( - List.of(A2ATExtension.TASK_T.uri())); - assertEquals(1, handlers.size()); - assertEquals("Task-T", handlers.get(0).extensionKeyword()); - } - - @Test - void matchesNegotiationTHandler() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = - reg.getHandlersForExtensions( - List.of(A2ATExtension.NEGOTIATION_T.uri())); - assertEquals(1, handlers.size()); - assertEquals("Negotiation-T", handlers.get(0).extensionKeyword()); - } - - @Test - void rejectsNonCanonicalBuiltinUris() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = - reg.getHandlersForExtensions( - List.of("https://example.com/Task-T/v1", "https://example.com/Task-T/v2")); - assertTrue(handlers.isEmpty()); - } - - @Test - void returnsEmptyForNoExtensions() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = reg.getHandlersForExtensions(List.of()); - assertTrue(handlers.isEmpty()); - } - - @Test - void returnsEmptyForUnmatchedUris() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = - reg.getHandlersForExtensions(List.of("https://example.com/Unknown-Extension")); - assertTrue(handlers.isEmpty()); - } - - @Test - void doesNotMatchKeywordThatIsOnlyPartOfAnotherPathSegment() { - ExtensionRegistry reg = new ExtensionRegistry(); - assertTrue( - reg.getHandlersForExtensions( - List.of("https://example.com/extensions/Task-T-Extended/v1")) - .isEmpty()); - } - - @Test - void ignoresMalformedUris() { - ExtensionRegistry reg = new ExtensionRegistry(); - assertTrue(reg.getHandlersForExtensions(List.of("https://bad uri/Task-T/v1")).isEmpty()); - } - - @Test - void nullUrisHandledGracefully() { - ExtensionRegistry reg = new ExtensionRegistry(); - List handlers = reg.getHandlersForExtensions(null); - assertTrue(handlers.isEmpty()); - } - - @Test - void customHandlerCanBeRegistered() { - ExtensionRegistry reg = new ExtensionRegistry(); - ExtensionHandler custom = - new ExtensionHandler() { - @Override - public String extensionKeyword() { - return "Custom-T"; - } - - @Override - public java.util.concurrent.CompletableFuture> - beforeSend( - org.a2aproject.sdk.spec.AgentCard agentCard, - String messageText, - java.util.Map metadata, - net.openan.a2at.sdk.client.A2ATClient a2atClient, - ControlPoint controlPoint) { - return java.util.concurrent.CompletableFuture.completedFuture(metadata); - } - - @Override - public java.util.concurrent.CompletableFuture< - SendMessageResult> - afterReceive( - org.a2aproject.sdk.spec.AgentCard agentCard, - SendMessageResult result, - net.openan.a2at.sdk.client.A2ATClient a2atClient, - ControlPoint controlPoint, - EventCallback eventCallback) { - return java.util.concurrent.CompletableFuture.completedFuture(result); - } - }; - reg.register(custom); - List handlers = - reg.getHandlersForExtensions(List.of("https://example.com/Custom-T/v1")); - assertTrue(handlers.size() >= 1); - assertEquals("Custom-T", handlers.get(0).extensionKeyword()); - } -} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java index 2038891e..b9a0ea51 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java @@ -1,376 +1,96 @@ /* * Copyright (c) 2026 Huawei Technologies Co., Ltd. * All Rights Reserved. + * * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. */ package dev.openan.workflow.engine.client; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.fasterxml.jackson.databind.ObjectMapper; - +import dev.openan.workflow.engine.model.*; 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.llm.LLMClient; -import net.openan.a2at.sdk.llm.LLMClientConfig; -import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMResponse; - -import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.spec.*; +import org.a2aproject.sdk.client.*; 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 org.junit.jupiter.api.BeforeAll; - -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.CompletionException; -import java.util.function.Consumer; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.function.*; +import static org.junit.jupiter.api.Assertions.*; class ExtensionSenderTest { - - private static final String TEST_PROVIDER = "workflow-engine-test"; - - @BeforeAll - static void registerMockProvider() { - if (!LLMClientFactory.availableProviders().contains(TEST_PROVIDER)) { - LLMClientFactory.register(TEST_PROVIDER, TestLlmClient.class); - } + static AgentCard card() throws Exception { + var card = DefaultWorkflowEngineClientNegotiationTest.card(); + return AgentCard.builder(card).capabilities(new AgentCapabilities(true, false, false, List.of( + new AgentExtension("", Map.of(), false, A2ATExtension.AUTHORIZATION_T.uri()), + new AgentExtension("", Map.of(), false, A2ATExtension.NOTIFICATION_T.uri())))).build(); } - @Test - void notificationCallbackMethodIsAnInterfaceContract() throws Exception { - Method method = - ExtensionSender.class.getMethod( - "sendNotification", - String.class, - String.class, - String.class, - TemplateUri.class, - java.util.function.Consumer.class); - - assertTrue(Modifier.isAbstract(method.getModifiers())); + static MessageContent content(A2ATExtension extension) { + return A2atMessages.from(new MetadataContent("host-template", "final-content", extension.uri()), + List.of(new TextPart("instruction"))); } - @Test - void authorizationFromDataUsesRealSdkMetadataRendering() throws Exception { - AtomicReference captured = new AtomicReference<>(); - A2ATransport transport = transport(captured); - try { - new DefaultExtensionSender(transport) - .sendExtensionMessageFromData( - "Test Agent", - "下发授权策略", - Map.of( - "授权策略的操作类型", "新增授权策略", - "动网操作的授权策略列表", "业务投诉诊断,业务抢通,隧道调优,2026-06-01~2030-06-18"), - schema("授权策略的操作类型", "动网操作的授权策略列表"), - StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT, - A2ATExtension.AUTHORIZATION_T) - .join(); - - Map metadata = captured.get().message().metadata(); - assertEquals( - StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT.uri(), - metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - assertTrue( - String.valueOf(metadata.get(A2ATExtension.AUTHORIZATION_T.uri())) - .contains("新增授权策略")); - } finally { - transport.close(); + @Test void sendsFinalAuthorizationWithoutGeneratingOrSharingContext() throws Exception { + List contexts = new CopyOnWriteArrayList<>(); + try (var transport = new A2ATransport(List.of(card()), + DefaultWorkflowEngineClientNegotiationTest.runtime(params -> { + contexts.add(params.message().contextId()); + assertEquals(content(A2ATExtension.AUTHORIZATION_T).metadata(), params.message().metadata()); + return List.of(DefaultWorkflowEngineClientNegotiationTest.response(params, + TaskState.TASK_STATE_COMPLETED, MessageContent.text("ack"))); + }), WorkflowEngineClientConfig.builder().build())) { + var sender = new DefaultExtensionSender(transport); + sender.sendAuthorization("test", content(A2ATExtension.AUTHORIZATION_T)).join(); + sender.sendAuthorization("test", content(A2ATExtension.AUTHORIZATION_T)).join(); + assertNotEquals(contexts.get(0), contexts.get(1)); + assertThrows(CompletionException.class, () -> sender.sendAuthorization("test", MessageContent.text("wrong")).join()); } } - @Test - void notificationFromDataReturnsLifecycleHandleAndSdkMetadata() throws Exception { - AtomicReference captured = new AtomicReference<>(); - A2ATransport transport = transport(captured); - try { - NotificationSubscription subscription = - new DefaultExtensionSender(transport) - .openNotificationFromData( - "Test Agent", - "订阅抢通结果", - Map.of( - "订阅条件", "", - "上报通知数据格式", "业务抢通方案执行状态、任务流水号、执行结果"), - schema("订阅条件", "上报通知数据格式"), - StandardTemplates.SERVICE_RECOVERY, - ignored -> {}) - .join(); - subscription.acknowledgement().join(); - subscription.completion().join(); - - assertNotNull(captured.get()); - Map metadata = captured.get().message().metadata(); - assertEquals( - StandardTemplates.SERVICE_RECOVERY.uri(), - metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - assertTrue(metadata.containsKey(A2ATExtension.NOTIFICATION_T.uri())); - } finally { - transport.close(); - } - } - - @Test - void notificationTemplateIsCallerSelectedFromTheCurrentSdkCatalog() throws Exception { - AtomicReference captured = new AtomicReference<>(); - A2ATransport transport = transport(captured); - try { - MetadataContent content = - new DefaultExtensionSender(transport) - .generateExtensionPrompt( - A2ATExtension.NOTIFICATION_T, - "订阅严重告警事件", - StandardTemplates.SUBSCRIBE_INCIDENT); - - assertNotNull(content); - assertEquals(StandardTemplates.SUBSCRIBE_INCIDENT.uri(), content.templateUri()); - } finally { - transport.close(); - } - } - - @Test - void notificationCannotUseTheOneShotAuthorizationApi() throws Exception { - AtomicReference captured = new AtomicReference<>(); - A2ATransport transport = transport(captured); - try { - CompletionException error = - assertThrows( - CompletionException.class, - () -> - new DefaultExtensionSender(transport) - .sendExtensionMessage( - "Test Agent", - "subscribe", - "订阅抢通结果", - StandardTemplates.SERVICE_RECOVERY, - A2ATExtension.NOTIFICATION_T) - .join()); - - assertTrue(error.getCause().getMessage().contains("openNotification")); - assertNull(captured.get()); - } finally { - transport.close(); - } - } - - @Test - void authorizationRejectsTemplateFromAnotherExtensionFamily() throws Exception { - AtomicReference captured = new AtomicReference<>(); - A2ATransport transport = transport(captured); - try { - CompletionException error = - assertThrows( - CompletionException.class, - () -> - new DefaultExtensionSender(transport) - .sendExtensionMessage( - "Test Agent", - "authorize", - "新增授权策略", - StandardTemplates.SERVICE_RECOVERY, - A2ATExtension.AUTHORIZATION_T) - .join()); - - assertTrue(error.getCause().getMessage().contains("does not belong")); - assertNull(captured.get()); - } finally { - transport.close(); - } - } - - @Test - void taskFromDataUsesRealSdkMetadataPipeline() throws Exception { - AtomicReference captured = new AtomicReference<>(); - A2ATransport transport = transport(captured); - try { - new DefaultWorkflowEngineClient(transport) - .sendMessageFromData( - "Test Agent", - "创建诊断任务", - Map.of( - "任务对象", "接入端口名称:P781-test", - "任务上下文", "投诉分类:专线质差"), - schema("任务对象", "任务上下文"), - StandardTemplates.PRIVATE_LINE_COMPLAINT) - .join(); - - Map metadata = captured.get().message().metadata(); - assertEquals( - StandardTemplates.PRIVATE_LINE_COMPLAINT.uri(), - metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); - assertTrue(metadata.containsKey(A2ATExtension.TASK_T.uri())); - } finally { - transport.close(); - } - } - - @Test - void naturalLanguageExtensionWithoutSdkDoesNotForgeProtocolMetadata() throws Exception { - AtomicReference captured = new AtomicReference<>(); - A2ATransport transport = - new A2ATransport( - List.of(agentCard()), - new CapturingRuntime(captured), - WorkflowEngineClientConfig.builder().build()); - try { - CompletionException error = - assertThrows( - CompletionException.class, - () -> - new DefaultExtensionSender(transport) - .sendAuthorization( - "Test Agent", - "authorize", - "新增业务抢通白名单") - .join()); - - assertTrue(error.getCause().getMessage().contains("SDK prompt generation failed")); - assertNull(captured.get()); - } finally { - transport.close(); - } - } - - private static Map schema(String... fields) { - Map properties = new java.util.LinkedHashMap<>(); - for (String field : fields) { - properties.put(field, Map.of("type", "string", "description", field)); - } - return Map.of("type", "object", "properties", properties); - } - - private static A2ATransport transport(AtomicReference captured) - throws Exception { - java.net.URL env = - ExtensionSenderTest.class.getClassLoader().getResource("a2at-mock.env"); - if (env == null) { - throw new IllegalStateException("Missing test resource a2at-mock.env"); - } - WorkflowEngineClientConfig config = - WorkflowEngineClientConfig.builder() - .a2atEnvPath(Path.of(env.toURI()).toString()) - .build(); - return new A2ATransport(List.of(agentCard()), new CapturingRuntime(captured), config); - } - - private static AgentCard agentCard() throws Exception { - String json = - """ - { - "name": "Test Agent", - "description": "test", - "version": "1.0", - "capabilities": { - "streaming": true, - "extensions": [ - {"uri": "%s", "required": false}, - {"uri": "%s", "required": false}, - {"uri": "%s", "required": false} - ] - }, - "defaultInputModes": ["text/plain"], - "defaultOutputModes": ["text/plain"], - "skills": [], - "supportedInterfaces": [{ - "protocolBinding": "HTTP+JSON", - "protocolVersion": "1.0", - "url": "https://agent.example.test/a2a/json", - "tenant": "" - }] - } - """ - .formatted( - A2ATExtension.TASK_T.uri(), - A2ATExtension.AUTHORIZATION_T.uri(), - A2ATExtension.NOTIFICATION_T.uri()); - return new ObjectMapper() - .registerModule(new AgentCardJacksonModule()) - .readValue(json, AgentCard.class); - } - - private record CapturingRuntime(AtomicReference captured) - implements A2AJavaClientRuntime { - @Override - public Iterable sendMessage( - AgentCard agentCard, - MessageSendParams params, - ClientCallContext callContext, - Consumer eventSink, - Consumer logSink) { - captured.set(params); - return List.of(); - } - - @Override - public void close() {} - } - - /** Offline LLM provider exercising the SDK's real schema-mapping pipelines without network. */ - public static final class TestLlmClient implements LLMClient { - private final LLMClientConfig config; - - public TestLlmClient(LLMClientConfig config) { - this.config = config; - } - - @Override - public LLMResponse structured( - List> messages, - Map jsonSchema, - Double temperature, - Integer maxTokens) { - Object properties = jsonSchema.get("properties"); - if (properties instanceof Map propertyMap - && propertyMap.containsKey("semantic_verdict")) { - return response( - "{\"semantic_verdict\":true,\"negotiation_type\":\"information\"," - + "\"errors\":[],\"params\":{}}"); - } - Object slotNames = jsonSchema.get("slotNames"); - StringBuilder slots = new StringBuilder("{"); - if (slotNames instanceof List names) { - for (int i = 0; i < names.size(); i++) { - if (i > 0) slots.append(','); - String name = String.valueOf(names.get(i)); - slots.append('"') - .append(name) - .append("\":\"") - .append(slotValue(name)) - .append('"'); + @Test void earlyEventCanCloseHandleAndCompletionWaitsForActualExit() throws Exception { + CountDownLatch callbackRan = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference observed = new AtomicReference<>(); + var runtime = new A2AJavaClientRuntime() { + public Iterable sendMessage(AgentCard card, MessageSendParams params, + ClientCallContext context, Consumer sink, Consumer logs) { + sink.accept(DefaultWorkflowEngineClientNegotiationTest.response(params, + TaskState.TASK_STATE_WORKING, MessageContent.text("ack"))); + while (true) { + try { release.await(); break; } + catch (InterruptedException ignored) { /* keep transport alive until test releases it */ } } + return List.of(); } - slots.append('}'); - return response("{\"slots\":" + slots + ",\"slot_errors\":[]}"); - } - - private LLMResponse response(String content) { - return new LLMResponse(content, config.model(), Map.of(), Map.of()); - } - - private static String slotValue(String name) { - if (name.contains("操作类型")) return "新增授权策略"; - if (name.contains("策略列表")) { - return "业务投诉诊断,业务抢通,隧道调优,2026-06-01~2030-06-18"; - } - if (name.contains("订阅条件")) return "全部地市"; - if (name.contains("上报通知数据格式")) return "执行状态、任务流水号、执行结果"; - if (name.contains("任务对象")) return "接入端口名称:P781-test"; - if (name.contains("任务上下文")) return "投诉分类:专线质差"; - return "test-value"; - } + public void close() {} + }; + try (var transport = new A2ATransport(List.of(card()), runtime, WorkflowEngineClientConfig.builder().build())) { + var subscription = new DefaultExtensionSender(transport).openNotification("test", + content(A2ATExtension.NOTIFICATION_T), (handle, event) -> { + observed.set(handle); + handle.close(); + callbackRan.countDown(); + }); + assertTrue(callbackRan.await(2, TimeUnit.SECONDS)); + assertSame(subscription, observed.get()); + assertFalse(subscription.completion().isDone()); + subscription.close(); + release.countDown(); + subscription.completion().get(2, TimeUnit.SECONDS); + assertFalse(subscription.isActive()); + } finally { release.countDown(); } } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/NotificationSubscriptionTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/NotificationSubscriptionTest.java index a4ddab7b..6b55b05e 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 @@ -39,9 +39,10 @@ void eventsRefreshHeartbeatAndExplicitCloseIsIdempotent() { assertEquals(1, closes.get()); assertFalse(subscription.isActive()); - assertTrue(subscription.completion().isDone()); + assertFalse(subscription.completion().isDone()); assertFalse(subscription.streamTermination().isDone()); subscription.markStreamTerminated(); + assertTrue(subscription.completion().isDone()); assertTrue(subscription.streamTermination().isDone()); assertEquals("ack", subscription.acknowledgement().join().getText()); } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolResponsesTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolResponsesTest.java new file mode 100644 index 00000000..aa20ae62 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolResponsesTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + + +package dev.openan.workflow.engine.client; + +import org.a2aproject.sdk.client.*; +import org.a2aproject.sdk.spec.*; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class ProtocolResponsesTest { + @Test void appendDoesNotCountSdkSnapshotTwiceAndReplaceIsAuthoritative() { + Artifact first = artifact("a", "first", Map.of("x", 1)); + Artifact second = artifact("a", "second", Map.of("y", 2)); + Task seed = task(TaskState.TASK_STATE_WORKING, List.of(), Message.builder() + .messageId("progress").role(Message.Role.ROLE_AGENT).parts(new TextPart("working")).build()); + Task afterFirst = org.a2aproject.sdk.spec.util.Utils.appendArtifactToTask(seed, update(first, false), "task"); + Task afterSecond = org.a2aproject.sdk.spec.util.Utils.appendArtifactToTask(afterFirst, update(second, true), "task"); + List events = new ArrayList<>(List.of(new TaskUpdateEvent(afterFirst, update(first, false)), + new TaskUpdateEvent(afterSecond, update(second, true)))); + var partial = ProtocolResponses.assemble(events).get(0); + assertEquals(List.of("first", "second"), partial.outputs()); + assertEquals(Map.of("x", 1, "y", 2), partial.artifacts().get(0).metadata()); + Artifact replacement = artifact("a", "replaced", Map.of("final", true)); + Artifact other = artifact("b", "city2", Map.of()); + events.add(new TaskUpdateEvent(task(TaskState.TASK_STATE_WORKING, List.of(replacement), null), update(replacement, false))); + events.add(new TaskEvent(task(TaskState.TASK_STATE_COMPLETED, List.of(replacement, other), null))); + var completed = ProtocolResponses.assemble(events).get(0); + assertEquals(List.of("replaced", "city2"), completed.outputs()); + assertEquals(List.of("a", "b"), completed.artifacts().stream().map(Artifact::artifactId).toList()); + assertEquals(Map.of("final", true), completed.artifacts().get(0).metadata()); + } + + @Test void failureRetainsPartialArtifactButNotErrorTextAsOutput() { + Message error = Message.builder().messageId("error").role(Message.Role.ROLE_AGENT) + .parts(new TextPart("failed detail")).metadata(Map.of("level", "message")).build(); + var received = ProtocolResponses.assemble(List.of(new TaskEvent(task(TaskState.TASK_STATE_FAILED, + List.of(artifact("a", "partial", Map.of("level", "artifact"))), error)))).get(0); + assertEquals(List.of("partial"), received.outputs()); + assertEquals("message", received.message().metadata().get("level")); + assertEquals("task", received.taskMetadata().get("level")); + assertEquals("artifact", received.artifacts().get(0).metadata().get("level")); + } + + @Test void metadataOnlyAndNestedDataRemainAvailable() { + var received = ProtocolResponses.assemble(List.of(new TaskEvent(task(TaskState.TASK_STATE_COMPLETED, + List.of(), null)))).get(0); + assertTrue(received.outputs().isEmpty()); + assertEquals("task", received.taskMetadata().get("level")); + Artifact nested = Artifact.builder().artifactId("a").parts(new DataPart(List.of(List.of(1, 2)))).build(); + assertEquals(List.of(List.of(List.of(1, 2))), ProtocolResponses.assemble(List.of( + new TaskEvent(task(TaskState.TASK_STATE_COMPLETED, List.of(nested), null)))).get(0).outputs()); + } + + private static Task task(TaskState state, List artifacts, Message message) { + return Task.builder().id("task").contextId("context").status(new TaskStatus(state, message, null)) + .artifacts(artifacts).metadata(Map.of("level", "task")).build(); + } + private static Artifact artifact(String id, String text, Map metadata) { + return Artifact.builder().artifactId(id).name("report").description("description") + .parts(new TextPart(text)).metadata(metadata).extensions(List.of("urn:test")).build(); + } + private static TaskArtifactUpdateEvent update(Artifact artifact, boolean append) { + return TaskArtifactUpdateEvent.builder().taskId("task").contextId("context") + .artifact(artifact).append(append).lastChunk(false).build(); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfigTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfigTest.java index 572c433d..96765f7c 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 @@ -1,48 +1,38 @@ /* * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. */ -package dev.openan.workflow.engine.client; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +package dev.openan.workflow.engine.client; import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; class WorkflowEngineClientConfigTest { - - @Test - void rejectsInvalidNegotiationRoundLimit() { - assertThrows( - IllegalArgumentException.class, - () -> WorkflowEngineClientConfig.builder().maxNegotiationRounds(0).build()); + @Test void rejectsInvalidResourceLimits() { + assertThrows(IllegalArgumentException.class, () -> WorkflowEngineClientConfig.builder().maxNegotiationExchanges(0).build()); + assertThrows(IllegalArgumentException.class, () -> WorkflowEngineClientConfig.builder().sendTimeoutSeconds(0).build()); } - - @Test - @SuppressWarnings("unchecked") - void deeplySnapshotsNegotiationSchema() { - List required = new ArrayList<>(List.of("city")); - Map properties = new LinkedHashMap<>(); - properties.put("city", Map.of("type", "string")); - Map schema = new LinkedHashMap<>(); - schema.put("required", required); - schema.put("properties", properties); - - WorkflowEngineClientConfig config = - WorkflowEngineClientConfig.builder().negotiationParamSchema(schema).build(); - required.add("port"); - properties.put("port", Map.of("type", "string")); - - Map snapshot = config.getNegotiationParamSchema(); - assertEquals(List.of("city"), snapshot.get("required")); - assertEquals(1, ((Map) snapshot.get("properties")).size()); - assertThrows( - UnsupportedOperationException.class, - () -> ((List) snapshot.get("required")).add("port")); + @Test void credentialKeyIsExplicitAndConfigurationIsSnapshotted() { + Map scheme = new LinkedHashMap<>(Map.of("username", "test")); + var config = WorkflowEngineClientConfig.builder() + .credentialsConfig(Map.of("agent", Map.of("auth", scheme))).credentialEncryptionKey("test-key").build(); + scheme.clear(); + assertEquals("test", config.getCredentialsConfig().get("agent").get("auth").get("username")); + assertEquals("test-key", config.getCredentialEncryptionKey()); } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/control/DefaultControlPointTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/control/DefaultControlPointTest.java index 52765193..f95c4bee 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/control/DefaultControlPointTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/control/DefaultControlPointTest.java @@ -4,43 +4,58 @@ */ package dev.openan.workflow.engine.control; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.openan.workflow.engine.StubWorkflowEngineClient; -import dev.openan.workflow.engine.model.TaskRequest; +import static org.junit.jupiter.api.Assertions.*; +import dev.openan.workflow.engine.model.*; import org.junit.jupiter.api.Test; +import java.util.List; class DefaultControlPointTest { - private static final TaskRequest REQUEST = - TaskRequest.builder().agentName("agent").stepName("step").message("run").build(); + private final TaskRequest request = + TaskRequest.builder().agentName("agent").stepName("step").instruction("run").build(); + + @Test + void preparesTextWithoutSending() { + assertThrows(java.util.concurrent.CompletionException.class, + () -> new DefaultControlPoint().onTask(request).join()); + } @Test - void completedTaskWithEmptyTextIsSuccessful() { - StubWorkflowEngineClient client = - new StubWorkflowEngineClient("agent") - .withDefaultResponse("") - .withDefaultTaskState("TASK_STATE_COMPLETED"); - assertTrue(new DefaultControlPoint().onTask(REQUEST, client).join().isSuccess()); + void localExecutionRequiresBusinessHandler() { + assertThrows( + java.util.concurrent.CompletionException.class, + () -> new DefaultControlPoint().onSelfTask(request).join()); } @Test - void failedTaskWithErrorTextIsNotSuccessful() { - StubWorkflowEngineClient client = - new StubWorkflowEngineClient("agent") - .withDefaultResponse("remote error") - .withDefaultTaskState("TASK_STATE_FAILED"); - assertFalse(new DefaultControlPoint().onTask(REQUEST, client).join().isSuccess()); + void conditionalRoutingRequiresBusinessHandler() { + assertThrows( + java.util.concurrent.CompletionException.class, + () -> + new DefaultControlPoint().onRoute( + new RouteRequest( + "run", + "step", + WorkflowInput.empty(), + List.of(), + List.of())) + .join()); } @Test - void messageOnlyLegacyResponseFallsBackToText() { - StubWorkflowEngineClient client = - new StubWorkflowEngineClient("agent") - .withDefaultResponse("ok") - .withDefaultTaskState(""); - assertTrue(new DefaultControlPoint().onTask(REQUEST, client).join().isSuccess()); + void handlersCanBeRegisteredIndependently() { + var handler = + ControlPoint.builder() + .onSelfTask( + q -> + java.util.concurrent.CompletableFuture.completedFuture( + TaskResult.builder() + .success(true) + .outputs(List.of("done")) + .build())) + .build(); + assertEquals(List.of("done"), handler.onSelfTask(request).join().getOutputs()); + assertThrows(java.util.concurrent.CompletionException.class, () -> handler.onTask(request).join()); } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/BusinessBoundaryTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/BusinessBoundaryTest.java new file mode 100644 index 00000000..32ad16a8 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/BusinessBoundaryTest.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.core; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.openan.workflow.engine.StubWorkflowEngineClient; +import dev.openan.workflow.engine.control.*; +import dev.openan.workflow.engine.model.*; + +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.concurrent.*; + +class BusinessBoundaryTest { + private WorkflowStep step(String name, String next, List context) { + return WorkflowStep.builder() + .name(name) + .stepType(StepType.SELF_LOOP) + .contextFrom(context) + .subtasks(List.of(Task.builder().agent("host").description(name).build())) + .next(next == null ? List.of() : List.of(new JumpCondition(next, ""))) + .build(); + } + + @Test + void identifiersAndWorkflowStateAreIsolatedAcrossExecutions() { + var workflow = + Workflow.builder() + .name("shared") + .steps(List.of(step("one", null, List.of()))) + .build(); + List seen = new CopyOnWriteArrayList<>(); + var callbacks = + ControlPoint.builder() + .onSelfTask( + q -> { + seen.add(q); + return CompletableFuture.completedFuture( + TaskResult.builder() + .success(true) + .outputs(List.of("ok")) + .build()); + }) + .build(); + var client = new StubWorkflowEngineClient(); + var one = new WorkflowExecutor(workflow, callbacks, client, null, "first", "zh"); + var two = new WorkflowExecutor(workflow, callbacks, client, null, "second", "zh"); + CompletableFuture.allOf(one.run(), two.run()).join(); + assertEquals(2, seen.size()); + assertNotEquals(seen.get(0).getTaskId(), seen.get(1).getTaskId()); + assertNotEquals(seen.get(0).getExecutionId(), seen.get(1).getExecutionId()); + assertEquals( + TaskStatus.PENDING, workflow.getSteps().get(0).getSubtasks().get(0).getStatus()); + assertThrows(CompletionException.class, () -> one.run().join()); + } + + @Test + void routingUsesSelectedAncestorsAndCurrentTypedOutputsSeparately() { + for (List selection : + Arrays.asList(null, List.of(), List.of("*"), List.of("first"))) { + var first = step("first", "second", List.of()); + var second = step("second", "route", null); + var route = step("route", null, selection); + route.setNext(List.of(new JumpCondition("endNode", "all done"))); + var workflow = + Workflow.builder() + .name("route-context") + .steps(List.of(first, second, route)) + .build(); + List seen = new ArrayList<>(); + var callbacks = + ControlPoint.builder() + .onSelfTask( + q -> + CompletableFuture.completedFuture( + TaskResult.builder() + .success(true) + .outputs(List.of(q.getStepName())) + .build())) + .onRoute( + q -> { + seen.add(q); + return CompletableFuture.completedFuture( + new RouteDecision("endNode", "done")); + }) + .build(); + assertTrue( + new WorkflowExecutor( + workflow, + callbacks, + new StubWorkflowEngineClient(), + null, + "intent", + "zh") + .run() + .join() + .isSuccess()); + var q = seen.get(0); + assertEquals("intent", q.workflowInput().runtimeIntent()); + assertEquals(List.of("route"), q.currentResults().get(0).outputs()); + int expected = + selection == null + ? 1 + : selection.isEmpty() ? 0 : selection.contains("*") ? 2 : 1; + assertEquals(expected, q.workflowInput().upstreamResults().size()); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ContextBuilderTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ContextBuilderTest.java index cdee4b27..7b5eba19 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ContextBuilderTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ContextBuilderTest.java @@ -19,12 +19,15 @@ package dev.openan.workflow.engine.core; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import dev.openan.workflow.engine.model.JumpCondition; import dev.openan.workflow.engine.model.Task; import dev.openan.workflow.engine.model.Workflow; import dev.openan.workflow.engine.model.WorkflowStep; +import dev.openan.workflow.engine.model.TaskExecutionResult; +import dev.openan.workflow.engine.model.TaskStatus; import org.junit.jupiter.api.Test; @@ -42,145 +45,114 @@ private WorkflowStep step(String name, int layer, List next, List JumpCondition.builder().step(s).build()) + .map(target -> JumpCondition.builder().step(target).build()) .toList()) .contextFrom(contextFrom) .subtasks(List.of(Task.builder().agent("A").description("t").build())) .build(); } - @Test - void layerZeroReturnsRuntimeIntentOnly() { - Workflow wf = - Workflow.builder() - .name("w") - .steps(List.of(step("s1", 0, List.of("s2"), null))) - .build(); - ContextBuilder cb = new ContextBuilder(wf, "my intent"); - String ctx = cb.buildContext(wf.getSteps().get(0), new HashMap<>()); - assertEquals("## Runtime Context\n\nmy intent", ctx); + private TaskExecutionResult result(String agent, String output) { + return new TaskExecutionResult( + agent, + "diagnose", + agent + "-task", + "task", + TaskStatus.SUCCESS, + List.of(output), + List.of(), null, null, Map.of()); } @Test - void layerZeroNoIntentReturnsEmpty() { - Workflow wf = + void layerZeroKeepsRuntimeIntentSeparateFromTaskInstruction() { + Workflow workflow = Workflow.builder() .name("w") .steps(List.of(step("s1", 0, List.of("s2"), null))) .build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - String ctx = cb.buildContext(wf.getSteps().get(0), new HashMap<>()); - assertEquals("", ctx); + + var context = new ContextBuilder(workflow, "my intent") + .buildWorkflowInput(workflow.getSteps().get(0), Map.of()); + assertEquals("my intent", context.runtimeIntent()); + assertTrue(context.upstreamResults().isEmpty()); } @Test - void directPredecessorsProvideContext() { + void directPredecessorsProvideTypedResults() { WorkflowStep s1 = step("s1", 0, List.of("s2"), null); WorkflowStep s2 = step("s2", 1, List.of(), null); - Workflow wf = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); - ContextBuilder cb = new ContextBuilder(wf, "intent"); - Map> outputs = new HashMap<>(); - outputs.put("s1", Map.of("do A", "result-from-A")); - String ctx = cb.buildContext(s2, outputs); - assertTrue(ctx.contains("## Runtime Context")); - assertTrue(ctx.contains("## Previous Step Execution Results")); - assertTrue(ctx.contains("### s1 Results")); - assertTrue(ctx.contains("result-from-A")); + Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); + Map> results = + Map.of("s1", List.of(result("OMC-1", "result-from-A"))); + + var context = new ContextBuilder(workflow, "intent").buildWorkflowInput(s2, results); + + assertEquals("intent", context.runtimeIntent()); + assertEquals(1, context.upstreamResults().size()); + assertEquals("s1", context.upstreamResults().get(0).stepName()); + assertEquals("OMC-1", context.upstreamResults().get(0).taskResults().get(0).agentName()); + assertEquals( + List.of("result-from-A"), + context.upstreamResults().get(0).taskResults().get(0).outputs()); } @Test - void contextFromExplicitListOverridesPredecessors() { + void contextFromExplicitListOverridesDirectPredecessors() { WorkflowStep s1 = step("s1", 0, List.of("s3"), null); WorkflowStep s2 = step("s2", 0, List.of("s3"), null); WorkflowStep s3 = step("s3", 1, List.of(), List.of("s2")); - Workflow wf = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); - ContextBuilder cb = new ContextBuilder(wf, "intent"); - Map> outputs = new HashMap<>(); - outputs.put("s1", Map.of("t1", "out1")); - outputs.put("s2", Map.of("t2", "out2")); - String ctx = cb.buildContext(s3, outputs); - assertTrue(ctx.contains("out2")); - assertFalse(ctx.contains("out1")); + Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); + Map> results = new HashMap<>(); + results.put("s1", List.of(result("OMC-1", "out1"))); + results.put("s2", List.of(result("OMC-2", "out2"))); + + var context = new ContextBuilder(workflow, "intent").buildWorkflowInput(s3, results); + + assertEquals( + List.of("s2"), + context.upstreamResults().stream().map(value -> value.stepName()).toList()); } @Test void contextFromStarIncludesAllAncestors() { - // DAG: s1 -> s2 -> s3, context_from=["*"] on s3 should include s1 and s2 WorkflowStep s1 = step("s1", 0, List.of("s2"), null); WorkflowStep s2 = step("s2", 1, List.of("s3"), null); - WorkflowStep s3 = step("s3", 1, List.of(), List.of("*")); - Workflow wf = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); - ContextBuilder cb = new ContextBuilder(wf, "intent"); - Map> outputs = new HashMap<>(); - outputs.put("s1", Map.of("t1", "out1")); - outputs.put("s2", Map.of("t2", "out2")); - String ctx = cb.buildContext(s3, outputs); - assertTrue(ctx.contains("out1")); - assertTrue(ctx.contains("out2")); + WorkflowStep s3 = step("s3", 2, List.of(), List.of("*")); + Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); + Map> results = new HashMap<>(); + results.put("s1", List.of(result("OMC-1", "out1"))); + results.put("s2", List.of(result("OMC-2", "out2"))); + + var context = new ContextBuilder(workflow, "intent").buildWorkflowInput(s3, results); + + assertEquals(2, context.upstreamResults().size()); + assertEquals( + List.of("s2", "s1"), + context.upstreamResults().stream().map(value -> value.stepName()).toList()); } @Test - void getStepPredecessorsFindsDirectParents() { - WorkflowStep s1 = step("s1", 0, List.of("s3"), null); - WorkflowStep s2 = step("s2", 0, List.of("s3"), null); - WorkflowStep s3 = step("s3", 1, List.of(), null); - Workflow wf = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - List preds = cb.getStepPredecessors("s3"); - assertEquals(2, preds.size()); - assertTrue(preds.contains("s1")); - assertTrue(preds.contains("s2")); - } - - @Test - void getStepPredecessorsEmptyForRoot() { + void explicitEmptyContextFromDisablesUpstreamAggregation() { WorkflowStep s1 = step("s1", 0, List.of("s2"), null); - WorkflowStep s2 = step("s2", 1, List.of(), null); - Workflow wf = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - assertTrue(cb.getStepPredecessors("s1").isEmpty()); - } + WorkflowStep s2 = step("s2", 1, List.of(), List.of()); + Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); + Map> results = + Map.of("s1", List.of(result("OMC-1", "out1"))); - @Test - void buildTaskMessageWithZhLang() { - Workflow wf = - Workflow.builder().name("w").steps(List.of(step("s", 0, List.of(), null))).build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - String msg = cb.buildTaskMessage("do task", "context here", "zh"); - assertTrue(msg.contains("context here")); - assertTrue(msg.contains("## Current Task")); - assertTrue(msg.contains("do task")); - assertTrue(msg.contains("请用中文回复")); - } - - @Test - void buildTaskMessageWithEnLang() { - Workflow wf = - Workflow.builder().name("w").steps(List.of(step("s", 0, List.of(), null))).build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - String msg = cb.buildTaskMessage("do task", "context here", "en"); - assertTrue(msg.contains("Please respond in English")); + var context = new ContextBuilder(workflow, "intent").buildWorkflowInput(s2, results); + assertTrue(context.upstreamResults().isEmpty()); } @Test - void buildTaskMessageNoContext() { - Workflow wf = - Workflow.builder().name("w").steps(List.of(step("s", 0, List.of(), null))).build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - String msg = cb.buildTaskMessage("just a task", "", "zh"); - assertTrue(msg.startsWith("just a task")); - assertTrue(msg.contains("请用中文回复")); - assertFalse(msg.contains("## Current Task")); - } + void getStepPredecessorsFindsDirectParents() { + WorkflowStep s1 = step("s1", 0, List.of("s3"), null); + WorkflowStep s2 = step("s2", 0, List.of("s3"), null); + WorkflowStep s3 = step("s3", 1, List.of(), null); + Workflow workflow = Workflow.builder().name("w").steps(List.of(s1, s2, s3)).build(); - @Test - void findStepIndexCorrect() { - WorkflowStep s1 = step("s1", 0, null, null); - WorkflowStep s2 = step("s2", 1, null, null); - Workflow wf = Workflow.builder().name("w").steps(List.of(s1, s2)).build(); - ContextBuilder cb = new ContextBuilder(wf, ""); - assertEquals(0, cb.findStepIndex("s1")); - assertEquals(1, cb.findStepIndex("s2")); - assertNull(cb.findStepIndex("nonexistent")); + List predecessors = new ContextBuilder(workflow, "").getStepPredecessors("s3"); + assertEquals(2, predecessors.size()); + assertTrue(predecessors.contains("s1")); + assertTrue(predecessors.contains("s2")); } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ProtocolResultAdapterTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ProtocolResultAdapterTest.java new file mode 100644 index 00000000..b09f54cf --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/ProtocolResultAdapterTest.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openan.workflow.engine.model.SendMessageResult; + +import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatus; +import org.a2aproject.sdk.spec.TextPart; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +class ProtocolResultAdapterTest { + + @Test + void convertsOrderedTextAndStructuredArtifactsToBusinessOutputs() { + Task task = + Task.builder() + .id("task-1") + .contextId("context-1") + .status(new TaskStatus(TaskState.TASK_STATE_COMPLETED)) + .artifacts( + List.of( + Artifact.builder() + .artifactId("text-result") + .parts(new TextPart("diagnosis")) + .build(), + Artifact.builder() + .artifactId("data-result") + .parts(new DataPart(Map.of("alarmCount", 1))) + .build())) + .history(List.of()) + .metadata(Map.of("protocol", "must-not-leak")) + .build(); + + var response = + ProtocolResultAdapter.toTaskResult( + SendMessageResult.builder() + .task(task) + .receivedMessages(dev.openan.workflow.engine.client.ProtocolResponses.assemble( + List.of(new org.a2aproject.sdk.client.TaskEvent(task)))) + .text("flattened-value-must-not-win") + .taskState("TASK_STATE_COMPLETED") + .metadata(Map.of("wire", "must-not-leak")) + .build()); + + assertTrue(response.isSuccess()); + assertEquals(List.of("diagnosis", Map.of("alarmCount", 1)), response.getOutputs()); + } + + @Test + void doesNotInventTextFallbackWhenTransportHasNoBusinessContent() { + var response = + ProtocolResultAdapter.toTaskResult( + SendMessageResult.builder() + .text("diagnosis") + .taskState("TASK_STATE_COMPLETED") + .build()); + + assertEquals(List.of(), response.getOutputs()); + } + + @Test + void keepsFailedProtocolStateOutOfBusinessOutputs() { + var response = + ProtocolResultAdapter.toTaskResult( + SendMessageResult.builder() + .text("remote failure detail") + .taskState("TASK_STATE_FAILED") + .build()); + + assertFalse(response.isSuccess()); + assertEquals(List.of(), response.getOutputs()); + assertEquals("Agent returned state=TASK_STATE_FAILED", response.getError()); + } + + @Test + void failedStatusIsRetainedAsEvidenceButOnlyArtifactsBecomePartialOutputs() { + var status = dev.openan.workflow.engine.model.MessageContent.text("error: remote rejected"); + var evidence = new dev.openan.workflow.engine.model.ReceivedMessage(status, + Map.of("reason", "rejected"), List.of()); + var failed = SendMessageResult.builder().taskState("TASK_STATE_FAILED") + .receivedMessages(List.of(evidence)).build(); + var result = ProtocolResultAdapter.toTaskResult(failed); + assertFalse(result.isSuccess()); + assertEquals(List.of(), result.getOutputs()); + assertEquals(status, result.getReceivedMessages().get(0).message()); + + var partial = new dev.openan.workflow.engine.model.ReceivedMessage(status, Map.of(), + List.of(Artifact.builder().artifactId("partial") + .parts(new DataPart(Map.of("ports", List.of("a", "b")))).build())); + failed.setReceivedMessages(List.of(partial)); + assertEquals(List.of(Map.of("ports", List.of("a", "b"))), + ProtocolResultAdapter.toTaskResult(failed).getOutputs()); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java index 26320670..83f7e463 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java @@ -23,11 +23,12 @@ import dev.openan.workflow.engine.StubWorkflowEngineClient; import dev.openan.workflow.engine.control.ControlPoint; -import dev.openan.workflow.engine.control.TaskDispatcher; import dev.openan.workflow.engine.control.EventCallback; import dev.openan.workflow.engine.control.EventType; import dev.openan.workflow.engine.model.*; +import dev.openan.workflow.engine.model.RouteRequest; +import dev.openan.workflow.engine.model.MessageContent; import org.junit.jupiter.api.Test; import java.util.*; @@ -68,22 +69,25 @@ private JumpCondition jump(String step, String cond) { private ControlPoint autoCp() { return new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher taskDispatcher) { - return taskDispatcher - .dispatch(dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( - request.getAgentName(), request.getMessage())) - .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + public CompletableFuture onTask( + TaskRequest request) { + return CompletableFuture.completedFuture( + MessageContent.text( + request.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, Map results, List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(conditions.get(0).getStep()) @@ -197,31 +201,36 @@ void parallelBranchesScheduleSharedMergeExactlyOnce() { ControlPoint controlPoint = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher taskDispatcher) { + public CompletableFuture onTask( + TaskRequest request) { return CompletableFuture.supplyAsync( () -> { awaitBarrier(diagnosisBarrier); - return TaskResponse.builder() - .success(true) - .output(request.getAgentName() + " diagnosis") - .build(); + return MessageContent.text(request.getInstruction()); }, taskExecutor); } @Override - public CompletableFuture onSelfTask(TaskRequest request) { + public CompletableFuture onSelfTask(TaskRequest request) { mergeCalls.incrementAndGet(); return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output("merged").build()); + TaskResult.builder().success(true).outputs(List.of("merged")).build()); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(conditions.get(0).getStep()) @@ -299,24 +308,26 @@ void conditionalRouteOnRouteCalled() { ControlPoint cp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest req, TaskDispatcher dispatcher) { - return dispatcher.dispatch( - dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( - req.getAgentName(), req.getMessage())) - .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + public CompletableFuture onTask( + TaskRequest req) { + return CompletableFuture.completedFuture( + MessageContent.text( + req.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder().nextStep("s3").reason("chose s3").build()); } @@ -350,24 +361,26 @@ void onRouteInvalidStepFailsWorkflow() { ControlPoint cp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest req, TaskDispatcher dispatcher) { - return dispatcher.dispatch( - dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( - req.getAgentName(), req.getMessage())) - .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + public CompletableFuture onTask( + TaskRequest req) { + return CompletableFuture.completedFuture( + MessageContent.text( + req.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep("nonexistent") @@ -404,31 +417,30 @@ void taskFailurePropagatesAndStopsWorkflow() { ControlPoint cp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest req, TaskDispatcher dispatcher) { + public CompletableFuture onTask( + TaskRequest req) { if (req.getAgentName().equals("A")) { - return CompletableFuture.completedFuture( - TaskResponse.builder() - .success(false) - .error("A failed") - .build()); + return CompletableFuture.failedFuture( + new IllegalStateException("business preparation failed")); } - return dispatcher.dispatch( - dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( - req.getAgentName(), req.getMessage())) - .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + return CompletableFuture.completedFuture( + MessageContent.text( + req.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(conditions.get(0).getStep()) @@ -483,21 +495,29 @@ void anySuccessAllFailReturnsFailure() { .next(List.of()) .build(); Workflow wf = Workflow.builder().name("any-fail").steps(List.of(s1)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B").withDefaultTaskState("TASK_STATE_FAILED"); ControlPoint cp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest req, TaskDispatcher dispatcher) { + public CompletableFuture onTask( + TaskRequest req) { return CompletableFuture.completedFuture( - TaskResponse.builder().success(false).error("failed").build()); + MessageContent.text(req.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(conditions.get(0).getStep()) @@ -553,22 +573,30 @@ void runtimeIntentPassedToContext() { .build(); Workflow wf = Workflow.builder().name("intent").steps(List.of(s1)).build(); StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A"); - List messages = Collections.synchronizedList(new ArrayList<>()); + List requests = Collections.synchronizedList(new ArrayList<>()); ControlPoint cp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest req, TaskDispatcher dispatcher) { - messages.add(req.getMessage()); + public CompletableFuture onTask( + TaskRequest req) { + requests.add(req); return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output("ok").build()); + MessageContent.text(req.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(conditions.get(0).getStep()) @@ -578,8 +606,10 @@ public CompletableFuture onRoute( WorkflowExecutor exec = new WorkflowExecutor(wf, cp, stub, new EventCallback(), "my intent", "zh"); exec.run().join(); - assertFalse(messages.isEmpty()); - assertTrue(messages.get(0).contains("my intent")); + assertFalse(requests.isEmpty()); + assertEquals("do A", requests.get(0).getInstruction()); + assertEquals("my intent", requests.get(0).getWorkflowInput().runtimeIntent()); + assertTrue(requests.get(0).getWorkflowInput().upstreamResults().isEmpty()); } @Test @@ -690,24 +720,26 @@ void mergeDoesNotWaitForUnselectedConditionalBranch() { ControlPoint chooseRight = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher taskDispatcher) { - return taskDispatcher.dispatch( - dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( - request.getAgentName(), request.getMessage())) - .thenApply( - response -> - TaskResponse.builder() - .success(true) - .output(response.getText()) - .build()); + public CompletableFuture onTask( + TaskRequest request) { + return CompletableFuture.completedFuture( + MessageContent.text( + request.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder().nextStep("right").reason("test").build()); } @@ -753,34 +785,44 @@ void selfLoopStepCallsOnSelfTaskNotOnTask() { .next(List.of()) .build(); Workflow wf = Workflow.builder().name("self-loop").steps(List.of(remote, selfStep)).build(); - StubWorkflowEngineClient stub = new StubWorkflowEngineClient("SPN"); - List selfTaskMsgs = Collections.synchronizedList(new ArrayList<>()); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("SPN") + .withDefaultOutputs( + List.of(Map.of("diagnosis", List.of("found"), "alarmCount", 1))); + List selfTaskRequests = Collections.synchronizedList(new ArrayList<>()); ControlPoint cp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest req, TaskDispatcher dispatcher) { - return dispatcher.dispatch( - dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( - req.getAgentName(), req.getMessage())) + public CompletableFuture onTask( + TaskRequest req) { + return CompletableFuture.completedFuture( + MessageContent.text( + req.getInstruction())) .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + r -> MessageContent.text(req.getInstruction())); } @Override - public CompletableFuture onSelfTask(TaskRequest req) { - selfTaskMsgs.add(req.getMessage()); + public CompletableFuture onSelfTask(TaskRequest req) { + selfTaskRequests.add(req); return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output("merged").build()); + TaskResult.builder() + .success(true) + .outputs(List.of("merged")).build()); } @Override - public CompletableFuture onRoute( - String s, Map r, List c) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String s = routeRequest.stepName(); + Map r = java.util.Map.of(); + List c = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(c.get(0).getStep()) @@ -795,7 +837,27 @@ public CompletableFuture onRoute( // Only the remote diagnosis step sends via A2A-T; the merge step is local. assertEquals(1, stub.getSentCount()); assertEquals("SPN", stub.getSentMessages().get(0).agentName); - assertEquals(1, selfTaskMsgs.size()); + assertEquals(1, selfTaskRequests.size()); + TaskRequest mergeRequest = selfTaskRequests.get(0); + assertEquals("merge results", mergeRequest.getInstruction()); + assertFalse( + mergeRequest.getInstruction().contains("diagnose"), + "Current instruction must not contain upstream output"); + assertEquals("intent", mergeRequest.getWorkflowInput().runtimeIntent()); + assertEquals(1, mergeRequest.getWorkflowInput().upstreamResults().size()); + assertEquals("diag", mergeRequest.getWorkflowInput().upstreamResults().get(0).stepName()); + List upstreamOutputs = + mergeRequest + .getWorkflowInput() + .upstreamResults() + .get(0) + .taskResults() + .get(0) + .outputs(); + Object upstreamOutput = upstreamOutputs.get(0); + assertInstanceOf(Map.class, upstreamOutput); + assertEquals(1, ((Map) upstreamOutput).get("alarmCount")); + assertEquals(1, upstreamOutputs.size()); assertTrue(events.contains(EventType.TASK_REQUEST)); assertTrue(events.contains(EventType.TASK_RESPONSE)); } @@ -814,20 +876,25 @@ void repeatedDescriptionsInParallelStepDoNotOverwriteResults() { ControlPoint cp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher taskDispatcher) { + public CompletableFuture onTask( + TaskRequest request) { return CompletableFuture.completedFuture( - TaskResponse.builder() - .success(true) - .output(request.getAgentName() + " result") - .build()); + MessageContent.text(request.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder().nextStep("end").build()); } @@ -840,7 +907,9 @@ public CompletableFuture onRoute( .steps(List.of(parallel)) .build(), cp, - new StubWorkflowEngineClient(), + new StubWorkflowEngineClient() + .withResponse("City1", "City1 result") + .withResponse("City2", "City2 result"), recordingCallback(), "", "zh") @@ -850,11 +919,11 @@ public CompletableFuture onRoute( assertTrue(result.isSuccess()); assertEquals( Map.of( - "SPN专线故障诊断 [City1#0]", "City1 result", - "SPN专线故障诊断 [City2#1]", "City2 result"), + "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 -> item.get("subtask_index")).toList()); + result.getHistory().stream().map(item -> (Integer) item.get("subtask_index")).sorted().toList()); } @Test @@ -868,17 +937,25 @@ void nullTaskOutputAndNullExceptionMessageAreNormalized() { ControlPoint nullOutput = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher taskDispatcher) { + public CompletableFuture onTask( + TaskRequest request) { return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output(null).build()); + MessageContent.text(request.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder().nextStep("end").build()); } @@ -887,28 +964,36 @@ public CompletableFuture onRoute( new WorkflowExecutor( Workflow.builder().name("null-output").steps(List.of(step)).build(), nullOutput, - new StubWorkflowEngineClient(), + new StubWorkflowEngineClient().withDefaultResponse(""), recordingCallback(), "", "zh") .run() .join(); assertTrue(success.isSuccess()); - assertEquals("", success.getStepOutputs().get("nullable").get("empty")); + assertEquals(List.of(), success.getStepOutputs().get("nullable").get("empty")); ControlPoint nullMessageFailure = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher taskDispatcher) { + public CompletableFuture onTask( + TaskRequest request) { return CompletableFuture.failedFuture(new RuntimeException((String) null)); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map( + option -> + new JumpCondition( + option.nextStep(), + option.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder().nextStep("end").build()); } @@ -917,7 +1002,7 @@ public CompletableFuture onRoute( new WorkflowExecutor( Workflow.builder().name("null-error").steps(List.of(step)).build(), nullMessageFailure, - new StubWorkflowEngineClient(), + new StubWorkflowEngineClient().withDefaultResponse(""), recordingCallback(), "", "zh") @@ -926,4 +1011,26 @@ public CompletableFuture onRoute( assertFalse(failure.isSuccess()); assertFalse(String.valueOf(failure.getHistory().get(0).get("output")).isBlank()); } + + @Test + void canceledAndTimedOutPreparationCannotSubmitLateContent() throws Exception { + for (boolean timeout : List.of(false, true)) { + Workflow wf = Workflow.builder().name("late").steps(List.of(WorkflowStep.builder() + .name("s1").subtasks(List.of(task("A", "work"))).build())).build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A") { + @Override public long callbackTimeoutSeconds() { return 1; } + }; + CompletableFuture prepared = new CompletableFuture<>(); + java.util.concurrent.CountDownLatch entered = new java.util.concurrent.CountDownLatch(1); + ControlPoint cp = ControlPoint.builder().onTask(request -> { + entered.countDown(); return prepared; + }).build(); + var run = new WorkflowExecutor(wf, cp, stub, recordingCallback(), "", "zh").run(); + assertTrue(entered.await(1, TimeUnit.SECONDS)); + if (timeout) assertFalse(run.get(3, TimeUnit.SECONDS).isSuccess()); + else assertTrue(run.cancel(true)); + prepared.complete(MessageContent.text("too late")); + assertEquals(0, stub.getSentCount()); + } + } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/model/MessageContentTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/MessageContentTest.java new file mode 100644 index 00000000..7937d240 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/MessageContentTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.model; + +import org.a2aproject.sdk.spec.*; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class MessageContentTest { + @Test void snapshotsPartsMetadataAndActivationWithoutChangingOrder() { + List nested = new ArrayList<>(List.of("city1")); + Map data = new LinkedHashMap<>(Map.of("cities", nested)); + Map metadata = new LinkedHashMap<>(Map.of("nested", data)); + Set extensions = new LinkedHashSet<>(List.of("urn:example:task")); + List> parts = new ArrayList<>(List.of( + new TextPart("instruction", metadata), new DataPart(data, metadata), + new FilePart(new FileWithUri("text/plain", "report", "https://invalid.example/report"), metadata))); + MessageContent content = new MessageContent(parts, metadata, extensions); + nested.add("changed"); + data.clear(); + parts.clear(); + metadata.clear(); + extensions.clear(); + assertEquals(3, content.parts().size()); + assertEquals(Map.of("cities", List.of("city1")), ((DataPart) content.parts().get(1)).data()); + assertEquals("https://invalid.example/report", ((FileWithUri) ((FilePart) content.parts().get(2)).file()).uri()); + assertEquals(Set.of("urn:example:task"), content.extensions()); + assertThrows(UnsupportedOperationException.class, () -> content.metadata().put("new", true)); + assertThrows(UnsupportedOperationException.class, () -> content.parts().clear()); + } + + @Test void snapshotsPrimitiveAndArrayDataWithoutFlattening() { + List nested = new ArrayList<>(List.of("one", Map.of("value", 2))); + ReceivedMessage received = new ReceivedMessage( + MessageContent.parts(List.of(new DataPart(nested), new DataPart(3), new TextPart("{not parsed}"))), + Map.of(), List.of()); + nested.clear(); + assertEquals(List.of(List.of("one", Map.of("value", 2)), 3, "{not parsed}"), received.outputs()); + } + + @Test void keepsMetadataOnlyResponseAndAllArtifactFields() { + List items = new ArrayList<>(List.of("a")); + Artifact artifact = new Artifact("artifact1", "report", "description", + List.of(new DataPart(items)), Map.of("same", "artifact"), List.of("urn:artifact")); + ReceivedMessage received = new ReceivedMessage( + new MessageContent(List.of(), Map.of("same", "message"), Set.of()), + Map.of("same", "task"), List.of(artifact)); + items.clear(); + assertEquals("message", received.message().metadata().get("same")); + assertEquals("task", received.taskMetadata().get("same")); + assertEquals("artifact", received.artifacts().get(0).metadata().get("same")); + assertEquals(List.of(List.of("a")), received.outputs()); + assertEquals("description", received.artifacts().get(0).description()); + assertEquals(List.of("urn:artifact"), received.artifacts().get(0).extensions()); + assertEquals(List.of(), new ReceivedMessage(new MessageContent(List.of(), Map.of("task-t", "body"), + Set.of()), Map.of(), List.of()).outputs()); + } + + @Test void preservesFileReferenceAndRejectsUnknownParts() { + assertInstanceOf(TextPart.class, MessageContent.text("").parts().get(0)); + assertThrows(IllegalArgumentException.class, + () -> MessageContent.parts(List.of(new Part() {}))); + assertThrows(NullPointerException.class, () -> MessageContent.parts(null)); + assertThrows(IllegalArgumentException.class, + () -> new MessageContent(List.of(), Map.of(), Set.of(""))); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationDecisionTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationDecisionTest.java deleted file mode 100644 index c99c14aa..00000000 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/model/NegotiationDecisionTest.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * SPDX-License-Identifier: Apache-2.0 - */ -package dev.openan.workflow.engine.model; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import org.junit.jupiter.api.Test; - -import java.util.Map; - -class NegotiationDecisionTest { - - @Test - void factoriesPreserveActionAndInputMode() { - var accept = NegotiationDecision.acceptData(Map.of("port", "P1")); - var reject = NegotiationDecision.rejectText("policy denied"); - var abort = NegotiationDecision.abortData("round limit"); - - assertEquals(NegotiationDecision.Action.ACCEPT, accept.action()); - assertEquals( - "P1", - assertInstanceOf(NegotiationDecision.StructuredData.class, accept.input()) - .values() - .get("port")); - assertEquals(NegotiationDecision.Action.REJECT, reject.action()); - assertEquals( - "policy denied", - assertInstanceOf(NegotiationDecision.NaturalLanguage.class, reject.input()).text()); - assertEquals(NegotiationDecision.Action.ABORT, abort.action()); - } - - @Test - void invalidBusinessValuesFailBeforeSdkInvocation() { - assertThrows(IllegalArgumentException.class, () -> NegotiationDecision.acceptText(" ")); - assertThrows(IllegalArgumentException.class, () -> NegotiationDecision.acceptData(Map.of())); - assertThrows( - IllegalArgumentException.class, - () -> NegotiationDecision.acceptData(java.util.Collections.singletonMap("port", null))); - assertThrows( - IllegalArgumentException.class, - () -> NegotiationDecision.rejectData(Map.of("port", "null"))); - assertThrows(IllegalArgumentException.class, () -> NegotiationDecision.abortData(" ")); - assertThrows(IllegalArgumentException.class, () -> NegotiationDecision.abortData("null")); - } -} 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 af5b8091..2cf32649 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,87 +18,25 @@ */ package dev.openan.workflow.engine.model; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import net.openan.a2at.sdk.core.model.NegotiationPerformative; - import org.junit.jupiter.api.Test; - -import java.util.LinkedHashMap; -import java.util.Map; +import java.util.*; +import java.time.Duration; +import static org.junit.jupiter.api.Assertions.*; class NegotiationRequestTest { - - @Test - void retainsTypedPerformativeAndDefensivelyCopiesBusinessData() { - Map metadata = new LinkedHashMap<>(); - metadata.put("trace", "one"); - Map parameters = new LinkedHashMap<>(); - parameters.put("接入端口名称", "举例:P533"); - var request = - new NegotiationRequest( - "SPN", - "confirm port", - "session-1", - 1, - 3, - NegotiationPerformative.PROPOSE, - NegotiationRequest.Kind.INFORMATION, - "urn:template", - parameters, - metadata); - - metadata.put("trace", "two"); - parameters.put("接入端口名称", "changed"); - assertEquals(NegotiationPerformative.PROPOSE, request.performative()); - assertEquals("举例:P533", request.parameters().get("接入端口名称")); - assertThrows( - UnsupportedOperationException.class, - () -> request.parameters().put("x", "y")); - assertEquals("one", request.metadata().get("trace")); - assertThrows(UnsupportedOperationException.class, () -> request.metadata().put("x", "y")); - } - - @Test - void rejectsInvalidSessionAndRounds() { - assertThrows( - IllegalArgumentException.class, - () -> request("", 1, 3)); - assertThrows( - IllegalArgumentException.class, - () -> request("session-1", 0, 3)); - assertThrows( - IllegalArgumentException.class, - () -> request("session-1", 4, 3)); - assertThrows( - IllegalArgumentException.class, - () -> - new NegotiationRequest( - "SPN", - "confirm port", - "session-1", - 1, - 3, - NegotiationPerformative.ACCEPT, - NegotiationRequest.Kind.INFORMATION, - "urn:template", - Map.of(), - Map.of())); - } - - private static NegotiationRequest request(String sessionId, int round, int maxRounds) { - return new NegotiationRequest( - "SPN", - "confirm port", - sessionId, - round, - maxRounds, - NegotiationPerformative.PROPOSE, - NegotiationRequest.Kind.INFORMATION, - "urn:template", - Map.of(), - Map.of()); + @Test void preservesTaskContentAndHistorySnapshot() { + var task = TaskRequest.builder().agentName("omc").taskId("local-task").build(); + var content = MessageContent.text("opaque"); + var received = new ReceivedMessage(content, Map.of("task-only", true), List.of()); + List history = new ArrayList<>(List.of( + new NegotiationRequest.Exchange(received, new NegotiationReply.Stop("code", "reason")))); + var request = new NegotiationRequest(task, content, received, history, Duration.ofSeconds(3)); + history.clear(); + assertEquals("omc", request.agentName()); + assertEquals("local-task", request.task().getTaskId()); + assertEquals(1, request.previousExchanges().size()); + assertSame(received, request.received()); + assertThrows(IllegalArgumentException.class, () -> + new NegotiationRequest(task, content, received, List.of(), Duration.ofSeconds(-1))); } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/model/TaskSubmissionTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/model/TaskSubmissionTest.java deleted file mode 100644 index 8fecd59b..00000000 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/model/TaskSubmissionTest.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * SPDX-License-Identifier: Apache-2.0 - */ -package dev.openan.workflow.engine.model; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import dev.openan.workflow.engine.StubWorkflowEngineClient; -import dev.openan.workflow.engine.client.A2ATExtension; - -import net.openan.a2at.sdk.core.model.StandardTemplates; - -import org.junit.jupiter.api.Test; - -import java.util.Map; - -class TaskSubmissionTest { - - @Test - void unclassifiedNaturalLanguageSubmissionUsesScenarioRecognitionPath() { - StubWorkflowEngineClient client = new StubWorkflowEngineClient("agent"); - - client.dispatch(TaskSubmission.fromUnclassifiedText("agent", "diagnose link")).join(); - - var sent = client.getSentMessages().get(0); - assertEquals("diagnose link", sent.message); - assertEquals(Map.of(), sent.metadata); - } - - @Test - void templatedNaturalLanguageSubmissionSelectsExplicitFromTextPath() { - StubWorkflowEngineClient client = new StubWorkflowEngineClient("agent"); - - client.dispatch( - TaskSubmission.fromText( - "agent", - "diagnose link", - StandardTemplates.PRIVATE_LINE_COMPLAINT)) - .join(); - - var sent = client.getSentMessages().get(0); - assertEquals( - StandardTemplates.PRIVATE_LINE_COMPLAINT.uri(), - sent.metadata.get(A2ATExtension.TASK_TEMPLATE_META_KEY)); - } - - @Test - void structuredSubmissionAddsOnlyEngineOwnedTaskInputMetadata() { - StubWorkflowEngineClient client = new StubWorkflowEngineClient("agent"); - TaskSubmission submission = - TaskSubmission.fromData( - "agent", - "create diagnosis", - Map.of("port", "P1"), - Map.of("type", "object"), - StandardTemplates.PRIVATE_LINE_COMPLAINT); - - client.dispatch(submission).join(); - - var metadata = client.getSentMessages().get(0).metadata; - assertEquals(Map.of("port", "P1"), metadata.get(A2ATExtension.TASK_DATA_META_KEY)); - assertEquals( - StandardTemplates.PRIVATE_LINE_COMPLAINT.uri(), - metadata.get(A2ATExtension.TASK_TEMPLATE_META_KEY)); - assertInstanceOf(TaskSubmission.StructuredData.class, submission.input()); - } - - @Test - void invalidSubmissionFailsAtBusinessBoundary() { - assertThrows( - IllegalArgumentException.class, - () -> - TaskSubmission.fromText( - "", "task", StandardTemplates.PRIVATE_LINE_COMPLAINT)); - assertThrows( - IllegalArgumentException.class, - () -> - TaskSubmission.fromData( - "agent", - "task", - Map.of(), - Map.of(), - StandardTemplates.PRIVATE_LINE_COMPLAINT)); - } -} 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 513096f5..7417f969 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/runner/ExecutePsopTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/runner/ExecutePsopTest.java @@ -23,11 +23,12 @@ import dev.openan.workflow.engine.StubWorkflowEngineClient; import dev.openan.workflow.engine.control.ControlPoint; -import dev.openan.workflow.engine.control.TaskDispatcher; import dev.openan.workflow.engine.control.EventCallback; import dev.openan.workflow.engine.control.EventType; import dev.openan.workflow.engine.model.*; +import dev.openan.workflow.engine.model.RouteRequest; +import dev.openan.workflow.engine.model.MessageContent; import org.junit.jupiter.api.Test; import java.util.*; @@ -50,22 +51,22 @@ private Task task(String agent, String desc) { private ControlPoint autoCp() { return new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher taskDispatcher) { - return taskDispatcher - .dispatch(dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( - request.getAgentName(), request.getMessage())) - .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + public CompletableFuture onTask( + TaskRequest request) { + return CompletableFuture.completedFuture( + MessageContent.text( + request.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, Map results, List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map(c -> new JumpCondition(c.nextStep(), c.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(conditions.get(0).getStep()) @@ -104,25 +105,11 @@ public void onEvent(String type, Map data) { } }; ExecutionResult result = - ExecutePsop.execute( - linearWorkflow(), - List.of(), - autoCp(), - stub, - "intent", - "zh", - null, - null, - false, - null, - null, - cb, - (BiFunction< + ExecutePsop.execute(linearWorkflow(), List.of(), autoCp(), stub, "intent", "zh", null, false, null, null, cb, (BiFunction< ExecutionResult, List>, CompletableFuture>) - null, - null) + null, null) .join(); assertTrue(result.isSuccess()); // Verify lifecycle bracket: start ... complete ... close @@ -154,21 +141,7 @@ void onFinishCalledWithResultAndEvents() { return CompletableFuture.completedFuture(null); }; ExecutionResult result = - ExecutePsop.execute( - linearWorkflow(), - List.of(), - autoCp(), - stub, - "intent", - "zh", - null, - null, - false, - null, - null, - new EventCallback(), - onFinish, - null) + ExecutePsop.execute(linearWorkflow(), List.of(), autoCp(), stub, "intent", "zh", null, false, null, null, new EventCallback(), onFinish, null) .join(); assertEquals(1, finishCalls.get()); assertTrue(capturedResult.get().isSuccess()); @@ -196,21 +169,7 @@ public void onEvent(String type, Map data) { } return event; }; - ExecutePsop.execute( - linearWorkflow(), - List.of(), - autoCp(), - stub, - "intent", - "zh", - null, - null, - false, - null, - null, - cb, - null, - onEvent) + ExecutePsop.execute(linearWorkflow(), List.of(), autoCp(), stub, "intent", "zh", null, false, null, null, cb, null, onEvent) .join(); assertTrue(events.contains("psop_update")); int psopIdx = events.indexOf("psop_update"); @@ -239,21 +198,7 @@ public void onEvent(String type, Map data) { } return event; }; - ExecutePsop.execute( - linearWorkflow(), - List.of(), - autoCp(), - stub, - "intent", - "zh", - null, - null, - false, - null, - null, - cb, - null, - onEvent) + ExecutePsop.execute(linearWorkflow(), List.of(), autoCp(), stub, "intent", "zh", null, false, null, null, cb, null, onEvent) .join(); assertFalse(events.contains(EventType.TASK_REQUEST)); } @@ -263,21 +208,7 @@ void builderProducesSameResultAsStaticExecute() { StubWorkflowEngineClient stub1 = new StubWorkflowEngineClient("A", "B"); StubWorkflowEngineClient stub2 = new StubWorkflowEngineClient("A", "B"); ExecutionResult r1 = - ExecutePsop.execute( - linearWorkflow(), - List.of(), - autoCp(), - stub1, - "intent", - "zh", - null, - null, - false, - null, - null, - new EventCallback(), - null, - null) + ExecutePsop.execute(linearWorkflow(), List.of(), autoCp(), stub1, "intent", "zh", null, false, null, null, new EventCallback(), null, null) .join(); ExecutionResult r2 = ExecutePsop.builder() @@ -337,28 +268,26 @@ void errorLifecycleOnTaskFailure() { ControlPoint failCp = new ControlPoint() { @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher taskDispatcher) { + public CompletableFuture onTask( + TaskRequest request) { if (request.getAgentName().equals("A")) { - return CompletableFuture.completedFuture( - TaskResponse.builder().success(false).error("A broke").build()); + return CompletableFuture.failedFuture( + new IllegalStateException("business preparation failed")); } - return taskDispatcher - .dispatch(dev.openan.workflow.engine.model.TaskSubmission.fromUnclassifiedText( - request.getAgentName(), request.getMessage())) - .thenApply( - r -> - TaskResponse.builder() - .success(true) - .output(r.getText()) - .build()); + return CompletableFuture.completedFuture( + MessageContent.text( + request.getInstruction())); } @Override - public CompletableFuture onRoute( - String stepName, - Map results, - List conditions) { + public CompletableFuture onRoute(RouteRequest routeRequest) { + String stepName = routeRequest.stepName(); + Map results = java.util.Map.of(); + List conditions = + routeRequest.candidates().stream() + .map(c -> new JumpCondition(c.nextStep(), c.condition())) + .toList(); + return CompletableFuture.completedFuture( RouteDecision.builder() .nextStep(conditions.get(0).getStep()) @@ -374,21 +303,7 @@ public void onEvent(String type, Map data) { } }; ExecutionResult result = - ExecutePsop.execute( - linearWorkflow(), - List.of(), - failCp, - stub, - "intent", - "zh", - null, - null, - false, - null, - null, - cb, - null, - null) + ExecutePsop.execute(linearWorkflow(), List.of(), failCp, stub, "intent", "zh", null, false, null, null, cb, null, null) .join(); assertFalse(result.isSuccess()); assertEquals(EventType.START, events.get(0)); From c34d8d343638dd7ab030eaaf3e5d8d07936879f4 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 31 Aug 2026 09:41:34 +0800 Subject: [PATCH 47/49] fix(samples): align authorization and negotiation with SDK 1.1.0 Signed-off-by: Zhoujie628 --- .../examples/demo/A2ATProtocolCases.java | 4 +- .../engine/examples/demo/SpnCasePrompts.java | 4 +- .../extension/AuthorizationPolicy.java | 57 +++++++++++++++---- .../PrePositionedExtensionHandler.java | 21 +++++-- .../negotiation/NegotiationStrategy.java | 25 +++++++- .../examples/demo/SpringSpnDemoE2ETest.java | 7 +++ .../extension/AuthorizationPolicyTest.java | 48 ++++++++++++++-- .../PrePositionedExtensionHandlerTest.java | 48 ++++++++++++++-- .../negotiation/NegotiationStrategyTest.java | 28 ++++++++- .../testsupport/OfflineA2ATLlmClient.java | 17 +++++- 10 files changed, 223 insertions(+), 36 deletions(-) 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 71a39553..a8227cac 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 @@ -266,7 +266,7 @@ private void case_7_6() { "授权策略的操作类型", "删除授权策略", "动网操作的授权策略列表", - "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"), SpnCasePrompts.authorizationSchema(), net.openan.a2at.sdk.core.model.StandardTemplates + "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(); @@ -286,7 +286,7 @@ private void case_7_7() { "授权策略的操作类型", "查询授权策略", "动网操作的授权策略列表", - "业务场景:业务投诉诊断,处置类型:业务抢通,操作名称:隧道调优"), SpnCasePrompts.authorizationSchema(), net.openan.a2at.sdk.core.model.StandardTemplates + ""), SpnCasePrompts.authorizationSchema(), net.openan.a2at.sdk.core.model.StandardTemplates .AUTHORIZATION_POLICY_MANAGEMENT.uri()), List.of(new org.a2aproject.sdk.spec.TextPart("查询业务抢通操作授权信息")))) .join(); 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 e6451fef..5c2ee393 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 @@ -202,7 +202,7 @@ public static Map addAuthorizationData() { data.put("授权策略的操作类型", "新增授权策略"); data.put( "动网操作的授权策略列表", - "业务投诉诊断,业务抢通,隧道调优,2026-06-01~2030-06-18"); + "1. 业务场景是业务投诉诊断,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-06-01~2030-06-18"); return data; } @@ -224,7 +224,7 @@ public static Map subscribeServiceRecoveryData() { "业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、" + "OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、" + "业务抢通方案名称、业务抢通方案详情、业务抢通方案执行结束时间、" - + "业务抢通方案执行结果(成功、失败)及失败原因。"); + + "业务抢通方案执行结果(成功、失败)及业务抢通方案执行失败原因(执行结果为失败时必填)。"); return data; } } 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 eecad4e7..9c3ec2a3 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 @@ -50,10 +50,8 @@ public static AuthorizationPolicy fromValidated(Map data) { } String policyList = requireText(data.get(POLICY_LIST_FIELD), POLICY_LIST_FIELD); List rules = new ArrayList<>(); - for (String entry : policyList.split("\uff1b", -1)) { - if (!entry.isBlank()) { - rules.add(Rule.parse(entry.strip())); - } + for (String entry : numberedEntries(policyList)) { + rules.add(Rule.parse(entry)); } return new AuthorizationPolicy(operationType, rules); } @@ -75,6 +73,38 @@ public static String requirePolicyList(Map data) { return requireText(data.get(POLICY_LIST_FIELD), POLICY_LIST_FIELD); } + /** Decodes the sample's single exact-id delete selector; conditions are not supported. */ + public static String policyIdSelector(Map data) { + List entries = numberedEntries(requirePolicyList(data)); + if (entries.size() != 1 || entries.get(0).contains(",")) { + throw new IllegalArgumentException("The sample requires one numbered 策略标识是 selector"); + } + return labeledValue(entries.get(0), "策略标识"); + } + + private static List numberedEntries(String policyList) { + String[] lines = policyList.strip().split("\\R", -1); + List entries = new ArrayList<>(); + for (int i = 0; i < lines.length; i++) { + String prefix = (i + 1) + "."; + String line = lines[i].strip(); + if (!line.startsWith(prefix)) { + throw new IllegalArgumentException( + "Authorization policies require consecutive numbered lines starting with 1."); + } + entries.add(requireText(line.substring(prefix.length()), POLICY_LIST_FIELD)); + } + return entries; + } + + private static String labeledValue(String field, String label) { + String prefix = label + "是"; + if (!field.strip().startsWith(prefix)) { + throw new IllegalArgumentException("Authorization field must use " + prefix + ""); + } + return requireText(field.strip().substring(prefix.length()), label); + } + /** Exact whitelist match; no substring or fuzzy authorization is permitted. */ public boolean authorizes( String businessScenario, String disposalType, String actionName, LocalDate onDate) { @@ -118,16 +148,19 @@ private static Rule parse(String entry) { + "SDK, separated by full-width commas: " + entry); } - String validity = fields[3].strip(); + 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( - fields[0].strip(), - fields[1].strip(), - fields[2].strip(), + scenario, + disposal, + action, LocalDate.MIN, LocalDate.MAX); } - String[] range = validity.split("[~\uff5e]", -1); + String[] range = validity.split("~", -1); if (range.length != 2) { throw new IllegalArgumentException( "Authorization validity must be start~end or 永久生效: " @@ -135,9 +168,9 @@ private static Rule parse(String entry) { } try { return new Rule( - fields[0].strip(), - fields[1].strip(), - fields[2].strip(), + scenario, + disposal, + action, parseDate(range[0]), parseDate(range[1])); } catch (DateTimeParseException e) { 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 40288c17..c748e717 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 @@ -270,7 +270,7 @@ synchronized AuthorizationOperationResult applyAuthorization( "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.requirePolicyList(validatedData); + String selector = AuthorizationPolicy.policyIdSelector(validatedData); if (authorizationPolicy == null || !selector.equals(authorizationPolicyId)) { throw new IllegalArgumentException( "Authorization policy identifier does not match the active policy"); @@ -289,6 +289,11 @@ synchronized AuthorizationOperationResult applyAuthorization( "## 授权操作执行结果\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, @@ -412,10 +417,16 @@ public NotificationPolicy acceptNotification( if (server == null) { throw new IllegalStateException("A2A-T server validator is unavailable"); } - filled = server.validateNotificationPromptAndDataFilling( - prompt, - validationSchema(StandardTemplates.SERVICE_RECOVERY), - StandardTemplates.SERVICE_RECOVERY.uri()); + 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, 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 71384d63..e31a8f24 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 @@ -56,7 +56,7 @@ private NegotiationReply answer(NegotiationRequest request) { 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", "OMC要求补充或修正的投诉诊断字段名称;仅提取实际请求的字段,不臆造字段"), + "description", "逐字提取所需信息项中编号后、冒号前的字段名称,例如任务对象、任务上下文;冒号后的补充要求是说明,不得改写为字段名称"), "relationship", Map.of("type", "string", "nullable", true, "description", "请求字段之间的逻辑关系;必须一起提供时为AND,明确为替代项时为OR,未说明可为空"))); FilledParamData validated = client.validateProposePromptAndDataFilling(prompt, context, schema, template); @@ -67,9 +67,12 @@ private NegotiationReply answer(NegotiationRequest request) { 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 (Object item : requested) { - if (!(item instanceof String name) || name.isBlank()) return abort(context, "协商字段名称无效"); + for (String name : names) { if (!mayProvideField(request.task(), name)) return reject(context, "业务策略不允许提供:" + name); String value = switch (name) { case "接入端口名称" -> field(data.get("任务对象"), "接入端口名称"); @@ -85,6 +88,22 @@ private NegotiationReply answer(NegotiationRequest request) { return new NegotiationReply.Send(A2atMessages.from(reply, List.of(new TextPart("补充诊断信息")))); } + private static List requestedFieldNames(String prompt) { + var section = Pattern.compile("(?m)^##[ \\t]+所需信息项[ \\t]*\\r?$").matcher(prompt); + if (!section.find()) return List.of(); + int end = prompt.indexOf("\n## ", section.end()); + String body = prompt.substring(section.end(), end < 0 ? prompt.length() : end); + List names = new ArrayList<>(); + var item = Pattern.compile("(?m)^[ \\t]*(\\d+)\\.[ \\t]*([^::\\r\\n]+)[::]").matcher(body); + while (item.find()) { + String name = item.group(2).strip(); + if (!Integer.toString(names.size() + 1).equals(item.group(1)) + || name.isEmpty() || names.contains(name)) return List.of(); + names.add(name); + } + return names; + } + private NegotiationReply abort(NegotiationContext context, String reason) { MetadataContent reply = client.generateNegotiationAbortPromptFromData( new NegotiationAbortData(context, new NegotiationAbortContent(reason)), 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 f9aea3bc..b52469aa 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 @@ -46,6 +46,13 @@ void defaultLocalDemoNegotiatesOnlyCity1WithoutChangingGlobalSwitch() throws Exc 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")); 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 21a4a749..0d793979 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 @@ -54,12 +54,12 @@ void sdkDefinedDeleteOperationAndSelectorAreAcceptedAsACommand() { AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.DELETE, AuthorizationPolicy.POLICY_LIST_FIELD, - "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"); + "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"); assertEquals(AuthorizationPolicy.DELETE, AuthorizationPolicy.operationFromValidated(data)); assertEquals( "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", - AuthorizationPolicy.requirePolicyList(data)); + AuthorizationPolicy.policyIdSelector(data)); } @Test @@ -80,11 +80,11 @@ void sdkCanonicalPermanentAndLimitedRulesAreSupported() { AuthorizationPolicy permanent = AuthorizationPolicy.fromValidated(Map.of( AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, AuthorizationPolicy.POLICY_LIST_FIELD, - "载波调度,业务抢通,载波调度,永久生效")); + "1. 业务场景是载波调度,处置类型是业务抢通,操作名称是载波调度,有效期是永久生效")); AuthorizationPolicy limited = AuthorizationPolicy.fromValidated(Map.of( AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, AuthorizationPolicy.POLICY_LIST_FIELD, - "业务投诉诊断,业务抢通,隧道调优,2026-06-01~2030-06-18")); + "1. 业务场景是业务投诉诊断,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-06-01~2030-06-18")); assertTrue(permanent.authorizes( "载波调度", "业务抢通", "载波调度", LocalDate.of(2099, 1, 1))); @@ -102,6 +102,44 @@ void oldSlashSeparatedRuleIsRejected() { "业务投诉诊断/业务抢通/隧道调优/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( @@ -109,6 +147,6 @@ void currentModifyShapeIsNotMisreadAsAnAddRule() { () -> AuthorizationPolicy.fromValidated(Map.of( AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.MODIFY, AuthorizationPolicy.POLICY_LIST_FIELD, - "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,永久生效"))); + "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效"))); } } 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 eeab8b80..7856e94a 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 @@ -80,7 +80,7 @@ void sdkExtractionMustExactlyMatchValuesInRenderedSections() { "## 授权策略的操作类型\n" + "新增授权策略\n\n" + "## 动网操作的授权策略列表\n" - + "业务投诉诊断,业务抢通,隧道调优,2026-06-01~2030-06-18\n"; + + "1. 业务场景是业务投诉诊断,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-06-01~2030-06-18\n"; Map exact = SpnCasePrompts.addAuthorizationData(); PrePositionedExtensionHandler.requireExtractedSectionsMatch( @@ -144,7 +144,7 @@ void queryIsSideEffectFreeAndDeleteClearsTheDemoWhitelist() { AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.DELETE, AuthorizationPolicy.POLICY_LIST_FIELD, - "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"), + "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"), "template", "test"); assertNull(handler.getAuthorizationPolicy()); @@ -180,12 +180,52 @@ void deleteMustTargetTheExactActivePolicyIdentifier() { AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.DELETE, AuthorizationPolicy.POLICY_LIST_FIELD, - "different-policy-id"), + "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c4"), "template", "test")); assertSame(existing, handler.getAuthorizationPolicy()); } + @Test + void aMalformedSecondRuleCannotPartiallyReplaceTheExistingWhitelist() { + PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); + handler.applyAuthorization(SpnCasePrompts.addAuthorizationData(), "template", "test"); + AuthorizationPolicy existing = handler.getAuthorizationPolicy(); + String rules = SpnCasePrompts.addAuthorizationData().get(AuthorizationPolicy.POLICY_LIST_FIELD) + + "\n2. 业务场景是其他,处置类型是业务抢通,操作名称是任意操作,有效期是2026-02-30~2030-06-18"; + assertThrows(IllegalArgumentException.class, () -> handler.applyAuthorization( + Map.of(AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.ADD, + AuthorizationPolicy.POLICY_LIST_FIELD, rules), "template", "test")); + assertSame(existing, handler.getAuthorizationPolicy()); + } + + @Test + void unsupportedQueryConditionsAreNotSilentlyIgnored() { + PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); + handler.applyAuthorization(SpnCasePrompts.addAuthorizationData(), "template", "test"); + AuthorizationPolicy existing = handler.getAuthorizationPolicy(); + assertThrows(UnsupportedOperationException.class, () -> handler.applyAuthorization( + Map.of(AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.QUERY, + AuthorizationPolicy.POLICY_LIST_FIELD, "1. 业务场景是其他"), + "template", "test")); + assertSame(existing, handler.getAuthorizationPolicy()); + } + + @Test + void deleteCannotTreatBareIdsOrMultipleSelectorsAsTheActiveIdentifier() { + PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); + handler.applyAuthorization(SpnCasePrompts.addAuthorizationData(), "template", "test"); + AuthorizationPolicy existing = handler.getAuthorizationPolicy(); + String id = "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3"; + for (String selector : List.of(id, "1. 业务场景是业务投诉诊断", + "1. 策略标识是" + id + "\n2. 策略标识是" + id)) { + assertThrows(IllegalArgumentException.class, () -> handler.applyAuthorization( + Map.of(AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.DELETE, + AuthorizationPolicy.POLICY_LIST_FIELD, selector), "template", "test")); + } + assertSame(existing, handler.getAuthorizationPolicy()); + } + @Test void modifyRequiresAnIntegratingPolicyStoreAndKeepsTheExistingWhitelist() { PrePositionedExtensionHandler handler = new PrePositionedExtensionHandler(); @@ -199,7 +239,7 @@ void modifyRequiresAnIntegratingPolicyStoreAndKeepsTheExistingWhitelist() { AuthorizationPolicy.OPERATION_TYPE_FIELD, AuthorizationPolicy.MODIFY, AuthorizationPolicy.POLICY_LIST_FIELD, - "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,永久生效"), + "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效"), "template", "test")); assertSame(existing, handler.getAuthorizationPolicy()); 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 aa3f604b..a2591646 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 @@ -65,6 +65,26 @@ class NegotiationStrategyTest { assertTrue(reply.content().metadata().get(A2ATExtension.NEGOTIATION_T.uri()).toString().contains("业务策略不允许提供")); } + @Test void llmDescriptionParaphraseCannotRenameTheRequestedField() { + var reply = assertInstanceOf(NegotiationReply.Send.class, + new NegotiationStrategy(env).resolve(request(SpnCasePrompts.privateLineComplaintData(), + List.of(new NegotiationItem("任务对象", "请提供本地市实际接入端口名称")))).join()); + assertEquals(NegotiationPerformative.ACCEPT, + A2atMessages.contextOf(new ReceivedMessage(reply.content(), Map.of(), List.of())).performative()); + String prompt = reply.content().metadata().get(A2ATExtension.NEGOTIATION_T.uri()).toString(); + assertTrue(prompt.contains("任务对象:")); + assertTrue(prompt.contains("P781-")); + assertFalse(prompt.contains("任务上下文:")); + } + + @Test void unknownLiteralItemCannotBeReplacedByARecognizableDescription() { + var reply = assertInstanceOf(NegotiationReply.Send.class, + new NegotiationStrategy(env).resolve(request(SpnCasePrompts.privateLineComplaintData(), + List.of(new NegotiationItem("其他用户的信息", "本地市实际接入端口名称")))).join()); + assertEquals(NegotiationPerformative.ABORT, + A2atMessages.contextOf(new ReceivedMessage(reply.content(), Map.of(), List.of())).performative()); + } + private String answer(Map input) { var reply = assertInstanceOf(NegotiationReply.Send.class, new NegotiationStrategy(env).resolve(request(input)).join()); @@ -72,11 +92,15 @@ private String answer(Map input) { } private NegotiationRequest request(Map input) { + return request(input, List.of(new NegotiationItem("接入端口名称", "补充实际端口"), + new NegotiationItem("投诉分类", "补充实际分类"))); + } + + private NegotiationRequest request(Map input, List items) { var sdk = A2ATInitialization.create(() -> new A2ATClient(java.nio.file.Path.of(env))); var generated = sdk.generateNegotiationProposePromptFromData(new NegotiationProposeData( new NegotiationContext(UUID.randomUUID().toString(), 1, 3, NegotiationPerformative.PROPOSE), - new InformationProposeContent(List.of(new NegotiationItem("接入端口名称", "补充实际端口"), - new NegotiationItem("投诉分类", "补充实际分类")), "AND")), + 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") 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 975cffea..9db0d822 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 @@ -120,7 +120,9 @@ private static Map validationParams(Map outputProperties, for (Object rawName : properties.keySet()) { String name = String.valueOf(rawName); if ("items".equals(name)) { - values.put(name, negotiationItems(source).keySet().stream().toList()); + // 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)) { @@ -151,6 +153,19 @@ private static boolean verdict(Map outputProperties, Map p 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; } From ad205f42a9400e67fe7a1f8d9a12ea3c820a182f Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 31 Aug 2026 09:41:53 +0800 Subject: [PATCH 48/49] docs: refresh callback contracts and SPN integration guidance Signed-off-by: Zhoujie628 --- CHANGELOG.md | 11 + README.md | 81 +- docs/en/API_REFERENCE.md | 451 +++------- docs/en/BUSINESS_CALLBACKS.md | 276 ++++++ docs/en/DESIGN.md | 279 +++---- docs/en/DEVELOPER_GUIDE.md | 160 ++-- docs/en/INTEGRATION_GUIDE.md | 272 ++---- docs/zh/A2AT-SDK-DEPENDENCY.md | 161 +++- docs/zh/API_REFERENCE.md | 431 +++------- docs/zh/BUSINESS_CALLBACKS.md | 312 +++++++ docs/zh/DESIGN.md | 257 ++---- docs/zh/DEVELOPER_GUIDE.md | 142 ++-- docs/zh/INTEGRATION_GUIDE.md | 281 +++---- .../\344\270\232\345\212\241\346\265\201.md" | 8 + ...06\346\210\220\346\214\207\345\215\227.md" | 786 ++++++------------ 15 files changed, 1742 insertions(+), 2166 deletions(-) create mode 100644 docs/en/BUSINESS_CALLBACKS.md create mode 100644 docs/zh/BUSINESS_CALLBACKS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 37015735..b68f9c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased — business callback boundary + +- Use published A2A-T 1.1.0 from Maven Central; remove SDK source checkout/install from CI; move content generation, validation, templates and SDK initialization to the host. +- onTask returns final MessageContent; onNegotiation returns Send/Stop. Remove engine profiles and content handlers. +- Preserve complete ReceivedMessage and metadata layers alongside deterministic convenience outputs; keep local nested multi-output values. +- Deduplicate negotiation rounds, separate resource budgets from protocol rounds, and suppress late sends after timeout/cancellation. +- Separate authorization/notification lifecycle from workflow outcomes; never synthesize a subscription ACK. +- Observe serialized HTTP/JSON-RPC, real gRPC metadata/protobuf and dev vendor SDK traffic with mandatory redaction and bounded SSE. +- Add local missing-port SpringSpnDemo negotiation tests for direct and dev Order; real LLM/platform/OMC validation remains separate. +- Refresh bilingual callback, architecture and integration contracts. No older SDK compatibility layer. + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres diff --git a/README.md b/README.md index 4cb0db30..6026cd55 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,18 @@ A standalone SDK for executing multi-agent workflows over the [A2A protocol](https://a2aproject.github.io/a2a-java/) with [A2A-T](https://projects.tmforum.org/a2aproject/telecommunication/extensions/) telecom extensions. -The engine handles all protocol mechanics: A2A message transport, SSE streaming, Task-T prompt generation, Negotiation-T auto-loop, authentication, and TLS. You implement only business decisions. +The engine handles workflow scheduling, A2A envelopes, transport, task waiting, authentication and TLS. Host callbacks return final message content and own any A2A-T generation, schema, validation or LLM calls. ## Features - **A2A-T Extension Support**: Task-T (structured task prompts), Negotiation-T (stateless auto negotiation loop), Authorization-T (independent whitelist operation), Notification-T (independent long-lived SSE subscription) -- **Negotiation Content Layer**: SDK-template rendering for propose / accept / reject / abort messages (typed-data and free-text variants), validate-and-fill param extraction across all three negotiation types (information / target / feasibility), round-exhaustion abort flow -- **Template Queries**: runtime enumeration of all A2A-T prompt templates (`getPrompts` / `getNegotiationPrompts` / `getPrompt`) +- **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**: Sanitized request/response summaries; protocol bodies are logged only with explicit opt-in +- **Protocol Logging**: actual HTTP/JSON-RPC boundaries, gRPC metadata/protobuf views and dev Order SDK observations; bodies default on at DEBUG with mandatory secret-field redaction The SPN sample treats the protocol document as an input, not as executable truth. The pinned A2A-T SDK templates, slot schemas, canonical URIs, and validation results are authoritative. Protocol @@ -34,20 +34,11 @@ explicit long-lived subscription until the recovery result, cancellation, or shu ### 1. Add Maven dependency -This revision intentionally targets an A2A-T SDK build that has not yet been published to Maven -Central. Install the pinned SDK revision first (do not use an older locally cached `1.0.0`): - -```bash -git clone https://github.com/project-openan/a2a-t-sdk-java.git -cd a2a-t-sdk-java -git checkout 0de5a2751781419820436e5eb17cffc39b9db47d -mvn -B -Drevision=1.0.0-0de5a27 -DskipTests \ - -pl a2a-t-client,a2a-t-server -am install -``` - -The engine POM and CI both pin that commit and immutable commit-derived version, preventing Maven from -silently resolving an older, API-incompatible jar. See the -[A2A-T SDK dependency guide](docs/zh/A2AT-SDK-DEPENDENCY.md) for Windows commands and upgrade steps. +A2A-T SDK `1.1.0` is published to Maven Central. Maven resolves it automatically; +no SDK source checkout or local SDK build is required. The engine depends only on +`a2a-t-core`; hosts using content generation explicitly add `a2a-t-client:1.1.0` +(and `a2a-t-server:1.1.0` for receiving-side validation). +See the [A2A-T SDK dependency guide](docs/zh/A2AT-SDK-DEPENDENCY.md) for IDEA setup and upgrade checks. ```xml @@ -84,31 +75,17 @@ List agentCards = registry.fetchAgentCards(); A2ATransport transport = new A2ATransport(agentCards, null, WorkflowEngineClientConfig.builder() .sslVerify(true) - .a2atEnvPath(".env") .credentialsConfigPath("credentials.json") .build()); WorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); -// 4. Implement ControlPoint (business decisions only) -ControlPoint controlPoint = new DefaultControlPoint() { - @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher dispatcher) { - return dispatcher - .dispatch(TaskSubmission.fromText( - request.getAgentName(), request.getMessage(), - StandardTemplates.PRIVATE_LINE_COMPLAINT)) - .thenApply(r -> { - String state = r.getTaskState(); - boolean success = state == null || state.isBlank() - || "TASK_STATE_COMPLETED".equals(state); - return TaskResponse.builder() - .success(success) - .output(r.getText()) - .build(); - }); - } -}; +// 4. Plain A2A example; use host A2A-T SDK generation for Task-T. +ControlPoint controlPoint = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .build(); +// Implement local, conditional-route and negotiation callbacks as needed. +// Complete contract: docs/en/BUSINESS_CALLBACKS.md // 5. Execute ExecutionResult result = ExecutePsop.builder() @@ -125,6 +102,8 @@ System.out.println("Success: " + result.isSuccess()); ``` +Final content callbacks, complete dependency inputs and negotiation Send/Stop: [English](docs/en/BUSINESS_CALLBACKS.md) / [中文](docs/zh/BUSINESS_CALLBACKS.md). + ## Architecture ```mermaid @@ -168,9 +147,9 @@ graph TD | Package | Key Classes | Description | |---------|-------------|-------------| | `client` | `WorkflowEngineClient`, `DefaultWorkflowEngineClient`, `ExtensionSender`, `A2ATransport`, `AuthProvider`, `AgentAuthManager`, `WorkflowEngineClientConfig`, `CredentialCrypto`, `AgentCardJacksonModule` | A2A transport, auth, extensions (package-private internals) | -| `control` | `ControlPoint`, `DefaultControlPoint`, `TaskDispatcher`, `EventCallback`, `EventType`, `NegotiationStrategy` | User-facing decision interfaces | +| `control` | `ControlPoint`, `DefaultControlPoint`, `EventCallback`, `EventType`, `NegotiationStrategy` | User-facing decision interfaces | | `core` | `WorkflowExecutor`, `ContextBuilder` | DAG traversal, context assembly | -| `model` | `Workflow`, `WorkflowStep`, `Task`, `TaskRequest`, `TaskSubmission`, `TaskResponse`, `NegotiationRequest`, `NegotiationDecision`, `ExecutionResult` | Data models | +| `model` | `Workflow`, `WorkflowStep`, `Task`, `TaskRequest`, `MessageContent`, `TaskResult`, `NegotiationRequest`, `NegotiationReply`, `ExecutionResult` | Data models | | `registry` | `LoadPsop`, `RegistryClient` | PSOP loading and AgentCard registry | | `runner` | `ExecutePsop` | Entry point for workflow execution | @@ -204,3 +183,23 @@ graph TD ## License [Apache License 2.0](LICENSE) + +### Inspect negotiation in the local Demo + +Running the local SpringSpnDemo without VM options now negotiates missing City1 input; City2 diagnoses directly. +This is a local sample scenario, not an engine default. To use complete inputs in both cities, add this IDEA **VM option**: + +```text +-Da2at.samples.negotiation=false +``` + +By default only City1 loses its Task-T task object. Add `-Da2at.samples.negotiation.city=city2` or `both` +to exercise City2 or both cities. The host retains city-scoped authoritative input and the complaint context is preserved. +Expect DEMO_NEGOTIATION → INPUT_REQUIRED/PROPOSE → onNegotiation → ACCEPT → both diagnoses → one aggregate. +External-OMC mode defaults to no injection and rejects an explicit true switch. +The Demo passes its setting into the current Spring application context without modifying JVM-wide properties. +Protocol observations are in the console and `logs/spn-demo.log` relative to the run directory. + +Run `SpringSpnDemoE2ETest` (direct) and, on dev, `SpringSpnDemoOrderE2ETest` sequentially. +Each tests the no-VM-option single-city default, explicit disable/enable and both-city negotiation with current SDK resources and an offline LLM provider. +This is local protocol E2E evidence, not real model/platform/OMC validation. diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index 7996e731..d5ec5252 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -29,7 +29,6 @@ Entry point for executing a PSOP workflow. Uses the Builder pattern. | `engineClient(WorkflowEngineClient)` | optional | null | Pre-configured client (null = auto-create) | | `runtimeIntent(String)` | optional | `""` | Natural-language intent for context assembly | | `lang(String)` | optional | `"zh"` | Language hint (`"zh"` or `"en"`) | -| `a2atEnvPath(String)` | optional | null | Path to `.env` file for A2A-T SDK | | `credentialsConfigPath(String)` | optional | null | Path to credentials JSON file | | `sslVerify(boolean)` | optional | `true` | Whether to verify TLS certificates | | `caCertsPath(String)` | optional | null | Path to CA certificates PEM file | @@ -44,7 +43,6 @@ ExecutionResult result = ExecutePsop.builder() .agentCards(cards) .controlPoint(cp) .runtimeIntent("diagnose fault") - .a2atEnvPath(".env") .sslVerify(false) .execute() .get(10, TimeUnit.MINUTES); @@ -58,204 +56,44 @@ ExecutionResult result = ExecutePsop.builder() ### WorkflowEngineClient -Primary interface for sending A2A messages to agents. - ```java -public interface WorkflowEngineClient { - // Send a message with optional context ID and preset metadata - CompletableFuture sendMessage( - String agentName, String message, - String contextId, Map metadata); - - // Convenience: no context ID, no metadata - CompletableFuture sendMessage( - String agentName, String message); - - void setControlPoint(ControlPoint controlPoint); - - void setEventCallback(EventCallback callback); - - // A2A-T template queries (empty when the SDK is not configured; never throws) - List getPrompts(); // all extensions' templates - List getNegotiationPrompts(); // negotiation templates (3 types x 2 phases + abort) - Optional getPrompt(TemplateUri); // single template by URI - - // Task lifecycle (A2A protocol operations) - CompletableFuture getTask(String agentName, String taskId); - CompletableFuture cancelTask(String agentName, String taskId); - CompletableFuture subscribeToTask( - String agentName, String taskId, Consumer> callback); - - void close(); -} +CompletableFuture dispatch(TaskRequest request, MessageContent content, ControlPoint callbacks); +CompletableFuture sendMessage(String agentName, MessageContent content); +CompletableFuture getTask(String agentName, String taskId); +CompletableFuture cancelTask(String agentName, String taskId); +CompletableFuture subscribeToTask(String agentName, String taskId, Consumer> callback); +long callbackTimeoutSeconds(); +void setControlPoint(ControlPoint callbacks); +void setEventCallback(EventCallback callback); +void close(); ``` -> Pre-positioning lives on `ExtensionSender`, not here: Authorization-T is a one-shot request and Notification-T is a -> long-lived subscription. See the `ExtensionSender` section below. - -#### sendMessage - -| Parameter | Type | Description | -|-------------|-----------------------|-----------------------------------------------| -| `agentName` | `String` | Target agent name (must match AgentCard.name) | -| `message` | `String` | Full assembled message text | -| `contextId` | `String` | Optional context ID (null = auto-generated) | -| `metadata` | `Map` | Optional preset metadata | - -**Returns:** `CompletableFuture` containing response text, task, metadata, and task state. - -The engine internally handles before sending: - -1. Task-T prompt generation (if AgentCard declares Task-T) -2. Negotiation-T metadata injection (for follow-up messages) -3. Auth header injection (from credentials or AuthProvider) -4. A2A-Extensions header (only extensions present in metadata) - -After receiving: - -1. Response text extraction from SSE events -2. Metadata extraction (task-level + artifact-level) -3. Negotiation-T auto-loop (triggered when metadata carries the Negotiation-T key; see "Negotiation auto-loop behavior" below) +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. -#### sendMessageFromData (structured-data input path) +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. -```java -CompletableFuture sendMessageFromData( - String agentName, - String message, - Map data, - Map schema, - TemplateUri templateUri); -``` - -| Parameter | Type | Description | -|---------------|-----------------------|---------------------------------------------------------------------| -| `agentName` | `String` | Target agent name | -| `message` | `String` | Short accompanying message text (A2A message parts text) | -| `data` | `Map` | Structured business data (raw fields, not a rendered prompt) | -| `schema` | `Map` | JSON schema describing what each data field means | -| `templateUri` | `TemplateUri` | Explicit current SDK target template; must not be null | - -**Choosing a track**: when the caller holds structured data (e.g. complaint-ticket fields) prefer -this method — the SDK uses the schema-aware -`generateTaskPromptFromDataWithSchema` pipeline. It bypasses scenario recognition, but slot -mapping may still invoke the SDK-configured LLM. With free-form -natural-language input use `sendMessage` (the SDK scenario-recognition pipeline). Both tracks run -through the full Negotiation-T auto-loop, auth, and extension-header injection. - -`ExtensionSender.sendExtensionMessageFromData(agentName, instruction, data, schema, templateUri, extension)` -provides the same structured-data track for independent Authorization-T operations; -`openNotificationFromData` is the preferred structured Notification-T subscription API. All -Notification-T entry points require an explicit SDK template so callers can select either -`SERVICE_RECOVERY` or `SUBSCRIBE_INCIDENT`. - -#### Negotiation auto-loop behavior - -- **Trigger**: the response metadata carries the Negotiation-T extension key (the agent opened a - negotiation via `Message(ROLE_AGENT)` + `INPUT_REQUIRED`) together with SDK-generated - `templateUri` and `negotiationContext={id,round,maxRounds,performative}`. Missing fields fail closed. -- **Each round**: `validateProposePromptAndDataFilling` validates and extracts the Propose → - `ControlPoint.onNegotiation` decides → the SDK content layer renders Accept / Reject / Abort → - Accept is checked with `validateAcceptPromptAndDataFilling` before sending → the engine copies - the ending message preserves the received Propose `NegotiationContext` and round → send → recurse. -- **Typed decisions** (the `onNegotiation` return value): - - `NegotiationDecision.acceptText/acceptData` — Accept through SDK fromText/fromData - - `NegotiationDecision.rejectText/rejectData` — Reject through SDK fromText/fromData - - `NegotiationDecision.abortText/abortData` — Abort through SDK fromText/fromData - - String control prefixes such as `data:`, `reject:`, and `abort:` are not accepted. - - Information Reject is itemized: every `rejectData` key names an unavailable requested item and - its value gives that item's concrete non-provision reason. Do not merge several requested items - into one aggregate rejection reason. -- **Round exhaustion**: beyond `maxNegotiationRounds` the loop stops, a terminal message is sent - via the SDK abort template (best effort — delivery failures are logged only), a - `NEGOTIATION_FAILED` event fires, and the last agent reply stands as the final response. -- **Negotiation context**: the engine does not support the legacy `startNegotiation`, - `receiveNegotiation`, or `continueNegotiation` state-machine entry points. It accepts only - canonical `negotiationContext` with - `{id, round, maxRounds, performative}` and does not read legacy `negotiation_context` metadata. - -#### Template queries - -The template queries expose the SDK content layer's template catalog and return -`PromptTemplate` (templateUri / description / content): - -- `getPrompts()`: every A2A-T extension's (Task-T / Notification-T / Authorization-T / - Negotiation-T) loadable templates for the configured language (`.env` `A2AT_LANGUAGE`), sorted by URI. -- `getNegotiationPrompts()`: the full negotiation set (propose and accept-reject for information, - target and feasibility negotiation, plus the common abort template), fixed order. -- `getPrompt(uri)`: one template by `TemplateUri`; empty when missing. - -Use the SDK's `StandardTemplates` constants for template URIs (e.g. -`StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`) instead of hand-written URI strings. +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 -Independent-protocol facade over caller-owned, dedicated `A2ATransport` instances. It sends one-shot -Authorization-T requests and establishes long-lived Notification-T subscriptions at independent -business times; neither operation is a workflow DAG node. It bypasses Task-T prompt -generation and the Negotiation-T auto-loop; later Notification-T events are delivered through the subscription callback. - ```java -public interface ExtensionSender { - CompletableFuture sendExtensionMessage( - String agentName, String instruction, - String naturalLanguageInput, TemplateUri templateUri, - A2ATExtension extension); // Authorization-T only - - // Convenience: Authorization-T - CompletableFuture sendAuthorization( - String agentName, String instruction, String naturalLanguageInput); - - CompletableFuture sendExtensionMessageFromData( - String agentName, String instruction, - Map data, Map schema, - TemplateUri templateUri, A2ATExtension extension); - - // Convenience: Notification-T (long-lived SSE) - CompletableFuture sendNotification( - String agentName, String instruction, String naturalLanguageInput, - TemplateUri templateUri, Consumer> eventCallback); - - // Interface contract: Notification-T (long-lived SSE + event callback) - CompletableFuture openNotification( - String agentName, String instruction, - String naturalLanguageInput, TemplateUri templateUri, - Consumer> eventCallback); - - CompletableFuture openNotificationFromData( - String agentName, String instruction, - Map data, Map schema, - TemplateUri templateUri, - Consumer> eventCallback); -} +CompletableFuture sendAuthorization(String agentName, MessageContent content); +NotificationSubscription openNotification(String agentName, MessageContent content, + BiConsumer listener); ``` -| Parameter | Type | Description | -|------------------------|-----------------|-------------------------------------------------| -| `agentName` | `String` | Target agent name (must match `AgentCard.name`) | -| `instruction` | `String` | Short instruction text; becomes `parts[].text` in the A2A message body | -| `naturalLanguageInput` | `String` | Natural-language track input; production should prefer the SDK-rendered structured-data methods | -| `templateUri` | `TemplateUri` | Explicit current-SDK template (bundled or caller-provided resource) | -| `extension` | `A2ATExtension` | Extension enum (never hardcode URIs) | -| `eventCallback` | `Consumer>` | Optional SSE event callback. The stable Map includes `event_kind`, `agent`, `task_id` and event-specific state/artifact fields | - -`NotificationSubscription` separates the initial ACK (`acknowledgement()`) from stream completion -(`completion()`), and exposes `heartbeat()`, `isHealthy(maximumIdle)`, and idempotent `close()`. -Notification-T is deliberately rejected by the generic one-shot methods; use -`openNotification` / `openNotificationFromData` so the lifecycle handle cannot be lost. -Task, Authorization, and Notification must use different transport/runtime instances. Completing a -workflow must not close a Notification-T subscription. - -**Wire format**: The resulting A2A message sent to the agent has `parts[].text = instruction` and `metadata = { "": "" }`. For example, Authorization-T produces: - -```json -{ - "parts": [{"text": "Authorize diagnosis operations"}], - "metadata": { - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1": "" - } -} -``` +Authorization and notification accept host-generated final content. Use separate transport/runtime/context instances; their outcomes do not gate the workflow. openNotification registers a handle before I/O, and passes it plus ReceivedMessage directly to the listener. acknowledgement() is the real ACK; timeout fails. close() requests closure; completion() observes actual stream termination. ### WorkflowEngineClientConfig @@ -276,11 +114,9 @@ Builder-based configuration for the workflow engine client. | `sendExecutorQueueCapacity` | `int` | `256` | Bounded send executor queue capacity | | `authProvider` | `AuthProvider` | null | Custom auth provider | | `credentialsConfigPath` | `String` | null | Path to credentials JSON; explicit missing or malformed files fail startup | +| `credentialEncryptionKey` | `String` | null | Host-supplied decryption key; never loaded from LLM .env | | `credentialsConfig` | `Map` | null | Inline credentials config; must match AgentCard security requirements | -| `a2atEnvPath` | `String` | null | Path to `.env` file | -| `maxNegotiationRounds` | `int` | `3` | Max negotiation auto-loop rounds | -| `customHandlers` | `List` | null | Custom extension handlers | -| `negotiationParamSchema` | `Map` | null | Business parameter JSON schema for the negotiation validate-and-fill; null = empty schema (context params only). Declaring your domain fields keeps the engine core domain-agnostic | +| `maxNegotiationExchanges` | `int` | `3` | Local interaction budget, independent of SDK maxRounds | ```java WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() @@ -289,9 +125,8 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .clientCertPath("client-cert.pem") .clientKeyPath("client-key.pem") .sendTimeoutSeconds(900) - .a2atEnvPath(".env") .credentialsConfigPath("creds.json") - .maxNegotiationRounds(5) + .maxNegotiationExchanges(5) .authProvider(myProvider) .build(); ``` @@ -309,49 +144,9 @@ public interface AuthProvider { Called for every message send. The `headers` map is mutable; add `Authorization`, custom headers, etc. `AuthProvider` can be the sole authentication source, including when `securityRequirements` is non-empty and credentials are not configured. When credentials are also configured, both sets are computed independently and merged; different values for the same header name throw `SecurityException` instead of silently overwriting either value. -### ExtensionHandler - -Extension handler for custom A2A-T extensions. - -```java -public interface ExtensionHandler { - String extensionKeyword(); - - CompletableFuture> beforeSend( - AgentCard agentCard, String messageText, - Map metadata, - A2ATClient a2atClient, ControlPoint controlPoint); - - CompletableFuture afterReceive( - AgentCard agentCard, SendMessageResult result, - A2ATClient a2atClient, ControlPoint controlPoint, - EventCallback eventCallback); - - // Business parameter schema for the negotiation validate-and-fill (default: empty schema) - Map negotiationParamSchema(); -} -``` - -Built-in: Task-T and Negotiation-T are handled automatically. You can register custom handlers via customHandlers in the -config (a later registration with the same keyword overrides the built-in). +### A2atMessages -### A2ATContentFacade - -Engine facade over the SDK's negotiation content layer, obtained via -`A2ATransport.getContentFacade()` (null when no `.env` is configured). It exposes the SDK's full -message-generation / validation / template-query surface, one method per `A2ATClient` counterpart: - -| Group | Methods | Notes | -|-------|---------|-------| -| fromData generation | `generateProposeFromData` / `generateAcceptFromData` / `generateRejectFromData` / `generateAbortFromData` | deterministic rendering from typed data, no LLM | -| fromText generation | `generateProposeFromText` / `generateAcceptFromText` / `generateRejectFromText` / `generateAbortFromText` | free text + one LLM extraction step | -| Stateless context | `MetadataContent.buildMetadataContent` / `NegotiationContext` | carry `{id, round, maxRounds, performative}`; generation stamps the output performative | -| Validate and fill | `validatePropose` / `validateAccept` / `validateReject` / `validateAbort` | rule gate + LLM semantic validation + param merge; requires a `NegotiationContext` (null = not a negotiation message) | -| Template queries | `getPrompts` / `getNegotiationPrompts` / `getPrompt` / `getNegotiationPrompt` | catalog enumeration and lookup | -| Utility | `toMetadata(MetadataContent)` | generated message → A2A metadata map (with `templateUri`, `negotiationContext` keys) | - -Pass template URIs via `StandardTemplates` constants. Failures throw `A2ATError` subtypes and must -fail closed: callers must never degrade by placing raw text under an A2A-T extension URI. +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 @@ -424,80 +219,21 @@ Map normalized = AgentCardNormalizer.normalize(rawMap); ## dev.openan.workflow.engine.control -### TaskDispatcher / TaskSubmission - -`onTask` receives only the narrow `TaskDispatcher` capability. Business code uses: - -```java -dispatcher.dispatch(TaskSubmission.fromText(agentName, text, templateUri)); -dispatcher.dispatch(TaskSubmission.fromUnclassifiedText(agentName, text)); -dispatcher.dispatch(TaskSubmission.fromData( - agentName, instruction, data, schema, templateUri)); -``` - -The first form invokes the SDK's explicit natural-language API for a known Task-T template without -reclassifying the scenario; the second invokes SDK scenario recognition only when the template is -genuinely unknown; the third invokes schema-aware fromData. `TaskSubmission` validates the target, instruction, schema, and Task-T template and -defensively copies business data before it reaches the protocol layer. - ### ControlPoint -User-facing decision interface. Each method has a single responsibility. - ```java -public interface ControlPoint { - // Take over a remote task and submit natural-language or structured Task-T input. - CompletableFuture onTask( - TaskRequest request, TaskDispatcher taskDispatcher); - - // Self-loop step: handled locally, no A2A-T message to self. - default CompletableFuture onSelfTask(TaskRequest request); - - // Conditional branch decision. Only decide which step to go to. - CompletableFuture onRoute( - String stepName, Map results, - List conditions); - - // Return a typed Accept / Reject / Abort decision. - default CompletableFuture onNegotiation( - NegotiationRequest request); +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -| Method | When Called | Return | -|-------------------|--------------------------------------------|-----------------------------------| -| `onTask` | A step dispatches a task to another agent | `TaskResponse` (success + output) | -| `onSelfTask` | A `SELF_LOOP` step runs locally (no A2A-T) | `TaskResponse` (success + output) | -| `onRoute` | After step completes, before next step | `RouteDecision` (nextStep) | -| `onNegotiation` | When agent returns `INPUT_REQUIRED` | `NegotiationDecision` | - -`NegotiationRequest` exposes `agentName`, `concern`, `sessionId`, `round`, `maxRounds`, the typed -`NegotiationPerformative`, negotiation `kind`, `templateUri`, read-only SDK-extracted business -`parameters`, and read-only `metadata`. Business decisions should prefer `parameters`; `metadata` -is retained for advanced diagnostics. Only a -`PROPOSE` with a complete context reaches the callback; missing context, invalid rounds, or an -unsupported template fail closed. Business code only returns `acceptText/acceptData`, -`rejectText/rejectData`, or `abortText/abortData`; it does not send a message or construct protocol -metadata. -For an Information Reject, provide a separate reason for every unavailable requested item, for example: - -```java -NegotiationDecision.rejectData(Map.of( - "access-port name", "The current account cannot query the resource system", - "complaint category", "The current account cannot query the resource system")); -``` - -### DefaultControlPoint - -Default implementation with sensible defaults: - -- `onTask`: dispatches `TaskSubmission.fromUnclassifiedText` by default and returns success/output; - hosts with a known template should override it and use `fromText(..., templateUri)` or `fromData(...)` -- `onSelfTask`: echoes the task message back (override for local logic) -- `onRoute`: picks first non-terminal branch -- `onNegotiation`: returns a generic `acceptText` decision - -Extend this class and override only the methods you need. +onTask returns final parts/metadata/extensions; the engine sends them without generating or rewriting content. +onSelfTask returns local TaskResult, onRoute selects an allowed candidate, and onNegotiation returns Send or Stop. +Unimplemented callbacks fail explicitly. No echo-success, first-branch choice or automatic consent. +See [Business callback contract](BUSINESS_CALLBACKS.md) for fields and working examples. ### EventCallback @@ -595,7 +331,7 @@ Map registerAgentCard(Map agentCard) ### WorkflowExecutor -Mid-layer DAG traversal engine. Walks the workflow steps, assembles upstream context via `ContextBuilder`, dispatches subtasks in parallel, applies step success policies (`ALL_SUCCESS` / `ANY_SUCCESS` / `SELF_LOOP`), and routes to the next step. +Mid-layer DAG traversal engine. Walks workflow steps, selects typed upstream execution results through `ContextBuilder`, dispatches subtasks in parallel, applies step success policies (`ALL_SUCCESS` / `ANY_SUCCESS` / `SELF_LOOP`), and routes to the next step. Not typically instantiated directly by SDK users — `ExecutePsop` wraps it internally. Exposed for advanced integrations that need to run the traversal layer without the runner's lifecycle management. @@ -608,7 +344,7 @@ ExecutionResult result = executor.run().join(); ### ContextBuilder -Package-private helper that assembles the context message for each step by folding upstream step outputs according to `contextFrom` rules (`"*"` = all ancestors, specific names = selective inheritance). Not part of the public API surface. +Package-private helper that selects upstream step results and creates a `WorkflowInput` according to `contextFrom` (omitted = direct predecessors, `[]` = no upstream input, `"*"` = all ancestors, explicit names = selective inheritance). It does not render prompts and is not part of the public API surface. --- @@ -632,8 +368,8 @@ Static factory: `Workflow.fromMap(Map)` parses from orchestratio | `name` | `String` | - | Step name (unique within workflow) | | `subtasks` | `List` | `List.of()` | Subtasks dispatched in this step | | `next` | `List` | `List.of()` | Conditional next steps | -| `layer` | `int` | `0` | Context layer (0 = runtime intent only) | -| `contextFrom` | `List` | null | Steps to inherit context from (`"*"` = all predecessors) | +| `layer` | `int` | `0` | Design-time hint; dependency graph selects context | +| `contextFrom` | `List` | null | Aggregation source: omitted = direct predecessors, `[]` = none, `"*"` = all ancestors, or explicit ancestor names | | `stepType` | `StepType` | `ALL_SUCCESS` | Execution mode | ### StepType @@ -670,35 +406,73 @@ Task lifecycle status, used in `TASK_STATUS_CHANGED` events for cross-SDK consis | `step` | `String` | Next step name (`"end"` for terminal) | | `condition` | `String` | Condition expression (`"success"`, `"fail"`, etc.) | -### TaskRequest +### TaskRequest / BusinessInput + +TaskRequest uses getXxx() accessors: + +| Field | Meaning | +|---|---| +| executionId / taskId | Local execution/logical task identities, not remote protocol IDs | +| stepName / agentName / skill | Current workflow step, destination and skill | +| instruction / language | Current instruction only; no appended history | +| input | BusinessInput: exactly one of text or arbitrary JSON-serializable data; no schema | +| workflowInput | WorkflowInput(runtimeIntent, upstreamResults), separate from current input | + +BusinessInput.text(value) / BusinessInput.data(value) create input snapshots. +WorkflowInput, UpstreamStepResult, ReceivedMessage and NegotiationRequest use record field() accessors. + +| contextFrom | Upstream selection | +|---|---| +| absent / null | Available direct predecessors | +| [] | No upstream results; runtimeIntent remains | +| ["*"] | Available ancestors | +| explicit ancestor names | Available named results in declaration order | + +contextFrom selects evidence, not scheduling edges; define dependencies with next. +Unknown/non-ancestor names or mixing "*" with names is invalid. Unselected/inactive branches are not fabricated. +The engine never appends upstream results to instruction/parts or invokes an LLM to map them. +The host decides how to consume the window or map it to the next agent's input. + +The window is stepName → taskResults[] → outputs[] / receivedMessages[]. +TaskExecutionResult also retains agentName, skill, logical taskId, taskDescription, status, +error, errorCode and errorDetails. Multiple subtasks remain distinguishable. +Nested arrays remain one output value; there is no requirement that outputs originated from an LLM. + +### MessageContent / ReceivedMessage + +```java +record MessageContent(List> parts, Map metadata, Set extensions) {} +record ReceivedMessage(MessageContent message, Map taskMetadata, List artifacts) {} +``` + +MessageContent.text(text), MessageContent.parts(parts), or the canonical constructor create snapshots. +TextPart, DataPart and FilePart preserve order and part metadata. File references are not fetched. +MessageContent has no role, target, messageId, taskId, contextId or auth headers. +A business metadata key named contextId does not override the A2A envelope. + +ReceivedMessage preserves message metadata, task metadata and each artifact's identity/parts/metadata separately. +message can be null; metadata-only results remain accessible through the complete view. +For convenience, outputs projects TextPart values and DataPart data from artifacts in order; +if there are no artifacts, a successful final/standalone message can supply these values. +FilePart remains available only in the complete view. No text parsing, adjacent-part concatenation or nested-array flattening. +Failed task status messages are evidence, not outputs; valid partial artifacts are retained. +Streaming append/replace is assembled per artifact; terminal snapshots do not duplicate earlier chunks. + +TaskResult.success(List) represents local output, including an empty list. +TaskResult.failure(code, message) separates failure from output; its builder can retain valid partial outputs. +TaskResult includes receivedMessages for remote evidence; remote convenience outputs derive from that view. +A remote task succeeds only in COMPLETED, or a standalone A2A Message can complete the interaction. +Progress and negotiation are never a successful workflow task merely because they have text. + -| Field | Type | Description | -|----------------|----------|---------------------------| -| `agentName` | `String` | Target agent | -| `skill` | `String` | Agent skill | -| `message` | `String` | Full message text | -| `description` | `String` | Task description | -| `context` | `String` | Context message | -| `stepName` | `String` | Source step name | -| `subtaskIndex` | `int` | Subtask index within step | -### TaskResponse +### NegotiationRequest / NegotiationReply -| Field | Type | Description | -|------------|-----------|----------------------------| -| `success` | `boolean` | Whether the task succeeded | -| `output` | `String` | Response text | -| `error` | `String` | Error message (if failed) | -| `metadata` | `Map` | Response metadata | +See [BUSINESS_CALLBACKS](BUSINESS_CALLBACKS.md). ### SendMessageResult -| Field | Type | Description | -|-------------|----------|------------------------------------------------| -| `text` | `String` | Extracted response text | -| `task` | `Task` | SDK Task object | -| `metadata` | `Map` | Response metadata (merged task + artifact) | -| `taskState` | `String` | Final task state (e.g. `TASK_STATE_COMPLETED`) | +getReceivedMessages() preserves response levels; getOutputs() is a convenience projection. getTask()/getTaskState() retain actual remote state; failureCode/failureMessage describe independent local interaction failure. text and flattened metadata are transport diagnostics, not substitutes for the complete business response. ### ExecutionResult @@ -755,14 +529,11 @@ Canonical negotiation metadata is `templateUri` plus ## Error Handling -- Agent call failures throw `RuntimeException` wrapping the cause. -- Negotiation beyond `maxNegotiationRounds` rounds: a `NEGOTIATION_FAILED` event fires, a terminal - message is sent to the agent via the SDK abort template (best effort), and the last agent reply - stands as the final response; the loop does not continue. -- SDK content-generation failures for negotiation follow-ups fail closed; raw business input is never - disguised as Negotiation-T metadata. -- Missing required credentials or authentication failures fail the request; it is never sent unauthenticated. -- SSE stream errors after terminal events are logged at `DEBUG` level (expected behavior). +- Missing/null/exceptional/timed-out callbacks fail explicitly. Host SDK errors may be mapped to BusinessFailure with safe code/details. +- Exhausted maxNegotiationExchanges or local Stop does not generate Abort. Business Send(Abort) is never diagnosis success. +- Remote state/errors stay separate from outputs; failed status text is evidence, while partial artifacts remain available. +- Missing required credentials reject the request; the engine never silently sends unauthenticated. +- Stream-exit/transport-error observations do not independently define workflow outcome; inspect ExecutionResult and remote task state. --- diff --git a/docs/en/BUSINESS_CALLBACKS.md b/docs/en/BUSINESS_CALLBACKS.md new file mode 100644 index 00000000..5011d43b --- /dev/null +++ b/docs/en/BUSINESS_CALLBACKS.md @@ -0,0 +1,276 @@ +# Business callback integration contract + +Initial-release API; A2A-T SDK `1.1.0`, A2A Java `1.2.0.Final`. +The engine owns DAG scheduling, standard A2A envelopes, auth, transport and task waiting. +The host owns content, schemas, templates, semantic validation and any LLM calls. +Direct OMC and dev's Eastcom forwarding use the same business contracts. + +## 1. Callbacks + +```java +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); +} +``` + +onTask returns final parts/metadata/extensions; the engine sends them without generating or rewriting content. +onSelfTask returns local TaskResult, onRoute selects an allowed candidate, and onNegotiation returns Send or Stop. +Unimplemented callbacks fail explicitly. No echo-success, first-branch choice or automatic consent. +See [Business callback contract](BUSINESS_CALLBACKS.md) for fields and working examples. + +```java +ControlPoint callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .onSelfTask(request -> CompletableFuture.completedFuture( + TaskResult.success(List.of(Map.of( + "sourceResults", request.getWorkflowInput().upstreamResults()))))) + .onRoute(request -> CompletableFuture.failedFuture( + new IllegalStateException("Supply a routing policy for " + request.stepName()))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Manual confirmation required"))) + .build(); +``` + +This example deliberately sends ordinary text, not Task-T. Replace the local projection and route/stop policies +with real business behavior. onTask must not send a second network request itself. +Import `control.ControlPoint` and `model.*` from `dev.openan.workflow.engine`, +plus `java.util.List/Map` and `java.util.concurrent.CompletableFuture`. + +## 2. TaskRequest and dependency input + +TaskRequest uses getXxx() accessors: + +| Field | Meaning | +|---|---| +| executionId / taskId | Local execution/logical task identities, not remote protocol IDs | +| stepName / agentName / skill | Current workflow step, destination and skill | +| instruction / language | Current instruction only; no appended history | +| input | BusinessInput: exactly one of text or arbitrary JSON-serializable data; no schema | +| workflowInput | WorkflowInput(runtimeIntent, upstreamResults), separate from current input | + +BusinessInput.text(value) / BusinessInput.data(value) create input snapshots. +WorkflowInput, UpstreamStepResult, ReceivedMessage and NegotiationRequest use record field() accessors. + +| contextFrom | Upstream selection | +|---|---| +| absent / null | Available direct predecessors | +| [] | No upstream results; runtimeIntent remains | +| ["*"] | Available ancestors | +| explicit ancestor names | Available named results in declaration order | + +contextFrom selects evidence, not scheduling edges; define dependencies with next. +Unknown/non-ancestor names or mixing "*" with names is invalid. Unselected/inactive branches are not fabricated. +The engine never appends upstream results to instruction/parts or invokes an LLM to map them. +The host decides how to consume the window or map it to the next agent's input. + +The window is stepName → taskResults[] → outputs[] / receivedMessages[]. +TaskExecutionResult also retains agentName, skill, logical taskId, taskDescription, status, +error, errorCode and errorDetails. Multiple subtasks remain distinguishable. +Nested arrays remain one output value; there is no requirement that outputs originated from an LLM. + +## 3. Final message and complete response + +```java +record MessageContent(List> parts, Map metadata, Set extensions) {} +record ReceivedMessage(MessageContent message, Map taskMetadata, List artifacts) {} +``` + +MessageContent.text(text), MessageContent.parts(parts), or the canonical constructor create snapshots. +TextPart, DataPart and FilePart preserve order and part metadata. File references are not fetched. +MessageContent has no role, target, messageId, taskId, contextId or auth headers. +A business metadata key named contextId does not override the A2A envelope. + +ReceivedMessage preserves message metadata, task metadata and each artifact's identity/parts/metadata separately. +message can be null; metadata-only results remain accessible through the complete view. +For convenience, outputs projects TextPart values and DataPart data from artifacts in order; +if there are no artifacts, a successful final/standalone message can supply these values. +FilePart remains available only in the complete view. No text parsing, adjacent-part concatenation or nested-array flattening. +Failed task status messages are evidence, not outputs; valid partial artifacts are retained. +Streaming append/replace is assembled per artifact; terminal snapshots do not duplicate earlier chunks. + +TaskResult.success(List) represents local output, including an empty list. +TaskResult.failure(code, message) separates failure from output; its builder can retain valid partial outputs. +TaskResult includes receivedMessages for remote evidence; remote convenience outputs derive from that view. +A remote task succeeds only in COMPLETED, or a standalone A2A Message can complete the interaction. +Progress and negotiation are never a successful workflow task merely because they have text. + +## 4. Host-owned A2A-T calls + +Pure engine consumers only need a2a-t-core; hosts generating content explicitly depend on a2a-t-client. +Create/configure A2ATClient in the host, not in WorkflowEngineClientConfig or ExecutePsop. + +```java +// sdk, data, schema and template selection are owned by host code. +MetadataContent generated = sdk.generateTaskPromptFromDataWithSchema( + data, schema, StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); +MessageContent outgoing = A2atMessages.from(generated, List.of(new TextPart("Diagnose this line"))); +``` + +Natural-language equivalent: `sdk.generateTaskPromptFromText(text, StandardTemplates.PRIVATE_LINE_COMPLAINT.uri())`, then use the same A2atMessages.from conversion. + +Natural-language generation/validation and all other A2A-T content APIs are called directly on the host's SDK. +A2atMessages.from copies generated metadata and activates exactly its extension; supplied parts stay unchanged. +It does not generate, validate or parse business content. Use StandardTemplates, not invented template URIs. +WorkbenchControlPoint and examples.negotiation.NegotiationStrategy provide runnable SPN implementations. +A2ATInitialization is a sample-host workaround for the pinned SDK's cached JarFile lifecycle, not an engine dependency. + +## 5. Negotiation + +NegotiationRequest(task, originalSubmission, received, previousExchanges, remainingWait): +task is the original TaskRequest; originalSubmission is the exact initial content; received is the complete +current response; previousExchanges contains only this session's completed Exchange(received, reply); +remainingWait is the remaining local interaction deadline. There are no engine-defined business proposal schemas. + +Only a remote `INPUT_REQUIRED` carrying valid Negotiation-T Propose enters `onNegotiation`. +Terminal responses never restart negotiation; ordinary `INPUT_REQUIRED` fails explicitly. +The host validates/interprets the proposal and generates the final Accept/Reject/Abort with its own A2A-T client. +Use `A2atMessages.contextOf(request.received())` to obtain the received context; +reply with the same id, round and maxRounds. The last allowed round can still be answered. +Do not call nextRound for an ending reply or return a new Propose. + +Return `new NegotiationReply.Send(content)` to send that exact content. +Return `new NegotiationReply.Stop(code, reason)` to stop locally without a generated Abort. +Repeated task/session/round events do not repeat the callback or submission. Unchanged waiting state is observed with getTask. +`maxNegotiationExchanges` (default 3) bounds local interactions, independently of the SDK context's maxRounds. +Timeout, exhausted budget or a missing handler fails locally; no implicit Accept or synthesized Abort. +Accept/Reject ACKs in SUBMITTED/WORKING remain pending and are observed without resending the command. +A business-sent Abort is never diagnosis success, even if the remote acknowledges it with COMPLETED. + +Host policy may call validateProposePromptAndDataFilling and typed fromData or natural-language SDK generation. +It must not invent missing customer facts. The sample supplies only requested fields from the current city's input. +Map SDK business exceptions to BusinessFailure(code, safeMessage, safeDetails) in host code. +The engine does not recognize SDK-specific content exception classes. + +## 6. Routing, concurrency and failures + +RouteRequest(executionId, stepName, workflowInput, currentResults, candidates). +Candidates are RouteOption(nextStep, condition); return RouteDecision.builder().nextStep(allowed).build(). +Unconditional edges fan out without calling onRoute; conditional selection must be an allowed destination. + +Callbacks may run concurrently; do not hold a shared mutable current task/city. +Each workflow task activation (preparation and dispatch combined) is bounded by the client timeout (default sendTimeoutSeconds=600). +Routing has a callback timeout; dispatch/negotiation additionally has its own total wait deadline. Cancellation/timeout prevents late sends; +it does not automatically stop external business/LLM work. Hosts own cancellation of their resources. +Synchronous callback entry points must return promptly; place blocking operations in an asynchronous executor. +Missing/null/exceptional callbacks fail; uncertain sends are not blindly retried. + +## 7. Independent extensions + +ExtensionSender.sendAuthorization(agentName, finalContent) returns CompletableFuture. +ExtensionSender.openNotification(agentName, finalContent, (handle, received) -> ...) returns the handle immediately. +Use host-generated Authorization-T or Notification-T content with the matching activated extension. +The handle is registered before I/O; early callbacks can call handle.close() without capturing an unassigned variable. +acknowledgement() reports actual ACK or failure; timeout is not a synthesized success. +close() requests shutdown; completion() completes after the stream actually exits. +Retain it until recovery, explicit cancellation or host shutdown, not just workflow completion. + +Task, authorization and notification use separate transports/runtimes/contexts. +Neither extension success nor failure is a prerequisite for workflow execution. +Whitelist success only controls the OMC's optional automatic recovery behavior. + +## 8. Verification and logs + +### Inspect negotiation in the local Demo + +Running the local SpringSpnDemo without VM options now negotiates missing City1 input; City2 diagnoses directly. +This is a local sample scenario, not an engine default. To use complete inputs in both cities, add this IDEA **VM option**: + +```text +-Da2at.samples.negotiation=false +``` + +By default only City1 loses its Task-T task object. Add `-Da2at.samples.negotiation.city=city2` or `both` +to exercise City2 or both cities. The host retains city-scoped authoritative input and the complaint context is preserved. +Expect DEMO_NEGOTIATION → INPUT_REQUIRED/PROPOSE → onNegotiation → ACCEPT → both diagnoses → one aggregate. +External-OMC mode defaults to no injection and rejects an explicit true switch. +The Demo passes its setting into the current Spring application context without modifying JVM-wide properties. +Protocol observations are in the console and `logs/spn-demo.log` relative to the run directory. + +Run `SpringSpnDemoE2ETest` (direct) and, on dev, `SpringSpnDemoOrderE2ETest` sequentially. +Each tests the no-VM-option single-city default, explicit disable/enable and both-city negotiation with current SDK resources and an offline LLM provider. +This is local protocol E2E evidence, not real model/platform/OMC validation. + +Actual startup tests use public simulator credentials and do not shadow SDK schemas/templates. +Run main/dev tests sequentially; fixed local E2E ports must be free. +After a local workflow, the demo may observe the first recovery notification for up to ten seconds; +absence of a recovery event does not change workflow success. +See [Integration guide](INTEGRATION_GUIDE.md) for logging and authentication configuration. + +## 9. Business-side A2A-T 1.1.0 reference + +Content generation, validation, filling and domain decisions belong to the sample host/OMC code, not the engine. +Each role calls the APIs it needs; do not invoke every generation alternative for the same message. + +| Business path | SDK generation and receiving validation | Consumed result | +|---|---|---| +| WAIMO → workbench | generateTaskPromptFromDataWithSchema → validateTaskPromptAndDataFilling | WorkbenchTaskInputParser retains filled.data for workflow selection | +| onTask → either OMC | generateTaskPromptFromDataWithSchema → validateTaskPromptAndDataFilling | SpnTaskInput built from filled.data, not raw protocol text | +| Either OMC → onNegotiation | generateNegotiationProposePromptFromData → validateProposePromptAndDataFilling | Requested items answered only from the current city's authoritative input | +| Host Accept → OMC | generateNegotiationAcceptPromptFromData → validateAcceptPromptAndDataFilling | Merge requested filled fields; validate domain requirements before diagnosis | +| Host Reject / Abort → OMC | generateNegotiationRejectPromptFromData / generateNegotiationAbortPromptFromData → corresponding validateRejectPromptAndDataFilling / validateAbortPromptAndDataFilling | Validate reason, end the task without diagnosis | +| Independent authorization | generateAuthPromptFromDataWithSchema → validateAuthPromptAndDataFilling | Apply AuthorizationPolicy from filled.data | +| Independent subscription | generateNotificationPromptFromDataWithSchema → validateNotificationPromptAndDataFilling | Create NotificationPolicy and keep a separate stream open | + +For natural-language inputs choose generateTaskPromptFromText(text, templateUri), +generateAuthPromptFromText(text, templateUri), or generateNotificationPromptFromText(text, templateUri). +Negotiation also offers phase-specific FromText and typed FromData alternatives. +Structured generation may still call the SDK's configured LLM. +Wrap the returned MetadataContent with A2atMessages.from(generated, List.of(new TextPart("business summary"))). +Preserve full metadata, including templateUri and negotiationContext; provide nonempty parts. +The parts summary is not the formal extension validation input. + +The complaint example negotiates the canonical business fields 任务对象 and 任务上下文. +The former identifies this city's access port; the latter retains classification, OSS incident ID, time and details. +Only unresolved fields are requested when trusted partial data exists. If the SDK rejects the whole input, +none of its partial fields are trusted. The current proposal uses AND: all requested fields are required. +Propose extraction uses items (nonempty field-name array) and nullable relationship; Accept extraction dynamically +requires the requested nonblank fields. These SPN domain checks are sample rules, not generic engine constraints. + +Replies must match A2A task/context IDs and negotiation id/round/maxRounds. Formal text comes from Negotiation-T metadata. +Validated replies are consumed once; malformed, incomplete, foreign-city or cross-negotiation replies never run diagnosis. +NegotiationStrategy.mayProvideField(task, fieldName) is the host disclosure-policy hook: false produces SDK Reject. +Missing authoritative information produces SDK Abort, not invented values. This policy is separate from Authorization-T recovery permissions. + +Do not reuse subscription-request generation/validation for recovery events: diagnosis results, authorization receipts +and recovery events are OMC business outputs. RecoveryNotification checks the SPN result fields in formal Notification-T +artifact metadata before closing the subscription: terminal state, task/incident IDs, port, plan and execution outcome. +Both successful and failed finished recoveries are terminal, without treating failure as success. +A name, summary, ACK or not-started plan alone never closes the stream. Invalid events remain observable by the callback. +City1 uses the actual validated port and OSS incident ID. A healthy City2 need not emit a recovery result. +Authorization/subscription outcomes remain independent of workflow execution. + +EmbeddedA2AServerTest covers normal/Accept filled data, invalid formal text, missing fields, cross-negotiation replies, +Reject and Abort. NegotiationStrategyTest covers city isolation and host Abort/Reject policy. +RecoveryNotificationTest covers complete success/failure and malformed/incomplete events. +SpringSpnDemoE2ETest and dev's SpringSpnDemoOrderE2ETest cover full input, City1 missing input and both cities missing input; +SpnCrossCityE2ETest checks the recovery lifecycle. Tests use actual SDK pipelines with a strict fixture-only offline LLM provider, +not a live-model semantic evaluation or real-OMC certification. + +### Authorization format and negotiation keys (SDK 1.1.0) + +ADD uses consecutive numbered lines, labeled values and full-width commas; multiple rules use +newlines, not semicolons. Example: +`1. 业务场景是业务投诉诊断,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-06-01~2030-06-18`. +Validity is an inclusive ISO-date range or `永久生效`. Parse every SDK-validated rule before replacing +the whitelist; malformed rules leave it unchanged. Authorization uses exact scenario/disposal/action +matches, never fuzzy matching. + +The sample stores one in-memory whitelist with a fixed demonstration ID. DELETE accepts only +`1. 策略标识是`. QUERY requires an omitted or empty policy list and returns all +active rules. MODIFY, filtered queries and batch/conditional deletion require an integrating policy +store; unsupported conditions are rejected, never silently ignored. These are sample business limits, +not limitations of the A2A-T SDK. + +After SDK Propose semantic validation, the sample preserves literal numbered item names from the +current Chinese typed information template. LLM paraphrases of descriptions must not rename an item +or broaden disclosure. Replies use only the current city's authoritative input; unknown/missing data +causes Abort and a host disclosure denial causes Reject. This handling belongs to sample business code, +not the workflow engine. + +Notification requests use the complete failure-reason field name `业务抢通方案执行失败原因`, required +when execution fails. SDK rejection logs include its code, slot errors and extracted parameter keys; +validation is not bypassed. Authorization and subscription failures still do not block the workflow. diff --git a/docs/en/DESIGN.md b/docs/en/DESIGN.md index d4914747..f11451f4 100644 --- a/docs/en/DESIGN.md +++ b/docs/en/DESIGN.md @@ -9,23 +9,18 @@ ## 1. Overview -The A2A-T Engine SDK lets a host agent execute multi-step workflows over the A2A protocol with A2A-T telecom extensions. -A workflow is a directed acyclic graph (DAG) of steps; each step dispatches one or more tasks to remote agents and -routes to the next step. The SDK owns the protocol mechanics - message send, streaming, authentication, Task-T prompt -generation, the Negotiation-T auto-loop - and exposes a small set of decision interfaces the host implements for -business logic. - -The guiding principle is a clean separation between **mechanics the SDK owns** and **decisions the host owns**: - -| The SDK owns (protocol mechanics) | The host owns (business decisions) | -|---------------------------------------------------------------------------|-----------------------------------------------| -| A2A message send, streaming, SSE normalization | Whether and when to send a task | -| Agent authentication (Bearer, custom headers) | Credential configuration | -| A2A-T extensions (Task-T, Negotiation-T, Authorization-T, Notification-T) | Authorization approval, notification handling | -| DAG traversal, context assembly, state management | Branch routing decisions | -| Event emission | Event handling | - ---- +The SDK is an embedded workflow protocol scheduler, not a separately deployed business service. +The host supplies final content; the engine owns DAG execution, parallel scheduling, A2A envelopes, +authentication, transport, waiting and result association. Host callbacks choose whether/how to invoke +A2A-T natural-language or structured content APIs. Templates, schemas, LLMs and semantic validation belong to the host. +The engine uses a2a-t-core only for canonical negotiation context and metadata copying, not content generation. + +| Engine responsibility | Host responsibility | +|---|---| +| DAG, task/context association, concurrency and route validation | Final content, routing policy and local analysis | +| Select complete upstream results with contextFrom | Consume evidence and map downstream business input | +| A2A authentication and transport mechanics | Credential source, AuthProvider and deployment | +| Independent sends, subscription handles and lifecycle | Authorization policy, subscription content and closure timing | ## 2. Host Integration and Surrounding Systems @@ -73,41 +68,22 @@ graph TD ### 3.1 Layer 0 - Communication -This layer is the heart of the transport-facade split. - -**`A2ATransport`** is the shared wire layer. It owns exactly one concern: -getting bytes to and from remote agents. That means the A2A SDK client runtime, -the auth manager and interceptors, the agent-card map, and the streaming-response consumer. It exposes two send -primitives - `send` (collect-and-return) and -`sendNotificationStream` (long-lived SSE) - plus static extractors that turn the raw SDK event stream into text, task -state, and metadata. - -**Two facades sit on top of the transport, each with a single responsibility:** - -- **`WorkflowEngineClient`** - the workflow execution send path. Owns Task-T prompt generation (before send), the - Negotiation-T auto-loop (after receive), the global `EventCallback`, and the `ControlPoint` wiring. - This is the facade the executor calls during workflow execution. -- **`ExtensionSender`** - independent protocol-operation facade. Sends one-shot Authorization-T requests or establishes - long-lived Notification-T subscriptions at a workbench-selected business time. It bypasses Task-T generation and the negotiation loop; - Notification-T events use the subscription callback rather than the workflow-global callback. - -#### Why reusable transport machinery with two facades? - -Both the workflow send path and the independent-extension path need the same wire-level machinery: an HTTP client, -TLS configuration, auth interceptors, agent-card resolution, and SSE parsing. Putting that machinery on either facade -would either (a) force a caller that only needs an independent extension to hold the full workflow facade, or (b) duplicate the -wire code across two classes. The reusable transport / two-facade design avoids both: -the wire layer is written once on `A2ATransport`, and each facade delegates all wire work to it while keeping its own -orchestration concern isolated. The implementation is reusable, but instances are not shared: Task-T, -Authorization-T, and Notification-T each require an independent transport/runtime/context. Authorization -uses a one-shot lifetime; Notification uses a workbench-scoped long-lived lifetime. +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. ### 3.2 Layer 1 - Traversal -**`WorkflowExecutor`** walks the DAG. At each step it assembles upstream context (`ContextBuilder`), dispatches subtasks +**`WorkflowExecutor`** walks the DAG. At each step it selects typed upstream results according to `contextFrom` +(`ContextBuilder`), dispatches subtasks concurrently, applies the step's success policy, and determines the next step (s). It delegates every *decision* to `ControlPoint` and every *send* to `WorkflowEngineClient`. +TaskRequest separates current input from workflowInput. ContextBuilder selects complete upstream views +and convenience outputs; it never formats or generates downstream content. The host decides how to consume them. + Step dispatch rules: - Steps whose predecessors are all satisfied are collected and dispatched in parallel (`CompletableFuture`), so steps at the same layer run concurrently. @@ -126,77 +102,42 @@ Step dispatch rules: ## 4. Decision Interfaces -The SDK exposes two user-implemented interfaces, split by responsibility. - -### 4.1 ControlPoint - flow decisions - -Drives the workflow forward. Each method is called by the executor or the auto-negotiate loop and makes exactly one -decision: - -| Method | Called by | Decision | -|-----------------|------------------|----------------------------------------------| -| `onTask` | executor | Submit a natural-language or structured `TaskSubmission` | -| `onSelfTask` | executor | Handle a self-loop task locally (no A2A-T) | -| `onRoute` | executor | Choose a branch at a conditional step | -| `onNegotiation` | client auto-loop | Return a typed decision on INPUT_REQUIRED | - -Authorization-T and Notification-T are independently triggered workbench protocol operations handled via -`ExtensionSender`; they are not workflow DAG nodes. +```java +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); +} +``` ---- +onTask returns final parts/metadata/extensions; the engine sends them without generating or rewriting content. +onSelfTask returns local TaskResult, onRoute selects an allowed candidate, and onNegotiation returns Send or Stop. +Unimplemented callbacks fail explicitly. No echo-success, first-branch choice or automatic consent. +See [Business callback contract](BUSINESS_CALLBACKS.md) for fields and working examples. ## 5. A2A-T Extension Model -Four A2A-T extensions are supported. They divide into two groups by lifecycle. - -### 5.1 In-workflow extensions - -Participate in every `sendMessage` lifecycle through the extension handler chain (`ExtensionRegistry` pre-registers -both): - -- **Task-T** - On send, calls the A2A-T SDK to generate a canonical prompt from structured data, - natural language with a caller-known template, or unclassified natural language that needs scenario - recognition. It injects the prompt into message metadata. Negotiation follow-ups and caller-supplied - prompts skip generation; the receiver validates and extracts business fields through the matching SDK - validate-and-fill pipeline. -- **Negotiation-T** - On receive, when the agent returns `INPUT_REQUIRED` - and declares the extension, extracts the negotiation context and message. This feeds the auto-loop: the engine calls - `ControlPoint.onNegotiation` for a `NegotiationDecision`, selects the matching SDK - fromText/fromData API, resends the follow-up, and repeats up to a configured round limit. - -### 5.2 Independent-lifecycle extensions - -Triggered through `ExtensionSender` at a workbench-selected business time and decoupled from any single workflow transport: - -- **Authorization-T** - Uses the SDK schema-aware generation and validation pipeline for add, modify, - delete, and query operations. A valid whitelist is a prerequisite for automatic OMC recovery. -- **Notification-T** - Uses the SDK schema-aware pipeline and returns a `NotificationSubscription`. - ACK and stream completion are separate; the handle is closed after the recovery result, cancellation, - or workbench shutdown. - -The subscription ACK is exposed by `NotificationSubscription.acknowledgement()` and subsequent recovery -events flow through the subscription callback. They do not enter workflow `ControlPoint` callbacks or the -global task-event stream. +Task-T: the host generates final content; the engine envelopes and sends it. AgentCard declarations do not trigger generation. -### 5.3 Extension handler chain +Negotiation-T: -```mermaid -graph TD - SM["sendMessage(agent, message)"] - BS["before_send: Task-T generates prompt, injects into metadata"] - TS["transport.send (Task-T metadata on the wire)"] - AR["after_receive: Negotiation-T extracts context (feeds auto-loop)"] - AN["auto_negotiate loop (if INPUT_REQUIRED)"] - - SM --> BS --> TS --> AR --> AN -``` +Only a remote `INPUT_REQUIRED` carrying valid Negotiation-T Propose enters `onNegotiation`. +Terminal responses never restart negotiation; ordinary `INPUT_REQUIRED` fails explicitly. +The host validates/interprets the proposal and generates the final Accept/Reject/Abort with its own A2A-T client. +Use `A2atMessages.contextOf(request.received())` to obtain the received context; +reply with the same id, round and maxRounds. The last allowed round can still be answered. +Do not call nextRound for an ending reply or return a new Propose. -`ExtensionRegistry.getHandlersForExtensions` requires exact canonical AgentCard URIs for the built-in -Task-T and Negotiation-T handlers and returns a deduplicated chain. Authorization-T / Notification-T are -not registered in the workflow handler chain; they are solely an `ExtensionSender` and -independent-lifecycle concern. +Return `new NegotiationReply.Send(content)` to send that exact content. +Return `new NegotiationReply.Stop(code, reason)` to stop locally without a generated Abort. +Repeated task/session/round events do not repeat the callback or submission. Unchanged waiting state is observed with getTask. +`maxNegotiationExchanges` (default 3) bounds local interactions, independently of the SDK context's maxRounds. +Timeout, exhausted budget or a missing handler fails locally; no implicit Accept or synthesized Abort. +Accept/Reject ACKs in SUBMITTED/WORKING remain pending and are observed without resending the command. +A business-sent Abort is never diagnosis success, even if the remote acknowledges it with COMPLETED. ---- +Authorization-T and Notification-T are independent of the DAG. The host generates content and uses dedicated senders; failure does not affect the workflow. Whitelists only control optional OMC recovery. Subscriptions stay open until the host closes them. ## 6. Condition Routing @@ -235,93 +176,59 @@ results are handled through the `ExtensionSender` result, and subscription event ## 8. Interaction Sequences -### 8.1 Workflow execution with negotiation - ```mermaid sequenceDiagram - participant H as Host - participant E as Executor - participant C as EngineClient - participant A as Agent - - H->>E: run(workflow) - E->>C: onTask(req) - C->>A: before_send: Task-T - A-->>C: INPUT_REQUIRED (Negotiation-T) - C->>E: negotiation result - E->>H: onNegotiation(NegotiationRequest) - H->>E: NegotiationDecision - E->>C: follow-up send - C->>A: send follow-up - A-->>C: final result - C->>E: final result - E->>H: ExecutionResult + participant H as Host callbacks + A2A-T client + participant E as Workflow engine + participant A as Remote agent + E->>H: onTask(TaskRequest + upstream window) + H->>H: Generate/validate final content + H-->>E: MessageContent + E->>A: A2A envelope + unchanged content + opt INPUT_REQUIRED with valid Propose + A-->>E: Task status + Negotiation-T Propose + E->>H: onNegotiation(originalSubmission, received, history) + H->>H: Validate proposal; generate reply + H-->>E: Send(MessageContent) or local Stop + E->>A: Same task/context; final reply content + end + A-->>E: Task result / artifacts + E->>H: onSelfTask(selected complete upstream results) + H-->>E: TaskResult ``` -### 8.2 Independent authorization - ```mermaid sequenceDiagram - participant H as Host - participant ES as ExtensionSender - participant T as Transport - participant A as Agent - - H->>ES: sendAuthorization(agent) - ES->>ES: generate prompt (SDK) - ES->>T: send(instruction, auth) - T->>A: send - A-->>T: auth result - T-->>ES: auth result - ES-->>H: result + participant H as Host + A2A-T client + participant ES as Independent ExtensionSender + participant A as OMC + H->>H: Generate final authorization content + H->>ES: sendAuthorization(agent, content) + ES->>A: One-shot authorization + A-->>H: Independent result, never gates workflow + H->>H: Generate final subscription content + H->>ES: openNotification(agent, content, listener) + ES-->>H: Registered handle + ES->>A: Independent long-lived stream + A-->>H: ACK via acknowledgement() + A-->>H: listener(handle, ReceivedMessage) + H->>ES: handle.close() on recovery/cancel/shutdown + ES-->>H: completion() after stream exits ``` -### 8.3 Notification subscription - -```mermaid -sequenceDiagram - participant H as Host - participant ES as ExtensionSender - participant T as Transport - participant A as Agent - - H->>ES: openNotificationFromData - ES->>T: openNotificationStream - T->>A: open long-lived SSE - A-->>T: ack (working) - T-->>ES: ACK - ES-->>H: NotificationSubscription - Note over T,A: later results stream back over the same connection -``` - ---- - ## 9. Dependencies - -**Java SDK:** `org.a2aproject.sdk:a2a-java-sdk-client` (A2A protocol), -`net.openan.a2at.sdk:a2a-t-client` (A2A-T extensions), Jackson, SLF4J, Lombok. - -The protocol send path has no hard dependency on an orchestration-center or registry-center client; -the host may pass locally constructed `Workflow` and AgentCard objects. In a standard production -integration, however, the host normally uses the SDK's `LoadPsop` and `RegistryClient` helpers, or its -own service clients, to access those centers before passing the discovered inputs to the engine. - ---- +workflow-engine uses A2A Java `1.2.0.Final` (REST/JSON-RPC/gRPC), a matching gRPC runtime, +`net.openan.a2a-t.sdk:a2a-t-core:1.1.0`, Jackson and SLF4J. +Pure engine consumers do not transitively receive A2A-T client/server, LLM, prompt or resources. +samples/hosts explicitly depend on a2a-t-client, and OMC receivers additionally use a2a-t-server. +Registry/orchestration discovery is host-owned; RegistryClient/LoadPsop are optional helpers. +Templates and slot schemas come from the pinned SDK jar, not sample resource overrides. ## 10. Design Decisions Summary -1. **Reusable transport, two facades** - wire machinery written once on - `A2ATransport`; `WorkflowEngineClient` and `ExtensionSender` each own one orchestration concern and delegate wire - work, with task-scoped or workbench-scoped transport lifetimes. Avoids forced-facade coupling and wire-code duplication. - -2. **In-workflow vs independent-lifecycle extensions** - Task-T and Negotiation-T are part of the - `sendMessage` chain. Authorization-T is an independently triggered one-shot request, while - Notification-T is a workbench-scoped long-lived subscription. The registry auto-registers only - the in-workflow pair. - -3. **Auto-negotiation loop** - the engine owns the resend loop so hosts only supply a typed decision - (`onNegotiation`), never the protocol mechanics of resending. - -4. **Condition routing semantics** - empty conditions mean fan-out (parallel), conditional branches mean N-choose-1 via - `onRoute`. Keeps the routing model predictable. +Final content is separate from protocol scheduling; hosts do not maintain A2A envelopes. +Local multiple outputs and complete remote evidence enter the selected upstream window without losing metadata or flattening arrays. +Business owns negotiation reply content; the engine owns association, deduplication and bounded waiting. Stop and Abort are distinct. +Independent authorization/notification never gate the workflow; direct and dev Order share callbacks. +Protocol logs observe actual boundaries with mandatory redaction; see [Integration guide](INTEGRATION_GUIDE.md). diff --git a/docs/en/DEVELOPER_GUIDE.md b/docs/en/DEVELOPER_GUIDE.md index abf1a5fd..03d31a9c 100644 --- a/docs/en/DEVELOPER_GUIDE.md +++ b/docs/en/DEVELOPER_GUIDE.md @@ -15,9 +15,7 @@ Add to your `pom.xml`: ``` -The engine transitively pulls in the A2A protocol SDK (`a2a-java-sdk-client` with REST, -JSON-RPC, and gRPC transports) and the A2A-T extension SDK (`a2a-t-client`). No additional -dependencies are needed. +The engine pulls in A2A protocol transports and a2a-t-core only. Hosts generating A2A-T content explicitly add a2a-t-client; receiving hosts may also add a2a-t-server. ## 2. Core Concepts @@ -29,41 +27,33 @@ dependencies are needed. ## 3. Implement ControlPoint -Only two methods are required: - ```java -public class MyControlPoint implements ControlPoint { - @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher dispatcher) { - return dispatcher.dispatch(TaskSubmission.fromText( - request.getAgentName(), request.getMessage(), - StandardTemplates.PRIVATE_LINE_COMPLAINT)) - .thenApply(result -> { - String state = result.getTaskState(); - boolean success = state == null || state.isBlank() - || "TASK_STATE_COMPLETED".equals(state); - return TaskResponse.builder() - .success(success) - .output(result.getText()) - .build(); - }); - } - - @Override - public CompletableFuture onRoute( - String stepName, Map results, - List conditions) { - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(conditions.get(0).getStep()) - .reason("picked first") - .build()); - } +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -`onNegotiation` has a default that returns a generic `acceptText` decision. Authorization and notification are independent protocol operations sent via dedicated `ExtensionSender`/transport instances at a workbench-selected business time, not `ControlPoint` callbacks or workflow DAG nodes. +onTask returns final parts/metadata/extensions; the engine sends them without generating or rewriting content. +onSelfTask returns local TaskResult, onRoute selects an allowed candidate, and onNegotiation returns Send or Stop. +Unimplemented callbacks fail explicitly. No echo-success, first-branch choice or automatic consent. +See [Business callback contract](BUSINESS_CALLBACKS.md) for fields and working examples. + +```java +ControlPoint callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .onSelfTask(request -> CompletableFuture.completedFuture( + TaskResult.success(List.of(Map.of( + "sourceResults", request.getWorkflowInput().upstreamResults()))))) + .onRoute(request -> CompletableFuture.failedFuture( + new IllegalStateException("Supply a routing policy for " + request.stepName()))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Manual confirmation required"))) + .build(); +``` ## 4. Execute via Builder (recommended) @@ -75,7 +65,6 @@ ExecutionResult result = ExecutePsop.builder() .runtimeIntent("Diagnose SPN fault") .lang("zh") .sslVerify(false) - .a2atEnvPath(".env") .credentialsConfigPath("agent_credentials.json") .eventCallback(new EventCallback()) .onFinish((r, e) -> { @@ -100,18 +89,18 @@ Events come from three layers: the runner (lifecycle bracket), the executor (ste | `start` | runner | Workflow begins | `workflow`, `steps` | | `step_start` | executor | Step begins | `step` | | `task_request` | executor | A subtask is dispatched to `onTask`/`onSelfTask` | `step`, `agent`, `task` | -| `task_response` | executor | `onTask`/`onSelfTask` returned a `TaskResponse` | `step`, `agent`, `task`, `output` | +| `task_response` | executor | Remote task completed or onSelfTask returned TaskResult | `step`, `agent`, `task`, `outputs` | | `task_status_changed` | executor | Task status changed (pending → running → success/failed) | `step`, `agent`, `task`, `status` | | `route_decision` | executor | Branch chosen | `step`, `next`, `reason` | | `step_complete` | executor | Step finished | `step`, `results` | | `workflow_complete` | executor | All steps finished | `history`, `step_outputs` | -| `agent_request` | engine client | Message sent to agent | `agent`, `request`, `metadata` | -| `agent_response` | engine client | Response from agent | `agent`, `response` | +| `agent_request` | engine client | Dispatch intent, not a wire observation | `agent`, `content` | +| `agent_response` | engine client | Remote response assembled | `agent`, `response`, `receivedMessages` | | `agent_status_update` | engine client | Agent SSE status update | `agent`, `state`, `is_final` | | `agent_artifact_update` | engine client | Agent SSE artifact update | `agent`, `artifact_name`, `text` | -| `negotiation_request` | engine client | Agent needs clarification | `agent`, `round`, `concern` | -| `negotiation_resolved` | engine client | Negotiation decision generated | `agent`, `round`, `decision` | -| `negotiation_failed` | engine client | Negotiation failed | `agent`, `round`, `reason` | +| `negotiation_request` | engine client | Valid Propose enters host callback | `agent`, `request`, `exchange` | +| `negotiation_resolved` | engine client | Host Send passed association checks, not task success | `agent`, `reply`, `exchange` | +| `negotiation_failed` | engine client | Local negotiation interaction failed | `agent`, `exchange`, `errorType` | | `complete` | runner | Workflow succeeded | `history`, `step_outputs` | | `error` | runner or executor | Workflow failed | runner: `error`, `history`; executor: `step`, `results` | | `close` | runner | Cleanup done | (empty) | @@ -123,7 +112,6 @@ try(var client = new DefaultWorkflowEngineClient(agentCards, a2aRuntime, WorkflowEngineClientConfig.builder() .sslVerify(false) .credentialsConfigPath("etc/conf/agent_credentials.json") - .a2atEnvPath(".env") .build())){ WorkflowExecutor executor = new WorkflowExecutor( workflow, @@ -139,28 +127,20 @@ ExecutionResult result = executor.run().join(); ### 6.1 Negotiation Auto-Loop -After dispatch, the engine client automatically handles negotiation: -when the agent returns `INPUT_REQUIRED`, the engine extracts the negotiation text from response metadata, calls -`ControlPoint.onNegotiation()` -for a typed decision, and sends it back as a follow-up message. The loop repeats up to `maxNegotiationRounds` (default -3). - -Override `onNegotiation()` in your `ControlPoint` to provide business-specific clarifications: - -```java - -@Override -public CompletableFuture onNegotiation( - NegotiationRequest request) { - return myLlm.generate( - "Agent " + request.agentName() + " needs: " + request.concern()) - .thenApply(Response::text) - .thenApply(NegotiationDecision::acceptText); -} -``` - -Returning `null` fails the round. Use `acceptData/rejectData/abortData` for structured business values; invalid -names, blank values, and the literal string `"null"` fail before SDK invocation. +Only a remote `INPUT_REQUIRED` carrying valid Negotiation-T Propose enters `onNegotiation`. +Terminal responses never restart negotiation; ordinary `INPUT_REQUIRED` fails explicitly. +The host validates/interprets the proposal and generates the final Accept/Reject/Abort with its own A2A-T client. +Use `A2atMessages.contextOf(request.received())` to obtain the received context; +reply with the same id, round and maxRounds. The last allowed round can still be answered. +Do not call nextRound for an ending reply or return a new Propose. + +Return `new NegotiationReply.Send(content)` to send that exact content. +Return `new NegotiationReply.Stop(code, reason)` to stop locally without a generated Abort. +Repeated task/session/round events do not repeat the callback or submission. Unchanged waiting state is observed with getTask. +`maxNegotiationExchanges` (default 3) bounds local interactions, independently of the SDK context's maxRounds. +Timeout, exhausted budget or a missing handler fails locally; no implicit Accept or synthesized Abort. +Accept/Reject ACKs in SUBMITTED/WORKING remain pending and are observed without resending the command. +A business-sent Abort is never diagnosis success, even if the remote acknowledges it with COMPLETED. ### 6.2 Workflow Model Fields @@ -169,8 +149,8 @@ names, blank values, and the literal string `"null"` fail before SDK invocation. | `steps[].stepType` | `WorkflowStep` | `AllSuccess` (default): every subtask must succeed; `AnySuccess`: any subtask success suffices; `SelfLoop`: the workflow agent handles the step locally via `onSelfTask` (no A2A-T message to the named agent). | | `steps[].subtasks[]` | `Task` | Each has `agent`, `skill`, `description`. One `onTask` (or `onSelfTask` for SelfLoop) call per subtask. | | `steps[].next[]` | `List` | Branch targets. `step` = next step name; `condition` = rule text. | -| `steps[].layer` | `WorkflowStep` | `layer == 0` starts the DAG (context = runtime intent only). Higher layers get upstream results. | -| `steps[].contextFrom` | `WorkflowStep` | Optional step names whose outputs fold into context. `"*"` = all ancestors. | +| `steps[].layer` | `WorkflowStep` | Orchestration-level hint; actual readiness is derived from DAG predecessors. | +| `steps[].contextFrom` | `WorkflowStep` | Selects steps exposed through `workflowInput.upstreamResults`; omitted = direct predecessors, `[]` = none, `"*"` = all ancestors, or explicit ancestor names. | ### 6.3 AgentCard Type @@ -252,23 +232,13 @@ Set `sslVerify=false` only for dev with self-signed certs. ## 9. A2A-T Environment (.env) -```ini -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_API_KEY=sk-... -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LANGUAGE=zh-CN -A2AT_CRED_KEY=<32-byte hex> -``` - -When `a2atEnvPath` is null, unstructured `sendMessage` sends plain A2A text and does not claim -Task-T semantics. Structured Task-T, Authorization-T, Notification-T, and Negotiation-T operations -fail explicitly. - -**Offline tests**: test `.env` resources select `OfflineA2ATLlmClient` through the SDK SPI. This -keeps the real A2ATClient/A2ATServer generation, validation, and parameter-filling pipelines -repeatable without network access. There is no production fallback that disables the SDK and sends -default negotiation text. +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 @@ -302,16 +272,32 @@ public Flux execute(@PathVariable String psopId) { ### Cancellation -`ExecutePsop.builder().execute()` returns a `CompletableFuture`. You can -`cancel(true)` it, but the internal executor does not actively interrupt a running A2A call. For SSE, drop the -subscriber and let the future complete. +ExecutePsop.builder().execute() returns a CompletableFuture. Cancellation prevents late callback results +from being sent and requests conversation cleanup. Hosts must separately cancel their own LLM/business work. +A remote task already submitted may require an explicit cancelTask operation; local cancellation is not protocol Abort. + ## 11. Checklist 1. Add Maven dependencies -2. Implement `ControlPoint` (at minimum `onTask` + `onRoute`; `onSelfTask` and `onNegotiation` have defaults) +2. Implement `ControlPoint` (implement final onTask content plus local tasks, conditional routes and negotiation when required) 3. Get AgentCards (from registry or JSON files) 4. Load Workflow (via `LoadPsop` or build your own) 5. Configure `.env` and credentials file 6. Call `ExecutePsop.builder().execute()` 7. Handle events + onFinish persistence + +### Pretty protocol log display + +Protocol logs default to pretty display: one header value per line and indented JSON bodies. +SSE keeps event controls (id/event/comments); JSON appears between `=== SSE data (JSON display; not wire text) ===` +and `=== End SSE data ===`, without repeating `data:` on every JSON line. Event boundaries remain separate. +Set the environment variable or JVM property `WORKFLOW_ENGINE_PROTOCOL_PRETTY=false` to retain redacted raw body text. +Formatting changes presentation only (JSON whitespace and SSE display labels), never transmitted bytes, metadata, number tokens or extension headers. +The raw observer content remains unchanged. +SSE pretty output is a display, not a replayable packet capture. Escaped newlines inside JSON strings stay escaped; +invalid/incomplete or non-JSON content stays raw. Redaction and capacity limits still apply. +The demo logs `NEGOTIATION_DEMO enabled=..., city=...` at startup. Locally City1 negotiates by default while City2 diagnoses directly; +`-Da2at.samples.negotiation=false` disables the missing-input scenario. +Look for `A2A-Extensions: .../Negotiation-T/v1`, the matching metadata key and `negotiationContext`; +there is no dedicated Negotiation-T HTTP header. diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index d05c2767..713e3974 100644 --- a/docs/en/INTEGRATION_GUIDE.md +++ b/docs/en/INTEGRATION_GUIDE.md @@ -5,8 +5,8 @@ The A2A-T Workflow Execution Engine is a Java SDK for orchestrating multi-agent workflows using the A2A protocol with A2A-T telecom extensions. -The engine handles all protocol mechanics automatically (message transport, SSE streaming, Task-T prompt generation, -Negotiation-T auto-loop, authentication, TLS). You focus on business decisions only. +The engine schedules A2A tasks, envelopes final content, manages authentication/transport and waits for results. +Host callbacks own A2A-T generation, semantic validation, schemas and any LLM calls. ## 2. Prerequisites @@ -88,75 +88,33 @@ List> cards = registry.fetchAgentCards(); ### 4.3 Implement ControlPoint -Extend `DefaultControlPoint` and override the methods you need: - ```java -public class MyControlPoint extends DefaultControlPoint { - @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher dispatcher) { - return dispatcher.dispatch(TaskSubmission.fromText( - request.getAgentName(), request.getMessage(), - StandardTemplates.PRIVATE_LINE_COMPLAINT)) - .thenApply(r -> { - String state = r.getTaskState(); - boolean success = state == null || state.isBlank() - ? r.getText() != null && !r.getText().isBlank() - : state.endsWith("COMPLETED"); - return TaskResponse.builder() - .success(success) - .output(r.getText()) - .build(); - }); - } - - @Override - public CompletableFuture onSelfTask(TaskRequest request) { - // SELF_LOOP step: handled locally, no engineClient, no A2A-T message. - // request.getMessage() already carries upstream step results as context. - String summary = summarizeLocally(request.getMessage()); - return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output(summary).build()); - } - - @Override - public CompletableFuture onRoute( - String stepName, Map results, - List conditions) { - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(conditions.get(0).getStep()) - .build()); - } - - @Override - public CompletableFuture onNegotiation( - NegotiationRequest request) { - return CompletableFuture.completedFuture( - NegotiationDecision.acceptText( - "Please proceed with available information.")); - } +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -| Method | When Called | What You Do | -|-------------------|-------------------------------------------|--------------------------------------------------| -| `onTask` | A step dispatches a task to another agent | Submit a typed `TaskSubmission` to `TaskDispatcher` | -| `onSelfTask` | A `SELF_LOOP` step runs locally | Handle locally, return result (no A2A-T message) | -| `onRoute` | After step completes, before next step | Pick the next step from candidates | -| `onNegotiation` | Agent returns `INPUT_REQUIRED` | Return a typed `NegotiationDecision` | +onTask returns final parts/metadata/extensions; the engine sends them without generating or rewriting content. +onSelfTask returns local TaskResult, onRoute selects an allowed candidate, and onNegotiation returns Send or Stop. +Unimplemented callbacks fail explicitly. No echo-success, first-branch choice or automatic consent. +See [Business callback contract](BUSINESS_CALLBACKS.md) for fields and working examples. -`onNegotiation` defaults to a generic `acceptText` decision. For structured business data use -`acceptData/rejectData/abortData`; never encode a decision in a string prefix. For an information-negotiation -`rejectData`, provide one map entry per unavailable requested item, using the item name as the key and its concrete -non-provision reason as the value. Do not collapse multiple unavailable items into one aggregate rejection reason. - -**Independent operations (Authorization-T / Notification-T)**: Both use `ExtensionSender` at a workbench-selected business time and have no ordering dependency on workflow execution. Authorization-T is a one-shot request whose transport closes after a successful acknowledgement. Notification-T is a long-lived subscription on a transport independent of any single workflow; `openNotificationFromData` returns a `NotificationSubscription`, and later events are delivered to its callback until the expected result arrives, it is canceled, or it is explicitly closed. - -**Self-loop steps (SelfLoop)**: When a step is the workflow-executing agent's own task (e.g. merging multiple agents' -diagnostic results), set `stepType` to `SELF_LOOP`. The engine calls `onSelfTask` locally instead of sending an A2A-T -message to the agent itself. `onSelfTask` takes no `engineClient` parameter — this enforces at the API level that -self-loop tasks never send A2A-T. Only steps targeting other agents go through `onTask` + A2A-T. +```java +ControlPoint callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .onSelfTask(request -> CompletableFuture.completedFuture( + TaskResult.success(List.of(Map.of( + "sourceResults", request.getWorkflowInput().upstreamResults()))))) + .onRoute(request -> CompletableFuture.failedFuture( + new IllegalStateException("Supply a routing policy for " + request.stepName()))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Manual confirmation required"))) + .build(); +``` ### 4.4 Execute @@ -167,7 +125,6 @@ ExecutionResult result = ExecutePsop.builder() .controlPoint(new MyControlPoint()) .runtimeIntent("SPN cross-city fault diagnosis") .lang("zh") - .a2atEnvPath(".env") .credentialsConfigPath("credentials.json") .sslVerify(true) .onFinish((r, history) -> { @@ -183,21 +140,13 @@ Required: `psop`, `controlPoint`. All other config items have defaults. ### 5.1 .env File -Configures the LLM and prompt runtime: - -```ini -A2AT_LANGUAGE=zh-CN -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-v4-flash -A2AT_LLM_API_KEY=sk-xxxxxxxxxxxxxxxx -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_MAX_TOKENS=2000 -A2AT_LLM_TEMPERATURE=0 -A2AT_LLM_TIMEOUT_SECONDS=60 -A2AT_CRED_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef -``` - -When `.env` is not configured, Task-T prompt generation is unavailable. All other features work normally. +The engine does not read A2A-T .env files or create LLM clients. If host callbacks use A2A-T, +initialize A2ATClient/A2ATServer with a host-owned environment file containing provider/model/key/base URL +and A2AT_LANGUAGE. The sample's a2atEnvPath setting is only a host/demo setting, not an engine builder option. +Do not put OMC credential decryption ownership in LLM configuration: pass the secret explicitly through +WorkflowEngineClientConfig.builder().credentialEncryptionKey(key) when using encrypted built-in credentials, +then pass that configured engineClient to ExecutePsop. Custom AuthProvider owns its own token/configuration. +Tests use the current SDK SPI with an offline provider, not template overrides or production fallbacks. ### 5.2 Credentials File @@ -308,7 +257,6 @@ public class SsoAuthProvider implements AuthProvider { WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .authProvider(new SsoAuthProvider(mySsoClient)) .sslVerify(true) - .a2atEnvPath(".env") .build(); ``` @@ -402,62 +350,28 @@ Both `securitySchemes` and `securityRequirements` are optional. The former lists ## 7. A2A-T Extensions -The engine handles four A2A-T extensions automatically. You do not need to deal with protocol details: - -### Task-T (automatic) - -In `onTask`, select `TaskSubmission.fromText(..., templateUri)`, -`TaskSubmission.fromUnclassifiedText(...)`, or `TaskSubmission.fromData(...)` and pass it to -`TaskDispatcher.dispatch(...)`. Pass the known complaint template when the workflow has already -classified the task; use `fromUnclassifiedText` only when the host genuinely needs SDK scenario -recognition. The engine calls the matching A2A-T SDK API and injects the generated metadata. - -### Negotiation-T (automatic) - -When an agent returns `INPUT_REQUIRED`, the engine creates a `NegotiationRequest`, calls your -`onNegotiation()` for a typed Accept/Reject/Abort decision, invokes the matching SDK fromText/fromData API, -and sends the rendered follow-up. Auto-loops up to `maxNegotiationRounds` (default 3). - -### Authorization-T (independent authorization operation) - -Invoke this when the workbench needs to manage a recovery whitelist; it is not a workflow node. -Authorization-T uses a dedicated transport/runtime/context: - -```java -ExtensionSender authorizationSender = new DefaultExtensionSender(authorizationTransport); -authorizationSender.sendExtensionMessageFromData( - "SPN Domain Agent", - "Authorization-T structured operation", - authorizationData, - authorizationSchema, - A2ATExtension.AUTHORIZATION_T).join(); -``` - -`A2ATExtension.AUTHORIZATION_T` is used internally; never hardcode the URI. The SPN agent stores the strategy and -compares subsequent operations against the whitelist. Operations within the whitelist are executed; others are rejected. - -### Notification-T (independent long-lived subscription) - -Invoke this when the workbench needs recovery-result notifications. It uses a transport/runtime/context -separate from Task-T and Authorization-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. +Do not call nextRound for an ending reply or return a new Propose. + +Return `new NegotiationReply.Send(content)` to send that exact content. +Return `new NegotiationReply.Stop(code, reason)` to stop locally without a generated Abort. +Repeated task/session/round events do not repeat the callback or submission. Unchanged waiting state is observed with getTask. +`maxNegotiationExchanges` (default 3) bounds local interactions, independently of the SDK context's maxRounds. +Timeout, exhausted budget or a missing handler fails locally; no implicit Accept or synthesized Abort. +Accept/Reject ACKs in SUBMITTED/WORKING remain pending and are observed without resending the command. +A business-sent Abort is never diagnosis success, even if the remote acknowledges it with COMPLETED. ```java -NotificationSubscription subscription = notificationSender.openNotificationFromData( - "SPN Domain Agent", - "Notification-T subscription", - notificationData, - notificationSchema, - event -> { - if ("recovery-result".equals(event.get("artifact_name"))) { - persistRecoveryResult(event); - } - }).join(); -SendMessageResult ack = subscription.acknowledgement().join(); +CompletableFuture sendAuthorization(String agentName, MessageContent content); +NotificationSubscription openNotification(String agentName, MessageContent content, + BiConsumer listener); ``` -The ACK only confirms that the subscription was established. Retain the `NotificationSubscription` and -call its idempotent `close()` after the expected `recovery-result`, cancellation, or workbench shutdown. -Normal workflow completion does not close this channel. +The host generates final Authorization-T/Notification-T content and calls these methods on separate transport/runtime/context instances. The listener receives handle and complete ReceivedMessage and closes on the business recovery event. acknowledgement() and completion() separately represent ACK and actual stream exit, never workflow prerequisites. ## 8. HTTPS Configuration @@ -483,26 +397,55 @@ so mTLS and `crlPath` cannot be combined with that mode and fail fast instead of ## 9. Logging -The dedicated `PROTOCOL` logger emits protocol request/response summaries. Bodies are disabled by -default. Sensitive headers such as Authorization, cookies, API keys, tokens, and secrets are -redacted by default. Enable DEBUG and bodies only temporarily in a controlled diagnostic environment: +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. ```properties logger.protocol.name=PROTOCOL logger.protocol.level=DEBUG -# Reuse the root console/file appenders; do not set false without a valid appenderRef logger.protocol.additivity=true +WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY=true +WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS=100000 ``` -Control the content with environment variables or same-named JVM system properties: +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. -```properties -WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY=true -WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS=100000 -WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=false +### Inspect negotiation in the local Demo + +Running the local SpringSpnDemo without VM options now negotiates missing City1 input; City2 diagnoses directly. +This is a local sample scenario, not an engine default. To use complete inputs in both cities, add this IDEA **VM option**: + +```text +-Da2at.samples.negotiation=false ``` -Enable sensitive headers only for isolated, controlled local diagnostics; the engine emits a security warning when this opt-in is active. +By default only City1 loses its Task-T task object. Add `-Da2at.samples.negotiation.city=city2` or `both` +to exercise City2 or both cities. The host retains city-scoped authoritative input and the complaint context is preserved. +Expect DEMO_NEGOTIATION → INPUT_REQUIRED/PROPOSE → onNegotiation → ACCEPT → both diagnoses → one aggregate. +External-OMC mode defaults to no injection and rejects an explicit true switch. +The Demo passes its setting into the current Spring application context without modifying JVM-wide properties. +Protocol observations are in the console and `logs/spn-demo.log` relative to the run directory. + +Run `SpringSpnDemoE2ETest` (direct) and, on dev, `SpringSpnDemoOrderE2ETest` sequentially. +Each tests the no-VM-option single-city default, explicit disable/enable and both-city negotiation with current SDK resources and an offline LLM provider. +This is local protocol E2E evidence, not real model/platform/OMC validation. ## 10. Event Callback @@ -546,41 +489,7 @@ Workflow workflow = LoadPsop.load( ## 12. Custom Extensions -To add a new A2A-T extension, implement `ExtensionHandler`: - -```java -public class MyExtensionHandler implements ExtensionHandler { - @Override - public String extensionKeyword() { - return "My-Extension"; - } - - @Override - public CompletableFuture> beforeSend( - AgentCard agentCard, String messageText, - Map metadata, - A2ATClient a2atClient, ControlPoint controlPoint) { - metadata.put("https://example.com/extensions/My-Extension/v1", "value"); - return CompletableFuture.completedFuture(metadata); - } - - @Override - public CompletableFuture afterReceive( - AgentCard agentCard, SendMessageResult result, - A2ATClient a2atClient, ControlPoint controlPoint, - EventCallback eventCallback) { - return CompletableFuture.completedFuture(result); - } -} -``` - -Register via config: - -```java -WorkflowEngineClientConfig.builder() - .customHandlers(List.of(new MyExtensionHandler())) - .build(); -``` +Construct final MessageContent(parts, metadata, extensions), with host-owned content generation/validation. No engine handler or SDK instance registration. A2atMessages.from copies A2A-T metadata; other extensions can directly supply metadata and activation URIs. AgentCard declarations never cause implicit generation. ## 13. Interface Reference @@ -593,9 +502,8 @@ WorkflowEngineClientConfig.builder() | `A2ATransport` | Shared wire layer (A2A Java client runtime, auth, SSE consumer) | | `WorkflowEngineClientConfig` | Configuration (SSL, auth, A2A-T, negotiation rounds, custom handlers) | | `AuthProvider` | Custom authentication | -| `ExtensionHandler` | Custom extension handler | | `EventCallback` / `EventType` | Event callback | | `LoadPsop` / `RegistryClient` | Workflow loading / AgentCard fetching | | `Workflow` / `WorkflowStep` / `Task` / `JumpCondition` | Workflow definition | | `ExecutionResult` | Execution result | -| `SendMessageResult` / `TaskResponse` | Message/task response | +| `SendMessageResult` / `TaskResult` | Message/task response | diff --git a/docs/zh/A2AT-SDK-DEPENDENCY.md b/docs/zh/A2AT-SDK-DEPENDENCY.md index 0d6cadef..51029242 100644 --- a/docs/zh/A2AT-SDK-DEPENDENCY.md +++ b/docs/zh/A2AT-SDK-DEPENDENCY.md @@ -1,50 +1,70 @@ -# 未发布 A2A-T SDK 依赖说明 +# A2A-T SDK 正式版本依赖说明 -执行引擎首发版本只支持下列最新 A2A-T SDK 源码基线,不提供旧 SDK 兼容层: +执行引擎首发版本只支持下列 A2A-T SDK 正式版本基线,不提供旧 SDK 兼容层: - 仓库:`project-openan/a2a-t-sdk-java` -- Git commit:`0de5a2751781419820436e5eb17cffc39b9db47d` -- 本地 Maven 版本:`1.0.0-0de5a27`(精确提交构建的不可变本地版本) -- 使用组件:`a2a-t-client`、`a2a-t-server` 及其传递依赖 +- 发布标签:`v1.1.0`,commit:`e42c83acce3e5ac2c245d36546ced0fa017b2b58` +- Maven Central 版本:`1.1.0`,groupId `net.openan.a2a-t.sdk` +- 引擎使用 `a2a-t-core`;samples/宿主显式使用 `a2a-t-client`,OMC 接收端使用 `a2a-t-server`。引擎不传递引入内容生成、LLM、prompt/resources。 ## 已知上游生命周期缺口 -该提交中的 `A2ATClient`、`A2ATServer` 和 `LLMClient` 尚未实现 +该正式版本中的 `A2ATClient`、`A2ATServer` 和 `LLMClient` 尚未实现 `AutoCloseable`,因此调用过 OpenAI 兼容 LLM 后,底层 OpenAI Java 客户端的 `DefaultSleeper` 定时线程无法由工作流引擎通过公共 API 关闭。`SpringSpnDemo` 的 -Task-T、Authorization-T、Notification-T 和直连业务链路均可正常完成, -但一次性关闭内嵌 Tomcat 时会提示这一条上游线程生命周期告警。 +离线协议测试不受此限制;真实模型调用后的生命周期仍需额外观察, +一次性关闭内嵌 Tomcat 时可能提示上游线程生命周期告警。 上游 SDK 应在新提交中补充幂等 `close()`,由 `A2ATClient/A2ATServer` 级联关闭其拥有的 LLM provider;执行引擎届时应升级到该新提交对应的新 Maven 版本。禁止在不改变版本号的 -情况下覆盖 `1.0.0-0de5a27`,否则 IDEA 与 CI 将得到不可复现的同坐标异内容制品。 +情况下覆盖 `1.1.0`,否则 IDEA 与 CI 将得到不可复现的同坐标异内容制品。 -该 SDK 尚未发布到 Maven Central。引擎不探测、不加载也不降级到其他 A2A-T SDK 版本; -本机与 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,也不降级到其他版本。 -## Windows / PowerShell 安装 +## 当前模板发现的资源隔离 + +当前 SDK 的 PromptTemplateCatalog 会关闭 JarURLConnection 返回的 JarFile。 +默认缓存可能让并行门面共用该句柄,导致 zip file closed。 +samples 宿主 A2ATInitialization 在初始化线程临时包装资源 URL,仅该连接 useCaches=false, +完成后恢复类加载器;不更改全局 URL 缓存或 SDK jar。 +每次 SDK 升级需重新验证该隔离是否仍必要。 + +SDK 当前内容接口接受字符串模板 URI,宿主通过 StandardTemplates.*.uri() 指定。 +业务调用生成/校验 API,再经 A2atMessages.from 转为最终 MessageContent。 +引擎只检查协商上下文关联,不解释业务内容。 +完整契约见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 + +## Maven / IDEA 接入 + +根 POM 的 `a2a.t.sdk.version` 是唯一依赖版本来源,当前为 `1.1.0`。 +在执行引擎目录直接执行: ```powershell -git clone https://github.com/project-openan/a2a-t-sdk-java.git -Set-Location a2a-t-sdk-java -git checkout 0de5a2751781419820436e5eb17cffc39b9db47d -mvn -B "-Drevision=1.0.0-0de5a27" -DskipTests ` - -pl a2a-t-client,a2a-t-server -am install +mvn -B -U clean verify ``` -随后在执行引擎目录执行: +IDEA 使用该工程的 Maven 配置并执行 Reload All Maven Projects 即可;无需手工安装 A2A-T jar。 +如果企业或公共镜像尚未同步正式版本,应等待同步或按组织要求调整 Maven 镜像, +不要同坐标 install 本地构建物替代正式发布制品。本次通过本机镜像下载并核对 +SDK jar 的 SHA-1 与 Central 发布校验值一致。 -```powershell -mvn -B clean verify +宿主若调用 A2A-T 内容生成,需显式声明: + +```xml + + net.openan.a2a-t.sdk + a2a-t-client + 1.1.0 + ``` -## 升级 A2A-T SDK +需要接收端校验时同样显式添加 `a2a-t-server:1.1.0`;samples 已声明二者。 -升级时必须同步修改三处,且用全量测试证明一致性: +## 升级 A2A-T SDK -1. 根 `pom.xml` 的 `a2a.t.sdk.version` 和 `a2a.t.sdk.git.revision`; -2. `.github/workflows/ci.yml` 的 `A2AT_SDK_REF` 和 `A2AT_SDK_VERSION`; -3. 本文及 README 的安装命令。 +升级时更新根 `pom.xml` 的 `a2a.t.sdk.version`、本文发布标签和相关接入文档, +核对正式制品与源码/资源差异,并运行完整回归。CI 直接使用根 POM,不单独维护版本或编译 SDK。 禁止只覆盖本地同版本 jar。新版本至少要验证 Task-T 生成/校验、无状态 Negotiation-T metadata、Authorization-T、Notification-T 长连接,以及现行传输路径: @@ -69,5 +89,92 @@ Order 两条 E2E。 samples 不再用同名 classpath 资源覆盖 SDK 内容,也不保留旧 renderer/validator 的兼容提示词。 测试会核对实际加载资源来自 `a2a-t-resources`,避免升级后仍静默运行旧规则。 -Authorization-T 只接受当前 SDK 定义的规范数据形态:单条策略字段使用全角逗号 `,`, -多条策略使用全角分号 `;`。旧 `/` 分隔格式不再接受。 +Authorization-T 只接受当前 SDK 定义的规范数据形态:每条策略以从 1 连续递增的编号 `1.` 开头, +字段为“字段名是值”,字段间使用全角逗号 `,`,多条策略用换行分隔。 +例如:`1. 业务场景是业务投诉诊断,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-06-01~2030-06-18`。 +日期范围与“永久生效”均支持;裸值列表、分号分隔和旧 `/` 格式不接受。 +此前本节关于全角分号的描述不正确,已按 1.1.0 正式 jar 的 slot.json 修正。 + +## 1.1.0 升级核对 + +相对此前 `ec14175` 源码基线,`v1.1.0` 的 core/client/server/llm/prompt/resources/negotiation +模块源码与资源没有差异;无需修改业务回调或增加版本兼容层。上述 JarFile 初始化隔离仍有必要, +LLM close 公共接口缺口也未随发布解决。发布之后当前 upstream/main 的新增差异仅为开发指南版本说明。 + +2026-08-31 本地发布制品回归:main 188 项、dev 239 项测试通过,均无失败、错误或跳过。 +覆盖正常输入与缺参协商的直连 SpringSpnDemo,dev 另覆盖 Order SDK 平台模拟路线。 +SDK 模板和校验使用正式 jar,LLM 使用离线 provider,OMC/平台使用本地模拟器;未验证真实模型或现网。 +依赖树确认纯引擎只有 a2a-t-core,samples 的全部 A2A-T 组件均为 1.1.0。 +构建日志位于各工作树 `logs/a2at-1.1.0-*-verify.log`,逐项报告在各模块 `target/surefire-reports/`。 + +## 示例业务接口调用复核(2026-08-31) + +此前发现的正文来源、填充结果丢弃、City2 缺参路径、Reject/Abort 分发及离线宽松校验问题, +现已在 samples 业务层整改;通用引擎未重新引入内容生成或语义判断职责。 + +| 路径 | 发送端生成 | 接收端校验/实际使用 | +|---|---|---| +| WAIMO → 工作台 | generateTaskPromptFromDataWithSchema | WorkbenchTaskInputParser 使用 validateTaskPromptAndDataFilling 的 filled.data | +| 工作台 → 两地市任务 | WorkbenchControlPoint.onTask:generateTaskPromptFromDataWithSchema | 两 OMC 校验 Task-T,使用 filled.data 构造 SpnTaskInput | +| 两地市 OMC → 工作台协商 | generateNegotiationProposePromptFromData,按实际缺失字段生成 | validateProposePromptAndDataFilling,按 items 从当前城市输入提取答案 | +| 工作台 → OMC Accept | generateNegotiationAcceptPromptFromData | 从正式 metadata 取正文,validateAcceptPromptAndDataFilling 后合并请求字段并复核业务必填信息 | +| 工作台 → OMC Reject/Abort | 对应 generateNegotiationRejectPromptFromData/generateNegotiationAbortPromptFromData | 对应 validateRejectPromptAndDataFilling/validateAbortPromptAndDataFilling,结束任务且不执行诊断 | +| 独立授权 | generateAuthPromptFromDataWithSchema | validateAuthPromptAndDataFilling 后构造并应用 AuthorizationPolicy | +| 独立订阅 | generateNotificationPromptFromDataWithSchema | validateNotificationPromptAndDataFilling 后构造 NotificationPolicy | + +自然语言 FromText 和结构化 FromDataWithSchema 是替代入口,不是同一消息必须串行全部执行的步骤。 +本 Demo 主路径选择结构化数据;Task-T 自然语言另有测试,不宣称覆盖 SDK 全部公开接口。 +端口资源归属、投诉分类和 OSS 流水号是 SPN 业务层规则;正式集成应替换为宿主/OMC 的实际业务判断。 + +协商校验不再读取 parts 摘要;会核对 A2A taskId/contextId 和协商 id/round/maxRounds。 +SDK 拒绝原始任务时,不使用未经验证的局部数据,重新协商完整必要字段。 +接收 Accept 后只合并本轮请求字段;错误正文、缺参和跨协商回复均不能进入诊断。 + +1.1.0 的授权/通知生成入口面向授权操作及订阅请求,并不是任意业务结果生成接口。 +诊断结果、授权回执和抢通事件由 OMC 业务产生。RecoveryNotification 从正式 Notification-T artifact +metadata 检查 SPN 最终抢通结果,避免仅凭 artifact 名称/摘要就关闭订阅。 +这不是对任意外部通知的通用语义验证,也不在工作流引擎内实施。 + +完整角色映射、字段规则、FromText/FromData 选择、宿主拒绝策略和可重复验证范围见 +[业务回调指南第 9 节](BUSINESS_CALLBACKS.md#9-示例业务侧的-a2a-t-110-调用参考)。 +严格离线测试只处理明确样例输入,不从模板示例臆造参数;不能代替真实 LLM 和现网 OMC 联调。 + +业务调用整改阶段的验证记录(后续默认演示及日志展示变更见下节):两分支均执行 `mvn -q test`; +补充端口修正/原上下文保留用例后,又分别执行 +`mvn -q -pl samples -am "-Dtest=EmbeddedA2AServerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`。 +最终 Surefire 汇总为 main 202、dev 254 项,失败/错误/跳过均为 0。 +dev 的直连及 Order Spring E2E 各 3 项,接收端真实 SDK 往返用例共 13 项。 +主测试日志位于各运行目录 `logs/business-sdk-*-verified.log`;最后补充用例日志为 +`logs/business-sdk-*-input-preservation.log`,详细报告位于各模块 `target/surefire-reports/`。 + +## 默认演示与 SSE 展示更新(2026-08-31) + +本地 SpringSpnDemo 无需 VM 参数即演示 City1 缺参协商、City2 完整输入直接诊断; +`-Da2at.samples.negotiation=false` 关闭演示。设置通过当前 Spring 应用实例传递, +不修改 JVM 全局开关,普通宿主默认关闭;外部 OMC 模式默认关闭并拒绝显式注入。 + +SSE pretty 日志保留事件控制字段,将 JSON 单独放在标明非原始报文的数据区,不再逐行重复 `data:`。 +原始观测正文保持不变,`WORKFLOW_ENGINE_PROTOCOL_PRETTY=false` 可查看脱敏后的原始正文; +非 JSON/不完整正文保持原样,脱敏和容量限制仍生效。 + +两分支分别执行 `mvn -q test`:main 208 项、dev 261 项,失败/错误/跳过均为 0。 +直连及 Order Spring E2E 各 4 项,包括默认仅 City1 协商、显式开关及双城市额外回归; +另有外部 OMC 禁止注入、无全局设置污染、SSE 原文不变及事件边界测试。 +日志为各分支 `logs/demo-display-main-verified.log` 或 `logs/demo-display-dev-verified.log`。 +仍为真实 SDK 加离线 LLM/本地 OMC 和平台模拟器验证,不代表现网模型或设备联调。 + +## 授权格式与协商字段回归(2026-08-31 09:28) + +按 1.1.0 正式资源修正授权编号、字段标签、换行和删除选择器,加入直接读取 jar 内多条策略示例的测试。 +真实运行出现的“本地市实际接入端口名称”提取结果已纳入离线 fixture;协商回复保留经 SDK +语义校验的原始编号信息项名称。详情见 [业务回调指南](BUSINESS_CALLBACKS.md)。 + +最终版本全量验证:main 221 项、dev 274 项,失败/错误/跳过均为 0(统计包含 Spring starter 模块)。 +main 直连与 dev 直连、Order 平台模拟路线均通过;默认场景确认 City1 Propose/Accept、 +City2 无协商,两个 OMC 授权成功、订阅成功,工作流完成并释放连接。 +日志为 main 工作树 `logs/auth-canonical-main-verify.log` 和 dev 工作树 +`logs/auth-canonical-dev-verify.log`,JUnit 明细位于各模块 `target/surefire-reports/`。 + +这些结果基于正式 SDK jar、离线 LLM provider 和本地 OMC/Order 模拟器,不是现网或真实模型验收。 +Notification-T 同步补齐失败原因字段名和拒绝详情日志,但历史日志没有 slot 错误详情, +不能断言此前真实模型拒绝订阅的唯一原因已经确认;需真实模型重跑核验。 diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index 64244cf6..469be164 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -29,7 +29,6 @@ | `controlPoint(ControlPoint)` | 必填 | - | 用户决策实现 | | `runtimeIntent(String)` | 可选 | `""` | 自然语言意图,用于上下文组装 | | `lang(String)` | 可选 | `"zh"` | 语言提示(`"zh"` 或 `"en"`) | -| `a2atEnvPath(String)` | 可选 | null | `.env` 文件路径(A2A-T SDK 配置) | | `credentialsConfigPath(String)` | 可选 | null | 凭证 JSON 文件路径 | | `sslVerify(boolean)` | 可选 | `true` | 是否验证 TLS 证书 | | `caCertsPath(String)` | 可选 | null | CA 证书 PEM 文件路径 | @@ -44,7 +43,6 @@ ExecutionResult result = ExecutePsop.builder() .agentCards(cards) .controlPoint(cp) .runtimeIntent("诊断故障") - .a2atEnvPath(".env") .sslVerify(false) .execute() .get(10, TimeUnit.MINUTES); @@ -58,194 +56,43 @@ ExecutionResult result = ExecutePsop.builder() ### WorkflowEngineClient -发送 A2A 消息的核心接口。 - ```java -public interface WorkflowEngineClient { - // 发送消息(可选 contextId 和预设 metadata) - CompletableFuture sendMessage( - String agentName, String message, - String contextId, Map metadata); - - // 便捷方法:无 contextId,无 metadata - CompletableFuture sendMessage( - String agentName, String message); - - void setControlPoint(ControlPoint controlPoint); - - void setEventCallback(EventCallback callback); - - // A2A-T 模板查询(SDK 未配置时返回空集合,不抛异常) - List getPrompts(); // 全部扩展的可用模板 - List getNegotiationPrompts(); // 协商模板(3 类型 x 2 阶段 + abort) - Optional getPrompt(TemplateUri); // 按 URI 查询单个模板 - - // 任务生命周期(A2A 协议操作) - CompletableFuture getTask(String agentName, String taskId); - CompletableFuture cancelTask(String agentName, String taskId); - CompletableFuture subscribeToTask( - String agentName, String taskId, Consumer> callback); - - void close(); -} +CompletableFuture dispatch(TaskRequest request, MessageContent content, ControlPoint callbacks); +CompletableFuture sendMessage(String agentName, MessageContent content); +CompletableFuture getTask(String agentName, String taskId); +CompletableFuture cancelTask(String agentName, String taskId); +CompletableFuture subscribeToTask(String agentName, String taskId, Consumer> callback); +long callbackTimeoutSeconds(); +void setControlPoint(ControlPoint callbacks); +void setEventCallback(EventCallback callback); +void close(); ``` -> 流程外操作位于 `ExtensionSender`,不在本接口:Authorization-T 是一次性请求,Notification-T 是长连接订阅。见下方 `ExtensionSender` 章节。 - -#### sendMessage - -| 参数 | 类型 | 说明 | -|-------------|-----------------------|-----------------------------------------| -| `agentName` | `String` | 目标智能体名称(须匹配 AgentCard.name) | -| `message` | `String` | 完整组装后的消息文本 | -| `contextId` | `String` | 可选上下文 ID(null = 自动生成) | -| `metadata` | `Map` | 可选预设 metadata | - -**返回:** `CompletableFuture`,包含响应文本、Task 对象、metadata、任务状态。 - -引擎内部在发送前自动处理: - -1. Task-T 提示词生成(如果 AgentCard 声明了 Task-T) -2. Negotiation-T metadata 注入(后续协商消息) -3. 认证头注入(来自凭证文件或 AuthProvider) -4. A2A-Extensions 头(仅 metadata 中实际存在的扩展) - -接收后自动处理: - -1. 从 SSE 事件提取响应文本 -2. 提取 metadata(task 级 + artifact 级合并) -3. Negotiation-T 自动循环(metadata 携带 Negotiation-T key 时触发;见下方「协商自动循环行为」) +执行器内部调用 dispatch;onTask 不自行发送。内容是最终 parts/metadata/extensions,引擎只管理信封和交互,不创建 A2ATClient,不按 AgentCard 声明生成内容。模板查询和生成接口请直接使用宿主 SDK。 -#### sendMessageFromData(结构化数据输入路径) +只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 +终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 +宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 +通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; +结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 -```java -CompletableFuture sendMessageFromData( - String agentName, - String message, - Map data, - Map schema, - TemplateUri templateUri); -``` - -| 参数 | 类型 | 说明 | -|----------------|-----------------------|---------------------------------------------------------| -| `agentName` | `String` | 目标智能体名称 | -| `message` | `String` | 伴随的短消息文本(A2A 消息 parts 文本) | -| `data` | `Map` | 结构化业务数据(原始字段,非渲染后的提示词) | -| `schema` | `Map` | JSON Schema,描述各数据字段的含义 | -| `templateUri` | `TemplateUri` | 明确指定当前 SDK 目标模板;不得为 null | - -**输入方式选型**:业务方持有结构化数据(如投诉工单字段)时优先用本方法——SDK 走 -`generateTaskPromptFromDataWithSchema` schema-aware 管线(跳过场景识别,但 slot 映射可能调用 -SDK 配置的 LLM);持有自然语言 -描述时用 `sendMessage`(SDK 场景识别管线)。两条轨道都完整经过 Negotiation-T 自动循环、 -认证和扩展头注入。 - -`ExtensionSender.sendExtensionMessageFromData(agentName, instruction, data, schema, templateUri, extension)` -为独立 Authorization-T 操作提供同样的结构化数据轨道;Notification-T 结构化订阅优先使用 -`openNotificationFromData`。所有 Notification-T 入口都必须显式指定 SDK 模板,因此通用 -发送器同时支持 `SERVICE_RECOVERY` 与 `SUBSCRIBE_INCIDENT`,不再暗含 SPN 场景默认值。 - -#### 协商自动循环行为 - -- **触发条件**:响应 metadata 携带 Negotiation-T 扩展 key(agent 通过 `Message(ROLE_AGENT)` + - `INPUT_REQUIRED` 发起协商),并同时携带 SDK 生成的 `templateUri` 与 - `negotiationContext={id,round,maxRounds,performative}`。缺少任一字段均按非法协商报文失败关闭。 -- **每轮**:`NegotiationTHandler` 用 `validateProposePromptAndDataFilling` 按 `templateUri` - 校验 Propose 并抽取参数 → `ControlPoint.onNegotiation` 生成决策 → SDK 内容层渲染 - Accept / Reject / Abort → Accept 在发出前再用 `validateAcceptPromptAndDataFilling` 校验 → - 结束报文保留收到的 Propose `NegotiationContext` 及轮次 → 发送给 agent → 递归检查下一轮。 -- **强类型决策**(`onNegotiation` 返回值): - - `NegotiationDecision.acceptText/acceptData` — 接受;分别走 SDK fromText/fromData - - `NegotiationDecision.rejectText/rejectData` — 拒绝;分别走 SDK fromText/fromData - - `NegotiationDecision.abortText/abortData` — 终止;分别走 SDK fromText/fromData - - 不接受 `data:`、`reject:`、`abort:` 等字符串控制前缀。 - - Information Reject 必须逐项表达:`rejectData` 的每个 key 是一个无法提供的信息项名称, - value 是该项无法提供的具体原因;多个请求项不可合并成单一“拒绝原因”。 -- **轮次耗尽**:超过 `maxNegotiationRounds` 后不再继续循环,通过 SDK abort 模板发送终止消息 - (尽力而为,发送失败仅记日志),发出 `NEGOTIATION_FAILED` 事件,agent 最后一次回复作为最终响应。 -- **协商上下文**:执行引擎不支持 `startNegotiation` / `receiveNegotiation` / - `continueNegotiation` 旧状态机入口。引擎只接受规范键 `negotiationContext`,结构固定为 - `{id, round, maxRounds, performative}`;不再读取 `negotiation_context` 等旧键。 - -#### 模板查询 - -模板查询暴露 SDK 内容层的模板目录,返回 `PromptTemplate`(templateUri / description / content): - -- `getPrompts()`:全部 A2A-T 扩展(Task-T / Notification-T / Authorization-T / Negotiation-T) - 在当前语言(`.env` 的 `A2AT_LANGUAGE`)下的可用模板,按 URI 排序。 -- `getNegotiationPrompts()`:协商模板全集(信息/目标/可行性协商的 propose 与 accept-reject, - 加通用 abort 模板),固定顺序。 -- `getPrompt(uri)`:按 `TemplateUri` 查单个模板;不存在时返回空。 - -模板 URI 常量用 SDK 的 `StandardTemplates`(如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE`), -避免手写 URI 字符串。 +返回 `new NegotiationReply.Send(content)` 发送最终内容; +返回 `new NegotiationReply.Stop(code, reason)` 只在本地停止,不生成 Abort。 +同一任务/会话/轮次的重复等待事件不会重复回调、重复提交;未变化状态通过 getTask 观察。 +`maxNegotiationExchanges` 默认 3,是独立于 SDK context.maxRounds 的本地交互资源预算。 +超时、预算耗尽、回调缺失均明确失败,不默认 Accept,也不自动生成 Abort。 +Accept/Reject 的 SUBMITTED/WORKING ACK 仍需等待任务结果,不重发原命令。 +业务发送 Abort 后,即使远端用 COMPLETED 确认,也不能判为诊断成功。 ### ExtensionSender -基于调用方专用 `A2ATransport` 的独立协议操作门面。在独立业务时机发送一次性 Authorization-T 请求、建立长连接 Notification-T 订阅。它们不是工作流 DAG 节点。跳过 -Task-T 提示词生成和 Negotiation-T 自动循环;Notification-T 后续事件通过订阅回调交付。 - ```java -public interface ExtensionSender { - CompletableFuture sendExtensionMessage( - String agentName, String instruction, - String naturalLanguageInput, TemplateUri templateUri, - A2ATExtension extension); // 仅 Authorization-T - - // 便捷方法:Authorization-T - CompletableFuture sendAuthorization( - String agentName, String instruction, String naturalLanguageInput); - - CompletableFuture sendExtensionMessageFromData( - String agentName, String instruction, - Map data, Map schema, - TemplateUri templateUri, A2ATExtension extension); - - // 便捷方法:Notification-T(长连接 SSE) - CompletableFuture sendNotification( - String agentName, String instruction, String naturalLanguageInput, - TemplateUri templateUri, Consumer> eventCallback); - - // 接口契约:Notification-T(长连接 SSE + 事件回调) - CompletableFuture openNotification( - String agentName, String instruction, - String naturalLanguageInput, TemplateUri templateUri, - Consumer> eventCallback); - - CompletableFuture openNotificationFromData( - String agentName, String instruction, - Map data, Map schema, - TemplateUri templateUri, - Consumer> eventCallback); -} +CompletableFuture sendAuthorization(String agentName, MessageContent content); +NotificationSubscription openNotification(String agentName, MessageContent content, + BiConsumer listener); ``` -| 参数 | 类型 | 说明 | -|------------------------|-----------------|-------------------------------------| -| `agentName` | `String` | 目标智能体名称(须匹配 `AgentCard.name`) | -| `instruction` | `String` | 简短指令文本;成为 A2A 消息体的 `parts[].text` | -| `naturalLanguageInput` | `String` | 自然语言轨道输入;生产优先使用 `sendExtensionMessageFromData` / `openNotificationFromData`,由 SDK 生成完整 metadata | -| `templateUri` | `TemplateUri` | 显式指定当前 SDK 模板(内置或集成方提供的资源) | -| `extension` | `A2ATExtension` | 扩展枚举(勿硬编码 URI) | -| `eventCallback` | `Consumer>` | 可选 SSE 事件回调。Map 含 `event_kind`、`agent`、`task_id` 及对应的状态/产物字段 | - -`NotificationSubscription` 将首个 ACK(`acknowledgement()`)与长连接终止 -(`completion()`)分开,并提供 `heartbeat()`、`isHealthy(maximumIdle)` 和幂等 `close()`。 -通用一次性发送方法会明确拒绝 Notification-T;必须调用 -`openNotification` / `openNotificationFromData`,避免丢失长连接关闭句柄。 -Task、Authorization、Notification 必须使用不同的 transport/runtime 实例;关闭单次工作流不得关闭订阅。 - -**报文格式**:发送给智能体的 A2A 消息结构为 `parts[].text = instruction`,`metadata = { "<扩展URI>": "<扩展值>" }`。以 Authorization-T 为例: - -```json -{ - "parts": [{"text": "授权诊断操作"}], - "metadata": { - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1": "<结构化授权策略>" - } -} -``` +授权和订阅接收宿主生成的最终内容。三类操作使用独立 transport/runtime/context,成功与否不阻断工作流。openNotification 先注册 handle 再开始 I/O,监听器直接收到 handle 和完整 ReceivedMessage。acknowledgement() 是实际 ACK;超时失败。close() 请求关闭,completion() 在流真正退出后完成。 ### WorkflowEngineClientConfig @@ -266,11 +113,9 @@ Task、Authorization、Notification 必须使用不同的 transport/runtime 实 | `sendExecutorQueueCapacity` | `int` | `256` | 发送线程池有界队列容量 | | `authProvider` | `AuthProvider` | null | 自定义认证提供器 | | `credentialsConfigPath` | `String` | null | 凭证 JSON 文件路径;显式配置缺失或损坏时启动失败 | +| `credentialEncryptionKey` | `String` | null | 宿主显式传入凭据解密密钥,不从 LLM .env 加载 | | `credentialsConfig` | `Map` | null | 内联凭证配置;AgentCard 声明安全要求时必须匹配 | -| `a2atEnvPath` | `String` | null | `.env` 文件路径 | -| `maxNegotiationRounds` | `int` | `3` | 协商自动循环最大轮数(超限走 abort 终止) | -| `customHandlers` | `List` | null | 自定义扩展处理器 | -| `negotiationParamSchema` | `Map` | null | 协商 validate-and-fill 的业务参数 JSON Schema;null = 空 schema(仅提取协商上下文参数)。声明业务字段让引擎核心保持领域无关 | +| `maxNegotiationExchanges` | `int` | `3` | 本地交互预算,独立于 SDK maxRounds;耗尽仅本地失败 | ```java WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() @@ -279,9 +124,8 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .clientCertPath("client-cert.pem") .clientKeyPath("client-key.pem") .sendTimeoutSeconds(900) - .a2atEnvPath(".env") .credentialsConfigPath("creds.json") - .maxNegotiationRounds(5) + .maxNegotiationExchanges(5) .authProvider(myProvider) .build(); ``` @@ -299,48 +143,9 @@ public interface AuthProvider { 每次消息发送时调用。`headers` 是可变 Map;直接添加 `Authorization`、自定义头等。`AuthProvider` 可作为唯一认证来源,包括 AgentCard 的 `securityRequirements` 非空但未配置 credentials 的场景。若同时配置 credentials,两者分别计算后合并;若同名 Header 生成不同值,引擎会抛出 `SecurityException`,不会静默覆盖。 -### ExtensionHandler - -自定义 A2A-T 扩展的处理器接口。 - -```java -public interface ExtensionHandler { - String extensionKeyword(); - - CompletableFuture> beforeSend( - AgentCard agentCard, String messageText, - Map metadata, - A2ATClient a2atClient, ControlPoint controlPoint); - - CompletableFuture afterReceive( - AgentCard agentCard, SendMessageResult result, - A2ATClient a2atClient, ControlPoint controlPoint, - EventCallback eventCallback); - - // 协商 validate-and-fill 的业务参数 schema(默认空 schema) - Map negotiationParamSchema(); -} -``` - -内置:Task-T 和 Negotiation-T 自动处理。可通过配置的 customHandlers 注册自定义处理器 -(同 keyword 后注册的覆盖内置)。 +### A2atMessages -### A2ATContentFacade - -SDK 协商内容层的引擎门面,经 `A2ATransport.getContentFacade()` 获取(未配置 `.env` 时为 null)。 -SDK 消息生成/校验/模板查询的完整能力面,方法与 SDK `A2ATClient` 一一对应: - -| 分组 | 方法 | 说明 | -|------|------|------| -| fromData 生成 | `generateProposeFromData` / `generateAcceptFromData` / `generateRejectFromData` / `generateAbortFromData` | 类型化数据确定性渲染,无 LLM | -| fromText 生成 | `generateProposeFromText` / `generateAcceptFromText` / `generateRejectFromText` / `generateAbortFromText` | 自由文本 + 一步 LLM 抽取 | -| 无状态上下文 | `MetadataContent.buildMetadataContent` / `NegotiationContext` | 生成并传递 `{id, round, maxRounds, performative}`;SDK 为输出消息标记 performative | -| 校验提取 | `validatePropose` / `validateAccept` / `validateReject` / `validateAbort` | 规则门 + LLM 语义校验 + 参数合并;需传 `NegotiationContext`(null = 非协商消息) | -| 模板查询 | `getPrompts` / `getNegotiationPrompts` / `getPrompt` / `getNegotiationPrompt` | 模板目录枚举与查询 | -| 工具 | `toMetadata(MetadataContent)` | 生成消息转 A2A metadata map(含 `templateUri`、`negotiationContext` key) | - -模板 URI 用 `StandardTemplates` 常量传参。失败抛 `A2ATError` 子类;协议内容必须 -fail-closed,不得将原始文本作为对应 A2A-T URI 的值降级发送。 +通过 MessageContent(parts, metadata, extensions) 提交自定义扩展内容,不注册引擎内容处理器。A2atMessages.from(MetadataContent, List>) 保留 SDK metadata 原位置并激活对应 URI;contextOf(ReceivedMessage) 或 contextOf(Map) 读取并检查规范协商上下文。只有 a2a-t-core 依赖,没有内容生成或语义校验。 ### A2AJavaClientRuntime @@ -413,77 +218,21 @@ Map normalized = AgentCardNormalizer.normalize(rawMap); ## dev.openan.workflow.engine.control -### TaskDispatcher / TaskSubmission - -`onTask` 只获得窄化的 `TaskDispatcher` 能力。业务使用: - -```java -dispatcher.dispatch(TaskSubmission.fromText(agentName, text, templateUri)); -dispatcher.dispatch(TaskSubmission.fromUnclassifiedText(agentName, text)); -dispatcher.dispatch(TaskSubmission.fromData( - agentName, instruction, data, schema, templateUri)); -``` - -第一种对已知 Task-T 模板调用 SDK 显式自然语言接口且不重复识别场景;第二种仅在模板未知时 -调用 SDK 自然语言场景识别;第三种调用 schema-aware fromData。`TaskSubmission` 在进入 -协议层前校验 agent、instruction、schema 和模板扩展类型,并防御性复制业务数据。 - ### ControlPoint -用户决策接口。每个方法单一职责。 - ```java -public interface ControlPoint { - // 接管远程任务,提交自然语言或结构化 Task-T 输入。 - CompletableFuture onTask( - TaskRequest request, TaskDispatcher taskDispatcher); - - // 自环节点:本地处理,不发 A2A-T 消息给自己。 - default CompletableFuture onSelfTask(TaskRequest request); - - // 条件分支决策。只决定下一步去哪个 step。 - CompletableFuture onRoute( - String stepName, Map results, - List conditions); - - // 返回强类型 Accept / Reject / Abort 决策。 - default CompletableFuture onNegotiation( - NegotiationRequest request); +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -| 方法 | 触发时机 | 返回值 | -|-------------------|----------------------------------------|-------------------------------| -| `onTask` | 步骤向其他智能体分派任务时 | `TaskResponse`(成功 + 输出) | -| `onSelfTask` | `SELF_LOOP` 步骤本地执行(不走 A2A-T) | `TaskResponse`(成功 + 输出) | -| `onRoute` | 步骤完成后、下一步前 | `RouteDecision`(nextStep) | -| `onNegotiation` | 智能体返回 `INPUT_REQUIRED` 时 | `NegotiationDecision` | - -`NegotiationRequest` 向业务方提供 `agentName`、`concern`、`sessionId`、`round`、 -`maxRounds`、强类型 `NegotiationPerformative`、协商 `kind`、`templateUri`、SDK 校验抽取后的只读 -`parameters` 和只读 `metadata`。业务判断优先使用 `parameters`;`metadata` 只用于高级诊断。 -只有带完整上下文的 `PROPOSE` 才会进入回调;上下文缺失、轮次非法或模板不受支持时均失败关闭。 -业务方只需根据这些字段返回 `acceptText/acceptData`、`rejectText/rejectData` 或 -`abortText/abortData`,无需发送消息或构造协议 metadata。 -对于 Information Reject,业务方必须对 Propose 中每个无法提供的请求项分别给出原因,例如: - -```java -NegotiationDecision.rejectData(Map.of( - "接入端口名称", "当前账号没有资源系统查询权限", - "投诉分类", "当前账号没有资源系统查询权限")); -``` - -### DefaultControlPoint - -默认实现,提供合理默认值: - -- `onTask`:默认用 `TaskSubmission.fromUnclassifiedText` 交给 `TaskDispatcher`,返回 success/output; - 已知业务模板的宿主应覆盖此方法并使用 `fromText(..., templateUri)` 或 `fromData(...)` -- `onSelfTask`:原样回传任务消息(本地逻辑请覆盖实现) -- `onRoute`:选第一个非终止分支 -- `onNegotiation`:返回通用的 `acceptText` 决策 - -继承此类,只需覆盖需要自定义的方法。 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 +onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 +未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 ### EventCallback @@ -581,7 +330,7 @@ Map registerAgentCard(Map agentCard) ### WorkflowExecutor -中层 DAG 遍历引擎。遍历工作流步骤,通过 `ContextBuilder` 组装上游上下文,并行分派子任务,应用步骤成功策略(`ALL_SUCCESS` / `ANY_SUCCESS` / `SELF_LOOP`),并路由到下一步。 +中层 DAG 遍历引擎。遍历工作流步骤,通过 `ContextBuilder` 选择强类型上游执行结果,并行分派子任务,应用步骤成功策略(`ALL_SUCCESS` / `ANY_SUCCESS` / `SELF_LOOP`),并路由到下一步。 SDK 用户通常不直接实例化——`ExecutePsop` 内部封装了它。供需要在不含 runner 生命周期管理的情况下运行遍历层的高级集成使用。 @@ -594,7 +343,7 @@ ExecutionResult result = executor.run().join(); ### ContextBuilder -包级私有辅助类,按 `contextFrom` 规则折叠上游步骤输出,为每个步骤组装上下文消息(`"*"` = 所有前驱步骤,指定名称 = 选择性继承)。不属于公共 API 接口。 +包级私有辅助类,按 `contextFrom` 规则选择上游步骤结果并构造 `WorkflowInput`(省略 = 直接前驱,`[]` = 不聚合上游,`"*"` = 所有祖先,指定名称 = 选择性聚合)。它不生成提示词,也不属于公共 API 接口。 --- @@ -619,7 +368,7 @@ ExecutionResult result = executor.run().join(); | `subtasks` | `List` | `List.of()` | 此步骤分派的子任务 | | `next` | `List` | `List.of()` | 条件后续步骤 | | `layer` | `int` | `0` | 上下文层(0 = 仅运行时意图) | -| `contextFrom` | `List` | null | 继承上下文的步骤(`"*"` = 所有前驱步骤) | +| `contextFrom` | `List` | null | 聚合来源;省略 = 直接前驱,`[]` = 不聚合,`"*"` = 所有祖先,或指定祖先名称 | | `stepType` | `StepType` | `ALL_SUCCESS` | 执行模式 | ### StepType @@ -656,35 +405,72 @@ ExecutionResult result = executor.run().join(); | `step` | `String` | 下一步名称(`"end"` 表示终止) | | `condition` | `String` | 条件表达式(`"success"`、`"fail"` 等) | -### TaskRequest +### TaskRequest / BusinessInput + +TaskRequest 使用 getXxx() 访问器: + +| 字段 | 含义 | +|---|---| +| executionId / taskId | 本地执行/逻辑任务标识,不是远端协议 ID | +| stepName / agentName / skill | 当前步骤、目标智能体和技能 | +| instruction / language | 当前指令,不含引擎拼接的历史 | +| input | BusinessInput:文本或任意 JSON 可序列化数据,二选一,不含 schema | +| workflowInput | WorkflowInput(runtimeIntent, upstreamResults),与当前输入分离 | + +BusinessInput.text(value) / BusinessInput.data(value) 创建输入快照。 +WorkflowInput、UpstreamStepResult、ReceivedMessage、NegotiationRequest 等 record 使用 field() 访问器。 + +| contextFrom | 上游选择 | +|---|---| +| 未指定 / null | 已产生结果的直接前驱 | +| [] | 不聚合上游,runtimeIntent 仍保留 | +| ["*"] | 已产生结果的全部祖先 | +| 显式祖先名称 | 按声明顺序选取对应结果 | + +contextFrom 只选择证据,不建立执行依赖;依赖由 next 定义。 +未知或非祖先名称、通配符与名称混用均非法;未激活分支不虚构结果。 +引擎不把窗口附加到 instruction/parts,也不调用 LLM 映射上游;由宿主决定怎么消费或映射下游输入。 + +窗口结构:stepName → taskResults[] → outputs[] / receivedMessages[]。 +TaskExecutionResult 还保留 agentName、skill、逻辑 taskId、taskDescription、status、 +error、errorCode、errorDetails。多子任务不混合,嵌套数组仍作为一个输出项。 +输出不要求来自 LLM,也不要求符合投诉模板。 + +### MessageContent / ReceivedMessage + +```java +record MessageContent(List> parts, Map metadata, Set extensions) {} +record ReceivedMessage(MessageContent message, Map taskMetadata, List artifacts) {} +``` + +通过 MessageContent.text(text)、MessageContent.parts(parts) 或构造器创建快照。 +TextPart、DataPart、FilePart 保留顺序及各自 metadata,文件引用不会自动下载。 +MessageContent 不提供 role、目标、messageId、taskId、contextId 或认证头。 +业务 metadata 中即使存在 contextId 字段,也不能覆盖真实 A2A 信封。 + +ReceivedMessage 分别保留消息 metadata、任务 metadata、artifact 身份及 parts/metadata,不互相覆盖。 +message 可以为空;只有 metadata 的业务结果仍可从完整视图读取。 +便利 outputs 按 artifact 与 part 顺序提取 TextPart 文本和 DataPart 数据; +没有 artifact 时,可提取成功终态或独立 Message 的正文。 +FilePart 仅在完整视图提供。不解析文本、不拼接相邻文本、不拍平嵌套业务数组。 +失败状态消息只保留作证据,不进入 outputs;已返回的有效部分 artifact 仍保留。 +SSE append/replace 按 artifact 组装,最终快照不重复累加。 + +本地 onSelfTask 返回 TaskResult.success(List),允许空列表; +TaskResult.failure(code, message) 将错误与输出分开,builder 可保留有效部分输出。 +远端 TaskResult 的 receivedMessages 是完整证据,便利输出由它派生。 +远端 Task 只有 COMPLETED 才成功,独立 A2A Message 也可完成交互。 +进度/协商提示不会因为含文本就变成工作流成功。 + -| 字段 | 类型 | 说明 | -|----------------|----------|------------------| -| `agentName` | `String` | 目标智能体 | -| `skill` | `String` | 智能体技能 | -| `message` | `String` | 完整消息文本 | -| `description` | `String` | 任务描述 | -| `context` | `String` | 上下文消息 | -| `stepName` | `String` | 源步骤名 | -| `subtaskIndex` | `int` | 步骤内子任务索引 | -### TaskResponse +### NegotiationRequest / NegotiationReply -| 字段 | 类型 | 说明 | -|------------|-----------|--------------------| -| `success` | `boolean` | 任务是否成功 | -| `output` | `String` | 响应文本 | -| `error` | `String` | 错误信息(失败时) | -| `metadata` | `Map` | 响应 metadata | +参见 [BUSINESS_CALLBACKS](BUSINESS_CALLBACKS.md). ### SendMessageResult -| 字段 | 类型 | 说明 | -|-------------|----------|-------------------------------------------| -| `text` | `String` | 提取的响应文本 | -| `task` | `Task` | SDK Task 对象 | -| `metadata` | `Map` | 响应 metadata(task + artifact 合并) | -| `taskState` | `String` | 最终任务状态(如 `TASK_STATE_COMPLETED`) | +getReceivedMessages() 是保留层级的响应来源,getOutputs() 为便利投影;getTask()/getTaskState() 保留实际远端状态,failureCode/failureMessage 为独立本地交互失败。text 和扁平 metadata 仅用于传输诊断,不用它们代替完整业务响应。 ### ExecutionResult @@ -746,12 +532,11 @@ ExecutionResult result = executor.run().join(); ## 错误处理 -- 智能体调用失败抛出 `RuntimeException`(包装原始异常)。 -- 协商超过 `maxNegotiationRounds` 轮后:发出 `NEGOTIATION_FAILED` 事件,通过 SDK abort - 模板向 agent 发送终止消息(尽力而为),agent 最后一次回复作为最终响应;循环不再继续。 -- 协商 follow-up 的 SDK 内容生成失败时直接失败;不会把裸业务输入伪装成 Negotiation-T metadata。 -- 必需认证信息缺失或获取失败时请求失败关闭,不会无认证继续发送。 -- SSE 流在终态事件后的连接关闭日志为 `DEBUG` 级别(预期行为)。 +- 回调异常/空值/超时明确失败;SDK 内容错误由宿主转换为 BusinessFailure,必要时保留安全的 code/details。 +- maxNegotiationExchanges 耗尽或本地 Stop 只结束本地交互,不自动发送 Abort。业务 Send(Abort) 不算诊断成功。 +- 远端状态和错误与业务输出分开,失败状态消息不成为 outputs,已有部分 artifact 保留。 +- 缺失必需认证信息时拒绝发送,不静默匿名访问。 +- SDK 流退出/传输错误日志是传输观察,不等同于工作流最终状态;以 ExecutionResult 和远端任务状态判断结果。 --- diff --git a/docs/zh/BUSINESS_CALLBACKS.md b/docs/zh/BUSINESS_CALLBACKS.md new file mode 100644 index 00000000..49246a28 --- /dev/null +++ b/docs/zh/BUSINESS_CALLBACKS.md @@ -0,0 +1,312 @@ +# 业务回调集成契约 + +首发 API,A2A-T SDK `1.1.0`,A2A Java `1.2.0.Final`。 +引擎负责 DAG、标准 A2A 信封、认证、传输和任务等待;宿主负责消息内容、schema、模板、语义校验和 LLM。 +直连与 dev 东信转发使用相同业务契约。 + +## 1. 回调接口 + +```java +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); +} +``` + +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 +onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 +未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 + +```java +ControlPoint callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .onSelfTask(request -> CompletableFuture.completedFuture( + TaskResult.success(List.of(Map.of( + "sourceResults", request.getWorkflowInput().upstreamResults()))))) + .onRoute(request -> CompletableFuture.failedFuture( + new IllegalStateException("Supply a routing policy for " + request.stepName()))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Manual confirmation required"))) + .build(); +``` + +示例显式发送普通文本,不宣称为 Task-T;本地透传、路由和停止策略需替换为真实业务逻辑。 +onTask 只返回内容,不自行再次发起任务网络请求。 +导入 `dev.openan.workflow.engine.control.ControlPoint`、`model.*`, +以及 Java 的 List、Map、CompletableFuture。 + +## 2. TaskRequest 与上游窗口 + +TaskRequest 使用 getXxx() 访问器: + +| 字段 | 含义 | +|---|---| +| executionId / taskId | 本地执行/逻辑任务标识,不是远端协议 ID | +| stepName / agentName / skill | 当前步骤、目标智能体和技能 | +| instruction / language | 当前指令,不含引擎拼接的历史 | +| input | BusinessInput:文本或任意 JSON 可序列化数据,二选一,不含 schema | +| workflowInput | WorkflowInput(runtimeIntent, upstreamResults),与当前输入分离 | + +BusinessInput.text(value) / BusinessInput.data(value) 创建输入快照。 +WorkflowInput、UpstreamStepResult、ReceivedMessage、NegotiationRequest 等 record 使用 field() 访问器。 + +| contextFrom | 上游选择 | +|---|---| +| 未指定 / null | 已产生结果的直接前驱 | +| [] | 不聚合上游,runtimeIntent 仍保留 | +| ["*"] | 已产生结果的全部祖先 | +| 显式祖先名称 | 按声明顺序选取对应结果 | + +contextFrom 只选择证据,不建立执行依赖;依赖由 next 定义。 +未知或非祖先名称、通配符与名称混用均非法;未激活分支不虚构结果。 +引擎不把窗口附加到 instruction/parts,也不调用 LLM 映射上游;由宿主决定怎么消费或映射下游输入。 + +窗口结构:stepName → taskResults[] → outputs[] / receivedMessages[]。 +TaskExecutionResult 还保留 agentName、skill、逻辑 taskId、taskDescription、status、 +error、errorCode、errorDetails。多子任务不混合,嵌套数组仍作为一个输出项。 +输出不要求来自 LLM,也不要求符合投诉模板。 + +## 3. 最终内容与完整响应 + +```java +record MessageContent(List> parts, Map metadata, Set extensions) {} +record ReceivedMessage(MessageContent message, Map taskMetadata, List artifacts) {} +``` + +通过 MessageContent.text(text)、MessageContent.parts(parts) 或构造器创建快照。 +TextPart、DataPart、FilePart 保留顺序及各自 metadata,文件引用不会自动下载。 +MessageContent 不提供 role、目标、messageId、taskId、contextId 或认证头。 +业务 metadata 中即使存在 contextId 字段,也不能覆盖真实 A2A 信封。 + +ReceivedMessage 分别保留消息 metadata、任务 metadata、artifact 身份及 parts/metadata,不互相覆盖。 +message 可以为空;只有 metadata 的业务结果仍可从完整视图读取。 +便利 outputs 按 artifact 与 part 顺序提取 TextPart 文本和 DataPart 数据; +没有 artifact 时,可提取成功终态或独立 Message 的正文。 +FilePart 仅在完整视图提供。不解析文本、不拼接相邻文本、不拍平嵌套业务数组。 +失败状态消息只保留作证据,不进入 outputs;已返回的有效部分 artifact 仍保留。 +SSE append/replace 按 artifact 组装,最终快照不重复累加。 + +本地 onSelfTask 返回 TaskResult.success(List),允许空列表; +TaskResult.failure(code, message) 将错误与输出分开,builder 可保留有效部分输出。 +远端 TaskResult 的 receivedMessages 是完整证据,便利输出由它派生。 +远端 Task 只有 COMPLETED 才成功,独立 A2A Message 也可完成交互。 +进度/协商提示不会因为含文本就变成工作流成功。 + +## 4. 宿主调用 A2A-T + +纯引擎仅依赖 a2a-t-core;生成内容的宿主显式依赖 a2a-t-client。 +宿主创建、配置自己的 A2ATClient,不通过 WorkflowEngineClientConfig 或 ExecutePsop 承载 LLM 配置。 + +```java +// sdk、data、schema、模板选择均属于宿主。 +MetadataContent generated = sdk.generateTaskPromptFromDataWithSchema( + data, schema, StandardTemplates.PRIVATE_LINE_COMPLAINT.uri()); +MessageContent outgoing = A2atMessages.from(generated, List.of(new TextPart("诊断当前专线"))); +``` + +自然语言入口为 `sdk.generateTaskPromptFromText(text, StandardTemplates.PRIVATE_LINE_COMPLAINT.uri())`,随后同样调用 A2atMessages.from。 + +自然语言、结构化生成和校验等能力直接调用宿主 SDK。 +A2atMessages.from 只复制生成的 metadata 并激活对应扩展,parts 保持业务提供的内容; +不生成、不校验业务语义。模板使用 StandardTemplates,不自行猜 URI。 +可运行 SPN 示例见 WorkbenchControlPoint 和 examples.negotiation.NegotiationStrategy。 +A2ATInitialization 是 samples 宿主对锁定 SDK 的 JarFile 初始化隔离,不属于引擎。 + +## 5. 协商 + +NegotiationRequest(task, originalSubmission, received, previousExchanges, remainingWait): +task 是原始 TaskRequest,originalSubmission 是首次最终提交, +received 是当前完整响应,previousExchanges 只含该会话的 Exchange(received, reply), +remainingWait 是本次交互剩余时间。引擎不规定业务 proposal 分类或 schema。 + +只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 +终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 +宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 +通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; +结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 + +返回 `new NegotiationReply.Send(content)` 发送最终内容; +返回 `new NegotiationReply.Stop(code, reason)` 只在本地停止,不生成 Abort。 +同一任务/会话/轮次的重复等待事件不会重复回调、重复提交;未变化状态通过 getTask 观察。 +`maxNegotiationExchanges` 默认 3,是独立于 SDK context.maxRounds 的本地交互资源预算。 +超时、预算耗尽、回调缺失均明确失败,不默认 Accept,也不自动生成 Abort。 +Accept/Reject 的 SUBMITTED/WORKING ACK 仍需等待任务结果,不重发原命令。 +业务发送 Abort 后,即使远端用 COMPLETED 确认,也不能判为诊断成功。 + +业务可调用 validateProposePromptAndDataFilling,再选择 SDK 强类型 fromData 或自然语言生成接口。 +不得虚构缺失客户事实;样例只从本地当前地市任务输入提取实际请求的字段。 +SDK 内容异常由宿主映射成 BusinessFailure(code, safeMessage, safeDetails); +引擎不识别 SDK 专属内容异常类,也不自动转存可能含敏感数据的原异常。 + +## 6. 路由、并发与失败 + +RouteRequest(executionId, stepName, workflowInput, currentResults, candidates)。 +候选为 RouteOption(nextStep, condition),返回 RouteDecision.builder().nextStep(允许目标).build()。 +无条件边自动并行推进,条件分支只接受候选中的目标。 + +回调可能并发,不要共享可变“当前任务/城市”字段。 +每个工作流任务从内容准备到传输完成受客户端 timeout 总体限制,默认 sendTimeoutSeconds=600; +路由单独限制回调等待时间,dispatch/协商另有总等待截止时间。取消/超时后晚到结果不发送, +但不等于自动取消宿主正在运行的 LLM 或业务操作,宿主负责清理其资源。 +同步回调入口应迅速返回,阻塞任务应交给异步执行器。 +回调缺失、返回 null 或异常均明确失败;不确定发送失败不自动重发。 + +## 7. 独立授权与订阅 + +ExtensionSender.sendAuthorization(agentName, finalContent) 返回 CompletableFuture。 +ExtensionSender.openNotification(agentName, finalContent, (handle, received) -> ...) 立即返回 handle。 +传入宿主生成的最终授权/订阅内容并激活对应扩展。 +I/O 前注册 handle,早到事件可直接 handle.close(),不用捕获尚未赋值的外部变量。 +acknowledgement() 代表真实 ACK 或失败;超时不伪造成功。 +close() 请求关闭,completion() 在流实际退出后完成。 +订阅保持到抢通结果、显式取消或宿主退出,不随单次工作流结束自动关闭。 + +任务、授权、通知使用独立 transport/runtime/context。 +授权和订阅成功与否均不作为工作流执行前提; +有效白名单只决定 OMC 是否允许额外的自动抢通操作。 + +## 8. 验证和日志 + +### 查看真实协商与协议日志 + +直接运行本地 SpringSpnDemo,默认 City1 缺参并协商、City2 参数完整直接诊断,无需添加 VM 参数。 +Negotiation-T 扩展激活本身不强制协商;这是本地 Demo 专门设置的场景,不是引擎默认行为。 +要关闭本地缺参演示、让两城市都直接诊断,在 IDEA **VM options** 增加: + +```text +-Da2at.samples.negotiation=false +``` + +默认仅移除 City1 的 Task-T 任务对象;启用状态下增加 `-Da2at.samples.negotiation.city=city2` 或 `both` +可演示 City2 或两城市同时缺参。宿主仍保留各城市的正确输入,原投诉上下文不变。预期链路是 `DEMO_NEGOTIATION` → `INPUT_REQUIRED / PROPOSE` +→ 工作台 onNegotiation → `ACCEPT` → 两城市完成 → 一次汇总。 +非内嵌 OMC 模式默认不注入缺参,显式设置 `-Da2at.samples.negotiation=true` 也会被拒绝。 +Demo 将开关传入当前 Spring 应用实例,不设置或修改 JVM 全局开关,不影响其他宿主。 +协议日志在控制台及以运行目录为基准的 `logs/spn-demo.log`。 +main 支持直连;dev 默认 order,两种模式使用同一业务回调。 + +离线重复验证(两个命令顺序执行,避免端口冲突): + +```powershell +mvn -q -pl samples -am "-Dtest=SpringSpnDemoE2ETest" "-Dsurefire.failIfNoSpecifiedTests=false" test +# 仅 dev:真实供应商 jar + 本地平台/OMC 模拟器 +mvn -q -pl samples -am "-Dtest=SpringSpnDemoOrderE2ETest" "-Dsurefire.failIfNoSpecifiedTests=false" test +``` + +每个测试类覆盖无 VM 参数的默认单城市协商、显式关闭、显式开启及两城市同时缺参。测试使用离线 LLM provider,但实际运行当前 SDK 的 +模板、校验和 HTTP/SSE;不是现网 OMC、平台或真实模型语义的验证。 + +启动测试使用公开模拟凭据,不覆盖 SDK 模板或 schema。 +运行时固定测试端口需空闲;本地工作流完成后最多观察首次抢通通知十秒, +没有抢通通知不改变工作流结果。认证和日志配置详见 [集成指南](INTEGRATION_GUIDE.md)。 + +## 9. 示例业务侧的 A2A-T 1.1.0 调用参考 + +引擎只调度最终 A2A 消息;下列生成、校验、填充和业务判断都在 samples 中完成。 +不是每个智能体都调用全部 API,也不是同一消息依次调用 FromText 和 FromData。 + +| 业务位置 | 生成/校验入口 | 对结果的业务使用 | +|---|---|---| +| WAIMO → 工作台 | SpringSpnDemo:generateTaskPromptFromDataWithSchema;WorkbenchTaskInputParser:validateTaskPromptAndDataFilling | 将 filled.data 作为投诉参数,按任务检索工作流 | +| 工作台 onTask → 两地市 OMC | WorkbenchControlPoint:generateTaskPromptFromDataWithSchema;NegotiationBaseAgentExecutor:validateTaskPromptAndDataFilling | 用 filled.data 构造 SpnTaskInput,交给 executeBusiness;不把原始协议提示词传给诊断 | +| OMC 缺参 → 工作台 onNegotiation | 两地市共用 generateNegotiationProposePromptFromData;NegotiationStrategy:validateProposePromptAndDataFilling | 从 items 读取实际请求字段,仅从当前城市原始业务输入补充,不使用另一城市的数据 | +| 工作台补参 → OMC | generateNegotiationAcceptPromptFromData;validateAcceptPromptAndDataFilling | 合并所请求字段的 filled.data,复核端口、投诉分类及 OSS 流水号后执行诊断 | +| 工作台拒绝/终止 → OMC | generateNegotiationRejectPromptFromData/generateNegotiationAbortPromptFromData;对应 validateRejectPromptAndDataFilling/validateAbortPromptAndDataFilling | 校验原因后结束当前诊断任务,不进入诊断业务 | +| 独立白名单授权 | ExtensionPrePositioner:generateAuthPromptFromDataWithSchema;PrePositionedExtensionHandler:validateAuthPromptAndDataFilling | 用 filled.data 构建并应用 AuthorizationPolicy | +| 独立订阅 | ExtensionPrePositioner:generateNotificationPromptFromDataWithSchema;PrePositionedExtensionHandler:validateNotificationPromptAndDataFilling | 用 filled.data 构建 NotificationPolicy,保持独立通知连接 | + +### 输入选择与消息封装 + +有结构化业务参数时使用上述 FromDataWithSchema;只有自然语言时,业务可改用 +generateTaskPromptFromText(text, templateUri)、generateAuthPromptFromText(text, templateUri)、 +generateNotificationPromptFromText(text, templateUri)。协商同时提供各阶段 FromText 和强类型 FromData。 +这些是输入方式的选择,不是每条消息必须全部执行的流水线。FromDataWithSchema 也可能调用 SDK 配置的 LLM, +不能等同于完全不调用模型。 + +无论选择哪一种生成入口,都保留返回 MetadataContent 的完整 metadata,再通过 +A2atMessages.from(generated, List.of(new TextPart("业务摘要"))) 转成引擎 MessageContent。 +parts 必须有合法内容;摘要不是正式扩展正文,不能拿摘要替代校验输入。不要只复制 promptText 而丢失 +templateUri、negotiationContext 或扩展 URI。 + +### 信息协商的数据与失败边界 + +本场景按 SDK 投诉模板的业务字段协商:任务对象中包含本地市接入端口,任务上下文中包含投诉分类、 +OSS侧事件流水号及已有的时间和详情。已验证任务只有部分字段不足时仅请求这些字段; +SDK 拒绝整个原始输入时不信任其中的局部内容,重新请求完整业务字段。当前请求项采用 AND,所有请求字段必需。 + +接收 Propose 的参数 schema 中,items 是非空字段名数组,relationship 是可空逻辑关系字符串; +其 description 说明“实际请求字段”和 AND/OR 含义。Accept 校验 schema 则按本轮请求动态生成, +字段为非空字符串并列入 required。SpnTaskInput 的端口、流水号等规则仅是 SPN 示例业务规则, +不是通用执行引擎的输出格式约束;正式 OMC 应接入自身资源清单与领域校验。 + +回复必须匹配 A2A taskId/contextId 及协商 id/round/maxRounds,正式正文从 Negotiation-T metadata 读取。 +一次有效回复只消费一次。错误正文、漏填、外地市端口或跨协商回复不能进入诊断。 +Reject 表示业务拒绝提供;Abort 表示无法核实信息或终止协商,均不是诊断成功。 + +默认 NegotiationStrategy 允许提供已掌握的当前任务字段;无法核实时生成 Abort,不编造字段。 +宿主可通过 mayProvideField(task, fieldName) 注入披露策略,拒绝时由示例生成 Reject: +```java +var strategy = new dev.openan.workflow.engine.examples.negotiation.NegotiationStrategy(envPath) { + @Override + protected boolean mayProvideField( + dev.openan.workflow.engine.model.TaskRequest task, String fieldName) { + return allowedFields.contains(fieldName); // 宿主已计算的业务允许字段集合 + } +}; +var callbacks = new dev.openan.workflow.engine.examples.workbench.WorkbenchControlPoint(envPath, strategy); +``` +这里的字段披露策略与 Authorization-T 的“自动抢通白名单”是两件事。 + +### 结果和通知不要错用请求生成接口 + +诊断结果、授权回执、业务抢通事件由 OMC 业务生成。1.1.0 的上述授权/通知入口面向授权操作和订阅请求, +不能把抢通事件再次交给订阅请求生成/校验接口。示例 RecoveryNotification 按 SPN 业务事件格式检查正式 +Notification-T artifact metadata:包含任务流水号、OSS流水号、端口、方案及完成结果等必要字段且状态为已结束, +才确认收到最终结果并关闭该订阅;成功和失败都是“执行已结束”,不把失败结果冒充成功。 +单独 artifact 名称、parts 摘要、订阅 ACK、未启动方案均不能触发关闭。不完整事件仍交给通知回调观察。 + +City1 上报的端口和 OSS 流水号来自本次已校验 SpnTaskInput,不再固定写成样例常量。 +City2 没有故障可以不产生抢通结果;授权/订阅失败和是否收到抢通通知,均不决定工作流诊断是否启动或成功。 + +### 可重复验证范围 + +EmbeddedA2AServerTest 覆盖正常填充数据、Accept 填充数据保留、错误正文、漏填、跨协商回复、 +Reject 和 Abort;NegotiationStrategyTest 覆盖城市输入隔离、不可核实的 Abort 和业务拒绝的 Reject; +RecoveryNotificationTest 覆盖完整成功/失败结果、仅摘要、错误命名和不完整事件。 +SpringSpnDemoE2ETest 及 dev 的 SpringSpnDemoOrderE2ETest 均覆盖完整输入、City1 缺参、两城市同时缺参。 +完整业务抢通链路由 SpnCrossCityE2ETest 验证。 + +这些测试用真实 SDK 1.1.0 的生成和校验管线、真实 A2A HTTP/SSE;Order 测试使用供应商 jar 和本地平台模拟器。 +离线 LLM provider 仅解析明确的样例输入,拒绝错误摘要/缺参,不代表真实模型的语义质量或现网联调已通过。 + +### 授权策略格式与协商字段(SDK 1.1.0) + +新增授权使用 SDK 当前的编号、标签、换行格式,不能传四个无标签的裸值: + +```text +1. 业务场景是业务投诉诊断,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-06-01~2030-06-18 +2. 业务场景是视频保障,处置类型是故障切换,操作名称是频谱重耕,有效期是永久生效 +``` + +OMC 示例仅从 SDK validate-and-fill 的数据构造白名单,逐字段提取标签后的值, +按业务场景、处置类型、操作名称精确匹配,有效期包含起止日。全部规则解析成功才替换原策略; +任意一条非法均保持原策略,不降级成默认授权。 + +示例只保存一组内存白名单(固定示例 ID),不代表生产策略库: +删除请求使用 `1. 策略标识是<当前策略UUID>`;仅支持单个精确 ID,不支持批量或条件删除。 +查询省略策略列表或传空字符串,返回全部当前规则;不支持带条件查询,不会静默忽略条件。 +修改、条件查询、批量/条件删除需由集成方的策略库实现。该业务限制不代表 A2A-T SDK 不支持这些操作。 + +信息协商仍先调用 SDK `validateProposePromptAndDataFilling`。当前中文信息协商模板由 typed API +生成,示例从已通过语义校验的“所需信息项”中保留编号后的原始名称(如“任务对象”)用于回复, +不把模型提取出的说明文字(如“本地市实际接入端口名称”)当成新字段,也不模糊映射到其他数据。 +未知字段或本地数据缺失时 Abort,业务披露策略拒绝时 Reject;不能用其他地市的信息补齐。 +这些处理均在 samples 的业务层,不进入通用引擎。 + +Notification-T 的失败原因字段使用完整名称“业务抢通方案执行失败原因”,执行失败时必填。 +SDK 校验拒绝时记录错误码、slot 错误和已提取参数的键名,方便定位;不绕过校验。 +授权、订阅的成功与否仍不阻断独立工作流。 diff --git a/docs/zh/DESIGN.md b/docs/zh/DESIGN.md index 6bc6b443..0a79f90b 100644 --- a/docs/zh/DESIGN.md +++ b/docs/zh/DESIGN.md @@ -7,22 +7,17 @@ ## 1. 概述 -A2A-T 工作流执行引擎让宿主智能体通过 A2A 协议和 A2A-T 电信扩展执行多步骤工作流。 -工作流是一个有向无环图(DAG),每个步骤向远程智能体下发一个或多个任务,并路由到下一步。 -SDK 负责协议机制(消息发送、流式传输、认证、Task-T 提示词生成、Negotiation-T 自动协商循环), -暴露少量决策接口由宿主实现业务逻辑。 - -核心设计原则:**SDK 负责协议机制,宿主负责业务决策**。 - -| SDK 负责(协议机制) | 宿主负责(业务决策) | -|----------------------------------------------------------|-------------------------------| -| A2A 消息发送、流式传输、SSE 规范化 | 是否发送任务、何时发送 | -| 智能体认证(Bearer、自定义 Header) | 凭证配置 | -| A2A-T 扩展(Task-T、Negotiation-T、Authorization-T、Notification-T) | 授权审批、通知处理 | -| DAG 遍历、上下文组装、状态管理 | 分支路由决策 | -| 事件发射 | 事件处理 | - ---- +SDK 是集成在宿主智能体进程中的工作流协议调度库,不是独立业务平台。 +宿主提供最终内容,引擎管理 DAG、并行调度、标准 A2A 信封、认证、传输、等待和结果关联。 +业务回调决定是否调用 A2A-T 的自然语言/结构化内容接口,模板、schema、LLM 与语义校验均归宿主。 +引擎只通过 a2a-t-core 读取规范协商上下文和复制扩展 metadata,不包含内容生成管线。 + +| 引擎职责 | 宿主职责 | +|---|---| +| DAG、任务/会话关联、并行与路由校验 | 内容生成、业务路由决策、本地汇总 | +| 按 contextFrom 选择完整上游结果 | 决定如何使用这些来源、映射下游输入 | +| A2A 认证与传输机制 | 凭据来源、AuthProvider、部署配置 | +| 独立授权发送、订阅 handle 和生命周期 | 授权策略、订阅内容、通知消费和关闭时机 | ## 2. 周边系统依赖与宿主集成 @@ -66,35 +61,22 @@ graph TD ### 3.1 Layer 0 - 通信层 -**`A2ATransport`** 是共享通信层,只负责一件事:把字节发到远程智能体再收回来。 -它拥有 A2A SDK 客户端运行时、认证管理器和拦截器、智能体卡片映射、流式响应消费者。 -暴露两个发送原语:`send`(收集并返回)和 `sendNotificationStream`(长连接 SSE), -以及将原始 SDK 事件流转换为文本、任务状态和元数据的静态提取器。 - -**两个门面构建在 transport 之上,各司其职:** - -- **`WorkflowEngineClient`** — 工作流执行发送路径。拥有 Task-T 提示词生成(发送前)、 - Negotiation-T 自动循环(接收后)、全局 `EventCallback`、`ControlPoint` 装配。 - 这是执行器在工作流执行期间调用的门面。 -- **`ExtensionSender`** — 独立协议操作门面。在工作台选定的业务时机发送一次性 Authorization-T 请求或建立长连接 Notification-T 订阅。 - 绕过 Task-T 生成和协商循环,不通过全局回调发射事件 — 返回的结果就是回调。 - -#### 为什么复用 transport 实现 + 两个门面? - -工作流发送路径和独立扩展路径都需要相同的通信层机制:HTTP 客户端、TLS 配置、 -认证拦截器、智能体卡片解析、SSE 解析。把这些机制放在任一门面上要么 (a) 强制只想做独立协议操作的 -调用方持有完整工作流门面,要么 (b) 在两个类中重复通信代码。复用 `A2ATransport` 实现 + 两个门面的设计 -避免了这两个问题。类实现可复用,实例不复用:Task-T、Authorization-T、Notification-T -必须各自创建 transport/runtime/context。Task-T 使用任务级实例,Authorization-T 一次请求后释放, -Notification-T 使用工作台级长连接实例。 +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。在每个步骤组装上游上下文(`ContextBuilder`), +**`WorkflowExecutor`** 遍历 DAG。在每个步骤按 `contextFrom` 选择强类型上游结果(`ContextBuilder`), 并发下发子任务,应用步骤成功策略,确定下一步。所有决策委托给 `ControlPoint`, 所有发送委托给 `WorkflowEngineClient`。 -步骤下发规则: +TaskRequest 当前输入与 workflowInput 分离。上游完整视图和便利 outputs 由 contextFrom 选择, +不会自动拼接、生成或套用业务 schema;业务自行决定怎么消费。详见 [回调契约](BUSINESS_CALLBACKS.md)。 + + - 前驱步骤全部完成的步骤被收集并并行下发 - 同一层的步骤并发执行 - `ALL_SUCCESS` — 所有子任务必须成功 @@ -110,66 +92,41 @@ Notification-T 使用工作台级长连接实例。 ## 4. 决策接口 -SDK 暴露两个用户实现的接口,按职责拆分。 - -### 4.1 ControlPoint — 流程决策 - -驱动工作流前进。每个方法由执行器或自动协商循环调用,做恰好一个决策: - -| 方法 | 调用方 | 决策 | -|------------------|-------------|-------------------------------------------| -| `onTask` | 执行器 | 提交自然语言或结构化 `TaskSubmission` | -| `onSelfTask` | 执行器 | 本地处理自环任务(不走 A2A-T) | -| `onRoute` | 执行器 | 在条件步骤选择分支 | -| `onNegotiation` | 客户端自动循环 | 在 INPUT_REQUIRED 时返回强类型业务决策 | - -Authorization-T 和 Notification-T 是工作台独立触发的协议操作,不是工作流 DAG 节点。 +```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)。 ## 5. A2A-T 扩展模型 -支持四个 A2A-T 扩展,按生命周期分为两组。 - -### 5.1 工作流内扩展 +Task-T:宿主生成最终内容,引擎只封装并发送。AgentCard 声明扩展不触发生成。 -参与每次 `sendMessage` 生命周期,通过扩展处理器链(`ExtensionRegistry` 自动注册): +Negotiation-T: -- **Task-T** — 发送时,调用 A2A-T SDK 从结构化数据、已知模板的自然语言或待识别场景的 - 自然语言生成规范任务提示词并注入消息 metadata。协商后续和调用方预设提示词时跳过。 - 接收方再用同一模板的 SDK validate-and-fill 校验和提取业务字段。 -- **Negotiation-T** — 接收时,当智能体返回 `INPUT_REQUIRED` 并声明该扩展, - 提取协商上下文和消息。这驱动自动循环:引擎调用 `ControlPoint.onNegotiation` 获取 - `NegotiationDecision`,按 action 与 input 类型选择 SDK fromText/fromData API,重发后续消息。 +只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 +终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 +宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 +通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; +结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 -### 5.2 独立生命周期扩展 +返回 `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` 触发,并与单次工作流 transport 解耦: - -- **Authorization-T** — 通过 SDK schema-aware 管线渲染和校验新增/修改/删除/查询请求。有效白名单是 OMC 自动抢通的前置条件。 -- **Notification-T** — 通过 SDK schema-aware 管线建立结果订阅,返回 `NotificationSubscription`。ACK 与流结束分离;收到抢通结果、取消或服务关闭时显式关闭。 - -订阅 ACK 从 `NotificationSubscription.acknowledgement()` 获取,后续抢通事件通过订阅回调返回。 -它们不进入工作流的 `ControlPoint` 或全局任务事件流。 - -### 5.3 扩展处理器链 - -```mermaid -graph TD - SM["sendMessage(agent, message)"] - BS["before_send: Task-T 生成提示词,注入 metadata"] - TS["transport.send(Task-T metadata 在线上传输)"] - AR["after_receive: Negotiation-T 提取上下文(驱动自动循环)"] - AN["auto_negotiate 循环(如果 INPUT_REQUIRED)"] - - SM --> BS --> TS --> AR --> AN -``` - -`ExtensionRegistry.getHandlersForExtensions` 对内置 Task-T/Negotiation-T 要求 AgentCard 中的规范 URI -精确匹配,返回去重的处理器链。Authorization-T / Notification-T 不注册到工作流处理器链, -只由 `ExtensionSender` 及独立生命周期处理。 - ---- +Authorization-T 和 Notification-T 是独立业务操作,不属于 DAG。宿主生成内容并使用独立发送器;失败不影响工作流。白名单仅影响 OMC 自动抢通,订阅保持到业务主动关闭。 ## 6. 条件路由 @@ -204,91 +161,59 @@ graph TD ## 8. 交互序列 -### 8.1 带协商的工作流执行 - ```mermaid sequenceDiagram - participant H as 宿主 - participant E as 执行器 - participant C as EngineClient - participant A as 智能体 - - H->>E: run(workflow) - E->>C: onTask(req) - C->>A: before_send: Task-T - A-->>C: INPUT_REQUIRED (Negotiation-T) - C->>E: negotiation result - E->>H: onNegotiation(NegotiationRequest) - H->>E: NegotiationDecision - E->>C: follow-up send - C->>A: send follow-up - A-->>C: final result - C->>E: final result - E->>H: ExecutionResult + participant H as Host callbacks + A2A-T client + participant E as Workflow engine + participant A as Remote agent + E->>H: onTask(TaskRequest + upstream window) + H->>H: Generate/validate final content + H-->>E: MessageContent + E->>A: A2A envelope + unchanged content + opt INPUT_REQUIRED with valid Propose + A-->>E: Task status + Negotiation-T Propose + E->>H: onNegotiation(originalSubmission, received, history) + H->>H: Validate proposal; generate reply + H-->>E: Send(MessageContent) or local Stop + E->>A: Same task/context; final reply content + end + A-->>E: Task result / artifacts + E->>H: onSelfTask(selected complete upstream results) + H-->>E: TaskResult ``` -### 8.2 独立下发授权 - ```mermaid sequenceDiagram - participant H as 宿主 - participant ES as ExtensionSender - participant T as Transport - participant A as 智能体 - - H->>ES: sendAuthorization - ES->>ES: generate prompt (SDK) - ES->>T: send(instruction, auth) - T->>A: send - A-->>T: auth result - T-->>ES: auth result - ES-->>H: result + participant H as Host + A2A-T client + participant ES as Independent ExtensionSender + participant A as OMC + H->>H: Generate final authorization content + H->>ES: sendAuthorization(agent, content) + ES->>A: One-shot authorization + A-->>H: Independent result, never gates workflow + H->>H: Generate final subscription content + H->>ES: openNotification(agent, content, listener) + ES-->>H: Registered handle + ES->>A: Independent long-lived stream + A-->>H: ACK via acknowledgement() + A-->>H: listener(handle, ReceivedMessage) + H->>ES: handle.close() on recovery/cancel/shutdown + ES-->>H: completion() after stream exits ``` -### 8.3 Notification 订阅 - -```mermaid -sequenceDiagram - participant H as 宿主 - participant ES as ExtensionSender - participant T as Transport - participant A as 智能体 - - H->>ES: openNotificationFromData - ES->>T: openNotificationStream - T->>A: open long-lived SSE - A-->>T: ack (working) - T-->>ES: ACK - ES-->>H: NotificationSubscription - Note over T,A: 后续结果通过同一连接流回 -``` - ---- - ## 9. 依赖 -**本 SDK:** `org.a2aproject.sdk:a2a-java-sdk-client`(A2A 协议)、 -`net.openan.a2at.sdk:a2a-t-client`(A2A-T 扩展)、Jackson、SLF4J、Lombok。 - -SDK 不要求编排中心或注册中心客户端成为底层协议发送的硬依赖;宿主也可以直接传入本地构造的 -`Workflow` 和 AgentCard。但在标准生产集成中,宿主通常通过 SDK 的 `LoadPsop`、`RegistryClient` -或自己的服务客户端访问编排中心和注册中心,再把发现结果交给执行引擎。 - ---- +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,样例不覆盖同名资源。 ## 10. 设计决策总结 -1. **复用 transport 实现,两个门面** — 通信层机制在 `A2ATransport` 上写一次; - `WorkflowEngineClient` 和 `ExtensionSender` 各自拥有一个编排职责,委托通信工作, - 并可使用任务级或工作台级 transport 生命周期。 - 避免强制门面耦合和通信代码重复。 - -2. **工作流内扩展 vs 独立生命周期扩展** — Task-T 和 Negotiation-T 是 `sendMessage` 链的一部分; - Authorization-T 是工作台独立触发的一次性请求,Notification-T 是工作台级长连接订阅。 - 注册表只自动注册工作流内的一对。 - -3. **自动协商循环** — 引擎拥有重发循环,宿主只提供强类型决策(`onNegotiation`), - 不需要关心重发的协议机制。 - -4. **条件路由语义** — 空条件意味着扇出(并行),条件分支意味着通过 `onRoute` 做 N 选 1。 - 保持路由模型可预测。 +最终内容与协议调度分离,宿主不自行维护 A2A 信封。 +本地多输出和远端完整证据统一进入下游窗口,不丢失 metadata、不拍平数组。 +协商回复内容归业务,任务关联/去重/有界等待归引擎;本地 Stop 与协议 Abort 分离。 +独立授权和通知不成为工作流前提,直连与 dev 东信转发继续共享回调。 +协议日志在实际边界采集并强制脱敏,详情见 [集成指南](INTEGRATION_GUIDE.md)。 diff --git a/docs/zh/DEVELOPER_GUIDE.md b/docs/zh/DEVELOPER_GUIDE.md index 99157741..a753eb03 100644 --- a/docs/zh/DEVELOPER_GUIDE.md +++ b/docs/zh/DEVELOPER_GUIDE.md @@ -15,7 +15,7 @@ ``` 引擎会传递性引入 A2A 协议 SDK(`a2a-java-sdk-client`,含 REST、JSON-RPC、gRPC 传输) -和 A2A-T 扩展 SDK(`a2a-t-client`)。无需额外依赖。 +和最小 A2A-T 核心(`a2a-t-core`)。生成内容的宿主显式依赖 a2a-t-client,OMC 接收端另引入 a2a-t-server。 ## 2. 核心概念 @@ -27,41 +27,33 @@ ## 3. 实现 ControlPoint -只需实现两个方法: - ```java -public class MyControlPoint implements ControlPoint { - @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher dispatcher) { - return dispatcher.dispatch(TaskSubmission.fromText( - request.getAgentName(), request.getMessage(), - StandardTemplates.PRIVATE_LINE_COMPLAINT)) - .thenApply(result -> { - String state = result.getTaskState(); - boolean success = state == null || state.isBlank() - || "TASK_STATE_COMPLETED".equals(state); - return TaskResponse.builder() - .success(success) - .output(result.getText()) - .build(); - }); - } - - @Override - public CompletableFuture onRoute( - String stepName, Map results, - List conditions) { - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(conditions.get(0).getStep()) - .reason("picked first") - .build()); - } +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -`onNegotiation` 有默认实现,返回通用的 `acceptText` 决策。授权和通知是独立协议操作,由工作台在业务时机通过各自专用的 `ExtensionSender`/transport 发起;它们不在 `ControlPoint` 上,也不是工作流 DAG 节点。 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 +onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 +未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 + +```java +ControlPoint callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .onSelfTask(request -> CompletableFuture.completedFuture( + TaskResult.success(List.of(Map.of( + "sourceResults", request.getWorkflowInput().upstreamResults()))))) + .onRoute(request -> CompletableFuture.failedFuture( + new IllegalStateException("Supply a routing policy for " + request.stepName()))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Manual confirmation required"))) + .build(); +``` ## 4. 通过 Builder 执行(推荐) @@ -73,7 +65,6 @@ ExecutionResult result = ExecutePsop.builder() .runtimeIntent("诊断SPN故障") .lang("zh") .sslVerify(false) - .a2atEnvPath(".env") .credentialsConfigPath("agent_credentials.json") .eventCallback(new EventCallback()) .onFinish((r, e) -> { @@ -97,18 +88,18 @@ ExecutionResult result = ExecutePsop.builder() | `start` | 运行器 | 工作流开始 | `workflow`、`steps` | | `step_start` | 执行器 | 步骤开始 | `step` | | `task_request` | 执行器 | 子任务下发到 `onTask`/`onSelfTask` | `step`、`agent`、`task` | -| `task_response` | 执行器 | `onTask`/`onSelfTask` 返回 `TaskResponse` | `step`、`agent`、`task`、`output` | +| `task_response` | 执行器 | 远端任务完成或 onSelfTask 返回 TaskResult | `step`、`agent`、`task`、`outputs` | | `task_status_changed` | 执行器 | 任务状态变更(pending → running → success/failed) | `step`、`agent`、`task`、`status` | | `route_decision` | 执行器 | 分支选择 | `step`、`next`、`reason` | | `step_complete` | 执行器 | 步骤完成 | `step`、`results` | | `workflow_complete` | 执行器 | 所有步骤完成 | `history`、`step_outputs` | -| `agent_request` | 引擎客户端 | 消息发送到智能体 | `agent`、`request`、`metadata` | -| `agent_response` | 引擎客户端 | 收到智能体响应 | `agent`、`response` | +| `agent_request` | engine client | 准备下发(不是 wire 日志) | `agent`, `content` | +| `agent_response` | engine client | 远端响应已组装 | `agent`, `response`, `receivedMessages` | | `agent_status_update` | 引擎客户端 | 智能体 SSE 状态更新 | `agent`、`state`、`is_final` | | `agent_artifact_update` | 引擎客户端 | 智能体 SSE 产物更新 | `agent`、`artifact_name`、`text` | -| `negotiation_request` | 引擎客户端 | 智能体需要澄清 | `agent`、`round`、`concern` | -| `negotiation_resolved` | 引擎客户端 | 协商决策已生成 | `agent`、`round`、`decision` | -| `negotiation_failed` | 引擎客户端 | 协商失败 | `agent`、`round`、`reason` | +| `negotiation_request` | engine client | 有效 Propose 进入业务接管 | `agent`, `request`, `exchange` | +| `negotiation_resolved` | engine client | 宿主 Send 通过关联检查;不等于任务成功 | `agent`, `reply`, `exchange` | +| `negotiation_failed` | engine client | 本地协商交互失败 | `agent`, `exchange`, `errorType` | | `complete` | 运行器 | 工作流成功 | `history`、`step_outputs` | | `error` | 运行器或执行器 | 工作流失败 | 运行器:`error`、`history`;执行器:`step`、`results` | | `close` | 运行器 | 清理完成 | (空) | @@ -120,7 +111,6 @@ try (var client = new DefaultWorkflowEngineClient(agentCards, a2aRuntime, WorkflowEngineClientConfig.builder() .sslVerify(false) .credentialsConfigPath("etc/conf/agent_credentials.json") - .a2atEnvPath(".env") .build())) { WorkflowExecutor executor = new WorkflowExecutor( workflow, @@ -136,25 +126,19 @@ try (var client = new DefaultWorkflowEngineClient(agentCards, a2aRuntime, ### 6.1 协商自动循环 -任务分派后的引擎客户端自动处理协商:当智能体返回 `INPUT_REQUIRED` 时, -引擎从响应 metadata 中构造 `NegotiationRequest`,调用 `ControlPoint.onNegotiation()` 获取强类型决策, -然后作为后续消息发回。循环重复直到 `maxNegotiationRounds`(默认 3)。 +只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 +终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 +宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 +通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; +结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 -覆盖 `onNegotiation()` 提供业务特定的澄清: - -```java -@Override -public CompletableFuture onNegotiation( - NegotiationRequest request) { - return myLlm.generate( - "Agent " + request.agentName() + " needs: " + request.concern()) - .thenApply(Response::text) - .thenApply(NegotiationDecision::acceptText); -} -``` - -返回 `null` 会失败该轮。结构化业务参数使用 `acceptData/rejectData/abortData`;输入对象会在调用 SDK -之前拒绝空键、空值和字符串 `"null"`。 +返回 `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 工作流模型字段 @@ -163,8 +147,8 @@ public CompletableFuture onNegotiation( | `steps[].stepType` | `WorkflowStep` | `AllSuccess`(默认):所有子任务必须成功;`AnySuccess`:任一成功即可;`SelfLoop`:工作流智能体通过 `onSelfTask` 本地处理(不向命名智能体发送 A2A-T 消息)。 | | `steps[].subtasks[]` | `Task` | 每个含 `agent`、`skill`、`description`。每个子任务触发一次 `onTask`(或 SelfLoop 的 `onSelfTask`)调用。 | | `steps[].next[]` | `List` | 分支目标。`step` = 下一步名称;`condition` = 规则文本。 | -| `steps[].layer` | `WorkflowStep` | `layer == 0` 开始 DAG(上下文 = 运行时意图)。更高层获取上游结果。 | -| `steps[].contextFrom` | `WorkflowStep` | 可选步骤名,其输出折叠到上下文中。`"*"` = 所有祖先。 | +| `steps[].layer` | `WorkflowStep` | 编排层级提示;实际就绪条件由 DAG 前驱关系决定。 | +| `steps[].contextFrom` | `WorkflowStep` | 选择传入 `workflowInput.upstreamResults` 的步骤;省略 = 直接前驱,`[]` = 不聚合,`"*"` = 所有祖先,或显式指定祖先名称。 | ### 6.3 AgentCard 类型 @@ -245,21 +229,13 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() ## 9. A2A-T 环境(.env) -```ini -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_API_KEY=sk-... -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LANGUAGE=zh-CN -A2AT_CRED_KEY=<32字节hex> -``` - -`a2atEnvPath` 为 null 时,非结构化 `sendMessage` 只发送普通 A2A 文本,不宣称为 -Task-T;结构化 Task-T、Authorization-T、Notification-T 和 Negotiation-T 都会显式失败。 - -**离线测试**:测试资源中的 `.env` 通过 SDK SPI 指定 `OfflineA2ATLlmClient`,使真实 -A2ATClient/A2ATServer 生成、校验和参数填充管线可在无外网时可重复运行。不存在 -“禁用 SDK 后发送默认协商文本”的生产降级路径。 +引擎不读取 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. 集成模式 @@ -298,9 +274,23 @@ public Flux execute(@PathVariable String psopId) { ## 11. 检查清单 1. 添加 Maven 依赖 -2. 实现 `ControlPoint`(至少 onTask + onRoute;onSelfTask 和 onNegotiation 有默认实现) +2. 实现 `ControlPoint`(onTask 必须返回最终内容;包含本地任务、条件路由或协商时也实现对应回调) 3. 获取 AgentCards(从注册中心或 JSON 文件) 4. 加载 Workflow(通过 `LoadPsop` 或自行构建) 5. 配置 `.env` 和凭证文件 6. 调用 `ExecutePsop.builder().execute()` 7. 处理事件 + onFinish 持久化 + +### 协议日志 pretty 展示 + +协议日志默认以 pretty 形式展示:逐行打印头字段,缩进 JSON 正文。SSE 保留 id/event 等事件信息, +JSON 在 `=== SSE data (JSON display; not wire text) ===` 与 `=== End SSE data ===` 之间单独展示, +不再为展开后的每行 JSON 添加 `data:` 前缀。多个事件各自保留边界。 +`WORKFLOW_ENGINE_PROTOCOL_PRETTY=false`(环境变量或同名 JVM 属性)可保留脱敏后的原始正文展示。 +pretty 只影响显示(JSON 缩进及 SSE 数据区标记),不改实际发送字节、metadata、数值或扩展头; +原始观测正文仍保留,SSE pretty 展示不是可直接重放的抓包。 +JSON 字符串中的 `\\n` 保留转义,避免把业务字符串误写成非法 JSON;非 JSON/不完整正文保持原样。 +日志仍执行脱敏和容量限制。Demo 启动记录 `NEGOTIATION_DEMO enabled=..., city=...`; +本地默认 City1 协商、City2 直接诊断,`-Da2at.samples.negotiation=false` 关闭缺参演示。 +协商激活位于 `A2A-Extensions: .../Negotiation-T/v1`,不存在专用的 Negotiation-T HTTP 头; +正文检查对应 URI 的 metadata 和 `negotiationContext`,不要只检索大写 `NEGOTIATION-T`。 diff --git a/docs/zh/INTEGRATION_GUIDE.md b/docs/zh/INTEGRATION_GUIDE.md index e9ee3f46..5edee085 100644 --- a/docs/zh/INTEGRATION_GUIDE.md +++ b/docs/zh/INTEGRATION_GUIDE.md @@ -4,7 +4,7 @@ A2A-T 工作流执行引擎是一个 Java SDK,用于基于 A2A 协议和 A2A-T 电信扩展编排多智能体工作流。 -引擎自动处理 A2A 协议层的全部机制(消息收发、SSE 流式传输、Task-T 提示词生成、Negotiation-T 协商循环、认证、TLS),你只需关注业务决策。 +引擎处理 A2A 信封、消息收发、流式响应、协商关联、认证和 TLS;宿主负责最终内容生成、语义校验和业务决策。 ## 2. 环境要求 @@ -86,77 +86,33 @@ List> cards = registry.fetchAgentCards(); ### 4.3 实现 ControlPoint -继承 `DefaultControlPoint`,按需覆盖以下方法: - ```java -public class MyControlPoint extends DefaultControlPoint { - @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher dispatcher) { - return dispatcher.dispatch(TaskSubmission.fromText( - request.getAgentName(), request.getMessage(), - StandardTemplates.PRIVATE_LINE_COMPLAINT)) - .thenApply(r -> { - String state = r.getTaskState(); - boolean success = state == null || state.isBlank() - ? r.getText() != null && !r.getText().isBlank() - : state.endsWith("COMPLETED"); - return TaskResponse.builder() - .success(success) - .output(r.getText()) - .build(); - }); - } - - @Override - public CompletableFuture onSelfTask(TaskRequest request) { - // SELF_LOOP 步骤在这里本地处理,不需要 engineClient,不发 A2A-T 消息。 - // request.getMessage() 已包含上游步骤的执行结果上下文。 - String summary = summarizeLocally(request.getMessage()); - return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output(summary).build()); - } - - @Override - public CompletableFuture onRoute( - String stepName, Map results, - List conditions) { - return CompletableFuture.completedFuture( - RouteDecision.builder() - .nextStep(conditions.get(0).getStep()) - .build()); - } - - @Override - public CompletableFuture onNegotiation( - NegotiationRequest request) { - return CompletableFuture.completedFuture( - NegotiationDecision.acceptText("请使用现有信息继续执行。")); - } +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -| 方法 | 何时调用 | 你需要做什么 | -|-------------------|--------------------------------|----------------------------------------------------------| -| `onTask` | 步骤向其他智能体分派任务时 | 向 `TaskDispatcher` 提交强类型 `TaskSubmission` | -| `onSelfTask` | `SELF_LOOP` 步骤本地执行时 | 本地处理并返回结果(不发 A2A-T 消息) | -| `onRoute` | 步骤完成后、决定下一步前 | 从候选分支中选择下一步 | -| `onNegotiation` | 智能体返回 `INPUT_REQUIRED` 时 | 返回强类型 `NegotiationDecision` | - -`onNegotiation` 默认返回通用的 `acceptText` 决策。结构化业务数据应使用 -`acceptData/rejectData/abortData`,不要拼接字符串控制前缀。信息协商使用 `rejectData` -时,Map 必须以每个无法提供的信息项名称为 key、具体原因为 value 逐项填写;不能只传一个汇总的 -“拒绝原因”。例如同时无法提供端口和投诉分类时,应分别传入 `接入端口名称` 与 `投诉分类` 两项。 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 +onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 +未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 -**流程外操作(Authorization-T / Notification-T)**:两者都由宿主在各自业务时机通过 -`ExtensionSender` 独立触发,与工作流没有固定先后关系。Authorization-T 是响应完成即结束的一次性请求; -Notification-T 是独立于单次工作流 transport 的长连接订阅,ACK 通过 -`NotificationSubscription.acknowledgement()` 获取,后续事件通过回调持续接收,直到收到目标结果、 -显式取消或宿主关闭订阅。 - -**自环节点(SelfLoop)**:当一个步骤是工作流执行智能体自身的任务(例如汇总多个智能体的诊断结果),把 `stepType` 设为 -`SELF_LOOP`。引擎会调用 `onSelfTask` 本地处理,而不是通过 A2A-T 协议给智能体自己发消息。`onSelfTask` 不接收 `engineClient` -参数——从契约上保证自环任务不会误发 A2A-T。只有发给其他智能体的步骤才走 `onTask` + A2A-T 协议。 +```java +ControlPoint callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture( + MessageContent.text(request.getInstruction()))) + .onSelfTask(request -> CompletableFuture.completedFuture( + TaskResult.success(List.of(Map.of( + "sourceResults", request.getWorkflowInput().upstreamResults()))))) + .onRoute(request -> CompletableFuture.failedFuture( + new IllegalStateException("Supply a routing policy for " + request.stepName()))) + .onNegotiation(request -> CompletableFuture.completedFuture( + new NegotiationReply.Stop("manual.required", "Manual confirmation required"))) + .build(); +``` ### 4.4 执行 @@ -167,7 +123,6 @@ ExecutionResult result = ExecutePsop.builder() .controlPoint(new MyControlPoint()) .runtimeIntent("SPN跨城专线故障诊断与抢通") .lang("zh") - .a2atEnvPath(".env") .credentialsConfigPath("credentials.json") .sslVerify(true) .onFinish((r, history) -> { @@ -183,21 +138,13 @@ ExecutionResult result = ExecutePsop.builder() ### 5.1 .env 文件 -配置 LLM 和提示词运行时: - -```ini -A2AT_LANGUAGE=zh-CN -A2AT_LLM_PROVIDER=openai -A2AT_LLM_MODEL=deepseek-v4-flash -A2AT_LLM_API_KEY=sk-xxxxxxxxxxxxxxxx -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_MAX_TOKENS=2000 -A2AT_LLM_TEMPERATURE=0 -A2AT_LLM_TIMEOUT_SECONDS=60 -A2AT_CRED_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef -``` - -不配置 `.env` 时,Task-T 提示词生成不可用,其余功能不受影响。 +引擎不读取 A2A-T .env,也不创建 LLM client。业务回调需要 A2A-T 时, +宿主用自己的环境文件初始化 A2ATClient/A2ATServer,配置 provider/model/key/base URL 和 A2AT_LANGUAGE。 +样例的 a2atEnvPath 是宿主/Demo 配置,不是引擎 builder 参数。 +OMC 凭据解密不与 LLM 配置耦合:内置凭据模式通过 +WorkflowEngineClientConfig.builder().credentialEncryptionKey(key) 显式提供密钥, +再将配置好的 engineClient 传给 ExecutePsop。自定义 AuthProvider 自行管理 token 和配置。 +测试使用当前 SDK SPI 的离线 provider,不覆盖模板,也不是生产失败兜底。 ### 5.2 凭证配置文件 @@ -242,7 +189,7 @@ openssl rand -hex 32 4f8a2b1c3d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b ``` -将生成的密钥写入 `.env` 文件: +生成密钥后由宿主安全保存,并显式传给 credentialEncryptionKey;下列环境变量也可由宿主读取: ``` A2AT_CRED_KEY=4f8a2b1c3d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b @@ -308,7 +255,6 @@ public class SsoAuthProvider implements AuthProvider { WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .authProvider(new SsoAuthProvider(mySsoClient)) .sslVerify(true) - .a2atEnvPath(".env") .build(); ``` @@ -402,66 +348,27 @@ AgentCard 通过 `capabilities.extensions` 声明扩展点: ## 7. A2A-T 扩展能力 -引擎自动处理四个 A2A-T 扩展,你无需关心协议细节: - -### Task-T(自动) - -`onTask` 通过 `TaskSubmission.fromText(..., templateUri)`、 -`TaskSubmission.fromUnclassifiedText(...)` 或 `TaskSubmission.fromData(...)` 明确选择输入轨道, -再交给 `TaskDispatcher.dispatch(...)`。工作流已经知道投诉模板时必须传入模板;只有宿主尚未完成 -场景分类时才使用 `fromUnclassifiedText` 让 SDK 识别场景。引擎自动调用匹配的 A2A-T SDK 方法并注入 metadata。 - -### Negotiation-T(自动) - -智能体返回 `INPUT_REQUIRED` 时,引擎构造 `NegotiationRequest`,调用你的 `onNegotiation()` 获取 -Accept/Reject/Abort 强类型决策,然后调用对应 SDK fromText/fromData 方法并发回后续消息。自动循环最多 -`maxNegotiationRounds` 次(默认 3)。 - -### Authorization-T(前置下发) - -工作流开始前,向 SPN 智能体下发白名单授权策略。前置操作使用基于同一 transport 的 `ExtensionSender` 门面,而非工作流客户端: +只有远端 `INPUT_REQUIRED` 携带有效 Negotiation-T Propose 才进入 `onNegotiation`。 +终态不会重启协商,普通 INPUT_REQUIRED 明确报告不支持的交互。 +宿主自行校验、理解 Propose,并用自己的 A2A-T client 生成最终 Accept/Reject/Abort。 +通过 `A2atMessages.contextOf(request.received())` 取得收到的上下文; +结束回复保持相同 id、round、maxRounds,最后允许的一轮仍可回答,不自行 nextRound 或返回新 Propose。 -```java -ExtensionSender sender = new DefaultExtensionSender(transport); -sender.sendAuthorization( - "SPN Domain Agent", - "Authorization-T pre-positioning", - "任务类型:新增授权,操作:业务抢通,操作类型:光模块更换,..." -); -``` - -内部使用 `A2ATExtension.AUTHORIZATION_T`,勿硬编码 URI。SPN 智能体收到后存储策略,后续操作与白名单比对,在策略内直接执行,不在则拒绝。 - -### Notification-T(前置订阅) - -工作流开始前,向 SPN 智能体订阅抢通结果通知: +返回 `new NegotiationReply.Send(content)` 发送最终内容; +返回 `new NegotiationReply.Stop(code, reason)` 只在本地停止,不生成 Abort。 +同一任务/会话/轮次的重复等待事件不会重复回调、重复提交;未变化状态通过 getTask 观察。 +`maxNegotiationExchanges` 默认 3,是独立于 SDK context.maxRounds 的本地交互资源预算。 +超时、预算耗尽、回调缺失均明确失败,不默认 Accept,也不自动生成 Abort。 +Accept/Reject 的 SUBMITTED/WORKING ACK 仍需等待任务结果,不重发原命令。 +业务发送 Abort 后,即使远端用 COMPLETED 确认,也不能判为诊断成功。 ```java -sender.sendNotification( - "SPN Domain Agent", - "Notification-T subscription", - "通知主题:service-recovery-execution-result,..." -); +CompletableFuture sendAuthorization(String agentName, MessageContent content); +NotificationSubscription openNotification(String agentName, MessageContent content, + BiConsumer listener); ``` -`A2ATExtension.NOTIFICATION_T` 打开长连接 SSE 流。如需接收后续抢通结果,传入第四个参数 `Consumer>` 回调: - -```java -sender.sendNotification( - "SPN Domain Agent", - "Notification-T subscription", - "通知主题:service-recovery-execution-result,...", - event -> { - // event 包含 agent, text, metadata, state - Object text = event.get("text"); - if (text != null) { - System.out.println("抢通结果: " + text); - } - } -); -``` - -不传回调(null)时后续事件被丢弃。SPN 智能体通过通知通道上报抢通结果。 +宿主生成最终 Authorization-T/Notification-T 内容后调用上述接口;使用三类独立 transport/runtime/context。订阅监听器收到 handle 与完整 ReceivedMessage,按业务收到抢通结果后关闭。handle.acknowledgement() 和 completion() 分别表示 ACK 和真实流退出,不用它们的成功作为工作流前提。 ## 8. HTTPS 配置 @@ -486,25 +393,62 @@ HTTP/JSON-RPC 的 TLS 策略只作用于当前客户端,不修改 JVM 全局 ## 9. 日志 -引擎设有专用 `PROTOCOL` 日志器输出协议层请求/响应摘要。Body 默认禁用; -`Authorization`、Cookie、API Key、Token、Secret 等敏感 Header 默认脱敏。只能在受控联调环境临时开启 DEBUG 和 body: +将 `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 -# 复用 root 的 console/file appender;不要在没有正确 appenderRef 时设为 false logger.protocol.additivity=true +WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY=true +WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS=100000 ``` -以下环境变量或同名 JVM system property 控制内容: +DEBUG 开启时正文观测默认开启;敏感部署可显式禁用。JVM 同名属性优先于环境变量。 +认证头、Cookie、Token 及识别出的口令字段强制脱敏,不提供关闭脱敏的开关。 +这是字段级脱敏,不能自动识别所有个人信息和业务敏感内容,生产应另定日志策略。 +缓冲有界:原始收集器按该数值限制字节,输出文本按字符限制; +超限 SSE 帧整帧丢弃至下个分隔符并标记 dropped-capacity,禁用、截断、中断也有明确标记。 +UTF-8 分片先组装再解码,日志观察失败不改变报文投递;文件引用不会为打印日志而下载。 +requestId 关联单次调用;工作流调用还带 executionId/logicalTaskId/attempt、 +agent/contextId/channel,已知远端任务时附 remoteTaskId,均为本地日志字段,不污染协议 metadata。 -```properties -WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY=true -WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS=100000 -WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=false +### 查看真实协商与协议日志 + +直接运行本地 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 ``` -只有在隔离、受控的本地联调中才可临时打开敏感 Header;打开时引擎会打印安全告警。 +每个测试类覆盖无 VM 参数的默认单城市协商、显式关闭、显式开启及两城市同时缺参。测试使用离线 LLM provider,但实际运行当前 SDK 的 +模板、校验和 HTTP/SSE;不是现网 OMC、平台或真实模型语义的验证。 ## 10. 事件回调 @@ -547,41 +491,7 @@ Workflow workflow = LoadPsop.load( ## 12. 自定义扩展 -如需扩展新的 A2A-T 扩展点,实现 `ExtensionHandler` 接口: - -```java -public class MyExtensionHandler implements ExtensionHandler { - @Override - public String extensionKeyword() { - return "My-Extension"; - } - - @Override - public CompletableFuture> beforeSend( - AgentCard agentCard, String messageText, - Map metadata, - A2ATClient a2atClient, ControlPoint controlPoint) { - metadata.put("https://example.com/extensions/My-Extension/v1", "value"); - return CompletableFuture.completedFuture(metadata); - } - - @Override - public CompletableFuture afterReceive( - AgentCard agentCard, SendMessageResult result, - A2ATClient a2atClient, ControlPoint controlPoint, - EventCallback eventCallback) { - return CompletableFuture.completedFuture(result); - } -} -``` - -通过配置注册: - -```java -WorkflowEngineClientConfig.builder() - .customHandlers(List.of(new MyExtensionHandler())) - .build(); -``` +直接构造最终 MessageContent(parts, metadata, extensions),由宿主管理扩展内容的生成/校验。无需注册引擎 handler 或 SDK 实例。A2atMessages.from 是 A2A-T metadata 复制辅助;非 A2A-T 扩展也可直接提供 metadata 和激活 URI。引擎不会仅因 AgentCard 声明而自动生成内容。 ## 13. 你需要使用的接口一览 @@ -594,9 +504,8 @@ WorkflowEngineClientConfig.builder() | `A2ATransport` | 共享通信层(A2A Java 客户端 runtime、认证、SSE 消费) | | `WorkflowEngineClientConfig` | 配置(SSL、认证、A2A-T、协商轮数、自定义 Handler) | | `AuthProvider` | 自定义认证 | -| `ExtensionHandler` | 自定义扩展 | | `EventCallback` / `EventType` | 事件回调 | | `LoadPsop` / `RegistryClient` | 工作流加载 / AgentCard 获取 | | `Workflow` / `WorkflowStep` / `Task` / `JumpCondition` | 工作流定义 | | `ExecutionResult` | 执行结果 | -| `SendMessageResult` / `TaskResponse` | 消息/任务响应 | +| `SendMessageResult` / `TaskResult` | 消息/任务响应 | diff --git "a/docs/zh/\344\270\232\345\212\241\346\265\201.md" "b/docs/zh/\344\270\232\345\212\241\346\265\201.md" index 607b8c02..ec5f6fbc 100644 --- "a/docs/zh/\344\270\232\345\212\241\346\265\201.md" +++ "b/docs/zh/\344\270\232\345\212\241\346\265\201.md" @@ -57,3 +57,11 @@ | **授权请求(工作台→OMC)** | **包含内容:**
          1. 操作类型:新增/修改/删除/查询授权策略
          2. SDK 模板所定义的策略列表或查询条件 | | **授权响应(OMC→工作台)** | artifact 的 Authorization-T metadata 返回“授权操作执行结果”;新增/查询成功时还返回策略标识和策略列表,删除必须精确匹配策略标识。失败返回 `FAILED` 且保留原策略。 | | **后置操作(OMC内部)** | 授权成功后,当OMC在执行诊断后的业务抢通时,若匹配到已授权项,则**自动执行**业务抢通,并依据**表格三**的订阅通道上报抢通结果消息。 | + + +## 引擎回调与依赖结果 + +上述场景使用同一套业务回调:onTask 返回最终 MessageContent,引擎封装信封并发送;onSelfTask 返回 TaskResult。 +多地市、多任务的输出按步骤分组保留于 WorkflowInput,contextFrom 选择是否聚合,当前 instruction 不拼历史。 +宿主自行调用 A2A-T SDK 生成协商回复,onNegotiation 返回 Send(content) 或 Stop;引擎只管理会话关联和等待。 +[业务回调集成契约](BUSINESS_CALLBACKS.md) 给出字段、最终内容与上下文选择的完整规则。 diff --git "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" index abf3fbb2..09562b52 100644 --- "a/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\267\245\344\275\234\345\217\260\351\233\206\346\210\220\346\214\207\345\215\227.md" @@ -1,7 +1,7 @@ # 工作流执行引擎 SDK 工作台集成指南 > 交付基线:workflow engine `1.0.0`,A2A-T SDK -> `1.0.0-0de5a27`(commit `0de5a2751781419820436e5eb17cffc39b9db47d`), +> `1.1.0`(commit `e42c83acce3e5ac2c245d36546ced0fa017b2b58`), > A2A Java SDK `1.2.0.Final`。本执行引擎是首发版本, > 只支持该当前 A2A-T API/资源基线,不提供旧 SDK 状态机接口或旧报文 fallback。 @@ -44,19 +44,16 @@ SDK 在该场景中承载四类 A2A-T 交互,对应业务流文档的四个接 ### 1.3 SDK 自动处理 vs 工作台实现 -引擎的设计原则是: **协议机制由 SDK 承担,业务决策由工作台实现**。 +| 引擎承担 | 工作台承担 | +|---|---| +| DAG、并行、任务关联、信封与传输 | onTask 返回最终 MessageContent | +| 上游来源选择、完整响应组装 | 根据业务需要映射上下游输入 | +| 协商关联、去重、等待 | SDK 生成/校验回复,返回 Send 或 Stop | +| 认证机制与 Header 注入 | 凭据来源或自定义 AuthProvider | +| 独立授权发送与订阅生命周期 | SDK 生成授权/订阅内容、处理业务结果 | -| SDK 自动处理 | 工作台实现 | -|-------------|-----------| -| A2A 消息收发、SSE 流式传输、事件提取 | 是否发送任务、发送什么内容 | -| Agent 认证 (Bearer 令牌获取/刷新) | 凭证配置 (credentials.json) 或自定义 AuthProvider | -| Task-T 结构化提示词生成 (调用 A2A-T SDK) | 诊断任务的原始自然语言输入 | -| Negotiation-T 自动协商循环 (重发 follow-up) | 协商澄清内容 (onNegotiation 返回) | -| Authorization-T / Notification-T 报文构造 | 授权策略描述、订阅主题描述 | -| DAG 遍历、上下文组装、状态管理 | 分支路由决策 (onRoute 返回) | -| 事件全量产出 (EventType) | 事件处理 (EventCallback) | - ---- +A2A-T client/server、模板、schema、LLM 初始化属于宿主; +workflow-engine 仅依赖 a2a-t-core,不管理内容生成。 ## 2. 集成全景图 @@ -267,7 +264,7 @@ String encrypted = CredentialCrypto.encrypt("Admin@123"); } ``` -运行时 SDK 从 `A2AT_CRED_KEY` 环境变量读取密钥自动解密。密钥来源优先级: 操作系统环境变量 > Java 系统属性 (由 `.env` 文件加载器设置)。 +运行时 SDK 从 `A2AT_CRED_KEY` 环境变量读取密钥自动解密。密钥来源优先级:显式 credentialEncryptionKey > 操作系统 A2AT_CRED_KEY > 同名 JVM 属性。引擎不自动读取宿主 LLM .env。 凭证配置支持的完整字段: @@ -309,7 +306,6 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() headers.put("Authorization", "Bearer " + token); }) .sslVerify(sslVerify) - .a2atEnvPath(a2atEnvPath) .build(); ``` @@ -355,394 +351,100 @@ SDK 通过此配置调用 A2A-T SDK 生成 Task-T 结构化提示词。如果此 ### 步骤 3: 实现 AgentExecutor -工作台作为 A2A 服务端,需要实现 `AgentExecutor` 接口。这是 上层故障系统调用工作台的入口。SDK 不提供此实现 -- 它是工作台的业务代码。 - -`AgentExecutor` 来自 A2A Java SDK (`org.a2aproject.sdk.server.agentexecution.AgentExecutor`)。工作台实现 `execute` 方法接收上层任务,实现 `cancel` 方法处理取消。 - -接口签名: - -```java -public interface AgentExecutor { - void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError; - void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError; -} -``` - -工作台实现示例: - -```java -public class TransportWorkbenchExecutor implements AgentExecutor { - - private final String orchUrl; // 编排中心地址 (PSOP 搜索/加载) - private final String credentialsPath; // 凭证配置路径 - private final boolean sslVerify; // 是否校验 TLS 证书 - private final String a2atEnvPath; // A2A-T .env 路径 - - public TransportWorkbenchExecutor(String orchUrl, String credentialsPath, - boolean sslVerify, String a2atEnvPath) { - this.orchUrl = orchUrl; - this.credentialsPath = credentialsPath; - this.sslVerify = sslVerify; - this.a2atEnvPath = a2atEnvPath; - } - - @Override - public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { - String taskId = ctx.getTaskId(); - String contextId = ctx.getContextId(); - String input = extractText(ctx.getMessage()); - - // 1. 向 上层系统 回报任务已接收 (A2A 状态消息) - emitter.submit(buildStatusMessage(contextId, taskId, "任务已接收")); - emitter.startWork(buildStatusMessage(contextId, taskId, "正在处理")); - - try { - // 2. 委托编排管线执行完整的诊断流程 - String result = new WorkbenchOrchestrator( - orchUrl, credentialsPath, sslVerify, a2atEnvPath - ).run(input); - - // 3. 返回诊断汇总结果 (A2A Artifact) - List> parts = List.of(new TextPart("跨城故障协同诊断汇总结果")); - emitter.addArtifact(parts, "result", "cross-city-diagnosis", null, false, true); - emitter.complete(buildStatusMessage(contextId, taskId, "完成")); - } catch (Exception e) { - emitter.fail(buildStatusMessage(contextId, taskId, "失败: " + e.getMessage())); - } - } - - @Override - public void cancel(RequestContext ctx, AgentEmitter emitter) throws A2AError { - emitter.cancel(); - } - - // --- 工具方法 --- - private static String extractText(Message message) { - StringBuilder sb = new StringBuilder(); - for (Part part : message.parts()) { - if (part instanceof TextPart tp) sb.append(tp.text()); - } - return sb.toString(); - } +工作台实现 A2A Java AgentExecutor.execute(RequestContext, AgentEmitter) 接收 WAIMO, +并在自己的入口用 A2ATServer.validateTaskPromptAndDataFilling 校验 Task-T。 +metadata 中的 Task-T 正文与 templateUri 是内容入口,不用 parts 长短决定读取哪个正文。 +然后搜索工作流、获取 AgentCards、调用执行引擎,并通过 A2A emitter 返回 artifact/终态。 - private static Message buildStatusMessage(String contextId, String taskId, String text) { - return Message.builder() - .messageId(UUID.randomUUID().toString()) - .contextId(contextId) - .taskId(taskId) - .role(Message.Role.ROLE_AGENT) - .parts(new TextPart(text)) - .build(); - } -} -``` - -`AgentEmitter` 的调用时序: `submit` (已接收) -> `startWork` (处理中) -> `addArtifact` (结果) -> `complete` (完成)。这一时序是 A2A 协议的标准消息交互,上层故障系统据此感知任务进度。 - -如果使用 Spring Boot starter,只需将此类标注 `@Component`,starter 自动注册到 A2A 服务: - -```java -@Component -public class TransportWorkbenchExecutor implements AgentExecutor { ... } -``` +可编译的完整实现见 samples 的 SpringWorkbenchExecutor、WorkbenchTaskInputParser、 +WorkbenchOrchestrator;Spring starter 只提供协议入口与生命周期,不替宿主决定业务或模板。 +入口取消应停止对应本地执行并清理宿主资源;远端已提交任务需按业务策略显式取消。 +不要把错误或空结果伪装成成功诊断。 ### 步骤 4: 实现 ControlPoint -`ControlPoint` 是工作台实现的核心决策接口。SDK 在工作流执行过程中调用这四个方法,工作台决定"做什么",SDK 负责"怎么发"。 - -```java -public interface ControlPoint { - CompletableFuture onTask(TaskRequest request, TaskDispatcher taskDispatcher); - default CompletableFuture onSelfTask(TaskRequest request) { ... } - CompletableFuture onRoute(String stepName, Map results, List conditions); - default CompletableFuture onNegotiation(NegotiationRequest request) { ... } -} -``` - -可以继承 `DefaultControlPoint` (已实现 onSelfTask、onNegotiation 默认逻辑),只重写需要的方法。下面逐个说明。 - -#### 4.1 onTask -- 下发诊断任务 - -SDK 在工作流走到一个需要向下游 Agent 发送任务的步骤时调用此方法。工作台决定发送什么内容给哪个 Agent。 - -`request` 包含: `agentName` (目标 Agent 名称)、`stepName` (当前步骤名)、`message` (SDK 组装的完整消息,含上游上下文)。 - -关键设计:给每个 OMC 发送的任务只包含该 OMC 相关的结构化参数,不混入其他地市的信息。 -有结构化业务数据时使用 `TaskSubmission.fromData`;自由文本且工作流已知投诉模板时使用 -`TaskSubmission.fromText(agent, text, StandardTemplates.PRIVATE_LINE_COMPLAINT)`;只有模板确实 -未知时才使用 `fromUnclassifiedText` 触发 SDK 场景识别: - -```java -public class WorkbenchControlPoint extends DefaultControlPoint { - - @Override - public CompletableFuture onTask( - TaskRequest request, TaskDispatcher dispatcher) { - String step = request.getStepName(); - String agentName = request.getAgentName(); - Map data = buildCityTaskData(step); - TaskSubmission submission = TaskSubmission.fromData( - agentName, - "创建专线业务投诉诊断任务", - data, - SpnCasePrompts.privateLineComplaintSchema(), - StandardTemplates.PRIVATE_LINE_COMPLAINT); - - return dispatcher.dispatch(submission) - .thenApply(r -> { - String state = r.getTaskState(); - boolean success = state == null || state.isBlank() - ? r.getText() != null && !r.getText().isBlank() - : state.endsWith("COMPLETED"); - return TaskResponse.builder() - .success(success) - .output(r.getText()) - .build(); - }) - .exceptionally(e -> TaskResponse.builder() - .success(false) - .error("Agent 调用失败: " + e.getMessage()) - .build()); - } - - private static Map buildCityTaskData(String step) { - return switch (step) { - case "diagnosis_city1" -> SpnCasePrompts.privateLineComplaintData(); - case "diagnosis_city2" -> SpnCasePrompts.privateLineComplaintDataCity2(); - default -> throw new IllegalArgumentException("未知诊断步骤: " + step); - }; - } -} -``` - -`TaskDispatcher.dispatch(submission)` 内部根据输入类型选择 A2A-T SDK 显式 fromText、场景识别或 -fromData API,并自动完成: -Task-T 生成 -> Bearer 认证 -> SSE 流式发送 -> 事件提取 -> Negotiation-T 协商循环。 - -#### 4.2 onSelfTask -- 本地汇总 (自环节点) - -当工作流步骤标记为 `SELF_LOOP` 时,SDK 调用此方法。这意味着该步骤由工作台本地处理,不向下游发送 A2A-T 消息。典型用途: 汇总多个 OMC 的诊断结果。 - ```java -@Override -public CompletableFuture onSelfTask(TaskRequest request) { - String message = request.getMessage(); - // 工作台本地汇总逻辑 (可调用 LLM 做故障定位结论) - String fallback = "汇总分析完成。故障定位: 城市1 OMC,端口 Down,光功率 -28dBm 低于阈值。"; - String sys = "你是 SPN 跨城故障协同诊断汇总专家。根据两地市 OMC 诊断结果,输出故障定位结论。"; - String result = LlmHelper.text(a2atEnvPath, sys, message, fallback); - return CompletableFuture.completedFuture( - TaskResponse.builder().success(true).output(result).build()); +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); } ``` -`DefaultControlPoint` 的默认实现是将 `request.message` 原样返回。如果不需要 LLM 汇总,可以不重写此方法。 - -#### 4.3 onRoute -- 分支路由 - -当工作流步骤有条件分支时 (即 `next` 列表中 `JumpCondition` 带有非空 `condition`),SDK 调用此方法。工作台根据已有结果决定走哪个分支。 - -返回 `RouteDecision`,其 `nextStep` 必须是 `conditions` 中声明的某个步骤名: +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 +onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 +未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 ```java -@Override -public CompletableFuture onRoute( - String stepName, Map results, List conditions) { - // 简单场景: 取第一个非终止分支 - String nextStep = conditions.get(0).getStep(); - for (JumpCondition jc : conditions) { - if (!"end".equals(jc.getStep()) && !"retry".equals(jc.getStep())) { - nextStep = jc.getStep(); - break; - } - } - return CompletableFuture.completedFuture( - RouteDecision.builder().nextStep(nextStep).reason("默认路由").build()); -} +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(); ``` -路由规则说明: -- 步骤没有 `next` -> 终止步骤,工作流结束。 -- `next` 中所有 `condition` 为空 -> 无条件扇出: 并行调度所有下游步骤。 -- `next` 中有 `condition` -> 条件分支: 调用 `onRoute`,返回单个目标步骤 (N 选 1)。 - -复杂场景可使用 LLM 决策路由 (参考编排中心 `DynamicWorkflowEngine._llm_route_decision` 的实现)。 - -#### 4.4 onNegotiation -- 协商澄清 - -当 OMC 返回 `INPUT_REQUIRED` (Negotiation-T) 时,SDK 自动进入协商循环。SDK 传入包含协商类型、 -会话轮次、concern 和 SDK 校验抽取后的只读 `parameters` 的 `NegotiationRequest`;工作台返回强类型 -决策,不需要自行发送消息。业务判断优先使用 `parameters`,无需解析协议 metadata。 - -触发条件: OMC 发现任务参数有误或缺失 (必选参数缺失、取值不在枚举范围、语义校验错误等)。 - -```java -@Override -public CompletableFuture onNegotiation( - NegotiationRequest request) { - return CompletableFuture.completedFuture( - NegotiationDecision.acceptData(Map.of( - "接入端口名称", "P533-珠江旧城-PTN3900-23-TPA1EG24-1", - "投诉分类", "专线质差"))); -} -``` - -业务可返回 `acceptText/acceptData/rejectText/rejectData/abortText/abortData`。SDK 自动选择对应的 -Negotiation-T fromText/fromData API 并重发,最多 3 轮(可配置);不再支持 `data:` 等字符串控制前缀。 -若拒绝 Information Propose,必须对每个无法提供的请求项逐项给出原因。例如: - -```java -NegotiationDecision.rejectData(Map.of( - "接入端口名称", "当前账号没有资源系统查询权限", - "投诉分类", "当前账号没有资源系统查询权限")); -``` - -不能把两项合并成 `Map.of("拒绝原因", "...")`;最新 A2A-T SDK 会把逐项内容渲染为 -Information Negotiation Reject 报文。 - -协商循环时序: - -```mermaid -sequenceDiagram - participant WB as 工作台 - participant SDK as SDK 引擎客户端 - participant OMC as OMC - - WB->>SDK: onTask -> dispatch(TaskSubmission) - SDK->>OMC: Task-T 下发诊断任务 - OMC-->>SDK: INPUT_REQUIRED (Negotiation-T) - SDK->>WB: onNegotiation() - WB-->>SDK: 返回 NegotiationDecision - SDK->>OMC: SDK 渲染并重发 follow-up - OMC-->>SDK: 诊断结果 (COMPLETED) - SDK-->>WB: TaskResponse -``` +生产 Task-T 生成及协商示例见 WorkbenchControlPoint / examples.negotiation.NegotiationStrategy;上述仅是普通 A2A 内容示例。 ### 步骤 5: 组装编排管线 -这一步将所有组件串联起来。工作台实现一个编排类,在 `AgentExecutor.execute` 被调用时运行完整的诊断流程。 - ```java -public class WorkbenchOrchestrator { - - private final String orchUrl; - private final String credentialsPath; - private final boolean sslVerify; - private final String a2atEnvPath; - - public String run(String messageText) throws Exception { - // 1. 加载 AgentCard (工作台自身 + 下游 OMC) - List agentCards = loadAgentCards(); - - // 2. 搜索 + 加载 PSOP 工作流 (从编排中心) - String psopId = searchPsop(messageText); - Workflow workflow = LoadPsop.load(orchUrl, psopId, null, sslVerify); - - // 3. 前置预定位使用工作台级 transport - // 生产环境应在服务启动时创建并持有 - A2ATransport extensionTransport = new A2ATransport( - agentCards, - null, - WorkflowEngineClientConfig.builder() - .sslVerify(sslVerify) - .a2atEnvPath(a2atEnvPath) - .credentialsConfigPath(credentialsPath) - // 如需自定义认证 (企业 SSO 等),取消注释: - // .authProvider((agentName, card, headers) -> { - // headers.put("Authorization", "Bearer " + ssoClient.getToken(agentName)); - // }) - .build()); - ExtensionSender extensionSender = new DefaultExtensionSender(extensionTransport); - - // 4. 前置预定位: 向每个 OMC 下发授权策略 + 订阅通知 - prePositionExtensions(extensionSender, agentCards); - - // 5. 工作流发送使用任务级 transport - A2ATransport taskTransport = new A2ATransport( - agentCards, - null, - WorkflowEngineClientConfig.builder() - .sslVerify(sslVerify) - .a2atEnvPath(a2atEnvPath) - .credentialsConfigPath(credentialsPath) - .build()); - WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(taskTransport); - - // 6. 执行工作流 - WorkbenchControlPoint controlPoint = - new WorkbenchControlPoint(a2atEnvPath, new NegotiationStrategy(a2atEnvPath)); - ExecutionResult result = ExecutePsop.builder() - .psop(workflow) - .agentCards(agentCards) - .controlPoint(controlPoint) - .engineClient(engineClient) - .runtimeIntent(messageText) - .lang("zh") - .sslVerify(sslVerify) - .credentialsConfigPath(credentialsPath) - .a2atEnvPath(a2atEnvPath) - .eventCallback(createLogCallback()) - .onFinish((r, events) -> { - // 持久化执行记录 (可选) - log.info("工作流完成: success={}, 事件数={}", r.isSuccess(), events.size()); - return CompletableFuture.completedFuture(null); - }) - .execute() - .join(); - - return buildResultText(result); - } +WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() + .sslVerify(true) + .credentialsConfigPath(credentialsPath) // 内置认证;自定义认证时改为 authProvider 并不配置它 + .credentialEncryptionKey(credentialKey) // 宿主密钥;明文配置可不提供 + .maxNegotiationExchanges(3) + .build(); +try (A2ATransport transport = new A2ATransport(cards, runtime, config)) { + WorkflowEngineClient client = new DefaultWorkflowEngineClient(transport); + ExecutionResult result = ExecutePsop.builder() + .psop(workflow).agentCards(cards).controlPoint(callbacks) + .engineClient(client).runtimeIntent(intent).execute().join(); } ``` -#### 5.1 前置预定位详解 +workflow、cards、callbacks、intent 由宿主取得或构造。 +runtime 为 null 时使用默认直连实现;dev 的东信 runtime 只替换传输,不替换业务回调。 +上面 transport 仅属于这次工作流;授权和通知使用另外的实例。 +宿主自己的 A2ATClient 可在回调中复用,但要确保业务状态按 execution/task 隔离。 -步骤 4 的前置预定位是本场景的关键设计。授权和订阅是单次消息调用,与工作流执行分离: +#### 5.1 前置预定位详解 ```java -private void prePositionExtensions(ExtensionSender sender, List agentCards) { - for (AgentCard card : agentCards) { - String name = card.name(); - if (name.contains("Workbench")) continue; // 跳过工作台自身 - - // 授权预定位: 告知 OMC 允许自动执行的业务抢通操作 - String authInput = "任务类型新增授权,操作名称业务抢通," - + "操作类型光模块更换,操作对象 SPN 专线业务," - + "溢权策略 OMC 自动执行," - + "触发执行条件业务投诉诊断确认故障," - + "预期输出返回是否成功。"; - try { - sender.sendAuthorization(name, "下发授权放行策略", authInput).join(); - } catch (RuntimeException e) { - recordAuthorizationFailure(name, e); // 只影响本次授权,不阻止订阅或工作流 - } - - // 订阅预定位: 向 OMC 订阅业务抢通结果通知 - String notifInput = "通知主题为 service-recovery-execution-result," - + "订阅条件为业务抢通方案执行结果," - + "上报通知数据格式为 TextPart。"; - try { - sender.sendNotification(name, "订阅业务抢通结果通知", notifInput, event -> { - // SSE 长连接回调: OMC 后续推送的抢通结果通过此通道接收 - Object text = event.get("text"); - Object state = event.get("state"); - log.info("[抢通结果] agent={}, state={}, text={} chars", - event.get("agent"), state, - text != null ? String.valueOf(text).length() : 0); - }).join(); - } catch (RuntimeException e) { - recordSubscriptionFailure(name, e); // 只影响该订阅,不阻止工作流 +// authorizationSender 与 notificationSender 各自使用独立 transport/runtime。 +MessageContent authContent = A2atMessages.from( + sdk.generateAuthPromptFromDataWithSchema(authData, authSchema, + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT.uri()), + List.of(new TextPart("新增动网操作授权"))); +authorizationSender.sendAuthorization(agentName, authContent) + .whenComplete((result, error) -> recordIndependentAuthorization(result, error)); + +MessageContent notificationContent = A2atMessages.from( + sdk.generateNotificationPromptFromDataWithSchema(notifData, notifSchema, + StandardTemplates.SERVICE_RECOVERY.uri()), + List.of(new TextPart("订阅业务抢通事件"))); +NotificationSubscription subscription = notificationSender.openNotification( + agentName, notificationContent, (handle, received) -> { + if (received.artifacts().stream().anyMatch(a -> "recovery-result".equals(a.name()))) { + handle.close(); } - } -} + }); +// 将 handle 加入宿主订阅集合;ACK/失败都仅影响这个独立操作。 +// 监听器使用传入的 handle,不依赖此时外部 subscription 变量是否赋值。 +subscription.acknowledgement().whenComplete((ack, error) -> recordIndependentSubscription(ack, error)); ``` -- `sendAuthorization`: 向 OMC 下发授权白名单。OMC 在后续诊断中如果命中已授权操作 (如光模块更换),自动执行抢通。 -- `sendNotification`: 建立 SSE 订阅通道。第四个参数 `Consumer>` 是长连接 SSE 回调,OMC 后续推送的抢通结果 (recovery result) 通过此回调实时接收。回调收到的 Map 包含 `agent`、`text`、`metadata`、`state` 等字段。不传回调 (null) 时后续事件被丢弃。 - -这两个方法返回 `CompletableFuture`,`.join()` 阻塞等待首个事件 (订阅确认) 完成。SDK 内部自动处理 Authorization-T / Notification-T 报文构造和认证头注入(来自 `AuthProvider` 或 credentials 配置)。对于 `sendNotification`,`.join()` 返回后 SSE 长连接仍然存活,后续事件持续推送到回调,直到工作台主动关闭 transport。 - -授权和订阅必须分别捕获、分别记录结果:授权失败仍要尝试订阅,订阅失败不回滚授权;两者任何一种结果都不能作为工作流启动条件。已经打开的订阅应在等待 ACK 前登记到工作台生命周期,确保后续步骤异常时仍能精确关闭资源。 +sdk、业务数据、schema、recordIndependent* 均由宿主提供,不是引擎隐式服务。 +内容生成失败也只记录本次独立操作失败,不能阻止任务 DAG。 +close() 幂等请求关闭;completion() 等待真实流退出。工作流完成不能关闭独立订阅。 +实际生命周期与异常处理见 samples 的 ExtensionPrePositioner / WorkbenchExtensionLifecycle。 #### 5.2 PSOP 工作流搜索与加载 @@ -776,11 +478,11 @@ private EventCallback createLogCallback() { case EventType.AGENT_REQUEST -> log.info("[发送] -> {}", data.get("agent")); case EventType.AGENT_RESPONSE -> log.info("[接收] <- {}", data.get("agent")); case EventType.NEGOTIATION_REQUEST -> - log.info("[协商请求] agent={}, 第{}轮", data.get("agent"), data.get("round")); + log.info("[协商请求] agent={}, 第{}次交互", data.get("agent"), data.get("exchange")); case EventType.NEGOTIATION_RESOLVED -> log.info("[协商完成] agent={}", data.get("agent")); // 以下事件类型为预留,当前 SDK 版本不会触发 - // (Authorization-T / Notification-T ExtensionHandler 未注册) + // (独立操作不进入工作流事件流) case EventType.AUTHORIZATION_REQUEST -> log.info("[授权请求] agent={}", data.get("agent")); case EventType.NOTIFICATION -> @@ -799,135 +501,94 @@ private EventCallback createLogCallback() { 事件类型完整列表见 SDK 的 `EventType` 常量类。关键事件: `start` -> `step_start` -> `agent_request` / `agent_response` (含 `negotiation_*`) -> `step_complete` -> `complete` / `error` -> `close`。 -> **注意:** `authorization_request` 和 `notification` 事件类型在当前 SDK 版本中不会被触发 (Authorization-T / Notification-T 的 ExtensionHandler 未注册到 ExtensionRegistry)。这些事件类型为未来扩展预留。前置预定位阶段的授权/订阅响应通过 `ExtensionSender` 的返回值 (`SendMessageResult`) 获取,不经过事件流。 +> **注意:** `authorization_request` 和 `notification` 事件类型在当前 SDK 版本中不会被触发 (独立操作不进入工作流事件流)。这些事件类型为未来扩展预留。独立授权通过 SendMessageResult,订阅通过 handle 的 ACK 和监听器取得响应,不经过事件流。 --- ## 4. 完整调用时序 -以下是工作台收到 上层系统 下发诊断任务后的完整端到端时序。 - -### 4.1 关键设计: 预定位与工作流执行分离 - -SDK 提供两个独立的门面 (facade),职责隔离: - -| 门面 | 职责 | A2A-T 扩展 | 调用时机 | -|------|------|-----------|---------| -| `ExtensionSender` | 前置预定位 | Authorization-T, Notification-T | 工作流启动前,工作台手动调用 | -| `WorkflowEngineClient` | 工作流执行 | Task-T, Negotiation-T | 工作流执行中,由 `ExecutePsop` 内部调度 | - -**`ExecutePsop` 不做任何预定位。** 它只负责 DAG 遍历、调用 `ControlPoint` 决策回调、通过 `WorkflowEngineClient` 发送 Task-T 消息、处理 Negotiation-T 协商循环。预定位是工作台编排类的职责,必须在调用 `ExecutePsop` 之前完成。 - -两个门面分别使用独立的 `A2ATransport` 生命周期。`WorkflowEngineClient` 使用任务级 transport,工作流结束后由 `ExecutePsop` 关闭;`ExtensionSender` 使用工作台级 transport,承载 Notification-T 长连接,直到服务关闭。 - -### 4.2 完整时序图 - ```mermaid sequenceDiagram - participant UPPER as 上层故障系统 - participant WB as 工作台 (WorkbenchOrchestrator) - participant ES as ExtensionSender (SDK 预定位门面) - participant EP as ExecutePsop (SDK 工作流运行器) - participant EC as WorkflowEngineClient (SDK 工作流发送门面) - participant OMC as OMC - - UPPER->>WB: A2A-T Task-T (原始诊断任务) - WB-->>UPPER: submit("任务已接收") - WB-->>UPPER: startWork("处理中") - - rect rgba(245, 245, 245, 1) - Note over WB: 工作台编排: 初始化 - Note over WB: 1. 加载 AgentCard (工作台 + OMC) - Note over WB: 2. 搜索/加载 PSOP (从编排中心) - Note over WB: 3. 创建工作台级 A2ATransport + ExtensionSender - Note over WB: 4. 创建任务级 A2ATransport + WorkflowEngineClient - end - - rect rgba(240, 248, 255, 1) - Note over WB,OMC: 工作台编排: 前置预定位 (手动调用 ExtensionSender) - WB->>ES: sendAuthorization(OMC, 授权策略) - ES->>OMC: Authorization-T (授权白名单) - OMC-->>ES: 授权确认 - ES-->>WB: SendMessageResult - - WB->>ES: sendNotification(OMC, 订阅主题, Consumer回调) - ES->>OMC: Notification-T (订阅请求) - OMC-->>ES: 订阅确认 - ES-->>WB: SendMessageResult (SSE 长连接已建立) - end - - rect rgba(255, 248, 240, 1) - Note over WB,OMC: 工作台编排: 执行工作流 (调用 ExecutePsop) - WB->>EP: ExecutePsop.builder().execute() - - Note over EP: emit: start - Note over EP: WorkflowExecutor DAG 遍历 - - Note over EP: step_start: diagnosis_city1 - EP->>WB: ControlPoint.onTask(request, taskDispatcher) - WB->>EC: taskDispatcher.dispatch(TaskSubmission) - EC->>OMC: Task-T (诊断请求) - OMC-->>EC: 诊断结果 (或 INPUT_REQUIRED) - Note over EC,OMC: (若 INPUT_REQUIRED: 自动 Negotiation-T 协商循环) - EC-->>WB: SendMessageResult - WB-->>EP: TaskResponse - Note over EP: step_complete: diagnosis_city1 - - Note over EP: step_start: diagnosis_city2 (并行) - EP->>WB: ControlPoint.onTask(request, taskDispatcher) - WB->>EC: taskDispatcher.dispatch(TaskSubmission) - EC->>OMC: Task-T (诊断请求) - OMC-->>EC: 诊断结果 - EC-->>WB: SendMessageResult - WB-->>EP: TaskResponse - Note over EP: step_complete: diagnosis_city2 - - Note over EP: step_start: merge (SELF_LOOP) - EP->>WB: ControlPoint.onSelfTask(request) - Note over WB: 本地 LLM 汇总 (无 A2A-T 发送) - WB-->>EP: TaskResponse - Note over EP: step_complete: merge - - Note over EP: emit: complete - EP->>WB: onFinish(result, events) 持久化 - EP-->>WB: ExecutionResult - end - - WB-->>UPPER: addArtifact(诊断汇总结果) - WB-->>UPPER: complete("完成") - - rect rgba(240, 255, 240, 1) - Note over ES,OMC: 后续: OMC 自动抢通并通过已建立的 SSE 通道上报 - OMC-->>ES: Notification-T SSE (抢通结果推送) - ES-->>WB: Consumer 回调 (预定位时注册的回调) + 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 ``` -### 4.3 时序说明 - -**初始化阶段 (工作台代码):** -- 步骤 1-4 是工作台 `WorkbenchOrchestrator.run()` 方法的代码,不是 SDK 自动完成的 -- SDK 只提供组件类 (`A2ATransport`, `ExtensionSender`, `WorkflowEngineClient`),工作台负责创建和组装 - -**预定位阶段 (工作台调用 ExtensionSender):** -- 工作台遍历下游 OMC AgentCard,逐个调用 `sendAuthorization` 和 `sendNotification` -- 这是单次消息调用 (one-shot),不进入工作流 DAG -- `sendNotification` 的 `Consumer` 回调注册后,SSE 长连接持续存活,后续 OMC 推送的抢通结果通过此回调接收 - -**工作流执行阶段 (工作台调用 ExecutePsop):** -- `ExecutePsop` 内部创建 `WorkflowExecutor`,执行 DAG 遍历 -- 每个步骤: `WorkflowExecutor` 调用 `ControlPoint.onTask()`,工作台决定发什么内容 -- 工作台在 `onTask` 中提交强类型 `TaskSubmission`,SDK 自动选择显式 fromText、场景识别或 - fromData,并处理认证、SSE 与协商循环 -- `SELF_LOOP` 步骤调用 `onSelfTask()`,工作台本地处理,不发送 A2A-T 消息 - -**后续通知阶段 (SSE 长连接):** -- OMC 在诊断后先上报抢通方案;只有方案精确命中有效 Authorization-T 白名单时,才自动执行 - 本场景的隧道调优 -- 抢通结果通过独立 Notification-T SSE 长连接推送 -- 回调在 `openNotificationFromData` 时注册,与 `ExecutePsop` 无关 - ---- +```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. 工作台需交付物清单 @@ -961,7 +622,6 @@ WorkflowEngineClientConfig.builder() .clientCertPath("/path/to/client-cert.pem") .clientKeyPath("/path/to/client-key.pem") .credentialsConfigPath(credentialsPath) - .a2atEnvPath(a2atEnvPath) .build() ``` @@ -985,12 +645,9 @@ public class SsoAuthProvider implements AuthProvider { ### 6.3 协商轮次限制 -默认协商最多 3 轮。如果 OMC 的协商场景复杂,可在创建 `WorkflowEngineClient` 时调整: - -```java -// Java: DefaultWorkflowEngineClient 构造时传入 -WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport, null, null, 5); -``` +WorkflowEngineClientConfig.builder().maxNegotiationExchanges(3) 配置本地交互预算, +与收到的 SDK context.maxRounds 分开。耗尽/超时只本地失败,不自动生成 Abort。 +结束回复保留收到的 id/round/maxRounds,最后一轮仍可回答。 ### 6.4 SSE 长连接管理 @@ -998,58 +655,39 @@ WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport, n ### 6.5 并发与并行 -SDK 的 `WorkflowExecutor` 自动并行调度同一层的步骤 (无依赖关系的步骤并发执行)。步骤内的子任务也并行执行。工作台无需自行管理并发。如果 OMC 有并发限制,通过工作流定义中的步骤分层 (layer) 控制执行顺序。 +SDK 的 `WorkflowExecutor` 自动并行调度同一层的步骤 (无依赖关系的步骤并发执行)。步骤内的子任务也并行执行。工作台无需自行管理并发。如果 OMC 有并发限制,通过 DAG 依赖边或宿主限流控制;layer 不是执行顺序约束。 ### 6.6 错误处理 -`SendMessageResult` 包含 `taskState` 字段。关键状态: -- `COMPLETED`: 任务正常完成 -- `INPUT_REQUIRED`: 需要协商 (SDK 自动处理,超过轮次后返回此状态) -- `FAILED`: 任务失败 +引擎处理远端协议状态和结果解包;onTask 只返回 MessageContent。onSelfTask 返回 TaskResult,失败原因与 outputs 分离。异常和失败 future 由引擎记录;不要把未发送成功包装成成功输出。详见 [业务回调契约](BUSINESS_CALLBACKS.md)。 -有明确 `taskState` 时,工作台应以任务状态判断成功;只有旧协议或消息响应没有状态时,才回退到文本内容判断: +## 7. 接口速查表 ```java -.thenApply(r -> { - String state = r.getTaskState(); - boolean success = state == null || state.isBlank() - ? r.getText() != null && !r.getText().isBlank() - : state.endsWith("COMPLETED"); - return TaskResponse.builder() - .success(success) - .output(r.getText()) - .build(); -}) +interface ControlPoint { + CompletableFuture onTask(TaskRequest request); + CompletableFuture onSelfTask(TaskRequest request); + CompletableFuture onRoute(RouteRequest request); + CompletableFuture onNegotiation(NegotiationRequest request); +} ``` -## 7. 接口速查表 +onTask 返回最终 parts/metadata/extensions,引擎封装发送,不再生成或改写内容。 +onSelfTask 返回本地 TaskResult;onRoute 选择允许的候选;onNegotiation 返回 Send 或 Stop。 +未实现的回调明确失败,不回显成功、不选首分支、不自动同意。 +字段与完整示例见 [业务回调集成契约](BUSINESS_CALLBACKS.md)。 -### 工作台实现的接口 - -| 接口 | 方法 | 签名 (Java) | 何时调用 | -|------|------|-------------|---------| -| `AgentExecutor` | `execute` | `void execute(RequestContext ctx, AgentEmitter emitter)` | 上层系统 下发任务时 | -| `AgentExecutor` | `cancel` | `void cancel(RequestContext ctx, AgentEmitter emitter)` | 上层系统 取消任务时 | -| `ControlPoint` | `onTask` | `CompletableFuture onTask(TaskRequest, TaskDispatcher)` | 步骤需要向下游发任务 | -| `ControlPoint` | `onSelfTask` | `CompletableFuture onSelfTask(TaskRequest)` | 自环节点本地处理 | -| `ControlPoint` | `onRoute` | `CompletableFuture onRoute(String, Map, List)` | 条件分支选择 | -| `ControlPoint` | `onNegotiation` | `CompletableFuture onNegotiation(NegotiationRequest)` | OMC 返回 INPUT_REQUIRED | -| `AuthProvider` | `applyAuth` | `void applyAuth(String agentName, AgentCard agentCard, Map headers)` | 每次消息发送前注入认证头 (可选) | - -### SDK 提供的接口 - -| 接口 | 方法 | 签名 (Java) | 用途 | -|------|------|-------------|------| -| `WorkflowEngineClient` | `sendMessage` | `CompletableFuture sendMessage(String agent, String message)` | 工作流中向下游发消息 | -| `ExtensionSender` | `sendAuthorization` | `CompletableFuture sendAuthorization(String agent, String instruction, String input)` | 前置授权预定位 | -| `ExtensionSender` | `sendNotification` | `CompletableFuture sendNotification(String agent, String instruction, String input)` | 前置订阅预定位 (无回调) | -| `ExtensionSender` | `sendNotification` | `CompletableFuture sendNotification(String agent, String instruction, String input, Consumer> callback)` | 前置订阅预定位 (接收 SSE 抢通结果回调) | -| `ExecutePsop` | `execute` | `CompletableFuture execute()` (Builder 模式) | 执行完整工作流 | -| `LoadPsop` | `search` | `List search(String url, String intent, int topK, ...)` | 语义搜索工作流 | -| `LoadPsop` | `load` | `Workflow load(String url, String psopId, ...)` | 加载工作流定义 | -| `AuthProvider` | `applyAuth` | `void applyAuth(String, AgentCard, Map)` | 自定义认证头注入 (接口,工作台实现) | +```java +CompletableFuture sendAuthorization(String agentName, MessageContent content); +NotificationSubscription openNotification(String agentName, MessageContent content, + BiConsumer listener); +``` ---- +- ExecutePsop.builder().execute():执行完整工作流。 +- WorkflowEngineClient.sendMessage(String, MessageContent):DAG 外发送最终内容;DAG 内由执行器调用 dispatch。 +- LoadPsop.search/load、RegistryClient.fetchAgentCards:可选发现辅助。 +- AuthProvider.applyAuth(String, AgentCard, Map):宿主认证,不接收引擎强制规定的凭据文件结构。 +- A2atMessages.from / contextOf:metadata 转换与规范上下文辅助,不是内容 SDK 门面。 ## 8. 与 Demo 的关键区别 @@ -1064,17 +702,61 @@ SDK 的 `WorkflowExecutor` 自动并行调度同一层的步骤 (无依赖关系 | TLS | sslVerify=false | 生产证书校验 | | PSOP | fallback 固定 ID | 编排中心语义搜索 + 动态加载 | | 事件 | 日志打印 | SSE 推送前端 + 持久化数据库 | -| 协商 | 结构化规则示例(`acceptData`) | LLM 生成或业务规则引擎返回强类型决策 | -| 抢通结果 | 不处理 | 通过 Notification-T SSE 通道接收并入库 | +| 协商 | 结构化规则示例(typed reply) | 宿主 SDK 生成最终回复,回调返回 Send 或 Stop | +| 抢通结果 | 通过独立订阅接收,收到结果关闭 | 通过 Notification-T SSE 通道接收并入库 | --- ## 9. 集成自查清单 -- Maven 是否解析到 `a2a-t-client:1.0.0-0de5a27`,且 jar 来自固定 commit 而非旧缓存 +- 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` 是否优先使用 `taskState` 判断成功,而不是只用文本非空判断 +- 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`。 From e8794f4e91b53240b2e3ceaf6b35fe849426cada Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 31 Aug 2026 09:59:57 +0800 Subject: [PATCH 49/49] docs: use the canonical Negotiation-T extension URI in the zh guide Signed-off-by: Zhoujie628 --- docs/zh/INTEGRATION_GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/zh/INTEGRATION_GUIDE.md b/docs/zh/INTEGRATION_GUIDE.md index 5edee085..3b194f88 100644 --- a/docs/zh/INTEGRATION_GUIDE.md +++ b/docs/zh/INTEGRATION_GUIDE.md @@ -303,7 +303,7 @@ AgentCard 通过 `capabilities.extensions` 声明扩展点: "required": false }, { - "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T", + "uri": "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1", "description": "协商文本交换", "required": false },