diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..eb79376 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 + +[{*.yml,*.yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example index 860697e..46810ef 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,11 @@ # under the License. # A2A-T SDK Configuration + +# Opt-in full protocol diagnostics. The second switch exposes credentials and cookies; +# use it only for controlled local troubleshooting. +WORKFLOW_ENGINE_PROTOCOL_LOGGING=false +WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=false # Copy this file to .env and fill in your values # Prompt runtime @@ -35,6 +40,8 @@ A2AT_LLM_BASE_URL=https://api.deepseek.com A2AT_LLM_MAX_TOKENS=2000 A2AT_LLM_TEMPERATURE=0 A2AT_LLM_TIMEOUT_SECONDS=60 +# none/minimal/low/medium/high/xhigh — set to "none" to disable reasoning on reasoning models +A2AT_LLM_REASONING_EFFORT=none A2AT_LLM_HISTORY_WINDOW=10 A2AT_LLM_SESSION_MAX_TOTAL=300 A2AT_LLM_SESSION_MAX_PER_PROVIDER=100 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5e6d123 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto + +*.py text eol=lf +*.md text eol=lf +*.toml text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..7ca7361 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,25 @@ +name: Bug report +description: Report a reproducible defect in the Python SDK +title: "[Bug]: " +labels: [bug] +body: + - type: markdown + attributes: + value: Do not include credentials, tokens, private AgentCards, or unredacted protocol dumps. + - type: input + id: version + attributes: + label: SDK version or commit + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: Include Python version, expected result, and actual result. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Redacted logs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1c6b462 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..99469e6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## Summary + +Describe what changed and why. + +## Compatibility + +- [ ] No public API or protocol compatibility impact +- [ ] Breaking changes are documented with a migration path + +## Verification + +- [ ] `python -m pytest -q` +- [ ] `python -m build` +- [ ] Tests added or updated +- [ ] Logs and examples contain no credentials or production data +- [ ] Commit includes a DCO sign-off (`git commit -s`) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..88b96b6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + strategy: + matrix: + python-version: ["3.12", "3.13"] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install + run: python -m pip install --upgrade pip && python -m pip install -e ".[dev]" + - name: Test + run: python -m pytest -q + - name: Build distributions + run: python -m build diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..2d9f8ef --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,18 @@ +name: Dependency Review + +on: + pull_request: + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + # Temporary: this repository has not enabled Dependency graph yet. + continue-on-error: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d94c47d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: Release to PyPI + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + name: Test, publish, and create GitHub release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install + run: python -m pip install --upgrade pip && python -m pip install -e ".[dev]" + + - name: Validate release version + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + run: | + VERSION="${GITHUB_REF_NAME#v}" + PROJECT_VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + if [[ "${VERSION}" != "${PROJECT_VERSION}" ]]; then + echo "Tag v${VERSION} does not match pyproject version ${PROJECT_VERSION}" + exit 1 + fi + + - name: Test + run: python -m pytest -q + + - name: Build distributions + run: python -m build + + - name: Publish distributions + if: startsWith(github.ref, 'refs/tags/v') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Create GitHub release + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + NOTES="$(awk -v ver="$VERSION" ' + index($0, "## [" ver "]") == 1 { found = 1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md)" + gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1 || \ + gh release create "${GITHUB_REF_NAME}" --verify-tag \ + --title "${GITHUB_REF_NAME}" --notes "${NOTES}" + gh release upload "${GITHUB_REF_NAME}" dist/* --clobber diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f88608..d42be39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ 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 to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.9] - 2026-09-18 + +### Changed +- Align the business callback contract with the Java engine: callbacks prepare final content and never receive the transport client. +- Replace rendered Markdown predecessor context with typed `WorkflowInput` and ordered task outputs. +- Evaluate every conditional outgoing edge independently; unconditional edges always pass and a node may activate zero through N successors. +- Integrate `a2a-t-sdk>=1.0.9,<2` core metadata types while keeping content generation and semantic validation in host code; remove implicit Task-T handlers and legacy negotiation schemas. +- Install development dependencies directly through the editable dev extras and remove the temporary bootstrap file. + +### Added +- Structured response evidence, protocol-to-business result mapping, safe `BusinessFailure`, and standard A2A error mapping. +- Remote `get_task`, `list_tasks`, `cancel_task`, and `subscribe_to_task` operations. +- Explicit `NotificationSubscription` acknowledgement, completion, close, heartbeat, and health lifecycle. +- mTLS/CRL transport configuration and fail-closed TLS file validation. +- Reusable per-agent credential profiles with nested overrides and fail-fast configuration checks. + +### Fixed +- Preserve message, task, and artifact metadata at their original levels and process status/artifact stream updates. +- Detect duplicate graph edges, invalid history sources, duplicate AgentCards, and conflicting authentication headers. +- Use the current A2A interceptor contract so activated extension and complete authentication requirement headers reach the wire. +- Close only resources owned by the high-level runner. +- Read canonical `negotiationContext` metadata and enforce current Propose/Accept/Reject/Abort context and round rules. +- Serialize protobuf message parts as their JSON fields in workflow events instead of leaking descriptor internals. + ## [0.0.3] - 2026-08-10 ### Fixed diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4dc7f6d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,9 @@ +# Code of Conduct + +We are committed to a welcoming, harassment-free community. Be respectful, +focus criticism on the work, and do not disclose private information. + +Maintainers may moderate contributions that violate these expectations. +Reports should be sent privately to the maintainers listed in +[MAINTAINERS.md](MAINTAINERS.md). Security vulnerabilities must follow +[SECURITY.md](SECURITY.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0e40582 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing to workflow-engine-sdk-python + +Thank you for contributing. Development requires Python 3.12 or newer. + +## Setup + +```bash +python -m venv .venv +python -m pip install -e ".[dev]" +python -m pytest -q +python -m build +``` + +Keep Java and Python protocol behavior aligned when changing workflow models, +events, routing, Task-T, or Negotiation-T. Add regression tests for both SDKs +when a change affects shared semantics. + +Do not commit `.env` files, credentials, tokens, certificates, private +AgentCards, or production protocol payloads. INFO logs must contain lifecycle +metadata only; payload diagnostics are opt-in and must be redacted before they +are attached to an issue or pull request. + +Use conventional commit messages and add a DCO sign-off: + +```bash +git commit -s -m "fix: describe the change" +``` + +Pull requests should explain motivation, compatibility impact, tests, and any +required migration. Security reports must follow [SECURITY.md](SECURITY.md). diff --git a/DESIGN.md b/DESIGN.md index 30acccc..c3b997a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,358 +1,116 @@ -# A2A-T Engine SDK - Design +# Workflow Engine Python SDK — Design -> Architecture and design rationale for the `workflow-engine` Python SDK and the -> `workflow-engine-java` Java SDK. This document describes the system as shipped -> in v1.0; the two SDKs are intentionally parallel and maintain feature -> parity. It is written for engineers integrating or extending the SDK, not -> as a walkthrough of any particular bug-fix history. +## 1. Boundary ---- +The engine is protocol-orchestration infrastructure embedded in a host agent. It owns: -## 1. Overview +- workflow validation, dependency resolution, parallel scheduling, and lifecycle events; +- A2A message envelopes, transport selection, authentication headers, task/context identity, remote waiting, and task APIs; +- detection and coordination of a Negotiation-T exchange after a valid `INPUT_REQUIRED` response; +- structured projection of remote messages, artifacts, outputs, and protocol failures. -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 host owns: -The guiding principle is a clean separation between **mechanics the SDK -owns** and **decisions the host owns**: +- schemas, templates, LLM calls, A2A-T generation/validation, and business interpretation; +- final content returned by `on_task` and `on_negotiation`; +- local work in `on_self_task` and per-edge decisions in `on_route`; +- when independent Authorization-T and Notification-T operations run and whether their failures matter to host policy. -| 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 engine depends only on the current `a2a-t-sdk` core metadata model. `A2atMessages` converts typed `MetadataContent` and reads canonical `NegotiationContext`; it does not generate prompts, invoke an LLM, validate business semantics, or use the deprecated state-machine negotiation API. Those content operations remain in host code. ---- +## 2. Architecture -## 2. Layered Architecture +```text +Host agent + ├─ ControlPoint (business content and decisions) + ├─ WorkflowExecutor (DAG, readiness, parallelism, result aggregation) + ├─ WorkflowEngineClient (task/context association, wait, negotiation loop) + └─ A2ATransport (A2A SDK, auth, TLS, stream reduction) -The SDK is structured in four layers. Each layer builds on the one below; -each has a single responsibility and a clear entry point. - -``` -```mermaid -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)"] - F["Foundation - Decision
ControlPoint
user-implemented business decisions"] - - L2 --> L1 --> L0 - L0 -.-> F +Independent channels + └─ ExtensionSender + ├─ Authorization-T one-shot operation + └─ NotificationSubscription long-lived stream ``` -### 2.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 HTTP client -(httpx in Python, the A2A SDK client runtime in Java), 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`** - one-shot pre-positioning. Sends Authorization-T - and Notification-T messages to agents *before* the workflow starts. It - bypasses Task-T generation and the negotiation loop and does not emit - events through the global callback - the returned result *is* the - callback. - -#### Why a shared transport with two facades? - -Both the workflow send path and the one-shot pre-positioning path need the -same wire-level machinery: an HTTP client, TLS configuration, auth -interceptors, agent-card resolution, and SSE parsing. Putting that machinery -on either facade would either (a) force a caller that only wants to -pre-position to hold the full workflow facade, or (b) duplicate the wire code -across two classes. The shared-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. - -### 2.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 `ControlPoint` and every *send* to `WorkflowEngineClient`. - -Step dispatch rules: - -- Steps whose predecessors are all satisfied are collected and dispatched in - parallel (`asyncio.gather` / `CompletableFuture`), so steps at the same - layer run concurrently. -- Subtasks within a step also run in parallel. -- `ALL_SUCCESS` - all subtasks must succeed. -- `ANY_SUCCESS` - the first successful subtask wins; the rest are cancelled. -- `SELF_LOOP` - the task is handled locally via `onSelfTask`, with no A2A-T - message sent to the agent. - -### 2.3 Layer 2 - Orchestration - -**`execute_psop`** (Python) / **`ExecutePsop`** (Java) is the high-level -runner. It wraps the executor with a lifecycle (start / complete / error / -close), event serialization, client-disconnect cancellation, and an -`onFinish` persistence hook. Most integrations use this layer. +`WorkflowExecutor` depends on the client abstraction, not on a concrete network. A caller can replace `A2ATransport` with another transport adapter that preserves the same client contract. ---- +## 3. Business callback contract -## 3. Decision Interfaces +`ControlPoint` has four callbacks: -The SDK exposes two user-implemented interfaces, split by responsibility. +| Callback | Input | Output | Engine action | +|---|---|---|---| +| `on_task` | `TaskRequest` | final `MessageContent` | wraps and sends it | +| `on_self_task` | `TaskRequest` | `TaskResult` | records local outputs; no A2A send | +| `on_route` | one `RouteRequest` | `RouteDecision` | activates or suppresses that edge | +| `on_negotiation` | `NegotiationRequest` | `NegotiationSend` or `NegotiationStop` | continues the same remote task or stops locally | -### 3.1 ControlPoint - flow decisions +No callback receives a transport client. The engine never treats missing handlers as success, route selection, or negotiation consent. -Drives the workflow forward. Each method is called by the executor or the -auto-negotiate loop and makes exactly one decision: +## 4. Data flow -| Method | Called by | Decision | -|---|---|---| -| `onTask` | executor | Send a task to an agent (call `sendMessage`) | -| `onSelfTask` | executor | Handle a self-loop task locally (no A2A-T) | -| `onRoute` | executor | Choose a branch at a conditional step | -| `onNegotiation` | client auto-loop | Supply clarification on INPUT_REQUIRED | +`TaskRequest.input` is the current task input. `TaskRequest.workflow_input` is a separate history window selected by the workflow: -Authorization-T and Notification-T are pre-positioning concerns handled -via `ExtensionSender` before the workflow starts, not in-workflow callbacks. - ---- - -## 4. A2A-T Extension Model - -Four A2A-T extensions are supported. They divide into two groups by -lifecycle. - -### 4.1 In-workflow extensions - -Participate in every `sendMessage` lifecycle through the extension handler -chain (`ExtensionRegistry` pre-registers both): - -- **Task-T** - On send, calls the A2A-T SDK to generate a structured task - prompt from the natural-language message and injects it into the message - metadata. Skipped for negotiation follow-ups and when the caller pre-sets - the prompt. On receive: pass-through. -- **Negotiation-T** - On receive, when the agent returns `INPUT_REQUIRED` - and declares the extension, extracts the negotiation context and message. - This feeds the auto-loop: the engine calls `ControlPoint.onNegotiation` - for a clarification, resends the follow-up, and repeats up to a configured - round limit. - -### 4.2 Pre-positioning extensions - -One-shot sends that happen before the workflow starts, via `ExtensionSender`: - -- **Authorization-T** - Sends an authorization pre-positioning request. - The prompt value is generated by the A2A-T SDK; the engine falls back to - the raw natural-language input when SDK generation is unavailable. -- **Notification-T** - Establishes a result subscription. Opens a long-lived - SSE stream so later recovery results flow back through the response - stream. - -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. - -Prompt generation methods for Authorization-T and Notification-T are -reserved on `ExtensionSender`; Task-T uses the SDK's `generateTaskPrompt`. -Negotiation-T and the reserved generators will be wired to the A2A-T SDK as -that support lands upstream. - -### 4.3 Extension handler chain - -```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 +```text +WorkflowInput + runtime_intent + upstream_results[] + step_name + task_results[] + agent_name / skill / logical task_id / task_description / status + outputs[] + received_messages[] + error / error_code / error_details ``` -`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. - ---- - -## 5. Condition Routing - -A step's `next` list holds `JumpCondition(step, condition)` entries. The -routing rule is: - -- **No `next`** - terminal; the step completes the branch. -- **All conditions empty** - unconditional fan-out: dispatch every - non-terminal next step in parallel. -- **Has conditions** - conditional: call `ControlPoint.onRoute`, which - returns a single `RouteDecision.nextStep`. The engine enforces that the - returned step is among the declared conditions; an invalid step ends the - workflow with a warning. - -This makes conditional branches an N-choose-1 selection and keeps -unconditional fan-out as automatic parallel dispatch. - ---- - -## 6. Event Model - -Events are emitted to an optional `EventCallback` as stable string types -(`EventType`). They are grouped by origin: - -- **Runner lifecycle** - `start`, `complete`, `close` -- **Step / task execution** - `step_start`, `step_complete`, `task_request`, - `task_response`, `task_status_changed`, `route_decision`, - `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` -- **Failure** - `error`, emitted on step failure by the executor and on - final failure by the runner - ---- - -## 7. Interaction Sequences - -### 7.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 (host supplies clarification) - H->>E: clarification - E->>C: follow-up send - C->>A: send follow-up - A-->>C: final result - C->>E: final result - E->>H: ExecutionResult -``` +Remote A2A messages are reduced to `ReceivedMessage` snapshots. Message metadata, task metadata, and artifact metadata remain at their original levels. Outputs are projected from final message parts when no artifact exists, or from artifacts when they do. Failed status messages remain evidence and do not become business output. -### 7.2 Pre-positioning authorization - -```mermaid -sequenceDiagram - participant H as Host - participant ES as ExtensionSender - participant T as Transport - participant A as Agent - - H->>ES: sendAuthorization(agent) - ES->>ES: generate prompt (SDK) - ES->>T: send(instruction, auth) - T->>A: send - A-->>T: auth result - T-->>ES: auth result - ES-->>H: result -``` +## 5. Scheduling and routing -### 7.3 Notification subscription - -```mermaid -sequenceDiagram - participant H as Host - participant ES as ExtensionSender - participant T as Transport - participant A as Agent - - H->>ES: sendNotification - ES->>T: sendNotificationStream - T->>A: open long-lived SSE - A-->>T: ack (working) - T-->>ES: first event -> future - ES-->>H: result - Note over T,A: later results stream back over the same connection -``` +All root steps start independently. A step becomes ready after every activated predecessor has produced a result. Ready steps run in parallel; subtasks within a step also run in parallel. ---- +- `ALL_SUCCESS`: all subtasks must succeed. +- `ANY_SUCCESS`: the first success wins and unfinished sibling calls are cancelled. +- `SELF_LOOP`: each subtask is handled by `on_self_task` without a network send. -## 8. Cross-SDK Parity +Outgoing edges are evaluated as a set: -The Python and Java SDKs are designed for feature parity. Module-to-class -mapping: +- a blank condition is unconditional and allowed without a callback; +- every nonblank condition invokes `on_route` independently, including mixed conditional/unconditional fan-out; +- zero, one, or many successors may be activated; +- all route evaluations finish before any successor is scheduled; +- one route callback failure suppresses every successor of that source and fails execution; +- all denied edges end that branch successfully. -| Concern | Python module | Java class | -|---|---|---| -| Shared wire layer | `client/a2a_transport.py` | `client/A2ATransport` | -| Workflow send facade | `client/engine_client.py` | `client/DefaultWorkflowEngineClient` | -| One-shot facade | `client/extension_sender.py` | `client/DefaultExtensionSender` | -| Extension handlers | `client/extension_handlers.py` | `client/TaskTHandler`, `NegotiationTHandler`, `ExtensionRegistry` | -| Extension enums | `client/extensions.py` | `client/A2ATExtension` | -| Auth | `client/auth_manager.py`, `credential_service.py` | `client/AgentAuthManager`, `AgentCredentialService` | -| SSL/TLS | `client/ssl_context.py` | `client/SslContextFactory` | -| SSE normalization | `client/sse_normalization.py` | `client/SseNormalization` | -| Flow decisions | `control/control_points.py` | `control/ControlPoint`, `DefaultControlPoint` | -| Events | `control/control_points.py` (`EventType`) | `control/EventType`, `EventCallback` | -| DAG traversal | `core/executor.py` | `core/WorkflowExecutor` | -| Context assembly | `core/context_builder.py` | `core/ContextBuilder` | -| Models | `core/models.py` | `model/*` | -| Registry | `registry/registry_client.py` | `registry/RegistryClient`, `LoadPsop` | -| Runner | `runner.py` (`execute_psop`) | `runner/ExecutePsop` | +Graph validation rejects blank or duplicate step names, reserved terminal names, missing or duplicate edge targets, cycles, invalid roots, invalid `context_from` entries, and combining `"*"` with named context sources. -Naming conventions differ by language (Python snake_case, Java camelCase) -but the public surface, event types, extension URIs, and model fields are -aligned. +## 6. Task and negotiation lifecycle ---- +For each logical subtask, the executor generates a stable local task ID. The client generates a fresh A2A context ID, sends host-provided content, and records the first remote task ID. Follow-up sends must preserve both remote IDs. -## 9. Dependencies +`SUBMITTED` and `WORKING` are acknowledgements, not final results. The client polls the A2A task until a terminal state or `INPUT_REQUIRED`. `INPUT_REQUIRED` is accepted as Negotiation-T only when the response contains a valid Negotiation-T context. The callback receives the original submission, current response evidence, previous exchanges, and remaining interaction budget. -**Python SDK:** `a2a-sdk` (A2A protocol), `a2a-t-sdk` (A2A-T extensions), -`httpx`, `loguru`, `protobuf`, `packaging`. +The host may send generated continuation or terminal content, or stop locally. The engine validates transport identity and progression but leaves A2A-T semantic validation to the host SDK; it does not invent an Abort. If local stop, timeout, cancellation, or invalid protocol data ends an interaction after a remote task ID is known, the engine makes a bounded best-effort A2A task-cancel call and preserves the original failure. Repeated proposals are deduplicated and observed rather than resending the business command. The content callback, remote execution, waiting, and negotiation exchanges share one timeout budget. -**Java SDK:** `org.a2aproject.sdk:a2a-java-sdk-client` (A2A protocol), -`net.openan.a2at.sdk:a2a-t-client` (A2A-T extensions), Jackson, SLF4J, -Lombok. +## 7. Independent operations -Both SDKs are standalone: they do not depend on the orchestration center. -The orchestration center consumes the Python SDK as a library. +Authorization-T is a one-shot operation that waits through `SUBMITTED` or `WORKING` until the remote task reaches a final result. Notification-T returns a `NotificationSubscription` before I/O begins, so even an early listener callback can close it. The acknowledgement future exposes the first protocol result; the host must reject failed, canceled, or rejected acknowledgements before treating the subscription as established. The completion future represents the SSE channel lifetime. Event count and last-event time provide local liveness only. ---- +These operations must use channel instances independent from workflow task traffic. Their success is not an engine prerequisite for workflow execution. -## 10. Design Decisions Summary +## 8. Failure model -1. **Shared transport, two facades** - wire machinery written once on - `A2ATransport`; `WorkflowEngineClient` and `ExtensionSender` each own one - orchestration concern and delegate wire work. Avoids both forced-facade - coupling and wire-code duplication. +The current A2A SDK parses official REST/JSON-RPC/gRPC error structures into `A2AError` subclasses. The engine maps them to stable `a2a.` codes and preserves safe protocol facts. A remote task that was created successfully but later failed remains an HTTP-successful interaction; its failed, rejected, or canceled task state is exposed through stable `a2a.task_*` failure semantics. -2. **In-workflow vs pre-positioning extensions** - Task-T and Negotiation-T - are part of the `sendMessage` chain; Authorization-T and Notification-T - are one-shot sends before the workflow. The registry auto-registers only - the in-workflow pair. +Host code may raise `BusinessFailure(code, message, details)` for explicitly safe diagnostics. Generic exceptions use the stable `workflow.execution_failed` code and preserve their message so integration errors remain actionable; credential and provider integrations remain responsible for supplying redacted exception text. -3. **Auto-negotiation loop** - the engine owns the resend loop so hosts only - supply clarification text (`onNegotiation`), never the protocol - mechanics of resending. +## 9. Resource and security rules -4. **Condition routing semantics** - empty conditions mean fan-out - (parallel), conditional branches mean N-choose-1 via `onRoute`. Keeps - the routing model predictable. +- TLS server verification defaults to enabled and configured trust/client files fail closed. +- `ssl_verify=False` is an explicit per-client development opt-out. +- AgentCard authentication requirements must be satisfiable by configured credentials or `AuthProvider`. +- credential profiles are resolved per agent and explicit malformed or missing configuration fails during construction. +- Conflicting header values from credentials and a custom provider fail instead of silently overwriting. +- caller-created transports and HTTP clients remain caller-owned; high-level `execute_psop` closes only clients it creates. +- full protocol payload logging is opt-in and sensitive headers are redacted. diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 6601d2e..1450b41 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1,516 +1,349 @@ -# Developer Guide: workflow-engine SDK +# Developer Guide -Integration guide for the workflow-engine Python SDK: installation, the layered -entry points, ControlPoint implementation, event handling, agent -authentication, and the A2A-T extension model. For the architecture -rationale, see [DESIGN.md](DESIGN.md). +## 1. Required versions ---- - -## 1. Installation +- Python 3.12 or newer +- `a2a-sdk>=1.1.2,<2` +- `a2a-t-sdk>=1.0.9,<2` (the engine uses its core metadata contract; host callbacks use its content APIs) ```bash -pip install workflow-engine +python -m pip install -e ".[dev]" +python -m pytest -q ``` -Dependencies (auto-installed): `a2a-sdk`, `a2a-t-sdk`, `httpx`, `loguru`, -`protobuf`, `packaging`. +Construct the host's `A2ATClient` with an existing `.env` path. Text-generation and semantic-validation calls need a configured LLM; deterministic negotiation `from_data` generation does not make an LLM call. -Verify: +## 2. Workflow model ```python -import workflow_engine -print(workflow_engine.__version__) # 0.0.3 +workflow = Workflow.from_dict({ + "name": "parallel-diagnosis", + "steps": [ + { + "name": "dispatch", + "layer": 0, + "step_type": "AllSuccess", + "subtasks": [ + {"agent": "domain-a", "skill": "diagnose", "description": "diagnose A"}, + {"agent": "domain-b", "skill": "diagnose", "description": "diagnose B"}, + ], + "next": [{"step": "aggregate", "condition": ""}], + }, + { + "name": "aggregate", + "layer": 1, + "step_type": "SelfLoop", + "context_from": ["dispatch"], + "subtasks": [{"agent": "host", "description": "aggregate results"}], + }, + ], +}) ``` ---- - -## 2. Core Concepts +`Task.input` may contain natural-language text or JSON-compatible structured data: -The SDK has three layers. Pick the one that matches your use case: +```python +Task(agent="domain-a", input=BusinessInput.from_text("diagnose circuit")) +Task(agent="domain-b", input=BusinessInput.from_data({"circuit_id": "C-1"})) +``` -| Layer | Entry Point | What It Handles | What You Provide | -|-------|------------|-----------------|-----------------| -| **2 (high)** | `execute_psop()` | Event stream, lifecycle, cancellation, event collection | ControlPoint + AgentCards + config | -| **1 (mid)** | `WorkflowExecutor` | DAG traversal, context assembly, ControlPoint dispatch | ControlPoint + WorkflowEngineClient + Workflow | -| **0 (low)** | `A2ATransport` + facades | A2A send, auth, extensions, SSE normalization | AgentCards + config | +When omitted, the task description becomes the text input. -**Most integrations use Layer 2 (`execute_psop`).** It handles the lifecycle -(start/complete/error/close), cancellation (client disconnect), event -serialization, and an `on_finish` persistence hook. +## 3. Implement callbacks -Layer 0 is a shared `A2ATransport` with two facades on top: -`WorkflowEngineClient` (workflow send) and `ExtensionSender` (one-shot -pre-positioning). See DESIGN.md for the rationale. +### `on_task(request) -> MessageContent` ---- +The callback receives business data only. Common fields: -## 3. Quick Integration (Layer 2: `execute_psop`) +| Field | Meaning | +|---|---| +| `execution_id` | local workflow execution identity | +| `task_id` | stable local logical subtask identity | +| `input` | current task text or structured data | +| `agent_name`, `skill` | target business capability | +| `instruction` | current task description only | +| `step_name`, `language` | workflow provenance | +| `workflow_input` | selected upstream history window | -### 3.1 Minimal Example +The callback may call the A2A-T SDK and an LLM, then return final A2A parts, metadata, and activated extension URIs: ```python -import asyncio -from workflow_engine import ( - execute_psop, ControlPoint, RouteDecision, - TaskResponse, RegistryClient, load_psop, -) +from a2a_t.client import A2ATClient +from a2a_t.core.standard_templates import PRIVATE_LINE_COMPLAINT_URI +a2at_client = A2ATClient(env_path=a2at_env_path) -class MyControlPoint(ControlPoint): - async def on_task(self, request, engine_client): - # SDK assembles the full message (context + task + lang hint) - # in request.message. Just send it. - result = await engine_client.send_message( - request.agent_name, request.message +class Callbacks(ControlPoint): + async def on_task(self, request): + generated = await asyncio.to_thread( + a2at_client.generate_task_prompt_from_text, + request.input.text, + PRIVATE_LINE_COMPLAINT_URI, + ) + return A2atMessages.from_generated( + generated, + [Part(text=request.instruction)], ) - return TaskResponse(success=bool(result.text), output=result.text) - - async def on_route(self, step_name, results, conditions): - return RouteDecision(next_step=conditions[0].step) - - -async def main(): - registry = RegistryClient(url="https://127.0.0.1:5000", ssl_verify=False) - agent_cards = await registry.fetch_agent_cards() - - workflow = await load_psop( - base_url="https://127.0.0.1:5001", - psop_id="your-psop-id", - ssl_verify=False, - ) - - # execute_psop builds A2ATransport + WorkflowEngineClient internally - async for event in execute_psop( - psop=workflow, - agent_cards=agent_cards, - control_point=MyControlPoint(), - a2at_env_path=".env", - credentials_config="agent_credentials.json", - runtime_intent="Diagnose SPN cross-city fault", - ssl_verify=False, - ): - print(f"[{event['type']}] {event['data']}") - - -if __name__ == "__main__": - asyncio.run(main()) ``` -### 3.2 What `execute_psop` Does For You - -| Responsibility | Handled by | -|---------------|-----------| -| Build A2ATransport, then WorkflowEngineClient over it | `execute_psop` | -| Attach ControlPoint + event emitter | `execute_psop` | -| Create WorkflowExecutor with shared emitter | `execute_psop` | -| Run workflow as asyncio task | `execute_psop` | -| Emit `start` / `complete` / `error` / `close` lifecycle events | `execute_psop` | -| Cancel workflow on client disconnect (GeneratorExit) | `execute_psop` | -| Serialize events to JSON-safe dicts | `execute_psop` | -| Call `on_finish(result, collected_events)` after workflow ends | `execute_psop` | -| Call `on_event(event)` transformer per event | `execute_psop` | -| Close transport httpx pool | `execute_psop` | - -### 3.3 Event Types - -Events come from three origins. The runner emits the lifecycle bracket -(`start` ... `complete`/`error` ... `close`); the executor emits step/task -and routing events; the engine client emits agent traffic and the A2A-T -extension handlers emit negotiation/authorization/notification. - -| Event Type | Origin | When | Data Keys | -|-----------|-------|------|-----------| -| `start` | runner | Workflow begins | `workflow`, `steps` | -| `step_start` | executor | A step begins | `step` | -| `task_request` | executor | A subtask is dispatched to `on_task` | `step`, `agent`, `task` | -| `task_response` | executor | `on_task` returned a `TaskResponse` | `step`, `agent`, `task`, `output` | -| `task_status_changed` | executor | Task status updated | `step`, `subtask_index`, `agent`, `status` | -| `route_decision` | executor | Branch decision made | `step`, `next`, `reason` | -| `step_complete` | executor | Step finished | `step`, `results` | -| `agent_request` | engine client | Message sent to agent | `agent`, `request`, `metadata` | -| `agent_response` | engine client | Response received | `agent`, `response` | -| `agent_status_update` | engine client | Streaming status update | `agent`, `state`, `text` | -| `agent_artifact_update` | engine client | Streaming artifact update | `agent`, `artifact_name`, `text` | -| `agent_message_event` | engine client | Streaming message event | `agent`, `text` | -| `negotiation_request` | engine client | Agent needs clarification | `agent`, `round`, `concern` | -| `negotiation_resolved` | engine client | Clarification provided | `agent`, `round`, `clarification` | -| `negotiation_failed` | engine client | Negotiation failed | `agent`, `round`, `reason` | -| `authorization_request` | extension | Agent requests authorization | `agent`, `auth_request` | -| `authorization_resolved` | extension | Authorization decision | `agent`, `decision` | -| `notification` | extension | Agent pushes notification | `agent`, `notification` | -| `workflow_complete` | executor | DAG traversal ended (precedes `complete`/`error`) | (empty) | -| `complete` | runner | Workflow succeeded | `history`, `step_outputs` | -| `error` | runner or executor | Workflow failed | runner: `error`, `history`, `step_outputs`; executor: `step`, `results` | -| `close` | runner | Cleanup done | (empty) | - -Compare with constants: `event["type"] == EventType.STEP_START`. Every type -in the table has a matching constant in `EventType`. - -> **`workflow_complete` vs `complete`, and duplicate `error`:** the -> executor emits `workflow_complete` as soon as DAG traversal ends, then -> the runner emits `complete` (success) or `error` (failure) with the -> final `ExecutionResult`. On a step failure the executor emits an -> `error` carrying `step`/`results`, and the runner later emits a -> second `error` carrying `history`/`step_outputs` -- check `data` keys -> to tell them apart. - -### 3.4 Persistence Hook (`on_finish`) +Do not call `WorkflowEngineClient.send_message()` inside this callback. The executor sends the returned content and preserves protocol identity across waiting and negotiation. -```python -async def on_finish(result, events): - """Called after the workflow ends (success or failure).""" - if result.success: - save_to_database(result.history, result.step_outputs) - else: - log_error(result.error) - - -async for event in execute_psop(..., on_finish=on_finish): - yield event # SSE stream to client -``` - -### 3.5 Event Transformer (`on_event`) +### `on_self_task(request) -> TaskResult` -Inject business-specific events or filter SDK events: +Return zero or more outputs. Outputs may be text or structured JSON values and may contain nested arrays. ```python -def shape_event(event): - if event["type"] == "task_status_changed": - d = event["data"] - update_my_model(d["step"], d["subtask_index"], d["status"]) - return [ - {"type": "psop_update", "data": {"model": my_model_dump()}}, - event, - ] - return event - - -async for event in execute_psop(..., on_event=shape_event): - ... +async def on_self_task(self, request): + summaries = [summarize(step) for step in request.workflow_input.upstream_results] + return TaskResult.succeeded(summaries) ``` -Return values: `event` (pass through), `None` (skip), `list` (inject multiple). +Use `TaskResult.failed(code, message)` or raise `BusinessFailure` for a safe business failure. -### 3.6 Cancellation +### `on_route(request) -> RouteDecision` -Closing the async iterator (client disconnect, timeout) automatically -cancels the running workflow. `execute_psop` catches `GeneratorExit`, -cancels the internal asyncio task, and still calls `on_finish` with a -"cancelled" result. +The engine calls once per nonblank edge. `request.current_results` contains results produced by the source step, while `request.workflow_input` contains the selected upstream window. ```python -async for event in execute_psop(...): - yield event # StreamingResponse -> client disconnect -> GeneratorExit +async def on_route(self, request): + if evaluate(request.condition, request.current_results): + return RouteDecision.allow("condition matched") + return RouteDecision.deny("condition did not match") ``` ---- - -## 4. ControlPoint: Flow Decisions +Unconditional edges never invoke this callback. Multiple conditional edges may be allowed. -`ControlPoint` drives the workflow forward. You must implement `on_task` -and `on_route`; the rest have defaults. Authorization-T and Notification-T -are pre-positioning operations handled via `ExtensionSender` before the -workflow starts, not in-workflow callbacks. +### `on_negotiation(request) -> NegotiationReply` -### 4.1 Methods +Use the host's A2A-T SDK to process `request.received` and generate a final reply for the same negotiation. `request.previous_exchanges` is ordered history; `remaining_wait_seconds` is the remaining engine budget. -| Method | Required | Called When | You Decide | -|--------|----------|-------------|-----------| -| `on_task(request, engine_client)` | **Yes** | A step sends a task to an agent | whether/how to send, what to return | -| `on_self_task(request)` | No (default echoes) | A SELF_LOOP step | local result (no A2A-T message) | -| `on_route(step_name, results, conditions)` | **Yes** | A step has conditional branches | which branch to take | -| `on_negotiation(agent_name, text, result)` | No (default generic) | Agent returns INPUT_REQUIRED | clarification text | - -### 4.2 TaskRequest Fields +```python +async def on_negotiation(self, request): + from a2a_t.core.standard_templates import ( + INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, + INFORMATION_NEGOTIATION_PROPOSE_URI, + ) + from a2a_t.negotiation.content import ( + InformationEndingContent, + NegotiationConclusion, + NegotiationEndingData, + NegotiationItem, + ) -| Field | Description | -|-------|-------------| -| `agent_name` | Target agent name (matches AgentCard.name) | -| `skill` | Skill declared on the task | -| `message` | Full assembled message (upstream context + task + language hint) | -| `description` | Original task description (for logging/history) | -| `context` | Upstream context only (without the current task) | -| `step_name` | Current step name | -| `subtask_index` | Index within the step's subtasks | + context = A2atMessages.negotiation_context(request.received) + metadata = request.received.message.metadata + remote_prompt = metadata[A2ATExtension.NEGOTIATION_T.uri] + validated = a2at_client.validate_propose_prompt_and_data_filling( + remote_prompt, + context, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "description": "Business fields requested by the delegated agent", + }, + "relationship": { + "type": "string", + "nullable": True, + "description": "Logical relationship between requested fields", + }, + }, + "required": ["items"], + }, + INFORMATION_NEGOTIATION_PROPOSE_URI, + ) + supplied = [ + NegotiationItem(name, resolve_business_value(name, request, validated.data)) + for name in validated.data["items"] + ] + generated = a2at_client.generate_negotiation_accept_prompt_from_data( + NegotiationEndingData( + context, + InformationEndingContent(NegotiationConclusion.ACCEPT, supplied), + ), + INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, + ) + content = A2atMessages.from_generated( + generated, + [Part(text="business-supplied clarification")], + ) + return NegotiationReply.send(content) +``` -### 4.3 Route Decision +The host validates and interprets the proposal before deciding. Generate Reject or Abort with the corresponding current content API. Accept, Reject, and Abort reuse the received `id`, `round`, and `maxRounds`; the SDK stamps only the terminal `performative`. Do not call the deprecated `start_negotiation`, `receive_negotiation`, or `continue_negotiation` methods, and do not send the legacy `negotiationId/status/extra` shape. -`conditions` is a `List[JumpCondition]`, each with `.step` (target) and -`.condition` (description). Return `RouteDecision(next_step=...)`. +To stop locally without claiming that a protocol Abort was sent: ```python -async def on_route(self, step_name, results, conditions): - prompt = build_route_prompt(step_name, results, conditions) - decision = await my_llm.generate(prompt) - return RouteDecision(next_step=decision, reason="LLM decided") +return NegotiationReply.stop("negotiation.rejected", "Host rejected the proposal") ``` -If `next_step` is not in the allowed list, the SDK logs a warning and ends -the workflow. Empty conditions mean unconditional fan-out (no `on_route` -call); only conditional branches reach this method. +If the remote task already exists, a local stop, timeout, cancellation, or invalid negotiation response triggers a bounded best-effort A2A task cancellation. This cleanup does not manufacture an A2A-T Reject or Abort and never replaces the original workflow failure. -### 4.4 Negotiation +## 4. Execute -When an agent returns `INPUT_REQUIRED` and supports Negotiation-T, the -engine's `send_message` auto-loop calls `on_negotiation` for a clarification -and resends the follow-up. You only supply the text; do not send messages -here. Return `None`/`""` to fail the round (the loop stops after -`max_negotiation_rounds`). +Low-level execution keeps transport ownership explicit: ```python -async def on_negotiation(self, agent_name, negotiation_text, receive_result): - prompt = f"Agent {agent_name} needs: {negotiation_text}" - return await my_llm.generate(prompt) +transport = A2ATransport( + agent_cards, + credentials_config="agent_credentials.json", + ssl_verify=True, + ca_certs_path="ca.pem", + preferred_protocol="HTTP+JSON", + send_timeout_seconds=600, +) +client = WorkflowEngineClient(transport, max_negotiation_exchanges=3) +result = await WorkflowExecutor( + workflow, Callbacks(), client, + runtime_intent="diagnose service", + lang="en", +).run() +await transport.close() ``` -For reusable strategies, implement `NegotiationStrategy` and inject it into -`DefaultControlPoint`. - ---- - -## 5. Pre-positioning: Authorization-T and Notification-T - -Authorization-T and Notification-T are one-shot operations sent via -`ExtensionSender` **before** the workflow starts. They are not part of the -in-workflow handler chain (`ExtensionRegistry` only auto-registers Task-T -and Negotiation-T). - -| Operation | Method | Callback mechanism | -|-----------|--------|-------------------| -| Authorization-T | `sender.send_authorization(agent, instruction, input)` | Returns `SendMessageResult` directly | -| Notification-T | `sender.send_notification(agent, instruction, input)` | Returns `SendMessageResult` (subscription confirmed). Subsequent SSE events are handled by the workbench agent's business code, not the SDK | - -### Parameters +The high-level runner yields events and owns only resources that it creates: -| Parameter | Type | Description | -|-----------|------|-------------| -| `agent_name` | `str` | Target agent name (must match `AgentCard.name`) | -| `instruction` | `str` | Short instruction text; becomes `parts[].text` in the A2A message body | -| `natural_language_input` | `str` | Natural language input passed to the A2A-T SDK to generate a structured extension prompt. The generated value is placed in the message `metadata` under the extension URI key (e.g. `https://.../Authorization-T/v1`). Falls back to this text as-is when SDK generation is unavailable | +```python +async for event in execute_psop( + workflow, + agent_cards, + Callbacks(), + credentials_config="agent_credentials.json", + on_finish=persist, +): + publish(event) +``` -### Wire format +If `engine_client` is supplied, the caller must close its transport. -The resulting A2A message sent to the agent: +One `WorkflowEngineClient` may be bound to only one active `WorkflowExecutor` at a time. Create separate clients for concurrent workflow executions; transports may be shared only through an adapter that explicitly supports that ownership model. -``` -parts[].text = instruction -metadata = { "": "" } -``` +## 5. Event contract -Example for Authorization-T: +Runner lifecycle: `start`, `complete`, `error`, `close`. -```json -{ - "parts": [{"text": "Authorize diagnosis operations"}], - "metadata": { - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1": "" - } -} -``` +Executor: `step_start`, `step_complete`, `task_request`, `task_response`, `task_status_changed`, `route_decision`, `workflow_complete`. -### Return value +Remote interaction: `agent_request`, `agent_response`, `agent_status_update`, `agent_artifact_update`, `agent_message_event`, `negotiation_request`, `negotiation_resolved`, `negotiation_failed`. -Both methods return `SendMessageResult` containing the agent's response: +Every executor event contains `execution_id`. A `route_decision` event contains source `step`, target `next`, original `condition`, `conditional`, `allowed`, and `reason`. -| Field | Description | -|-------|-------------| -| `text` | Response text from the agent | -| `task_state` | Final task state (e.g. `TASK_STATE_COMPLETED`, `WORKING`) | -| `metadata` | Response metadata (merged task + artifact level) | +## 6. Authorization-T and Notification-T -### Example +Generate and validate content in host code. `A2atMessages.from_generated` keeps the SDK metadata and activates the generated extension, so the target AgentCard must declare that same URI. ```python -sender = ExtensionSender(transport) - -# Authorization-T: send whitelist strategy before workflow -auth = await sender.send_authorization( - "SPN Domain Agent", - "Authorize diagnosis operations", - "Task type: fault diagnosis, operations: optical module replacement, port reset" +from a2a_t.core.standard_templates import ( + AUTHORIZATION_POLICY_MANAGEMENT_URI, + SUBSCRIBE_INCIDENT_URI, ) -# Notification-T: subscribe to recovery results -notif = await sender.send_notification( - "SPN Domain Agent", - "Subscribe to recovery notifications", - "Topic: service-recovery-execution-result" +auth_transport = A2ATransport(agent_cards, auth_provider=provider) +sender = ExtensionSender(auth_transport) + +generated_auth = a2at_client.generate_auth_prompt_from_text( + authorization_text, + AUTHORIZATION_POLICY_MANAGEMENT_URI, ) +auth_content = A2atMessages.from_generated(generated_auth, [Part(text="authorize policy")]) +auth_result = await sender.send_authorization("domain-a", auth_content) +if not auth_result.is_success: + record_independent_failure(auth_result.failure_code, auth_result.failure_message) ``` ---- - -## 6. Layer 0: A2ATransport + Facades - -Use this layer directly when you need manual control of the transport and -the send lifecycle. +Notification uses a long-lived handle: ```python -from workflow_engine import ( - A2ATransport, WorkflowEngineClient, ExtensionSender, - ControlPoint, WorkflowExecutor, -) - -transport = A2ATransport( - agent_cards=agent_cards, - a2at_env_path=".env", - credentials_config="agent_credentials.json", - ssl_verify=False, +def on_notification(subscription, received): + process(received) + if is_expected_final_result(received): + subscription.close() + +generated_notification = a2at_client.generate_notification_prompt_from_text( + subscription_text, + SUBSCRIBE_INCIDENT_URI, ) - -# Workflow facade -engine_client = WorkflowEngineClient(transport) - -# One-shot pre-positioning facade (before the workflow) -sender = ExtensionSender(transport) -auth_result = await sender.send_authorization("agent_a", "authorize", "Diagnose SPN fault") -notif_result = await sender.send_notification("agent_a", "subscribe recovery", "Diagnose SPN fault") - -# Then run the workflow -executor = WorkflowExecutor( - workflow=workflow, - control_point=MyControlPoint(), - engine_client=engine_client, - runtime_intent="Diagnose SPN cross-city fault", +notification_content = A2atMessages.from_generated( + generated_notification, + [Part(text="subscribe to business event")], ) -result = await executor.run() -await transport.close() +subscription = sender.open_notification("domain-a", notification_content, on_notification) +ack = await subscription.acknowledgement +if ack.is_failure: + subscription.close() + record_independent_failure(ack.failure_code, ack.failure_message) +await subscription.completion ``` -Both facades share one transport. `WorkflowEngineClient` owns the workflow -send path (Task-T generation, Negotiation-T auto-loop, event callback); -`ExtensionSender` owns one-shot pre-positioning. Neither duplicates wire -code. - ---- +Use a transport instance separate from workflow task traffic. Host orchestration may log an independent-operation failure, but it must not make workflow execution depend on that result unless explicit business policy says so. -## 7. A2A-T Extensions - -The four extensions split into in-workflow and one-shot pre-positioning: - -| Extension | Lifecycle | Handler | Description | -|---|---|---|---| -| Task-T | in-workflow | `TaskTHandler` (auto-registered) | Generates structured task prompt on send | -| Negotiation-T | in-workflow | `NegotiationTHandler` (auto-registered) | Extracts negotiation context on receive | -| Authorization-T | pre-positioning | N/A (no handler) | Pre-positioned via `ExtensionSender` | -| Notification-T | pre-positioning | N/A (no handler) | Subscription via `ExtensionSender` | - -The handler chain runs in every `send_message`: `before_send` (Task-T -injects the prompt), send, `after_receive` (Negotiation-T extracts context, -feeds the auto-loop). Pre-positioning extensions bypass this chain entirely. - -Prompt generation: Task-T uses the A2A-T SDK's `generateTaskPrompt`. -Authorization-T, Notification-T, and Negotiation-T prompt generators are -reserved on `ExtensionSender` and wired to the A2A-T SDK as support lands -upstream; until then the engine falls back to the raw natural-language -input. - ---- - -## 8. Agent Authentication - -The SDK provides two authentication layers. They can be used independently or combined (custom provider runs first, credentials-based auth second). - -### 8.1 Credentials-Based Auth - -When an AgentCard declares `securitySchemes` and `securityRequirements`, -the SDK logs in to obtain a token and attaches the auth header to outbound -requests. Configure a JSON credentials file (see README for the full field -table) and pass the path to `A2ATransport(credentials_config=...)` or -`execute_psop(..., credentials_config=...)`. +## 7. Existing task operations ```python -transport = A2ATransport( - agent_cards=agent_cards, - credentials_config="agent_credentials.json", - ssl_verify=False, -) +task = await client.get_task(agent, task_id) +tasks = await client.list_tasks(agent, ListTasksRequest(page_size=20)) +cancelled = await client.cancel_task(agent, task_id) +latest = await client.subscribe_to_task(agent, task_id, on_task_event) ``` -A dict may also be passed: `credentials_config={...}`. - -**Password Encryption:** - -Password fields support the `enc::` format -(AES-256-GCM). The SDK reads the key from `A2AT_CRED_KEY` (32-byte hex) -at runtime. To encrypt a password: +Demo or test cleanup may list and cancel unfinished tasks before a run. This is application behavior; the executor does not cancel unrelated remote tasks automatically. -```bash -# Generate key (one-time) -python -c "import secrets; print(secrets.token_hex(32))" +## 8. Authentication -# Encrypt -export A2AT_CRED_KEY= -python -c "from workflow_engine.client.credential_crypto import encrypt; print(encrypt('MyPassword'))" -# Output: enc:xxxxxxxxxxxx:yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy -``` +Credential configuration is keyed by AgentCard name and security-scheme name. A login response token defaults to the `accessSession` body field and may use a configured nested `token_field`. Passwords may use the engine's encrypted `enc:` form. -Or programmatically: +Repeated credentials may be defined once under `profiles` and bound under `agents`; an agent may supply nested `overrides`. Unknown profiles, malformed configuration, missing explicitly configured files, and encrypted values without a valid key fail during transport construction. -```python -from workflow_engine.client.credential_crypto import encrypt -import os -os.environ["A2AT_CRED_KEY"] = "a1b2c3d4..." -encrypted = encrypt("MyPassword") -# encrypted = "enc:xxxxxxxxxxxx:yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" +```json +{ + "profiles": { + "shared-login": { + "bearer": { + "login_url": "https://identity.example/login", + "request_fields": {"username": "user", "password": "enc::"}, + "token_field": "accessSession" + } + } + }, + "agents": { + "domain-a": {"profile": "shared-login"}, + "domain-b": { + "profile": "shared-login", + "overrides": {"bearer": {"request_fields": {"username": "domain-b-user"}}} + } + } +} ``` -### 8.2 Custom AuthProvider - -For non-standard auth (corporate SSO, external identity providers, agents -with no `securitySchemes` that still require auth), implement the -`AuthProvider` ABC: +Alternatively, supply an `AuthProvider`: ```python -from workflow_engine import AuthProvider - -class SsoAuthProvider(AuthProvider): - def __init__(self, sso_client): - self._sso = sso_client - - def apply_auth(self, agent_name: str, agent_card, headers: dict) -> None: - token = self._sso.get_access_token(agent_name) +class Provider(AuthProvider): + def apply_auth(self, agent_name, agent_card, headers): + token = token_service.get_or_refresh(agent_name) headers["Authorization"] = f"Bearer {token}" ``` -Register via `A2ATransport`: +The provider owns token acquisition. The engine does not read provider-specific usernames, passwords, or login addresses. If configured credentials and the provider produce different values for the same header, the request fails. -```python -transport = A2ATransport( - agent_cards=agent_cards, - auth_provider=SsoAuthProvider(sso_client), - ssl_verify=False, -) -``` +## 9. TLS and diagnostics -`apply_auth` is called on **every** message send, regardless of whether -the AgentCard declares `securitySchemes`. Typical use cases: +Default TLS verifies the server with system trust. Optional parameters are `ca_certs_path`, `client_cert_path`, `client_key_path`, `client_key_password`, and `crl_path`. Missing or invalid configured files raise during construction. -- AgentCard has no `securitySchemes` but the server still requires auth -- Auth tokens come from an external identity provider (e.g. corporate SSO) -- Non-standard auth headers (e.g. `X-Custom-Token`, `X-Request-Signature`) +Set `WORKFLOW_ENGINE_PROTOCOL_LOGGING=true` to log pretty-printed final A2A request/response objects. Sensitive headers stay redacted unless the separate sensitive-header flag is explicitly enabled. Protocol logs are diagnostic representations; transport internals may still prevent observation of exact wire bytes. -### 8.3 Combining Both +## 10. Workflow retrieval ```python -transport = A2ATransport( - agent_cards=agent_cards, - credentials_config="agent_credentials.json", # credentials auth - auth_provider=SsoAuthProvider(sso_client), # custom auth (runs first) - ssl_verify=False, -) -``` +matches = await search_psop(base_url, intent, top_n=5, access_token=token) +workflow = await load_psop(base_url, matches[0].workflow_id, access_token=token) ---- - -## 9. Integration Checklist +registry = RegistryClient(registry_url) +agent_cards = await registry.fetch_agent_cards() +``` -1. [ ] Install: `pip install workflow-exec-engine` -2. [ ] Implement `ControlPoint` (at minimum: `on_task` + `on_route`) -3. [ ] Get AgentCards (from registry or custom source) -4. [ ] Load a PSOP workflow (`load_psop`) or build a `Workflow` from dict -5. [ ] Configure agent auth (optional: `credentials_config` or `auth_provider`) -6. [ ] Pre-position Authorization-T / Notification-T via `ExtensionSender` (optional) -7. [ ] Run with `execute_psop` (Layer 2) or `WorkflowExecutor` (Layer 1) -8. [ ] Drain the event stream; persist results in `on_finish` +`ssl_verify=False` is available for controlled development endpoints with untrusted certificates. It is scoped to the created HTTP client and does not change process-wide TLS defaults. diff --git a/README.md b/README.md index 0aad961..2cd1d3e 100644 --- a/README.md +++ b/README.md @@ -1,279 +1,127 @@ -# workflow-engine +# A2A-T Workflow Engine SDK(Python) -独立工作流执行 SDK,支持宿主 Agent 执行编排中心工作流(PSOP),同时保留对 A2A 通信、A2A-T 扩展与路由决策的完全控制权。SDK 自包含,不依赖编排中心任何代码。 +面向宿主智能体的嵌入式工作流执行引擎。引擎负责工作流 DAG、A2A 消息封装、任务与会话关联、远端任务等待、Negotiation-T 交互循环和生命周期;宿主负责业务输入解释、A2A-T 内容生成与语义校验,以及路由和协商决策。 -> 完整设计参见 [DESIGN.md](DESIGN.md)。本文档面向集成者,快速上手与接口说明。 +Python 与 Java 版本遵循同一业务契约。Python 当前要求 Python 3.12+、`a2a-sdk>=1.1.2,<2` 和 `a2a-t-sdk>=1.0.9,<2`。 -## 设计原则 +## 安装 -| SDK 提供(协议机制) | 用户控制(业务决策) | -|---|---| -| A2A 消息发送、流式、SSE 归一化 | 何时 / 是否发送任务 | -| Agent 认证(Bearer、自定义 Header,基于 AgentCard) | 凭据配置 | -| A2A-T 扩展(Task-T、Negotiation-T、Authorization-T、Notification-T) | 授权审批、通知处理 | -| DAG 遍历、上下文组装、状态管理 | 分支路由决策 | -| 事件追踪 | 事件处理方式 | - -## 架构 - -共享传输层 + 两个门面,职责单一: - -``` -A2ATransport(共享通信层:httpx + 认证 + AgentCard 映射 + SSE 消费) - ├── WorkflowEngineClient(工作流发送门面:Task-T 生成、Negotiation-T 自动循环、事件回调、ControlPoint 装配) - └── ExtensionSender(一次性预置门面:Authorization-T / Notification-T 发送) +```bash +python -m pip install workflow-exec-engine ``` -决策层接口: - -- **ControlPoint** — 流程决策(`on_task` / `on_self_task` / `on_route` / `on_negotiation`) - -Authorization-T 和 Notification-T 是预置操作,在工作流启动前通过 `ExtensionSender` 单向下发,不在工作流执行中回调。 - -```mermaid -flowchart TB - subgraph User["用户(宿主 Agent)"] - AC["AgentCards
(注册中心或自定义来源)"] - CP["ControlPoint
流程决策"] - end - subgraph SDK["SDK(自包含)"] - TR["A2ATransport
共享通信层"] - WEC["WorkflowEngineClient
工作流发送"] - ES["ExtensionSender
一次性预置"] - WE["WorkflowExecutor
DAG 遍历"] - end - subgraph Agents["远端 Agents"] - A1["Agent A"] - A2["Agent B"] - end - AC --> TR - TR --> WEC - TR --> ES - WEC -->|send_message| A1 - WEC -->|send_message| A2 - ES -->|预置发送| A1 - WE -->|on_task/on_route| CP - WEC -->|on_negotiation| CP -``` +执行引擎直接使用最新版 A2A-T core 元数据类型识别协议,但不会调用 LLM,也不会替宿主生成或校验 Task-T、Negotiation-T、Authorization-T、Notification-T 的业务内容;这些内容操作仍由宿主调用 `a2a-t-sdk` 完成。 -## 快速开始 +## 最小集成 ```python -import asyncio +from a2a.types import Part +from a2a_t.client import A2ATClient +from a2a_t.core.standard_templates import PRIVATE_LINE_COMPLAINT_URI from workflow_engine import ( - execute_psop, ControlPoint, RouteDecision, - TaskResponse, RegistryClient, load_psop, + A2ATransport, A2atMessages, ControlPoint, ExtensionSender, + MessageContent, NegotiationReply, RouteDecision, TaskResult, + WorkflowEngineClient, WorkflowExecutor, ) -class MyControlPoint(ControlPoint): - async def on_task(self, request, engine_client): - # SDK 已组装完整消息(上下文 + 任务 + 语言提示),直接发送 - result = await engine_client.send_message( - request.agent_name, request.message +class BusinessCallbacks(ControlPoint): + async def on_task(self, request): + # a2a-t-sdk 负责内容生成;执行引擎只接收生成结果并发送。 + generated = a2at.generate_task_prompt_from_text( + request.input.text, + PRIVATE_LINE_COMPLAINT_URI, + ) + return A2atMessages.from_generated( + generated, + [Part(text=request.instruction)], ) - return TaskResponse(success=bool(result.text), output=result.text) - - async def on_route(self, step_name, results, conditions): - # conditions: List[JumpCondition],每个含 .step 与 .condition - # 用你的 LLM 或业务逻辑选一个分支 - return RouteDecision(next_step=conditions[0].step) - - -async def main(): - # 1. 获取 AgentCards(注册中心或自定义来源) - registry = RegistryClient(url="https://127.0.0.1:5000", ssl_verify=False) - agent_cards = await registry.fetch_agent_cards() - - # 2. 加载 PSOP 工作流 - workflow = await load_psop( - base_url="https://127.0.0.1:5001", - psop_id="your-psop-id", - ssl_verify=False, - ) - - # 3. 执行:execute_psop 内部构建 A2ATransport + WorkflowEngineClient - async for event in execute_psop( - psop=workflow, - agent_cards=agent_cards, - control_point=MyControlPoint(), - a2at_env_path=".env", - credentials_config="agent_credentials.json", - runtime_intent="诊断 SPN 跨市故障", - ssl_verify=False, - ): - print(f"[{event['type']}] {event['data']}") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -## 分层入口 - -| 层 | 入口 | 处理 | 你提供 | -|---|---|---|---| -| 2(高) | `execute_psop()` | 事件流、生命周期、取消、onFinish | ControlPoint + AgentCards + 配置 | -| 1(中) | `WorkflowExecutor` | DAG 遍历、上下文组装、调度 | ControlPoint + WorkflowEngineClient + Workflow | -| 0(低) | `A2ATransport` + 两个门面 | A2A 发送、认证、扩展、SSE | AgentCards + 配置 | - -大多数集成使用第 2 层。需要手动控制时使用第 1 层。仅做一次性预置发送时直接使用 `ExtensionSender`。 - -## 用户实现的接口 - -### ControlPoint(流程决策) - -| 方法 | 必需 | 调用时机 | 决定 | -|---|---|---|---| -| `on_task(request, engine_client)` | 是 | 步骤需向 Agent 发送任务 | 是否 / 如何发送 | -| `on_self_task(request)` | 否(默认回显) | SELF_LOOP 步骤 | 本地处理结果 | -| `on_route(step_name, results, conditions)` | 是 | 步骤有条件分支 | 走哪个分支 | -| `on_negotiation(agent_name, text, result)` | 否(默认通用澄清) | Agent 返回 INPUT_REQUIRED | 补充澄清文本 | -## A2ATransport + 门面(第 0 层) + async def on_self_task(self, request): + outputs = aggregate(request.workflow_input.upstream_results) + return TaskResult.succeeded(outputs) -```python -from workflow_engine import A2ATransport, WorkflowEngineClient, ExtensionSender + async def on_route(self, request): + # 每条非空条件边独立调用;允许多条边同时返回 true。 + return RouteDecision.allow() if matches(request.condition) else RouteDecision.deny() -transport = A2ATransport( - agent_cards=agent_cards, - a2at_env_path=".env", - credentials_config="agent_credentials.json", - ssl_verify=False, -) + async def on_negotiation(self, request): + generated = generate_terminal_negotiation_reply_with_a2at(request) + return NegotiationReply.send( + A2atMessages.from_generated(generated, [Part(text="补充后的业务内容")]) + ) -# 工作流发送门面 -engine_client = WorkflowEngineClient(transport) -# 一次性预置门面(工作流开始前) -sender = ExtensionSender(transport) -auth_result = await sender.send_authorization("agent_a", "授权诊断操作", "诊断 SPN 跨市故障") -notif_result = await sender.send_notification("agent_a", "订阅恢复结果通知", "诊断 SPN 跨市故障") +a2at = A2ATClient(env_path=a2at_env_path) +transport = A2ATransport(agent_cards=agent_cards) +client = WorkflowEngineClient(transport) +result = await WorkflowExecutor(workflow, BusinessCallbacks(), client).run() +await transport.close() ``` -两个门面共享同一个 transport,不重复 wire 代码。 - -**前置操作的回调**:Authorization-T 和 Notification-T 是工作流开始前的一次性操作,通过 `ExtensionSender` 发送。发送结果直接通过返回的 `SendMessageResult` 获取,无需额外的回调接口。 +`on_task` 不接收客户端对象,也不自行发送消息。这样业务代码只负责最终内容,引擎统一保证 A2A 信封、上下文 ID、远端任务 ID、协议头和后续协商仍属于同一次任务交互。 -## A2A-T 扩展 +## 路由规则 -| 扩展 | 归属 | 说明 | -|---|---|---| -| Task-T | 工作流链路 | 发送时由 SDK 生成结构化任务提示并注入 `metadata["...Task-T/v1"]` | -| Negotiation-T | 工作流链路 | 接收时从 `metadata["...NEGOTIATION-T"]` 提取协商上下文,驱动自动循环 | -| Authorization-T | 一次性预置 | 工作流开始前通过 `ExtensionSender` 发送,`instruction` → `parts[].text`,`natural_language_input` → SDK 生成结构化策略 → `metadata["...Authorization-T/v1"]` | -| Notification-T | 一次性预置 | 工作流开始前通过 `ExtensionSender` 发送,`instruction` → `parts[].text`,`natural_language_input` → SDK 生成结构化订阅 → `metadata["...Notification-T/v1"]` | +- 空条件边:无需调用 `on_route`,默认放行。 +- 非空条件边:每条边独立调用一次 `on_route(RouteRequest)`。 +- 一个节点可激活 0 到 N 条后继边;全部拒绝表示该分支正常结束。 +- 同一节点的全部条件判断完成后才调度后继节点;任一判断异常时不调度任何后继节点。 -`ExtensionRegistry` 自动注册 Task-T 与 Negotiation-T(工作流内处理器);Authorization-T / Notification-T 是预置操作,不自动注册,其 handler 类保留供手动注册处理 Agent 内联推送的数据。 +## 上游结果 -## 智能体认证配置 +引擎不再把前置结果拼成 `Runtime Context` Markdown。回调通过 `request.workflow_input` 获取: -当 AgentCard 声明 `securitySchemes` 与 `securityRequirements` 时,SDK 自动通过登录接口获取 token 并将认证头附加到出站请求。创建 JSON 文件: - -```json -{ - "agent_a": { - "bearerAuth": { - "login_url": "https://127.0.0.1:8080/auth/login", - "method": "POST", - "content_type": "application/json", - "request_fields": { "username": "user", "password": "pass" }, - "token_field": "access_token", - "token_ttl": 3600, - "auth_header": "Authorization", - "auth_header_prefix": "Bearer " - } - } -} -``` +- `runtime_intent`:本次执行的运行时意图; +- `upstream_results`:按步骤分组的上游任务结果; +- 每个任务结果包含有序 `outputs`、来源 Agent/skill、逻辑任务 ID、状态和安全错误信息。 -| 字段 | 必填 | 默认 | 说明 | -|---|---|---|---| -| login_url | 是 | - | 获取 token 的 URL | -| method | 否 | POST | HTTP 方法 | -| content_type | 否 | application/json | 请求内容类型 | -| request_fields | 否 | - | 请求体字段(覆盖 username/password) | -| token_field | 否 | accessSession | 响应中提取 token 的路径(点分隔) | -| token_ttl | 否 | 3600 | token 缓存时长(秒) | -| auth_header | 否 | Authorization | 自定义认证头名 | -| auth_header_prefix | 否 | 空 | token 前缀(如 Bearer) | -| accept_header | 否 | - | 自定义 Accept 头 | +工作流的 `context_from` 控制聚合范围:省略时使用直接前驱,`["*"]` 使用全部祖先,空列表表示不传上游结果,指定名称时仅传对应祖先步骤。 -智能体名称须与 AgentCard 的 `name` 一致;认证方案名须与 `securitySchemes` 键一致。也可直接传 dict:`credentials_config=dict`。参见 `examples/agent_credentials.example.json`。 +## 独立协议操作 -**密码加密:** +Authorization-T 和 Notification-T 不属于工作流因果链,应使用独立 `A2ATransport` 和 `ExtensionSender`: -`request_fields` 中的密码字段支持 `enc:` 前缀加密格式 `enc::`,算法为 AES-256-GCM。SDK 运行时从 `A2AT_CRED_KEY` 环境变量读取密钥自动解密。 +```python +sender = ExtensionSender(independent_transport) +authorization = await sender.send_authorization(agent_name, authorization_content) + +subscription = sender.open_notification(agent_name, notification_content, on_notification) +ack = await subscription.acknowledgement +# 收到业务结果后由集成方显式关闭: +subscription.close() +await subscription.completion +``` -```bash -# 1. 生成 32 字节密钥 (仅首次) -python -c "import secrets; print(secrets.token_hex(32))" +授权或订阅失败不会自动阻断后续工作流。`send_authorization` 会等待授权任务到达终态,集成方通过 `is_success` 判断结果;订阅确认通过 `is_failure` 排除失败、拒绝或取消。订阅确认与长连接结束是两个不同 Future;`heartbeat` 和 `is_healthy()` 可用于本地存活性判断。 -# 2. 设置密钥环境变量 -export A2AT_CRED_KEY=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2 +## 远端任务管理 -# 3. 加密密码 -python -c "from workflow_engine.client.credential_crypto import encrypt; print(encrypt('Admin@123'))" -# 输出: enc:xxxxxxxxxxxx:yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +```python +await client.get_task(agent_name, task_id) +page = await client.list_tasks(agent_name, list_tasks_request) +await client.cancel_task(agent_name, task_id) +await client.subscribe_to_task(agent_name, task_id, on_event) ``` -将输出的 `enc:...` 值填入 credentials JSON 的密码字段即可。 +这些是标准 A2A 任务接口;取消任务不等同于 Negotiation-T Abort。 -### 自定义 AuthProvider +同一个 `WorkflowEngineClient` 同一时刻只能绑定一个工作流执行;并发执行应创建独立 client。协商在本地停止、超时、取消或协议校验失败时,引擎会对已知远端任务做有界的尽力取消,取消失败只记录日志,不覆盖原始错误。 -对于非标准认证(企业 SSO、外部身份提供商、AgentCard 无 `securitySchemes` 但仍需认证),实现 `AuthProvider` ABC: +## 认证与 TLS -```python -from workflow_engine import AuthProvider - -class SsoAuthProvider(AuthProvider): - def apply_auth(self, agent_name: str, agent_card, headers: dict) -> None: - token = sso_client.get_access_token(agent_name) - headers["Authorization"] = f"Bearer {token}" +`A2ATransport` 支持 AgentCard 声明的凭据配置、自定义 `AuthProvider`、自定义 CA、mTLS 客户端证书、CRL、协议偏好和发送超时。默认验证服务端证书;指定的证书文件缺失或无效时启动失败,不会静默降级为不校验。 -transport = A2ATransport( - agent_cards=agent_cards, - auth_provider=SsoAuthProvider(), -) -``` +仅受控开发环境可显式配置 `ssl_verify=False`。调用方传入的 `httpx.AsyncClient` 仍由调用方关闭;执行引擎只关闭自己创建的客户端资源。 -两种方式可组合使用:`AuthProvider` 先执行,credentials 认证后执行,各自向请求头注入认证信息。 +## 文档与验证 -## 文件结构 +- [设计说明](DESIGN.md) +- [集成开发指南](DEVELOPER_GUIDE.md) +- [英文 README](README_en.md) +```bash +python -m pip install -e ".[dev]" +python -m pytest -q +python -m build ``` -workflow-exec-engine/ -├── README.md # 本文档 -├── README_en.md # English -├── DESIGN.md # 设计文档 -├── DEVELOPER_GUIDE.md # 开发者指南 -├── pyproject.toml -├── examples/ -│ ├── quickstart.py -│ └── execute_psop_demo.py -└── workflow_engine/ - ├── __init__.py # 公共 API 导出 - ├── runner.py # execute_psop 高层运行器 - ├── core/ # 核心执行 - │ ├── models.py # 数据模型 - │ ├── context_builder.py - │ └── executor.py # WorkflowExecutor DAG 遍历 - ├── client/ # 通信层 - │ ├── a2a_transport.py # A2ATransport 共享通信层 - │ ├── engine_client.py # WorkflowEngineClient 工作流门面 - │ ├── extension_sender.py # ExtensionSender 一次性门面 - │ ├── extension_handlers.py - │ ├── extensions.py # A2ATExtension 枚举 - │ ├── auth_manager.py - │ ├── credential_service.py - │ ├── ssl_context.py - │ └── sse_normalization.py - ├── control/ # 决策接口 - │ └── control_points.py # ControlPoint + EventType - └── registry/ # 注册中心集成(可选) - └── registry_client.py -``` - -## 许可证 - -Apache License 2.0 \ No newline at end of file diff --git a/README_en.md b/README_en.md index facc9df..e2bb0f2 100644 --- a/README_en.md +++ b/README_en.md @@ -1,279 +1,119 @@ -# workflow-engine +# A2A-T Workflow Engine SDK for Python -Standalone workflow execution SDK for A2A-T multi-agent orchestration. The host agent executes orchestration-center workflows (PSOP) while retaining full control over A2A communication, A2A-T extensions, and routing decisions. The SDK is self-contained and does not depend on the orchestration center. +An embedded workflow execution SDK for host agents. The engine owns workflow DAG execution, A2A envelopes, task/context correlation, remote-task waiting, the Negotiation-T interaction loop, and lifecycle management. The host owns business-input interpretation, A2A-T content generation and semantic validation, routing, and negotiation decisions. -> For the full design see [DESIGN.md](DESIGN.md). This document targets integrators: quick start and interface reference. +The Python and Java engines follow the same business contract. This package requires Python 3.12+, `a2a-sdk>=1.1.2,<2`, and `a2a-t-sdk>=1.1.0,<2`. -## Principle +## Installation -| The SDK owns (protocol mechanics) | The host owns (business decisions) | -|---|---| -| A2A message send, streaming, SSE normalization | Whether and when to send a task | -| Agent auth (Bearer, custom headers, from AgentCard) | 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 tracking | Event handling strategy | - -## Architecture - -A shared transport with two single-responsibility facades: - -``` -A2ATransport (shared wire: httpx + auth + agent-card map + SSE consumer) - |-- WorkflowEngineClient (workflow facade: Task-T generation, Negotiation-T - | auto-loop, event callback, ControlPoint) - `-- ExtensionSender (one-shot facade: Authorization-T / Notification-T) +```bash +python -m pip install workflow-exec-engine ``` -The decision layer interface: - -- **ControlPoint** -- flow decisions (`on_task` / `on_self_task` / `on_route` / `on_negotiation`) - -Authorization-T and Notification-T are pre-positioning operations sent via `ExtensionSender` before the workflow starts, not in-workflow callbacks. - -```mermaid -flowchart TB - subgraph User["User (Host Agent)"] - AC["AgentCards
(registry or custom)"] - CP["ControlPoint
flow decisions"] - end - subgraph SDK["SDK (self-contained)"] - TR["A2ATransport
shared wire"] - WEC["WorkflowEngineClient
workflow send"] - ES["ExtensionSender
one-shot pre-position"] - WE["WorkflowExecutor
DAG traversal"] - end - subgraph Agents["Remote Agents"] - A1["Agent A"] - A2["Agent B"] - end - AC --> TR - TR --> WEC - TR --> ES - WEC -->|send_message| A1 - WEC -->|send_message| A2 - ES -->|pre-position send| A1 - WE -->|on_task/on_route| CP - WEC -->|on_negotiation| CP -``` +The engine uses the current A2A-T core metadata types for protocol coordination. It does not call an LLM or generate or validate Task-T, Negotiation-T, Authorization-T, or Notification-T business content; the host still invokes `a2a-t-sdk` for those content operations. -## Quick Start +## Minimal integration ```python -import asyncio +from a2a.types import Part +from a2a_t.client import A2ATClient +from a2a_t.core.standard_templates import PRIVATE_LINE_COMPLAINT_URI from workflow_engine import ( - execute_psop, ControlPoint, RouteDecision, - TaskResponse, RegistryClient, load_psop, + A2ATransport, A2atMessages, ControlPoint, ExtensionSender, + MessageContent, NegotiationReply, RouteDecision, TaskResult, + WorkflowEngineClient, WorkflowExecutor, ) -class MyControlPoint(ControlPoint): - async def on_task(self, request, engine_client): - # SDK assembles the full message (context + task + lang hint) - result = await engine_client.send_message( - request.agent_name, request.message +class BusinessCallbacks(ControlPoint): + async def on_task(self, request): + generated = a2at.generate_task_prompt_from_text( + request.input.text, + PRIVATE_LINE_COMPLAINT_URI, + ) + return A2atMessages.from_generated( + generated, + [Part(text=request.instruction)], ) - return TaskResponse(success=bool(result.text), output=result.text) - - async def on_route(self, step_name, results, conditions): - # conditions: List[JumpCondition], each with .step and .condition - return RouteDecision(next_step=conditions[0].step) - - -async def main(): - # 1. Get AgentCards (registry or custom source) - registry = RegistryClient(url="https://127.0.0.1:5000", ssl_verify=False) - agent_cards = await registry.fetch_agent_cards() - - # 2. Load a PSOP workflow - workflow = await load_psop( - base_url="https://127.0.0.1:5001", - psop_id="your-psop-id", - ssl_verify=False, - ) - - # 3. Execute: execute_psop builds A2ATransport + WorkflowEngineClient internally - async for event in execute_psop( - psop=workflow, - agent_cards=agent_cards, - control_point=MyControlPoint(), - a2at_env_path=".env", - credentials_config="agent_credentials.json", - runtime_intent="Diagnose SPN cross-city fault", - ssl_verify=False, - ): - print(f"[{event['type']}] {event['data']}") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -## Layered Entry Points - -| Layer | Entry Point | Handles | You Provide | -|---|---|---|---| -| 2 (high) | `execute_psop()` | Event stream, lifecycle, cancellation, onFinish | ControlPoint + AgentCards + config | -| 1 (mid) | `WorkflowExecutor` | DAG traversal, context assembly, dispatch | ControlPoint + WorkflowEngineClient + Workflow | -| 0 (low) | `A2ATransport` + two facades | A2A send, auth, extensions, SSE | AgentCards + config | - -Most integrations use Layer 2. Use Layer 1 for manual control. Use `ExtensionSender` directly for one-shot pre-positioning only. - -## Interfaces to Implement - -### ControlPoint (flow decisions) - -| Method | Required | Called when | Decides | -|---|---|---|---| -| `on_task(request, engine_client)` | yes | a step needs to send a task | whether/how to send | -| `on_self_task(request)` | no (default echoes) | a SELF_LOOP step | local result | -| `on_route(step_name, results, conditions)` | yes | a step has conditional branches | which branch | -| `on_negotiation(agent_name, text, result)` | no (default generic) | agent returns INPUT_REQUIRED | clarification text | -## A2ATransport + Facades (Layer 0) + async def on_self_task(self, request): + return TaskResult.succeeded(aggregate(request.workflow_input.upstream_results)) -```python -from workflow_engine import A2ATransport, WorkflowEngineClient, ExtensionSender + async def on_route(self, request): + return RouteDecision.allow() if matches(request.condition) else RouteDecision.deny() -transport = A2ATransport( - agent_cards=agent_cards, - a2at_env_path=".env", - credentials_config="agent_credentials.json", - ssl_verify=False, -) + async def on_negotiation(self, request): + generated = generate_terminal_negotiation_reply_with_a2at(request) + return NegotiationReply.send( + A2atMessages.from_generated(generated, [Part(text="supplemented content")]) + ) -# Workflow send facade -engine_client = WorkflowEngineClient(transport) -# One-shot pre-positioning facade (before workflow starts) -sender = ExtensionSender(transport) -auth_result = await sender.send_authorization("agent_a", "authorize diagnosis", "Diagnose SPN cross-city fault") -notif_result = await sender.send_notification("agent_a", "subscribe to recovery result", "Diagnose SPN cross-city fault") +a2at = A2ATClient(env_path=a2at_env_path) +transport = A2ATransport(agent_cards=agent_cards) +client = WorkflowEngineClient(transport) +result = await WorkflowExecutor(workflow, BusinessCallbacks(), client).run() +await transport.close() ``` -Both facades share one transport; no wire code is duplicated. - -**Pre-positioning callbacks**: Authorization-T and Notification-T are one-shot operations sent via `ExtensionSender` before the workflow starts. The send result is returned directly as a `SendMessageResult` -- no separate callback interface is needed. +`on_task` does not receive a transport client and does not send the message. The host prepares final content; the engine keeps the A2A envelope, context ID, remote task ID, headers, waiting, and subsequent negotiation in one interaction. -## A2A-T Extensions +## Routing contract -| Extension | Lifecycle | Description | -|---|---|---| -| Task-T | in-workflow | SDK generates a structured task prompt on send and injects into `metadata["...Task-T/v1"]` | -| Negotiation-T | in-workflow | Extracts negotiation context from `metadata["...NEGOTIATION-T"]` on receive, drives the auto-loop | -| Authorization-T | one-shot pre-position | Sent via `ExtensionSender` before the workflow. `instruction` → `parts[].text`, `natural_language_input` → SDK generates structured policy → `metadata["...Authorization-T/v1"]` | -| Notification-T | one-shot pre-position | Sent via `ExtensionSender` before the workflow. `instruction` → `parts[].text`, `natural_language_input` → SDK generates structured subscription → `metadata["...Notification-T/v1"]` | +- A blank condition is unconditional, bypasses `on_route`, and is allowed. +- Every nonblank edge gets one independent `on_route(RouteRequest)` call. +- A node may activate zero through N successors; all denied means a normal branch end. +- All decisions for the source node finish before successors are scheduled. If one decision fails, no successor of that source is scheduled. -`ExtensionRegistry` auto-registers Task-T and Negotiation-T (in-workflow handlers); Authorization-T / Notification-T are pre-positioning operations, not auto-registered. Their handler classes are retained for callers that need inline handling of agent-pushed data. +## Upstream results -## Agent Authentication +The engine no longer renders predecessor output into a `Runtime Context` Markdown string. Callbacks receive `request.workflow_input` with the runtime intent and ordered, step-scoped upstream task results. Each task result carries outputs, source agent/skill, logical task ID, status, and safe failure information. -When an AgentCard declares `securitySchemes` and `securityRequirements`, the SDK logs in to obtain a token and attaches the auth header to outbound requests. Create a JSON file: - -```json -{ - "agent_a": { - "bearerAuth": { - "login_url": "https://127.0.0.1:8080/auth/login", - "method": "POST", - "content_type": "application/json", - "request_fields": { "username": "user", "password": "pass" }, - "token_field": "access_token", - "token_ttl": 3600, - "auth_header": "Authorization", - "auth_header_prefix": "Bearer " - } - } -} -``` +`context_from` selects the history window: omission selects direct predecessors, `["*"]` selects every ancestor, an empty list selects none, and named entries select only those ancestor steps. -| Field | Required | Default | Description | -|---|---|---|---| -| login_url | yes | - | URL to obtain the token | -| method | no | POST | HTTP method | -| content_type | no | application/json | request content type | -| request_fields | no | - | request body fields (overrides username/password) | -| token_field | no | accessSession | response path to extract token (dot-separated) | -| token_ttl | no | 3600 | token cache TTL (seconds) | -| auth_header | no | Authorization | custom auth header name | -| auth_header_prefix | no | empty | token prefix (e.g. Bearer) | -| accept_header | no | - | custom Accept header | +## Independent protocol operations -Agent names must match the AgentCard `name`; scheme names must match `securitySchemes` keys. A dict may be passed directly: `credentials_config=dict`. See `examples/agent_credentials.example.json`. +Authorization-T and Notification-T are independent of workflow causality. Give them a separate `A2ATransport` and `ExtensionSender`: -**Password Encryption:** +```python +sender = ExtensionSender(independent_transport) +authorization = await sender.send_authorization(agent_name, authorization_content) -Password fields in `request_fields` support the `enc:` prefix format `enc::` using AES-256-GCM. The SDK reads the key from the `A2AT_CRED_KEY` environment variable at runtime to auto-decrypt. +subscription = sender.open_notification(agent_name, notification_content, on_notification) +ack = await subscription.acknowledgement +subscription.close() +await subscription.completion +``` -```bash -# 1. Generate a 32-byte key (one-time) -python -c "import secrets; print(secrets.token_hex(32))" +Authorization or subscription failure does not automatically block workflow execution. `send_authorization` waits for a final task state and the caller checks `is_success`; a subscription acknowledgement must be rejected when `is_failure` is true. A subscription acknowledgement and stream completion are separate futures. Use `heartbeat` and `is_healthy()` for local liveness checks. -# 2. Set the key as an environment variable -export A2AT_CRED_KEY=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2 +## Remote task management -# 3. Encrypt the password -python -c "from workflow_engine.client.credential_crypto import encrypt; print(encrypt('Admin@123'))" -# Output: enc:xxxxxxxxxxxx:yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +```python +await client.get_task(agent_name, task_id) +page = await client.list_tasks(agent_name, list_tasks_request) +await client.cancel_task(agent_name, task_id) +await client.subscribe_to_task(agent_name, task_id, on_event) ``` -Paste the `enc:...` output into the password field of your credentials JSON. +These are standard A2A task operations. Task cancellation is not a Negotiation-T Abort. -### Custom AuthProvider +A `WorkflowEngineClient` can be bound to only one active workflow execution at a time; concurrent executions require separate clients. When negotiation ends locally because of stop, timeout, cancellation, or protocol validation failure, the engine makes a bounded best-effort cancellation of a known remote task without replacing the original error. -For non-standard auth (corporate SSO, external identity providers, agents with no `securitySchemes` that still require auth), implement the `AuthProvider` ABC: +## Authentication and TLS -```python -from workflow_engine import AuthProvider - -class SsoAuthProvider(AuthProvider): - def apply_auth(self, agent_name: str, agent_card, headers: dict) -> None: - token = sso_client.get_access_token(agent_name) - headers["Authorization"] = f"Bearer {token}" +`A2ATransport` supports AgentCard-driven credential configuration, a host `AuthProvider`, custom CA trust, mTLS client identity, CRL checks, protocol preference, and send timeout. Server certificates are verified by default. Missing or invalid configured certificate files fail closed instead of silently disabling verification. -transport = A2ATransport( - agent_cards=agent_cards, - auth_provider=SsoAuthProvider(), -) -``` +Only controlled development environments should use `ssl_verify=False`. A caller-supplied `httpx.AsyncClient` remains caller-owned; the engine closes only resources it creates. -Both approaches can be combined: `AuthProvider` runs first, credentials-based auth runs second, each injecting headers independently. +## Documentation and verification -## File Structure +- [Design](DESIGN.md) +- [Developer guide](DEVELOPER_GUIDE.md) +- [Chinese README](README.md) +```bash +python -m pip install -e ".[dev]" +python -m pytest -q +python -m build ``` -workflow-exec-engine/ -|-- README.md # this document -|-- README_en.md # English -|-- DESIGN.md # design document -|-- DEVELOPER_GUIDE.md # developer guide -|-- pyproject.toml -|-- examples/ -| |-- quickstart.py -| `-- execute_psop_demo.py -`-- workflow_engine/ - |-- __init__.py # public API exports - |-- runner.py # execute_psop high-level runner - |-- core/ # core execution - | |-- models.py # data models - | |-- context_builder.py - | `-- executor.py # WorkflowExecutor DAG traversal - |-- client/ # communication layer - | |-- a2a_transport.py # A2ATransport shared wire - | |-- engine_client.py # WorkflowEngineClient workflow facade - | |-- extension_sender.py # ExtensionSender one-shot facade - | |-- extension_handlers.py - | |-- extensions.py # A2ATExtension enum - | |-- auth_manager.py - | |-- credential_service.py - | |-- ssl_context.py - | `-- sse_normalization.py - |-- control/ # decision interfaces - | `-- control_points.py # ControlPoint + EventType - `-- registry/ # registry integration (optional) - `-- registry_client.py -``` - -## License - -Apache License 2.0 \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ea2833a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Supported Versions + +The latest published release and the current `main` branch receive security +fixes. Pre-release APIs may change when required to close a vulnerability. + +## Reporting a Vulnerability + +Do not open a public issue. Email **security@openan.com** with a description, +reproduction steps, affected versions, and potential impact. Do not include +real production credentials or customer data. + +We aim to acknowledge reports within 48 hours and provide a fix or mitigation +within 90 days. Coordinated disclosure timing will be agreed with the reporter. + +## Security Defaults + +- TLS certificate verification is enabled by default. +- Encrypted credentials fail closed when the key is absent or invalid. +- Full protocol payload logging is not intended for normal production use. +- `.env`, credentials, private keys, and generated distributions are excluded + from source control. diff --git a/docs/parity-analysis.md b/docs/parity-analysis.md deleted file mode 100644 index 4e7fc14..0000000 --- a/docs/parity-analysis.md +++ /dev/null @@ -1,354 +0,0 @@ -# Python vs Java A2A-T SDK Parity Analysis - -## 1. Overview - -### Repositories - -| Role | Python | Java | -|------|--------|------| -| Execution Engine SDK | `workflow-exec-engine` (`workflow_engine` v0.0.3) | `workflow-exec-engine-java` (`workflow-engine` module) | -| A2A-T Foundation SDK | bundled in `workflow_engine` (a2a_t pip package) | `a2a-t-sdk-java` (a2a-t-core, a2a-t-client, a2a-t-server, a2a-t-llm, a2a-t-negotiation, a2a-t-prompt, a2a-t-resources) | -| Demo Agents | `orchestration-center/samples/agents/` | `workflow-exec-engine-java/samples/src/.../agents/` | - -### Parity Principle - -Both SDKs expose the same business-facing interfaces with the same parameter names, parameter meanings, parameter formats, and event data structures. Language differences (CompletableFuture vs async/await, Lombok vs dataclass) are expected; protocol-level behavior must be identical. - -### Verdict: ALIGNED with 6 gaps identified below - ---- - -## 2. Control Layer - -### 2.1 EventType Constants (22/22 match) - -All 22 event type string constants are identical between Python and Java: START, COMPLETE, CLOSE, STEP_START, STEP_COMPLETE, TASK_REQUEST, TASK_RESPONSE, TASK_STATUS_CHANGED, ROUTE_DECISION, WORKFLOW_COMPLETE, AGENT_REQUEST, AGENT_RESPONSE, AGENT_STATUS_UPDATE, AGENT_ARTIFACT_UPDATE, AGENT_MESSAGE_EVENT, NEGOTIATION_REQUEST, NEGOTIATION_RESOLVED, NEGOTIATION_FAILED, AUTHORIZATION_REQUEST, AUTHORIZATION_RESOLVED, NOTIFICATION, ERROR. - -### 2.2 EventCallback - -| Aspect | Python | Java | Match | -|--------|--------|------|-------| -| Method | `on_event(self, event_type: str, data: Dict)` | `onEvent(String eventType, Map data)` | YES | -| Default behavior | no-op | no-op | YES | - -### 2.3 ControlPoint Interface - -| Method | Python | Java | Match | -|--------|--------|------|-------| -| on_task (required) | `async on_task(request, engine_client) -> TaskResponse` | `CompletableFuture onTask(TaskRequest, WorkflowEngineClient)` | YES | -| on_self_task (default) | `async on_self_task(request) -> TaskResponse` | `default CompletableFuture onSelfTask(TaskRequest)` | YES | -| on_route (required) | `async on_route(step_name, results, conditions) -> RouteDecision` | `CompletableFuture onRoute(String, Map, List)` | YES | -| on_negotiation (default) | `async on_negotiation(agent_name, neg_text, receive_result) -> str` | `default CompletableFuture onNegotiation(String, String, Map)` | YES | - -Key: on_self_task does NOT receive engine_client in either SDK. Self-loop tasks must not send A2A-T messages. - -### 2.4 DefaultControlPoint + NegotiationStrategy - -All 4 method behaviors match: on_task (send + success=text non-empty), on_self_task (echo message), on_route (first non-terminal branch), on_negotiation (delegate to strategy or generic). Constructor accepts NegotiationStrategy in both. - ---- - -## 3. Core Models (all fields match) - -### 3.1 Data Models - -| Model | Fields | Match | -|-------|--------|-------| -| Task | agent, skill="", description="", status=PENDING | YES | -| TaskRequest | agent_name, skill, message, context, step_name, subtask_index=0, description="" | YES | -| TaskResponse | success, output="", error=None, metadata=None | YES | -| SendMessageResult | text="", task=None, metadata={}, task_state="" | YES | -| RouteDecision | next_step, reason="" | YES | -| JumpCondition | step, condition="" | YES | -| ExecutionResult | success, history=[], step_outputs={}, error=None | YES | -| WorkflowSearchResult | workflow_id, workflow_type, name, description, tags=[], created_at, score=1.0, user_intent, related_preflow, tasks_summary | YES | - -### 3.2 Enums - -| Enum | Values | Match | -|------|--------|-------| -| StepType | ALL_SUCCESS="AllSuccess", ANY_SUCCESS="AnySuccess", SELF_LOOP="SelfLoop" | YES | -| TaskStatus | PENDING="pending", RUNNING="running", SUCCESS="success", FAILED="failed" | YES | - -### 3.3 Workflow / WorkflowStep - -All fields match: id, name, description, steps, name, subtasks, next, layer, context_from, step_type. from_dict/fromMap parsing: step_type/type fallback, context_from str->list coercion all match. - ---- - -## 4. Client Layer - -### 4.1 WorkflowEngineClient Interface - -| Method | Python | Java | Match | -|--------|--------|------|-------| -| send_message | `(agent_name, message, context_id=None, metadata=None, skip_extensions=False)` | `sendMessage(agentName, message, contextId, metadata)` | GAP 2 | -| set_control_point | `set_control_point(cp)` | `setControlPoint(ControlPoint)` | YES | -| set_event_callback | `set_event_callback(cb)` | `setEventCallback(EventCallback)` | YES | -| close | `async close()` | `void close()` | YES | - -**GAP 2 (non-critical):** Python has `skip_extensions` param. Not called by executor (executor calls on_self_task directly for SELF_LOOP). No protocol impact. - -### 4.2 DefaultWorkflowEngineClient / WorkflowEngineClient (Python) - -| Behavior | Match | -|----------|-------| -| Constructor: transport + custom_handlers + event_callback + max_negotiation_rounds | YES | -| ExtensionRegistry: pre-registers Task-T + Negotiation-T | YES | -| send_message flow: before_send -> AGENT_REQUEST -> transport.send -> after_receive -> auto_negotiate | YES | -| AGENT_REQUEST data: {agent, request, metadata} | YES | -| AGENT_RESPONSE data: {agent, response, metadata} | YES | -| Auto-negotiation: INPUT_REQUIRED -> on_negotiation -> follow-up -> recurse | YES | -| Follow-up format: [NEGOTIATION_RESOLUTION]\n...clarification...\n---\nOriginal Task:\n...message...\n\nPlease re-execute... | YES | -| build_negotiation_follow_up_meta: SDK continue_negotiation with AGREED | YES | -| Fallback: {NEGOTIATION_T.uri: "## Data Return Confirmation\n" + clarification} | YES | - -**GAP 3 (resolved):** Python AGENT_RESPONSE includes `metadata`: `{"agent", "response", "metadata"}`. Java now also includes metadata in AGENT_RESPONSE event. - -### 4.3 ExtensionHandler + TaskTHandler + NegotiationTHandler - -All behaviors match exactly: -- Skip if no a2at_client -- Skip if [NEGOTIATION_RESOLUTION] in message (Task-T) -- Find URI from card capabilities.extensions -- Skip if metadata already preset (Task-T) -- Call generate_task_prompt / receive_negotiation -- On success/failure: same metadata injection + same logging -- Negotiation: extract context from DATA-NEGOTIATION-T key, fallback to metadata -- Exception handling: "Unsupported negotiation type" -> debug level - -### 4.4 ExtensionInterceptor (A2A-Extensions Header) - -Both SDKs filter: only extensions present in message metadata are included in the A2A-Extensions header. This resolves the previous complaint about all extensions being stuffed in. - -### 4.5 A2ATExtension Enum (4/4 URIs match) - -TASK_T, NEGOTIATION_T, AUTHORIZATION_T, NOTIFICATION_T - all URIs identical. - -### 4.6 ExtensionSender - -| Method | Match | -|--------|-------| -| send_extension_message | YES | -| send_authorization | YES | -| send_notification | YES | -| generate_extension_prompt dispatch | YES | - -**GAP 4 (not an SDK gap):** Java's `sendNotification` has a `Consumer` overload for long-lived SSE event callbacks. Python's `send_notification` returns `SendMessageResult` via `consume_stream`. Long-lived SSE subscription handling is the responsibility of the workbench agent's business code, not the SDK. Both SDKs provide the `send_notification` interface; how the agent processes subsequent SSE events is a demo-agent concern, not an SDK contract difference. - -### 4.7 A2ATransport - -All core behaviors match: constructor, card map, auth manager, A2ATClient init, context ID, create_a2a_client, build_send_request, consume_stream/send, forward intermediate events, merge task+artifact metadata, SSL context, close. - -### 4.8 ProtocolLogger - -| Aspect | Match | -|--------|-------| -| Format: `>>> [agent] REQUEST to endpoint\n=== Headers ===\n...\n=== Body ===\n...` | YES | -| Format: `<<< [agent] RESPONSE [type]\n...` | YES | -| Body serialization: JSON | YES | - -**GAP 5 (non-critical):** Python builds header view from metadata URIs (only A2A-Extensions). Java logs actual HTTP headers (Authorization + A2A-Extensions). - ---- - -## 5. Executor Layer - -### 5.1 WorkflowExecutor (all behaviors match) - -- Constructor: workflow, control_point, engine_client, event_callback, runtime_intent, lang -- DAG traversal with parallel step dispatch (asyncio.gather / CompletableFuture.allOf) -- _collect_ready: predecessors satisfied check -- _execute_step: STEP_START -> execute_subtasks -> STEP_COMPLETE/ERROR -- _execute_subtasks: build context -> build task message -> dispatch -- dispatchTask: SELF_LOOP -> on_self_task, else -> on_task -- ANY_SUCCESS: first success cancels rest -- ALL_SUCCESS: all must succeed -- _determine_next_steps: unconditional -> fan out, conditional -> on_route -- Route context: context_from results + current step results -- Terminal markers: "end", "retry", "endNode" -- Defer count: skip after len(steps) retries -- WORKFLOW_COMPLETE after run -- execution_history: [{step, task, agent, status, output}] - -### 5.2 ContextBuilder (all behaviors match) - -- get_step_predecessors, get_all_predecessors -- build_context: layer 0 -> runtime intent, context_from="*" -> all predecessors, etc. -- build_task_message: context + "\n\n## Current Task\n" + task + lang_hint -- lang="zh"/"en" hints match -- find_step_index - -Minor: Python logs context content (2000 chars), Java only logs char count. - ---- - -## 6. Runner Layer - -### execute_psop / ExecutePsop (all behaviors match) - -- Async generator / CompletableFuture + EventCallback -- Lifecycle: START -> events -> COMPLETE/ERROR -> CLOSE -- on_finish hook: (ExecutionResult, collected_events) -- on_event transformer: may return event/list/None -- Event serialization: _serialize / serialize -- Auto-create engine_client if none -- _EventEmitter / CollectingCallback: queue-based collection, timestamps, {type, data, timestamp} format - ---- - -## 7. Registry Layer - -### RegistryClient + load_psop + search_psop (all match) - -- fetch_agent_cards: GET /rest/v1/registry-center/agent-cards -- fetch_agent_card(name, org): GET with query params -- register_agent_card: POST with {agentCards: [card]} -- load_psop: GET /api/v1/orchestrate/psop/{psop_id} -- search_psop: POST /api/v1/orchestrate/search with {intent, top_n} -- SSL verify configurable -- Card normalization on both sides - ---- - -## 8. Demo Agents Comparison - -### 8.1 Agent Roster (3/3 match) - -| Agent | Python | Java | -|-------|--------|------| -| SPN Domain Agent City1 | spn_domain_agent.py | SpnDomainAgentCity1Executor.java | -| SPN Domain Agent City2 | spn_domain_agent_city2.py | SpnDomainAgentCity2Executor.java | -| Transport Workbench Agent | workbench_platform_agent.py | TransportWorkbenchAgentExecutor.java | - -### 8.2 NegotiationBaseAgentExecutor (Base Class) - -| Behavior | Match | -|----------|-------| -| Init: LLM + A2ATServer/A2ATClient + prompt_template | YES | -| execute: extract text -> check follow-up -> handle | YES | -| Follow-up: [NEGOTIATION_RESOLUTION] marker | YES | -| New task: start negotiation -> execute | YES | -| Negotiation start: FULFILLMENT type | YES | -| Fallback: in-process context | YES | -| build_task_response: metadata[TASK_PROMPT_KEY] = response | YES | -| Artifact: Part(text=response) | YES | - -**GAP 6:** Python uses LLM-based `is_uncertain_response` check. Java uses deterministic `needsNegotiation(input)` override. Different negotiation trigger mechanism. - -**GAP 7:** Python reads Task-T prompt from incoming message metadata and uses it as input. Java only uses raw text. Python agents may process different input than Java agents. - -**GAP 8:** Java handles Notification-T subscription (long-lived stream + push recovery results). Python does not. Java handles pre-positioned extensions via `PrePositionedExtensionHandler.detect`. Python does not. - -### 8.3 SPN Domain Agent City1 - -| Behavior | Match | -|----------|-------| -| Fault scenario: Yuedong port DOWN, -28dBm | YES | -| Diagnosis result via LLM | YES | -| buildResponseMetadata: only TASK_PROMPT_KEY | YES | - -**GAP 9 (critical):** Java has `selfTriggerRecovery`: checks Authorization-T whitelist, executes recovery, pushes result via Notification-T. Python has no recovery/authorization/notification logic. - -### 8.4 SPN Domain Agent City2 - -All behaviors match. Both report healthy state, no recovery needed. - -### 8.5 Transport Workbench Agent / WorkbenchOrchestrator - -**GAP 10 (critical):** Java's `WorkbenchOrchestrator` calls `ExtensionPrePositioner.prePosition` to send Authorization-T + Notification-T to each non-workbench agent before the workflow starts. Python orchestration center does not have this pre-positioning step. - -### 8.6 Follow-up Format - -**GAP 11:** Python's `build_negotiation_resolution_task` appends `[NEGOTIATION_CONTEXT]` + JSON context. Java's `buildResolutionMessage` does not. - ---- - -## 9. Gap Summary - -### Critical (protocol-level impact) - -| # | Gap | Fix Direction | -|---|-----|--------------| -| 8 | Python agents lack Notification-T subscription handling | Add to Python NegotiationBaseAgentExecutor | -| 9 | Python SPN agent lacks self-triggered recovery | Add selfTriggerRecovery to Python SPN agents | -| 10 | Orchestration center lacks Extension pre-positioning | Add ExtensionPrePositioner to exec_engine.py | - -### Behavioral (demo agent differences) - -| # | Gap | Fix Direction | -|---|-----|--------------| -| 6 | Uncertainty check differs | Align mechanism | -| 7 | Python reads Task-T from message metadata | Add to Java or remove from Python | -| 11 | Follow-up context marker differs | Align format | - -### Non-critical - -| # | Gap | Action | -|---|-----|--------| -| 2 | skip_extensions param | Acceptable divergence | -| 5 | Python log_request header source | Best-effort | - -> **GAP 4 (removed):** Previously listed as "Python lacks long-lived Notification-T stream". This is not an SDK-level gap. Both SDKs provide `send_notification`; long-lived SSE subscription handling belongs to the workbench agent's business code, not the SDK. - ---- - -## 10. Verification Checklist - -### Interface Parity (All Business-Facing APIs) - -- [x] EventType: 22/22 constants match -- [x] EventCallback: signature + default match -- [x] ControlPoint: 4 methods match -- [x] DefaultControlPoint: 4 behaviors match -- [x] NegotiationStrategy: match -- [x] Task, TaskRequest, TaskResponse, SendMessageResult: all fields match -- [x] RouteDecision, JumpCondition, ExecutionResult, WorkflowSearchResult: match -- [x] StepType, TaskStatus enums: values match -- [x] Workflow, WorkflowStep: match -- [x] WorkflowEngineClient: match (except skip_extensions) -- [x] ExtensionHandler: match -- [x] TaskTHandler: all behaviors match -- [x] NegotiationTHandler: all behaviors match -- [x] ExtensionRegistry: match -- [x] ExtensionInterceptor: filter active extensions -- [x] A2ATExtension: 4 URIs match -- [x] ExtensionSender: match -- [x] A2ATransport: all behaviors match -- [x] ProtocolLogger: format match (except header source) -- [x] WorkflowExecutor: DAG, parallel, routing all match -- [x] ContextBuilder: all behaviors match -- [x] execute_psop / ExecutePsop: match -- [x] RegistryClient: 3 methods match -- [x] load_psop / search_psop: match - -### Demo Agent Parity - -- [x] 3 agents exist on both sides -- [x] City1 fault scenario: match -- [x] City2 normal scenario: match -- [x] buildResponseMetadata: TASK_PROMPT_KEY only on both sides -- [x] Negotiation FULFILLMENT type on both sides -- [ ] Uncertainty check: different mechanism (GAP 6) -- [ ] Task-T metadata reading: Python only (GAP 7) -- [ ] Notification-T subscription: Java only (GAP 8) -- [ ] Self-triggered recovery: Java only (GAP 9) -- [ ] Extension pre-positioning: Java only (GAP 10) -- [ ] Follow-up context marker: Python only (GAP 11) - ---- - -## 11. Conclusion - -**SDK-level interfaces are fully aligned.** All 40+ business-facing interfaces have matching parameter names, parameter meanings, parameter formats, and event data structures. - -**6 gaps remain**, all in the demo-agent layer: - -1. **Demo-agent protocol gaps** (8, 9, 10): Affect whether the full A2A-T extension lifecycle (Notification-T subscription, self-triggered recovery, extension pre-positioning) works in the Python demo agents. - -2. **Demo-agent behavioral gaps** (6, 7, 11): Differences in how demo agents process messages and trigger negotiation. No SDK protocol contract impact. - -> **Resolved:** GAP 3 (Java AGENT_RESPONSE missing metadata) — fixed. -> **Removed:** GAP 4 (Python lacks long-lived Notification-T stream) — not an SDK-level gap; subscription handling belongs to the workbench agent's business code. - -**Recommendation:** Fix GAPs 8, 9, 10 first (demo-agent protocol), then GAPs 6, 7, 11 (demo-agent behavioral alignment). GAPs 2, 5 are non-critical. diff --git a/examples/execute_psop_demo.py b/examples/execute_psop_demo.py index f66dbe3..2538f21 100644 --- a/examples/execute_psop_demo.py +++ b/examples/execute_psop_demo.py @@ -1,26 +1,43 @@ """Quick start using execute_psop (recommended high-level API). -This is the simplest way to integrate the SDK: implement ControlPoint -(only on_task and on_route required), call execute_psop, drain events. +This is the simplest way to integrate the SDK: implement the ControlPoint +callbacks used by the selected workflow, call execute_psop, and drain events. """ import asyncio +from pathlib import Path + +from a2a.types import Part +from a2a_t.client import A2ATClient +from a2a_t.core.standard_templates import PRIVATE_LINE_COMPLAINT_URI + from workflow_engine import ( - execute_psop, ControlPoint, RegistryClient, load_psop, - TaskResponse, RouteDecision, EventType, + A2atMessages, + ControlPoint, + EventType, + RegistryClient, + RouteDecision, + execute_psop, + load_psop, ) class MyControlPoint(ControlPoint): - async def on_task(self, request, engine_client): - result = await engine_client.send_message( - request.agent_name, request.message + def __init__(self, a2at_client): + self._a2at_client = a2at_client + + async def on_task(self, request): + if request.input.text is None: + raise ValueError("This example expects text task input") + generated = await asyncio.to_thread( + self._a2at_client.generate_task_prompt_from_text, + request.input.text, + PRIVATE_LINE_COMPLAINT_URI, ) - return TaskResponse(success=True, output=result.text) + return A2atMessages.from_generated(generated, [Part(text=request.instruction)]) - async def on_route(self, step_name, results, conditions): - # Pick first branch (in production: use your own LLM or business logic) - return RouteDecision(next_step=conditions[0].step) + async def on_route(self, request): + return RouteDecision.allow("business condition matched") async def on_finish(result, events): @@ -32,6 +49,8 @@ async def on_finish(result, events): async def main(): + a2at_client = A2ATClient(env_path=Path("a2at.env")) + # 1. Fetch AgentCards from the registry center registry = RegistryClient(url="https://127.0.0.1:5000") agent_cards = await registry.fetch_agent_cards() @@ -48,10 +67,9 @@ async def main(): async for event in execute_psop( psop=workflow, agent_cards=agent_cards, - control_point=MyControlPoint(), - a2at_env_path=".env", + control_point=MyControlPoint(a2at_client), credentials_config="agent_credentials.json", - runtime_intent="Diagnose SPN cross-city fault", + runtime_intent="Analyze the service issue and aggregate delegated results", ssl_verify=False, on_finish=on_finish, ): @@ -61,7 +79,7 @@ async def main(): elif etype == EventType.TASK_REQUEST: print(f" Agent: {event['data']['agent']}") elif etype == EventType.TASK_RESPONSE: - print(f" Response: {event['data'].get('response', '')[:60]}") + print(f" Outputs: {event['data'].get('outputs', ())}") elif etype == "complete": print("Workflow complete!") elif etype == "error": diff --git a/examples/quickstart.py b/examples/quickstart.py index 6600f31..4262dec 100644 --- a/examples/quickstart.py +++ b/examples/quickstart.py @@ -25,43 +25,47 @@ """ import asyncio +from pathlib import Path + +from a2a.types import Part +from a2a_t.client import A2ATClient +from a2a_t.core.standard_templates import PRIVATE_LINE_COMPLAINT_URI + from workflow_engine import ( - WorkflowExecutor, - ControlPoint, - WorkflowEngineClient, + A2atMessages, A2ATransport, + ControlPoint, RegistryClient, - Workflow, - TaskResponse, RouteDecision, + WorkflowEngineClient, + WorkflowExecutor, ) class MyControlPoint(ControlPoint): """User-implemented decision layer.""" - async def on_task(self, request, engine_client): - # User decides whether/how to send the task - result = await engine_client.send_message( - request.agent_name, - request.message, - ) - return TaskResponse(success=True, output=result.text) - - async def on_route(self, step_name, results, conditions): - # User decides which branch to take - # In production, use your own LLM or business logic here - return RouteDecision(next_step=conditions[0].step) + def __init__(self, a2at_client): + self._a2at_client = a2at_client - async def on_authorization(self, agent_name, auth_request): - # User approves or denies authorization requests - return True + async def on_task(self, request): + if request.input.text is None: + raise ValueError("This example expects text task input") + generated = await asyncio.to_thread( + self._a2at_client.generate_task_prompt_from_text, + request.input.text, + PRIVATE_LINE_COMPLAINT_URI, + ) + return A2atMessages.from_generated(generated, [Part(text=request.instruction)]) - async def on_notification(self, agent_name, notification): - print(f"Notification from {agent_name}: {notification}") + async def on_route(self, request): + # Called once for each conditional edge. Multiple edges may be allowed. + return RouteDecision.allow("business condition matched") async def main(): + a2at_client = A2ATClient(env_path=Path("a2at.env")) + # 1. Fetch AgentCards from the registry center registry = RegistryClient(url="https://127.0.0.1:5000") agent_cards = await registry.fetch_agent_cards() @@ -69,7 +73,6 @@ async def main(): # 2. Build a shared transport, then the workflow facade on top transport = A2ATransport( agent_cards=agent_cards, - a2at_env_path=".env", credentials_config="agent_credentials.json", ) engine_client = WorkflowEngineClient(transport) @@ -85,21 +88,21 @@ async def main(): # 4. Execute the workflow executor = WorkflowExecutor( workflow=workflow, - control_point=MyControlPoint(), + control_point=MyControlPoint(a2at_client), engine_client=engine_client, - runtime_intent="Diagnose SPN cross-city fault", + runtime_intent="Analyze the service issue and aggregate delegated results", ) result = await executor.run() if result.success: - print(f"Workflow completed successfully.") + print("Workflow completed successfully.") else: print(f"Workflow failed: {result.error}") print(f"Execution history: {len(result.history)} steps") - await engine_client.close() + await transport.close() if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 02e1f5e..6db6148 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "workflow-exec-engine" -version = "0.0.3" +version = "0.0.9" description = "Standalone workflow execution SDK for A2A-T multi-agent orchestration" readme = "README.md" license = "Apache-2.0" @@ -18,15 +18,13 @@ classifiers = [ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries", ] dependencies = [ - "a2a-sdk>=1.0.0", - "a2a-t-sdk>=1.0.0", + "a2a-sdk>=1.1.2,<2", + "a2a-t-sdk>=1.0.9,<2", "httpx>=0.27.0", "loguru>=0.7.0", "protobuf>=4.25.0", @@ -34,10 +32,24 @@ dependencies = [ "cryptography>=42.0.0", ] +[project.optional-dependencies] +dev = [ + "build>=1.2.0", + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", +] + [project.urls] -Homepage = "https://github.com/Zhoujie628/workflow-exec-engine" -Documentation = "https://github.com/Zhoujie628/workflow-exec-engine#readme" -Repository = "https://github.com/Zhoujie628/workflow-exec-engine" +Homepage = "https://github.com/project-openan/workflow-engine-sdk-python" +Documentation = "https://github.com/project-openan/workflow-engine-sdk-python#readme" +Repository = "https://github.com/project-openan/workflow-engine-sdk-python" +Issues = "https://github.com/project-openan/workflow-engine-sdk-python/issues" +Changelog = "https://github.com/project-openan/workflow-engine-sdk-python/blob/main/CHANGELOG.md" [tool.setuptools.packages.find] include = ["workflow_engine*"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["."] +python_files = ["test_*.py"] diff --git a/requirements.txt b/requirements.txt index 9816da0..7567311 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ -a2a-sdk>=1.0.0 -a2a-t-sdk>=1.0.0 +a2a-sdk>=1.1.2,<2 +a2a-t-sdk>=1.0.9,<2 httpx>=0.27.0 loguru>=0.7.0 protobuf>=4.25.0 packaging>=23.0 +cryptography>=42.0.0 diff --git a/test_core_parity.py b/test_core_parity.py new file mode 100644 index 0000000..72e2e33 --- /dev/null +++ b/test_core_parity.py @@ -0,0 +1,201 @@ +import asyncio + +import pytest +from a2a.types import Part + +from workflow_engine import ( + BusinessInput, ControlPoint, JumpCondition, MessageContent, RouteDecision, + StepType, StubWorkflowEngineClient, Task, TaskResult, Workflow, WorkflowExecutor, + WorkflowStep, +) + + +class RecordingControlPoint(ControlPoint): + def __init__(self): + self.task_requests = [] + self.route_requests = [] + + async def on_task(self, request): + self.task_requests.append(request) + return MessageContent.text(request.instruction) + + async def on_self_task(self, request): + self.task_requests.append(request) + return TaskResult.succeeded(("first", {"second": 2})) + + async def on_route(self, request): + self.route_requests.append(request) + return RouteDecision.allow(request.condition) + + +def workflow_with_routes(edges): + targets = {edge.step for edge in edges} + return Workflow(name="routes", steps=[ + WorkflowStep( + name="source", layer=0, + subtasks=[Task(agent="source-agent", description="source task")], + next=edges, + ), + *[ + WorkflowStep( + name=name, layer=1, + subtasks=[Task(agent=f"agent-{name}", description=f"task-{name}")], + ) + for name in ("a", "b", "c") if name in targets + ], + ]) + + +@pytest.mark.asyncio +async def test_mixed_routes_allow_unconditional_and_each_allowed_condition(): + control = RecordingControlPoint() + workflow = workflow_with_routes([ + JumpCondition("a", ""), + JumpCondition("b", "eligible"), + JumpCondition("c", "healthy"), + ]) + result = await WorkflowExecutor( + workflow, control, StubWorkflowEngineClient() + ).run() + + assert result.success + assert set(result.step_outputs) == {"source", "a", "b", "c"} + assert [(item.next_step, item.condition) for item in control.route_requests] == [ + ("b", "eligible"), ("c", "healthy") + ] + + +@pytest.mark.asyncio +async def test_all_denied_conditions_end_branch_normally(): + class Deny(RecordingControlPoint): + async def on_route(self, request): + self.route_requests.append(request) + return RouteDecision.deny("not selected") + + control = Deny() + result = await WorkflowExecutor( + workflow_with_routes([JumpCondition("a", "x"), JumpCondition("b", "y")]), + control, StubWorkflowEngineClient(), + ).run() + + assert result.success + assert set(result.step_outputs) == {"source"} + assert len(control.route_requests) == 2 + + +@pytest.mark.asyncio +async def test_one_route_failure_suppresses_every_successor(): + class Failure(RecordingControlPoint): + async def on_route(self, request): + if request.next_step == "b": + raise ValueError("broken condition") + return RouteDecision.allow() + + result = await WorkflowExecutor( + workflow_with_routes([JumpCondition("a", "x"), JumpCondition("b", "y")]), + Failure(), StubWorkflowEngineClient(), + ).run() + + assert not result.success + assert "source -> b" in result.error + assert set(result.step_outputs) == {"source"} + + +@pytest.mark.asyncio +async def test_structured_upstream_results_are_not_rendered_into_instruction(): + control = RecordingControlPoint() + workflow = Workflow(name="context", steps=[ + WorkflowStep( + name="first", layer=0, + subtasks=[Task(agent="a", description="produce")], + next=[JumpCondition("second")], + ), + WorkflowStep( + name="second", layer=1, + subtasks=[Task(agent="b", description="consume")], + ), + ]) + result = await WorkflowExecutor( + workflow, control, StubWorkflowEngineClient(), runtime_intent="diagnose" + ).run() + + assert result.success + second = next(item for item in control.task_requests if item.step_name == "second") + assert second.instruction == "consume" + assert second.workflow_input.runtime_intent == "diagnose" + assert second.workflow_input.upstream_results[0].step_name == "first" + assert second.workflow_input.upstream_results[0].task_results[0].outputs == ("OK from a",) + + +@pytest.mark.asyncio +async def test_self_task_supports_multiple_unrestricted_outputs(): + control = RecordingControlPoint() + workflow = Workflow(name="self", steps=[WorkflowStep( + name="local", layer=0, step_type=StepType.SELF_LOOP, + subtasks=[Task(agent="host", description="aggregate")], + )]) + result = await WorkflowExecutor( + workflow, control, StubWorkflowEngineClient() + ).run() + + assert result.success + assert result.step_outputs["local"]["aggregate"] == ("first", {"second": 2}) + + +@pytest.mark.asyncio +async def test_duplicate_descriptions_do_not_overwrite_parallel_results(): + control = RecordingControlPoint() + workflow = Workflow(name="duplicates", steps=[WorkflowStep( + name="parallel", layer=0, + subtasks=[ + Task(agent="a", description="same"), + Task(agent="b", description="same"), + ], + )]) + result = await WorkflowExecutor( + workflow, control, StubWorkflowEngineClient() + ).run() + + assert result.success + assert set(result.step_outputs["parallel"]) == {"same [a#0]", "same [b#1]"} + + +def test_business_input_and_content_snapshot_mutable_values(): + source = {"items": ["a"]} + value = BusinessInput.from_data(source) + source["items"].append("b") + assert value.data["items"] == ("a",) + + metadata = {"extension": {"items": [1]}} + content = MessageContent((Part(text="task"),), metadata) + metadata["extension"]["items"].append(2) + assert content.metadata["extension"]["items"] == (1,) + + +def test_business_values_and_message_parts_fail_fast_on_invalid_types(): + with pytest.raises(TypeError, match="JSON-compatible"): + BusinessInput.from_data(object()) + with pytest.raises(TypeError, match="a2a.types.Part"): + MessageContent(parts=("not-a-part",)) + with pytest.raises(ValueError, match="must contain"): + MessageContent(parts=(Part(),)) + + +def test_validator_rejects_duplicate_edges_and_invalid_context_source(): + from workflow_engine.core.workflow_validator import validate_workflow + + duplicate = workflow_with_routes([ + JumpCondition("a"), JumpCondition("a", "condition") + ]) + with pytest.raises(ValueError, match="duplicate outgoing target"): + validate_workflow(duplicate) + + invalid_context = Workflow(name="bad-context", steps=[ + WorkflowStep(name="left", layer=0, subtasks=[Task(agent="a")]), + WorkflowStep( + name="right", layer=0, subtasks=[Task(agent="b")], + context_from=["left"], + ), + ]) + with pytest.raises(ValueError, match="not an upstream dependency"): + validate_workflow(invalid_context) diff --git a/test_parallel_steps.py b/test_parallel_steps.py index b713d0f..a73fe73 100644 --- a/test_parallel_steps.py +++ b/test_parallel_steps.py @@ -9,9 +9,10 @@ import pytest from workflow_engine.core.executor import WorkflowExecutor from workflow_engine.core.models import ( - Workflow, WorkflowStep, Task, StepType, JumpCondition, TaskResponse, RouteDecision, + MessageContent, Workflow, WorkflowStep, Task, StepType, JumpCondition, ) from workflow_engine.control.control_points import ControlPoint +from workflow_engine.client.stub_engine_client import StubWorkflowEngineClient class _ConcurrencyCP(ControlPoint): @@ -19,13 +20,10 @@ class _ConcurrencyCP(ControlPoint): def __init__(self): self.dispatch_times: dict = {} - async def on_task(self, request, engine_client): + async def on_task(self, request): self.dispatch_times[request.agent_name] = time.monotonic() await asyncio.sleep(0.2) - return TaskResponse(success=True, output=f"done:{request.agent_name}") - - async def on_route(self, step_name, results, conditions): - return RouteDecision(next_step=conditions[0].step) + return MessageContent.text(f"task:{request.agent_name}") def _two_parallel_workflow() -> Workflow: @@ -48,10 +46,7 @@ async def test_parallel_step_dispatch(): """Two layer-0 steps must dispatch concurrently, not sequentially.""" wf = _two_parallel_workflow() cp = _ConcurrencyCP() - mock_client = type("Stub", (), { - "set_control_point": lambda self, cp: None, - "set_event_callback": lambda self, cb: None, - })() + mock_client = StubWorkflowEngineClient() executor = WorkflowExecutor(workflow=wf, control_point=cp, engine_client=mock_client) result = await executor.run() assert result.success, f"Workflow failed: {result.error}" @@ -74,12 +69,9 @@ async def test_sequential_workflow_still_works(): next=[JumpCondition(step="endNode", condition="")], layer=1), ]) cp = _ConcurrencyCP() - mock_client = type("Stub", (), { - "set_control_point": lambda self, cp: None, - "set_event_callback": lambda self, cb: None, - })() + mock_client = StubWorkflowEngineClient() executor = WorkflowExecutor(workflow=wf, control_point=cp, engine_client=mock_client) result = await executor.run() assert result.success assert len(result.history) == 2 - assert cp.dispatch_times["a1"] < cp.dispatch_times["a2"] \ No newline at end of file + assert cp.dispatch_times["a1"] < cp.dispatch_times["a2"] diff --git a/test_protocol_parity.py b/test_protocol_parity.py new file mode 100644 index 0000000..2ae98a9 --- /dev/null +++ b/test_protocol_parity.py @@ -0,0 +1,678 @@ +import asyncio +import warnings +from pathlib import Path +from types import SimpleNamespace + +import pytest +from a2a.client.interceptors import BeforeArgs +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentExtension, + Message, + Part, + Role, + SendMessageRequest, + Task, + TaskState, + TaskStatus, + TaskStatusUpdateEvent, +) +from a2a_t.core import ( + NEGOTIATION_CONTEXT_METADATA_KEY, + MetadataContent, + NegotiationContext, + NegotiationPerformative, +) +from a2a_t.core.standard_templates import INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI +from a2a_t.negotiation.content import ( + InformationEndingContent, + NegotiationConclusion, + NegotiationEndingData, + NegotiationItem, +) +from google.protobuf.struct_pb2 import Struct + +from workflow_engine import ( + A2ATExtension, + A2atMessages, + BusinessInput, + ControlPoint, + MessageContent, + NegotiationReply, + ReceivedMessage, + SendMessageResult, + TaskRequest, + WorkflowEngineClient, +) +from workflow_engine.client.a2a_transport import A2ATransport +from workflow_engine.client.extension_interceptor import ExtensionInterceptor +from workflow_engine.client.extension_sender import ( + ExtensionSender, + NotificationSubscription, +) +from workflow_engine.control.control_points import EventCallback, EventType +from workflow_engine.core.failure_mapping import failure_to_task_result +from workflow_engine.runner import _serialize + + +def test_event_serialization_preserves_protobuf_part_content(): + assert _serialize(Part(text="diagnosis request")) == {"text": "diagnosis request"} + + +def test_final_content_is_wrapped_without_modifying_parts_or_metadata(): + transport = object.__new__(A2ATransport) + transport._context_id = "default-context" + metadata = {A2ATExtension.TASK_T.uri: "generated task"} + content = MessageContent( + parts=(Part(text="final task"),), + metadata=metadata, + extensions=frozenset({A2ATExtension.TASK_T.uri}), + ) + + request = transport.build_send_request(content, "context-1", "task-1") + + assert request.message.context_id == "context-1" + assert request.message.task_id == "task-1" + assert request.message.role == Role.ROLE_USER + assert request.message.parts[0].text == "final task" + assert request.message.metadata[A2ATExtension.TASK_T.uri] == "generated task" + assert list(request.message.extensions) == [A2ATExtension.TASK_T.uri] + + +def _negotiation_content( + negotiation_id="3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + round_number=1, + performative=NegotiationPerformative.PROPOSE, +): + return A2atMessages.from_generated( + MetadataContent( + "Negotiation-T/information-negotiation/propose/v1", + "negotiation prompt", + A2ATExtension.NEGOTIATION_T.uri, + NegotiationContext(negotiation_id, round_number, 5, performative), + ), + [Part(text="negotiation prompt")], + ) + + +def test_a2at_adapter_preserves_current_sdk_metadata_and_extension(): + generated = MetadataContent( + "Task-T/network-layer/private-line-complaint/v1", + "final task prompt", + A2ATExtension.TASK_T.uri, + ) + + content = A2atMessages.from_generated(generated, [Part(text="business input")]) + + assert content.metadata[A2ATExtension.TASK_T.uri] == "final task prompt" + assert content.metadata["templateUri"] == generated.template_uri + assert content.extensions == frozenset({A2ATExtension.TASK_T.uri}) + + +def test_current_a2at_sdk_generated_content_integrates_without_legacy_api(tmp_path): + env_path = tmp_path / "a2at.env" + env_path.write_text( + "A2AT_LANGUAGE=en-US\n" + "A2AT_PROMPT_SOURCE_TYPE=packaged\n" + "A2AT_LLM_PROVIDER=openai\n" + "A2AT_LLM_MODEL=test-model\n" + "A2AT_LLM_API_KEY=test-key\n", + encoding="utf-8", + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from a2a_t.client import A2ATClient + + client = A2ATClient(env_path=env_path) + context = NegotiationContext( + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + 1, + 5, + NegotiationPerformative.PROPOSE, + ) + data = NegotiationEndingData( + context, + InformationEndingContent( + NegotiationConclusion.ACCEPT, + [NegotiationItem("accessPort", "P1")], + ), + ) + + generated = client.generate_negotiation_accept_prompt_from_data( + data, + INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, + ) + content = A2atMessages.from_generated(generated, [Part(text=generated.prompt_text)]) + + assert content.metadata[NEGOTIATION_CONTEXT_METADATA_KEY]["performative"] == "ACCEPT" + assert A2atMessages.negotiation_context(ReceivedMessage(message=content)) == ( + context.with_performative(NegotiationPerformative.ACCEPT) + ) + + +def test_a2at_adapter_extracts_typed_current_negotiation_context(): + content = _negotiation_content(round_number=2) + + context = A2atMessages.negotiation_context(ReceivedMessage(message=content)) + + assert context.id == "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3" + assert context.round == 2 + assert context.max_rounds == 5 + assert context.performative is NegotiationPerformative.PROPOSE + + +def test_a2at_adapter_rejects_conflicting_negotiation_contexts(): + first = { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, "maxRounds": 5, "performative": "PROPOSE", + } + second = { + "id": "17d6eef6-065d-4358-84ea-ff945d814187", + "round": 1, "maxRounds": 5, "performative": "PROPOSE", + } + received = ReceivedMessage( + message=MessageContent( + (Part(text="proposal"),), + { + A2ATExtension.NEGOTIATION_T.uri: "proposal", + NEGOTIATION_CONTEXT_METADATA_KEY: first, + }, + ), + task_metadata={NEGOTIATION_CONTEXT_METADATA_KEY: second}, + ) + + with pytest.raises(ValueError, match="Conflicting negotiation contexts"): + A2atMessages.negotiation_context(received) + + +def test_a2at_adapter_rejects_legacy_and_fractional_context_shapes(): + with pytest.raises(ValueError, match="Invalid negotiationContext"): + A2atMessages.context_from_metadata({ + NEGOTIATION_CONTEXT_METADATA_KEY: { + "negotiationId": "legacy", "round": 1, "status": "in-progress", + }, + }) + with pytest.raises(ValueError, match="must be an integer"): + A2atMessages.context_from_metadata({ + NEGOTIATION_CONTEXT_METADATA_KEY: { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1.5, "maxRounds": 5, "performative": "PROPOSE", + }, + }) + + +def test_standard_a2a_error_maps_to_stable_business_failure(): + from a2a.utils.errors import TaskNotFoundError + + error = TaskNotFoundError("Task unavailable", {"taskId": "task-1"}) + result = failure_to_task_result(error) + + assert not result.success + assert result.error_code == "a2a.task_not_found" + assert result.error == "Task unavailable" + assert result.error_details["http_status"] == 404 + assert result.error_details["taskId"] == "task-1" + + +def test_generic_failure_preserves_actionable_message(): + result = failure_to_task_result(ValueError("missing required target")) + + assert result.error_code == "workflow.execution_failed" + assert result.error == "missing required target" + + +def test_failed_task_result_has_stable_failure_semantics(): + task = Task( + id="task-1", + context_id="context-1", + status=TaskStatus( + state=TaskState.TASK_STATE_FAILED, + message=Message(role=Role.ROLE_AGENT, parts=[Part(text="invalid input")]), + ), + ) + + result = A2ATransport._result_from_task(task) + + assert result.is_terminal + assert result.is_failure + assert not result.is_success + assert result.failure_code == "a2a.task_failed" + assert result.failure_message == "invalid input" + + +def test_public_stream_event_preserves_status_metadata(): + metadata = Struct() + metadata.update({"__sdk_event__": '{"type":"step_start"}'}) + response = SimpleNamespace( + HasField=lambda name: name == "status_update", + status_update=TaskStatusUpdateEvent( + task_id="task-1", + context_id="context-1", + status=TaskStatus(state=TaskState.TASK_STATE_WORKING), + metadata=metadata, + ), + ) + + event = A2ATransport.parse_stream_event(response) + + assert event.event_type == "status_update" + assert event.task_state == "TASK_STATE_WORKING" + assert event.metadata["__sdk_event__"] == '{"type":"step_start"}' + + +@pytest.mark.asyncio +async def test_extension_header_uses_current_sdk_before_args_input(): + uri = A2ATExtension.TASK_T.uri + card = AgentCard( + name="agent", + capabilities=AgentCapabilities(extensions=[AgentExtension(uri=uri)]), + ) + request = SendMessageRequest(message=Message( + message_id="message", role=Role.ROLE_USER, + extensions=[uri], parts=[Part(text="task")], + )) + args = BeforeArgs(input=request, method="send_message", agent_card=card) + + await ExtensionInterceptor([uri]).before(args) + + assert args.context.service_parameters["A2A-Extensions"] == uri + + +class _NegotiationControlPoint(ControlPoint): + def __init__(self): + self.request = None + + async def on_negotiation(self, request): + self.request = request + proposal = A2atMessages.negotiation_context(request.received) + generated = MetadataContent( + INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, + "clarification accepted", + A2ATExtension.NEGOTIATION_T.uri, + proposal.with_performative(NegotiationPerformative.ACCEPT), + ) + return NegotiationReply.send( + A2atMessages.from_generated(generated, [Part(text="clarification accepted")]) + ) + + +class _NegotiationTransport: + _send_timeout_seconds = 5 + + def __init__(self): + self.calls = [] + self.cancelled = [] + self.card = SimpleNamespace( + name="agent", + supported_interfaces=[SimpleNamespace(url="https://agent.example/a2a")], + ) + + @property + def agent_names(self): + return ["agent"] + + @property + def send_timeout_seconds(self): + return self._send_timeout_seconds + + def get_card(self, name): + return self.card if name == "agent" else None + + def create_a2a_client(self, card): + return object() + + def validate_content_extensions(self, card, content): + return None + + def build_send_request(self, content, context_id, task_id=None): + message = Message( + message_id=f"message-{len(self.calls)}", context_id=context_id, + task_id=task_id or "", role=Role.ROLE_USER, + parts=list(content.parts), extensions=list(content.extensions), + ) + metadata = Struct() + metadata.update(A2ATransport._plain(content.metadata)) + message.metadata.CopyFrom(metadata) + return SendMessageRequest(message=message) + + async def consume_stream(self, client, request, callback, agent_name): + self.calls.append(request) + context_id = request.message.context_id + if len(self.calls) == 1: + received = ReceivedMessage(message=_negotiation_content()) + return SendMessageResult( + task=Task( + id="remote-1", context_id=context_id, + status=TaskStatus(state=TaskState.TASK_STATE_INPUT_REQUIRED), + ), + task_state="TASK_STATE_INPUT_REQUIRED", + received_messages=(received,), + ) + received = ReceivedMessage(message=MessageContent.text("complete")) + return SendMessageResult( + task=Task( + id="remote-1", context_id=context_id, + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED), + ), + task_state="TASK_STATE_COMPLETED", + received_messages=(received,), + ) + + async def cancel_task(self, agent_name, task_id): + self.cancelled.append((agent_name, task_id)) + return SendMessageResult(task_state="TASK_STATE_CANCELED") + + async def close(self): + pass + + +@pytest.mark.asyncio +async def test_negotiation_keeps_remote_task_and_context_identity(): + transport = _NegotiationTransport() + client = WorkflowEngineClient(transport) + control = _NegotiationControlPoint() + request = TaskRequest( + execution_id="execution", task_id="logical", input=BusinessInput.from_text("task"), + agent_name="agent", skill="", instruction="task", step_name="step", + ) + + result = await client.dispatch(request, MessageContent.text("task"), control) + + assert result.task_state == "TASK_STATE_COMPLETED" + assert control.request.task.task_id == "logical" + assert transport.calls[1].message.task_id == "remote-1" + assert transport.calls[1].message.context_id == transport.calls[0].message.context_id + + +@pytest.mark.asyncio +async def test_duplicate_negotiation_waits_through_working_until_terminal(): + class DuplicateProposalTransport(_NegotiationTransport): + def __init__(self): + super().__init__() + self.get_calls = 0 + self.context_id = None + + async def consume_stream(self, client, request, callback, agent_name): + del client, callback, agent_name + self.calls.append(request) + self.context_id = request.message.context_id + received = ReceivedMessage(message=_negotiation_content()) + return SendMessageResult( + task=Task( + id="remote-1", context_id=self.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_INPUT_REQUIRED), + ), + task_state="TASK_STATE_INPUT_REQUIRED", + received_messages=(received,), + ) + + async def get_task(self, agent_name, task_id): + del agent_name, task_id + self.get_calls += 1 + state = ( + TaskState.TASK_STATE_WORKING + if self.get_calls == 1 else TaskState.TASK_STATE_COMPLETED + ) + return SendMessageResult( + task=Task( + id="remote-1", context_id=self.context_id, + status=TaskStatus(state=state), + ), + task_state=TaskState.Name(state), + ) + + transport = DuplicateProposalTransport() + client = WorkflowEngineClient(transport) + control = _NegotiationControlPoint() + request = TaskRequest( + execution_id="execution", task_id="logical", + input=BusinessInput.from_text("task"), agent_name="agent", skill="", + instruction="task", step_name="step", + ) + + result = await client.dispatch(request, MessageContent.text("task"), control) + + assert result.task_state == "TASK_STATE_COMPLETED" + assert transport.get_calls == 2 + assert len(transport.calls) == 2 + + +@pytest.mark.asyncio +async def test_negotiation_rejects_host_generated_propose_as_reply(): + class Continue(_NegotiationControlPoint): + async def on_negotiation(self, request): + context = A2atMessages.negotiation_context(request.received) + return NegotiationReply.send( + _negotiation_content( + negotiation_id=context.id, + round_number=context.round + 1, + performative=NegotiationPerformative.PROPOSE, + ) + ) + + transport = _NegotiationTransport() + client = WorkflowEngineClient(transport) + request = TaskRequest( + execution_id="execution", task_id="logical", input=BusinessInput.from_text("task"), + agent_name="agent", skill="", instruction="task", step_name="step", + ) + + with pytest.raises(ValueError, match="context/round"): + await client.dispatch(request, MessageContent.text("task"), Continue()) + + assert transport.cancelled == [("agent", "remote-1")] + + +@pytest.mark.asyncio +async def test_negotiation_stop_cancels_remote_input_required_task(): + class Stop(_NegotiationControlPoint): + async def on_negotiation(self, request): + return NegotiationReply.stop("business.cannot_continue", "no safe answer") + + transport = _NegotiationTransport() + client = WorkflowEngineClient(transport) + request = TaskRequest( + execution_id="execution", task_id="logical", input=BusinessInput.from_text("task"), + agent_name="agent", skill="", instruction="task", step_name="step", + ) + + with pytest.raises(RuntimeError, match="no safe answer"): + await client.dispatch(request, MessageContent.text("task"), Stop()) + + assert transport.cancelled == [("agent", "remote-1")] + + +@pytest.mark.asyncio +async def test_unsupported_auth_required_state_cancels_remote_task(): + class AuthRequiredTransport(_NegotiationTransport): + async def consume_stream(self, client, request, callback, agent_name): + del client, callback, agent_name + self.calls.append(request) + task = Task( + id="remote-auth", + context_id=request.message.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_AUTH_REQUIRED), + ) + return SendMessageResult( + task=task, + task_state="TASK_STATE_AUTH_REQUIRED", + ) + + transport = AuthRequiredTransport() + client = WorkflowEngineClient(transport) + request = TaskRequest( + execution_id="execution", task_id="logical", + input=BusinessInput.from_text("task"), agent_name="agent", skill="", + instruction="task", step_name="step", + ) + + result = await client.dispatch(request, MessageContent.text("task")) + + assert result.failure_code == "a2a.unsupported_task_state" + assert transport.cancelled == [("agent", "remote-auth")] + + +def test_workflow_client_rejects_concurrent_execution_binding(): + client = WorkflowEngineClient(_NegotiationTransport()) + + client.begin_execution("execution-1", None, None) + with pytest.raises(RuntimeError, match="concurrent reuse"): + client.begin_execution("execution-2", None, None) + + client.end_execution("execution-1") + client.begin_execution("execution-2", None, None) + client.end_execution("execution-2") + + +@pytest.mark.asyncio +async def test_authorization_waits_for_terminal_result(): + class AuthorizationTransport: + send_timeout_seconds = 2 + + def __init__(self): + self.context_id = None + self.get_calls = 0 + + def get_card(self, agent_name): + return SimpleNamespace(name=agent_name) + + def _get_extensions(self, card): + del card + return {A2ATExtension.AUTHORIZATION_T.uri} + + def validate_content_extensions(self, card, content): + del card, content + + def create_a2a_client(self, card): + return card + + def build_send_request(self, content, context_id): + del content + self.context_id = context_id + return object() + + async def consume_stream(self, client, request, agent_name=""): + del client, request, agent_name + task = Task( + id="authorization-1", context_id=self.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED), + ) + return SendMessageResult( + task=task, task_state="TASK_STATE_SUBMITTED", + ) + + async def get_task(self, agent_name, task_id): + del agent_name, task_id + self.get_calls += 1 + task = Task( + id="authorization-1", context_id=self.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_FAILED), + ) + return A2ATransport._result_from_task(task) + + transport = AuthorizationTransport() + sender = ExtensionSender(transport) + content = MessageContent( + parts=(Part(text="authorize"),), + metadata={A2ATExtension.AUTHORIZATION_T.uri: "authorization"}, + extensions={A2ATExtension.AUTHORIZATION_T.uri}, + ) + + result = await sender.send_authorization("agent", content) + + assert transport.get_calls == 1 + assert result.failure_code == "a2a.task_failed" + assert not result.is_success + + +@pytest.mark.asyncio +async def test_negotiation_rejects_reply_without_extension_activation(): + class BadReply(_NegotiationControlPoint): + async def on_negotiation(self, request): + return NegotiationReply.send(MessageContent.text("plain")) + + transport = _NegotiationTransport() + client = WorkflowEngineClient(transport) + request = TaskRequest( + execution_id="execution", task_id="logical", input=BusinessInput.from_text("task"), + agent_name="agent", skill="", instruction="task", step_name="step", + ) + + with pytest.raises(ValueError, match="activate Negotiation-T"): + await client.dispatch(request, MessageContent.text("task"), BadReply()) + + +@pytest.mark.asyncio +async def test_negotiation_failure_emits_terminal_event(): + class InvalidReply(_NegotiationControlPoint): + async def on_negotiation(self, request): + return NegotiationReply.send(MessageContent.text("plain")) + + class Events(EventCallback): + def __init__(self): + self.types = [] + + def on_event(self, event_type, data): + self.types.append(event_type) + + events = Events() + transport = _NegotiationTransport() + client = WorkflowEngineClient(transport, event_callback=events) + request = TaskRequest( + execution_id="execution", task_id="logical", input=BusinessInput.from_text("task"), + agent_name="agent", skill="", instruction="task", step_name="step", + ) + + with pytest.raises(ValueError, match="activate Negotiation-T"): + await client.dispatch(request, MessageContent.text("task"), InvalidReply()) + + assert events.types.count(EventType.NEGOTIATION_FAILED) == 1 + + +@pytest.mark.asyncio +async def test_negotiation_abort_is_not_reported_as_task_success(): + class Abort(_NegotiationControlPoint): + async def on_negotiation(self, request): + context = A2atMessages.negotiation_context(request.received) + generated = MetadataContent( + "Negotiation-T/common/abort/v1", + "abort", + A2ATExtension.NEGOTIATION_T.uri, + context.with_performative(NegotiationPerformative.ABORT), + ) + return NegotiationReply.send( + A2atMessages.from_generated(generated, [Part(text="abort")]) + ) + + client = WorkflowEngineClient(_NegotiationTransport()) + request = TaskRequest( + execution_id="execution", task_id="logical", input=BusinessInput.from_text("task"), + agent_name="agent", skill="", instruction="task", step_name="step", + ) + + result = await client.dispatch(request, MessageContent.text("task"), Abort()) + + assert result.failure_code == "negotiation.aborted" + assert not result.outputs + + +@pytest.mark.asyncio +async def test_notification_manual_close_completes_channel_lifecycle(): + subscription = NotificationSubscription("agent", "context") + + async def wait_forever(): + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + if not subscription.completion.done(): + subscription.completion.set_result(None) + + task = asyncio.create_task(wait_forever()) + subscription._attach(task) + subscription.close() + await asyncio.gather(task, return_exceptions=True) + await subscription.completion + + assert subscription.acknowledgement.cancelled() + assert not subscription.is_active diff --git a/test_validation_and_security.py b/test_validation_and_security.py new file mode 100644 index 0000000..be99335 --- /dev/null +++ b/test_validation_and_security.py @@ -0,0 +1,242 @@ +"""Regression tests for workflow validation and credential security.""" + +import pytest +from a2a.client.interceptors import BeforeArgs +from a2a.types import ( + APIKeySecurityScheme, AgentCapabilities, AgentCard, AgentExtension, + AgentInterface, HTTPAuthSecurityScheme, SecurityRequirement, SecurityScheme, + StringList, +) + +from workflow_engine.client.a2a_transport import A2ATransport +from workflow_engine.client.agentcard_normalizer import normalize_agent_dict +from workflow_engine.client.credential_crypto import decrypt_if_needed, encrypt +from workflow_engine.client.credential_service import ( + AgentAuthManager, CustomAuthInterceptor, +) +from workflow_engine.client.protocol_logger import log_request +from workflow_engine.client.ssl_context import create_ssl_context +from workflow_engine.control.control_points import ControlPoint +from workflow_engine.core.executor import WorkflowExecutor +from workflow_engine.core.models import ( + JumpCondition, + MessageContent, + RouteDecision, + Task, + Workflow, + WorkflowStep, +) +from workflow_engine.client.stub_engine_client import StubWorkflowEngineClient +from workflow_engine.core.workflow_validator import validate_workflow + + +def test_credential_crypto_round_trip_requires_256_bit_key(monkeypatch): + monkeypatch.setenv("A2AT_CRED_KEY", "ab" * 32) + encrypted = encrypt("secret") + assert decrypt_if_needed(encrypted) == "secret" + + +def test_encrypted_credential_fails_closed_without_key(monkeypatch): + monkeypatch.delenv("A2AT_CRED_KEY", raising=False) + with pytest.raises(RuntimeError, match="not configured"): + decrypt_if_needed("enc:aXY=:Y2lwaGVydGV4dA==") + + +def test_invalid_key_is_rejected(monkeypatch): + monkeypatch.setenv("A2AT_CRED_KEY", "not-a-256-bit-key") + with pytest.raises(ValueError, match="64 hexadecimal"): + encrypt("secret") + + +def test_credential_profiles_are_copied_and_overridden_per_agent(): + source = { + "profiles": { + "shared": { + "bearer": { + "login_url": "https://identity.example/login", + "request_fields": {"username": "shared-user"}, + } + } + }, + "agents": { + "agent-a": {"profile": "shared"}, + "agent-b": { + "profile": "shared", + "overrides": { + "bearer": {"request_fields": {"username": "agent-b-user"}} + }, + }, + }, + } + + manager = AgentAuthManager(config=source) + + assert manager.get_config("agent-a")["bearer"]["request_fields"]["username"] == "shared-user" + assert manager.get_config("agent-b")["bearer"]["request_fields"]["username"] == "agent-b-user" + assert source["profiles"]["shared"]["bearer"]["request_fields"]["username"] == "shared-user" + + +def test_unknown_credential_profile_fails_during_configuration(): + with pytest.raises(ValueError, match="Unknown credential profile"): + AgentAuthManager(config={ + "profiles": {}, + "agents": {"agent": {"profile": "missing"}}, + }) + + +@pytest.mark.asyncio +async def test_all_schemes_in_one_auth_requirement_are_applied(): + class Credentials: + async def get_credentials(self, scheme_name, context=None): + return {"bearer": "bearer-token", "api": "api-token"}[scheme_name] + + card = AgentCard( + name="agent", + security_schemes={ + "bearer": SecurityScheme( + http_auth_security_scheme=HTTPAuthSecurityScheme(scheme="bearer") + ), + "api": SecurityScheme( + api_key_security_scheme=APIKeySecurityScheme( + location="header", name="X-API-Key" + ) + ), + }, + security_requirements=[SecurityRequirement( + schemes={"bearer": StringList(), "api": StringList()} + )], + ) + interceptor = CustomAuthInterceptor( + Credentials(), {"bearer": {"login_url": "unused"}, "api": {"login_url": "unused"}} + ) + args = BeforeArgs(input=None, method="send_message", agent_card=card) + + await interceptor.before(args) + + assert args.context.service_parameters["Authorization"] == "Bearer bearer-token" + assert args.context.service_parameters["X-API-Key"] == "api-token" + + +def test_protocol_logging_is_opt_in_and_redacts_headers(monkeypatch): + from loguru import logger + + messages = [] + sink = logger.add(messages.append, format="{message}", level="DEBUG") + try: + monkeypatch.delenv("WORKFLOW_ENGINE_PROTOCOL_LOGGING", raising=False) + log_request("agent", "https://example.com", "payload", {"Authorization": "secret"}) + assert messages == [] + + monkeypatch.setenv("WORKFLOW_ENGINE_PROTOCOL_LOGGING", "true") + log_request( + "agent", + "https://example.com", + "payload", + {"Authorization": "secret", "X-Request-ID": "request-1"}, + ) + rendered = "".join(messages) + assert "***REDACTED***" in rendered + assert "secret" not in rendered + assert "request-1" in rendered + finally: + logger.remove(sink) + + +def test_tls_configuration_fails_closed_for_missing_ca(tmp_path): + with pytest.raises(FileNotFoundError, match="CA trust store"): + create_ssl_context(True, str(tmp_path / "missing-ca.pem")) + + +def test_mtls_requires_certificate_and_key(tmp_path): + with pytest.raises(ValueError, match="Both client certificate"): + create_ssl_context(True, cert_path=str(tmp_path / "client.pem")) + + +def test_agent_card_requires_capabilities_and_transport_interface(): + without_capabilities = AgentCard( + name="agent", + supported_interfaces=[AgentInterface( + url="https://agent.example/a2a", protocol_binding="HTTP+JSON", + )], + ) + with pytest.raises(ValueError, match="capabilities"): + A2ATransport._build_card_map([without_capabilities]) + + without_interface = AgentCard( + name="agent", capabilities=AgentCapabilities(streaming=True), + ) + with pytest.raises(ValueError, match="supported_interfaces"): + A2ATransport._build_card_map([without_interface]) + + +def test_required_agent_extension_must_be_activated(): + uri = "urn:example:required" + card = AgentCard( + name="agent", + capabilities=AgentCapabilities( + extensions=[AgentExtension(uri=uri, required=True)] + ), + supported_interfaces=[AgentInterface( + url="https://agent.example/a2a", protocol_binding="HTTP+JSON", + )], + ) + with pytest.raises(ValueError, match="Required extension"): + A2ATransport.validate_content_extensions(card, MessageContent.text("task")) + + +def test_agent_card_normalization_does_not_invent_auth_requirements(): + normalized = normalize_agent_dict({ + "name": "agent", + "securitySchemes": {"bearer": {"scheme": "bearer"}}, + }) + + assert "securityRequirements" not in normalized + + +def test_validator_rejects_unknown_target(): + workflow = Workflow( + name="invalid", + steps=[ + WorkflowStep( + name="start", + layer=0, + subtasks=[Task(agent="agent", description="work")], + next=[JumpCondition(step="missing", condition="")], + ) + ], + ) + with pytest.raises(ValueError, match="unknown step 'missing'"): + validate_workflow(workflow) + + +@pytest.mark.asyncio +async def test_route_callback_failure_names_the_edge(): + class FailingRouteControlPoint(ControlPoint): + async def on_task(self, request): + return MessageContent.text("done") + + async def on_route(self, request): + raise ValueError("cannot decide") + + workflow = Workflow( + name="runtime-route", + steps=[ + WorkflowStep( + name="start", + layer=0, + subtasks=[Task(agent="agent-a", description="start")], + next=[JumpCondition(step="finish", condition="choose")], + ), + WorkflowStep( + name="finish", + layer=1, + subtasks=[Task(agent="agent-b", description="finish")], + ), + ], + ) + result = await WorkflowExecutor( + workflow, FailingRouteControlPoint(), StubWorkflowEngineClient() + ).run() + + assert not result.success + assert "start -> finish" in result.error diff --git a/verify_sdk.py b/verify_sdk.py index df47372..08b9064 100644 --- a/verify_sdk.py +++ b/verify_sdk.py @@ -4,33 +4,19 @@ workflow_complete lifecycle, ControlPoint decision dispatch, on_finish hook. """ import asyncio +from importlib.metadata import version + +from packaging.version import Version from workflow_engine import ( - execute_psop, ControlPoint, Workflow, TaskResponse, RouteDecision, EventType, + ControlPoint, MessageContent, RouteDecision, StubWorkflowEngineClient, Workflow, execute_psop, ) -class StubEngineClient: - """Minimal stub that records sends and returns canned text.""" - def __init__(self): - self.sent = [] - async def send_message(self, agent_name, message, context_id=None, metadata=None): - self.sent.append((agent_name, message)) - from workflow_engine.core.models import SendMessageResult - return SendMessageResult(text=f"OK from {agent_name}", task_state="COMPLETED") - def set_event_callback(self, cb): - self._cb = cb - def set_control_point(self, cp): - self._cp = cp - async def close(self): - pass - - class MyCP(ControlPoint): - async def on_task(self, request, engine_client): - r = await engine_client.send_message(request.agent_name, request.message) - return TaskResponse(success=True, output=r.text) - async def on_route(self, step_name, results, conditions): - return RouteDecision(next_step=conditions[0].step, reason="pick first") + async def on_task(self, request): + return MessageContent.text(request.instruction) + async def on_route(self, request): + return RouteDecision.allow("condition matched") def build_workflow(): @@ -52,10 +38,13 @@ async def on_finish(result, events): async def main(): + a2at_version = Version(version("a2a-t-sdk")) + assert Version("1.1.0") <= a2at_version < Version("2") + print(f"a2a-t-sdk: {a2at_version}") wf = build_workflow() print(f"step s1 type: {wf.steps[0].step_type}") print(f"step s2 type: {wf.steps[1].step_type} (input was 'allsuccess')") - stub = StubEngineClient() + stub = StubWorkflowEngineClient() events = [] async for ev in execute_psop( psop=wf, @@ -67,8 +56,6 @@ async def main(): ): events.append(ev["type"]) print("event sequence:", events) - expected_prefix = ["start", "step_start", "task_request", "task_response", - "task_status_changed", "step_complete", "route_decision"] ok = events[0] == "start" and events[-1] == "close" ok = ok and "complete" in events ok = ok and "workflow_complete" in events diff --git a/workflow_engine/__init__.py b/workflow_engine/__init__.py index e0e3a82..5a3eba9 100644 --- a/workflow_engine/__init__.py +++ b/workflow_engine/__init__.py @@ -23,27 +23,26 @@ from workflow_engine import ( WorkflowExecutor, ControlPoint, A2ATransport, WorkflowEngineClient, ExtensionSender, - Workflow, TaskResponse, RouteDecision, + Workflow, MessageContent, TaskResult, RouteDecision, ) # 1. User fetches AgentCards (from registry or custom source) # 2. User builds a shared transport, then the workflow facade on top - transport = A2ATransport(agent_cards=my_cards, a2at_env_path=".env") + transport = A2ATransport(agent_cards=my_cards) engine_client = WorkflowEngineClient(transport) # 3. User implements ControlPoint (workflow decisions) class MyCP(ControlPoint): - async def on_task(self, request, engine_client): - result = await engine_client.send_message(request.agent_name, request.message) - return TaskResponse(success=True, output=result.text) - async def on_route(self, step_name, results, conditions): - return RouteDecision(next_step="step_b") + async def on_task(self, request): + return MessageContent.text(request.instruction) + async def on_route(self, request): + return RouteDecision.allow() - # 4. Optional: one-shot pre-positioning (Authorization-T / Notification-T) - # via ExtensionSender before workflow execution - # sender = ExtensionSender(transport) - # await sender.send_authorization("agent_a", "authorize", "policy text") - # await sender.send_notification("agent_a", "subscribe", "topic text") + # 4. Optional: independent Authorization-T / Notification-T operations. + # Use a transport separate from workflow task traffic. + # sender = ExtensionSender(independent_transport) + # await sender.send_authorization("agent_a", authorization_content) + # subscription = sender.open_notification("agent_a", notification_content, listener) # 5. Execute executor = WorkflowExecutor(workflow=wf, control_point=MyCP(), engine_client=engine_client) @@ -53,16 +52,20 @@ async def on_route(self, step_name, results, conditions): from workflow_engine.core import ( Workflow, WorkflowStep, Task, JumpCondition, StepType, TaskStatus, ExecutionResult, - SendMessageResult, TaskRequest, TaskResponse, RouteDecision, + BusinessFailure, BusinessInput, MessageContent, ReceivedArtifact, + ReceivedMessage, A2AStreamEvent, SendMessageResult, TaskRequest, TaskResult, + TaskExecutionResult, UpstreamStepResult, WorkflowInput, RouteRequest, + RouteDecision, NegotiationRequest, NegotiationReply, NegotiationSend, + NegotiationStop, NegotiationExchange, WorkflowSearchResult, ContextBuilder, WorkflowExecutor, ) from workflow_engine.client import ( - WorkflowEngineClient, A2ATransport, ExtensionSender, AuthManager, - ExtensionHandler, TaskTHandler, NegotiationTHandler, ExtensionRegistry, - A2ATExtension, AuthProvider, + WorkflowEngineClient, A2ATransport, ExtensionSender, NotificationSubscription, + NotificationHeartbeat, AuthManager, + A2ATExtension, A2atMessages, AuthProvider, create_ssl_context, normalize_agent_dict, StubWorkflowEngineClient, - log_request, log_response, log_response_event, + log_request, log_response, ) from workflow_engine.control import ControlPoint, EventCallback, EventType from workflow_engine.control import ( @@ -75,13 +78,17 @@ async def on_route(self, step_name, results, conditions): # Core "Workflow", "WorkflowStep", "Task", "JumpCondition", "StepType", "TaskStatus", "ExecutionResult", - "SendMessageResult", "TaskRequest", "TaskResponse", "RouteDecision", + "BusinessFailure", "BusinessInput", "MessageContent", "ReceivedArtifact", + "ReceivedMessage", "A2AStreamEvent", "SendMessageResult", "TaskRequest", "TaskResult", + "TaskExecutionResult", "UpstreamStepResult", "WorkflowInput", "RouteRequest", + "RouteDecision", "NegotiationRequest", "NegotiationReply", "NegotiationSend", + "NegotiationStop", "NegotiationExchange", "WorkflowSearchResult", "ContextBuilder", "WorkflowExecutor", # Client - "WorkflowEngineClient", "A2ATransport", "ExtensionSender", "AuthManager", - "ExtensionHandler", "TaskTHandler", "NegotiationTHandler", "ExtensionRegistry", - "A2ATExtension", "AuthProvider", "log_request", "log_response", "log_response_event", "StubWorkflowEngineClient", + "WorkflowEngineClient", "A2ATransport", "ExtensionSender", "NotificationSubscription", + "NotificationHeartbeat", "AuthManager", + "A2ATExtension", "A2atMessages", "AuthProvider", "log_request", "log_response", "StubWorkflowEngineClient", "create_ssl_context", "normalize_agent_dict", # Control (user implements) "ControlPoint", "EventCallback", "EventType", diff --git a/workflow_engine/client/__init__.py b/workflow_engine/client/__init__.py index cd1d368..ebac7ce 100644 --- a/workflow_engine/client/__init__.py +++ b/workflow_engine/client/__init__.py @@ -18,14 +18,14 @@ from workflow_engine.client.engine_client import WorkflowEngineClient from workflow_engine.client.a2a_transport import A2ATransport -from workflow_engine.client.extension_sender import ExtensionSender -from workflow_engine.client.auth_manager import AuthManager -from workflow_engine.client.extension_handlers import ( - ExtensionHandler, TaskTHandler, NegotiationTHandler, ExtensionRegistry, +from workflow_engine.client.extension_sender import ( + ExtensionSender, NotificationHeartbeat, NotificationSubscription, ) +from workflow_engine.client.auth_manager import AuthManager from workflow_engine.client.ssl_context import create_ssl_context from workflow_engine.client.credential_service import AgentCredentialService, AgentAuthManager, CustomAuthInterceptor from workflow_engine.client.extensions import A2ATExtension +from workflow_engine.client.a2at_messages import A2atMessages from workflow_engine.client.credential_crypto import encrypt, decrypt_if_needed from workflow_engine.client.env_file_loader import load_to_environ from workflow_engine.client.auth_provider import AuthProvider @@ -33,15 +33,15 @@ from workflow_engine.client.extension_interceptor import ExtensionInterceptor from workflow_engine.client.agentcard_normalizer import normalize_agent_dict -from workflow_engine.client.protocol_logger import log_request, log_response, log_response_event +from workflow_engine.client.protocol_logger import log_request, log_response from workflow_engine.client.stub_engine_client import StubWorkflowEngineClient __all__ = [ - "WorkflowEngineClient", "A2ATransport", "ExtensionSender", "AuthManager", - "ExtensionHandler", "TaskTHandler", "NegotiationTHandler", "ExtensionRegistry", + "WorkflowEngineClient", "A2ATransport", "ExtensionSender", + "NotificationHeartbeat", "NotificationSubscription", "AuthManager", "create_ssl_context", "AgentCredentialService", "AgentAuthManager", "CustomAuthInterceptor", "ExtensionInterceptor", "normalize_agent_dict", - "A2ATExtension", "encrypt", "decrypt_if_needed", "load_to_environ", + "A2ATExtension", "A2atMessages", "encrypt", "decrypt_if_needed", "load_to_environ", "AuthProvider", "AuthProviderInterceptor", - "log_request", "log_response", "log_response_event", "StubWorkflowEngineClient", + "log_request", "log_response", "StubWorkflowEngineClient", ] diff --git a/workflow_engine/client/a2a_transport.py b/workflow_engine/client/a2a_transport.py index 0c70866..f9c7e4d 100644 --- a/workflow_engine/client/a2a_transport.py +++ b/workflow_engine/client/a2a_transport.py @@ -3,22 +3,21 @@ # # SPDX-License-Identifier: Apache-2.0 -"""A2ATransport -- shared low-level A2A communication layer. +"""Shared low-level A2A communication layer. Single responsibility: own the httpx client, auth manager, agent-card -map, the A2ATClient handle, and the SSE stream consumer. This is the +map and the A2A response-stream consumer. This is the shared base over which the two single-responsibility facades sit: -* ``WorkflowEngineClient`` (engine_client.py) -- workflow execution - path: Task-T prompt generation, Negotiation-T auto-loop, extension - handlers, event callback, control point. +* ``WorkflowEngineClient`` (engine_client.py) -- workflow task dispatch, + task waiting, Negotiation-T lifecycle, event callback, and control point. * ``ExtensionSender`` (extension_sender.py) -- one-shot pre-positioning sends: Authorization-T / Notification-T. Neither facade duplicates transport code; both delegate here. """ -import asyncio +import copy import uuid from typing import Dict, Any, List, Optional, Callable from loguru import logger @@ -31,28 +30,25 @@ try: from a2a.client import ClientConfig, ClientFactory - from a2a.helpers import new_text_message - from a2a.types import SendMessageRequest, TaskState + from a2a.types import ( + CancelTaskRequest, GetTaskRequest, ListTasksRequest, Message, Role, + SendMessageRequest, SubscribeToTaskRequest, TaskState, + ) _A2A_AVAILABLE = True except ImportError: _A2A_AVAILABLE = False -try: - from a2a_t.client import A2ATClient - _A2AT_AVAILABLE = True -except ImportError: - _A2AT_AVAILABLE = False - A2ATClient = None - from workflow_engine.client.ssl_context import create_ssl_context from workflow_engine.client.auth_manager import AuthManager -from workflow_engine.client.protocol_logger import log_request, log_response +from workflow_engine.client.protocol_logger import log_response +from workflow_engine.client.protocol_interceptor import ProtocolLoggingInterceptor from workflow_engine.client.sse_normalization import apply_sse_normalization from workflow_engine.client.agentcard_normalizer import normalize_agent_dict from workflow_engine.control.control_points import EventType -from workflow_engine.core.models import SendMessageResult -from workflow_engine.client.credential_crypto import decrypt_if_needed as _decrypt_credential -from workflow_engine.client.env_file_loader import load_to_environ as _load_env_file +from workflow_engine.core.models import ( + A2AStreamEvent, MessageContent, ReceivedArtifact, ReceivedMessage, + SendMessageResult, +) from workflow_engine.client.auth_provider import AuthProvider # Apply SSE response normalization once at import time. @@ -63,7 +59,7 @@ class A2ATransport: """Shared A2A communication base (httpx + auth + SSE consumer). Owns the httpx.AsyncClient, AgentAuthManager, agent-card map, the - A2ATClient handle, and the streaming-response consumer. Facades + authentication interceptors, and the streaming-response consumer. Facades (WorkflowEngineClient / ExtensionSender) delegate all wire-level work here. """ @@ -73,32 +69,35 @@ def __init__( agent_cards: List[Any], httpx_client: Optional[httpx.AsyncClient] = None, credentials_config: Optional[str | Dict] = None, - a2at_env_path: Optional[str] = None, ssl_verify: bool = True, ca_certs_path: Optional[str] = None, + client_cert_path: Optional[str] = None, + client_key_path: Optional[str] = None, + client_key_password: Optional[str] = None, + crl_path: Optional[str] = None, auth_provider: Optional[AuthProvider] = None, preferred_protocol: Optional[str] = None, send_timeout_seconds: int = 600, ): - if a2at_env_path: - _load_env_file(a2at_env_path) + if send_timeout_seconds <= 0: + raise ValueError("send_timeout_seconds must be positive") normalized_cards = self._normalize_cards(agent_cards) - self._card_map = { - card.name: card for card in normalized_cards if hasattr(card, "name") - } + self._card_map = self._build_card_map(normalized_cards) self._send_timeout_seconds = send_timeout_seconds + self._owns_httpx_client = httpx_client is None + self._closed = False self._httpx_client = httpx_client or self._create_httpx_client( - ssl_verify, ca_certs_path + ssl_verify, ca_certs_path, client_cert_path, client_key_path, + client_key_password, crl_path, ) - self._auth_manager = AuthManager(agent_cards, credentials_config) + self._auth_manager = AuthManager(normalized_cards, credentials_config) self._auth_manager.set_httpx_client(self._httpx_client) - self._a2at_client = self._init_a2at_client(a2at_env_path) self._context_id = str(uuid.uuid4()) self._auth_provider = auth_provider self._preferred_protocol = preferred_protocol logger.info( f"[Transport] Initialized with {len(self._card_map)} agent(s), " - f"ssl_verify={ssl_verify}, a2at={self._a2at_client is not None}, " + f"ssl_verify={ssl_verify}, " f"send_timeout={send_timeout_seconds}s" ) @@ -106,32 +105,24 @@ def __init__( # Setup helpers # ------------------------------------------------------------------ - def _init_a2at_client(self, a2at_env_path): - if not a2at_env_path or not _A2AT_AVAILABLE: - return None - from pathlib import Path - env_path = Path(a2at_env_path) if not isinstance(a2at_env_path, Path) else a2at_env_path - try: - client = A2ATClient(env_path=env_path) - logger.info("A2ATClient initialized") - return client - except Exception as e: - logger.warning(f"Failed to init A2ATClient: {e}") - return None - - def _create_httpx_client(self, ssl_verify, ca_certs_path) -> httpx.AsyncClient: + def _create_httpx_client( + self, ssl_verify, ca_certs_path, client_cert_path, + client_key_path, client_key_password, crl_path, + ) -> httpx.AsyncClient: if not ssl_verify: logger.warning( "[Transport] ssl_verify=False -- TLS server certificate " "validation disabled. Not recommended for production." ) ssl_ctx = create_ssl_context( - verify_server=ssl_verify, ca_certs_path=ca_certs_path + verify_server=ssl_verify, ca_certs_path=ca_certs_path, + cert_path=client_cert_path, key_path=client_key_path, + key_password=client_key_password, crl_path=crl_path, ) return httpx.AsyncClient( timeout=httpx.Timeout(connect=60, read=self._send_timeout_seconds, write=60, pool=10.0), verify=ssl_ctx, - follow_redirects=True, + follow_redirects=False, ) @staticmethod @@ -164,9 +155,30 @@ def _normalize_cards(agent_cards: List[Any]) -> List[Any]: raise TypeError(f"Failed to parse AgentCard dict: {e}") from e name = getattr(card, "name", "") or "" logger.info(f"[Transport] Auto-normalized dict AgentCard -> {name}") - result.append(card) + result.append(copy.deepcopy(card)) return result + @staticmethod + def _build_card_map(agent_cards: List[Any]) -> Dict[str, Any]: + cards: Dict[str, Any] = {} + for card in agent_cards: + if card is None: + raise ValueError("AgentCard must not be null") + name = getattr(card, "name", "") + if not name or not name.strip(): + raise ValueError("AgentCard name must not be blank") + capabilities = getattr(card, "capabilities", None) + if capabilities is None or ( + hasattr(card, "HasField") and not card.HasField("capabilities") + ): + raise ValueError(f"AgentCard capabilities are required: {name}") + if not list(getattr(card, "supported_interfaces", ()) or ()): + raise ValueError(f"AgentCard supported_interfaces are required: {name}") + if name in cards: + raise ValueError(f"Duplicate AgentCard name: {name}") + cards[name] = card + return cards + # ------------------------------------------------------------------ # Accessors # ------------------------------------------------------------------ @@ -179,22 +191,50 @@ def agent_names(self) -> List[str]: def httpx_client(self) -> httpx.AsyncClient: return self._httpx_client - def get_a2at_client(self): - return self._a2at_client + @property + def send_timeout_seconds(self) -> int: + return self._send_timeout_seconds def get_card(self, agent_name: str): return self._card_map.get(agent_name) def update_agent_cards(self, agent_cards: List[Any]): - self._card_map = { - card.name: card for card in agent_cards if hasattr(card, "name") - } + normalized_cards = self._normalize_cards(agent_cards) + self._card_map = self._build_card_map(normalized_cards) + self._auth_manager.update_agent_cards(normalized_cards) + + @staticmethod + def validate_content_extensions(agent_card, content: MessageContent) -> None: + """Enforce AgentCard extensions marked as required for this request.""" + declared = getattr( + getattr(agent_card, "capabilities", None), "extensions", () + ) or () + missing = [ + extension.uri for extension in declared + if getattr(extension, "required", False) + and extension.uri not in content.extensions + ] + if missing: + raise ValueError(f"Required extension not activated: {missing[0]}") # ------------------------------------------------------------------ # Wire-level send primitives (shared by both facades) # ------------------------------------------------------------------ def create_a2a_client(self, agent_card): + if self._closed: + raise RuntimeError("Transport is closed") + requires_auth = bool( + agent_card.security_schemes and agent_card.security_requirements + ) + if ( + requires_auth + and self._auth_provider is None + and not self._auth_manager.has_credentials(agent_card.name) + ): + raise RuntimeError( + f"Agent {agent_card.name} declares authentication but no credentials are configured" + ) interfaces = [ iface for iface in agent_card.supported_interfaces if iface.protocol_binding @@ -228,16 +268,45 @@ def create_a2a_client(self, agent_card): from workflow_engine.client.auth_manager import AuthProviderInterceptor interceptors = list(interceptors) + [AuthProviderInterceptor( self._auth_provider, agent_card.name)] + selected_interface = interfaces[0] if interfaces else None + interceptors = list(interceptors) + [ProtocolLoggingInterceptor( + agent_card.name, + getattr(selected_interface, "url", "") or "?", + getattr(selected_interface, "protocol_version", "") or "", + )] logger.info(f"[Transport] Created A2A client for {agent_card.name}: protocol={protocol_bindings}, streaming={streaming}, interceptors={len(interceptors)}") return ClientFactory(config).create(agent_card, interceptors=interceptors) - def build_send_request(self, message, context_id, metadata): - ctx = context_id or self._context_id - request_msg = new_text_message(text=message, context_id=ctx) - if metadata: - meta = Struct() - meta.update(metadata) - request_msg.metadata.CopyFrom(meta) + @staticmethod + def _plain(value): + if isinstance(value, dict) or hasattr(value, "items"): + return {str(key): A2ATransport._plain(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [A2ATransport._plain(item) for item in value] + return value + + def build_send_request( + self, + content: MessageContent, + context_id: Optional[str], + task_id: Optional[str] = None, + ): + """Wrap final business content in an engine-owned A2A message envelope.""" + if not isinstance(content, MessageContent): + raise TypeError("content must be MessageContent") + request_msg = Message( + message_id=str(uuid.uuid4()), + context_id=context_id or self._context_id, + task_id=task_id or "", + role=Role.ROLE_USER, + extensions=list(content.extensions), + ) + for part in content.parts: + request_msg.parts.add().CopyFrom(part) + if content.metadata: + metadata = Struct() + metadata.update(self._plain(content.metadata)) + request_msg.metadata.CopyFrom(metadata) return SendMessageRequest(message=request_msg) async def consume_stream( @@ -245,40 +314,35 @@ async def consume_stream( on_intermediate: Optional[Callable[[str, Dict[str, Any]], None]] = None, agent_name: str = "", ): - """Iterate over streaming responses, extract text/task/state/metadata. - - Optionally forwards intermediate events (status updates, artifact - updates, message events) through ``on_intermediate`` when provided - by the calling facade (the workflow facade wires it to its - EventCallback). Merges task-level AND artifact-level metadata into - the result so extension payloads on artifacts reach the extension - handlers. - """ + """Reduce a response stream to its final task and structured evidence.""" response_text = None last_task_result = None last_metadata_dict: Dict[str, Any] = {} task_state = "" + standalone_messages: Dict[str, ReceivedMessage] = {} + task_artifacts: Dict[str, ReceivedArtifact] = {} + task_status_message = None async for response in client.send_message(send_req): + self.log_response_event(agent_name, response) has_task = response.HasField("task") has_message = response.HasField("message") + has_status = response.HasField("status_update") + has_artifact = response.HasField("artifact_update") if has_task: task = response.task state = self._extract_task_state(task) logger.info(f"[Transport] Received StreamResponse with task: state={state or None}") - try: - task_json = MessageToJson(task, ensure_ascii=False, indent=2) - except Exception as _e: - logger.warning(f"[Transport] MessageToJson task failed: {type(_e).__name__}: {_e}") - task_json = str(task) - log_response(agent_name, "Task", task_json) last_task_result = task response_text = self._extract_task_text(task, response_text) task_state = state or task_state last_metadata_dict = self._merge_task_metadata(task, last_metadata_dict) - if response_text is None: - response_text = self._text_from_metadata(last_metadata_dict) + task_status_message = self._message_content(task.status.message) + task_artifacts = { + artifact.artifact_id: self._received_artifact(artifact) + for artifact in task.artifacts + } if on_intermediate is not None: is_final = task_state in ( "TASK_STATE_COMPLETED", "TASK_STATE_FAILED", @@ -319,14 +383,11 @@ async def consume_stream( elif has_message: logger.info("[Transport] Received StreamResponse with message") msg = response.message - try: - msg_json = MessageToJson(msg, ensure_ascii=False, indent=2) - except Exception as _e: - logger.warning(f"[Transport] MessageToJson msg failed: {type(_e).__name__}: {_e}") - msg_json = str(msg) - log_response(agent_name, "Message", msg_json) msg_text = self._extract_message_text(msg, None) response_text = self._extract_message_text(msg, response_text) + standalone_messages[msg.message_id] = ReceivedMessage( + message=self._message_content(msg) + ) msg_role = "" try: msg_role = type(msg).Role.Name(msg.role) @@ -350,114 +411,178 @@ async def consume_stream( "metadata": msg_meta, }) - return response_text, last_task_result, last_metadata_dict, task_state - - async def consume_notification_stream( - self, client, send_req, - event_callback: Optional[Callable[[Dict[str, Any]], None]] = None, - agent_name: str = "", - ) -> "asyncio.Task": - """Open a long-lived SSE stream for Notification-T subscription. - - Returns an ``asyncio.Task`` that keeps the stream alive. The task - completes when the stream closes or is cancelled. Each SSE event - is forwarded to ``event_callback`` (if provided) as a dict with - keys: ``agent``, ``type``, ``state``, ``text``, ``metadata``, etc. - - The first event (subscription confirmation) is also forwarded. - Unlike ``consume_stream``, this method does NOT return a result -- - the stream stays open and events flow asynchronously. - """ - async def _consume(): - try: - logger.info(f"[Transport] Opening Notification-T long-lived stream to {agent_name}") - async for response in client.send_message(send_req): - has_task = response.HasField("task") - has_message = response.HasField("message") - - event_data: Dict[str, Any] = {"agent": agent_name} - - if has_task: - task = response.task - state = self._extract_task_state(task) - logger.info(f"[Transport] Notification-T event from {agent_name}: state={state or None}") - event_data["type"] = "task_update" - event_data["state"] = state or "" - is_final = state in ( + elif has_status: + update = response.status_update + task_state = TaskState.Name(update.status.state) if update.status.state else task_state + task_status_message = self._message_content(update.status.message) + if on_intermediate is not None: + on_intermediate(EventType.AGENT_STATUS_UPDATE, { + "agent": agent_name, + "state": task_state, + "is_final": task_state in ( "TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "TASK_STATE_REJECTED", - ) - event_data["is_final"] = is_final - text = self._extract_task_text(task, None) - if text: - event_data["text"] = text - md = self._extract_task_metadata(task) - if md: - event_data["metadata"] = md - for art in (task.artifacts or []): - art_text = "" - for part in (art.parts or []): - if part.text: - art_text += part.text - art_data = { - "artifact_id": getattr(art, "artifact_id", "") or "", - "artifact_name": getattr(art, "name", "") or "", - "text": art_text, - } - am = getattr(art, "metadata", None) - if am: - if isinstance(am, dict): - art_data["metadata"] = am - else: - try: - art_data["metadata"] = MessageToDict(am, preserving_proto_field_name=True) - except Exception: - pass - event_data.setdefault("artifacts", []).append(art_data) - - elif has_message: - logger.info(f"[Transport] Notification-T message event from {agent_name}") - msg = response.message - event_data["type"] = "message" - msg_text = self._extract_message_text(msg, None) - if msg_text: - event_data["text"] = msg_text - try: - event_data["role"] = type(msg).Role.Name(msg.role) - except Exception: - event_data["role"] = str(getattr(msg, "role", "")) - mm = getattr(msg, "metadata", None) - if mm: - if isinstance(mm, dict): - event_data["metadata"] = mm - else: - try: - event_data["metadata"] = MessageToDict(mm, preserving_proto_field_name=True) - except Exception: - pass + ), + "metadata": self._struct_dict(update.metadata), + }) - if event_callback and event_data.get("type"): - try: - event_callback(event_data) - except Exception as e: - logger.warning(f"[Transport] Notification-T callback error for {agent_name}: {e}") - - logger.info(f"[Transport] Notification-T stream closed for {agent_name}") - except asyncio.CancelledError: - logger.info(f"[Transport] Notification-T stream cancelled for {agent_name}") - except Exception as e: - msg = str(e) - if "connection closed" in msg.lower() or "reading_length" in msg.lower(): - logger.info(f"[Transport] Notification-T stream closed for {agent_name}") - else: - logger.warning(f"[Transport] Notification-T stream error for {agent_name}: {e}") - - return asyncio.create_task(_consume(), name=f"notif-t-{agent_name}") + elif has_artifact: + update = response.artifact_update + artifact = self._received_artifact(update.artifact) + previous = task_artifacts.get(artifact.artifact_id) + if getattr(update, "append", False) and previous is not None: + artifact = ReceivedArtifact( + artifact_id=artifact.artifact_id, + name=artifact.name or previous.name, + description=artifact.description or previous.description, + parts=previous.parts + artifact.parts, + metadata={**dict(previous.metadata), **dict(artifact.metadata)}, + extensions=artifact.extensions or previous.extensions, + ) + task_artifacts[artifact.artifact_id] = artifact + if on_intermediate is not None: + on_intermediate(EventType.AGENT_ARTIFACT_UPDATE, { + "agent": agent_name, + "artifact_id": artifact.artifact_id, + "artifact_name": artifact.name, + "append": getattr(update, "append", False), + "last_chunk": getattr(update, "last_chunk", False), + "metadata": dict(artifact.metadata), + }) + + received = list(standalone_messages.values()) + if last_task_result is not None or task_artifacts or task_status_message is not None: + received.append(ReceivedMessage( + message=task_status_message, + task_metadata=self._extract_task_metadata(last_task_result) + if last_task_result is not None else {}, + artifacts=tuple(task_artifacts.values()), + )) + failure_code, failure_message = self._failure_from_state( + task_state, task_status_message, + ) + return SendMessageResult( + text=response_text or "", + task=last_task_result, + metadata=last_metadata_dict, + task_state=task_state, + failure_code=failure_code, + failure_message=failure_message, + received_messages=tuple(received), + ) + + @staticmethod + def log_response_event(agent_name: str, response) -> None: + """Log one real A2A stream payload in its received protobuf shape.""" + fields = ( + ("task", "Task"), + ("message", "Message"), + ("status_update", "TaskStatusUpdateEvent"), + ("artifact_update", "TaskArtifactUpdateEvent"), + ) + for field, event_type in fields: + if not response.HasField(field): + continue + payload = getattr(response, field) + A2ATransport._log_response_payload(agent_name, event_type, payload) + return + + @classmethod + def parse_stream_event(cls, response) -> A2AStreamEvent: + """Convert one A2A SDK response to the engine's public stream model.""" + if response.HasField("task"): + task = response.task + state = cls._extract_task_state(task) + return A2AStreamEvent( + event_type="task", + task_id=task.id, + context_id=task.context_id, + task_state=state, + is_final=cls._is_terminal_state(state), + message=cls._message_content(task.status.message), + artifacts=tuple(cls._received_artifact(item) for item in task.artifacts), + metadata=cls._extract_task_metadata(task), + ) + if response.HasField("message"): + message = response.message + return A2AStreamEvent( + event_type="message", + task_id=message.task_id, + context_id=message.context_id, + message=cls._message_content(message), + metadata=cls._struct_dict(message.metadata), + ) + if response.HasField("status_update"): + update = response.status_update + state = ( + TaskState.Name(update.status.state) + if update.status.state else "" + ) + return A2AStreamEvent( + event_type="status_update", + task_id=update.task_id, + context_id=update.context_id, + task_state=state, + is_final=cls._is_terminal_state(state), + message=cls._message_content(update.status.message), + metadata=cls._struct_dict(update.metadata), + ) + if response.HasField("artifact_update"): + update = response.artifact_update + return A2AStreamEvent( + event_type="artifact_update", + task_id=update.task_id, + context_id=update.context_id, + artifacts=(cls._received_artifact(update.artifact),), + metadata=cls._struct_dict(update.metadata), + ) + raise ValueError("A2A stream response contains no supported payload") + + @staticmethod + def _log_response_payload(agent_name: str, event_type: str, payload) -> None: + try: + body = MessageToJson(payload, ensure_ascii=False, indent=2) + except (TypeError, ValueError) as exc: + logger.warning( + f"[Transport] MessageToJson {event_type} failed: " + f"{type(exc).__name__}: {exc}" + ) + body = str(payload) + log_response(agent_name, event_type, body) # ------------------------------------------------------------------ # Parsing helpers (static) # ------------------------------------------------------------------ + @staticmethod + def _struct_dict(value) -> Dict[str, Any]: + if not value: + return {} + if isinstance(value, dict): + return dict(value) + return MessageToDict(value, preserving_proto_field_name=True) + + @classmethod + def _message_content(cls, message) -> Optional[MessageContent]: + if message is None or not getattr(message, "parts", None): + return None + return MessageContent( + parts=tuple(message.parts), + metadata=cls._struct_dict(getattr(message, "metadata", None)), + extensions=frozenset(getattr(message, "extensions", ()) or ()), + ) + + @classmethod + def _received_artifact(cls, artifact) -> ReceivedArtifact: + return ReceivedArtifact( + artifact_id=getattr(artifact, "artifact_id", "") or "", + name=getattr(artifact, "name", "") or "", + description=getattr(artifact, "description", "") or "", + parts=tuple(getattr(artifact, "parts", ()) or ()), + metadata=cls._struct_dict(getattr(artifact, "metadata", None)), + extensions=tuple(getattr(artifact, "extensions", ()) or ()), + ) + @staticmethod def _merge_task_metadata(task, current: Dict[str, Any]) -> Dict[str, Any]: """Merge task-level AND each artifact's metadata into the result map.""" @@ -505,6 +630,30 @@ def _extract_task_state(task) -> str: except Exception: return str(task.status.state) + @staticmethod + def _is_terminal_state(state: str) -> bool: + return state in { + "TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED", + "TASK_STATE_REJECTED", + } + + @classmethod + def _failure_from_state(cls, state: str, message: Optional[MessageContent] = None): + codes = { + "TASK_STATE_FAILED": "a2a.task_failed", + "TASK_STATE_CANCELED": "a2a.task_canceled", + "TASK_STATE_REJECTED": "a2a.task_rejected", + } + code = codes.get(state) + if code is None: + return None, None + text = "" + if message is not None: + for part in message.parts: + if getattr(part, "text", ""): + text += part.text + return code, text or state + @staticmethod def _extract_task_metadata(task) -> Dict[str, Any]: if not task.metadata: @@ -544,12 +693,93 @@ def _get_extensions(agent_card) -> List[str]: uris.append(uri) return uris + def _client_for(self, agent_name: str): + card = self.get_card(agent_name) + if card is None: + raise RuntimeError(f"Agent not found: {agent_name}") + return self.create_a2a_client(card) + + @classmethod + def _result_from_task(cls, task) -> SendMessageResult: + state = cls._extract_task_state(task) + status_message = cls._message_content(task.status.message) + failure_code, failure_message = cls._failure_from_state(state, status_message) + received = ReceivedMessage( + message=status_message, + task_metadata=cls._extract_task_metadata(task), + artifacts=tuple(cls._received_artifact(artifact) for artifact in task.artifacts), + ) + return SendMessageResult( + text=cls._extract_task_text(task, None) or "", + task=task, + metadata=cls._extract_task_metadata(task), + task_state=state, + failure_code=failure_code, + failure_message=failure_message, + received_messages=(received,), + ) + + async def get_task(self, agent_name: str, task_id: str) -> SendMessageResult: + task = await self._client_for(agent_name).get_task(GetTaskRequest(id=task_id)) + self._log_response_payload(agent_name, "Task", task) + return self._result_from_task(task) + + async def list_tasks(self, agent_name: str, request=None): + response = await self._client_for(agent_name).list_tasks( + request or ListTasksRequest() + ) + self._log_response_payload(agent_name, "ListTasksResponse", response) + return response + + async def cancel_task(self, agent_name: str, task_id: str) -> SendMessageResult: + task = await self._client_for(agent_name).cancel_task(CancelTaskRequest(id=task_id)) + self._log_response_payload(agent_name, "Task", task) + return self._result_from_task(task) + + async def subscribe_to_task( + self, + agent_name: str, + task_id: str, + event_callback: Optional[Callable[[Dict[str, Any]], None]] = None, + ) -> SendMessageResult: + client = self._client_for(agent_name) + response_text = None + task = None + metadata: Dict[str, Any] = {} + state = "" + received: list[ReceivedMessage] = [] + async for response in client.subscribe(SubscribeToTaskRequest(id=task_id)): + self.log_response_event(agent_name, response) + if response.HasField("task"): + task = response.task + state = self._extract_task_state(task) + response_text = self._extract_task_text(task, response_text) + metadata = self._merge_task_metadata(task, metadata) + received = [self._result_from_task(task).received_messages[0]] + event = {"agent": agent_name, "type": "task", "state": state} + elif response.HasField("message"): + message = response.message + response_text = self._extract_message_text(message, response_text) + received.append(ReceivedMessage(message=self._message_content(message))) + event = {"agent": agent_name, "type": "message", "text": response_text or ""} + else: + event = {"agent": agent_name, "type": "update"} + if event_callback is not None: + event_callback(event) + return SendMessageResult( + text=response_text or "", task=task, metadata=metadata, + task_state=state, received_messages=tuple(received), + ) + # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ async def close(self): - if self._httpx_client: + if self._closed: + return + self._closed = True + if self._httpx_client and self._owns_httpx_client: logger.info("[Transport] Closing httpx client") await self._httpx_client.aclose() @@ -557,4 +787,4 @@ async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): - await self.close() \ No newline at end of file + await self.close() diff --git a/workflow_engine/client/a2at_messages.py b/workflow_engine/client/a2at_messages.py new file mode 100644 index 0000000..ce7db3c --- /dev/null +++ b/workflow_engine/client/a2at_messages.py @@ -0,0 +1,92 @@ +# Copyright (c) 2026 Huawei Technologies Co., Ltd. +# All Rights Reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Bridge current A2A-T SDK metadata into final engine message content.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from numbers import Real +from typing import Any + +from a2a_t.core import ( + NEGOTIATION_CONTEXT_METADATA_KEY, + MetadataContent, + NegotiationContext, + NegotiationPerformative, +) + +from workflow_engine.core.models import MessageContent, ReceivedMessage + + +class A2atMessages: + """Thin current-SDK conversion; generation and validation stay in host code.""" + + @staticmethod + def from_generated(generated: MetadataContent, parts: Sequence[Any]) -> MessageContent: + """Preserve SDK metadata and activate the one extension it generated.""" + if not isinstance(generated, MetadataContent): + raise TypeError("generated must be a2a_t.core.MetadataContent") + if not generated.extension_uri or not generated.extension_uri.strip(): + raise ValueError("generated extension_uri must not be blank") + return MessageContent( + tuple(parts), + generated.build_metadata_content(), + frozenset({generated.extension_uri}), + ) + + @staticmethod + def negotiation_context(received: ReceivedMessage) -> NegotiationContext: + """Extract one unambiguous canonical negotiation context.""" + if not isinstance(received, ReceivedMessage): + raise TypeError("received must be ReceivedMessage") + layers = [] + if received.message is not None: + layers.append(received.message.metadata) + layers.append(received.task_metadata) + layers.extend(artifact.metadata for artifact in received.artifacts) + context = None + for metadata in layers: + if NEGOTIATION_CONTEXT_METADATA_KEY not in metadata: + continue + candidate = A2atMessages.context_from_metadata(metadata) + if context is not None and context != candidate: + raise ValueError("Conflicting negotiation contexts in response") + context = candidate + if context is None: + raise ValueError("Missing negotiationContext") + return context + + @staticmethod + def context_from_metadata(metadata: Mapping[str, Any]) -> NegotiationContext: + """Parse current 1.1 wire fields without accepting legacy context shapes.""" + raw = metadata.get(NEGOTIATION_CONTEXT_METADATA_KEY) + if not isinstance(raw, Mapping): + raise ValueError("Invalid negotiationContext") + negotiation_id = raw.get("id") + action = raw.get("performative") + if not isinstance(negotiation_id, str) or not isinstance(action, str): + raise ValueError("Invalid negotiationContext") + performative = NegotiationPerformative.try_parse(action) + if performative is None: + raise ValueError("Invalid negotiation performative") + return NegotiationContext( + negotiation_id, + A2atMessages._integer(raw.get("round")), + A2atMessages._integer(raw.get("maxRounds")), + performative, + ) + + @staticmethod + def _integer(value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, Real): + raise ValueError("Negotiation round must be an integer") + try: + integer = int(value) + except (OverflowError, ValueError) as exc: + raise ValueError("Negotiation round must be an integer") from exc + if value != integer: + raise ValueError("Negotiation round must be an integer") + return integer diff --git a/workflow_engine/client/agentcard_normalizer.py b/workflow_engine/client/agentcard_normalizer.py index d085caa..9bcbcc0 100644 --- a/workflow_engine/client/agentcard_normalizer.py +++ b/workflow_engine/client/agentcard_normalizer.py @@ -28,7 +28,6 @@ """ from typing import Any, Dict, List -from loguru import logger def _normalize_security_schemes(sec_schemes: Any) -> Dict[str, Any]: @@ -99,8 +98,4 @@ def normalize_agent_dict(agent_dict: Dict[str, Any]) -> Dict[str, Any]: result["securitySchemes"] = _normalize_security_schemes(result["securitySchemes"]) if "securityRequirements" in result: result["securityRequirements"] = _normalize_security_requirements(result["securityRequirements"]) - if result.get("securitySchemes") and not result.get("securityRequirements"): - scheme_names = list(result["securitySchemes"].keys()) - result["securityRequirements"] = [{"schemes": {s: {} for s in scheme_names}}] - logger.info(f"Auto-populated securityRequirements from securitySchemes: {scheme_names}") return result diff --git a/workflow_engine/client/auth_manager.py b/workflow_engine/client/auth_manager.py index b3ce4d3..df2cc13 100644 --- a/workflow_engine/client/auth_manager.py +++ b/workflow_engine/client/auth_manager.py @@ -21,12 +21,10 @@ from loguru import logger try: - from a2a.client.auth import AuthInterceptor from a2a.client.interceptors import ClientCallInterceptor, BeforeArgs, AfterArgs _A2A_AUTH_AVAILABLE = True except ImportError: _A2A_AUTH_AVAILABLE = False - AuthInterceptor = None ClientCallInterceptor = None BeforeArgs = None AfterArgs = None @@ -49,11 +47,13 @@ def __init__(self, agent_cards: List[Any], credentials_config: Optional[str | Di return logger.info(f"[AuthManager] Initializing with {len(agent_cards)} agent card(s), config={credentials_config is not None}") - if credentials_config: + if credentials_config is not None: if isinstance(credentials_config, str): self._auth_manager = AgentAuthManager(config_path=credentials_config) elif isinstance(credentials_config, dict): self._auth_manager = AgentAuthManager(config=credentials_config) + else: + raise TypeError("credentials_config must be a file path or mapping") else: self._auth_manager = AgentAuthManager() @@ -77,11 +77,7 @@ def _build_interceptors(self, agent_cards: List[Any]): if cred_svc is not None: logger.info(f"[AuthManager] Agent {card.name}: credentials found") agent_cfg = self._auth_manager.get_config(card.name) or {} - if any(isinstance(v, dict) and (v.get("auth_header") or v.get("accept_header")) - for v in agent_cfg.values()): - interceptors.append(CustomAuthInterceptor(cred_svc, agent_cfg)) - else: - interceptors.append(AuthInterceptor(cred_svc)) + interceptors.append(CustomAuthInterceptor(cred_svc, agent_cfg)) logger.info(f"[AuthManager] Agent {card.name}: configured with {type(interceptors[0]).__name__}") if getattr(card, "capabilities", None) and card.capabilities.extensions: ext_uris = [ext.uri for ext in card.capabilities.extensions if ext.uri] @@ -93,6 +89,17 @@ def _build_interceptors(self, agent_cards: List[Any]): def get_interceptors(self, agent_name: str) -> List[Any]: return self._interceptors.get(agent_name, []) + def update_agent_cards(self, agent_cards: List[Any]) -> None: + """Rebuild card-derived interceptors while retaining credential state.""" + self._interceptors.clear() + self._build_interceptors(agent_cards) + + def has_credentials(self, agent_name: str) -> bool: + return bool( + self._auth_manager + and self._auth_manager.get_config(agent_name) + ) + def set_httpx_client(self, client): if self._auth_manager: self._auth_manager.set_httpx_client(client) @@ -110,16 +117,19 @@ def __init__(self, auth_provider: AuthProvider, agent_name: str): async def before(self, args: "BeforeArgs") -> None: agent_card = args.agent_card headers: Dict[str, str] = {} - try: - self._auth_provider.apply_auth(self._agent_name, agent_card, headers) - except Exception as e: - logger.warning(f"[AuthProvider] apply_auth raised: {e}") + self._auth_provider.apply_auth(self._agent_name, agent_card, headers) if args.context is None: from a2a.client.client import ClientCallContext args.context = ClientCallContext() if args.context.service_parameters is None: args.context.service_parameters = {} - args.context.service_parameters.update(headers) + for name, value in headers.items(): + existing = args.context.service_parameters.get(name) + if existing is not None and existing != value: + raise RuntimeError( + f"Authentication header conflict for agent {self._agent_name}: {name}" + ) + args.context.service_parameters[name] = value if headers: logger.info(f"[AuthProvider] Injected {len(headers)} header(s) for {self._agent_name}") diff --git a/workflow_engine/client/auth_provider.py b/workflow_engine/client/auth_provider.py index f3ae68b..42b6f40 100644 --- a/workflow_engine/client/auth_provider.py +++ b/workflow_engine/client/auth_provider.py @@ -21,11 +21,12 @@ credentials file or the AgentCard's security schemes (e.g. corporate SSO, non-standard auth). Mirrors the Java SDK's ``AuthProvider`` interface. -Register via ``WorkflowEngineClient(..., auth_provider=my_provider)``. +Register via ``A2ATransport(..., auth_provider=my_provider)`` or +``execute_psop(..., auth_provider=my_provider)``. The provider is called for every message send, regardless of whether the AgentCard declares security schemes. If both a credentials config and a -custom AuthProvider are configured, both run (custom provider first, -credentials-based auth second). +custom AuthProvider are configured, both are evaluated and different values +for the same header are rejected. """ from abc import ABC, abstractmethod diff --git a/workflow_engine/client/credential_crypto.py b/workflow_engine/client/credential_crypto.py index 660f2bd..24dfffe 100644 --- a/workflow_engine/client/credential_crypto.py +++ b/workflow_engine/client/credential_crypto.py @@ -33,8 +33,6 @@ import secrets from typing import Optional -from loguru import logger - _ENV_KEY = "A2AT_CRED_KEY" _PREFIX = "enc:" _IV_LENGTH = 12 # 96-bit IV for GCM @@ -58,27 +56,28 @@ def decrypt_if_needed(value: Optional[str]) -> Optional[str]: return value key_hex = _resolve_key() if not key_hex or not key_hex.strip(): - logger.warning( - f"[CredentialCrypto] Encrypted value found but {_ENV_KEY} not set, using as-is" + raise RuntimeError( + f"Encrypted credential found but {_ENV_KEY} is not configured" ) - return value try: encoded = value[len(_PREFIX):] parts = encoded.split(":", 1) if len(parts) != 2: - logger.error("[CredentialCrypto] Invalid encrypted format, expected enc::") - return value + raise ValueError( + "Invalid encrypted credential format; expected enc::" + ) iv = base64.b64decode(parts[0]) ciphertext_and_tag = base64.b64decode(parts[1]) - key_bytes = bytes.fromhex(key_hex) + key_bytes = _decode_key(key_hex) ciphertext = ciphertext_and_tag[:-_TAG_LENGTH] tag = ciphertext_and_tag[-_TAG_LENGTH:] from cryptography.hazmat.primitives.ciphers.aead import AESGCM plaintext = AESGCM(key_bytes).decrypt(iv, ciphertext + tag, None) return plaintext.decode("utf-8") except Exception as e: - logger.error(f"[CredentialCrypto] Decryption failed: {e}") - return value + if isinstance(e, (RuntimeError, ValueError)): + raise + raise RuntimeError("Credential decryption failed") from e def encrypt(plaintext: str) -> str: @@ -90,7 +89,7 @@ def encrypt(plaintext: str) -> str: key_hex = _resolve_key() if not key_hex or not key_hex.strip(): raise RuntimeError(f"{_ENV_KEY} environment variable not set") - key_bytes = bytes.fromhex(key_hex) + key_bytes = _decode_key(key_hex) iv = secrets.token_bytes(_IV_LENGTH) from cryptography.hazmat.primitives.ciphers.aead import AESGCM ciphertext_and_tag = AESGCM(key_bytes).encrypt(iv, plaintext.encode("utf-8"), None) @@ -100,3 +99,16 @@ def encrypt(plaintext: str) -> str: + ":" + base64.b64encode(ciphertext_and_tag).decode("ascii") ) + + +def _decode_key(key_hex: str) -> bytes: + """Decode and validate the required 256-bit AES key.""" + normalized = key_hex.strip() + if len(normalized) != 64: + raise ValueError( + "A2AT_CRED_KEY must contain exactly 64 hexadecimal characters (32 bytes)" + ) + try: + return bytes.fromhex(normalized) + except ValueError as exc: + raise ValueError("A2AT_CRED_KEY contains non-hexadecimal characters") from exc diff --git a/workflow_engine/client/credential_service.py b/workflow_engine/client/credential_service.py index 10d36bb..e3d1cbd 100644 --- a/workflow_engine/client/credential_service.py +++ b/workflow_engine/client/credential_service.py @@ -22,14 +22,28 @@ """ import json +import copy import time from pathlib import Path from typing import Dict, Optional +from urllib.parse import urlsplit, urlunsplit import httpx from loguru import logger from workflow_engine.client.credential_crypto import decrypt_if_needed + +def _safe_url(value: str) -> str: + try: + parsed = urlsplit(value) + host = parsed.hostname or "" + if ":" in host and not host.startswith("["): + host = f"[{host}]" + netloc = f"{host}:{parsed.port}" if parsed.port is not None else host + return urlunsplit((parsed.scheme, netloc, parsed.path, "", "")) + except (TypeError, ValueError): + return "" + try: from a2a.client.auth import CredentialService from a2a.client.auth import InMemoryContextCredentialStore @@ -86,7 +100,7 @@ async def get_credentials(self, security_scheme_name: str, context=None) -> Opti async def _login(self, scheme_cfg: dict) -> Optional[str]: login_url = scheme_cfg.get("login_url") if not login_url: - return None + raise ValueError(f"Authentication login_url is required for {self._agent_name}") method = scheme_cfg.get("method", "POST").upper() content_type = scheme_cfg.get("content_type", "application/json") token_field = scheme_cfg.get("token_field", "accessSession") @@ -98,12 +112,21 @@ async def _login(self, scheme_cfg: dict) -> Optional[str]: username = scheme_cfg.get("username") password = decrypt_if_needed(scheme_cfg.get("password")) if not username or not password: - return None + raise ValueError( + f"Authentication username and password are required for {self._agent_name}" + ) body = {scheme_cfg.get("username_field","username"): username, scheme_cfg.get("password_field","password"): password} - client = self._httpx_client or httpx.AsyncClient(timeout=httpx.Timeout(connect=30, read=30, write=30, pool=5.0), verify=False) + client = self._httpx_client or httpx.AsyncClient( + timeout=httpx.Timeout(connect=30, read=30, write=30, pool=5.0), + verify=True, + follow_redirects=False, + ) own_client = self._httpx_client is None try: - logger.info(f"[Auth] Login attempt: agent={self._agent_name}, method={method}, url={login_url}, content_type={content_type}, params={_sanitize_body(body)}") + logger.info( + f"[Auth] LOGIN_START agent={self._agent_name}, method={method}, " + f"url={_safe_url(login_url)}, content_type={content_type}, param_names={list(body)}" + ) req_kwargs = {"method": method, "url": login_url} if content_type == "application/x-www-form-urlencoded": req_kwargs["data"] = body @@ -115,10 +138,24 @@ async def _login(self, scheme_cfg: dict) -> Optional[str]: token = self._extract_nested_value(data, token_field) if isinstance(data, dict) else None if not token and isinstance(data, dict): token = data.get("accessSession") or data.get("access_session") or data.get("access_token") or data.get("token") + if not isinstance(token, str) or not token.strip(): + raise ValueError( + f"Authentication response has no nonblank token for {self._agent_name}" + ) return token - except Exception as e: - logger.error(f"[Auth] Login failed: agent={self._agent_name}, url={login_url}, error={e}") - return None + except Exception as exc: + status = ( + exc.response.status_code + if isinstance(exc, httpx.HTTPStatusError) else None + ) + logger.error( + f"[Auth] Login failed: agent={self._agent_name}, " + f"url={_safe_url(login_url)}, error_type={type(exc).__name__}, " + f"status={status}" + ) + raise RuntimeError( + f"Authentication login failed for agent {self._agent_name}" + ) from exc finally: if own_client: await client.aclose() @@ -143,21 +180,80 @@ class AgentAuthManager: def __init__(self, config: Optional[Dict[str, dict]] = None, config_path: Optional[str] = None): self._config: Dict[str, dict] = {} self._services: Dict[str, AgentCredentialService] = {} - if config: - self._config = config + self._httpx_client: Optional[httpx.AsyncClient] = None + if config is not None: + self._config = self._resolve_config(copy.deepcopy(config)) elif config_path: self._load_from_file(config_path) + self._validate_encrypted_credentials(self._config) def _load_from_file(self, path: str): p = Path(path) - if not p.exists(): - return + if not p.is_file(): + raise FileNotFoundError(f"Credentials file not found: {path}") try: with open(p, "r", encoding="utf-8") as f: - self._config = json.load(f) + loaded = json.load(f) + if not isinstance(loaded, dict): + raise ValueError("Credentials root must be an object") + self._config = self._resolve_config(loaded) logger.info(f"[Auth] Loaded credentials for {len(self._config)} agent(s): {list(self._config.keys())}") - except Exception as e: - logger.warning(f"[Auth] Failed to load credentials: {e}") + except (OSError, ValueError, TypeError) as exc: + raise ValueError(f"Failed to load credentials from {path}: {exc}") from exc + + @classmethod + def _resolve_config(cls, root: Dict[str, dict]) -> Dict[str, dict]: + if "profiles" not in root and "agents" not in root: + return root + profiles = root.get("profiles") + agents = root.get("agents") + if not isinstance(profiles, dict) or not isinstance(agents, dict): + raise ValueError("Credential profile form requires object fields: profiles and agents") + resolved = {} + for agent_name, binding in agents.items(): + if not isinstance(binding, dict): + raise ValueError(f"Credential binding for {agent_name} must be an object") + profile_name = binding.get("profile") + if not isinstance(profile_name, str) or not profile_name.strip(): + raise ValueError(f"Credential profile for {agent_name} must not be blank") + profile = profiles.get(profile_name) + if not isinstance(profile, dict): + raise ValueError( + f"Unknown credential profile '{profile_name}' for agent {agent_name}" + ) + agent_config = copy.deepcopy(profile) + overrides = binding.get("overrides", {}) + if not isinstance(overrides, dict): + raise ValueError(f"Credential overrides for {agent_name} must be an object") + for scheme_name, values in overrides.items(): + if not isinstance(values, dict): + raise ValueError( + f"Credential override {agent_name}.{scheme_name} must be an object" + ) + target = agent_config.setdefault(scheme_name, {}) + if not isinstance(target, dict): + raise ValueError( + f"Credential scheme {profile_name}.{scheme_name} must be an object" + ) + cls._merge_mapping(target, values) + resolved[str(agent_name)] = agent_config + return resolved + + @classmethod + def _merge_mapping(cls, target: dict, overrides: dict) -> None: + for key, value in overrides.items(): + if isinstance(target.get(key), dict) and isinstance(value, dict): + cls._merge_mapping(target[key], value) + else: + target[key] = copy.deepcopy(value) + + @classmethod + def _validate_encrypted_credentials(cls, value) -> None: + if isinstance(value, dict): + for nested in value.values(): + cls._validate_encrypted_credentials(nested) + elif isinstance(value, str) and value.startswith("enc:"): + decrypt_if_needed(value) def get_service(self, agent_name: str) -> Optional[AgentCredentialService]: if agent_name in self._services: @@ -165,7 +261,9 @@ def get_service(self, agent_name: str) -> Optional[AgentCredentialService]: agent_creds = self._config.get(agent_name) if not agent_creds: return None - service = AgentCredentialService(agent_name, agent_creds) + service = AgentCredentialService( + agent_name, agent_creds, httpx_client=self._httpx_client + ) self._services[agent_name] = service logger.info(f"[Auth] Created credential service for agent: {agent_name}") return service @@ -174,12 +272,13 @@ def get_config(self, agent_name: str) -> Optional[Dict[str, dict]]: return self._config.get(agent_name) def set_httpx_client(self, client: httpx.AsyncClient): + self._httpx_client = client for svc in self._services.values(): svc.set_httpx_client(client) class CustomAuthInterceptor(ClientCallInterceptor if _A2A_AVAILABLE else object): - """Auth interceptor supporting custom header names.""" + """Auth interceptor supporting A2A requirement groups and custom headers.""" def __init__(self, credential_service: AgentCredentialService, scheme_configs: Dict[str, dict]): self._credential_service = credential_service @@ -189,41 +288,73 @@ async def before(self, args: BeforeArgs) -> None: agent_card = args.agent_card if not agent_card.security_requirements or not agent_card.security_schemes: return + failures = [] for requirement in agent_card.security_requirements: + candidate = {} + failure = None for scheme_name in requirement.schemes: scheme_cfg = self._scheme_configs.get(scheme_name, {}) - credential = await self._credential_service.get_credentials(scheme_name, args.context) + if scheme_name not in agent_card.security_schemes: + failure = f"scheme {scheme_name} is not declared by the AgentCard" + break + if not scheme_cfg: + failure = f"no configuration for scheme {scheme_name}" + break + credential = await self._credential_service.get_credentials( + scheme_name, args.context + ) if not credential: - continue - if args.context is None: - from a2a.client.client import ClientCallContext - args.context = ClientCallContext() - if args.context.service_parameters is None: - args.context.service_parameters = {} + failure = f"no credential for scheme {scheme_name}" + break auth_header = scheme_cfg.get("auth_header") if auth_header: prefix = scheme_cfg.get("auth_header_prefix", "") - args.context.service_parameters[auth_header] = f"{prefix}{credential}" - logger.info(f"[CustomAuth] Set header {auth_header} for scheme {scheme_name}") + name, value = auth_header, f"{prefix}{credential}" else: - args.context.service_parameters["Authorization"] = f"Bearer {credential}" - logger.info(f"[CustomAuth] Set Bearer header for scheme {scheme_name}") + scheme = agent_card.security_schemes[scheme_name] + if ( + scheme.HasField("api_key_security_scheme") + and scheme.api_key_security_scheme.location.lower() == "header" + ): + name = scheme.api_key_security_scheme.name + value = credential + else: + name, value = "Authorization", f"Bearer {credential}" + if name in candidate and candidate[name] != value: + failure = f"conflicting values for header {name}" + break + candidate[name] = value accept_header = scheme_cfg.get("accept_header") if accept_header: - args.context.service_parameters["Accept"] = accept_header - logger.info(f"[CustomAuth] Override Accept header to {accept_header} for agent {getattr(args.agent_card, chr(39)+chr(110)+chr(97)+chr(109)+chr(101)+chr(39), chr(63))}") - return + if "Accept" in candidate and candidate["Accept"] != accept_header: + failure = "conflicting values for header Accept" + break + candidate["Accept"] = accept_header + if failure is not None: + failures.append(failure) + continue + if args.context is None: + from a2a.client.client import ClientCallContext + args.context = ClientCallContext() + if args.context.service_parameters is None: + args.context.service_parameters = {} + for name, value in candidate.items(): + existing = args.context.service_parameters.get(name) + if existing is not None and existing != value: + raise RuntimeError( + f"Authentication header conflict for agent " + f"{getattr(agent_card, 'name', '?')}: {name}" + ) + args.context.service_parameters[name] = value + logger.info( + f"[CustomAuth] Applied {len(candidate)} authentication header(s) " + f"for agent {getattr(agent_card, 'name', '?')}" + ) + return + raise RuntimeError( + f"Authentication requirements are not satisfied for agent " + f"{getattr(agent_card, 'name', '?')}: {'; '.join(failures)}" + ) async def after(self, args: AfterArgs) -> None: pass - - -def _sanitize_body(body: dict) -> dict: - """Mask sensitive fields (password, value, accessSession) for safe logging.""" - sanitized = {} - for k, v in body.items(): - if k.lower() in ("password", "value", "accesssession"): - sanitized[k] = "***" - else: - sanitized[k] = v - return sanitized diff --git a/workflow_engine/client/engine_client.py b/workflow_engine/client/engine_client.py index 281d76d..8dcc81e 100644 --- a/workflow_engine/client/engine_client.py +++ b/workflow_engine/client/engine_client.py @@ -1,127 +1,95 @@ -# Copyright (c) 2026 Huawei Technologies Co., Ltd. +# 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. -"""WorkflowEngineClient -- workflow-execution facade over A2ATransport. +"""Workflow task interaction facade over :class:`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 (httpx, auth, SSE consumer) delegates to -:class:`A2ATransport`. +from __future__ import annotations -One-shot pre-positioning sends (Authorization-T / Notification-T) are -a separate concern and live on :class:`ExtensionSender` -- callers -that only need pre-positioning hold that lighter facade instead. -""" - -import uuid import asyncio import time -from typing import Dict, Any, List, Optional, Callable, Union, Awaitable -from loguru import logger +import uuid +from typing import Any, AsyncIterator, Dict, List, Optional +from a2a_t.core import NegotiationContext, NegotiationPerformative import httpx - -try: - from a2a.types import Task - _A2A_AVAILABLE = True -except ImportError: - _A2A_AVAILABLE = False +from loguru import logger from workflow_engine.client.a2a_transport import A2ATransport -from workflow_engine.client.auth_manager import AuthManager -from workflow_engine.client.extension_handlers import ExtensionRegistry, ExtensionHandler -from workflow_engine.client.protocol_logger import log_request, log_response -from workflow_engine.control.control_points import ( - EventCallback, EventType, -) -from workflow_engine.core.models import SendMessageResult +from workflow_engine.client.a2at_messages import A2atMessages from workflow_engine.client.extensions import A2ATExtension - -# Type alias for the negotiation resolver callback. May be sync (returning -# str/None) or async (returning an awaitable of str/None). The SDK awaits -# coroutine results automatically, so an `async def` resolver is supported. -NegotiationResolver = Union[ - Callable[[str, str, Dict[str, Any]], Optional[str]], - Callable[[str, str, Dict[str, Any]], Awaitable[Optional[str]]], -] +from workflow_engine.control.control_points import EventCallback, EventType +from workflow_engine.core.models import ( + A2AStreamEvent, BusinessFailure, MessageContent, NegotiationExchange, NegotiationRequest, + NegotiationSend, NegotiationStop, ReceivedMessage, SendMessageResult, TaskRequest, +) class WorkflowEngineClient: - """Workflow-execution send facade built on a shared :class:`A2ATransport`.""" + """Own task/context correlation, remote waiting, and Negotiation-T exchanges.""" def __init__( self, transport: A2ATransport, - custom_handlers: Optional[List[ExtensionHandler]] = None, event_callback: Optional[EventCallback] = None, - max_negotiation_rounds: int = 3, + max_negotiation_exchanges: int = 3, + close_transport_on_close: bool = False, ): + if max_negotiation_exchanges < 1: + raise ValueError("Negotiation exchange budget must be positive") self._transport = transport - self._extension_registry = ExtensionRegistry() - if custom_handlers: - for h in custom_handlers: - self._extension_registry.register(h) + self._event_callback = event_callback or EventCallback() self._control_point = None - self._event_callback = event_callback - self._max_negotiation_rounds = max_negotiation_rounds - logger.info( - f"[EngineClient] Initialized over transport " - f"({len(self._transport.agent_names)} agent(s)), " - f"max_neg={max_negotiation_rounds}" - ) + self._max_negotiation_exchanges = max_negotiation_exchanges + self._close_transport_on_close = close_transport_on_close + self._closed = False + self._active_execution_id: Optional[str] = None - # ------------------------------------------------------------------ - # Wiring - # ------------------------------------------------------------------ + @classmethod + def owning(cls, transport: A2ATransport, **kwargs) -> "WorkflowEngineClient": + return cls(transport, close_transport_on_close=True, **kwargs) - def set_control_point(self, control_point): + @property + def callback_timeout_seconds(self) -> int: + return self._transport.send_timeout_seconds + + def begin_execution(self, execution_id: str, control_point, event_callback) -> None: + """Bind this client to one active workflow execution.""" + if self._closed: + raise RuntimeError("Workflow client closed") + if not execution_id: + raise ValueError("execution_id is required") + if self._active_execution_id is not None: + raise RuntimeError( + "WorkflowEngineClient is already bound to execution " + f"{self._active_execution_id}; concurrent reuse is not supported" + ) + self._active_execution_id = execution_id self._control_point = control_point + self._event_callback = event_callback or EventCallback() - def set_event_callback(self, callback): - """Attach an EventCallback so send_message emits agent_request/response.""" - self._event_callback = callback + def end_execution(self, execution_id: str) -> None: + if self._active_execution_id != execution_id: + return + self._active_execution_id = None + self._control_point = None + self._event_callback = EventCallback() - def _emit(self, event_type: str, data: Dict[str, Any]): - if self._event_callback: - self._event_callback.on_event(event_type, data) + def set_control_point(self, control_point) -> None: + self._control_point = control_point - def _forward_intermediate_event(self, event_type: str, data: Dict[str, Any]): - """Forward intermediate SSE events with structured logging (mirrors Java forwardIntermediateEvent).""" - agent = data.get("agent", "?") - if event_type == EventType.AGENT_STATUS_UPDATE: - state = data.get("state", "") - is_final = data.get("is_final", False) - logger.info(f"[EngineClient] Agent {agent} status update: {state} (final={is_final})") - elif event_type == EventType.AGENT_ARTIFACT_UPDATE: - art_name = data.get("artifact_name", "") - art_id = data.get("artifact_id", "") - logger.info(f"[EngineClient] Agent {agent} artifact update: {art_name} ({art_id})") - elif event_type == EventType.AGENT_MESSAGE_EVENT: - text = data.get("text", "") - logger.info(f"[EngineClient] Agent {agent} message event: {len(text)} chars") - self._emit(event_type, data) + def set_event_callback(self, callback) -> None: + self._event_callback = callback or EventCallback() - def register_handler(self, handler: ExtensionHandler): - self._extension_registry.register(handler) + def _emit(self, event_type: str, data: Dict[str, Any]) -> None: + try: + self._event_callback.on_event(event_type, data) + except Exception as exc: + logger.warning(f"Event callback failed for {event_type}: {exc}") - # ------------------------------------------------------------------ - # Delegated transport accessors (for convenience) - # ------------------------------------------------------------------ + def _forward_intermediate_event(self, event_type: str, data: Dict[str, Any]) -> None: + self._emit(event_type, data) @property def agent_names(self) -> List[str]: @@ -131,244 +99,361 @@ def agent_names(self) -> List[str]: def httpx_client(self) -> httpx.AsyncClient: return self._transport.httpx_client - def get_a2at_client(self): - return self._transport.get_a2at_client() - def get_card(self, agent_name: str): return self._transport.get_card(agent_name) - def update_agent_cards(self, agent_cards: List[Any]): + def update_agent_cards(self, agent_cards: List[Any]) -> None: self._transport.update_agent_cards(agent_cards) - async def close(self): - await self._transport.close() - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - await self.close() - - # ------------------------------------------------------------------ - # Workflow send path - # ------------------------------------------------------------------ + async def send_message(self, agent_name: str, content: MessageContent) -> SendMessageResult: + request = TaskRequest( + execution_id=str(uuid.uuid4()), task_id=str(uuid.uuid4()), + input=self._default_input(), agent_name=agent_name, skill="", + instruction="", step_name="", + ) + return await self.dispatch(request, content, self._control_point) - async def send_message( + async def stream_message( self, agent_name: str, - message: str, + content: MessageContent, context_id: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - skip_extensions: bool = False, - ) -> SendMessageResult: - t_total = time.time() - agent_card = self._transport.get_card(agent_name) - if not agent_card: - logger.error(f"[EngineClient] Agent not found: {agent_name}") + task_id: Optional[str] = None, + ) -> AsyncIterator[A2AStreamEvent]: + """Stream normalized A2A events without exposing transport internals.""" + if self._closed: + raise RuntimeError("Workflow client closed") + card = self._transport.get_card(agent_name) + if card is None: raise RuntimeError(f"Agent not found: {agent_name}") - logger.info(f"[EngineClient] send_message to {agent_name}: {len(message)} chars") - if skip_extensions: - logger.info(f"[EngineClient] Skipping A2A-T extensions for {agent_name} (self-loop)") - else: - t_ext = time.time() - metadata = await self._run_before_send_handlers(agent_card, message, metadata) - logger.info(f"[Timing] Extensions before_send for {agent_name}: {time.time()-t_ext:.2f}s") - logger.info(f"[EngineClient] Emitting agent_request for {agent_name}") - self._emit(EventType.AGENT_REQUEST, {"agent": agent_name, "request": message, "metadata": metadata or {}}) - client = self._transport.create_a2a_client(agent_card) - send_req = self._transport.build_send_request(message, context_id, metadata) - # Log full protocol request (endpoint + headers + body) after build, before send. - endpoint = "?" - if hasattr(agent_card, "supported_interfaces") and agent_card.supported_interfaces: - endpoint = agent_card.supported_interfaces[0].url or "?" - from google.protobuf.json_format import MessageToJson - try: - body_json = MessageToJson(send_req, ensure_ascii=False, indent=2) - except Exception: - body_json = str(send_req) - # Build HTTP header view: only real headers belong here, not message metadata. - # A2A-Extensions is derived from which extension URIs appear as metadata keys. - # Authorization is held by the credential service / auth interceptor at send time. - ext_uris = [k for k in (metadata or {}) if "tmforum.org" in k] - header_view = {} - if ext_uris: - header_view["A2A-Extensions"] = ",".join(ext_uris) - if agent_card.security_schemes and agent_card.security_requirements: - header_view["Authorization"] = "Bearer " - log_request(agent_name, endpoint, body_json, header_view) - - t_stream = time.time() - response_text, last_task, last_meta, task_state = ( - await self._transport.consume_stream(client, send_req, self._forward_intermediate_event, agent_name) + self._transport.validate_content_extensions(card, content) + client = self._transport.create_a2a_client(card) + request = self._transport.build_send_request( + content, context_id or str(uuid.uuid4()), task_id, ) - logger.info(f"[Timing] A2A stream for {agent_name}: {time.time()-t_stream:.2f}s") - - if response_text is None and last_task is not None: - response_text = str(last_task) + async for response in client.send_message(request): + self._transport.log_response_event(agent_name, response) + yield self._transport.parse_stream_event(response) - logger.info(f"[EngineClient] Response from {agent_name}: text={len(response_text or '')} chars, state={task_state}") - result = SendMessageResult( - text=response_text or "", - task=last_task, - metadata=last_meta, - task_state=task_state, - ) - if skip_extensions: - self._emit(EventType.AGENT_RESPONSE, {"agent": agent_name, "response": result.text, "metadata": result.metadata or {}}) - logger.info(f"[Timing] send_message to {agent_name} total: {time.time()-t_total:.2f}s") - return result - result = await self._run_after_receive_handlers(agent_card, result) - result = await self._auto_negotiate(agent_card, agent_name, message, context_id, result, 1) - logger.info(f"[Timing] send_message to {agent_name} total: {time.time()-t_total:.2f}s") - return result + @staticmethod + def _default_input(): + from workflow_engine.core.models import BusinessInput - # ------------------------------------------------------------------ - # Auto-negotiation (integrated into send_message) - # ------------------------------------------------------------------ + return BusinessInput.from_text("external message") - async def _auto_negotiate( - self, agent_card, agent_name, original_message, - context_id, result, round_num, + async def dispatch( + self, + request: TaskRequest, + content: MessageContent, + callbacks=None, ) -> SendMessageResult: - if not self._is_negotiation_needed(result) or round_num > self._max_negotiation_rounds: - self._emit(EventType.AGENT_RESPONSE, {"agent": agent_name, "response": result.text, "metadata": result.metadata or {}}) - return result - neg_meta = result.metadata or {} - neg_text = neg_meta.get("negotiation_message", "") or neg_meta.get("negotiationConcern", "") or "" - logger.info(f"[Negotiation] Round {round_num} for '{agent_name}': {neg_text}") - self._emit(EventType.NEGOTIATION_REQUEST, { - "agent": agent_name, "round": round_num, "concern": neg_text, - }) - if self._control_point is not None: - try: - clarification = self._control_point.on_negotiation(agent_name, neg_text, neg_meta) - if asyncio.iscoroutine(clarification): - clarification = await clarification - except Exception as e: - logger.warning(f"[Negotiation] on_negotiation raised: {e}") - clarification = None - else: - clarification = "Please proceed with the original task using available information." - if not clarification: - self._emit(EventType.NEGOTIATION_FAILED, { - "agent": agent_name, "round": round_num, "reason": "no clarification", - }) - self._emit(EventType.AGENT_RESPONSE, {"agent": agent_name, "response": result.text, "metadata": result.metadata or {}}) - return result - logger.info(f"[Negotiation] Clarification for '{agent_name}' round {round_num}: {clarification}") - self._emit(EventType.NEGOTIATION_RESOLVED, { - "agent": agent_name, "round": round_num, "clarification": clarification, - }) - follow_up = ( - "[NEGOTIATION_RESOLUTION]\n" - "The engine has reviewed your negotiation request and provides " - "the following clarification:\n\n" + clarification + "\n\n" - "---\nOriginal Task:\n" + original_message + "\n\n" - "Please re-execute the task based on the clarification above." - ) - follow_up_meta = await self._build_negotiation_follow_up_meta( - agent_name, neg_meta, clarification) - follow_up_meta = await self._run_before_send_handlers(agent_card, follow_up, follow_up_meta) - client = self._transport.create_a2a_client(agent_card) - send_req = self._transport.build_send_request(follow_up, context_id, follow_up_meta) - t_neg_stream = time.time() - logger.info(f"[Timing] Negotiation round {round_num} A2A stream to {agent_name}: starting") - response_text, last_task, last_meta, task_state = ( - await self._transport.consume_stream(client, send_req, self._forward_intermediate_event, agent_name) - ) - logger.info(f"[Timing] Negotiation round {round_num} A2A stream to {agent_name}: {time.time()-t_neg_stream:.2f}s") - if response_text is None and last_task is not None: - response_text = str(last_task) - r = SendMessageResult( - text=response_text or "", task=last_task, - metadata=last_meta, task_state=task_state, - ) - r = await self._run_after_receive_handlers(agent_card, r) - return await self._auto_negotiate(agent_card, agent_name, original_message, context_id, r, round_num + 1) + interaction = { + "negotiation_started": False, + "remote_task_id": None, + "terminal": False, + } + try: + return await self._dispatch(request, content, callbacks, interaction) + except asyncio.CancelledError: + await asyncio.shield( + self._cancel_abandoned_interaction(request.agent_name, interaction) + ) + raise + except Exception as exc: + await self._cancel_abandoned_interaction(request.agent_name, interaction) + if interaction["negotiation_started"]: + self._emit(EventType.NEGOTIATION_FAILED, { + "agent": request.agent_name, + "error": str(exc), + "error_type": type(exc).__name__, + }) + raise + + async def _dispatch( + self, + request: TaskRequest, + content: MessageContent, + callbacks, + interaction: dict[str, Any], + ) -> SendMessageResult: + if self._closed: + raise RuntimeError("Workflow client closed") + card = self._transport.get_card(request.agent_name) + if card is None: + raise RuntimeError(f"Agent not found: {request.agent_name}") + context_id = str(uuid.uuid4()) + deadline = time.monotonic() + self.callback_timeout_seconds + remote_task_id: Optional[str] = None + histories: dict[str, list[NegotiationExchange]] = {} + contexts: dict[str, NegotiationContext] = {} + answered: set[tuple[str, str, int]] = set() + current_content = content + abort_sent = False + + for exchange_number in range(self._max_negotiation_exchanges + 1): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Task interaction timed out") + result = await asyncio.wait_for( + self._send_once(card, request, current_content, context_id, remote_task_id), + timeout=remaining, + ) + if result.task is not None and result.task.id: + interaction["remote_task_id"] = result.task.id + remote_task_id = self._validate_remote_identity(result, context_id, remote_task_id) + interaction["remote_task_id"] = remote_task_id + if abort_sent: + interaction["terminal"] = result.is_terminal + if not result.is_terminal: + await self._cancel_abandoned_interaction( + request.agent_name, interaction, + ) + result.failure_code = "negotiation.aborted" + result.failure_message = "Business sent Abort; task was not completed successfully" + self._emit(EventType.AGENT_RESPONSE, { + "agent": request.agent_name, + "response": result.text, + "received_messages": result.received_messages, + }) + return result + while result.task_state in {"TASK_STATE_SUBMITTED", "TASK_STATE_WORKING"}: + if remote_task_id is None: + raise ValueError("Non-terminal response has no remote task identity") + await asyncio.sleep(0.25) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Task interaction timed out") + result = await asyncio.wait_for( + self._transport.get_task(request.agent_name, remote_task_id), + timeout=remaining, + ) + self._validate_remote_identity(result, context_id, remote_task_id) - async def _build_negotiation_follow_up_meta( - self, agent_name, neg_meta, clarification, - ): - """Build follow-up metadata, preferring SDK continue_negotiation. - - Calls a2a-t-sdk continue_negotiation to generate a structured - Negotiation-T payload (with DATA-NEGOTIATION-T context). Falls - back to manual metadata construction when the SDK is unavailable - or the negotiation context is missing. - """ - a2at_client = self._transport.get_a2at_client() - if a2at_client: - try: - receive_result = neg_meta.get("negotiation_context") - if isinstance(receive_result, dict): - context_dict = receive_result.get("context") - if isinstance(context_dict, dict): - from a2a_t.negotiation.common.models import ( - ContinueNegotiationInput, NegotiationContext, - ) - from a2a_t.negotiation.common.enums import NegotiationStatus - context = NegotiationContext.from_context(context_dict) - input_obj = ContinueNegotiationInput( - context=context, - status=NegotiationStatus.AGREED, - content_text=clarification, - ) - payload = await asyncio.to_thread(a2at_client.continue_negotiation, input_obj) - logger.info( - f"[Negotiation] SDK continue_negotiation payload " - f"for '{agent_name}': round {context.round} -> AGREED" - ) - return dict(payload) - except Exception as e: - logger.warning( - f"[Negotiation] continue_negotiation failed for " - f"'{agent_name}': {e}; using fallback" + if result.task_state != "TASK_STATE_INPUT_REQUIRED": + return await self._finish_remote_result( + request.agent_name, result, interaction, ) - return { - A2ATExtension.NEGOTIATION_T.uri: - "## Data Return Confirmation\n" + clarification + "\n", - } - # ------------------------------------------------------------------ - # Extension handler chain - # ------------------------------------------------------------------ - - async def _run_before_send_handlers( - self, agent_card, message: str, - preset_metadata: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - metadata: Dict[str, Any] = dict(preset_metadata) if preset_metadata else {} - ext_uris = self._transport._get_extensions(agent_card) - handlers = self._extension_registry.get_handlers_for_extensions(ext_uris) - agent_name = getattr(agent_card, "name", "?") - for handler in handlers: - t_h = time.time() - metadata = await handler.before_send( - agent_card, message, metadata, - self._transport.get_a2at_client(), self._control_point, + interaction["negotiation_started"] = True + if exchange_number >= self._max_negotiation_exchanges: + raise RuntimeError("Negotiation exchange budget exhausted; no Abort generated") + if callbacks is None: + raise RuntimeError("on_negotiation handler is required") + if remote_task_id is None: + raise ValueError("INPUT_REQUIRED has no remote task identity") + + received = self._negotiation_response(result) + context = self._negotiation_context(received) + previous_context = contexts.get(context.id) + self._validate_context_progression(previous_context, context) + contexts[context.id] = context + key = (remote_task_id, context.id, context.round) + while key in answered: + await asyncio.sleep(0.25) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Task interaction timed out") + result = await asyncio.wait_for( + self._transport.get_task(request.agent_name, remote_task_id), + timeout=remaining, + ) + self._validate_remote_identity(result, context_id, remote_task_id) + if result.task_state in {"TASK_STATE_SUBMITTED", "TASK_STATE_WORKING"}: + continue + if result.task_state != "TASK_STATE_INPUT_REQUIRED": + return await self._finish_remote_result( + request.agent_name, result, interaction, + ) + received = self._negotiation_response(result) + context = self._negotiation_context(received) + previous_context = contexts.get(context.id) + self._validate_context_progression(previous_context, context) + contexts[context.id] = context + key = (remote_task_id, context.id, context.round) + answered.add(key) + exchanges = histories.setdefault(context.id, []) + request_model = NegotiationRequest( + task=request, + original_submission=content, + received=received, + previous_exchanges=tuple(exchanges), + remaining_wait_seconds=max(0.0, deadline - time.monotonic()), + ) + self._emit(EventType.NEGOTIATION_REQUEST, { + "agent": request.agent_name, "request": request_model, + "exchange": exchange_number + 1, + }) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Task interaction timed out") + reply = await asyncio.wait_for( + callbacks.on_negotiation(request_model), timeout=remaining ) - logger.info( - f"[Timing] Handler {type(handler).__name__}.before_send " - f"for {agent_name}: {time.time()-t_h:.2f}s" + if isinstance(reply, NegotiationStop): + raise BusinessFailure(reply.code, reply.reason) + if not isinstance(reply, NegotiationSend): + raise TypeError("on_negotiation must return NegotiationSend or NegotiationStop") + self._validate_negotiation_reply(context, reply.content) + exchanges.append(NegotiationExchange(received, reply)) + self._emit(EventType.NEGOTIATION_RESOLVED, { + "agent": request.agent_name, "exchange": exchange_number + 1, + "reply": reply, + }) + current_content = reply.content + abort_sent = ( + A2atMessages.context_from_metadata(current_content.metadata).performative + is NegotiationPerformative.ABORT ) - return metadata - async def _run_after_receive_handlers( - self, agent_card, result: SendMessageResult, + raise RuntimeError("Negotiation exchange budget exhausted; no Abort generated") + + async def _finish_remote_result( + self, + agent_name: str, + result: SendMessageResult, + interaction: dict[str, Any], ) -> SendMessageResult: - ext_uris = self._transport._get_extensions(agent_card) - handlers = self._extension_registry.get_handlers_for_extensions(ext_uris) - for handler in handlers: - result = await handler.after_receive( - agent_card, result, - self._transport.get_a2at_client(), self._control_point, - self._event_callback, - ) + interaction["terminal"] = result.is_terminal + if result.task is not None and not result.is_terminal: + await self._cancel_abandoned_interaction(agent_name, interaction) + if result.failure_code is None: + result.failure_code = "a2a.unsupported_task_state" + result.failure_message = ( + "Workflow execution cannot continue remote task state " + f"{result.task_state or 'unknown'}" + ) + self._emit(EventType.AGENT_RESPONSE, { + "agent": agent_name, + "response": result.text, + "received_messages": result.received_messages, + }) return result - # ------------------------------------------------------------------ - # Negotiation helpers - # ------------------------------------------------------------------ + async def _cancel_abandoned_interaction( + self, agent_name: str, interaction: dict[str, Any], + ) -> None: + task_id = interaction.get("remote_task_id") + if not task_id or interaction.get("terminal"): + return + try: + result = await asyncio.wait_for( + self._transport.cancel_task(agent_name, task_id), + timeout=min(5.0, float(self.callback_timeout_seconds)), + ) + interaction["terminal"] = result.is_terminal + message = ( + f"Remote task cleanup result: agent={agent_name}, " + f"task_id={task_id}, state={result.task_state or 'unknown'}" + ) + if result.task_state == "TASK_STATE_CANCELED": + logger.info(message) + else: + logger.warning(message) + except asyncio.CancelledError: + logger.warning( + f"Remote task cleanup was cancelled: agent={agent_name}, task_id={task_id}" + ) + except Exception as exc: + logger.warning( + f"Failed to cancel abandoned remote task: agent={agent_name}, " + f"task_id={task_id}, error={type(exc).__name__}: {exc}" + ) + + async def _send_once( + self, card, request: TaskRequest, content: MessageContent, + context_id: str, remote_task_id: Optional[str], + ) -> SendMessageResult: + self._emit(EventType.AGENT_REQUEST, {"agent": request.agent_name, "content": content}) + self._transport.validate_content_extensions(card, content) + client = self._transport.create_a2a_client(card) + send_request = self._transport.build_send_request(content, context_id, remote_task_id) + return await self._transport.consume_stream( + client, send_request, self._forward_intermediate_event, request.agent_name, + ) + + @staticmethod + def _validate_remote_identity( + result: SendMessageResult, + context_id: str, + remote_task_id: Optional[str], + ) -> Optional[str]: + task = result.task + if task is None: + return remote_task_id + if not task.id or task.context_id != context_id: + raise ValueError("Remote task/context identity changed") + if remote_task_id is not None and task.id != remote_task_id: + raise ValueError("Remote task/context identity changed") + return task.id @staticmethod - def _is_negotiation_needed(result: SendMessageResult) -> bool: - return bool(result.task_state and "INPUT_REQUIRED" in result.task_state) + def _metadata_views(received: ReceivedMessage): + if received.message is not None: + yield received.message.metadata + yield received.task_metadata + for artifact in received.artifacts: + yield artifact.metadata + + @classmethod + def _negotiation_response(cls, result: SendMessageResult) -> ReceivedMessage: + uri = A2ATExtension.NEGOTIATION_T.uri + for received in result.received_messages: + if any(uri in metadata for metadata in cls._metadata_views(received)): + return received + raise ValueError("Unsupported INPUT_REQUIRED interaction: no Negotiation-T proposal") + + @classmethod + def _negotiation_context(cls, received: ReceivedMessage) -> NegotiationContext: + context = A2atMessages.negotiation_context(received) + if context.performative is not NegotiationPerformative.PROPOSE or context.is_exhausted(): + raise ValueError("Expected a valid Negotiation-T Propose") + return context + + @staticmethod + def _validate_context_progression( + previous: Optional[NegotiationContext], current: NegotiationContext, + ) -> None: + if previous is None: + return + if current.round < previous.round or current.max_rounds != previous.max_rounds: + raise ValueError("Negotiation round regressed or maxRounds changed") + + @classmethod + def _validate_negotiation_reply( + cls, original: NegotiationContext, content: MessageContent, + ) -> None: + uri = A2ATExtension.NEGOTIATION_T.uri + if uri not in content.extensions or uri not in content.metadata: + raise ValueError("Negotiation reply must carry and activate Negotiation-T") + ending = A2atMessages.context_from_metadata(content.metadata) + if ( + ending.id != original.id + or ending.round != original.round + or ending.max_rounds != original.max_rounds + or ending.performative is NegotiationPerformative.PROPOSE + ): + raise ValueError("Reply does not match the received negotiation context/round") + + async def get_task(self, agent_name: str, task_id: str) -> SendMessageResult: + return await self._transport.get_task(agent_name, task_id) + + async def list_tasks(self, agent_name: str, request=None): + return await self._transport.list_tasks(agent_name, request) + + async def cancel_task(self, agent_name: str, task_id: str) -> SendMessageResult: + return await self._transport.cancel_task(agent_name, task_id) + + async def subscribe_to_task(self, agent_name: str, task_id: str, event_callback=None): + return await self._transport.subscribe_to_task(agent_name, task_id, event_callback) + + async def close(self) -> None: + if self._closed: + return + self._closed = True + if self._close_transport_on_close: + await self._transport.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() diff --git a/workflow_engine/client/extension_handlers.py b/workflow_engine/client/extension_handlers.py deleted file mode 100644 index ec5644c..0000000 --- a/workflow_engine/client/extension_handlers.py +++ /dev/null @@ -1,203 +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. - -"""Extension handler registry 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 and Notification-T are pre-positioning concerns handled -once before the workflow starts via ExtensionSender, not part of this -in-workflow handler chain. -""" - -from abc import ABC, abstractmethod -from typing import Any, Dict, Optional, List, TYPE_CHECKING -from loguru import logger - -if TYPE_CHECKING: - from workflow_engine.control.control_points import ControlPoint - -from workflow_engine.core.models import SendMessageResult - - -class ExtensionHandler(ABC): - extension_keyword: str = "" - - @abstractmethod - async def before_send(self, agent_card, message_text, metadata, - a2at_client=None, control_point=None) -> Dict[str, Any]: - ... - - @abstractmethod - async def after_receive(self, agent_card, result, a2at_client=None, - control_point=None, event_callback=None) -> SendMessageResult: - ... - - -class TaskTHandler(ExtensionHandler): - extension_keyword = "Task-T" - - def __init__(self): - self._prompt_cache: Dict[str, str] = {} - - async def before_send(self, agent_card, message_text, metadata, a2at_client=None, control_point=None): - if not a2at_client: - return metadata - if "[NEGOTIATION_RESOLUTION]" in message_text: - logger.info("[Task-T] Skipping prompt generation for negotiation follow-up") - return metadata - task_t_uri = None - extensions = getattr(getattr(agent_card, "capabilities", None), "extensions", None) or [] - for ext in extensions: - uri = getattr(ext, "uri", "") or "" - if "Task-T" in uri: - task_t_uri = uri - break - if not task_t_uri: - return metadata - if task_t_uri in metadata: - logger.info(f"[Task-T] Metadata already preset, skipping generation") - return metadata - cache_key = message_text - if cache_key in self._prompt_cache: - cached = self._prompt_cache[cache_key] - metadata[task_t_uri] = cached - logger.info( - f"[Task-T] Cache hit for '{getattr(agent_card, 'name', '?')}', " - f"{len(cached)} chars" - ) - return metadata - try: - import asyncio - prompt_result = await asyncio.to_thread(a2at_client.generate_task_prompt, message_text) - if hasattr(prompt_result, "success") and prompt_result.success: - if hasattr(prompt_result, "prompt_text") and prompt_result.prompt_text: - metadata[task_t_uri] = prompt_result.prompt_text - self._prompt_cache[cache_key] = prompt_result.prompt_text - logger.info(f"[Task-T] Generated prompt for '{getattr(agent_card, 'name', '?')}', {len(prompt_result.prompt_text)} chars") - logger.debug(f"[Task-T] Prompt content: [{prompt_result.prompt_text}]") - else: - failure = getattr(prompt_result, "failure", None) - if failure: - logger.warning(f"[Task-T] Prompt generation failed: {getattr(failure, 'message', '')}") - logger.info(f"[Task-T] Failure detail: {failure}") - except Exception as e: - logger.warning(f"[Task-T] Failed: {e}") - return metadata - - async def after_receive(self, agent_card, result, a2at_client=None, control_point=None, event_callback=None): - return result - - -class NegotiationTHandler(ExtensionHandler): - extension_keyword = "Negotiation-T" - - async def before_send(self, agent_card, message_text, metadata, a2at_client=None, control_point=None): - return metadata - - async def after_receive(self, agent_card, result, a2at_client=None, control_point=None, event_callback=None): - if not a2at_client: - return result - if not result.task_state or "INPUT_REQUIRED" not in result.task_state: - return result - extensions = getattr(getattr(agent_card, "capabilities", None), "extensions", None) or [] - supports_neg = any("NEGOTIATION-T" in (getattr(ext, "uri", "") or "") for ext in extensions) - if not supports_neg: - return result - metadata = dict(result.metadata) if result.metadata else {} - context_map = self._extract_negotiation_context(metadata) - if context_map is None: - context_map = metadata - try: - import asyncio as _aio - receive_result = await _aio.to_thread( - a2at_client.receive_negotiation, message=result.text, context=context_map) - if receive_result.get("needResponse", False): - metadata["negotiation_message"] = receive_result.get("message", "") - metadata["negotiation_context"] = receive_result - logger.info(f"[Negotiation-T] Agent '{getattr(agent_card, 'name', '?')}' requested negotiation: {metadata['negotiation_message']}") - except Exception as e: - msg = str(e) if e else "" - if "Unsupported negotiation type" in msg: - logger.debug(f"[Negotiation-T] SDK receiveNegotiation unavailable for '{getattr(agent_card, 'name', '?')}' ({msg}), using fallback") - else: - logger.warning(f"[Negotiation-T] receiveNegotiation failed for '{getattr(agent_card, 'name', '?')}': {msg}, using fallback") - if "negotiation_message" not in metadata or not metadata["negotiation_message"]: - concern = metadata.get("negotiationConcern", "") - if concern: - metadata["negotiation_message"] = concern - logger.info(f"[Negotiation-T] Agent '{getattr(agent_card, 'name', '?')}' concern: {concern}") - else: - fallback_text = self._extract_negotiation_text(metadata) - if fallback_text: - metadata["negotiation_message"] = fallback_text - logger.info(f"[Negotiation-T] Agent '{getattr(agent_card, 'name', '?')}' negotiation (NEGOTIATION-T fallback): {fallback_text}") - result.metadata = metadata - return result - - @staticmethod - def _extract_negotiation_context(metadata): - if not metadata: - return None - for key, value in metadata.items(): - if "DATA-NEGOTIATION-T" in str(key) and isinstance(value, dict): - return value - return None - - @staticmethod - def _extract_negotiation_text(metadata): - if not metadata: - return None - for key, value in metadata.items(): - key_str = str(key) - if "NEGOTIATION-T" in key_str and "DATA-NEGOTIATION-T" not in key_str and isinstance(value, str): - return value - return None - - -class ExtensionRegistry: - """Registry of in-workflow extension handlers. - - Pre-registers the Task-T and Negotiation-T handlers, which participate - in every send_message lifecycle. Authorization-T / Notification-T are - excluded by design: they are one-shot pre-positioning operations - (see ExtensionSender), not in-workflow handlers. - """ - - def __init__(self): - self._handlers: Dict[str, ExtensionHandler] = {} - self.register(TaskTHandler()) - self.register(NegotiationTHandler()) - - def register(self, handler: ExtensionHandler): - self._handlers[handler.extension_keyword] = handler - - def get_handlers_for_extensions(self, extension_uris: List[str]) -> List[ExtensionHandler]: - matched = [] - seen = set() - for uri in extension_uris: - for keyword, handler in self._handlers.items(): - # Case-insensitive match: extension URIs commonly use - # uppercase (e.g. "NEGOTIATION-T") while the handler keyword - # uses mixed case ("Negotiation-T"). - if keyword.lower() in uri.lower() and keyword not in seen: - matched.append(handler) - seen.add(keyword) - break - return matched diff --git a/workflow_engine/client/extension_interceptor.py b/workflow_engine/client/extension_interceptor.py index 0e2c85c..3b7a367 100644 --- a/workflow_engine/client/extension_interceptor.py +++ b/workflow_engine/client/extension_interceptor.py @@ -17,9 +17,8 @@ """A2A Extension interceptor - injects A2A-Extensions HTTP header. -Reads the agent's declared extensions from AgentCard.capabilities.extensions[].uri -and sets the A2A-Extensions header so the server knows which extensions -the client supports. +Advertises only extension URIs that are both declared by the AgentCard and +activated by the current message. """ from typing import List @@ -47,20 +46,20 @@ async def before(self, args: BeforeArgs) -> None: args.context = ClientCallContext() if args.context.service_parameters is None: args.context.service_parameters = {} - # Only advertise extensions that are actually present in this message's metadata. - # Java SDK's ExtensionInterceptor.filterActiveExtensions does the same: it inspects - # the payload (message metadata) and only includes URIs that appear as keys. - payload = args.payload if hasattr(args, "payload") else None + # Advertise only extensions activated by this message, never every + # capability declared by the target AgentCard. + payload = args.input active_uris = [] if isinstance(payload, dict): for uri in self._uris: if uri in payload: active_uris.append(uri) - elif hasattr(payload, "message") and hasattr(payload.message, "metadata"): + elif hasattr(payload, "message"): + activated = set(getattr(payload.message, "extensions", ()) or ()) meta = payload.message.metadata meta_keys = set(meta.keys()) if meta else set() for uri in self._uris: - if uri in meta_keys: + if uri in activated or uri in meta_keys: active_uris.append(uri) if not active_uris: return @@ -69,7 +68,6 @@ async def before(self, args: BeforeArgs) -> None: merged = sorted(get_requested_extensions([*existing_values, *active_uris])) extension_value = ",".join(merged) args.context.service_parameters[HTTP_EXTENSION_HEADER] = extension_value - args.context.service_parameters["x-a2a-extensions"] = extension_value logger.info(f"[Extensions] Set {HTTP_EXTENSION_HEADER}={extension_value}") async def after(self, args: AfterArgs) -> None: diff --git a/workflow_engine/client/extension_sender.py b/workflow_engine/client/extension_sender.py index 02b745e..f74c704 100644 --- a/workflow_engine/client/extension_sender.py +++ b/workflow_engine/client/extension_sender.py @@ -1,31 +1,98 @@ -# Copyright (c) 2026 Huawei Technologies Co., Ltd. +# Copyright (c) 2026 Huawei Technologies Co., Ltd. # All Rights Reserved. # # SPDX-License-Identifier: Apache-2.0 -"""ExtensionSender -- one-shot pre-positioning facade over A2ATransport. +"""Independent Authorization-T and Notification-T operations.""" -Single responsibility: send Authorization-T / Notification-T (and any -other one-shot extension) messages to agents BEFORE the workflow starts. -Bypasses Task-T prompt generation and the Negotiation-T auto-loop, and -does not emit events through the global EventCallback (the returned -result is the callback). +from __future__ import annotations -Kept separate from WorkflowEngineClient so a caller that only wants to -pre-position is not forced to hold a workflow-machinery facade. -""" - -from typing import Dict, Any, Optional, Callable import asyncio -from loguru import logger +import inspect +import time +import uuid +from dataclasses import dataclass +from typing import Callable, Optional from workflow_engine.client.a2a_transport import A2ATransport from workflow_engine.client.extensions import A2ATExtension -from workflow_engine.core.models import SendMessageResult +from workflow_engine.core.models import MessageContent, ReceivedMessage, SendMessageResult + + +@dataclass(frozen=True) +class NotificationHeartbeat: + opened_at: float + last_event_at: Optional[float] + event_count: int + active: bool + + +class NotificationSubscription: + """Explicit lifecycle handle for one long-lived Notification-T stream.""" + + def __init__(self, agent_name: str, context_id: str): + loop = asyncio.get_running_loop() + self.agent_name = agent_name + self.context_id = context_id + self.acknowledgement: asyncio.Future[SendMessageResult] = loop.create_future() + self.completion: asyncio.Future[None] = loop.create_future() + self._opened_at = time.time() + self._last_event_at: Optional[float] = None + self._event_count = 0 + self._task: Optional[asyncio.Task] = None + self._closed = False + + @property + def is_active(self) -> bool: + return not self._closed and not self.completion.done() + + @property + def heartbeat(self) -> NotificationHeartbeat: + return NotificationHeartbeat( + self._opened_at, self._last_event_at, self._event_count, self.is_active + ) + + def is_healthy(self, maximum_idle_seconds: float) -> bool: + if maximum_idle_seconds < 0: + raise ValueError("maximum_idle_seconds must not be negative") + return ( + self.is_active and self._last_event_at is not None + and time.time() - self._last_event_at <= maximum_idle_seconds + ) + + def _record_event(self) -> None: + self._event_count += 1 + self._last_event_at = time.time() + + def _attach(self, task: asyncio.Task) -> None: + self._task = task + + def close(self) -> None: + if self._closed: + return + self._closed = True + if self._task is not None and not self._task.done(): + self._task.cancel() + if not self.acknowledgement.done(): + self.acknowledgement.cancel( + "Notification-T subscription closed before acknowledgement" + ) + if not self.completion.done(): + self.completion.set_result(None) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + self.close() + try: + await self.completion + except asyncio.CancelledError: + pass class ExtensionSender: - """One-shot extension message sender built on a shared A2ATransport.""" + """Final-content sender on a caller-owned transport, outside workflow execution.""" def __init__(self, transport: A2ATransport): self._transport = transport @@ -34,170 +101,144 @@ def __init__(self, transport: A2ATransport): def transport(self) -> A2ATransport: return self._transport - def get_a2at_client(self): - return self._transport.get_a2at_client() + def _require_extension( + self, agent_name: str, content: MessageContent, extension: A2ATExtension, + ): + card = self._transport.get_card(agent_name) + if card is None: + raise ValueError(f"Agent not found: {agent_name}") + advertised = self._transport._get_extensions(card) + if ( + extension.uri not in advertised + or extension.uri not in content.extensions + or extension.uri not in content.metadata + ): + raise ValueError(f"Target capability and content must use {extension.uri}") + return card - async def send_extension_message( - self, - agent_name: str, - instruction: str, - natural_language_input: str, - extension: A2ATExtension, + async def send_authorization( + self, agent_name: str, content: MessageContent, ) -> SendMessageResult: - """Send a one-shot extension message for pre-positioning. - - The metadata value is generated by the A2A-T SDK (LLM + prompt - template) from the natural-language input; if the SDK cannot - generate, the input text is used as-is. - - For NOTIFICATION_T, automatically routes to a long-lived SSE stream - (subscription stays open). The returned result contains the - subscription confirmation. - """ - agent_card = self._transport.get_card(agent_name) - if not agent_card: - raise RuntimeError(f"Agent not found: {agent_name}") - metadata_value = await asyncio.to_thread( - self._generate_extension_prompt, extension, natural_language_input + card = self._require_extension(agent_name, content, A2ATExtension.AUTHORIZATION_T) + self._transport.validate_content_extensions(card, content) + context_id = str(uuid.uuid4()) + client = self._transport.create_a2a_client(card) + request = self._transport.build_send_request(content, context_id) + deadline = time.monotonic() + self._transport.send_timeout_seconds + result = await asyncio.wait_for( + self._transport.consume_stream(client, request, agent_name=agent_name), + timeout=max(0.001, deadline - time.monotonic()), ) - if not metadata_value: - metadata_value = natural_language_input - logger.info(f"[ExtensionSender] SDK prompt generation unavailable for {agent_name} ({extension.display_name}), using input as metadata") - logger.info(f"[ExtensionSender] sendExtensionMessage to {agent_name}: extension={extension.display_name}, metadataValue={len(metadata_value)} chars") - metadata = {extension.uri: metadata_value} - client = self._transport.create_a2a_client(agent_card) - send_req = self._transport.build_send_request(instruction, None, metadata) - - if extension == A2ATExtension.NOTIFICATION_T: - logger.info(f"[ExtensionSender] Notification-T auto-routed to long-lived SSE stream") - await self._transport.consume_notification_stream( - client, send_req, event_callback=None, agent_name=agent_name + task_id = self._validate_task_identity(result, context_id) + while result.task_state in {"TASK_STATE_SUBMITTED", "TASK_STATE_WORKING"}: + if not task_id: + raise ValueError("Authorization-T acknowledgement has no task identity") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Authorization-T operation timed out") + await asyncio.sleep(min(0.25, remaining)) + result = await asyncio.wait_for( + self._transport.get_task(agent_name, task_id), + timeout=max(0.001, deadline - time.monotonic()), ) - return SendMessageResult( - text="Subscribed", task=None, - metadata=metadata, task_state="TASK_STATE_WORKING", - ) - - response_text, last_task, last_meta, task_state = ( - await self._transport.consume_stream(client, send_req) - ) - if response_text is None and last_task is not None: - response_text = str(last_task) - result = SendMessageResult( - text=response_text or "", task=last_task, - metadata=last_meta, task_state=task_state, - ) - logger.info(f"[ExtensionSender] Extension response from {agent_name}: state={result.task_state}") + self._validate_task_identity(result, context_id, task_id) return result - async def send_authorization( - self, agent_name: str, instruction: str, natural_language_input: str, - ) -> SendMessageResult: - """Convenience for Authorization-T pre-positioning.""" - return await self.send_extension_message( - agent_name, instruction, natural_language_input, A2ATExtension.AUTHORIZATION_T) - - async def send_notification( - self, agent_name: str, instruction: str, natural_language_input: str, - event_callback: Optional[Callable[[Dict[str, Any]], None]] = None, - ) -> SendMessageResult: - """Notification-T pre-positioning with long-lived SSE subscription. - - Always opens a long-lived SSE stream. The returned - ``SendMessageResult`` contains the subscription confirmation. - Subsequent events pushed by the agent (e.g. recovery results) are - forwarded to ``event_callback`` as dicts containing ``agent``, - ``type``, ``state``, ``text``, ``metadata``, etc. - - When ``event_callback`` is ``None``, the stream stays open and - events are logged at INFO level but not forwarded. - - The background stream task is stored on the sender instance and - can be cancelled via ``cancel_notification_streams()``. - """ - agent_card = self._transport.get_card(agent_name) - if not agent_card: - raise RuntimeError(f"Agent not found: {agent_name}") - metadata_value = await asyncio.to_thread( - self._generate_extension_prompt, A2ATExtension.NOTIFICATION_T, natural_language_input - ) - if not metadata_value: - metadata_value = natural_language_input - logger.info(f"[ExtensionSender] SDK prompt generation unavailable for {agent_name} (Notification-T), using input as metadata") - metadata = {A2ATExtension.NOTIFICATION_T.uri: metadata_value} - client = self._transport.create_a2a_client(agent_card) - send_req = self._transport.build_send_request(instruction, None, metadata) - - logger.info(f"[ExtensionSender] sendNotification to {agent_name}: long-lived SSE, callback={'yes' if event_callback else 'no'}") - bg_task = await self._transport.consume_notification_stream( - client, send_req, event_callback, agent_name - ) - if not hasattr(self, '_notification_tasks'): - self._notification_tasks = [] - self._notification_tasks.append(bg_task) - return SendMessageResult( - text="Subscribed", task=None, - metadata=metadata, task_state="TASK_STATE_WORKING", - ) - - def cancel_notification_streams(self): - """Cancel all active Notification-T background streams.""" - tasks = getattr(self, '_notification_tasks', []) - for t in tasks: - if not t.done(): - t.cancel() - tasks.clear() - logger.info("[ExtensionSender] All notification streams cancelled") - - # ------------------------------------------------------------------ - # Extension prompt generation dispatch - # ------------------------------------------------------------------ - - def _generate_extension_prompt(self, extension, natural_language_input): - """Dispatch to the SDK extension-specific prompt generation.""" - if extension == A2ATExtension.TASK_T: - return self.generate_prompt_text(natural_language_input) - if extension == A2ATExtension.NEGOTIATION_T: - return self.generate_negotiation_prompt(natural_language_input) - if extension == A2ATExtension.AUTHORIZATION_T: - return self.generate_authorization_prompt(natural_language_input) - if extension == A2ATExtension.NOTIFICATION_T: - return self.generate_notification_prompt(natural_language_input) - return '' - - def generate_prompt_text(self, natural_language_input: str) -> str: - """Generate structured Task-T prompt text from natural-language input.""" - a2at_client = self._transport.get_a2at_client() - if not a2at_client: - return '' - try: - prompt_result = a2at_client.generate_task_prompt(natural_language_input) - if hasattr(prompt_result, 'success') and prompt_result.success: - text = getattr(prompt_result, 'prompt_text', None) - if text: - return text + @staticmethod + def _validate_task_identity( + result: SendMessageResult, + context_id: str, + task_id: Optional[str] = None, + ) -> Optional[str]: + if result.task is None: + return task_id + if not result.task.id or result.task.context_id != context_id: + raise ValueError("Authorization-T task/context identity changed") + if task_id is not None and result.task.id != task_id: + raise ValueError("Authorization-T task/context identity changed") + return result.task.id + + def open_notification( + self, + agent_name: str, + content: MessageContent, + listener: Callable[[NotificationSubscription, ReceivedMessage], object], + ) -> NotificationSubscription: + card = self._require_extension(agent_name, content, A2ATExtension.NOTIFICATION_T) + self._transport.validate_content_extensions(card, content) + if listener is None: + raise ValueError("listener is required") + context_id = str(uuid.uuid4()) + subscription = NotificationSubscription(agent_name, context_id) + + async def consume() -> None: + try: + client = self._transport.create_a2a_client(card) + request = self._transport.build_send_request(content, context_id) + async for response in client.send_message(request): + self._transport.log_response_event(agent_name, response) + subscription._record_event() + result, received = self._incremental_result(response) + if not subscription.acknowledgement.done(): + subscription.acknowledgement.set_result(result) + if received is not None: + returned = listener(subscription, received) + if inspect.isawaitable(returned): + await returned + except asyncio.CancelledError: + if not subscription.completion.done(): + subscription.completion.set_result(None) + except Exception as exc: + if not subscription.acknowledgement.done(): + subscription.acknowledgement.set_exception(exc) + if not subscription.completion.done(): + subscription.completion.set_exception(exc) else: - failure = getattr(prompt_result, 'failure', None) - if failure: - logger.warning('[ExtensionSender] SDK Task-T prompt generation failed: ' + str(getattr(failure, 'message', ''))) - except Exception as e: - logger.warning('[ExtensionSender] SDK Task-T prompt generation error: ' + str(e)) - return '' - - def generate_negotiation_prompt(self, natural_language_input: str) -> str: - """Generate Negotiation-T prompt text. Reserved for SDK support.""" - if not self._transport.get_a2at_client(): - return '' - return '' - - def generate_authorization_prompt(self, natural_language_input: str) -> str: - """Generate Authorization-T prompt text. Reserved for SDK support.""" - if not self._transport.get_a2at_client(): - return '' - return '' - - def generate_notification_prompt(self, natural_language_input: str) -> str: - """Generate Notification-T prompt text. Reserved for SDK support.""" - if not self._transport.get_a2at_client(): - return '' - return '' \ No newline at end of file + if not subscription.completion.done(): + subscription.completion.set_result(None) + finally: + subscription._closed = True + if not subscription.completion.done(): + subscription.completion.set_result(None) + + task = asyncio.create_task(consume(), name=f"notification-t-{agent_name}") + subscription._attach(task) + return subscription + + def _incremental_result(self, response): + if response.HasField("task"): + result = self._transport._result_from_task(response.task) + return result, result.received_messages[0] + if response.HasField("message"): + received = ReceivedMessage( + message=self._transport._message_content(response.message) + ) + return SendMessageResult(received_messages=(received,)), received + if response.HasField("status_update"): + update = response.status_update + message = self._transport._message_content(update.status.message) + state = self._transport._extract_task_state( + type("TaskView", (), {"status": update.status})() + ) + failure_code, failure_message = self._transport._failure_from_state( + state, message, + ) + received = ReceivedMessage( + message=message, + task_metadata=self._transport._struct_dict(update.metadata), + ) + return SendMessageResult( + task_state=state, + failure_code=failure_code, + failure_message=failure_message, + received_messages=(received,), + ), received + if response.HasField("artifact_update"): + update = response.artifact_update + artifact = self._transport._received_artifact(update.artifact) + received = ReceivedMessage( + task_metadata=self._transport._struct_dict(update.metadata), + artifacts=(artifact,), + ) + return SendMessageResult(received_messages=(received,)), received + return SendMessageResult(), None diff --git a/workflow_engine/client/extensions.py b/workflow_engine/client/extensions.py index a14417f..b8d5820 100644 --- a/workflow_engine/client/extensions.py +++ b/workflow_engine/client/extensions.py @@ -18,19 +18,27 @@ """A2A-T extension type constants. Each constant encapsulates the full extension URI so callers never need to -hardcode URI strings. Use these with ``WorkflowEngineClient.send_extension_message``. +hardcode URI strings. Activate them through ``MessageContent.extensions``; +Authorization-T and Notification-T are sent through ``ExtensionSender``. """ from enum import Enum +from a2a_t.core.metadata import ( + AUTHORIZATION_T_EXTENSION_URI, + NEGOTIATION_T_EXTENSION_URI, + NOTIFICATION_T_EXTENSION_URI, + TASK_T_EXTENSION_URI, +) + class A2ATExtension(Enum): """A2A-T extension types supported by the workflow execution engine.""" - TASK_T = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1" - NEGOTIATION_T = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T" - AUTHORIZATION_T = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1" - NOTIFICATION_T = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1" + TASK_T = TASK_T_EXTENSION_URI + NEGOTIATION_T = NEGOTIATION_T_EXTENSION_URI + AUTHORIZATION_T = AUTHORIZATION_T_EXTENSION_URI + NOTIFICATION_T = NOTIFICATION_T_EXTENSION_URI @property def uri(self) -> str: diff --git a/workflow_engine/client/protocol_interceptor.py b/workflow_engine/client/protocol_interceptor.py new file mode 100644 index 0000000..3baedcf --- /dev/null +++ b/workflow_engine/client/protocol_interceptor.py @@ -0,0 +1,43 @@ +# Copyright (c) 2026 Huawei Technologies Co., Ltd. +# All Rights Reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""A2A client interceptor that records the final request context.""" + +from __future__ import annotations + +from google.protobuf.json_format import MessageToJson + +from a2a.client.interceptors import AfterArgs, BeforeArgs, ClientCallInterceptor + +from workflow_engine.client.protocol_logger import log_request + + +class ProtocolLoggingInterceptor(ClientCallInterceptor): + """Log the request after auth and extension interceptors have contributed headers.""" + + def __init__(self, agent_name: str, endpoint: str, protocol_version: str = ""): + self._agent_name = agent_name + self._endpoint = endpoint + self._protocol_version = protocol_version + + async def before(self, args: BeforeArgs) -> None: + headers = dict( + getattr(args.context, "service_parameters", None) or {} + ) + if self._protocol_version: + headers.setdefault("A2A-Version", self._protocol_version) + try: + body = MessageToJson(args.input, ensure_ascii=False, indent=2) + except (TypeError, ValueError): + body = str(args.input) + log_request( + self._agent_name, + f"{self._endpoint} ({args.method})", + body, + headers, + ) + + async def after(self, args: AfterArgs) -> None: + return None diff --git a/workflow_engine/client/protocol_logger.py b/workflow_engine/client/protocol_logger.py index 60f1776..3990469 100644 --- a/workflow_engine/client/protocol_logger.py +++ b/workflow_engine/client/protocol_logger.py @@ -3,21 +3,52 @@ # # SPDX-License-Identifier: Apache-2.0 -"""Protocol-level request/response logger. +"""Opt-in protocol-level request/response diagnostics. -Mirrors the Java SDK's ProtocolLogger: prints full HTTP headers and -body content at INFO level for protocol debugging. Enable by setting -the logging level to INFO for this module. +Full payloads may contain customer data and are disabled unless +``WORKFLOW_ENGINE_PROTOCOL_LOGGING=true`` is set. Sensitive headers remain +redacted unless ``WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=true`` +is also explicitly set. """ import json +import os from typing import Any, Dict, Optional + from loguru import logger -def log_request(agent_name: str, endpoint: str, - params: Any, headers: Optional[Dict[str, str]] = None) -> None: - """Log an outgoing A2A request (headers + body).""" +_SENSITIVE_HEADER_PARTS = ( + "authorization", "token", "secret", "api-key", "apikey", "cookie" +) + + +def _enabled() -> bool: + return os.getenv("WORKFLOW_ENGINE_PROTOCOL_LOGGING", "").lower() == "true" + + +def _format_header(name: str, value: Any) -> str: + normalized = name.lower() + include_sensitive = ( + os.getenv("WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS", "").lower() + == "true" + ) + if not include_sensitive and any( + part in normalized for part in _SENSITIVE_HEADER_PARTS + ): + return "***REDACTED***" + return value if isinstance(value, str) else str(value)[:200] + + +def log_request( + agent_name: str, + endpoint: str, + params: Any, + headers: Optional[Dict[str, str]] = None, +) -> None: + """Log an outgoing A2A request after client interceptors have run.""" + if not _enabled(): + return if isinstance(params, str): body = params else: @@ -27,52 +58,19 @@ def log_request(agent_name: str, endpoint: str, body = str(params) header_lines = [] if headers: - for k, v in sorted(headers.items()): - header_lines.append(f" {k}: {v if isinstance(v, str) else str(v)[:200]}") - header_str = "\n".join(header_lines) if header_lines else " (none)" - logger.info(f">>> [{agent_name}] REQUEST to {endpoint}\n=== Headers ===\n{header_str}\n=== Body ===\n{body}") + for name, value in sorted(headers.items()): + header_lines.append(f" {name}: {_format_header(name, value)}") + header_text = "\n".join(header_lines) if header_lines else " (none)" + logger.debug( + f">>> [{agent_name}] REQUEST to {endpoint}\n" + f"=== Headers ===\n{header_text}\n=== Body ===\n{body}" + ) def log_response(agent_name: str, event_type: str, body: str) -> None: - """Log an incoming A2A response (event type + body).""" - logger.info(f"<<< [{agent_name}] RESPONSE [{event_type}]\n{body}") - - -def log_response_event(agent_name: str, event: Any) -> None: - """Log a structured SSE response event (mirrors Java ProtocolLogger.logResponseEvent). - - Extracts the inner payload from TaskUpdateEvent / MessageEvent wrappers - and logs the full JSON for protocol-level debugging. - """ - try: - event_type = type(event).__name__ - payload = _extract_payload(event) - if payload is None: - logger.info(f"<<< [{agent_name}] RESPONSE [{event_type}]: (no serializable payload)") - return - if isinstance(payload, str): - body = payload - else: - try: - body = json.dumps(payload, ensure_ascii=False, indent=2, default=str) - except Exception: - body = str(payload) - logger.info(f"<<< [{agent_name}] RESPONSE [{event_type}]\n{body}") - except Exception as e: - logger.warning(f"<<< [{agent_name}] Failed to serialize response event: {e}") - - -def _extract_payload(event: Any) -> Any: - """Extract the serializable protocol payload from a ClientEvent.""" - if hasattr(event, "task"): - task = event.task - if hasattr(task, "status_updates") and task.status_updates: - return task.status_updates[-1] - if hasattr(task, "artifacts") and task.artifacts: - return task.artifacts[-1] - return task - if hasattr(event, "message"): - return event.message - if hasattr(event, "update_event"): - return event.update_event - return event \ No newline at end of file + """Log an incoming A2A response event.""" + if _enabled(): + logger.debug(f"<<< [{agent_name}] RESPONSE [{event_type}]\n{body}") + + +__all__ = ["log_request", "log_response"] diff --git a/workflow_engine/client/sse_normalization.py b/workflow_engine/client/sse_normalization.py index bf454ad..b07cd86 100644 --- a/workflow_engine/client/sse_normalization.py +++ b/workflow_engine/client/sse_normalization.py @@ -56,7 +56,10 @@ def _parse_with_unknown(text, message, ignore_unknown_fields=False, **kwargs): if isinstance(data, dict): if not _STREAM_RESPONSE_KEYS.intersection(data): logger = __import__("loguru").logger - logger.warning(f"[A2A] Non-SSE response from server: {text[:2048]}") + logger.warning( + f"[A2A] Non-SSE response from server: body_chars={len(text)}" + ) + logger.trace(f"[A2A] Non-SSE response body: {text[:2048]}") data = _normalize_stream_response(data) text = _json.dumps(data) except Exception: diff --git a/workflow_engine/client/ssl_context.py b/workflow_engine/client/ssl_context.py index b46e822..67d3b02 100644 --- a/workflow_engine/client/ssl_context.py +++ b/workflow_engine/client/ssl_context.py @@ -15,12 +15,7 @@ # License for the specific language governing permissions and limitations # under the License. -"""SSL context factory for outbound HTTPS calls. - -Self-contained - does not depend on the orchestration center's config system. -Accepts a config dict with SSL parameters, or returns False (skip verification) -when no config is provided. -""" +"""Fail-closed SSL context factory for outbound HTTPS calls.""" import os import ssl @@ -29,7 +24,7 @@ def create_ssl_context( - verify_server: bool = False, + verify_server: bool = True, ca_certs_path: Optional[str] = None, cert_path: Optional[str] = None, key_path: Optional[str] = None, @@ -53,32 +48,31 @@ def create_ssl_context( logger.warning("Outbound TLS verification disabled. Insecure for production.") return False - try: - ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) - if ca_certs_path and os.path.exists(ca_certs_path): - ctx.load_verify_locations(ca_certs_path) - logger.info(f"Client SSL: loaded CA trust store from {ca_certs_path}") - else: - logger.warning(f"Client SSL: CA trust store not found at {ca_certs_path}, using system default") + if ca_certs_path: + if not os.path.isfile(ca_certs_path): + raise FileNotFoundError(f"CA trust store not found: {ca_certs_path}") + ctx.load_verify_locations(ca_certs_path) + logger.info(f"Client SSL: loaded CA trust store from {ca_certs_path}") - if cert_path and key_path and os.path.exists(cert_path) and os.path.exists(key_path): - try: - ctx.load_cert_chain( - certfile=cert_path, - keyfile=key_path, - password=key_password if key_password else None, - ) - logger.info("Client SSL: loaded client identity cert for mTLS") - except Exception as e: - logger.warning(f"Client SSL: could not load client cert chain: {e}") + if bool(cert_path) != bool(key_path): + raise ValueError("Both client certificate and private key are required for mTLS") + if cert_path and key_path: + if not os.path.isfile(cert_path) or not os.path.isfile(key_path): + raise FileNotFoundError("Client certificate or private key not found") + ctx.load_cert_chain( + certfile=cert_path, + keyfile=key_path, + password=key_password if key_password else None, + ) + logger.info("Client SSL: loaded client identity cert for mTLS") - if crl_path and os.path.exists(crl_path): - ctx.load_verify_locations(crl_path) - ctx.verify_flags |= ssl.VERIFY_CRL_CHECK_LEAF - logger.info(f"Client SSL: enabled CRL checking from {crl_path}") + if crl_path: + if not os.path.isfile(crl_path): + raise FileNotFoundError(f"CRL file not found: {crl_path}") + ctx.load_verify_locations(crl_path) + ctx.verify_flags |= ssl.VERIFY_CRL_CHECK_LEAF + logger.info(f"Client SSL: enabled CRL checking from {crl_path}") - return ctx - except Exception as e: - logger.error(f"Failed to build SSL context: {e}. Falling back to no verification.") - return False + return ctx diff --git a/workflow_engine/client/stub_engine_client.py b/workflow_engine/client/stub_engine_client.py index 4847545..c92b482 100644 --- a/workflow_engine/client/stub_engine_client.py +++ b/workflow_engine/client/stub_engine_client.py @@ -10,17 +10,15 @@ verify the workflow engine dispatches correctly without real A2A. """ -from typing import Any, Dict, List, Optional -from workflow_engine.core.models import SendMessageResult -from workflow_engine.control.control_points import EventType +from typing import Any, List +from workflow_engine.core.models import MessageContent, ReceivedMessage, SendMessageResult class StubWorkflowEngineClient: """Minimal stub that records sends and returns canned text. - Implements the workflow-send surface only (send_message). The - pre-positioning surface (send_extension_message) lives on - ExtensionSender in production; tests that need to stub it can + Implements the workflow-send surface only. Independent protocol + operations live on ExtensionSender in production; tests that need to stub it can subclass ExtensionSender or build a transport-backed stub. """ @@ -28,13 +26,20 @@ def __init__(self): self.sent: List[tuple] = [] self._control_point = None self._event_callback = None + self._active_execution_id = None - async def send_message(self, agent_name: str, message: str, - context_id: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None) -> SendMessageResult: - self.sent.append((agent_name, message)) + @property + def callback_timeout_seconds(self) -> int: + return 600 + + async def send_message(self, agent_name: str, content: MessageContent) -> SendMessageResult: + self.sent.append((agent_name, content)) + received = ReceivedMessage(message=MessageContent.text(f"OK from {agent_name}")) return SendMessageResult( - text=f"OK from {agent_name}", task_state="COMPLETED") + text=f"OK from {agent_name}", received_messages=(received,)) + + async def dispatch(self, request, content, callbacks=None) -> SendMessageResult: + return await self.send_message(request.agent_name, content) def set_control_point(self, control_point): self._control_point = control_point @@ -42,8 +47,18 @@ def set_control_point(self, control_point): def set_event_callback(self, callback): self._event_callback = callback - def register_handler(self, handler): - pass + def begin_execution(self, execution_id, control_point, event_callback): + if self._active_execution_id is not None: + raise RuntimeError("WorkflowEngineClient is already bound to an execution") + self._active_execution_id = execution_id + self._control_point = control_point + self._event_callback = event_callback + + def end_execution(self, execution_id): + if self._active_execution_id == execution_id: + self._active_execution_id = None + self._control_point = None + self._event_callback = None @property def agent_names(self) -> List[str]: @@ -52,9 +67,6 @@ def agent_names(self) -> List[str]: def update_agent_cards(self, agent_cards: List[Any]): pass - def get_a2at_client(self): - return None - def get_card(self, agent_name: str): return None @@ -65,4 +77,4 @@ async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): - await self.close() \ No newline at end of file + await self.close() diff --git a/workflow_engine/control/control_points.py b/workflow_engine/control/control_points.py index 37126b2..4b735cb 100644 --- a/workflow_engine/control/control_points.py +++ b/workflow_engine/control/control_points.py @@ -15,28 +15,14 @@ # License for the specific language governing permissions and limitations # under the License. -"""Control point interfaces -- user implements the decision layer. - -ControlPoint (workflow control -- drives the workflow forward): -- on_task: send a task to an agent (user decides when/how) [required] -- on_self_task: handle a self-loop task locally [default] -- on_route: choose a branch (user decides which path) [required] -- on_negotiation: supply clarification during Negotiation-T [default] - -Authorization-T and Notification-T are pre-positioning concerns handled -once before the workflow starts via ExtensionSender, not in-workflow -callbacks. EventCallback is optional; instantiate directly as a no-op -sink or subclass. -""" +"""Business-only control callbacks; protocol transport stays inside the engine.""" from abc import ABC, abstractmethod -from typing import Dict, Any, List, Optional, TYPE_CHECKING -from loguru import logger +from typing import Dict, Any, Optional -if TYPE_CHECKING: - from workflow_engine.client.engine_client import WorkflowEngineClient from workflow_engine.core.models import ( - TaskRequest, TaskResponse, RouteDecision, JumpCondition, + MessageContent, NegotiationReply, NegotiationRequest, RouteDecision, + RouteRequest, TaskRequest, TaskResult, ) @@ -87,128 +73,45 @@ class EventType: class ControlPoint(ABC): - """Workflow-control decision interface. - - Each method drives the workflow forward and is called by the - WorkflowExecutor (``on_task`` / ``on_self_task`` / ``on_route``) or the - client auto-negotiate loop (``on_negotiation``). - """ + """Callbacks that prepare business content or make one business decision.""" - @abstractmethod - async def on_task(self, request: TaskRequest, engine_client: "WorkflowEngineClient") -> TaskResponse: - """Called when a step needs to send a task. User decides how to send. - - ``request.message`` holds the full assembled message (upstream context - + task + language hint); ``request.context`` holds just the upstream - context. Call ``engine_client.send_message(request.agent_name, - request.message)`` to dispatch, or skip / transform as you see fit. - """ - ... + async def on_task(self, request: TaskRequest) -> MessageContent: + """Return final message content. The engine sends it after this returns.""" + raise NotImplementedError(f"on_task handler is required for {request.step_name}") - async def on_self_task(self, request: TaskRequest) -> TaskResponse: - """Handle a self-loop task locally, WITHOUT sending an A2A-T message. + async def on_self_task(self, request: TaskRequest) -> TaskResult: + """Run a local task. Missing implementations never echo-success implicitly.""" + raise NotImplementedError(f"on_self_task handler is required for {request.step_name}") - Called when a workflow step is marked ``SELF_LOOP``: the agent - executing the workflow processes the task itself. No - ``engine_client`` is passed on purpose: self-loop tasks must not - send A2A-T messages. Override to handle local aggregation, merge, - or any business logic the workflow-executing agent owns. + async def on_route(self, request: RouteRequest) -> RouteDecision: + """Evaluate one nonblank conditional edge; unconditional edges bypass this hook.""" + raise NotImplementedError( + f"on_route handler is required for edge {request.step_name} -> {request.next_step}" + ) - Default: echoes the task message back as the output. - """ - return TaskResponse(success=True, output=request.message) - - @abstractmethod - async def on_route(self, step_name: str, results: Dict[str, Any], - conditions: List[JumpCondition]) -> RouteDecision: - """Called at a branch. User decides which branch to take. - - ``conditions`` is the list of ``JumpCondition(step, condition)`` - declared on the current step. Return a ``RouteDecision`` whose - ``next_step`` matches one of the conditions' step names. An invalid - ``next_step`` logs a warning and ends the workflow. - """ - ... - - async def on_negotiation(self, agent_name: str, negotiation_text: str, - receive_result: Dict[str, Any]) -> str: - """Provide supplementary data when an agent returns INPUT_REQUIRED. - - Return the clarification text -- the SDK internally resends the - follow-up message. Do NOT send messages here. The engine's - ``send_message`` auto-negotiation loop calls this method when an - agent returns INPUT_REQUIRED. - - Default: returns a generic clarification. - """ - return "Please proceed with the original task using available information." + async def on_negotiation(self, request: NegotiationRequest) -> NegotiationReply: + """Return final Negotiation-T content or explicitly stop local execution.""" + raise NotImplementedError("on_negotiation handler is required") class NegotiationStrategy(ABC): - """Strategy for generating negotiation clarifications. - - 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, DAG- - predecessor forwarding, etc.) implement this interface and inject it - into DefaultControlPoint rather than mixing negotiation policy into - their ControlPoint class. - """ + """Optional strategy object for the negotiation business decision.""" @abstractmethod - async def resolve(self, agent_name: str, negotiation_text: str, - receive_result: Dict[str, Any]) -> str: - """Generate a clarification for the given negotiation request.""" + async def resolve(self, request: NegotiationRequest) -> NegotiationReply: ... class DefaultControlPoint(ControlPoint): - """Default ControlPoint with single-responsibility methods. - - Negotiation-T auto-loop delegates to an injected NegotiationStrategy - (or returns a generic clarification if none is provided). Override - on_negotiation directly when a full strategy object is overkill. - """ + """No implicit task success, route selection, or negotiation consent.""" def __init__(self, negotiation_strategy: Optional["NegotiationStrategy"] = None): self._negotiation_strategy = negotiation_strategy - async def on_task(self, request: TaskRequest, engine_client: "WorkflowEngineClient") -> TaskResponse: - logger.info(f"[DefaultCP] onTask: agent={request.agent_name}, step={request.step_name}") - try: - result = await engine_client.send_message(request.agent_name, request.message) - success = bool(result.text) - logger.info( - f"[DefaultCP] Response from {request.agent_name}: " - f"{len(result.text or '')} chars, success={success}" - ) - return TaskResponse(success=success, output=result.text or "") - except Exception as e: - logger.error(f"[DefaultCP] Task failed for {request.agent_name}: {e}") - return TaskResponse(success=False, error=f"Agent call failed: {e}") - - async def on_self_task(self, request: TaskRequest) -> TaskResponse: - logger.info(f"[DefaultCP] onSelfTask: step={request.step_name}, agent={request.agent_name} (local, no A2A-T)") - return TaskResponse(success=True, output=request.message) - - async def on_route(self, step_name: str, results: Dict[str, Any], - conditions: List[JumpCondition]) -> RouteDecision: - next_step = conditions[0].step - for jc in conditions: - if jc.step not in ("end", "retry", "endNode"): - next_step = jc.step - break - logger.info(f"[DefaultCP] onRoute: {step_name} -> {next_step}") - return RouteDecision(next_step=next_step, reason="default: first non-terminal branch") - - async def on_negotiation(self, agent_name: str, negotiation_text: str, - receive_result: Dict[str, Any]) -> str: - if self._negotiation_strategy is not None: - return await self._negotiation_strategy.resolve( - agent_name, negotiation_text, receive_result) - logger.info(f"[DefaultCP] onNegotiation: agent={agent_name}, concern={negotiation_text}") - return "Please proceed with the original task using available information." + async def on_negotiation(self, request: NegotiationRequest) -> NegotiationReply: + if self._negotiation_strategy is None: + return await super().on_negotiation(request) + return await self._negotiation_strategy.resolve(request) class EventCallback: @@ -220,4 +123,4 @@ class EventCallback: def on_event(self, event_type: str, data: Dict[str, Any]): """Called for each execution event. Default: no-op.""" - return None \ No newline at end of file + return None diff --git a/workflow_engine/core/__init__.py b/workflow_engine/core/__init__.py index 76f26ed..93328fc 100644 --- a/workflow_engine/core/__init__.py +++ b/workflow_engine/core/__init__.py @@ -18,17 +18,24 @@ from workflow_engine.core.models import ( Workflow, WorkflowStep, Task, JumpCondition, - StepType, TaskStatus, ExecutionResult, - SendMessageResult, TaskRequest, TaskResponse, RouteDecision, + StepType, TaskStatus, ExecutionResult, BusinessFailure, BusinessInput, + MessageContent, ReceivedArtifact, ReceivedMessage, A2AStreamEvent, SendMessageResult, + TaskRequest, TaskResult, TaskExecutionResult, UpstreamStepResult, + WorkflowInput, RouteRequest, RouteDecision, NegotiationRequest, + NegotiationReply, NegotiationSend, NegotiationStop, NegotiationExchange, WorkflowSearchResult, ) from workflow_engine.core.context_builder import ContextBuilder from workflow_engine.core.executor import WorkflowExecutor +from workflow_engine.core.workflow_validator import validate_workflow, find_workflow_errors __all__ = [ "Workflow", "WorkflowStep", "Task", "JumpCondition", - "StepType", "TaskStatus", "ExecutionResult", - "SendMessageResult", "TaskRequest", "TaskResponse", "RouteDecision", + "StepType", "TaskStatus", "ExecutionResult", "BusinessFailure", "BusinessInput", + "MessageContent", "ReceivedArtifact", "ReceivedMessage", "A2AStreamEvent", "SendMessageResult", + "TaskRequest", "TaskResult", "TaskExecutionResult", "UpstreamStepResult", + "WorkflowInput", "RouteRequest", "RouteDecision", "NegotiationRequest", + "NegotiationReply", "NegotiationSend", "NegotiationStop", "NegotiationExchange", "WorkflowSearchResult", - "ContextBuilder", "WorkflowExecutor", + "ContextBuilder", "WorkflowExecutor", "validate_workflow", "find_workflow_errors", ] diff --git a/workflow_engine/core/context_builder.py b/workflow_engine/core/context_builder.py index fcf3b46..803e563 100644 --- a/workflow_engine/core/context_builder.py +++ b/workflow_engine/core/context_builder.py @@ -15,13 +15,15 @@ # License for the specific language governing permissions and limitations # under the License. -"""Context assembly for the Workflow Execution SDK.""" +"""Select structured upstream results according to workflow dependencies.""" from collections import deque -from typing import Dict, Any, List, Optional +from typing import Dict, List, Optional from loguru import logger -from workflow_engine.core.models import Workflow, WorkflowStep +from workflow_engine.core.models import ( + TaskExecutionResult, UpstreamStepResult, Workflow, WorkflowInput, WorkflowStep, +) class ContextBuilder: @@ -41,61 +43,43 @@ def get_step_predecessors(self, step_name: str) -> List[str]: return predecessors def get_all_predecessors(self, step_name: str) -> List[str]: - ancestors = set() + ancestors = [] + seen = set() queue = deque([step_name]) while queue: current = queue.popleft() for s in self.workflow.steps: if s.next: for jc in s.next: - if jc.step == current and s.name != current and s.name not in ancestors: - ancestors.add(s.name) + if jc.step == current and s.name != current and s.name not in seen: + seen.add(s.name) + ancestors.append(s.name) queue.append(s.name) break - return list(ancestors) + return ancestors - def build_context(self, step: WorkflowStep, step_outputs: Dict[str, Dict[str, Any]]) -> str: - if step.layer <= 0: - if self.runtime_intent: - logger.info(f"[Context] Step {step.name}: layer 0, using runtime intent only") - return f"## Runtime Context\n\n{self.runtime_intent}" - logger.info(f"[Context] Step {step.name}: layer 0, no context") - return "" - parts = [] - if self.runtime_intent: - parts.append(f"## Runtime Context\n\n{self.runtime_intent}") - parts.append("## Previous Step Execution Results\n") + def build_workflow_input( + self, + step: WorkflowStep, + step_results: Dict[str, list[TaskExecutionResult]], + ) -> WorkflowInput: if step.context_from and "*" in step.context_from: - all_pred = self.get_all_predecessors(step.name) - ref_pairs = [(n, step_outputs[n]) for n in all_pred if n in step_outputs] - logger.info(f"[Context] Step {step.name}: using ALL predecessors ({len(ref_pairs)} available)") + selected = [name for name in self.get_all_predecessors(step.name) if name in step_results] elif step.context_from: - ref_pairs = [(n, step_outputs[n]) for n in step.context_from if n in step_outputs] - logger.info(f"[Context] Step {step.name}: using context_from={step.context_from} ({len(ref_pairs)} available)") + selected = [name for name in step.context_from if name in step_results] + elif step.context_from is None: + selected = [name for name in self.get_step_predecessors(step.name) if name in step_results] else: - pred_names = self.get_step_predecessors(step.name) - ref_pairs = [(n, step_outputs[n]) for n in pred_names if n in step_outputs] - logger.info(f"[Context] Step {step.name}: using direct predecessors={pred_names} ({len(ref_pairs)} available)") - for ref_step_name, ref_results in ref_pairs: - parts.append(f"### {ref_step_name} Results\n") - for task_desc, output in ref_results.items(): - text = output if isinstance(output, str) else str(output) - parts.append(f"**Task**: {task_desc}\n**Output**: {text}\n\n") - result = "\n".join(parts).strip() - logger.info(f"[Context] Step {step.name}: built context ({len(result)} chars)") - if result: - logger.info(f"[Context] Content:\n{result[:2000]}") - return result - - def build_task_message(self, task_description: str, context_message: str, lang: str = "zh") -> str: - lang_hint = "" - if lang == "en": - lang_hint = "\n\nPlease respond in English." - elif lang == "zh": - lang_hint = "\n\n请用中文回复。" - if context_message: - return f"{context_message}\n\n## Current Task\n{task_description}{lang_hint}" - return f"{task_description}{lang_hint}" + selected = [] + upstream = tuple( + UpstreamStepResult(name, tuple(step_results[name])) for name in selected + ) + result_count = sum(len(item.task_results) for item in upstream) + logger.info( + f"[Context] Step {step.name}: selected {len(upstream)} upstream step(s), " + f"{result_count} task result(s)" + ) + return WorkflowInput(self.runtime_intent, upstream) def find_step_index(self, step_name: str) -> Optional[int]: return self._step_index.get(step_name) diff --git a/workflow_engine/core/executor.py b/workflow_engine/core/executor.py index b737e06..5f99237 100644 --- a/workflow_engine/core/executor.py +++ b/workflow_engine/core/executor.py @@ -1,278 +1,368 @@ -# Copyright (c) 2026 Huawei Technologies Co., Ltd. +# 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. -"""WorkflowExecutor - DAG traversal, delegates to ControlPoint.""" +"""DAG execution with protocol-neutral business callbacks.""" + +from __future__ import annotations import asyncio -import time -from collections import deque -from typing import Dict, Any, List, Optional, TYPE_CHECKING +import copy +import uuid +from collections import Counter, deque +from typing import Any, Dict, Optional + from loguru import logger +from workflow_engine.control.control_points import ControlPoint, EventCallback, EventType +from workflow_engine.core.context_builder import ContextBuilder +from workflow_engine.core.failure_mapping import failure_to_task_result from workflow_engine.core.models import ( - Workflow, WorkflowStep, Task, StepType, TaskStatus, - ExecutionResult, TaskRequest, TaskResponse, RouteDecision, + BusinessInput, ExecutionResult, MessageContent, RouteDecision, RouteRequest, + SendMessageResult, StepType, Task, TaskExecutionResult, TaskRequest, TaskResult, + TaskStatus, Workflow, WorkflowInput, WorkflowStep, ) -from workflow_engine.core.context_builder import ContextBuilder -from workflow_engine.control.control_points import ControlPoint, EventCallback - -if TYPE_CHECKING: - from workflow_engine.client.engine_client import WorkflowEngineClient +from workflow_engine.core.workflow_validator import TERMINAL_TARGETS, validate_workflow class WorkflowExecutor: - """Main entry point. Traverses DAG, calls ControlPoint at decision points.""" + """Single-use workflow executor. Ready steps and step subtasks run concurrently.""" def __init__( self, workflow: Workflow, control_point: ControlPoint, - engine_client: "WorkflowEngineClient", + engine_client, event_callback: Optional[EventCallback] = None, runtime_intent: str = "", lang: str = "zh", ): - self.workflow = workflow + self.workflow = copy.deepcopy(workflow) self.control_point = control_point self.engine_client = engine_client - self.engine_client.set_control_point(control_point) - try: - self.engine_client.set_event_callback(event_callback) - except Exception: - pass - self.event_callback = event_callback - self.lang = lang - self.context_builder = ContextBuilder(workflow, runtime_intent) + self.event_callback = event_callback or EventCallback() + self.lang = lang or "zh" + self.execution_id = str(uuid.uuid4()) + self.context_builder = ContextBuilder(self.workflow, runtime_intent) self.step_outputs: Dict[str, Dict[str, Any]] = {} - self.execution_history: List[Dict[str, Any]] = [] - logger.info(f"[Executor] Workflow: {workflow.name}, steps={len(workflow.steps)}, intent={runtime_intent[:80] if runtime_intent else None}, lang={lang}") + self.step_execution_results: Dict[str, list[TaskExecutionResult]] = {} + self.execution_history: list[Dict[str, Any]] = [] + self._started = False - def _emit_event(self, event_type: str, data: Dict[str, Any]): - if self.event_callback: - try: - self.event_callback.on_event(event_type, data) - except Exception as e: - logger.warning(f"Event callback error: {e}") + def _emit_event(self, event_type: str, data: Dict[str, Any]) -> None: + correlated = dict(data) + correlated["execution_id"] = self.execution_id + try: + self.event_callback.on_event(event_type, correlated) + except Exception as exc: + logger.warning(f"Event callback error: {exc}") + + def _task_id(self, step_name: str, index: int) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_URL, f"{self.execution_id}:{step_name}:{index}")) async def run(self) -> ExecutionResult: - """Execute the workflow DAG with parallel step dispatch. + if self._started: + raise RuntimeError("WorkflowExecutor is single-use") + self._started = True + self.engine_client.begin_execution( + self.execution_id, self.control_point, self.event_callback, + ) + try: + return await self._run_bound() + finally: + self.engine_client.end_execution(self.execution_id) - Mirrors Java's executeSteps: collects all ready steps (predecessors - satisfied), dispatches them concurrently via asyncio.gather, then - processes their next-step indices. Steps at the same layer run in - parallel; subtasks within a step also run in parallel. - """ - logger.info(f"[Executor] Starting workflow: {self.workflow.name} ({len(self.workflow.steps)} steps)") - pending = deque([ - i for i, s in enumerate(self.workflow.steps) - if s.layer == 0 and not self.context_builder.get_step_predecessors(s.name) - ]) - executed: set = set() - defer_count: Dict[int, int] = {} - failed = False + async def _run_bound(self) -> ExecutionResult: try: - while pending and not failed: - ready, deferred = self._collect_ready(pending, executed, defer_count) - for idx in deferred: - pending.append(idx) + validate_workflow(self.workflow) + except ValueError as exc: + self._emit_event(EventType.ERROR, {"error": str(exc)}) + return ExecutionResult(False, error=str(exc)) + + pending = deque( + index for index, step in enumerate(self.workflow.steps) + if not self.context_builder.get_step_predecessors(step.name) + ) + activated = set(pending) + scheduled = set(pending) + executed: set[int] = set() + failure: Optional[str] = None + + try: + while pending and failure is None: + ready, deferred = self._collect_ready(pending, activated, executed) + pending.extend(deferred) if not ready: - if deferred: - await asyncio.sleep(0.05) - continue - break + missing = { + self.workflow.steps[index].name: [ + name for name in self.context_builder.get_step_predecessors( + self.workflow.steps[index].name + ) + if (pred_index := self.context_builder.find_step_index(name)) in activated + and name not in self.step_outputs + ] + for index in deferred + } + raise RuntimeError( + f"Workflow dependency deadlock; unresolved active predecessors: {missing}" + ) executed.update(ready) results = await asyncio.gather( - *[self._execute_step(idx) for idx in ready], + *(self._execute_step(index) for index in ready), return_exceptions=True, ) - failed = self._process_results(ready, results, pending, executed) - except Exception as e: - logger.critical(f"DAG traversal error: {e}", exc_info=True) - return ExecutionResult(success=False, history=self.execution_history, - step_outputs=self.step_outputs, error=str(e)) - self._emit_event("workflow_complete", {}) - logger.info(f"[Executor] Workflow completed: {self.workflow.name}, {len(self.execution_history)} task(s) executed") - return ExecutionResult(success=not failed, history=self.execution_history, - step_outputs=self.step_outputs, - error=("Step execution failed" if failed else None)) + for index, result in zip(ready, results): + if isinstance(result, BaseException): + raise result + success, next_indices = result + if not success: + failure = "Step execution failed" + break + for target in reversed(next_indices): + activated.add(target) + if target not in scheduled: + scheduled.add(target) + pending.appendleft(target) + except asyncio.CancelledError: + raise + except Exception as exc: + failure = str(exc) or type(exc).__name__ + logger.opt(exception=True).error(f"[Executor] DAG traversal error: {failure}") + self._emit_event(EventType.ERROR, {"error": failure}) - def _collect_ready(self, pending: deque, executed: set, - defer_count: Dict[int, int]) -> tuple: - """Drain pending into ready (predecessors satisfied) and deferred.""" - ready: List[int] = [] - deferred: List[int] = [] + self._emit_event(EventType.WORKFLOW_COMPLETE, {"success": failure is None}) + return ExecutionResult( + success=failure is None, + history=list(self.execution_history), + step_outputs=dict(self.step_outputs), + error=failure, + ) + + def _collect_ready(self, pending, activated, executed): + ready, deferred = [], [] while pending: - idx = pending.popleft() - if idx >= len(self.workflow.steps) or idx in executed: + index = pending.popleft() + if index >= len(self.workflow.steps) or index in executed: continue - step = self.workflow.steps[idx] - predecessors = self.context_builder.get_step_predecessors(step.name) - if all(p in self.step_outputs for p in predecessors): - ready.append(idx) - else: - dc = defer_count.get(idx, 0) + 1 - if dc > len(self.workflow.steps): - executed.add(idx) - continue - defer_count[idx] = dc - deferred.append(idx) + step = self.workflow.steps[index] + active_predecessors = [ + name for name in self.context_builder.get_step_predecessors(step.name) + if (pred_index := self.context_builder.find_step_index(name)) in activated + ] + (ready if all(name in self.step_outputs for name in active_predecessors) + else deferred).append(index) return ready, deferred - async def _execute_step(self, idx: int) -> tuple: - """Execute one step: subtasks + next-step determination. + async def _execute_step(self, index: int) -> tuple[bool, list[int]]: + step = self.workflow.steps[index] + self._emit_event(EventType.STEP_START, {"step": step.name}) + results, task_results, success = await self._execute_subtasks(step) + self.step_outputs[step.name] = results + self.step_execution_results[step.name] = task_results + if not success: + self._emit_event(EventType.ERROR, { + "step": step.name, "results": results, + "error": "Step execution failed", "error_code": "workflow.step_failed", + }) + return False, [] + self._emit_event(EventType.STEP_COMPLETE, {"step": step.name, "results": results}) + return True, await self._determine_next_steps(step) - Returns (step_name, step_result, success, next_indices). - """ - step = self.workflow.steps[idx] - t_step = time.time() - logger.info(f"--- Executing step: {step.name} ---") - self._emit_event("step_start", {"step": step.name}) - step_result, success = await self._execute_subtasks(step) - self.step_outputs[step.name] = step_result - next_indices: List[int] = [] - if success: - self._emit_event("step_complete", {"step": step.name, "results": step_result}) - next_indices = await self._determine_next_steps(step, step_result) - else: - logger.error(f"Step {step.name} failed, stopping.") - self._emit_event("error", {"step": step.name, "results": step_result}) - logger.info(f"[Timing] Step '{step.name}' total: {time.time()-t_step:.2f}s, success={success}") - return step.name, step_result, success, next_indices + def _build_request( + self, step: WorkflowStep, task: Task, index: int, workflow_input: WorkflowInput, + ) -> TaskRequest: + return TaskRequest( + execution_id=self.execution_id, + task_id=self._task_id(step.name, index), + input=task.input or BusinessInput.from_text(task.description), + agent_name=task.agent, + skill=task.skill, + instruction=task.description, + language=self.lang, + step_name=step.name, + workflow_input=workflow_input, + ) - def _process_results(self, ready: List[int], results: list, - pending: deque, executed: set) -> bool: - """Process asyncio.gather results, enqueue next steps. Returns failed.""" - for idx, result in zip(ready, results): - if isinstance(result, Exception): - step = self.workflow.steps[idx] - logger.error(f"Step {step.name} raised: {result}") - self._emit_event("error", {"step": step.name, "error": str(result)}) - return True - _, _, success, next_indices = result - if not success: - return True - for nxt in reversed(next_indices): - if nxt not in executed and nxt not in pending: - pending.appendleft(nxt) - return False + async def _execute_subtasks( + self, step: WorkflowStep, + ) -> tuple[Dict[str, Any], list[TaskExecutionResult], bool]: + workflow_input = self.context_builder.build_workflow_input( + step, self.step_execution_results + ) + coroutines = [ + self._execute_single(step, task, index, workflow_input) + for index, task in enumerate(step.subtasks) + ] + if step.step_type == StepType.ANY_SUCCESS: + tasks = [asyncio.create_task(coro) for coro in coroutines] + completed: list[tuple[str, int, TaskExecutionResult]] = [] + try: + for future in asyncio.as_completed(tasks): + value = await future + completed.append(value) + if value[2].status == TaskStatus.SUCCESS: + for pending in tasks: + if not pending.done(): + pending.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + return self._collect_results(completed, True) + return self._collect_results(completed, False) + finally: + for pending in tasks: + if not pending.done(): + pending.cancel() - async def _execute_subtasks(self, step: WorkflowStep) -> tuple[Dict[str, Any], bool]: - context_message = self.context_builder.build_context(step, self.step_outputs) - results: Dict[str, Any] = {} - logger.info(f"[Executor] Step {step.name}: {len(step.subtasks)} subtask(s), type={step.step_type.value}") + completed = await asyncio.gather(*coroutines) + return self._collect_results( + list(completed), + all(item[2].status == TaskStatus.SUCCESS for item in completed), + ) - async def execute_single(task: Task, subtask_index: int) -> tuple[str, Any, bool]: - task_message = self.context_builder.build_task_message(task.description, context_message, self.lang) - request = TaskRequest(agent_name=task.agent, skill=task.skill, message=task_message, - description=task.description, - context=context_message, step_name=step.name, subtask_index=subtask_index) - self._emit_event("task_request", {"step": step.name, "agent": task.agent, "task": task.description}) - logger.info(f"[Executor] Dispatching task: step={step.name}, agent={task.agent}, subtask_index={subtask_index}, desc={task.description}") - logger.debug(f"[Executor] Task message to {task.agent}: [{task_message}]") - t_task = time.time() - try: - # SELF_LOOP steps are handled locally without sending an - # A2A-T message (mirrors Java dispatchTask SELF_LOOP branch). - if step.step_type == StepType.SELF_LOOP: - logger.info(f"[Executor] Self-loop task: step={step.name}, agent={task.agent} (local, no A2A-T)") - response = await self.control_point.on_self_task(request) - else: - response = await self.control_point.on_task(request, self.engine_client) - logger.info(f"[Timing] Task '{task.description}' -> {task.agent}: {time.time()-t_task:.2f}s") - task.status = TaskStatus.SUCCESS if response.success else TaskStatus.FAILED - self._emit_event("task_status_changed", {"step": step.name, "subtask_index": subtask_index, "agent": task.agent, "status": task.status.value}) - status = "success" if response.success else "failed" - logger.info(f"[Executor] Task {task.description[:60]} -> {task.agent}: {status}") - if response.success and response.output: - logger.debug(f"[Executor] Task output from {task.agent}: [{response.output}]") - self.execution_history.append({"step": step.name, "task": task.description, "agent": task.agent, - "status": status, - "output": response.output if response.success else (response.error or "")}) - self._emit_event("task_response", {"step": step.name, "agent": task.agent, "task": task.description, - "output": response.output if response.success else (response.error or "")}) - return task.description, response.output, response.success - except Exception as e: - logger.info(f"[Timing] Task '{task.description}' -> {task.agent}: {time.time()-t_task:.2f}s (failed)") - task.status = TaskStatus.FAILED - self._emit_event("task_status_changed", {"step": step.name, "subtask_index": subtask_index, "agent": task.agent, "status": task.status.value}) - logger.error(f"[Executor] Task {task.description[:60]} -> {task.agent}: exception: {e}") - self.execution_history.append({"step": step.name, "task": task.description, "agent": task.agent, - "status": "failed", "output": str(e)}) - return task.description, {"error": str(e)}, False + async def _execute_single( + self, step: WorkflowStep, task: Task, index: int, workflow_input: WorkflowInput, + ) -> tuple[str, int, TaskExecutionResult]: + request = self._build_request(step, task, index, workflow_input) + self._emit_event(EventType.TASK_REQUEST, { + "step": step.name, "agent": task.agent, "task": task.description, + "subtask_index": index, "task_id": request.task_id, + }) + try: + if step.step_type == StepType.SELF_LOOP: + result = await asyncio.wait_for( + self.control_point.on_self_task(request), + timeout=self.engine_client.callback_timeout_seconds, + ) + else: + async def prepare_and_dispatch() -> SendMessageResult: + content = await self.control_point.on_task(request) + if not isinstance(content, MessageContent): + raise TypeError("on_task must return MessageContent") + return await self.engine_client.dispatch( + request, content, self.control_point + ) - if step.step_type == StepType.ANY_SUCCESS: - tasks = [asyncio.create_task(execute_single(t, i)) for i, t in enumerate(step.subtasks)] - for coro in asyncio.as_completed(tasks): - desc, output, success = await coro - results[desc] = output - if success: - logger.info(f"[Executor] Step {step.name}: ANY_SUCCESS, first success for task: {desc}") - for t in tasks: - if not t.done(): t.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - return results, True - return results, False - gathered = await asyncio.gather(*[execute_single(t, i) for i, t in enumerate(step.subtasks)]) - failed = False - for desc, output, success in gathered: - results[desc] = output - if not success: failed = True - return results, not failed + sent = await asyncio.wait_for( + prepare_and_dispatch(), + timeout=self.engine_client.callback_timeout_seconds, + ) + result = self._protocol_result(sent) + if not isinstance(result, TaskResult): + raise TypeError("on_self_task must return TaskResult") + except asyncio.CancelledError: + raise + except Exception as exc: + result = failure_to_task_result(exc) + + task.status = TaskStatus.SUCCESS if result.success else TaskStatus.FAILED + execution_result = TaskExecutionResult( + agent_name=task.agent, + skill=task.skill, + task_id=request.task_id, + task_description=task.description, + status=task.status, + outputs=result.outputs, + received_messages=result.received_messages, + error=result.error, + error_code=result.error_code, + error_details=result.error_details, + ) + history = { + "step": step.name, "subtask_index": index, "task": task.description, + "agent": task.agent, "status": task.status.value, "task_id": request.task_id, + "outputs": result.outputs, "error": result.error or "", + "error_code": result.error_code or "", "error_details": result.error_details, + } + self.execution_history.append(history) + self._emit_event(EventType.TASK_STATUS_CHANGED, { + "step": step.name, "subtask_index": index, + "agent": task.agent, "status": task.status.value, + }) + self._emit_event(EventType.TASK_RESPONSE, history) + return task.description, index, execution_result - async def _determine_next_steps(self, step: WorkflowStep, step_result: Dict[str, Any]) -> List[int]: + @staticmethod + def _protocol_result(result: SendMessageResult) -> TaskResult: + standalone = result.task is None and bool(result.received_messages) + success = ( + result.task_state == "TASK_STATE_COMPLETED" + or (result.task_state in {"", "TASK_STATE_UNSPECIFIED"} and standalone) + ) and result.failure_code is None + return TaskResult( + success=success, + received_messages=result.received_messages, + error=None if success else ( + result.failure_message or f"Agent returned state={result.task_state}" + ), + error_code=None if success else (result.failure_code or "remote.task_failed"), + ) + + @staticmethod + def _collect_results(completed, success): + counts = Counter(description for description, _, _ in completed) + outputs: Dict[str, Any] = {} + task_results = [] + for description, index, result in completed: + key = description + if counts[description] > 1: + key = f"{description} [{result.agent_name}#{index}]" + outputs[key] = result.outputs + task_results.append(result) + return outputs, task_results, success + + async def _determine_next_steps(self, step: WorkflowStep) -> list[int]: if not step.next: return [] - # All-unconditional next steps -> fan out (parallel execution), - # skipping terminal markers. Mirrors the original engine's semantics: - # empty conditions mean "go to all of them", not "pick one". - if all(not jc.condition for jc in step.next): - indices = [] - for jc in step.next: - if jc.step in ("end", "retry", "endNode"): - continue - idx = self.context_builder.find_step_index(jc.step) - if idx is not None: - indices.append(idx) - return indices - # Has conditional branches -> user decides via on_route. - # Build route context: merge context_from upstream results + current - # step results (mirrors Java's determineNextSteps routeContext). - route_context: Dict[str, Any] = {} - if step.context_from: - for ref in step.context_from: - if ref in self.step_outputs: - route_context[ref] = self.step_outputs[ref] - route_context[step.name] = step_result - decision = await self.control_point.on_route(step.name, route_context, step.next) - logger.info(f"Route for '{step.name}': {decision.next_step} ({decision.reason})") - self._emit_event("route_decision", {"step": step.name, "next": decision.next_step, "reason": decision.reason}) - idx = self.context_builder.find_step_index(decision.next_step) - if idx is None: - allowed = [jc.step for jc in step.next] - logger.warning( - f"on_route returned '{decision.next_step}' for step '{step.name}', " - f"not in allowed next steps {allowed}; workflow will end." + workflow_input = self.context_builder.build_workflow_input( + step, self.step_execution_results + ) + current_results = tuple(self.step_execution_results.get(step.name, ())) + + async def evaluate(edge): + conditional = bool(edge.condition and edge.condition.strip()) + if not conditional: + return edge, conditional, RouteDecision.allow("unconditional edge") + request = RouteRequest( + self.execution_id, step.name, edge.step, edge.condition, + workflow_input, current_results, ) - return [idx] if idx is not None else [] + try: + decision = await asyncio.wait_for( + self.control_point.on_route(request), + timeout=self.engine_client.callback_timeout_seconds, + ) + except Exception as exc: + raise RuntimeError( + f"on_route failed for edge {step.name} -> {edge.step}: " + f"{str(exc) or type(exc).__name__}" + ) from exc + if not isinstance(decision, RouteDecision): + raise TypeError( + f"on_route failed for edge {step.name} -> {edge.step}: " + "callback must return RouteDecision" + ) + return edge, conditional, decision + evaluations = await asyncio.gather(*(evaluate(edge) for edge in step.next)) + targets = [] + for edge, conditional, decision in evaluations: + self._emit_event(EventType.ROUTE_DECISION, { + "step": step.name, "next": edge.step, "condition": edge.condition or "", + "conditional": conditional, "allowed": decision.allowed, + "reason": decision.reason, + }) + if not decision.allowed or edge.step in TERMINAL_TARGETS: + continue + target = self.context_builder.find_step_index(edge.step) + if target is None: + raise RuntimeError(f"Route target does not exist: {edge.step}") + targets.append(target) + return targets @property def current_step_outputs(self) -> Dict[str, Dict[str, Any]]: - return self.step_outputs + return dict(self.step_outputs) + @property - def history(self) -> List[Dict[str, Any]]: - return self.execution_history + def history(self) -> list[Dict[str, Any]]: + return list(self.execution_history) diff --git a/workflow_engine/core/failure_mapping.py b/workflow_engine/core/failure_mapping.py new file mode 100644 index 0000000..7eee592 --- /dev/null +++ b/workflow_engine/core/failure_mapping.py @@ -0,0 +1,59 @@ +# Copyright (c) 2026 Huawei Technologies Co., Ltd. +# All Rights Reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Map failures to stable workflow-facing diagnostics.""" + +from __future__ import annotations + +import asyncio + +from workflow_engine.core.models import BusinessFailure, TaskResult + + +def failure_to_task_result(error: BaseException) -> TaskResult: + current = error + seen = set() + while current.__cause__ is not None and id(current) not in seen: + if isinstance(current, BusinessFailure): + break + seen.add(id(current)) + current = current.__cause__ + + if isinstance(current, BusinessFailure): + return TaskResult( + success=False, error_code=current.code, error=str(current), + error_details=current.details, + ) + + try: + from a2a.utils.errors import A2AError, A2A_ERROR_MAPPING + + if isinstance(current, A2AError): + mapping = A2A_ERROR_MAPPING.get(type(current)) + reason = mapping.reason if mapping else "" + code = f"a2a.{reason.lower()}" if reason else "a2a.remote_error" + details = dict(getattr(current, "data", None) or {}) + if mapping: + details.update({ + "http_status": mapping.http_code, + "status": mapping.grpc_status, + "reason": mapping.reason, + "domain": "a2a-protocol.org", + }) + return TaskResult( + success=False, error_code=code, + error=getattr(current, "message", type(current).__name__), + error_details=details, + ) + except ImportError: + pass + + if isinstance(current, (TimeoutError, asyncio.TimeoutError)): + code = "workflow.timeout" + elif isinstance(current, asyncio.CancelledError): + code = "workflow.cancelled" + else: + code = "workflow.execution_failed" + return TaskResult.failed(code, str(current) or type(current).__name__) diff --git a/workflow_engine/core/models.py b/workflow_engine/core/models.py index c1283db..0177727 100644 --- a/workflow_engine/core/models.py +++ b/workflow_engine/core/models.py @@ -2,24 +2,34 @@ # 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. -"""Data models for the Workflow Execution SDK.""" +"""Protocol-neutral public models for the workflow execution SDK.""" + +from __future__ import annotations +import copy +import json from dataclasses import dataclass, field from enum import Enum -from typing import List, Optional, Dict, Any +from types import MappingProxyType +from typing import Any, Mapping, Optional, Sequence + + +def _snapshot(value: Any) -> Any: + """Return an immutable JSON-value snapshot without imposing a business schema.""" + if isinstance(value, Mapping): + return MappingProxyType({str(key): _snapshot(item) for key, item in value.items()}) + if isinstance(value, (list, tuple)): + return tuple(_snapshot(item) for item in value) + if isinstance(value, (str, int, float, bool, type(None))): + return value + raise TypeError( + "Business values must contain only JSON-compatible mappings, sequences, and scalars" + ) + + +def _snapshot_mapping(value: Optional[Mapping[str, Any]]) -> Mapping[str, Any]: + return _snapshot(value or {}) class StepType(Enum): @@ -29,24 +39,18 @@ class StepType(Enum): @classmethod def from_value(cls, value: Any) -> "StepType": - """Case-insensitive lookup by enum value or name. - - Accepts "AllSuccess", "ALLSUCCESS", "allsuccess", "ALL_SUCCESS", - "any_success", etc. Falls back to ALL_SUCCESS when unknown. - Mirrors the Java SDK's StepType.fromValue(). - """ if not value: return cls.ALL_SUCCESS - if isinstance(value, StepType): + if isinstance(value, cls): return value - # Handle enum objects from other modules (e.g. the orchestration - # center's own StepType): extract .value so str() does not produce - # "StepType.SELF_LOOP" which would never match. if hasattr(value, "value"): value = value.value - text = str(value).strip() + normalized = str(value).strip().replace("_", "").lower() for member in cls: - if member.value.lower() == text.lower() or member.name.lower() == text.lower(): + if normalized in { + member.name.replace("_", "").lower(), + member.value.replace("_", "").lower(), + }: return member return cls.ALL_SUCCESS @@ -58,19 +62,145 @@ class TaskStatus(Enum): FAILED = "failed" +@dataclass(frozen=True) +class BusinessInput: + """Current task business input. Exactly one of ``text`` and ``data`` is present.""" + + text: Optional[str] = None + data: Any = None + + def __post_init__(self) -> None: + if (self.text is None) == (self.data is None): + raise ValueError("Exactly one of text or data is required") + if self.text is not None and not self.text.strip(): + raise ValueError("Text must not be blank") + if self.data is not None: + object.__setattr__(self, "data", _snapshot(self.data)) + + @classmethod + def from_text(cls, text: str) -> "BusinessInput": + return cls(text=text) + + @classmethod + def from_data(cls, data: Any) -> "BusinessInput": + return cls(data=data) + + +@dataclass(frozen=True) +class MessageContent: + """Final A2A business content; the engine owns the message envelope and transport.""" + + parts: tuple[Any, ...] + metadata: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({})) + extensions: frozenset[str] = field(default_factory=frozenset) + + def __post_init__(self) -> None: + if self.parts is None: + raise ValueError("parts is required") + from a2a.types import Part + + parts = tuple(copy.deepcopy(part) for part in self.parts) + for part in parts: + if not isinstance(part, Part): + raise TypeError("Every message part must be an a2a.types.Part") + if part.WhichOneof("content") is None: + raise ValueError("Every message part must contain text, data, raw, or url content") + for uri in self.extensions: + if not isinstance(uri, str) or not uri.strip(): + raise ValueError("Extension URI must not be blank") + object.__setattr__(self, "parts", parts) + object.__setattr__(self, "metadata", _snapshot_mapping(self.metadata)) + object.__setattr__(self, "extensions", frozenset(self.extensions)) + + @classmethod + def text(cls, text: str) -> "MessageContent": + if text is None: + raise ValueError("text is required") + from a2a.types import Part + + return cls(parts=(Part(text=text),)) + + @classmethod + def from_parts(cls, parts: Sequence[Any]) -> "MessageContent": + return cls(parts=tuple(parts)) + + +@dataclass(frozen=True) +class ReceivedArtifact: + artifact_id: str = "" + name: str = "" + description: str = "" + parts: tuple[Any, ...] = () + metadata: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({})) + extensions: tuple[str, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "parts", tuple(copy.deepcopy(part) for part in self.parts)) + object.__setattr__(self, "metadata", _snapshot_mapping(self.metadata)) + object.__setattr__(self, "extensions", tuple(self.extensions or ())) + + +def _part_value(part: Any) -> Any: + which = part.WhichOneof("content") if hasattr(part, "WhichOneof") else None + if which == "text" or (which is None and getattr(part, "text", "")): + return part.text + if which == "data": + from google.protobuf.json_format import MessageToDict + + return MessageToDict(part.data, preserving_proto_field_name=True) + return None + + +@dataclass(frozen=True) +class ReceivedMessage: + """Structured response evidence with protocol envelope identifiers removed.""" + + message: Optional[MessageContent] = None + task_metadata: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({})) + artifacts: tuple[ReceivedArtifact, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "task_metadata", _snapshot_mapping(self.task_metadata)) + object.__setattr__(self, "artifacts", tuple(self.artifacts or ())) + + def outputs(self, include_message: bool = True) -> tuple[Any, ...]: + values: list[Any] = [] + if include_message and self.message is not None and not self.artifacts: + values.extend(value for value in map(_part_value, self.message.parts) if value is not None) + for artifact in self.artifacts: + values.extend(value for value in map(_part_value, artifact.parts) if value is not None) + return tuple(_snapshot(value) for value in values) + + +@dataclass(frozen=True) +class A2AStreamEvent: + """Normalized event from one public A2A message stream.""" + + event_type: str + task_id: str = "" + context_id: str = "" + task_state: str = "" + is_final: bool = False + message: Optional[MessageContent] = None + artifacts: tuple[ReceivedArtifact, ...] = () + metadata: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({})) + + def __post_init__(self) -> None: + if self.event_type not in { + "task", "message", "status_update", "artifact_update", + }: + raise ValueError(f"Unsupported A2A stream event type: {self.event_type}") + object.__setattr__(self, "artifacts", tuple(self.artifacts or ())) + object.__setattr__(self, "metadata", _snapshot_mapping(self.metadata)) + + @dataclass class WorkflowSearchResult: - """Summary of a PSOP workflow returned by the search endpoint. - - Mirrors the Java SDK's WorkflowSearchResult. Returned by - ``search_psop()``. To get the full workflow with steps, take - ``workflow_id`` and call ``load_psop()``. - """ workflow_id: str = "" workflow_type: str = "" name: str = "" description: str = "" - tags: List[str] = field(default_factory=list) + tags: list[str] = field(default_factory=list) created_at: str = "" score: float = 1.0 user_intent: str = "" @@ -78,13 +208,13 @@ class WorkflowSearchResult: tasks_summary: str = "" @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "WorkflowSearchResult": + def from_dict(cls, data: Mapping[str, Any]) -> "WorkflowSearchResult": return cls( workflow_id=data.get("workflow_id", data.get("id", "")), workflow_type=data.get("workflow_type", ""), name=data.get("name", ""), description=data.get("description", ""), - tags=data.get("tags", []), + tags=list(data.get("tags", [])), created_at=str(data["created_at"]) if data.get("created_at") else "", score=float(data["score"]) if isinstance(data.get("score"), (int, float)) else 1.0, user_intent=data.get("user_intent", ""), @@ -105,15 +235,16 @@ class Task: skill: str = "" description: str = "" status: TaskStatus = TaskStatus.PENDING + input: Optional[BusinessInput] = None @dataclass class WorkflowStep: name: str - subtasks: List[Task] = field(default_factory=list) - next: List[JumpCondition] = field(default_factory=list) + subtasks: list[Task] = field(default_factory=list) + next: list[JumpCondition] = field(default_factory=list) layer: int = 0 - context_from: Optional[List[str]] = None + context_from: Optional[list[str]] = None step_type: StepType = StepType.ALL_SUCCESS @@ -122,63 +253,281 @@ class Workflow: id: str = "" name: str = "" description: str = "" - steps: List[WorkflowStep] = field(default_factory=list) + steps: list[WorkflowStep] = field(default_factory=list) @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "Workflow": + def from_dict(cls, data: Mapping[str, Any]) -> "Workflow": steps = [] - for s in data.get("steps", []): - subtasks = [Task(agent=t.get("agent",""), skill=t.get("skill",""), description=t.get("description","")) for t in (s.get("subtasks") or [])] - next_list = [JumpCondition(step=jc.get("step",""), condition=jc.get("condition","")) for jc in (s.get("next") or [])] - st = s.get("step_type", s.get("type", "AllSuccess")) - step_type = StepType.from_value(st) - cf = s.get("context_from") - if cf and not isinstance(cf, list): cf = [cf] - steps.append(WorkflowStep(name=s.get("name",""), subtasks=subtasks, next=next_list, layer=s.get("layer",0), context_from=cf, step_type=step_type)) - return cls(id=data.get("id",""), name=data.get("name",""), description=data.get("description",""), steps=steps) + for raw_step in data.get("steps", []): + subtasks = [] + for raw_task in raw_step.get("subtasks") or []: + raw_input = raw_task.get("input") + business_input = None + if isinstance(raw_input, Mapping): + if raw_input.get("text") is not None: + business_input = BusinessInput.from_text(raw_input["text"]) + elif "data" in raw_input: + business_input = BusinessInput.from_data(raw_input["data"]) + elif isinstance(raw_input, str): + business_input = BusinessInput.from_text(raw_input) + subtasks.append(Task( + agent=raw_task.get("agent", ""), + skill=raw_task.get("skill", ""), + description=raw_task.get("description", ""), + input=business_input, + )) + next_edges = [ + JumpCondition(step=edge.get("step", ""), condition=edge.get("condition", "")) + for edge in (raw_step.get("next") or []) + ] + context_from = raw_step.get("context_from") + if context_from and not isinstance(context_from, list): + context_from = [context_from] + steps.append(WorkflowStep( + name=raw_step.get("name", ""), + subtasks=subtasks, + next=next_edges, + layer=raw_step.get("layer", 0), + context_from=context_from, + step_type=StepType.from_value(raw_step.get("step_type", raw_step.get("type"))), + )) + return cls( + id=data.get("id", ""), name=data.get("name", ""), + description=data.get("description", ""), steps=steps, + ) @classmethod def from_json(cls, json_str: str) -> "Workflow": - import json return cls.from_dict(json.loads(json_str)) -@dataclass -class SendMessageResult: - text: str = "" - task: Any = None - metadata: Dict[str, Any] = field(default_factory=dict) - task_state: str = "" +@dataclass(frozen=True) +class TaskExecutionResult: + agent_name: str + skill: str + task_id: str + task_description: str + status: TaskStatus + outputs: tuple[Any, ...] = () + received_messages: tuple[ReceivedMessage, ...] = () + error: Optional[str] = None + error_code: Optional[str] = None + error_details: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({})) + + def __post_init__(self) -> None: + if not self.agent_name or not self.agent_name.strip(): + raise ValueError("Task result agent_name must not be blank") + if not self.task_id: + raise ValueError("Task id is required") + messages = tuple(self.received_messages or ()) + outputs = tuple( + value for message in messages for value in message.outputs() + ) if messages else tuple(_snapshot(value) for value in self.outputs) + object.__setattr__(self, "received_messages", messages) + object.__setattr__(self, "outputs", outputs) + object.__setattr__(self, "error_details", _snapshot_mapping(self.error_details)) + + +@dataclass(frozen=True) +class UpstreamStepResult: + step_name: str + task_results: tuple[TaskExecutionResult, ...] = () + def __post_init__(self) -> None: + if not self.step_name or not self.step_name.strip(): + raise ValueError("Upstream step_name must not be blank") + object.__setattr__(self, "task_results", tuple(self.task_results or ())) -@dataclass + +@dataclass(frozen=True) +class WorkflowInput: + runtime_intent: str = "" + upstream_results: tuple[UpstreamStepResult, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "runtime_intent", self.runtime_intent or "") + object.__setattr__(self, "upstream_results", tuple(self.upstream_results or ())) + + +@dataclass(frozen=True) class TaskRequest: + execution_id: str + task_id: str + input: BusinessInput agent_name: str skill: str - message: str - context: str + instruction: str step_name: str - subtask_index: int = 0 - description: str = "" + workflow_input: WorkflowInput = field(default_factory=WorkflowInput) + language: str = "zh" -@dataclass -class TaskResponse: +@dataclass(frozen=True) +class TaskResult: success: bool - output: str = "" + outputs: tuple[Any, ...] = () + received_messages: tuple[ReceivedMessage, ...] = () error: Optional[str] = None - metadata: Optional[Dict[str, Any]] = None + error_code: Optional[str] = None + error_details: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({})) + + def __post_init__(self) -> None: + messages = tuple(self.received_messages or ()) + outputs = tuple( + value for message in messages + for value in message.outputs(include_message=self.success) + ) if messages else tuple(_snapshot(value) for value in self.outputs) + object.__setattr__(self, "received_messages", messages) + object.__setattr__(self, "outputs", outputs) + object.__setattr__(self, "error_details", _snapshot_mapping(self.error_details)) + + @classmethod + def succeeded(cls, outputs: Sequence[Any] = ()) -> "TaskResult": + return cls(success=True, outputs=tuple(outputs)) + + @classmethod + def failed(cls, code: str, message: str) -> "TaskResult": + return cls(success=False, error_code=code, error=message) @dataclass -class RouteDecision: +class SendMessageResult: + text: str = "" + task: Any = None + metadata: Mapping[str, Any] = field(default_factory=dict) + task_state: str = "" + failure_code: Optional[str] = None + failure_message: Optional[str] = None + received_messages: tuple[ReceivedMessage, ...] = () + + @property + def is_terminal(self) -> bool: + return self.task_state in { + "TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED", + "TASK_STATE_REJECTED", + } + + @property + def is_failure(self) -> bool: + return self.failure_code is not None or self.task_state in { + "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "TASK_STATE_REJECTED", + } + + @property + def is_success(self) -> bool: + standalone = self.task is None and bool(self.received_messages) + return not self.is_failure and ( + self.task_state == "TASK_STATE_COMPLETED" + or (self.task_state in {"", "TASK_STATE_UNSPECIFIED"} and standalone) + ) + + @property + def outputs(self) -> tuple[Any, ...]: + include_message = self.failure_code is None and ( + self.task is None or self.task_state == "TASK_STATE_COMPLETED" + ) + return tuple( + value for message in self.received_messages + for value in message.outputs(include_message=include_message) + ) + + +@dataclass(frozen=True) +class RouteRequest: + execution_id: str + step_name: str next_step: str + condition: str + workflow_input: WorkflowInput + current_results: tuple[TaskExecutionResult, ...] + + def __post_init__(self) -> None: + for name, value in ( + ("execution_id", self.execution_id), ("step_name", self.step_name), + ("next_step", self.next_step), ("condition", self.condition), + ): + if not value or not value.strip(): + raise ValueError(f"{name} must not be blank") + object.__setattr__(self, "current_results", tuple(self.current_results or ())) + + +@dataclass(frozen=True) +class RouteDecision: + allowed: bool reason: str = "" + @classmethod + def allow(cls, reason: str = "") -> "RouteDecision": + return cls(True, reason or "") + + @classmethod + def deny(cls, reason: str = "") -> "RouteDecision": + return cls(False, reason or "") + + +class NegotiationReply: + @classmethod + def send(cls, content: MessageContent) -> "NegotiationSend": + return NegotiationSend(content) + + @classmethod + def stop(cls, code: str, reason: str) -> "NegotiationStop": + return NegotiationStop(code, reason) + + +@dataclass(frozen=True) +class NegotiationSend(NegotiationReply): + content: MessageContent + + +@dataclass(frozen=True) +class NegotiationStop(NegotiationReply): + code: str + reason: str + + def __post_init__(self) -> None: + if not self.code or not self.code.strip() or not self.reason or not self.reason.strip(): + raise ValueError("Stop code and reason are required") + + +@dataclass(frozen=True) +class NegotiationExchange: + received: ReceivedMessage + reply: NegotiationReply + + +@dataclass(frozen=True) +class NegotiationRequest: + task: TaskRequest + original_submission: MessageContent + received: ReceivedMessage + previous_exchanges: tuple[NegotiationExchange, ...] + remaining_wait_seconds: float + + def __post_init__(self) -> None: + if self.remaining_wait_seconds < 0: + raise ValueError("Negative remaining wait") + object.__setattr__(self, "previous_exchanges", tuple(self.previous_exchanges or ())) + + @property + def agent_name(self) -> str: + return self.task.agent_name + + +class BusinessFailure(RuntimeError): + """Host-supplied safe business failure.""" + + def __init__(self, code: str, message: str, details: Optional[Mapping[str, Any]] = None): + if not code or not code.strip(): + raise ValueError("Failure code is required") + super().__init__(message) + self.code = code + self.details = _snapshot_mapping(details) + @dataclass class ExecutionResult: success: bool - history: List[Dict[str, Any]] = field(default_factory=list) - step_outputs: Dict[str, Dict[str, Any]] = field(default_factory=dict) + history: list[Mapping[str, Any]] = field(default_factory=list) + step_outputs: dict[str, Mapping[str, Any]] = field(default_factory=dict) error: Optional[str] = None diff --git a/workflow_engine/core/workflow_validator.py b/workflow_engine/core/workflow_validator.py new file mode 100644 index 0000000..4ff7bbc --- /dev/null +++ b/workflow_engine/core/workflow_validator.py @@ -0,0 +1,144 @@ +# Copyright (c) 2026 Huawei Technologies Co., Ltd. +# All Rights Reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Validation for workflow definitions before any task is dispatched.""" + +from collections import deque + +from workflow_engine.core.models import Workflow + + +TERMINAL_TARGETS = frozenset({"end", "retry", "endNode"}) + + +def validate_workflow(workflow: Workflow) -> None: + """Raise ``ValueError`` when a workflow cannot be executed safely.""" + errors = find_workflow_errors(workflow) + if errors: + raise ValueError("Invalid workflow: " + "; ".join(errors)) + + +def find_workflow_errors(workflow: Workflow) -> list[str]: + """Return deterministic validation errors without dispatching work.""" + if workflow is None: + return ["workflow is null"] + if not workflow.steps: + return ["workflow has no steps"] + + errors: list[str] = [] + steps = {} + for step in workflow.steps: + if step is None or not step.name or not step.name.strip(): + errors.append("step name is blank") + continue + if step.name in steps: + errors.append(f"duplicate step '{step.name}'") + else: + steps[step.name] = step + if step.name in TERMINAL_TARGETS: + errors.append(f"step name '{step.name}' is reserved for a terminal route") + for task in step.subtasks or []: + if task is None: + errors.append(f"step '{step.name}' has a null subtask") + elif not task.agent or not task.agent.strip(): + errors.append(f"step '{step.name}' has a subtask with blank agent") + + graph: dict[str, list[str]] = {name: [] for name in steps} + indegree: dict[str, int] = {name: 0 for name in steps} + for step in steps.values(): + outgoing_targets: set[str] = set() + for jump in step.next or []: + if jump is None: + errors.append(f"step '{step.name}' has a null next condition") + continue + target = jump.step + if not target or not target.strip(): + errors.append(f"step '{step.name}' has a blank next target") + elif target in outgoing_targets: + errors.append( + f"step '{step.name}' has duplicate outgoing target '{target}'" + ) + elif target not in TERMINAL_TARGETS: + if target not in steps: + errors.append( + f"step '{step.name}' references unknown step '{target}'" + ) + elif target == step.name: + errors.append(f"step '{step.name}' contains a cycle") + else: + graph[step.name].append(target) + indegree[target] += 1 + if target: + outgoing_targets.add(target) + if step.context_from and "*" in step.context_from and len(step.context_from) != 1: + errors.append( + f"step '{step.name}' cannot combine '*' with named context sources" + ) + for context_ref in step.context_from or []: + if context_ref != "*" and context_ref not in steps: + errors.append( + f"step '{step.name}' references unknown context step " + f"'{context_ref}'" + ) + + if errors: + return errors + + def ancestors(name: str) -> set[str]: + found: set[str] = set() + queue = deque([name]) + while queue: + current = queue.popleft() + for source, targets in graph.items(): + if current in targets and source not in found: + found.add(source) + queue.append(source) + return found + + for step in steps.values(): + if not step.context_from or step.context_from == ["*"]: + continue + upstream = ancestors(step.name) + for source in step.context_from: + if source not in upstream: + errors.append( + f"step '{step.name}' context source '{source}' is not an upstream dependency" + ) + + if errors: + return errors + + roots = deque(name for name, degree in indegree.items() if degree == 0) + if not roots: + return ["workflow has no root step"] + for root in roots: + if steps[root].layer != 0: + errors.append(f"root step '{root}' must have layer 0") + + visited: set[str] = set() + reachable = deque(roots) + while reachable: + current = reachable.popleft() + if current in visited: + continue + visited.add(current) + reachable.extend(graph[current]) + if len(visited) != len(graph): + unreachable = sorted(set(graph) - visited) + errors.append(f"cyclic or unreachable steps {unreachable}") + + remaining = dict(indegree) + topological = deque(roots) + processed = 0 + while topological: + current = topological.popleft() + processed += 1 + for target in graph[current]: + remaining[target] -= 1 + if remaining[target] == 0: + topological.append(target) + if processed != len(graph) and len(visited) == len(graph): + errors.append("workflow contains a cycle") + return errors diff --git a/workflow_engine/registry/registry_client.py b/workflow_engine/registry/registry_client.py index 53b8b08..4666a24 100644 --- a/workflow_engine/registry/registry_client.py +++ b/workflow_engine/registry/registry_client.py @@ -44,12 +44,10 @@ async def load_psop( import httpx from workflow_engine.core.models import Workflow url = f"{base_url}/api/v1/orchestrate/psop/{psop_id}" - params = {} - if access_token: - params["access_token"] = access_token + headers = {"Authorization": f"Bearer {access_token}"} if access_token else {} logger.info(f"[Registry] Loading PSOP from {url} (ssl_verify={ssl_verify})") - async with httpx.AsyncClient(verify=ssl_verify, timeout=30, follow_redirects=True) as client: - resp = await client.get(url, params=params) + async with httpx.AsyncClient(verify=ssl_verify, timeout=30, follow_redirects=False) as client: + resp = await client.get(url, headers=headers) resp.raise_for_status() data = resp.json() wf = Workflow.from_dict(data.get("data", data)) @@ -75,13 +73,12 @@ async def search_psop( import httpx from workflow_engine.core.models import WorkflowSearchResult url = f"{base_url}/api/v1/orchestrate/search" - params = {} - if access_token: - params["access_token"] = access_token + headers = {"Authorization": f"Bearer {access_token}"} if access_token else {} body = {"intent": intent, "top_n": top_n} - logger.info(f"[Registry] Searching PSOP at {url} (intent={intent[:60]}, top_n={top_n})") - async with httpx.AsyncClient(verify=ssl_verify, timeout=30, follow_redirects=True) as client: - resp = await client.post(url, json=body, params=params) + logger.info(f"[Registry] Searching PSOP at {url} (intent_chars={len(intent)}, top_n={top_n})") + logger.trace(f"[Registry] Search intent={intent}") + async with httpx.AsyncClient(verify=ssl_verify, timeout=30, follow_redirects=False) as client: + resp = await client.post(url, json=body, headers=headers) resp.raise_for_status() data = resp.json() raw_results = data.get("data", []) @@ -94,11 +91,7 @@ async def search_psop( class RegistryClient: """Fetches AgentCards from the Registry Center.""" - def __init__(self, url: str, ssl_verify: bool = False, verify_ssl: bool = None): - # Accept the legacy ``verify_ssl`` keyword for backward compatibility; - # ``ssl_verify`` matches WorkflowEngineClient / load_psop / execute_psop. - if verify_ssl is not None: - ssl_verify = verify_ssl + def __init__(self, url: str, ssl_verify: bool = True): self.url = url.rstrip("/") self.ssl_verify = ssl_verify @@ -112,18 +105,14 @@ async def fetch_agent_cards(self) -> List[Any]: data = resp.json() raw_cards = data.get("agentCards", data.get("data", [])) logger.info(f"[Registry] Received {len(raw_cards)} agent card(s)") - try: - from a2a.types import AgentCard - from google.protobuf.json_format import Parse - cards = [] - for raw in raw_cards: - normalized = normalize_agent_dict(raw) - cards.append(Parse(json.dumps(normalized), AgentCard())) - logger.info(f"[Registry] Parsed {len(cards)} AgentCard(s) into protobuf objects") - return cards - except ImportError: - logger.info(f"[Registry] a2a-sdk not available, returning raw dicts") - return raw_cards + from a2a.types import AgentCard + from google.protobuf.json_format import Parse + cards = [] + for raw in raw_cards: + normalized = normalize_agent_dict(raw) + cards.append(Parse(json.dumps(normalized), AgentCard())) + logger.info(f"[Registry] Parsed {len(cards)} AgentCard(s) into protobuf objects") + return cards async def fetch_agent_card(self, name: str, organization: str = None) -> Any: """Fetch a single AgentCard by name.""" @@ -141,16 +130,12 @@ async def fetch_agent_card(self, name: str, organization: str = None) -> Any: logger.warning(f"[Registry] Agent card not found: name={name}") return None raw = cards[0] - try: - from a2a.types import AgentCard - from google.protobuf.json_format import Parse - normalized = normalize_agent_dict(raw) - card = Parse(json.dumps(normalized), AgentCard()) - logger.info(f"[Registry] Agent card parsed: name={name}") - return card - except ImportError: - logger.info(f"[Registry] a2a-sdk not available, returning raw dict") - return raw + from a2a.types import AgentCard + from google.protobuf.json_format import Parse + normalized = normalize_agent_dict(raw) + card = Parse(json.dumps(normalized), AgentCard()) + logger.info(f"[Registry] Agent card parsed: name={name}") + return card async def register_agent_card(self, agent_card) -> dict: """Register or update an AgentCard in the registry. @@ -160,17 +145,20 @@ async def register_agent_card(self, agent_card) -> dict: RegistryClient.registerAgentCard. """ import httpx + if isinstance(agent_card, dict): + card_payload = agent_card + else: + from google.protobuf.json_format import MessageToDict + card_payload = MessageToDict(agent_card) url = f"{self.url}/rest/v1/registry-center/agent-cards" - payload = {"agentCards": [agent_card]} - logger.info(f"[Registry] Registering agent card: name={agent_card.get('name') if isinstance(agent_card, dict) else getattr(agent_card, 'name', '?')}") + payload = {"agentCards": [card_payload]} + logger.info(f"[Registry] Registering agent card: name={card_payload.get('name', '?')}") async with httpx.AsyncClient(verify=self.ssl_verify, timeout=30) as client: resp = await client.post(url, json=payload) + resp.raise_for_status() result = resp.json() - if resp.status_code in (200, 201): - logger.info(f"[Registry] Agent card registered") - else: - logger.warning(f"[Registry] Registration returned {resp.status_code}: {resp.text}") - return result + logger.info(f"[Registry] Agent card registered: name={card_payload.get('name', '?')}") + return result @property def base_url(self) -> str: diff --git a/workflow_engine/runner.py b/workflow_engine/runner.py index 6ef5161..1415f37 100644 --- a/workflow_engine/runner.py +++ b/workflow_engine/runner.py @@ -27,30 +27,38 @@ import asyncio import time +from collections.abc import Mapping +from dataclasses import fields, is_dataclass from typing import Any, AsyncIterator, Awaitable, Callable, Optional, Union +from google.protobuf.json_format import MessageToDict +from google.protobuf.message import Message as ProtobufMessage from loguru import logger -from workflow_engine.core.models import Workflow, ExecutionResult -from workflow_engine.core.executor import WorkflowExecutor -from workflow_engine.client.engine_client import WorkflowEngineClient from workflow_engine.client.a2a_transport import A2ATransport +from workflow_engine.client.engine_client import WorkflowEngineClient from workflow_engine.control.control_points import ControlPoint, EventCallback +from workflow_engine.core.executor import WorkflowExecutor +from workflow_engine.core.models import ExecutionResult, Workflow def _serialize(data: Any) -> Any: """Make event data JSON-serializable (pydantic models, protobuf, etc.).""" if data is None or isinstance(data, (str, int, float, bool)): return data + if isinstance(data, ProtobufMessage): + return MessageToDict(data, preserving_proto_field_name=False) if hasattr(data, "model_dump"): try: return data.model_dump() except Exception: pass - if isinstance(data, dict): + if isinstance(data, Mapping): return {k: _serialize(v) for k, v in data.items()} if isinstance(data, (list, tuple)): return [_serialize(v) for v in data] + if is_dataclass(data): + return {field.name: _serialize(getattr(data, field.name)) for field in fields(data)} if hasattr(data, "__dict__"): try: return {k: _serialize(v) for k, v in data.__dict__.items() @@ -104,10 +112,17 @@ async def execute_psop( engine_client: Optional[WorkflowEngineClient] = None, runtime_intent: str = "", lang: str = "zh", - a2at_env_path: Optional[str] = None, credentials_config: Optional[Union[str, dict]] = None, ssl_verify: bool = True, ca_certs_path: Optional[str] = None, + client_cert_path: Optional[str] = None, + client_key_path: Optional[str] = None, + client_key_password: Optional[str] = None, + crl_path: Optional[str] = None, + auth_provider=None, + preferred_protocol: Optional[str] = None, + send_timeout_seconds: int = 600, + max_negotiation_exchanges: int = 3, on_finish: Optional[Callable[[ExecutionResult, list], Awaitable[None]]] = None, on_event: Optional[Callable[[dict], Any]] = None, ) -> AsyncIterator[dict]: @@ -142,21 +157,26 @@ async def execute_psop( workflow = psop emitter = _EventEmitter() - if engine_client is None: + owns_client = engine_client is None + if owns_client: transport = A2ATransport( agent_cards=agent_cards, - a2at_env_path=a2at_env_path, credentials_config=credentials_config, ssl_verify=ssl_verify, ca_certs_path=ca_certs_path, + client_cert_path=client_cert_path, + client_key_path=client_key_path, + client_key_password=client_key_password, + crl_path=crl_path, + auth_provider=auth_provider, + preferred_protocol=preferred_protocol, + send_timeout_seconds=send_timeout_seconds, ) - engine_client = WorkflowEngineClient( - transport, event_callback=emitter, + engine_client = WorkflowEngineClient.owning( + transport, + event_callback=emitter, + max_negotiation_exchanges=max_negotiation_exchanges, ) - else: - # Attach the emitter to a caller-provided client so its - # agent_request/agent_response events reach this stream. - engine_client.set_event_callback(emitter) executor = WorkflowExecutor( workflow=workflow, control_point=control_point, @@ -179,10 +199,11 @@ async def _run_and_finalize(): logger.error(f"[execute_psop] Execution failed: {e}", exc_info=True) holder["error"] = str(e) finally: - try: - await engine_client.close() - except Exception: - pass + if owns_client: + try: + await engine_client.close() + except Exception: + pass result: ExecutionResult = holder.get("result") or ExecutionResult( success=False, error=holder.get("error") or "Unknown error"