From 6ea27948b828c2d3f1f4f9b71df4196d7f90fa52 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 19 Oct 2026 21:55:23 +0800 Subject: [PATCH 01/30] docs(sample): describe negotiation in business terms, not internal state The four-message loop named A2A task states, and the "when a negotiation happens" section opened with the demo's validation error codes. Both read as internal implementation detail and did not say when a real deployment would negotiate. - 4.1 now opens with the three situations in business terms (an incomplete request, a target beyond the server's capability, a goal that may be impossible under current conditions) and only then explains how the demo wires the trigger; the error-code table becomes a sample-convention note - 4.2's four messages are described as business actions with no task states and no task framing, since a negotiation does not need a created task - the per-kind "when it opens" lines lead with the business condition and mention the demo's error code parenthetically - the chapter intro no longer frames every negotiation as a validation failure Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 71 +++++++++-------- a2a-t-sample/ran-energy-saving/README.md | 86 ++++++++++++--------- 2 files changed, 88 insertions(+), 69 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index bdcf7c40..4b76b6d5 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -107,45 +107,50 @@ async for stream_response in a2a_client.send_message(request, context=call_conte ## 4. 使用 A2A-T SDK 实现 Negotiation-T 交互 -一句话分界:SDK 拥有报文 —— 渲染、校验、协商会话映射、传输机制;你的系统拥有业务 —— 校验失败 -何时转为协商、以及你这一侧如何应答 propose。 +一句话分界:SDK 拥有报文 —— 渲染、校验、协商会话映射、传输机制;你的系统拥有业务 —— 请求何时 +无法按原样执行、值得先谈一谈,以及你这一侧如何应答 propose。 -### 4.1 服务端何时开启协商 +### 4.1 什么时候会发生协商 -当 Task-T 内容校验以某条内容错误码失败、且服务端倾向于通过对话而非直接拒绝来解决时。哪种失败对应 -哪种协商类型是**样例层的约定,不是 SDK 契约** —— 报文是自描述的:每个 propose 携带各自的 -`templateUri`(`Negotiation-T/{information,target,feasibility}-negotiation/propose/v1`),客户端 -基于该 URI 分派 accept。 +服务端无法按请求原样执行,但这个差距又是双方**谈一谈就能补齐**、而不是只能拒绝的时候。样例覆盖三种 +情况: -| 内容校验错误码 | 协商类型 | 业务语义的触发条件 | 样例输入 | -| --- | --- | --- | --- | -| `content.param_missing` | **信息协商(information)** | 请求缺少服务端必需的参数 | `--scenario negotiation` | -| `content.value_not_allowed` | **目标协商(target)** | 目标本身合法但超出或偏离服务端能力口径 | `--negotiation target` | -| `content.semantic_conflict` | **可行性协商(feasibility)** | 目标在当前网络状态下可能不可达 | `--negotiation feasibility --channel fromText` | +**信息不完整。** 客户端要求执行节能,给了目标、时段和吞吐目标,却没说在哪个区域。服务端缺这一项 +无法开工,于是告诉客户端缺什么;客户端补上,任务继续。 -映射常量是 `_NEGOTIATION_TYPE_BY_ERROR_CODE` -([`server_flow.py:64`](src/server/server_flow.py#L64)) —— 你可以随意编辑自己的副本(重映射错误码、 -新增自有错误码、或让某些码不映射;不映射的码保持普通 `TASK_STATE_REJECTED`)。 +**要求超出服务端能力。** 客户端要求全区保障 100Mbps。目标本身是合法的,但超出了小区能做到的范围。 +服务端复述它理解的意图,请客户端确认一个可行的目标,再作承诺。 + +**目标在当前条件下可能做不到。** 客户端要求节能 30% 同时保障 50Mbps。两个目标方向相反,能否同时 +成立取决于实时网络状态。服务端请客户端共同做可行性评估,并把结论摘要回来。 + +**样例是怎么接线的。** 判断属于哪种情况、以及到底要不要协商,是**你的业务决策,SDK 不持立场**。 +本样例是数据驱动的:首个内容校验错误的错误码通过 `_NEGOTIATION_TYPE_BY_ERROR_CODE` 表 +([`server_flow.py:64`](src/server/server_flow.py#L64))选出类型。这张表是**样例约定,不是 SDK +契约** —— 可随意改自己的副本(重映射、新增、或干脆不用映射、按自己的业务规则判断;未映射的失败就是 +普通拒绝)。 + +报文并不依赖这张表:每个 propose 通过自身的 `templateUri` +(`Negotiation-T/{information,target,feasibility}-negotiation/propose/v1`)自我描述,客户端按收到 +的 URI 分派应答,而不是按错误码。 ### 4.2 共享机制 以下对三类协商通用,各类型小节不再重复。 -**一轮 = 一次 propose 加上对它的应答。** 会话上下文携带 1 起始的 `round`;应答 propose 的 accept -沿用该 propose 的轮次,只有**新的** propose 才会推进轮次。初始的 Task-T 请求不是协商轮 —— 它是一次 -普通任务请求,协商从服务端的 propose 才开始。`maxRounds`(默认 5)限制会话轮次。 +**一轮 = 一次 propose 加上对它的应答。** 会话上下文携带 1 起始的 `round`;应答沿用 propose 的轮次, +只有**新的** propose 才会推进轮次。客户端最初那条请求不算协商轮 —— 协商从服务端的 propose 才开始。 +`maxRounds`(默认 5)限制会话轮次。 **四条报文的闭环:** -1. 客户端 → 服务端:Task-T 任务请求;内容校验失败。 -2. 服务端 → 客户端:`TASK_STATE_INPUT_REQUIRED`,Negotiation-T propose 放在该状态消息的 metadata - 中(`build_metadata_content()`);会话随嵌套的 `negotiationContext` metadata 映射往返 —— - SDK 本身无状态。 -3. 客户端 → 服务端:由补全后的参数重新生成的 Task-T 提示词,加上 Negotiation-T accept,一并放在 - 消息 metadata 中;重发同一任务(`task_id` / `context_id` 沿用初始请求),`A2A-Extensions` - 头同时携带两个扩展 URI。 -4. 服务端 → 客户端:补全后的提示词校验通过并执行 —— 进度更新、报告 artifact 与 - `TASK_STATE_COMPLETED` 收尾协商运行。 +1. 客户端 → 服务端:按原样的节能请求 —— 样例中缺少区域。 +2. 服务端 → 客户端:propose —— 缺什么、需要澄清什么、或需要评估什么。 +3. 客户端 → 服务端:应答(补齐的取值、确认、或可行性摘要),连同补全后的请求一并发出。 +4. 服务端 → 客户端:结果 —— 先进度,后报告。 + +会话(`id` / `round` / `maxRounds` / performative)随每条协商消息放在 `negotiationContext` metadata +中,因此 SDK 本身无状态;每个 propose 还携带自己的 `templateUri`,客户端据此分派应答。 **决策接缝。** 收到第 2 条报文时,客户端 flow 构建一个轻量的 `NegotiationProposeView`(kind、 `templateUri`、渲染文本)并调用([`negotiation_response.py`](src/client/negotiation_response.py)): @@ -169,8 +174,8 @@ flow 根据你的决策重新生成 Task-T 提示词、构建带同轮 `ACCEPT` ### 4.3 信息协商(information) -**① 服务端何时开启。** Task-T 校验以 `content.param_missing` 失败:请求缺少服务端必需的参数 -(样例中为 `region`)。 +**① 服务端何时开启。** 请求不完整 —— 缺少服务端必需的参数(样例中为区域)。样例的触发条件是 +`content.param_missing` 校验错误,但这个错误码是样例的接线方式,不属于协议本身。 **② 参考代码做了什么。** @@ -197,8 +202,8 @@ class MyProvider: ### 4.4 目标协商(target) -**① 服务端何时开启。** Task-T 校验以 `content.value_not_allowed` 失败:目标本身合法但超出或偏离 -服务端能力口径(样例中为 100Mbps 吞吐目标,超出小区能力)。 +**① 服务端何时开启。** 目标本身合法但超出或偏离服务端能力口径(样例中为 100Mbps 吞吐目标,超出 +小区能力)。样例的触发条件是 `content.value_not_allowed` 校验错误。 **② 参考代码做了什么。** @@ -222,8 +227,8 @@ class MyProvider: ### 4.5 可行性协商(feasibility) -**① 服务端何时开启。** Task-T 校验以 `content.semantic_conflict` 失败:目标在当前网络状态下可能 -不可达(样例中为 30% 节能目标与 50Mbps 吞吐保障冲突)。 +**① 服务端何时开启。** 目标在当前网络状态下可能做不到(样例中为 30% 节能目标与 50Mbps 吞吐保障 +冲突)。样例的触发条件是 `content.semantic_conflict` 校验错误。 **② 参考代码做了什么。** diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 59a996e6..70643bc9 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -114,47 +114,60 @@ server-side `TaskExecutionService` seam (see the ## 4. Using the A2A-T SDK to implement Negotiation-T interaction The boundary in one sentence: the SDK owns the wire — rendering, validation, the negotiation -session map, the transport mechanics — your system owns the business: when a validation failure -turns into a negotiation, and how your side answers a propose. +session map, the transport mechanics — your system owns the business: when a request cannot be +carried out as-is and a conversation would settle it, and how your side answers a propose. -### 4.1 When the server opens a negotiation +### 4.1 When a negotiation happens -When the Task-T content validation fails with a content error the server prefers to resolve by -talking instead of rejecting. Which failure means which kind is a **sample-level convention, not an -SDK contract** — the wire is self-describing: each propose carries its own `templateUri` -(`Negotiation-T/{information,target,feasibility}-negotiation/propose/v1`) and the client dispatches -its accept on that URI. +A negotiation opens when the server cannot carry out the request as it stands, but the gap is +something the two agents can settle by talking rather than by refusing to act. The demo covers +three such situations: -| Validation error code | Negotiation kind | Business trigger | Demo input | -| --- | --- | --- | --- | -| `content.param_missing` | **information** | the request lacks a parameter the server needs | `--scenario negotiation` | -| `content.value_not_allowed` | **target** | the target is well-formed but beyond or ambiguous for the server's capability | `--negotiation target` | -| `content.semantic_conflict` | **feasibility** | the goal may not be achievable in the current network state | `--negotiation feasibility --channel fromText` | +**Information — the request is incomplete.** The client asks to run energy saving and names a goal, +a time window and a throughput target, but never says which region. The server cannot start without +it, so it says which piece is missing; the client supplies it and the task proceeds. -The mapping constant is `_NEGOTIATION_TYPE_BY_ERROR_CODE` -([`server_flow.py:64`](src/server/server_flow.py#L64)) — edit your copy freely (remap codes, add -your own, or leave codes unmapped; unmapped codes keep the plain `TASK_STATE_REJECTED`). +**Target — the request asks for more than the server can deliver.** The client asks for a +guaranteed 100 Mbps across the region. The target is well-formed, but it exceeds what the cells can +guarantee. The server restates the intent it understood and asks the client to confirm a workable +target before it commits. + +**Feasibility — the request may be impossible under current conditions.** The client asks for a +30 % energy saving while guaranteeing 50 Mbps. The two goals pull in opposite directions, and +whether both can hold depends on the live network state. The server asks the client to evaluate +feasibility jointly and answer with a summary. + +**How the demo wires it.** Which situation applies — and whether to negotiate at all — is **your +business decision; the SDK has no opinion**. In this demo it is data-driven: the first +content-validation error's code selects the kind, via the `_NEGOTIATION_TYPE_BY_ERROR_CODE` table +([`server_flow.py:64`](src/server/server_flow.py#L64)). That table is a **sample convention, not an +SDK contract** — edit your copy freely (remap codes, add your own, or drop the mapping and decide +from your own business rules; an unmapped failure is simply rejected). + +The wire does not depend on that table: every propose self-describes through its own `templateUri` +(`Negotiation-T/{information,target,feasibility}-negotiation/propose/v1`), so the client dispatches +the accept on the URI it received, not on any error code. ### 4.2 Shared mechanics These apply to all three kinds; the per-kind sections below do not repeat them. **A round is one propose plus the answer to it.** The session context carries a 1-based `round`; -the accept answering a propose keeps that propose's round, and only a *new* propose advances it. -The initial Task-T request is not a negotiation round — it is an ordinary task request, and the -negotiation starts with the server's propose. `maxRounds` (default 5) bounds the session. +the answer keeps the propose's round, and only a *new* propose advances it. The client's opening +request is not a negotiation round — the negotiation starts with the server's propose. +`maxRounds` (default 5) bounds the session. **The closed loop, in four messages:** -1. client → server: the Task-T task request; the content validation fails. -2. server → client: `TASK_STATE_INPUT_REQUIRED` with the Negotiation-T propose in the status - message metadata (`build_metadata_content()`); the session travels in the nested - `negotiationContext` metadata map — the SDK is stateless. -3. client → server: the filled Task-T prompt regenerated from the parameters plus the - Negotiation-T accept, both in the message metadata; the same task re-sent (`task_id` / - `context_id` from the initial request) with both extension URIs in the `A2A-Extensions` header. -4. server → client: the now-valid prompt executes — progress updates, the report artifact, and - `TASK_STATE_COMPLETED` close the negotiation run. +1. client → server: the energy-saving request as it stands — in the demo, missing the region. +2. server → client: the propose — what is missing, what needs clarifying, or what needs evaluating. +3. client → server: the answer (the values, the confirmation, or the feasibility summary) together + with the completed request. +4. server → client: the result — progress, then the report. + +The session (`id` / `round` / `maxRounds` / performative) travels with every negotiation message in +the `negotiationContext` metadata map, so the SDK itself stays stateless; each propose also carries +its `templateUri`, which is what the client dispatches its answer on. **The decision seam.** When message 2 arrives, the client flow builds a lightweight `NegotiationProposeView` (kind, `templateUri`, rendered text) and calls @@ -181,8 +194,9 @@ that internally. ### 4.3 Information negotiation -**① When the server opens it.** The Task-T validation fails with `content.param_missing`: the -request lacks a parameter the server needs (in the demo, `region`). +**① When the server opens it.** The request is incomplete — it lacks a parameter the server needs +(in the demo, the region). The demo's trigger is a `content.param_missing` validation error, but +that code is the sample's wiring, not part of the protocol. **② What the reference code does.** @@ -211,9 +225,9 @@ class MyProvider: ### 4.4 Target negotiation -**① When the server opens it.** The Task-T validation fails with `content.value_not_allowed`: the -target is well-formed but beyond or ambiguous for the server's capability (in the demo, a 100 Mbps -throughput target beyond the cell's capability). +**① When the server opens it.** The target is well-formed but beyond or ambiguous for the server's +capability (in the demo, a 100 Mbps throughput target beyond what the cells can guarantee). The +demo's trigger is a `content.value_not_allowed` validation error. **② What the reference code does.** @@ -240,9 +254,9 @@ demo reads it from `scenario_data`; yours comes from whoever confirms the target ### 4.5 Feasibility negotiation -**① When the server opens it.** The Task-T validation fails with `content.semantic_conflict`: the -goal may not be achievable in the current network state (in the demo, a 30 % saving goal -conflicting with a 50 Mbps throughput guarantee). +**① When the server opens it.** The goal may not be achievable in the current network state (in the +demo, a 30 % saving goal conflicting with a 50 Mbps throughput guarantee). The demo's trigger is a +`content.semantic_conflict` validation error. **② What the reference code does.** From 508e6e0a5f76c3fe6d2dd7d9879623bf87a68bbf Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 19 Oct 2026 22:04:55 +0800 Subject: [PATCH 02/30] docs(sample): say answer, not accept, when describing templateUri dispatch The client does not only accept a propose: it can reject or abort too. The dispatch on the propose's templateUri selects which answer to build, so the wording must not narrow it to accept. Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 70643bc9..9cc6d273 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -145,8 +145,8 @@ SDK contract** — edit your copy freely (remap codes, add your own, or drop the from your own business rules; an unmapped failure is simply rejected). The wire does not depend on that table: every propose self-describes through its own `templateUri` -(`Negotiation-T/{information,target,feasibility}-negotiation/propose/v1`), so the client dispatches -the accept on the URI it received, not on any error code. +(`Negotiation-T/{information,target,feasibility}-negotiation/propose/v1`), so the client picks its +answer — accept, reject or abort — from the URI it received, not from any error code. ### 4.2 Shared mechanics From 3e9b5eb054194ab24ebc8f3303f72076c2012291 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 19 Oct 2026 22:46:33 +0800 Subject: [PATCH 03/30] fix(sample): align the target and feasibility cases with the negotiation spec The sample's two negotiation cases had drifted from the canonical cases in the A2A-T negotiation spec. Rebuild them to match, including the message forms the spec uses. - target: two overlapping time segments ({17:30~23:00,20Mbps} and {20:00~00:00,10Mbps}) instead of a rate beyond cell capability; the propose now uses the clarification form (intent_understanding + request_for_clarification) and the accept carries the corrected segments - feasibility: stations must hold a guaranteed rate through an 18-hour outage but cannot sustain it; the propose now uses PROPOSE_ALTERNATIVE_ON_FAILURE with infeasibility_details_and_proposal, and the accept agrees to lower the 06:00~18:00 rate from 10Mbps to 2Mbps - negotiation_filled_params is now negotiation-kind aware, so the refilled Task-T prompt carries the agreed target and the continuation re-validates - mock routing markers use values the failing input carries and the refill does not, keeping the continuation on the complete variant - caller-facing jargon replaced: "seam" -> "integration point" in the sample docstrings, matching the INTEGRATION POINT annotations Signed-off-by: codetalks-hw --- .../en-US/content_validation_feasibility.json | 8 +- ...tiation_feasibility_accept_extraction.json | 2 +- ...iation_feasibility_propose_extraction.json | 20 +- .../negotiation_target_accept_extraction.json | 2 +- ...negotiation_target_propose_extraction.json | 22 +- .../en-US/slot_extraction_feasibility.json | 4 +- .../en-US/slot_extraction_target.json | 2 +- .../zh-CN/content_validation_feasibility.json | 8 +- ...tiation_feasibility_accept_extraction.json | 2 +- ...iation_feasibility_propose_extraction.json | 20 +- .../negotiation_target_accept_extraction.json | 2 +- ...negotiation_target_propose_extraction.json | 22 +- .../zh-CN/slot_extraction_feasibility.json | 4 +- .../zh-CN/slot_extraction_target.json | 2 +- .../src/client/client_flow.py | 10 +- .../src/client/client_main.py | 4 +- .../src/client/negotiation_response.py | 4 +- .../src/client/scenario_data.py | 223 ++++++++++++++---- .../ran-energy-saving/src/common/mock_llm.py | 13 +- .../src/server/server_flow.py | 53 +++-- .../src/server/server_main.py | 2 +- .../src/server/task_execution.py | 4 +- .../test/test_negotiation_scenario.py | 60 +++-- .../test/test_negotiation_server_flow.py | 29 ++- 24 files changed, 353 insertions(+), 169 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility.json index 104acf13..36089cd7 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility.json @@ -2,17 +2,17 @@ "semantic_verdict": false, "errors": [ { - "slot_name": "energySavingGoal", + "slot_name": "guaranteedThroughputTarget", "code": "content.semantic_conflict", "facts": { - "section_label": "task_target" + "section_label": "task_context" } } ], "params": { "operationType": "create", "region": "Songshanhu Administration Committee", - "energySavingGoal": null, - "guaranteedThroughputTarget": "{00:00~07:00,2Mbps}, {07:00~17:30,10Mbps}, {17:30~23:00,20Mbps}, {23:00~24:00,2Mbps}" + "energySavingGoal": "Maximize energy saving under the condition that the task context is met.", + "guaranteedThroughputTarget": null } } diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_feasibility_accept_extraction.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_feasibility_accept_extraction.json index 8f374c9a..2baa6fd4 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_feasibility_accept_extraction.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_feasibility_accept_extraction.json @@ -1,4 +1,4 @@ { "conclusion": "Accept", - "feasibility_summary": "The energy saving target is achievable; the negotiation is confirmed." + "feasibility_summary": "Agreed to lower the guaranteed throughput target for 06:00~18:00 from 10Mbps to 2Mbps; the negotiation is confirmed as complete." } diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_feasibility_propose_extraction.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_feasibility_propose_extraction.json index c4ac8462..c2989d01 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_feasibility_propose_extraction.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_feasibility_propose_extraction.json @@ -1,16 +1,20 @@ { - "feasibility_negotiation_description": "Feasibility negotiation for the RAN energy saving task: the 30% energy saving goal conflicts with the 50Mbps guaranteed throughput target, so the feasibility needs a joint evaluation.", - "action": "REQUEST_FEASIBILITY_EVALUATION", - "contents_to_evaluate": [ + "feasibility_negotiation_description": "The feasibility assessment for the station-level energy saving task is complete: the current guaranteed throughput target cannot satisfy the power-outage duration requirement; see the infeasibility details and proposal.", + "action": "PROPOSE_ALTERNATIVE_ON_FAILURE", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": [ { - "name": "Target achievement", - "value": "The area runs energy saving from 00:00 to 06:00 while guaranteeing a throughput target of 50Mbps." + "name": "Conclusion", + "value": "Infeasible." }, { - "name": "Resource status", - "value": "The cells in the area are NR with carrier shutdown available during the low-traffic period." + "name": "Basis", + "value": "The original request asks stations \"Station 1\" and \"Station 2\" to guarantee {00:00~06:00,2Mbps} and {06:00~18:00,10Mbps}, while the sites carry an existing constraint of at least 18 hours of power autonomy during an outage. With the current guaranteed rate the power consumption in that period cannot meet the outage-duration requirement; the conclusion is based on the sites' battery capacity and historical power curves." + }, + { + "name": "Proposal", + "value": "Lower the guaranteed throughput target for 06:00~18:00 from 10Mbps to 2Mbps, which extends the supportable outage duration to the required 18 hours. If lowering the rate is not acceptable, shorten the required outage duration instead. One of the two must be adjusted; no third option satisfies both original requirements." } ], - "infeasibility_details_and_proposal": null, "feasibility_confirm_request": null } diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_target_accept_extraction.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_target_accept_extraction.json index d79efa45..66a84e4c 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_target_accept_extraction.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_target_accept_extraction.json @@ -1,5 +1,5 @@ { "conclusion": "Accept", - "confirmed_intent": "The clarified target is a guaranteed throughput of 20Mbps for the region.", + "confirmed_intent": "The energy saving target is corrected to {00:00~07:00,2Mbps}, {07:00~09:00,10Mbps}, {09:00~17:30,10Mbps}, {17:30~23:00,20Mbps}, {23:00~00:00,10Mbps}.", "failure_reason": null } diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_target_propose_extraction.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_target_propose_extraction.json index 91b5783f..2ae89038 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_target_propose_extraction.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_target_propose_extraction.json @@ -1,7 +1,21 @@ { - "target_negotiation_description": "Target negotiation for the RAN energy saving task: the submitted guaranteed throughput target of 100Mbps exceeds the cell capability, so the clarified target needs confirmation.", - "intent_understanding": null, + "target_negotiation_description": "Target negotiation for the RAN energy saving task: the guaranteed throughput target has two overlapping time segments, {17:30~23:00,20Mbps} and {20:00~00:00,10Mbps}, so the guaranteed rate for the overlapping period cannot be determined uniquely.", + "intent_understanding": [ + { + "name": "Region", + "value": "Songshanhu Administration Committee" + }, + { + "name": "Time-segment-based guaranteed throughput target", + "value": "{00:00~07:00,2Mbps}, {07:00~09:00,10Mbps}, {09:00~17:30,10Mbps}, {17:30~23:00,20Mbps}, {20:00~00:00,10Mbps}" + } + ], "alignment_and_clarification": null, - "request_for_clarification": null, - "target_confirm_request": "The clarified target is a guaranteed throughput of 20Mbps for the region; please confirm." + "request_for_clarification": [ + { + "name": "Time-segment-based guaranteed throughput target", + "value": "The two segments {17:30~23:00,20Mbps} and {20:00~00:00,10Mbps} overlap, so the guaranteed rate for the overlapping period is ambiguous; please adjust the time." + } + ], + "target_confirm_request": null } diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_feasibility.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_feasibility.json index 1aaa947a..944dd06a 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_feasibility.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_feasibility.json @@ -3,8 +3,8 @@ "operation_type": "Create", "task_description": "Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in .", "task_object": "The area is the Songshanhu Administration Committee.", - "task_target": "Achieve a 30% energy saving under the condition that the task context is met.", - "task_context": "Time-segment-based guaranteed throughput target: {00:00~06:00,50Mbps}.", + "task_target": "Maximize energy saving under the condition that the task context is met.", + "task_context": "Station list 1: [\"Station 1\", \"Station 2\"], time-segment-based guaranteed throughput target: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}, background event: power outage from 00:00 to 18:00.", "expected_output": "Output the intent report for the network energy saving task." }, "slot_errors": [] diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_target.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_target.json index 8da631f5..2eec7b7c 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_target.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_target.json @@ -4,7 +4,7 @@ "task_description": "Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in .", "task_object": "The area is the Songshanhu Administration Committee.", "task_target": "Maximize energy saving under the condition that the task context is met.", - "task_context": "Time-segment-based guaranteed throughput target: {00:00~07:00,100Mbps}.", + "task_context": "Time-segment-based guaranteed throughput target: {00:00~07:00,2Mbps}, {07:00~09:00,10Mbps}, {09:00~17:30,10Mbps}, {17:30~23:00,20Mbps}, {20:00~00:00,10Mbps}.", "expected_output": "Output the intent report for the network energy saving task." }, "slot_errors": [] diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility.json index 6f2b1a93..a8e27556 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility.json @@ -2,17 +2,17 @@ "semantic_verdict": false, "errors": [ { - "slot_name": "energySavingGoal", + "slot_name": "guaranteedThroughputTarget", "code": "content.semantic_conflict", "facts": { - "section_label": "任务目标" + "section_label": "任务上下文" } } ], "params": { "operationType": "create", "region": "松山湖管委会", - "energySavingGoal": null, - "guaranteedThroughputTarget": "{00:00~07:00,2Mbps}、{07:00~17:30,10Mbps}、{17:30~23:00,20Mbps}、{23:00~24:00,2Mbps}" + "energySavingGoal": "在满足任务上下文的前提下最大化节能。", + "guaranteedThroughputTarget": null } } diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_feasibility_accept_extraction.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_feasibility_accept_extraction.json index a373b84e..c69f498a 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_feasibility_accept_extraction.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_feasibility_accept_extraction.json @@ -1,4 +1,4 @@ { "conclusion": "Accept", - "feasibility_summary": "节能目标可达成,本次协商确认结束。" + "feasibility_summary": "同意将06:00~18:00时段的速率保障目标由10Mbps下调至2Mbps,本次协商确认结束。" } diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_feasibility_propose_extraction.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_feasibility_propose_extraction.json index 490471e9..59a9f4dc 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_feasibility_propose_extraction.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_feasibility_propose_extraction.json @@ -1,16 +1,20 @@ { - "feasibility_negotiation_description": "无线网络节能任务的可行性协商:节能目标30%与保障速率目标50Mbps存在冲突,需对可行性进行联合评估。", - "action": "REQUEST_FEASIBILITY_EVALUATION", - "contents_to_evaluate": [ + "feasibility_negotiation_description": "站点级节能任务的目标达成可行性评估已完成,当前速率目标无法同时满足停电时长要求,详见评估不可行时的详情和提案,请确认。", + "action": "PROPOSE_ALTERNATIVE_ON_FAILURE", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": [ { - "name": "目标达成", - "value": "区域在00:00~06:00时段执行节能,同时保障速率目标为50Mbps。" + "name": "评估结论", + "value": "不可行。" }, { - "name": "能源现状", - "value": "区域内小区为NR制式,低话务时段具备关断载频条件。" + "name": "评估依据", + "value": "原始请求要求“站点名称1”“站点名称2”2个站点执行 {00:00~06:00,2Mbps}、{06:00~18:00,10Mbps} 的速率保障目标,同时该站点存在停电场景下需保障至少18小时供电时长的既有约束。经评估,若按当前速率保障目标执行,该时段内设备功耗无法满足停电时长要求,两项要求之间存在冲突,判断依据为当前站点的备电容量及历史功耗曲线。" + }, + { + "name": "提案", + "value": "建议将06:00~18:00时段的速率保障目标由10Mbps下调至2Mbps,以延长可支撑的停电时长至满足要求的18小时。若无法接受下调速率,则需相应缩短停电时长保障要求。两者需二选一调整,暂无第三种可同时满足原有两项要求的方案。" } ], - "infeasibility_details_and_proposal": null, "feasibility_confirm_request": null } diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_target_accept_extraction.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_target_accept_extraction.json index d9b9db70..288a1d00 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_target_accept_extraction.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_target_accept_extraction.json @@ -1,5 +1,5 @@ { "conclusion": "Accept", - "confirmed_intent": "澄清后的目标为该区域保障速率20Mbps。", + "confirmed_intent": "节能目标修正为{00:00~07:00,2Mbps}、{07:00~09:00,10Mbps}、{09:00~17:30,10Mbps}、{17:30~23:00,20Mbps}、{23:00~00:00,10Mbps}。", "failure_reason": null } diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_target_propose_extraction.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_target_propose_extraction.json index a6ee9ef6..445e31c3 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_target_propose_extraction.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_target_propose_extraction.json @@ -1,7 +1,21 @@ { - "target_negotiation_description": "无线网络节能任务的目标协商:用户提交的保障速率目标100Mbps超出小区能力,需对澄清后的目标进行确认。", - "intent_understanding": null, + "target_negotiation_description": "无线网络节能任务的目标协商:分时段保障速率目标中 {17:30~23:00,20Mbps} 与 {20:00~00:00,10Mbps} 两个时段存在时间重叠,无法唯一确定重叠时段的速率保障目标。", + "intent_understanding": [ + { + "name": "区域", + "value": "松山湖管委会" + }, + { + "name": "分时段保障速率目标", + "value": "{00:00~07:00,2Mbps}、{07:00~09:00,10Mbps}、{09:00~17:30,10Mbps}、{17:30~23:00,20Mbps}、{20:00~00:00,10Mbps}" + } + ], "alignment_and_clarification": null, - "request_for_clarification": null, - "target_confirm_request": "澄清后的目标为该区域保障速率20Mbps,请确认。" + "request_for_clarification": [ + { + "name": "分时段保障速率目标", + "value": "如下两个时段存在时间重叠,无法唯一确定重叠时段的速率保障目标,请调整时间:{17:30~23:00,20Mbps}、{20:00~00:00,10Mbps}" + } + ], + "target_confirm_request": null } diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_feasibility.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_feasibility.json index c01f5d52..d01afa2d 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_feasibility.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_feasibility.json @@ -3,8 +3,8 @@ "操作类型": "创建", "任务描述": "基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。", "任务对象": "区域为松山湖管委会。", - "任务目标": "在满足任务上下文的前提下实现30%的节能。", - "任务上下文": "分时段保障速率目标:{00:00~06:00,50Mbps}。", + "任务目标": "在满足任务上下文的前提下最大化节能。", + "任务上下文": "站点列表1:[\"站点名称1\",\"站点名称2\"],分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps},背景事件:在00:00~18:00停电。", "预期输出": "输出网络节能任务的意图报告。" }, "slot_errors": [] diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_target.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_target.json index 2a81707b..89f1afbc 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_target.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_target.json @@ -4,7 +4,7 @@ "任务描述": "基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。", "任务对象": "区域为松山湖管委会。", "任务目标": "在满足任务上下文的前提下最大化节能。", - "任务上下文": "分时段保障速率目标:{00:00~07:00,100Mbps}。", + "任务上下文": "分时段保障速率目标:{00:00~07:00,2Mbps}、{07:00~09:00,10Mbps}、{09:00~17:30,10Mbps}、{17:30~23:00,20Mbps}、{20:00~00:00,10Mbps}。", "预期输出": "输出网络节能任务的意图报告。" }, "slot_errors": [] diff --git a/a2a-t-sample/ran-energy-saving/src/client/client_flow.py b/a2a-t-sample/ran-energy-saving/src/client/client_flow.py index 91683d96..cfb17a62 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/client_flow.py +++ b/a2a-t-sample/ran-energy-saving/src/client/client_flow.py @@ -473,7 +473,7 @@ async def _run_webhook_flow( if end_signal != _WEBHOOK_SIGNAL_INPUT_REQUIRED: return artifact_count, end_signal - # Negotiation continuation (reuses the SSE-path detection/provider seam/builders verbatim; same + # Negotiation continuation (reuses the SSE-path detection/provider integration point/builders verbatim; same # unhandled logs). input_required_event = _find_negotiation_input_required(normalized_events) if input_required_event is None: @@ -759,7 +759,7 @@ def _plan_negotiation_accept( ) -> _NegotiationAcceptPlan | None: """Ask the provider how to answer the propose, or log why the accept stays unsent. - Shared by the SSE and webhook drivers (the seam replaced the scripted answer selection, plan + Shared by the SSE and webhook drivers (the integration point replaced the scripted answer selection, plan R3): the existing unhandled degradations (malformed/missing session context, unrecognized propose templateUri) keep their logs; a reject/abort decision logs the dedicated ``negotiation-decision`` line and ends the flow with the unhandled semantics (no accept sent, @@ -954,7 +954,7 @@ async def run_client_flow( artifact end signal itself closes the default flow there, so the stream-cut cap does not apply. - Negotiation response seam (plan R3/R4): when the server answers INPUT_REQUIRED with a + Negotiation response integration point (plan R3/R4): when the server answers INPUT_REQUIRED with a Negotiation-T propose, HOW to answer is decided by ``negotiation_response_provider`` (``NegotiationResponseProvider.respond`` — the client-side INTEGRATION POINT). Supplying a provider replaces the scripted answer derivation entirely: ``negotiation_answers`` is not @@ -963,7 +963,7 @@ async def run_client_flow( the wire mechanics: ``negotiation_params`` remains the Task-T prompt refill input of the accept message and ``negotiation_kind`` keeps hinting the propose view's kind resolution (the flag wins, else the propose's ``templateUri`` dispatches on the wire). Without a provider, the - legacy scripted arguments keep the pre-seam behavior unchanged: when both + legacy scripted arguments keep the pre-integration-point behavior unchanged: when both ``negotiation_params`` and ``negotiation_answers`` are supplied, the flow internally builds the demo ``ScriptedNegotiationResponseProvider`` around them (back-compat shim); without them an INPUT_REQUIRED propose is logged as unhandled and the propose stays unanswered. The default @@ -1000,7 +1000,7 @@ async def run_client_flow( if log_sink is not None: log_sink(format_payload_log(role="client", stage="scenario-data", payload=initial_input)) - # Negotiation response seam (plan R4): the explicit provider wins; without one, the legacy + # Negotiation response integration point (plan R4): the explicit provider wins; without one, the legacy # scripted answers keep working through an internally built demo provider (identical # behavior), and with neither the propose stays unhandled below. negotiation_provider = negotiation_response_provider diff --git a/a2a-t-sample/ran-energy-saving/src/client/client_main.py b/a2a-t-sample/ran-energy-saving/src/client/client_main.py index 6f5c954a..bad695dc 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/client_main.py +++ b/a2a-t-sample/ran-energy-saving/src/client/client_main.py @@ -319,7 +319,9 @@ def main( NEGOTIATION_KIND_TARGET, NEGOTIATION_KIND_FEASIBILITY, ) - negotiation_params = negotiation_filled_params(env_path=sample_env_path) if negotiation_engaged else None + negotiation_params = ( + negotiation_filled_params(negotiation_kind, env_path=sample_env_path) if negotiation_engaged else None + ) negotiation_answers_map = negotiation_answers(env_path=sample_env_path) if negotiation_engaged else None # INTEGRATION POINT: replace ScriptedNegotiationResponseProvider with your negotiation # response provider (human confirmation, policy engine, upstream system...) diff --git a/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py b/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py index 58697484..77eb5d1e 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py +++ b/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py @@ -1,4 +1,4 @@ -"""Client-side negotiation response seam (INTEGRATION POINT) of the ran-energy-saving demo. +"""Client-side negotiation response integration point (INTEGRATION POINT) of the ran-energy-saving demo. ``NegotiationResponseProvider`` is the Protocol the client flow depends on: it is where a customer's system decides how to answer a negotiation propose. ``ScriptedNegotiationResponseProvider`` @@ -72,7 +72,7 @@ class NegotiationDecision: class NegotiationResponseProvider(Protocol): """INTEGRATION POINT (client): decide how to answer a negotiation propose. - The client flow calls this seam once the server answers the initial task request with an + The client flow calls this integration point once the server answers the initial task request with an INPUT_REQUIRED Negotiation-T propose: ``propose`` is the lightweight :class:`NegotiationProposeView` observed from the wire (negotiation kind, template URI, rendered text) and ``session`` the parsed :class:`NegotiationContext` of the server's session. The diff --git a/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py b/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py index ab80563b..98e8f538 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py +++ b/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py @@ -106,31 +106,39 @@ "{17:30~23:00,20Mbps}, {23:00~24:00,2Mbps}." ) -#: Chinese natural-language task request of the target negotiation: the complete input with a -#: 100Mbps throughput target beyond the cell capability, so the Task-T validation reports the -#: value-not-allowed content error that triggers the target negotiation. +#: Chinese natural-language task request of the target negotiation: the guaranteed throughput +#: target has two overlapping time segments ({17:30~23:00,20Mbps} and {20:00~00:00,10Mbps}), so +#: the Task-T validation reports the value-not-allowed content error that triggers the target +#: negotiation (spec case 2: a time conflict within the segmented target). NATURAL_LANGUAGE_PROMPT_INPUT_TARGET_ZH = ( "在松山湖管委会创建无线网络节能任务:在满足任务上下文的前提下最大化节能;" - "分时段保障速率目标为 {00:00~07:00,100Mbps}。" + "分时段保障速率目标为 {00:00~07:00,2Mbps}、{07:00~09:00,10Mbps}、{09:00~17:30,10Mbps}、" + "{17:30~23:00,20Mbps}、{20:00~00:00,10Mbps}。" ) -#: English natural-language task request of the target negotiation (100Mbps target). +#: English natural-language task request of the target negotiation (overlapping segments). NATURAL_LANGUAGE_PROMPT_INPUT_TARGET_EN = ( "Create a RAN energy saving task in the Songshanhu Administration Committee area to maximize " - "energy saving. The time-segment-based guaranteed throughput target is {00:00~07:00,100Mbps}." + "energy saving. The time-segment-based guaranteed throughput target is {00:00~07:00,2Mbps}, " + "{07:00~09:00,10Mbps}, {09:00~17:30,10Mbps}, {17:30~23:00,20Mbps}, {20:00~00:00,10Mbps}." ) -#: Chinese natural-language task request of the feasibility negotiation: a 30% energy saving goal -#: conflicting with the 50Mbps guaranteed throughput target, so the Task-T validation reports the -#: semantic-conflict content error that triggers the feasibility negotiation. +#: Chinese natural-language task request of the feasibility negotiation: two stations must keep +#: a guaranteed rate through an 18-hour power outage, so the Task-T validation reports the +#: semantic-conflict content error that triggers the feasibility negotiation (spec case 3: the +#: guaranteed rate cannot sustain the outage duration). NATURAL_LANGUAGE_PROMPT_INPUT_FEASIBILITY_ZH = ( - "在松山湖管委会创建无线网络节能任务:节能目标为30%;分时段保障速率目标为 {00:00~06:00,50Mbps}。" + "在松山湖管委会创建无线网络节能任务:站点列表1为[站点名称1, 站点名称2];" + "分时段保障速率目标为 {00:00~06:00,2Mbps}、{06:00~18:00,10Mbps};" + "背景事件为在00:00~18:00停电;在满足任务上下文的前提下最大化节能。" ) -#: English natural-language task request of the feasibility negotiation (30% goal, 50Mbps target). +#: English natural-language task request of the feasibility negotiation (outage case). NATURAL_LANGUAGE_PROMPT_INPUT_FEASIBILITY_EN = ( - "Create a RAN energy saving task in the Songshanhu Administration Committee area with a 30% " - "energy saving goal. The time-segment-based guaranteed throughput target is {00:00~06:00,50Mbps}." + "Create a RAN energy saving task in the Songshanhu Administration Committee area to maximize " + "energy saving. Station list 1 is [Station 1, Station 2]; the time-segment-based guaranteed " + "throughput target is {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}; the background event is a " + "power outage from 00:00 to 18:00." ) #: Natural-language task requests keyed by negotiation kind, each as ``(en-US, zh-CN)``. The @@ -191,57 +199,126 @@ def resolve_language(*, env_path: Path | None = None) -> str: #: accept-extraction content so the from-data and from-text channels produce equivalent messages. _NEGOTIATION_TARGET_DESCRIPTIONS: dict[str, str] = { "en-US": ( - "Target negotiation for the RAN energy saving task: the submitted guaranteed throughput " - "target of 100Mbps exceeds the cell capability, so the clarified target needs confirmation." + "Target negotiation for the RAN energy saving task: the guaranteed throughput target has " + "two overlapping time segments, {17:30~23:00,20Mbps} and {20:00~00:00,10Mbps}, so the " + "guaranteed rate for the overlapping period cannot be determined uniquely." + ), + "zh-CN": ( + "无线网络节能任务的目标协商:分时段保障速率目标中 {17:30~23:00,20Mbps} 与 " + "{20:00~00:00,10Mbps} 两个时段存在时间重叠,无法唯一确定重叠时段的速率保障目标。" ), - "zh-CN": "无线网络节能任务的目标协商:用户提交的保障速率目标100Mbps超出小区能力,需对澄清后的目标进行确认。", } -#: Clarified-target confirm request of the target negotiation propose per language. -_NEGOTIATION_TARGET_CONFIRM_REQUESTS: dict[str, str] = { - "en-US": "The clarified target is a guaranteed throughput of 20Mbps for the region; please confirm.", - "zh-CN": "澄清后的目标为该区域保障速率20Mbps,请确认。", +#: Restatement of the original intent carried by the target propose (``intent_understanding``). +_NEGOTIATION_TARGET_INTENT_UNDERSTANDINGS: dict[str, list[tuple[str, str]]] = { + "en-US": [ + ("Region", "Songshanhu Administration Committee"), + ( + "Time-segment-based guaranteed throughput target", + "{00:00~07:00,2Mbps}, {07:00~09:00,10Mbps}, {09:00~17:30,10Mbps}, " + "{17:30~23:00,20Mbps}, {20:00~00:00,10Mbps}", + ), + ], + "zh-CN": [ + ("区域", "松山湖管委会"), + ( + "分时段保障速率目标", + "{00:00~07:00,2Mbps}、{07:00~09:00,10Mbps}、{09:00~17:30,10Mbps}、" + "{17:30~23:00,20Mbps}、{20:00~00:00,10Mbps}", + ), + ], } -#: Confirmed intent of the target negotiation accept (the confirmed-target sentence) per language. +#: Clarification requests carried by the target propose (``request_for_clarification``). +_NEGOTIATION_TARGET_CLARIFICATIONS: dict[str, list[tuple[str, str]]] = { + "en-US": [ + ( + "Time-segment-based guaranteed throughput target", + "The two segments {17:30~23:00,20Mbps} and {20:00~00:00,10Mbps} overlap, so the " + "guaranteed rate for the overlapping period is ambiguous; please adjust the time.", + ) + ], + "zh-CN": [ + ( + "分时段保障速率目标", + "如下两个时段存在时间重叠,无法唯一确定重叠时段的速率保障目标,请调整时间:" + "{17:30~23:00,20Mbps}、{20:00~00:00,10Mbps}", + ) + ], +} + +#: Confirmed intent of the target negotiation accept (the corrected target) per language. _NEGOTIATION_TARGET_CONFIRMATIONS: dict[str, str] = { - "en-US": "The clarified target is a guaranteed throughput of 20Mbps for the region.", - "zh-CN": "澄清后的目标为该区域保障速率20Mbps。", + "en-US": ( + "The energy saving target is corrected to {00:00~07:00,2Mbps}, {07:00~09:00,10Mbps}, " + "{09:00~17:30,10Mbps}, {17:30~23:00,20Mbps}, {23:00~00:00,10Mbps}." + ), + "zh-CN": ( + "节能目标修正为{00:00~07:00,2Mbps}、{07:00~09:00,10Mbps}、{09:00~17:30,10Mbps}、" + "{17:30~23:00,20Mbps}、{23:00~00:00,10Mbps}。" + ), } #: Feasibility description of the feasibility negotiation propose per language. _NEGOTIATION_FEASIBILITY_DESCRIPTIONS: dict[str, str] = { "en-US": ( - "Feasibility negotiation for the RAN energy saving task: the 30% energy saving goal " - "conflicts with the 50Mbps guaranteed throughput target, so the feasibility needs a joint " - "evaluation." + "The feasibility assessment for the station-level energy saving task is complete: the " + "current guaranteed throughput target cannot satisfy the power-outage duration " + "requirement; see the infeasibility details and proposal." + ), + "zh-CN": ( + "站点级节能任务的目标达成可行性评估已完成,当前速率目标无法同时满足停电时长要求," + "详见评估不可行时的详情和提案,请确认。" ), - "zh-CN": "无线网络节能任务的可行性协商:节能目标30%与保障速率目标50Mbps存在冲突,需对可行性进行联合评估。", } -#: Contents to evaluate of the feasibility negotiation propose per language (corpus -#: "Target achievement" / "Resource status" style). -_NEGOTIATION_FEASIBILITY_CONTENTS: dict[str, list[tuple[str, str]]] = { +#: Infeasibility details and proposal of the feasibility propose +#: (``infeasibility_details_and_proposal``). +_NEGOTIATION_FEASIBILITY_INFEASIBILITY: dict[str, list[tuple[str, str]]] = { "en-US": [ + ("Conclusion", "Infeasible."), ( - "Target achievement", - "The area runs energy saving from 00:00 to 06:00 while guaranteeing a throughput target of 50Mbps.", + "Basis", + 'The original request asks stations "Station 1" and "Station 2" to guarantee ' + "{00:00~06:00,2Mbps} and {06:00~18:00,10Mbps}, while the sites carry an existing " + "constraint of at least 18 hours of power autonomy during an outage. With the current " + "guaranteed rate the power consumption in that period cannot meet the outage-duration " + "requirement; the conclusion is based on the sites' battery capacity and historical " + "power curves.", ), ( - "Resource status", - "The cells in the area are NR with carrier shutdown available during the low-traffic period.", + "Proposal", + "Lower the guaranteed throughput target for 06:00~18:00 from 10Mbps to 2Mbps, which " + "extends the supportable outage duration to the required 18 hours. If lowering the " + "rate is not acceptable, shorten the required outage duration instead. One of the two " + "must be adjusted; no third option satisfies both original requirements.", ), ], "zh-CN": [ - ("目标达成", "区域在00:00~06:00时段执行节能,同时保障速率目标为50Mbps。"), - ("能源现状", "区域内小区为NR制式,低话务时段具备关断载频条件。"), + ("评估结论", "不可行。"), + ( + "评估依据", + "原始请求要求“站点名称1”“站点名称2”2个站点执行 {00:00~06:00,2Mbps}、" + "{06:00~18:00,10Mbps} 的速率保障目标,同时该站点存在停电场景下需保障至少18小时供电" + "时长的既有约束。经评估,若按当前速率保障目标执行,该时段内设备功耗无法满足停电时长" + "要求,两项要求之间存在冲突,判断依据为当前站点的备电容量及历史功耗曲线。", + ), + ( + "提案", + "建议将06:00~18:00时段的速率保障目标由10Mbps下调至2Mbps,以延长可支撑的停电时长至" + "满足要求的18小时。若无法接受下调速率,则需相应缩短停电时长保障要求。两者需二选一" + "调整,暂无第三种可同时满足原有两项要求的方案。", + ), ], } -#: Feasibility summary of the feasibility negotiation accept per language. +#: Feasibility summary of the feasibility negotiation accept (the accepted proposal) per language. _NEGOTIATION_FEASIBILITY_SUMMARIES: dict[str, str] = { - "en-US": "The energy saving target is achievable; the negotiation is confirmed.", - "zh-CN": "节能目标可达成,本次协商确认结束。", + "en-US": ( + "Agreed to lower the guaranteed throughput target for 06:00~18:00 from 10Mbps to 2Mbps; " + "the negotiation is confirmed as complete." + ), + "zh-CN": "同意将06:00~18:00时段的速率保障目标由10Mbps下调至2Mbps,本次协商确认结束。", } @@ -376,11 +453,44 @@ def build_prompt_input( return NATURAL_LANGUAGE_PROMPT_INPUT_EN if is_english else NATURAL_LANGUAGE_PROMPT_INPUT_ZH -def negotiation_filled_params(*, env_path: Path | None = None) -> dict[str, object]: - """Return the four Task-T parameters with the region filled (the negotiation accept input).""" +#: Guaranteed-throughput target of the Task-T refill between negotiation kinds: the target case +#: accepts the corrected non-overlapping segments, the feasibility case the lowered rate. The +#: information case keeps the complete input's target (the missing parameter is the region only). +_NEGOTIATION_FILLED_TARGETS: dict[str, dict[str, str]] = { + NEGOTIATION_KIND_TARGET: { + "en-US": ( + "{00:00~07:00,2Mbps}, {07:00~09:00,10Mbps}, {09:00~17:30,10Mbps}, " + "{17:30~23:00,20Mbps}, {23:00~00:00,10Mbps}" + ), + "zh-CN": ( + "{00:00~07:00,2Mbps}、{07:00~09:00,10Mbps}、{09:00~17:30,10Mbps}、" + "{17:30~23:00,20Mbps}、{23:00~00:00,10Mbps}" + ), + }, + NEGOTIATION_KIND_FEASIBILITY: { + "en-US": "{00:00~06:00,2Mbps}, {06:00~18:00,2Mbps}", + "zh-CN": "{00:00~06:00,2Mbps}、{06:00~18:00,2Mbps}", + }, +} + + +def negotiation_filled_params( + negotiation_kind: str = NEGOTIATION_KIND_INFORMATION, + *, + env_path: Path | None = None, +) -> dict[str, object]: + """Return the four Task-T parameters for the refill, adjusted to what the negotiation accepted. + + The target case refills the corrected (non-overlapping) segments and the feasibility case the + lowered guaranteed rate, so the re-validated Task-T prompt carries the agreed target; the + information case only fills the region and keeps the complete input's target. + """ language = resolve_language(env_path=env_path) - filled = _NEGOTIATION_FILLED_PARAMS.get(language, _NEGOTIATION_FILLED_PARAMS["zh-CN"]) - return dict(filled) + filled = dict(_NEGOTIATION_FILLED_PARAMS.get(language, _NEGOTIATION_FILLED_PARAMS["zh-CN"])) + kind_targets = _NEGOTIATION_FILLED_TARGETS.get(negotiation_kind) + if kind_targets is not None: + filled["guaranteedThroughputTarget"] = kind_targets.get(language, kind_targets["zh-CN"]) + return filled def negotiation_answers(*, env_path: Path | None = None) -> dict[str, str]: @@ -394,9 +504,20 @@ def negotiation_target_description(*, env_path: Path | None = None) -> str: return _localized_text(_NEGOTIATION_TARGET_DESCRIPTIONS, env_path=env_path) -def negotiation_target_confirm_request(*, env_path: Path | None = None) -> str: - """Return the clarified-target confirm request (mirrors the canned propose extraction).""" - return _localized_text(_NEGOTIATION_TARGET_CONFIRM_REQUESTS, env_path=env_path) +def negotiation_target_intent_understanding(*, env_path: Path | None = None) -> list[tuple[str, str]]: + """Return the intent restatement of the target propose (canned propose extraction).""" + language = resolve_language(env_path=env_path) + items = _NEGOTIATION_TARGET_INTENT_UNDERSTANDINGS.get( + language, _NEGOTIATION_TARGET_INTENT_UNDERSTANDINGS["zh-CN"] + ) + return list(items) + + +def negotiation_target_clarifications(*, env_path: Path | None = None) -> list[tuple[str, str]]: + """Return the clarification requests of the target propose (canned propose extraction).""" + language = resolve_language(env_path=env_path) + items = _NEGOTIATION_TARGET_CLARIFICATIONS.get(language, _NEGOTIATION_TARGET_CLARIFICATIONS["zh-CN"]) + return list(items) def negotiation_target_confirmation(*, env_path: Path | None = None) -> str: @@ -409,11 +530,13 @@ def negotiation_feasibility_description(*, env_path: Path | None = None) -> str: return _localized_text(_NEGOTIATION_FEASIBILITY_DESCRIPTIONS, env_path=env_path) -def negotiation_feasibility_contents(*, env_path: Path | None = None) -> list[tuple[str, str]]: - """Return the contents to evaluate of the feasibility propose (canned propose extraction).""" +def negotiation_feasibility_infeasibility(*, env_path: Path | None = None) -> list[tuple[str, str]]: + """Return the infeasibility details and proposal of the feasibility propose (canned extraction).""" language = resolve_language(env_path=env_path) - contents = _NEGOTIATION_FEASIBILITY_CONTENTS.get(language, _NEGOTIATION_FEASIBILITY_CONTENTS["zh-CN"]) - return list(contents) + items = _NEGOTIATION_FEASIBILITY_INFEASIBILITY.get( + language, _NEGOTIATION_FEASIBILITY_INFEASIBILITY["zh-CN"] + ) + return list(items) def negotiation_feasibility_summary(*, env_path: Path | None = None) -> str: diff --git a/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py b/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py index ec21a2f3..6bde077d 100644 --- a/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py +++ b/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py @@ -128,9 +128,12 @@ #: Distinguishing values the target/feasibility rendered prompts carry (the dedicated #: slot-extraction variants preserve them), so the content-validation routing also fires on real -#: rendered-prompt heads and not only on the scenario natural-language inputs. -_TARGET_VALIDATION_MARKER = "100Mbps" -_FEASIBILITY_VALIDATION_MARKERS = ("30%", "50Mbps") +#: rendered-prompt heads and not only on the scenario natural-language inputs. Each marker is a +#: value the FAILING input carries and the negotiated refill does not, so the continuation routes +#: to the complete variant: the target marker is the overlapping segment, the feasibility marker +#: the pre-proposal guaranteed rate. +_TARGET_VALIDATION_MARKER = "20:00~00:00,10Mbps" +_FEASIBILITY_VALIDATION_MARKERS = ("06:00~18:00,10Mbps",) #: Negotiation-T extraction signatures routed most-specific property set first, pinned to the #: property sets of ``a2a_t.negotiation.generation.json_schema_builder.build_extraction_schema``. @@ -156,7 +159,7 @@ #: Language the canned script was loaded for (region-string routing reads it). _script_language = "zh-CN" -#: Routed stage name of every served structured call (test seam). +#: Routed stage name of every served structured call (test hook). llm_calls: list[str] = [] _mock_enabled = False @@ -189,7 +192,7 @@ def is_mock_enabled() -> bool: def reset_call_log() -> None: - """Clear the routed stage log (test seam).""" + """Clear the routed stage log (test hook).""" llm_calls.clear() diff --git a/a2a-t-sample/ran-energy-saving/src/server/server_flow.py b/a2a-t-sample/ran-energy-saving/src/server/server_flow.py index b33f08f3..bac8a242 100644 --- a/a2a-t-sample/ran-energy-saving/src/server/server_flow.py +++ b/a2a-t-sample/ran-energy-saving/src/server/server_flow.py @@ -182,12 +182,13 @@ def _negotiation_propose_text( """ if negotiation_type == NEGOTIATION_TYPE_TARGET: description = scenario_data.negotiation_target_description(env_path=env_path) - confirm_request = scenario_data.negotiation_target_confirm_request(env_path=env_path) - return f"{description} {confirm_request}" + intent = scenario_data.negotiation_target_intent_understanding(env_path=env_path) + clarifications = scenario_data.negotiation_target_clarifications(env_path=env_path) + return f"{description} {_numbered_items(intent)} {_numbered_items(clarifications)}" if negotiation_type == NEGOTIATION_TYPE_FEASIBILITY: description = scenario_data.negotiation_feasibility_description(env_path=env_path) - contents = scenario_data.negotiation_feasibility_contents(env_path=env_path) - return f"{description} Contents to evaluate: {_numbered_items(contents)}" + infeasibility = scenario_data.negotiation_feasibility_infeasibility(env_path=env_path) + return f"{description} {_numbered_items(infeasibility)}" text = _numbered_items((item.name, item.value) for item in missing_items) relationship = NEGOTIATION_RELATIONSHIP if len(missing_items) > 1 else None if relationship: @@ -211,21 +212,29 @@ def _negotiation_propose_data( *, env_path: Path | None, ) -> NegotiationProposeData: - """Build the typed propose content of one negotiation type (plan R3 scenario semantics). + """Build the typed propose content of one negotiation type (spec scenario semantics). - Target proposes the clarified-target confirm-request round and feasibility proposes the - feasibility evaluation request; both fill their scenario-owned texts from the - ``client.scenario_data`` accessors. Information keeps the v1 missing-item propose. + Target proposes the intent restatement plus the clarification request (the overlapping + time segments), and feasibility proposes the infeasibility assessment plus the alternative + proposal (lower the guaranteed rate to sustain the outage duration); both fill their + scenario-owned texts from the ``client.scenario_data`` accessors. Information keeps the + missing-item propose. """ if negotiation_type == NEGOTIATION_TYPE_TARGET: return NegotiationProposeData( context=context, content=TargetProposeContent( target_negotiation_description=scenario_data.negotiation_target_description(env_path=env_path), - intent_understanding=None, + intent_understanding=[ + NegotiationItem(name=name, value=value) + for name, value in scenario_data.negotiation_target_intent_understanding(env_path=env_path) + ], alignment_and_clarification=None, - request_for_clarification=None, - target_confirm_request=scenario_data.negotiation_target_confirm_request(env_path=env_path), + request_for_clarification=[ + NegotiationItem(name=name, value=value) + for name, value in scenario_data.negotiation_target_clarifications(env_path=env_path) + ], + target_confirm_request=None, ), ) if negotiation_type == NEGOTIATION_TYPE_FEASIBILITY: @@ -235,12 +244,12 @@ def _negotiation_propose_data( feasibility_negotiation_description=scenario_data.negotiation_feasibility_description( env_path=env_path ), - action=NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, - contents_to_evaluate=[ + action=NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, + contents_to_evaluate=None, + infeasibility_details_and_proposal=[ NegotiationItem(name=name, value=value) - for name, value in scenario_data.negotiation_feasibility_contents(env_path=env_path) + for name, value in scenario_data.negotiation_feasibility_infeasibility(env_path=env_path) ], - infeasibility_details_and_proposal=None, feasibility_confirm_request=None, ), ) @@ -323,7 +332,7 @@ async def _execute_and_emit_result( context_id: str, log_sink: object | None, ) -> None: - """Execute the negotiated task through the ``TaskExecutionService`` seam and push its artifact. + """Execute the negotiated task through the ``TaskExecutionService`` integration point and push its artifact. INTEGRATION POINT (server): the flow owns the A2A emission — each ``emit_progress`` call is a ``TASK_STATE_WORKING`` status update, the returned ``TaskExecutionResult`` becomes the report @@ -412,14 +421,14 @@ async def execute_server_flow( through it and the flow only emits the A2A events. ``None`` (the default) keeps the demo behavior — an internal :class:`Uc1DemoExecutionService` is constructed from the legacy ``progress_steps`` / ``intent_report`` / ``sleep_fn`` / ``artifact_send_interval_seconds`` - parameters, whose defaults preserve the pre-seam behavior for existing call sites and tests. + parameters, whose defaults preserve the pre-integration-point behavior for existing call sites and tests. Flow (RAN energy-saving interface spec, UC1): 1. Validate the A2A-Extensions header (Task-T) and extract the prompt text 2. ``A2ATServer.validate_task_prompt_and_data_filling`` (A2A-T server SDK) validates the prompt and extracts the filled parameters 3. Negotiation continuation (Negotiation-T URI in the message metadata): no SUBMITTED — - validate, execute the task through the seam (progress messages and report artifact), + validate, execute the task through the integration point (progress messages and report artifact), then COMPLETED 4. Default flow: emit SUBMITTED, then on failure REJECTED — or, when the first validation error's code selects a negotiation type (plan R1: ``content.param_missing`` information @@ -427,7 +436,7 @@ async def execute_server_flow( feasibility), a Negotiation-T propose of that type on an INPUT_REQUIRED status update, generated from typed data or from a natural-language rendering per the configured channel (data-driven trigger, the server reads no scenario flag) - 5. Default success: execute the negotiated task through the seam — each progress line + 5. Default success: execute the negotiated task through the integration point — each progress line becomes a ``TASK_STATE_WORKING`` status update and the ``TaskExecutionResult`` becomes the report artifact as one ``TaskArtifactUpdateEvent`` (the report body travels in ``artifact.metadata[Task-T/v1]``); the task then stays long-running — no terminal state @@ -478,7 +487,7 @@ async def execute_server_flow( validation_failure: str | None = None content_errors: Sequence[SlotValidationError] | None = None missing_items: list[NegotiationItem] = [] - # The object parameter mapping the execution seam consumes; set on validation success. The + # The object parameter mapping the execution integration point consumes; set on validation success. The # sample schema is object-shaped, so the filled data is always a mapping — the empty mapping # only keeps the (unused by this sample) array-shaped corner type-safe. filled_params_data: dict[str, object] = {} @@ -510,7 +519,7 @@ async def execute_server_flow( log_sink(format_stage_log(role="server", stage="sdk-result", detail=f"invalid input {validation_failure}")) # Negotiation continuation: no SUBMITTED — the completed Task-T prompt is validated, - # the task executes through the seam, and the task closes with COMPLETED. + # the task executes through the integration point, and the task closes with COMPLETED. if negotiation_continuation: if validation_failure is not None: await emit_status_update( @@ -592,7 +601,7 @@ async def execute_server_flow( log_sink(format_stage_log(role="server", stage="task-status", detail="TASK_STATE_REJECTED")) return - # 3. On success → execute the negotiated task through the seam (default flow stays long-running) + # 3. On success → execute the negotiated task through the integration point (default flow stays long-running) await _execute_and_emit_result( request_context=request_context, event_queue=event_queue, diff --git a/a2a-t-sample/ran-energy-saving/src/server/server_main.py b/a2a-t-sample/ran-energy-saving/src/server/server_main.py index 8204bb6e..2247f781 100644 --- a/a2a-t-sample/ran-energy-saving/src/server/server_main.py +++ b/a2a-t-sample/ran-energy-saving/src/server/server_main.py @@ -130,7 +130,7 @@ def build_server_runtime( The negotiation generation channel is resolved here from the run's ``.env`` (``A2AT_SAMPLE_CHANNEL``, default ``data``) and, with the ``env_path`` itself, threaded into the executor so the propose builders read the same scenario configuration. The execution - service of the task seam is assembled here too and threaded through the executor into + service of the task execution integration point is assembled here too and threaded through the executor into ``execute_server_flow``. """ resolved_logger = logger diff --git a/a2a-t-sample/ran-energy-saving/src/server/task_execution.py b/a2a-t-sample/ran-energy-saving/src/server/task_execution.py index 699928d1..cafc036f 100644 --- a/a2a-t-sample/ran-energy-saving/src/server/task_execution.py +++ b/a2a-t-sample/ran-energy-saving/src/server/task_execution.py @@ -1,4 +1,4 @@ -"""Server-side task execution seam (INTEGRATION POINT) of the ran-energy-saving demo. +"""Server-side task execution integration point (INTEGRATION POINT) of the ran-energy-saving demo. ``TaskExecutionService`` is the Protocol the server flow depends on: it is where a customer's business system executes the negotiated task. ``Uc1DemoExecutionService`` is the colocated demo @@ -41,7 +41,7 @@ class TaskExecutionResult: class TaskExecutionService(Protocol): """INTEGRATION POINT (server): execute the negotiated task in YOUR business system. - The server flow calls this seam once the Task-T prompt validated and the negotiation + The server flow calls this integration point once the Task-T prompt validated and the negotiation concluded: ``params`` carries the filled task parameters extracted by the Task-T content validation, and the implementation runs the real business logic (in this demo, the scripted UC1 energy-saving execution; in production, your EMS/NMS northbound call). diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py index 7ebc8ccc..e0d129c5 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py @@ -47,13 +47,14 @@ SUPPORTED_SCENARIOS, build_prompt_input, negotiation_answers, - negotiation_feasibility_contents, negotiation_feasibility_description, + negotiation_feasibility_infeasibility, negotiation_feasibility_summary, negotiation_filled_params, - negotiation_target_confirm_request, + negotiation_target_clarifications, negotiation_target_confirmation, negotiation_target_description, + negotiation_target_intent_understanding, resolve_channel, resolve_negotiation_kind, resolve_scenario, @@ -524,7 +525,6 @@ def test_missing_mock_resources_mirror_the_complete_variants(language: str) -> N def test_negotiation_slot_resources_preserve_the_distinguishing_values(language: str) -> None: region_slot = _REGION_SLOT_NAMES[language] context_slot = _SLOT_SECTION_NAMES[language]["context"] - target_slot = _SLOT_SECTION_NAMES[language]["target"] complete = _resource(language, "slot_extraction.json") target = _resource(language, "slot_extraction_target.json") feasibility = _resource(language, "slot_extraction_feasibility.json") @@ -538,10 +538,10 @@ def test_negotiation_slot_resources_preserve_the_distinguishing_values(language: assert target["slots"][region_slot] == complete["slots"][region_slot] assert feasibility["slots"][region_slot] == complete["slots"][region_slot] # Every slot equals the complete variant except the distinguishing ones: the target variant - # keeps the 100Mbps throughput, the feasibility variant the 30% goal and the 50Mbps throughput. + # carries the overlapping segments, the feasibility variant the outage context. for variant, distinguishing in ( - (target, {context_slot: "100Mbps"}), - (feasibility, {context_slot: "50Mbps", target_slot: "30%"}), + (target, {context_slot: "20:00~00:00,10Mbps"}), + (feasibility, {context_slot: "00:00~18:00" if language == "zh-CN" else "00:00 to 18:00"}), ): for name, value in complete["slots"].items(): # type: ignore[union-attr] if name in distinguishing: @@ -618,7 +618,8 @@ def test_content_validation_variant_resources_carry_the_trigger_codes(language: feasibility = _resource(language, "content_validation_feasibility.json") # The target variant reports the value-not-allowed error on the throughput param; the - # feasibility variant reports the semantic-conflict error on the energy goal param. + # feasibility variant reports the semantic-conflict error on the throughput param too — the + # conflict is between the guaranteed rate and the outage duration, not the energy goal. assert target["semantic_verdict"] is False assert target["errors"] == [ { @@ -631,12 +632,12 @@ def test_content_validation_variant_resources_carry_the_trigger_codes(language: assert feasibility["semantic_verdict"] is False assert feasibility["errors"] == [ { - "slot_name": "energySavingGoal", + "slot_name": "guaranteedThroughputTarget", "code": "content.semantic_conflict", - "facts": {"section_label": _VARIANT_SECTION_LABELS[language]["task_target"]}, + "facts": {"section_label": _VARIANT_SECTION_LABELS[language]["task_context"]}, } ] - assert feasibility["params"] == {**complete["params"], "energySavingGoal": None} + assert feasibility["params"] == {**complete["params"], "guaranteedThroughputTarget": None} @pytest.mark.parametrize("language", ["en-US", "zh-CN"]) @@ -654,23 +655,29 @@ def test_negotiation_accessors_match_the_canned_extraction_responses(tmp_path: P assert negotiation_target_confirmation(env_path=env_path) == target_accept["confirmed_intent"] assert feasibility_accept["conclusion"] == "Accept" assert negotiation_feasibility_summary(env_path=env_path) == feasibility_accept["feasibility_summary"] - # The server-side propose texts mirror the canned propose extraction (confirm-request round: - # all three conditional lists stay null; evaluation request: details/request stay null). + # The server-side propose texts mirror the canned propose extraction (clarification form: the + # intent restatement plus the clarification request; infeasibility form: the details/proposal). assert negotiation_target_description(env_path=env_path) == target_propose["target_negotiation_description"] - assert negotiation_target_confirm_request(env_path=env_path) == target_propose["target_confirm_request"] - assert target_propose["intent_understanding"] is None + assert negotiation_target_intent_understanding(env_path=env_path) == [ + (item["name"], item["value"]) + for item in target_propose["intent_understanding"] # type: ignore[union-attr] + ] + assert negotiation_target_clarifications(env_path=env_path) == [ + (item["name"], item["value"]) + for item in target_propose["request_for_clarification"] # type: ignore[union-attr] + ] assert target_propose["alignment_and_clarification"] is None - assert target_propose["request_for_clarification"] is None - assert feasibility_propose["action"] == "REQUEST_FEASIBILITY_EVALUATION" + assert target_propose["target_confirm_request"] is None + assert feasibility_propose["action"] == "PROPOSE_ALTERNATIVE_ON_FAILURE" assert ( negotiation_feasibility_description(env_path=env_path) == feasibility_propose["feasibility_negotiation_description"] ) - assert negotiation_feasibility_contents(env_path=env_path) == [ + assert negotiation_feasibility_infeasibility(env_path=env_path) == [ (item["name"], item["value"]) - for item in feasibility_propose["contents_to_evaluate"] # type: ignore[union-attr] + for item in feasibility_propose["infeasibility_details_and_proposal"] # type: ignore[union-attr] ] - assert feasibility_propose["infeasibility_details_and_proposal"] is None + assert feasibility_propose["contents_to_evaluate"] is None assert feasibility_propose["feasibility_confirm_request"] is None @@ -847,11 +854,11 @@ def test_negotiation_kind_inputs_and_accessors_are_complete(tmp_path: Path, lang # Both kind inputs are complete inputs: they carry the region display string. assert NEGOTIATION_REGION_STRINGS[language] in target_input assert NEGOTIATION_REGION_STRINGS[language] in feasibility_input - # The target input carries the 100Mbps target; the feasibility input carries both the 30% - # goal and the 50Mbps throughput. - assert "100Mbps" in target_input - assert "30%" in feasibility_input - assert "50Mbps" in feasibility_input + # The target input carries the overlapping segments; the feasibility input carries the + # station list and the power outage. + assert "20:00~00:00,10Mbps" in target_input + assert "06:00~18:00,10Mbps" in feasibility_input + assert ("停电" in feasibility_input) if language == "zh-CN" else ("power outage" in feasibility_input) # Each kind input maps to its own content-validation stage via the router. assert ( route_structured_call( @@ -867,9 +874,10 @@ def test_negotiation_kind_inputs_and_accessors_are_complete(tmp_path: Path, lang ) # All per-type accessors return non-empty values for both languages. assert negotiation_target_description(env_path=env_path) - assert negotiation_target_confirm_request(env_path=env_path) + assert negotiation_target_intent_understanding(env_path=env_path) + assert negotiation_target_clarifications(env_path=env_path) assert negotiation_target_confirmation(env_path=env_path) - assert len(negotiation_feasibility_contents(env_path=env_path)) == 2 + assert len(negotiation_feasibility_infeasibility(env_path=env_path)) == 3 assert negotiation_feasibility_summary(env_path=env_path) diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py index 8df099f2..8f56a97c 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py @@ -60,11 +60,12 @@ ENERGY_SAVING_TEMPLATE_URI, build_prompt_input, negotiation_answers, - negotiation_feasibility_contents, negotiation_feasibility_description, + negotiation_feasibility_infeasibility, negotiation_filled_params, - negotiation_target_confirm_request, + negotiation_target_clarifications, negotiation_target_description, + negotiation_target_intent_understanding, ) from common import mock_llm from common.llm_logger import install_llm_logger @@ -368,9 +369,10 @@ def test_round_1_target_failure_answers_input_required_carrying_the_target_propo assert context["round"] == 1 assert context["maxRounds"] == NegotiationContext.DEFAULT_MAX_ROUNDS assert context["performative"] == "PROPOSE" - # The propose text carries the clarified-target confirm request of the scenario data. + # The propose text carries the clarification request of the scenario data. propose_text = str(metadata[NEGOTIATION_T_EXTENSION_URI]) - assert negotiation_target_confirm_request(env_path=_offline_env) in propose_text + for name, _value in negotiation_target_clarifications(env_path=_offline_env): + assert name in propose_text # from-data propose generation is deterministic: no negotiation extraction call is logged. assert mock_llm.llm_calls == [ mock_llm.STAGE_SLOT_EXTRACTION_TARGET, @@ -397,9 +399,9 @@ def test_round_1_feasibility_failure_answers_input_required_carrying_the_feasibi context = metadata[NEGOTIATION_CONTEXT_METADATA_KEY] assert context["round"] == 1 assert context["performative"] == "PROPOSE" - # The propose text renders the contents to evaluate of the scenario data. + # The propose text renders the infeasibility details and proposal of the scenario data. propose_text = str(metadata[NEGOTIATION_T_EXTENSION_URI]) - for name, _value in negotiation_feasibility_contents(env_path=_offline_env): + for name, _value in negotiation_feasibility_infeasibility(env_path=_offline_env): assert name in propose_text assert mock_llm.llm_calls == [ mock_llm.STAGE_SLOT_EXTRACTION_FEASIBILITY, @@ -429,7 +431,7 @@ def test_round_1_target_failure_from_text_channel_generates_via_extraction(_offl propose_metadata = _message_metadata(events[-1]) # The templateUri still carries the target propose URI — the client's dispatch key is unchanged. assert propose_metadata[TEMPLATE_URI_METADATA_KEY] == TARGET_NEGOTIATION_PROPOSE_URI - assert negotiation_target_confirm_request(env_path=_offline_env) in str( + assert negotiation_target_clarifications(env_path=_offline_env)[0][0] in str( propose_metadata[NEGOTIATION_T_EXTENSION_URI] ) # from-text propose generation runs exactly one extraction call after the initial validation. @@ -461,7 +463,7 @@ def test_round_1_feasibility_failure_from_text_channel_generates_via_extraction( # The rendered text carries the description and the contents to evaluate of the scenario data. propose_text = str(propose_metadata[NEGOTIATION_T_EXTENSION_URI]) assert negotiation_feasibility_description(env_path=_offline_env) in propose_text - for name, value in negotiation_feasibility_contents(env_path=_offline_env): + for name, value in negotiation_feasibility_infeasibility(env_path=_offline_env): assert f"{name}: {value}" in propose_text assert mock_llm.llm_calls == [ mock_llm.STAGE_SLOT_EXTRACTION_FEASIBILITY, @@ -539,22 +541,23 @@ def test_information_propose_text_renders_numbered_items_and_the_relationship() assert _numbered_items([("a", ""), ("b", None)]) == "1. a;2. b;" -def test_target_propose_text_joins_the_description_and_the_confirm_request(_offline_env: Path) -> None: +def test_target_propose_text_joins_the_description_and_the_clarification_request(_offline_env: Path) -> None: text = _negotiation_propose_text(NEGOTIATION_TYPE_TARGET, [], env_path=_offline_env) assert text == ( f"{negotiation_target_description(env_path=_offline_env)} " - f"{negotiation_target_confirm_request(env_path=_offline_env)}" + f"{_numbered_items(negotiation_target_intent_understanding(env_path=_offline_env))} " + f"{_numbered_items(negotiation_target_clarifications(env_path=_offline_env))}" ) -def test_feasibility_propose_text_renders_the_contents_to_evaluate(_offline_env: Path) -> None: +def test_feasibility_propose_text_renders_the_infeasibility_details(_offline_env: Path) -> None: text = _negotiation_propose_text(NEGOTIATION_TYPE_FEASIBILITY, [], env_path=_offline_env) assert text.startswith( - f"{scenario_data.negotiation_feasibility_description(env_path=_offline_env)} Contents to evaluate: " + f"{scenario_data.negotiation_feasibility_description(env_path=_offline_env)} " ) - for name, value in negotiation_feasibility_contents(env_path=_offline_env): + for name, value in negotiation_feasibility_infeasibility(env_path=_offline_env): assert f"{name}:{value}" in text From 105e679b474cf6a22c94ef1179b828a7a9dd47e2 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 19 Oct 2026 22:46:37 +0800 Subject: [PATCH 04/30] docs(sample): rewrite the negotiation chapter in plain language Second SE review pass over the Negotiation-T chapter. - the three cases now match the spec: the target case is the overlapping time segments, the feasibility case the outage whose duration the guaranteed rate cannot sustain - "when a negotiation happens" and "how the demo decides" are separate subsections instead of one section with two topics - message 3 says the request is re-sent with the missing or corrected content and that the answer travels in that same message, so it no longer reads as two messages - templateUri is described by what it is (the prompt template the message text was rendered from) and, separately, how the client uses it - "decision seam" -> "where your decision plugs in" / integration point; "generation channels (fromData / fromText)" -> generated from structured data or from natural language Signed-off-by: codetalks-hw --- a2a-t-sample/README-zh.md | 2 +- a2a-t-sample/README.md | 2 +- a2a-t-sample/ran-energy-saving/README-zh.md | 113 ++++++++-------- a2a-t-sample/ran-energy-saving/README.md | 138 +++++++++++--------- 4 files changed, 137 insertions(+), 118 deletions(-) diff --git a/a2a-t-sample/README-zh.md b/a2a-t-sample/README-zh.md index 56470ab1..07b1b064 100644 --- a/a2a-t-sample/README-zh.md +++ b/a2a-t-sample/README-zh.md @@ -13,4 +13,4 @@ | 目录 | 说明 | | --- | --- | | [fault-management/](fault-management/) | 事件订阅样例——流式推送 Incident 通知 | -| [ran-energy-saving/](ran-energy-saving/) | 无线网络节能样例——运行 RAN 节能任务,并包含三类 Negotiation-T 协商(信息 / 目标 / 可行性),覆盖 fromData / fromText 两种生成通道,另支持可选的 webhook 传输(`--transport webhook`):全部任务事件以 A2A 推送通知回调送达客户端内嵌的接收器 | +| [ran-energy-saving/](ran-energy-saving/) | 无线网络节能样例——运行 RAN 节能任务,并包含三类 Negotiation-T 协商(信息 / 目标 / 可行性),协商消息可从结构化数据或自然语言生成,另支持可选的 webhook 传输(`--transport webhook`):全部任务事件以 A2A 推送通知回调送达客户端内嵌的接收器 | diff --git a/a2a-t-sample/README.md b/a2a-t-sample/README.md index a613b219..be0a826d 100644 --- a/a2a-t-sample/README.md +++ b/a2a-t-sample/README.md @@ -13,4 +13,4 @@ See the repository root [`README.md`](../README.md) for setup and how to run. | Directory | Description | | --- | --- | | [fault-management/](fault-management/) | Event subscription sample — stream Incident notifications | -| [ran-energy-saving/](ran-energy-saving/) | RAN energy saving sample — run a RAN energy-saving task, including Negotiation-T negotiations in three kinds (information / target / feasibility) over both generation channels (fromData / fromText), plus an optional webhook transport (`--transport webhook`) that delivers every task event as an A2A push-notification callback to a client-embedded receiver | +| [ran-energy-saving/](ran-energy-saving/) | RAN energy saving sample — run a RAN energy-saving task, including Negotiation-T negotiations in three kinds (information / target / feasibility), generated either from structured data or from natural language, plus an optional webhook transport (`--transport webhook`) that delivers every task event as an A2A push-notification callback to a client-embedded receiver | diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 4b76b6d5..36b6010a 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -27,12 +27,12 @@ Negotiation-T 请求不能直接执行时(信息缺失 / 目标不清晰 / | 路径 | 核心职责 | | --- | --- | | [`src/client/client_main.py`](src/client/client_main.py) | 装配根:读取 `.env`、构建运行时(`build_client_runtime`)、从注册中心发现服务端 AgentCard、启动/停止 webhook 接收器、装配 demo 的 `ScriptedNegotiationResponseProvider`。客户端侧 `INTEGRATION POINT` 标注位于 `main()`。 | -| [`src/client/client_flow.py`](src/client/client_flow.py) | 编排:消息序列(生成 → 发送 → 消费)、报文机制(metadata 键、`A2A-Extensions` 头)、事件管线,以及两种传输驱动(SSE 流式 / webhook 队列)。它调用协商决策接缝,但不持有任何决策。 | -| [`src/client/negotiation_response.py`](src/client/negotiation_response.py) | 协商决策接缝:`NegotiationProposeView`、`NegotiationDecision`、`NegotiationResponseProvider` Protocol,以及 demo 实现 `ScriptedNegotiationResponseProvider`。 | +| [`src/client/client_flow.py`](src/client/client_flow.py) | 编排:消息序列(生成 → 发送 → 消费)、报文机制(metadata 键、`A2A-Extensions` 头)、事件管线,以及两种传输驱动(SSE 流式 / webhook 队列)。它调用协商决策接入点,但不持有任何决策。 | +| [`src/client/negotiation_response.py`](src/client/negotiation_response.py) | 协商决策接入点:`NegotiationProposeView`、`NegotiationDecision`、`NegotiationResponseProvider` Protocol,以及 demo 实现 `ScriptedNegotiationResponseProvider`。 | | [`src/client/scenario_data.py`](src/client/scenario_data.py) | 样例输入与脚本化应答 —— 它**同时喂两侧**(见[替换清单](#5-替换清单demo-到生产)),不能原样删除。 | | [`src/server/server_main.py`](src/server/server_main.py) | a2a-sdk 应用装配,推送通知机制无条件装配。服务端侧 `INTEGRATION POINT` 标注位于 `build_server_runtime`。 | | [`src/server/server_flow.py`](src/server/server_flow.py) | 服务端流程:校验 → 协商或执行 → 流式推送进度与报告。 | -| [`src/server/task_execution.py`](src/server/task_execution.py) | `TaskExecutionService` 执行接缝,以及 demo 的 UC1 服务。 | +| [`src/server/task_execution.py`](src/server/task_execution.py) | `TaskExecutionService` 执行接入点,以及 demo 的 UC1 服务。 | | [`src/common/`](src/common) | 共享适配器、按 schema 路由的 mock LLM、`WebhookReceiver`,以及双端共享的 `TASK_PARAM_SCHEMA`。 | | [`src/registry/`](src/registry) | mock 注册中心(AgentCard 注册/查询,端口 5001)。 | | [`resources/mock_llm/`](resources/mock_llm) | 脚本化的槽位抽取与内容校验应答。 | @@ -97,13 +97,13 @@ async for stream_response in a2a_client.send_message(request, context=call_conte `_extract_prompt_text` 从 metadata 读取提示词,`validate_task_prompt_and_data_filling` 校验并抽取 ([`server_flow.py:82`](src/server/server_flow.py#L82)、[`server_flow.py:486`](src/server/server_flow.py#L486))。 -被拒绝的提示词映射为 `TASK_STATE_REJECTED`,而不是让服务崩溃;校验通过的参数进入执行接缝。 +被拒绝的提示词映射为 `TASK_STATE_REJECTED`,而不是让服务崩溃;校验通过的参数进入执行接入点。 ### 3.4 你要替换什么 模板(`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`)与参数 schema 都是本样例特有的 —— 请换成你自己的([`scenario_data.py`](src/client/scenario_data.py));业务执行 -运行在服务端 `TaskExecutionService` 接缝之后(见[替换清单](#5-替换清单demo-到生产))。 +运行在服务端 `TaskExecutionService` 接入点之后(见[替换清单](#5-替换清单demo-到生产))。 ## 4. 使用 A2A-T SDK 实现 Negotiation-T 交互 @@ -118,23 +118,29 @@ async for stream_response in a2a_client.send_message(request, context=call_conte **信息不完整。** 客户端要求执行节能,给了目标、时段和吞吐目标,却没说在哪个区域。服务端缺这一项 无法开工,于是告诉客户端缺什么;客户端补上,任务继续。 -**要求超出服务端能力。** 客户端要求全区保障 100Mbps。目标本身是合法的,但超出了小区能做到的范围。 -服务端复述它理解的意图,请客户端确认一个可行的目标,再作承诺。 +**目标自相矛盾。** 客户端给出的分时段保障速率目标里,两个时段重叠: +`{17:30~23:00,20Mbps}` 与 `{20:00~00:00,10Mbps}`。请求本身格式没问题,但重叠时段的保障速率无法 +唯一确定。服务端复述它理解的意图,请客户端调整时间;客户端带着修正后的时段回应。 -**目标在当前条件下可能做不到。** 客户端要求节能 30% 同时保障 50Mbps。两个目标方向相反,能否同时 -成立取决于实时网络状态。服务端请客户端共同做可行性评估,并把结论摘要回来。 +**目标在当前条件下可能做不到。** 客户端要求两个站点在 00:00~18:00 的停电期间保障 +`{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}`,而站点既有的备电要求是至少支撑 18 小时。按这个 +速率执行,电池撑不到停电结束。服务端给出不可行的结论、判断依据和提案(把 06:00~18:00 的保障速率 +从 10Mbps 下调到 2Mbps);客户端接受调整后的目标。 -**样例是怎么接线的。** 判断属于哪种情况、以及到底要不要协商,是**你的业务决策,SDK 不持立场**。 -本样例是数据驱动的:首个内容校验错误的错误码通过 `_NEGOTIATION_TYPE_BY_ERROR_CODE` 表 +### 4.2 样例如何判断属于哪一种 + +判断属于哪种情况、以及到底要不要协商,是**你的业务决策,SDK 不持立场**。本样例是数据驱动的: +首个内容校验错误的错误码通过 `_NEGOTIATION_TYPE_BY_ERROR_CODE` 表 ([`server_flow.py:64`](src/server/server_flow.py#L64))选出类型。这张表是**样例约定,不是 SDK 契约** —— 可随意改自己的副本(重映射、新增、或干脆不用映射、按自己的业务规则判断;未映射的失败就是 普通拒绝)。 -报文并不依赖这张表:每个 propose 通过自身的 `templateUri` -(`Negotiation-T/{information,target,feasibility}-negotiation/propose/v1`)自我描述,客户端按收到 -的 URI 分派应答,而不是按错误码。 +报文并不依赖这张表。每个 propose 都记录自己的 `templateUri` —— 即 metadata 中这段报文由哪个 +Prompt 模板渲染而来,例如 `Negotiation-T/information-negotiation/propose/v1`。这个 URI 同时告诉 +客户端收到的是哪一类协商,因此客户端按收到的消息决定应答 —— accept、reject 或 abort —— 而不是按 +错误码。 -### 4.2 共享机制 +### 4.3 共享机制 以下对三类协商通用,各类型小节不再重复。 @@ -146,13 +152,14 @@ async for stream_response in a2a_client.send_message(request, context=call_conte 1. 客户端 → 服务端:按原样的节能请求 —— 样例中缺少区域。 2. 服务端 → 客户端:propose —— 缺什么、需要澄清什么、或需要评估什么。 -3. 客户端 → 服务端:应答(补齐的取值、确认、或可行性摘要),连同补全后的请求一并发出。 +3. 客户端 → 服务端:把缺失或需修正的内容补全后重新发送的那条请求;应答随同一条消息一起发出 + (是一条消息,不是两条)。 4. 服务端 → 客户端:结果 —— 先进度,后报告。 会话(`id` / `round` / `maxRounds` / performative)随每条协商消息放在 `negotiationContext` metadata -中,因此 SDK 本身无状态;每个 propose 还携带自己的 `templateUri`,客户端据此分派应答。 +中,因此 SDK 本身无状态。 -**决策接缝。** 收到第 2 条报文时,客户端 flow 构建一个轻量的 `NegotiationProposeView`(kind、 +**你要接的地方。** 收到第 2 条报文时,客户端 flow 构建一个轻量的 `NegotiationProposeView`(kind、 `templateUri`、渲染文本)并调用([`negotiation_response.py`](src/client/negotiation_response.py)): ```python @@ -172,7 +179,7 @@ flow 根据你的决策重新生成 Task-T 提示词、构建带同轮 `ACCEPT` > Provider 只替换对 propose 的脚本化**应答** —— Task-T 补全参数(`negotiation_params`)始终是 > flow 的调用方输入,缺了它们协商不会开启。 -### 4.3 信息协商(information) +### 4.4 信息协商(information) **① 服务端何时开启。** 请求不完整 —— 缺少服务端必需的参数(样例中为区域)。样例的触发条件是 `content.param_missing` 校验错误,但这个错误码是样例的接线方式,不属于协议本身。 @@ -191,7 +198,7 @@ propose 的 `items` 是缺失的参数;accept 的 `items` 是补全后的取 会话处理与传输 —— `client_flow.py` 中除决策本身以外的全部内容。 **④ 哪些必须你自己写。** propose 的业务措辞(要问哪些参数、如何命名 —— -`server_flow._missing_items_from_errors` 加上你的槽位 schema),以及 provider 接缝背后的应答来源: +`server_flow._missing_items_from_errors` 加上你的槽位 schema),以及 provider 接入点背后的应答来源: ```python class MyProvider: @@ -200,57 +207,57 @@ class MyProvider: return NegotiationDecision(conclusion="accept", filled_params=values) ``` -### 4.4 目标协商(target) +### 4.5 目标协商(target) -**① 服务端何时开启。** 目标本身合法但超出或偏离服务端能力口径(样例中为 100Mbps 吞吐目标,超出 -小区能力)。样例的触发条件是 `content.value_not_allowed` 校验错误。 +**① 服务端何时开启。** 请求自相矛盾:分时段保障速率目标里有两个时段重叠,重叠时段的保障速率无法 +唯一确定。样例的触发条件是 `content.value_not_allowed` 校验错误。 **② 参考代码做了什么。** | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | -| 服务端 propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, TargetProposeContent(description, target_confirm_request=...)), TARGET_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | +| 服务端 propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, TargetProposeContent(description, intent_understanding=[...], request_for_clarification=[...])), TARGET_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | | 客户端 accept | `generate_negotiation_accept_prompt_from_data(NegotiationEndingData(context, TargetEndingContent(ACCEPT, confirmed_intent, failure_reason=None)), TARGET_NEGOTIATION_ACCEPT_REJECT_URI)` | [`client_flow.py:607`](src/client/client_flow.py#L607) | | 应答来源 | `NegotiationDecision(conclusion="accept", confirmed_intent="...")` | [`negotiation_response.py`](src/client/negotiation_response.py) | -样例提出的是**确认请求**形态(`target_confirm_request` 非空,三个条件列表为空 —— SDK 强制的互斥 -规则),accept 确认澄清后的意图。 +样例用的是**澄清**形态:服务端复述它理解的意图(`intent_understanding`),并请求调整时间 +(`request_for_clarification`)。accept 把修正后的目标放进 `confirmed_intent`。 **③ 哪些可以原样复用。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 -**④ 哪些必须你自己写。** propose 中澄清后目标的措辞(`_negotiation_propose_data` 的 target 分支), -以及你这一侧如何得出 `confirmed_intent` —— 样例从 `scenario_data` 读取;你的来自确认目标的那一方。 +**④ 哪些必须你自己写。** propose 中澄清请求的措辞(`_negotiation_propose_data` 的 target 分支), +以及你这一侧如何得出 `confirmed_intent` —— 样例从 `scenario_data` 读取;你的来自把歧义解决掉的那 +一方。 -> `TargetProposeContent` 还支持多轮澄清(首轮的 `intent_understanding`、后续轮的 -> `alignment_and_clarification`、开放式问题的 `request_for_clarification`)。样例只用了确认请求 -> 形态;SDK 四种都会渲染。 +> `TargetProposeContent` 还支持确认请求形态(`target_confirm_request` 非空,三个条件列表为空 —— +> SDK 强制的互斥规则),以及后续轮的 `alignment_and_clarification`。样例用的是首轮澄清形态。 -### 4.5 可行性协商(feasibility) +### 4.6 可行性协商(feasibility) -**① 服务端何时开启。** 目标在当前网络状态下可能做不到(样例中为 30% 节能目标与 50Mbps 吞吐保障 -冲突)。样例的触发条件是 `content.semantic_conflict` 校验错误。 +**① 服务端何时开启。** 请求在当前条件下可能做不到:保障速率无法支撑所声明站点的停电时长。样例的 +触发条件是 `content.semantic_conflict` 校验错误。 **② 参考代码做了什么。** | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | -| 服务端 propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, FeasibilityProposeContent(description, action=REQUEST_FEASIBILITY_EVALUATION, contents_to_evaluate=[...])), FEASIBILITY_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | +| 服务端 propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, FeasibilityProposeContent(description, action=PROPOSE_ALTERNATIVE_ON_FAILURE, infeasibility_details_and_proposal=[...])), FEASIBILITY_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | | 客户端 accept | `generate_negotiation_accept_prompt_from_data(NegotiationEndingData(context, FeasibilityEndingContent(ACCEPT, feasibility_summary)), FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI)` | [`client_flow.py:607`](src/client/client_flow.py#L607) | | 应答来源 | `NegotiationDecision(conclusion="accept", feasibility_summary="...")` | [`negotiation_response.py`](src/client/negotiation_response.py) | -propose 请求评估(`action=REQUEST_FEASIBILITY_EVALUATION` 加 `contents_to_evaluate`);accept 返回 -评估摘要。 +样例把已完成的评估结论作为不可行上报,并附上判断依据和替代提案 +(`action=PROPOSE_ALTERNATIVE_ON_FAILURE` 加 `infeasibility_details_and_proposal`);accept 说明 +它同意的调整。 **③ 哪些可以原样复用。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 -**④ 哪些必须你自己写。** 评估请求的措辞,更重要的是评估本身:`feasibility_summary` 必须来自你的 -规划或仿真系统。 +**④ 哪些必须你自己写。** 评估本身 —— 不可行的判定依据和提案 —— 以及应答:两者都必须来自你的规划、 +仿真或备电容量系统。 -> `FeasibilityProposeContent` 还支持 `PROPOSE_ALTERNATIVE_ON_FAILURE` -> (`infeasibility_details_and_proposal`)与 `feasibility_confirm_request` 形态。样例只用了评估 -> 请求形态。 +> `FeasibilityProposeContent` 还支持 `REQUEST_FEASIBILITY_EVALUATION`(请对方评估 +> `contents_to_evaluate`)与 `feasibility_confirm_request` 形态。样例用的是「不可行 + 提案」形态。 -### 4.6 reject 与 abort —— 扩展点 +### 4.7 reject 与 abort —— 扩展点 `reject`/`abort` 决策**在本 demo 中未接线**:flow 打日志 `negotiation-decision: {conclusion} (not wired in this demo...)` 后结束、不发送任何报文。扩展点是 @@ -262,24 +269,26 @@ SDK 的 `generate_negotiation_reject_prompt_from_data` / `..._from_text` 生成 `NegotiationAbortContent(termination_reason)` 经 `generate_negotiation_abort_prompt_from_data` 渲染到 公共的 `Negotiation-T/common/abort/v1` 模板。 -### 4.7 fromData 还是 fromText +### 4.8 从结构化数据生成,还是从自然语言生成 + +协商消息有两种产生方式,最终报文格式完全一致: -默认的 `fromData` 通道从类型化内容确定性渲染 —— 不调用 LLM。`fromText`(`--channel fromText`)先把 -每条消息渲染为自然语言文本,再由 SDK 抽取回结构化内容 —— 每条生成的消息多一次抽取 LLM 调用; -报文格式与 `fromData` 完全一致。 +- **从结构化数据生成(默认)**:在代码里构造类型化内容,SDK 直接渲染模板 —— 确定性,不调用模型。 +- **从自然语言生成**(`--channel fromText`):先写一段自然语言,SDK 让模型抽取成结构化内容,再渲染 + 模板 —— 每条生成的消息多一次抽取调用。 ## 5. 替换清单(demo 到生产) | demo 组成 | demo 中的作用 | 保留还是替换 | | --- | --- | --- | | mock LLM —— [`resources/mock_llm/`](resources/mock_llm) + `src/common/` 中按 schema 路由的 mock | 脚本化的槽位抽取与内容校验应答,`A2AT_LLM_API_KEY` 为空时启用 | CI/离线运行**保留**;生产中移除并配置真实的 OpenAI 兼容端点 | -| 脚本化执行数据 —— [`src/server/constants_data.py`](src/server/constants_data.py) | demo 执行流式推送的 UC1 进度步骤与意图报告 | 经 `TaskExecutionService` 接缝**替换**([`src/server/task_execution.py`](src/server/task_execution.py));你的服务装配后,demo 服务与数据即可删除 | +| 脚本化执行数据 —— [`src/server/constants_data.py`](src/server/constants_data.py) | demo 执行流式推送的 UC1 进度步骤与意图报告 | 经 `TaskExecutionService` 接入点**替换**([`src/server/task_execution.py`](src/server/task_execution.py));你的服务装配后,demo 服务与数据即可删除 | | 脚本化应答 —— [`src/client/scenario_data.py`](src/client/scenario_data.py) | 同时喂两侧(见下) | 两侧都要**替换**:客户端应答经你的 `NegotiationResponseProvider`;你副本中 `server_flow._negotiation_propose_data` 的 propose 文案改为你的业务措辞 | | mock 注册中心 —— [`src/registry/`](src/registry)(端口 5001) | AgentCard 注册/查询,用于发现 | **替换**为你的注册中心,或绕过发现、直接从已知 AgentCard 构造 `a2a-sdk` 客户端 | | 静态 webhook token —— `A2AT_SAMPLE_WEBHOOK_TOKEN`(默认 `demo-webhook-token`) | `X-A2A-Notification-Token` 头中的共享静态 token | **替换**为生产鉴权(逐对密钥、轮换、TLS);把接收器当作暴露在外的 HTTP 端点对待 | > **`scenario_data` 同时喂两侧** —— 客户端的脚本化应答(补全参数、确认意图、可行性摘要)经 -> provider 接缝使用;服务端的 propose 内容也由同一模块构建:`_negotiation_propose_data` +> provider 接入点使用;服务端的 propose 内容也由同一模块构建:`_negotiation_propose_data` > ([`server_flow.py:207`](src/server/server_flow.py#L207)) 用其访问器填充 target/feasibility 的 > propose 文案。只替换 provider 与执行服务,服务端仍会用 demo 的预制文案发起 propose —— > 该模块不可删除。 @@ -329,7 +338,7 @@ webhook 模式下的变化:客户端非阻塞发送(`return_immediately`) | `A2AT_LANGUAGE` | `zh-CN` / `en-US`;决定自然语言输入与 mock/模板语言树 | | `A2AT_SAMPLE_SCENARIO` | `default` / `negotiation`;样例场景变体(与 `--scenario` 语义相同) | | `A2AT_SAMPLE_NEGOTIATION_KIND` | `information` / `target` / `feasibility`(默认 `information`);协商场景输入,与 `--negotiation` 语义相同 | -| `A2AT_SAMPLE_CHANNEL` | `data` / `text`(默认 `data`);协商 propose/accept 消息的生成通道,与 `--channel` 语义相同 | +| `A2AT_SAMPLE_CHANNEL` | `data` / `text`(默认 `data`);协商消息是从结构化数据生成还是从自然语言生成,与 `--channel` 语义相同 | | `A2AT_SAMPLE_TRANSPORT` | `sse` / `webhook`(默认 `sse`);客户端事件传输方式,与 `--transport` 语义相同 | | `A2AT_SAMPLE_WEBHOOK_PORT` | 内嵌 webhook 接收器的绑定端口(默认 `9000`;`0` = 操作系统分配临时端口) | | `A2AT_SAMPLE_WEBHOOK_TOKEN` | 服务端在 `X-A2A-Notification-Token` 头中必须携带的推送 token(默认 `demo-webhook-token`);不匹配回 `401` | @@ -388,8 +397,8 @@ uv run --no-sync python -m client.client_main | --- | --- | | `--language {en-US,zh-CN}` | 运行语言(默认 `en-US`) | | `--scenario {default,negotiation}` | 场景变体(默认 `default`;`negotiation` 运行缺失 region 的信息协商) | -| `--negotiation {information,target,feasibility}` | 协商类型,选择客户端场景输入(默认 `information` = 缺失 region;`target` = 100Mbps 速率超出小区能力;`feasibility` = 30% 节能目标与 50Mbps 速率目标冲突)。`information` 仍受 `--scenario` 开关控制(只有 `--scenario negotiation` 运行缺失 region 流程);`target`/`feasibility` 无需该开关即可触发。该参数只选择输入 —— 服务端依据内容校验错误码数据驱动地分派 | -| `--channel {fromData,fromText}` | 协商 propose/accept 消息的生成通道(默认 `fromData`;`fromText` 每条生成的消息多一次抽取 LLM 调用) | +| `--negotiation {information,target,feasibility}` | 协商类型,选择客户端场景输入(默认 `information` = 缺失 region;`target` = 分时段保障速率目标中两个时段重叠;`feasibility` = 保障速率无法支撑 18 小时停电)。`information` 仍受 `--scenario` 开关控制(只有 `--scenario negotiation` 运行缺失 region 流程);`target`/`feasibility` 无需该开关即可触发。该参数只选择输入 —— 服务端依据内容校验错误码数据驱动地分派 | +| `--channel {fromData,fromText}` | 协商消息从结构化数据生成(`fromData`,默认)还是从自然语言生成(`fromText`,每条消息多一次抽取模型调用) | | `--transport {sse,webhook}` | 客户端的事件传输方式(默认 `sse`);见 [§6.1](#61-webhook-传输) | | `--webhook-port N` | `--transport webhook` 时内嵌 webhook 接收器的绑定端口(默认 9000;`0` 表示由操作系统分配临时端口) | | `--keep-alive` | 保留注册中心/服务端不停止;之后用 `--stop` 关闭 | diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 9cc6d273..e6d0f9e1 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -28,12 +28,12 @@ Read it one of three ways: | Path | Core responsibility | | --- | --- | | [`src/client/client_main.py`](src/client/client_main.py) | Assembly root: reads `.env`, builds the runtime (`build_client_runtime`), discovers the server's AgentCard from the registry, starts/stops the webhook receiver, and assembles the demo `ScriptedNegotiationResponseProvider`. The client-side `INTEGRATION POINT` comment sits in `main()`. | -| [`src/client/client_flow.py`](src/client/client_flow.py) | Orchestration: the message sequence (generate → send → consume), the wire mechanics (metadata keys, `A2A-Extensions` header), the event pipeline, and the two transport drivers (SSE streaming / webhook queue). It calls the negotiation decision seam but owns no decision. | -| [`src/client/negotiation_response.py`](src/client/negotiation_response.py) | The negotiation decision seam: `NegotiationProposeView`, `NegotiationDecision`, the `NegotiationResponseProvider` Protocol, and the demo `ScriptedNegotiationResponseProvider`. | +| [`src/client/client_flow.py`](src/client/client_flow.py) | Orchestration: the message sequence (generate → send → consume), the wire mechanics (metadata keys, `A2A-Extensions` header), the event pipeline, and the two transport drivers (SSE streaming / webhook queue). It calls the negotiation decision integration point but owns no decision. | +| [`src/client/negotiation_response.py`](src/client/negotiation_response.py) | The negotiation decision integration point: `NegotiationProposeView`, `NegotiationDecision`, the `NegotiationResponseProvider` Protocol, and the demo `ScriptedNegotiationResponseProvider`. | | [`src/client/scenario_data.py`](src/client/scenario_data.py) | The sample's inputs and scripted answers — it feeds **both** sides (see the [replace checklist](#5-replace-checklist-demo--production)); not removable as-is. | | [`src/server/server_main.py`](src/server/server_main.py) | a2a-sdk app assembly, with the push-notification machinery assembled unconditionally. The server-side `INTEGRATION POINT` comment sits in `build_server_runtime`. | | [`src/server/server_flow.py`](src/server/server_flow.py) | The server flow: validate → negotiate-or-execute → stream progress and the report. | -| [`src/server/task_execution.py`](src/server/task_execution.py) | The `TaskExecutionService` execution seam plus the demo UC1 service. | +| [`src/server/task_execution.py`](src/server/task_execution.py) | The `TaskExecutionService` execution integration point plus the demo UC1 service. | | [`src/common/`](src/common) | Shared adapters, the schema-routed mock LLM, the `WebhookReceiver`, and the shared `TASK_PARAM_SCHEMA`. | | [`src/registry/`](src/registry) | Mock registry center (AgentCard register/query, port 5001). | | [`resources/mock_llm/`](resources/mock_llm) | Scripted slot-extraction and content-validation replies. | @@ -101,14 +101,14 @@ async for stream_response in a2a_client.send_message(request, context=call_conte `_extract_prompt_text` reads the metadata and `validate_task_prompt_and_data_filling` validates and extracts ([`server_flow.py:82`](src/server/server_flow.py#L82), [`server_flow.py:486`](src/server/server_flow.py#L486)). A rejected prompt is mapped to `TASK_STATE_REJECTED` instead of crashing the server; the validated -parameters go into the execution seam. +parameters go into the execution integration point. ### 3.4 What you replace The template (`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`) and the parameter schema are sample-specific — bring your own ([`scenario_data.py`](src/client/scenario_data.py)); the business execution runs behind the -server-side `TaskExecutionService` seam (see the +server-side `TaskExecutionService` integration point (see the [replace checklist](#5-replace-checklist-demo--production)). ## 4. Using the A2A-T SDK to implement Negotiation-T interaction @@ -127,28 +127,35 @@ three such situations: a time window and a throughput target, but never says which region. The server cannot start without it, so it says which piece is missing; the client supplies it and the task proceeds. -**Target — the request asks for more than the server can deliver.** The client asks for a -guaranteed 100 Mbps across the region. The target is well-formed, but it exceeds what the cells can -guarantee. The server restates the intent it understood and asks the client to confirm a workable -target before it commits. +**Target — the request is internally inconsistent.** The client sends a segmented guaranteed +throughput target in which two time windows overlap: `{17:30~23:00,20Mbps}` and +`{20:00~00:00,10Mbps}`. The request is well-formed, but the guaranteed rate for the overlapping +period cannot be determined. The server restates the intent it understood and asks which time +window should be adjusted; the client answers with the corrected segments. -**Feasibility — the request may be impossible under current conditions.** The client asks for a -30 % energy saving while guaranteeing 50 Mbps. The two goals pull in opposite directions, and -whether both can hold depends on the live network state. The server asks the client to evaluate -feasibility jointly and answer with a summary. +**Feasibility — the request may be impossible under current conditions.** The client asks two +stations to hold `{00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}` through an outage that lasts from +00:00 to 18:00, while an existing constraint requires at least 18 hours of power autonomy. Holding +that rate would drain the batteries faster than the outage lasts. The server reports the +infeasibility together with the evidence and a proposal (lower the 06:00~18:00 rate from 10 Mbps to +2 Mbps); the client accepts the adjusted target. -**How the demo wires it.** Which situation applies — and whether to negotiate at all — is **your -business decision; the SDK has no opinion**. In this demo it is data-driven: the first -content-validation error's code selects the kind, via the `_NEGOTIATION_TYPE_BY_ERROR_CODE` table +### 4.2 How the demo decides which one applies + +Which situation applies — and whether to negotiate at all — is **your business decision; the SDK +has no opinion**. In this demo it is data-driven: the first content-validation error's code selects +the kind, via the `_NEGOTIATION_TYPE_BY_ERROR_CODE` table ([`server_flow.py:64`](src/server/server_flow.py#L64)). That table is a **sample convention, not an SDK contract** — edit your copy freely (remap codes, add your own, or drop the mapping and decide from your own business rules; an unmapped failure is simply rejected). -The wire does not depend on that table: every propose self-describes through its own `templateUri` -(`Negotiation-T/{information,target,feasibility}-negotiation/propose/v1`), so the client picks its -answer — accept, reject or abort — from the URI it received, not from any error code. +The wire does not depend on that table. Each propose records its `templateUri` — the prompt +template its message text was rendered from, such as +`Negotiation-T/information-negotiation/propose/v1`. That URI also tells the client which kind of +propose it received, so the client picks its answer — accept, reject or abort — from the message it +got, not from any error code. -### 4.2 Shared mechanics +### 4.3 Shared mechanics These apply to all three kinds; the per-kind sections below do not repeat them. @@ -161,16 +168,15 @@ request is not a negotiation round — the negotiation starts with the server's 1. client → server: the energy-saving request as it stands — in the demo, missing the region. 2. server → client: the propose — what is missing, what needs clarifying, or what needs evaluating. -3. client → server: the answer (the values, the confirmation, or the feasibility summary) together - with the completed request. +3. client → server: the request re-sent with the missing or corrected content filled in; the + answer travels in that same message (it is one message, not two). 4. server → client: the result — progress, then the report. The session (`id` / `round` / `maxRounds` / performative) travels with every negotiation message in -the `negotiationContext` metadata map, so the SDK itself stays stateless; each propose also carries -its `templateUri`, which is what the client dispatches its answer on. +the `negotiationContext` metadata map, so the SDK itself stays stateless. -**The decision seam.** When message 2 arrives, the client flow builds a lightweight -`NegotiationProposeView` (kind, `templateUri`, rendered text) and calls +**Where your decision plugs in.** When message 2 arrives, the client flow builds a lightweight +`NegotiationProposeView` (kind, `templateUri`, rendered text) and calls the provider ([`negotiation_response.py`](src/client/negotiation_response.py)): ```python @@ -192,7 +198,7 @@ that internally. > The provider replaces only the scripted **answer** to a propose — the Task-T refill parameters > (`negotiation_params`) stay caller input to the flow, which will not engage a negotiation without them. -### 4.3 Information negotiation +### 4.4 Information negotiation **① When the server opens it.** The request is incomplete — it lacks a parameter the server needs (in the demo, the region). The demo's trigger is a `content.param_missing` validation error, but @@ -214,7 +220,7 @@ the decision itself. **④ What you must write yourself.** The propose's business phrasing (which parameters to ask for and how to name them — `server_flow._missing_items_from_errors` plus your slot schema) and the -answer source behind the provider seam: +answer source behind the provider integration point: ```python class MyProvider: @@ -223,63 +229,64 @@ class MyProvider: return NegotiationDecision(conclusion="accept", filled_params=values) ``` -### 4.4 Target negotiation +### 4.5 Target negotiation -**① When the server opens it.** The target is well-formed but beyond or ambiguous for the server's -capability (in the demo, a 100 Mbps throughput target beyond what the cells can guarantee). The -demo's trigger is a `content.value_not_allowed` validation error. +**① When the server opens it.** The request contradicts itself: two time windows of the segmented +guaranteed throughput target overlap, so the guaranteed rate for the overlapping period is +ambiguous. The demo's trigger is a `content.value_not_allowed` validation error. **② What the reference code does.** | Step | SDK call | Reference code | | --- | --- | --- | -| Server propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, TargetProposeContent(description, target_confirm_request=...)), TARGET_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | +| Server propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, TargetProposeContent(description, intent_understanding=[...], request_for_clarification=[...])), TARGET_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | | Client accept | `generate_negotiation_accept_prompt_from_data(NegotiationEndingData(context, TargetEndingContent(ACCEPT, confirmed_intent, failure_reason=None)), TARGET_NEGOTIATION_ACCEPT_REJECT_URI)` | [`client_flow.py:607`](src/client/client_flow.py#L607) | | Answer source | `NegotiationDecision(conclusion="accept", confirmed_intent="...")` | [`negotiation_response.py`](src/client/negotiation_response.py) | -The demo proposes the *confirm-request* form (`target_confirm_request` non-blank, the three -conditional lists empty — the mutual-exclusion rule the SDK enforces) and the accept confirms the -clarified intent. +The demo proposes the *clarification* form: the server restates the intent it understood +(`intent_understanding`) and asks for the time adjustment (`request_for_clarification`). The accept +carries the corrected target as `confirmed_intent`. **③ What you can copy as-is.** The same as information: rendering, metadata assembly, session handling, transport. -**④ What you must write yourself.** The clarified-target wording for the propose +**④ What you must write yourself.** The clarification wording for the propose (`_negotiation_propose_data`'s target branch) and how your side derives `confirmed_intent` — the -demo reads it from `scenario_data`; yours comes from whoever confirms the target. +demo reads it from `scenario_data`; yours comes from whoever resolves the ambiguity. -> `TargetProposeContent` also supports multi-round clarification (`intent_understanding` on the -> first round, `alignment_and_clarification` on later rounds, `request_for_clarification` for open -> questions). The demo uses the confirm-request form only; the SDK renders all four. +> `TargetProposeContent` also supports the confirm-request form (`target_confirm_request`, with the +> three conditional lists empty — the mutual-exclusion rule the SDK enforces) and +> `alignment_and_clarification` for later rounds. The demo uses the first-round clarification form. -### 4.5 Feasibility negotiation +### 4.6 Feasibility negotiation -**① When the server opens it.** The goal may not be achievable in the current network state (in the -demo, a 30 % saving goal conflicting with a 50 Mbps throughput guarantee). The demo's trigger is a -`content.semantic_conflict` validation error. +**① When the server opens it.** The request may be impossible under current conditions: the +guaranteed rate cannot sustain the declared outage duration for the listed stations. The demo's +trigger is a `content.semantic_conflict` validation error. **② What the reference code does.** | Step | SDK call | Reference code | | --- | --- | --- | -| Server propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, FeasibilityProposeContent(description, action=REQUEST_FEASIBILITY_EVALUATION, contents_to_evaluate=[...])), FEASIBILITY_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | +| Server propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, FeasibilityProposeContent(description, action=PROPOSE_ALTERNATIVE_ON_FAILURE, infeasibility_details_and_proposal=[...])), FEASIBILITY_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | | Client accept | `generate_negotiation_accept_prompt_from_data(NegotiationEndingData(context, FeasibilityEndingContent(ACCEPT, feasibility_summary)), FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI)` | [`client_flow.py:607`](src/client/client_flow.py#L607) | | Answer source | `NegotiationDecision(conclusion="accept", feasibility_summary="...")` | [`negotiation_response.py`](src/client/negotiation_response.py) | -The propose requests an evaluation (`action=REQUEST_FEASIBILITY_EVALUATION` with -`contents_to_evaluate`); the accept returns the evaluation summary. +The demo reports the completed assessment as infeasible and attaches the evidence plus an +alternative proposal (`action=PROPOSE_ALTERNATIVE_ON_FAILURE` with +`infeasibility_details_and_proposal`); the accept states the adjustment it agrees to. **③ What you can copy as-is.** The same as information: rendering, metadata assembly, session handling, transport. -**④ What you must write yourself.** The evaluation request wording and, more importantly, the -actual evaluation: `feasibility_summary` must come from your planning or simulation system. +**④ What you must write yourself.** The assessment itself — the infeasibility findings and the +proposal — and the answer: both must come from your planning, simulation or power-capacity system. -> `FeasibilityProposeContent` also supports `PROPOSE_ALTERNATIVE_ON_FAILURE` -> (`infeasibility_details_and_proposal`) and the `feasibility_confirm_request` form. The demo uses -> the evaluation-request form only. +> `FeasibilityProposeContent` also supports `REQUEST_FEASIBILITY_EVALUATION` (asking the client to +> evaluate `contents_to_evaluate`) and the `feasibility_confirm_request` form. The demo uses the +> infeasibility-and-proposal form. -### 4.6 Reject and abort — the extension point +### 4.7 Reject and abort — the extension point A `reject`/`abort` decision is **not wired in this demo**: the flow logs `negotiation-decision: {conclusion} (not wired in this demo...)` and ends without sending. The @@ -293,25 +300,28 @@ ending content's `failure_reason`), and generate the message with the SDK's `NegotiationAbortContent(termination_reason)` renders against the common `Negotiation-T/common/abort/v1` template via `generate_negotiation_abort_prompt_from_data`. -### 4.7 fromData or fromText +### 4.8 Generating messages from structured data or from natural language + +There are two ways to produce a negotiation message, and both end up on the wire in the same form: -The default `fromData` channel renders the typed content deterministically — no LLM call. -`fromText` (`--channel fromText`) renders each message as natural-language text first and the SDK -extracts the structured content back — one extraction LLM call per generated message; the wire -format is identical. +- **from structured data (default).** You build the typed content in code and the SDK renders the + template directly — deterministic, no model call. +- **from natural language** (`--channel fromText`). You write the message as plain text and the SDK + asks the model to extract the structured content first, then renders the template — one extra + extraction call per generated message. ## 5. Replace checklist (demo → production) | Demo piece | What it does in the demo | Keep or replace | | --- | --- | --- | | mock LLM — [`resources/mock_llm/`](resources/mock_llm) + the schema-routed mock in `src/common/` | scripted slot-extraction and content-validation replies, selected when `A2AT_LLM_API_KEY` is empty | **KEEP** for CI/offline runs; in production remove it and configure a real OpenAI-compatible endpoint | -| scripted execution data — [`src/server/constants_data.py`](src/server/constants_data.py) | the UC1 progress steps and intent report the demo execution streams | **REPLACE** via the `TaskExecutionService` seam ([`src/server/task_execution.py`](src/server/task_execution.py)); once your service is assembled, the demo service and data can be deleted | +| scripted execution data — [`src/server/constants_data.py`](src/server/constants_data.py) | the UC1 progress steps and intent report the demo execution streams | **REPLACE** via the `TaskExecutionService` integration point ([`src/server/task_execution.py`](src/server/task_execution.py)); once your service is assembled, the demo service and data can be deleted | | scripted answers — [`src/client/scenario_data.py`](src/client/scenario_data.py) | feeds BOTH sides (see below) | **REPLACE** on both sides: the client answers via your `NegotiationResponseProvider`; the server's propose texts in your copy of `server_flow._negotiation_propose_data` become your business phrasing | | mock registry — [`src/registry/`](src/registry) (port 5001) | AgentCard register/query for discovery | **REPLACE** with your registry center, or bypass discovery and construct the `a2a-sdk` client from a known AgentCard | | static webhook token — `A2AT_SAMPLE_WEBHOOK_TOKEN` (default `demo-webhook-token`) | shared static token in the `X-A2A-Notification-Token` header | **REPLACE** with production auth (per-pair secrets, rotation, TLS); treat the receiver as an exposed HTTP endpoint | > **`scenario_data` feeds BOTH sides** — the client's scripted answers (filled parameters, -> confirmed intent, feasibility summary) go through the provider seam, and the server's propose +> confirmed intent, feasibility summary) go through the provider integration point, and the server's propose > content is built from the same module: `_negotiation_propose_data` > ([`server_flow.py:207`](src/server/server_flow.py#L207)) fills the target/feasibility propose > texts from its accessors. Replacing only the provider and the execution service still leaves @@ -371,7 +381,7 @@ transports. | `A2AT_LANGUAGE` | `zh-CN` / `en-US`; selects the natural-language input and the mock/template tree | | `A2AT_SAMPLE_SCENARIO` | `default` / `negotiation`; selects the sample scenario variant (same semantics as `--scenario`) | | `A2AT_SAMPLE_NEGOTIATION_KIND` | `information` / `target` / `feasibility` (default `information`); negotiation scenario input, same semantics as `--negotiation` | -| `A2AT_SAMPLE_CHANNEL` | `data` / `text` (default `data`); generation channel of the negotiation propose/accept messages, same semantics as `--channel` | +| `A2AT_SAMPLE_CHANNEL` | `data` / `text` (default `data`); whether negotiation messages are generated from structured data or from natural language, same semantics as `--channel` | | `A2AT_SAMPLE_TRANSPORT` | `sse` / `webhook` (default `sse`); client event transport, same semantics as `--transport` | | `A2AT_SAMPLE_WEBHOOK_PORT` | Bind port of the embedded webhook receiver (default `9000`; `0` = OS-assigned ephemeral port) | | `A2AT_SAMPLE_WEBHOOK_TOKEN` | Push token the server must carry in the `X-A2A-Notification-Token` header (default `demo-webhook-token`); a mismatch answers `401` | @@ -433,8 +443,8 @@ content-validation responses of [`resources/mock_llm/{zh-CN,en-US}/`](resources/ | --- | --- | | `--language {en-US,zh-CN}` | Run language (default `en-US`) | | `--scenario {default,negotiation}` | Scenario variant (default `default`; `negotiation` runs the missing-region information negotiation) | -| `--negotiation {information,target,feasibility}` | Negotiation kind selecting the client scenario input (default `information` = missing region; `target` = 100Mbps throughput beyond cell capability; `feasibility` = 30% saving goal conflicting with a 50Mbps throughput target). `information` keeps the `--scenario` switch (only `--scenario negotiation` runs the missing-region flow); `target`/`feasibility` engage without it. The flag only selects the input — the server stays data-driven on the validation error code | -| `--channel {fromData,fromText}` | Generation channel of the negotiation propose/accept messages (default `fromData`; `fromText` adds one extraction LLM call per generated message) | +| `--negotiation {information,target,feasibility}` | Negotiation kind selecting the client scenario input (default `information` = missing region; `target` = two overlapping time segments in the guaranteed throughput target; `feasibility` = the guaranteed rate cannot sustain an 18-hour power outage). `information` keeps the `--scenario` switch (only `--scenario negotiation` runs the missing-region flow); `target`/`feasibility` engage without it. The flag only selects the input — the server stays data-driven on the validation error code | +| `--channel {fromData,fromText}` | Whether negotiation messages are generated from structured data (`fromData`, default) or from natural language (`fromText`, one extra extraction model call per message) | | `--transport {sse,webhook}` | Event transport of the client (default `sse`); see [§6.1](#61-webhook-transport) | | `--webhook-port N` | Bind port of the embedded webhook receiver used with `--transport webhook` (default 9000; `0` selects an OS-assigned ephemeral port) | | `--keep-alive` | Leave registry/server running; stop later with `--stop` | From 4f2fe3807608afa54ddd31b14bdccaa2c587ef80 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 19 Oct 2026 22:48:08 +0800 Subject: [PATCH 05/30] docs(sample): update the run_demo help text for the aligned cases The --negotiation help and the usage examples still described the retired 100Mbps / 30%-vs-50Mbps inputs. Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/run_demo.py | 6 +++--- a2a-t-sample/ran-energy-saving/src/common/mock_llm.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/run_demo.py b/a2a-t-sample/ran-energy-saving/run_demo.py index cd0ddca8..4afcaa02 100644 --- a/a2a-t-sample/ran-energy-saving/run_demo.py +++ b/a2a-t-sample/ran-energy-saving/run_demo.py @@ -27,7 +27,7 @@ python run_demo.py # English, streams the energy-saving steps, then exits python run_demo.py --language zh-CN python run_demo.py --scenario negotiation # missing-region input; information negotiation - python run_demo.py --negotiation target # 100Mbps input; target negotiation + python run_demo.py --negotiation target # overlapping time segments; target negotiation python run_demo.py --negotiation feasibility --channel fromText python run_demo.py --transport webhook # task events via the embedded webhook receiver python run_demo.py --keep-alive # leave registry/server running; stop with --stop @@ -281,8 +281,8 @@ def main() -> int: choices=["information", "target", "feasibility"], help=( "negotiation kind selecting the client scenario input: information (missing region), " - "target (100Mbps throughput beyond cell capability), feasibility (30-percent saving " - "goal conflicting with 50Mbps throughput). information engages only together with " + "target (overlapping time segments in the guaranteed throughput target), feasibility " + "(the guaranteed rate cannot sustain the outage duration). information engages only together with " "--scenario negotiation; target/feasibility engage without it. The KIND flag only " "selects the scenario input — the server stays data-driven on the content-validation " "error code " diff --git a/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py b/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py index 6bde077d..71ddd3c6 100644 --- a/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py +++ b/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py @@ -17,7 +17,7 @@ ``slot_extraction_target`` / ``slot_extraction_feasibility`` variants for the target/feasibility negotiation inputs (complete slot sets that preserve the distinguishing - 100Mbps / 30%+50Mbps values in the rendered prompt), told + overlapping-segment / outage values in the rendered prompt), told apart by the scenario natural-language constants of ``client.scenario_data`` ``semantic_verdict`` property content semantic validation — 4-way by the scenario From 2c50e5e4873a3525c5e0c00ecbb0a07f2256274e Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 19 Oct 2026 22:48:33 +0800 Subject: [PATCH 06/30] docs(sample): make the four-message loop cover all three cases Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 2 +- a2a-t-sample/ran-energy-saving/README.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 36b6010a..0c4055f1 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -150,7 +150,7 @@ Prompt 模板渲染而来,例如 `Negotiation-T/information-negotiation/propos **四条报文的闭环:** -1. 客户端 → 服务端:按原样的节能请求 —— 样例中缺少区域。 +1. 客户端 → 服务端:按原样的节能请求 —— 样例中分别是缺区域、时段重叠、停电时长无法支撑。 2. 服务端 → 客户端:propose —— 缺什么、需要澄清什么、或需要评估什么。 3. 客户端 → 服务端:把缺失或需修正的内容补全后重新发送的那条请求;应答随同一条消息一起发出 (是一条消息,不是两条)。 diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index e6d0f9e1..a56382a0 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -166,7 +166,8 @@ request is not a negotiation round — the negotiation starts with the server's **The closed loop, in four messages:** -1. client → server: the energy-saving request as it stands — in the demo, missing the region. +1. client → server: the energy-saving request as it stands — in the demo: a missing region, an + overlapping target, or an outage the guaranteed rate cannot sustain. 2. server → client: the propose — what is missing, what needs clarifying, or what needs evaluating. 3. client → server: the request re-sent with the missing or corrected content filled in; the answer travels in that same message (it is one message, not two). From f8e0c1ddf1a96e97f780bfdaaa5afe5b888a1aa7 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 19 Oct 2026 23:17:58 +0800 Subject: [PATCH 07/30] docs(sample): drop the evidence claim and add a table of contents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feasibility narrative said the server reports the infeasibility "together with the evidence", but the demo cannot produce an assessment basis — the mock only returns canned content. Remove the basis item from the scenario data and the mock resources, and reword the narrative to "reports that the target is infeasible and proposes an alternative". Also add a table of contents to both READMEs, covering sections 1–6. Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 35 +++++++++++++++-- a2a-t-sample/ran-energy-saving/README.md | 39 ++++++++++++++++--- ...iation_feasibility_propose_extraction.json | 4 -- ...iation_feasibility_propose_extraction.json | 4 -- .../src/client/scenario_data.py | 16 -------- .../test/test_negotiation_scenario.py | 2 +- 6 files changed, 65 insertions(+), 35 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 0c4055f1..315b6900 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -3,6 +3,33 @@ 一个 A2A-T 样例:包含一个客户端 Agent、一个 RAN 域服务端 Agent,以及用于发现 AgentCard 的 mock 注册中心。 +## 目录 + +- [1. 本样例演示了什么](#1-本样例演示了什么) + - [1.1 目录结构](#11-目录结构) +- [2. 快速开始](#2-快速开始) +- [3. 使用 A2A-T SDK 实现 Task-T 交互](#3-使用-a2a-t-sdk-实现-task-t-交互) + - [3.1 四个步骤](#31-四个步骤) + - [3.2 客户端侧](#32-客户端侧) + - [3.3 服务端侧](#33-服务端侧) + - [3.4 你要替换什么](#34-你要替换什么) +- [4. 使用 A2A-T SDK 实现 Negotiation-T 交互](#4-使用-a2a-t-sdk-实现-negotiation-t-交互) + - [4.1 什么时候会发生协商](#41-什么时候会发生协商) + - [4.2 样例如何判断属于哪一种](#42-样例如何判断属于哪一种) + - [4.3 共享机制](#43-共享机制) + - [4.4 信息协商(information)](#44-信息协商information) + - [4.5 目标协商(target)](#45-目标协商target) + - [4.6 可行性协商(feasibility)](#46-可行性协商feasibility) + - [4.7 reject 与 abort —— 扩展点](#47-reject-与-abort--扩展点) + - [4.8 从结构化数据生成,还是从自然语言生成](#48-从结构化数据生成还是从自然语言生成) +- [5. 替换清单(demo 到生产)](#5-替换清单demo-到生产) +- [6. 附录](#6-附录) + - [6.1 Webhook 传输](#61-webhook-传输) + - [6.2 配置(a2a-t-sample/.env)](#62-配置a2a-t-sampleenv) + - [6.3 分别启动两端](#63-分别启动两端) + - [6.4 mock LLM 机制](#64-mock-llm-机制) + - [6.5 支持的参数(`run_demo.py`)](#65-支持的参数run_demopy) + ## 1. 本样例演示了什么 一个客户端 Agent 通过 A2A 委托一个 RAN 域 Agent 执行节能任务。在这一个场景上,样例按顺序覆盖客户 @@ -124,8 +151,8 @@ async for stream_response in a2a_client.send_message(request, context=call_conte **目标在当前条件下可能做不到。** 客户端要求两个站点在 00:00~18:00 的停电期间保障 `{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}`,而站点既有的备电要求是至少支撑 18 小时。按这个 -速率执行,电池撑不到停电结束。服务端给出不可行的结论、判断依据和提案(把 06:00~18:00 的保障速率 -从 10Mbps 下调到 2Mbps);客户端接受调整后的目标。 +速率执行,电池撑不到停电结束。服务端给出不可行的结论和提案(把 06:00~18:00 的保障速率从 10Mbps 下调 +到 2Mbps);客户端接受调整后的目标。 ### 4.2 样例如何判断属于哪一种 @@ -245,13 +272,13 @@ class MyProvider: | 客户端 accept | `generate_negotiation_accept_prompt_from_data(NegotiationEndingData(context, FeasibilityEndingContent(ACCEPT, feasibility_summary)), FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI)` | [`client_flow.py:607`](src/client/client_flow.py#L607) | | 应答来源 | `NegotiationDecision(conclusion="accept", feasibility_summary="...")` | [`negotiation_response.py`](src/client/negotiation_response.py) | -样例把已完成的评估结论作为不可行上报,并附上判断依据和替代提案 +样例把已完成的评估结论作为不可行上报,并附上替代提案 (`action=PROPOSE_ALTERNATIVE_ON_FAILURE` 加 `infeasibility_details_and_proposal`);accept 说明 它同意的调整。 **③ 哪些可以原样复用。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 -**④ 哪些必须你自己写。** 评估本身 —— 不可行的判定依据和提案 —— 以及应答:两者都必须来自你的规划、 +**④ 哪些必须你自己写。** 评估本身 —— 不可行的结论和提案 —— 以及应答:两者都必须来自你的规划、 仿真或备电容量系统。 > `FeasibilityProposeContent` 还支持 `REQUEST_FEASIBILITY_EVALUATION`(请对方评估 diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index a56382a0..5daded50 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -3,6 +3,33 @@ An A2A-T sample with a client agent, a RAN domain server agent, and a mock registry center for AgentCard discovery. +## Contents + +- [1. What this sample demonstrates](#1-what-this-sample-demonstrates) + - [1.1 Project layout](#11-project-layout) +- [2. Quickstart](#2-quickstart) +- [3. Using the A2A-T SDK to implement Task-T interaction](#3-using-the-a2a-t-sdk-to-implement-task-t-interaction) + - [3.1 The four steps](#31-the-four-steps) + - [3.2 Client side](#32-client-side) + - [3.3 Server side](#33-server-side) + - [3.4 What you replace](#34-what-you-replace) +- [4. Using the A2A-T SDK to implement Negotiation-T interaction](#4-using-the-a2a-t-sdk-to-implement-negotiation-t-interaction) + - [4.1 When a negotiation happens](#41-when-a-negotiation-happens) + - [4.2 How the demo decides which one applies](#42-how-the-demo-decides-which-one-applies) + - [4.3 Shared mechanics](#43-shared-mechanics) + - [4.4 Information negotiation](#44-information-negotiation) + - [4.5 Target negotiation](#45-target-negotiation) + - [4.6 Feasibility negotiation](#46-feasibility-negotiation) + - [4.7 Reject and abort — the extension point](#47-reject-and-abort--the-extension-point) + - [4.8 Generating messages from structured data or from natural language](#48-generating-messages-from-structured-data-or-from-natural-language) +- [5. Replace checklist (demo → production)](#5-replace-checklist-demo--production) +- [6. Appendix](#6-appendix) + - [6.1 Webhook transport](#61-webhook-transport) + - [6.2 Configuration (a2a-t-sample/.env)](#62-configuration-a2a-t-sampleenv) + - [6.3 Run the two sides separately](#63-run-the-two-sides-separately) + - [6.4 Mock LLM mechanism](#64-mock-llm-mechanism) + - [6.5 Supported flags (`run_demo.py`)](#65-supported-flags-run_demopy) + ## 1. What this sample demonstrates A client agent commissions a RAN domain agent to run an energy-saving task over A2A. On this one @@ -136,9 +163,9 @@ window should be adjusted; the client answers with the corrected segments. **Feasibility — the request may be impossible under current conditions.** The client asks two stations to hold `{00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}` through an outage that lasts from 00:00 to 18:00, while an existing constraint requires at least 18 hours of power autonomy. Holding -that rate would drain the batteries faster than the outage lasts. The server reports the -infeasibility together with the evidence and a proposal (lower the 06:00~18:00 rate from 10 Mbps to -2 Mbps); the client accepts the adjusted target. +that rate would drain the batteries faster than the outage lasts. The server reports that the target +is infeasible and proposes an alternative (lower the 06:00~18:00 rate from 10 Mbps to 2 Mbps); the +client accepts the adjusted target. ### 4.2 How the demo decides which one applies @@ -273,9 +300,9 @@ trigger is a `content.semantic_conflict` validation error. | Client accept | `generate_negotiation_accept_prompt_from_data(NegotiationEndingData(context, FeasibilityEndingContent(ACCEPT, feasibility_summary)), FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI)` | [`client_flow.py:607`](src/client/client_flow.py#L607) | | Answer source | `NegotiationDecision(conclusion="accept", feasibility_summary="...")` | [`negotiation_response.py`](src/client/negotiation_response.py) | -The demo reports the completed assessment as infeasible and attaches the evidence plus an -alternative proposal (`action=PROPOSE_ALTERNATIVE_ON_FAILURE` with -`infeasibility_details_and_proposal`); the accept states the adjustment it agrees to. +The demo reports the completed assessment as infeasible and attaches an alternative proposal +(`action=PROPOSE_ALTERNATIVE_ON_FAILURE` with `infeasibility_details_and_proposal`); the accept +states the adjustment it agrees to. **③ What you can copy as-is.** The same as information: rendering, metadata assembly, session handling, transport. diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_feasibility_propose_extraction.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_feasibility_propose_extraction.json index c2989d01..62020a9e 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_feasibility_propose_extraction.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_feasibility_propose_extraction.json @@ -7,10 +7,6 @@ "name": "Conclusion", "value": "Infeasible." }, - { - "name": "Basis", - "value": "The original request asks stations \"Station 1\" and \"Station 2\" to guarantee {00:00~06:00,2Mbps} and {06:00~18:00,10Mbps}, while the sites carry an existing constraint of at least 18 hours of power autonomy during an outage. With the current guaranteed rate the power consumption in that period cannot meet the outage-duration requirement; the conclusion is based on the sites' battery capacity and historical power curves." - }, { "name": "Proposal", "value": "Lower the guaranteed throughput target for 06:00~18:00 from 10Mbps to 2Mbps, which extends the supportable outage duration to the required 18 hours. If lowering the rate is not acceptable, shorten the required outage duration instead. One of the two must be adjusted; no third option satisfies both original requirements." diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_feasibility_propose_extraction.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_feasibility_propose_extraction.json index 59a9f4dc..694352a9 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_feasibility_propose_extraction.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_feasibility_propose_extraction.json @@ -7,10 +7,6 @@ "name": "评估结论", "value": "不可行。" }, - { - "name": "评估依据", - "value": "原始请求要求“站点名称1”“站点名称2”2个站点执行 {00:00~06:00,2Mbps}、{06:00~18:00,10Mbps} 的速率保障目标,同时该站点存在停电场景下需保障至少18小时供电时长的既有约束。经评估,若按当前速率保障目标执行,该时段内设备功耗无法满足停电时长要求,两项要求之间存在冲突,判断依据为当前站点的备电容量及历史功耗曲线。" - }, { "name": "提案", "value": "建议将06:00~18:00时段的速率保障目标由10Mbps下调至2Mbps,以延长可支撑的停电时长至满足要求的18小时。若无法接受下调速率,则需相应缩短停电时长保障要求。两者需二选一调整,暂无第三种可同时满足原有两项要求的方案。" diff --git a/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py b/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py index 98e8f538..185530c5 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py +++ b/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py @@ -277,15 +277,6 @@ def resolve_language(*, env_path: Path | None = None) -> str: _NEGOTIATION_FEASIBILITY_INFEASIBILITY: dict[str, list[tuple[str, str]]] = { "en-US": [ ("Conclusion", "Infeasible."), - ( - "Basis", - 'The original request asks stations "Station 1" and "Station 2" to guarantee ' - "{00:00~06:00,2Mbps} and {06:00~18:00,10Mbps}, while the sites carry an existing " - "constraint of at least 18 hours of power autonomy during an outage. With the current " - "guaranteed rate the power consumption in that period cannot meet the outage-duration " - "requirement; the conclusion is based on the sites' battery capacity and historical " - "power curves.", - ), ( "Proposal", "Lower the guaranteed throughput target for 06:00~18:00 from 10Mbps to 2Mbps, which " @@ -296,13 +287,6 @@ def resolve_language(*, env_path: Path | None = None) -> str: ], "zh-CN": [ ("评估结论", "不可行。"), - ( - "评估依据", - "原始请求要求“站点名称1”“站点名称2”2个站点执行 {00:00~06:00,2Mbps}、" - "{06:00~18:00,10Mbps} 的速率保障目标,同时该站点存在停电场景下需保障至少18小时供电" - "时长的既有约束。经评估,若按当前速率保障目标执行,该时段内设备功耗无法满足停电时长" - "要求,两项要求之间存在冲突,判断依据为当前站点的备电容量及历史功耗曲线。", - ), ( "提案", "建议将06:00~18:00时段的速率保障目标由10Mbps下调至2Mbps,以延长可支撑的停电时长至" diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py index e0d129c5..d271b2bb 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py @@ -877,7 +877,7 @@ def test_negotiation_kind_inputs_and_accessors_are_complete(tmp_path: Path, lang assert negotiation_target_intent_understanding(env_path=env_path) assert negotiation_target_clarifications(env_path=env_path) assert negotiation_target_confirmation(env_path=env_path) - assert len(negotiation_feasibility_infeasibility(env_path=env_path)) == 3 + assert len(negotiation_feasibility_infeasibility(env_path=env_path)) == 2 assert negotiation_feasibility_summary(env_path=env_path) From 24d3fa199ee250093742fc812f4199796832e086 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 19 Oct 2026 23:26:21 +0800 Subject: [PATCH 08/30] docs(sample): explain that either side can open a negotiation The demo has the server propose because the server is the side that finds the request unexecutable, but the protocol is symmetric: both facades expose the same propose/accept/reject/abort generation methods and validation methods. State that explicitly, note that the choice is this scenario's, and point out where the decision lives if a customer's client opens the negotiation. Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 7 +++++++ a2a-t-sample/ran-energy-saving/README.md | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 315b6900..13f68d6f 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -175,6 +175,13 @@ Prompt 模板渲染而来,例如 `Negotiation-T/information-negotiation/propos 只有**新的** propose 才会推进轮次。客户端最初那条请求不算协商轮 —— 协商从服务端的 propose 才开始。 `maxRounds`(默认 5)限制会话轮次。 +**双方都可以发起 propose。** 协商是对称的:客户端与服务端 SDK 都提供同样的四个生成方法 +(propose / accept / reject / abort)和同样的校验方法,任何一方都能发起协商,任何一方也都能应答。 +本样例让**服务端**发起,是因为发现"请求无法按原样执行"的是服务端 —— 这是本场景的选择,不是报文本身 +的约束:客户端同样可以发起协商,例如它无法满足服务端公布的某项约束时,由服务端来 accept / reject / +abort。角色只决定谁发 propose;报文形态、会话处理与传输完全一致。本样例只在客户端侧接了决策 +(provider 接入点);如果你的客户端发起协商,对等的决策逻辑要放在服务端侧。 + **四条报文的闭环:** 1. 客户端 → 服务端:按原样的节能请求 —— 样例中分别是缺区域、时段重叠、停电时长无法支撑。 diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 5daded50..98be0bf5 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -191,6 +191,17 @@ the answer keeps the propose's round, and only a *new* propose advances it. The request is not a negotiation round — the negotiation starts with the server's propose. `maxRounds` (default 5) bounds the session. +**Either side can propose.** Negotiation is symmetric: the client and the server SDK expose the same +four generation methods (propose / accept / reject / abort) and the same validation methods, so +either agent can open a negotiation and either can answer one. This demo has the **server** propose +because the server is the side that discovers the request cannot be executed as-is. That is a choice +of this scenario, not a property of the wire: a client could open a negotiation just as well, for +example when it cannot meet a constraint the server announced, and the server would answer with +accept, reject or abort. The role only decides who sends the propose — the message forms, the +session handling and the transport are identical. This demo wires the decision on the client side +(the provider integration point); if your client proposes, the equivalent decision belongs on the +server side. + **The closed loop, in four messages:** 1. client → server: the energy-saving request as it stands — in the demo: a missing region, an From 268b9cb454963668e91e181d0594ad85c2aafdb4 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 19 Oct 2026 23:44:45 +0800 Subject: [PATCH 09/30] docs(sample): name the responsible side instead of an unspecified "you" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production splits the two agents across companies: the server agent is typically another vendor's system, while the client agent is the one this sample is meant to help implement. The README addressed both as "you", which made every responsibility paragraph ambiguous. - state the audience up front: the client agent's implementers, with the server agent as a runnable reference counterpart - 3.4 and the per-kind ④ sections now say what the client must provide and, where it applies, what belongs to the server agent ("the server agent's business decision", "the propose wording belongs to the server agent") - the replace checklist gains an ownership column (client / server / both) - second-person wording removed from the sample docstrings as well Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 75 ++++++++------- a2a-t-sample/ran-energy-saving/README.md | 94 +++++++++++-------- .../src/client/client_main.py | 4 +- .../src/client/negotiation_response.py | 6 +- .../src/server/server_main.py | 3 +- .../src/server/task_execution.py | 6 +- 6 files changed, 108 insertions(+), 80 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 13f68d6f..37c5bc14 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -3,6 +3,9 @@ 一个 A2A-T 样例:包含一个客户端 Agent、一个 RAN 域服务端 Agent,以及用于发现 AgentCard 的 mock 注册中心。 +**读者定位。** 本文档面向实现 **client agent** 的团队;**server agent** 是可运行的参考对端 —— +生产中两端通常归属不同厂商,因此下文统一按角色说明各自负责什么,不使用含混的第二人称。 + ## 目录 - [1. 本样例演示了什么](#1-本样例演示了什么) @@ -12,7 +15,7 @@ - [3.1 四个步骤](#31-四个步骤) - [3.2 客户端侧](#32-客户端侧) - [3.3 服务端侧](#33-服务端侧) - - [3.4 你要替换什么](#34-你要替换什么) + - [3.4 client 侧要替换什么](#34-client-侧要替换什么) - [4. 使用 A2A-T SDK 实现 Negotiation-T 交互](#4-使用-a2a-t-sdk-实现-negotiation-t-交互) - [4.1 什么时候会发生协商](#41-什么时候会发生协商) - [4.2 样例如何判断属于哪一种](#42-样例如何判断属于哪一种) @@ -87,7 +90,7 @@ python run_demo.py # 默认英文;加 --language zh-CN 运行中文 `..\..\.venv\Scripts\python.exe run_demo.py`;两端输出会实时交错打印到终端,随后后台进程被停止。 加 `--scenario negotiation`(或 `--negotiation target` / `--negotiation feasibility`)改为运行 -协商。若你的客户端无法保持流式连接,加 `--transport webhook` —— 见 [§6.1](#61-webhook-传输)。 +协商。若客户端无法保持流式连接,加 `--transport webhook` —— 见 [§6.1](#61-webhook-传输)。 ## 3. 使用 A2A-T SDK 实现 Task-T 交互 @@ -126,16 +129,18 @@ async for stream_response in a2a_client.send_message(request, context=call_conte ([`server_flow.py:82`](src/server/server_flow.py#L82)、[`server_flow.py:486`](src/server/server_flow.py#L486))。 被拒绝的提示词映射为 `TASK_STATE_REJECTED`,而不是让服务崩溃;校验通过的参数进入执行接入点。 -### 3.4 你要替换什么 +### 3.4 client 侧要替换什么 -模板(`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`)与参数 schema -都是本样例特有的 —— 请换成你自己的([`scenario_data.py`](src/client/scenario_data.py));业务执行 -运行在服务端 `TaskExecutionService` 接入点之后(见[替换清单](#5-替换清单demo-到生产))。 +模板与参数 schema 是两端共享的契约:客户端用它渲染任务请求,服务端用它做校验。本样例的模板 +(`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`)与 schema 都是样例 +特有的,因此 client 实现要换成自己的([`scenario_data.py`](src/client/scenario_data.py));服务端 +`TaskExecutionService` 接入点之后的业务执行属于 server agent(见 +[替换清单](#5-替换清单demo-到生产))。 ## 4. 使用 A2A-T SDK 实现 Negotiation-T 交互 -一句话分界:SDK 拥有报文 —— 渲染、校验、协商会话映射、传输机制;你的系统拥有业务 —— 请求何时 -无法按原样执行、值得先谈一谈,以及你这一侧如何应答 propose。 +一句话分界:SDK 拥有报文 —— 渲染、校验、协商会话映射、传输机制。业务由两端各自负责:server +agent 决定请求何时无法按原样执行、值得先谈一谈;client agent 决定如何应答收到的 propose。 ### 4.1 什么时候会发生协商 @@ -156,11 +161,12 @@ async for stream_response in a2a_client.send_message(request, context=call_conte ### 4.2 样例如何判断属于哪一种 -判断属于哪种情况、以及到底要不要协商,是**你的业务决策,SDK 不持立场**。本样例是数据驱动的: +判断属于哪种情况、以及到底要不要协商,是**server agent 的业务决策,SDK 不持立场** —— client +只对收到的 propose 作出反应。本样例是数据驱动的: 首个内容校验错误的错误码通过 `_NEGOTIATION_TYPE_BY_ERROR_CODE` 表 ([`server_flow.py:64`](src/server/server_flow.py#L64))选出类型。这张表是**样例约定,不是 SDK -契约** —— 可随意改自己的副本(重映射、新增、或干脆不用映射、按自己的业务规则判断;未映射的失败就是 -普通拒绝)。 +契约** —— server 实现可随意改自己的副本(重映射、新增、或干脆不用映射、按自己的业务规则判断; +未映射的失败就是普通拒绝)。 报文并不依赖这张表。每个 propose 都记录自己的 `templateUri` —— 即 metadata 中这段报文由哪个 Prompt 模板渲染而来,例如 `Negotiation-T/information-negotiation/propose/v1`。这个 URI 同时告诉 @@ -180,7 +186,7 @@ Prompt 模板渲染而来,例如 `Negotiation-T/information-negotiation/propos 本样例让**服务端**发起,是因为发现"请求无法按原样执行"的是服务端 —— 这是本场景的选择,不是报文本身 的约束:客户端同样可以发起协商,例如它无法满足服务端公布的某项约束时,由服务端来 accept / reject / abort。角色只决定谁发 propose;报文形态、会话处理与传输完全一致。本样例只在客户端侧接了决策 -(provider 接入点);如果你的客户端发起协商,对等的决策逻辑要放在服务端侧。 +(provider 接入点);如果由客户端发起协商,对等的决策逻辑要放在服务端侧。 **四条报文的闭环:** @@ -193,7 +199,7 @@ abort。角色只决定谁发 propose;报文形态、会话处理与传输完 会话(`id` / `round` / `maxRounds` / performative)随每条协商消息放在 `negotiationContext` metadata 中,因此 SDK 本身无状态。 -**你要接的地方。** 收到第 2 条报文时,客户端 flow 构建一个轻量的 `NegotiationProposeView`(kind、 +**client 侧要接的地方。** 收到第 2 条报文时,客户端 flow 构建一个轻量的 `NegotiationProposeView`(kind、 `templateUri`、渲染文本)并调用([`negotiation_response.py`](src/client/negotiation_response.py)): ```python @@ -207,7 +213,7 @@ class NegotiationResponseProvider(Protocol): `NegotiationDecision` 携带 `conclusion`(`accept` / `reject` / `abort`)加上对应类型的 accept 字段:`filled_params`(信息协商,name → value)、`confirmed_intent`(目标协商)、 `feasibility_summary`(可行性协商),以及 `reason`(reject/abort)。**`accept` 已端到端接线**: -flow 根据你的决策重新生成 Task-T 提示词、构建带同轮 `ACCEPT` 标记的类型化 accept,并经所选传输发送。 +flow 根据 client 侧的决策重新生成 Task-T 提示词、构建带同轮 `ACCEPT` 标记的类型化 accept,并经所选传输发送。 `respond` 是同步的 —— 需要异步的实现(人工确认、上游系统调用)在实现内部自行处理。 > Provider 只替换对 propose 的脚本化**应答** —— Task-T 补全参数(`negotiation_params`)始终是 @@ -228,16 +234,17 @@ flow 根据你的决策重新生成 Task-T 提示词、构建带同轮 `ACCEPT` propose 的 `items` 是缺失的参数;accept 的 `items` 是补全后的取值。 -**③ 哪些可以原样复用。** 提示词渲染、`templateUri`/`negotiationContext` 的 metadata 组装、 +**③ client 侧可以原样复用的部分。** 提示词渲染、`templateUri`/`negotiationContext` 的 metadata 组装、 会话处理与传输 —— `client_flow.py` 中除决策本身以外的全部内容。 -**④ 哪些必须你自己写。** propose 的业务措辞(要问哪些参数、如何命名 —— -`server_flow._missing_items_from_errors` 加上你的槽位 schema),以及 provider 接入点背后的应答来源: +**④ client 侧必须自己实现的部分。** provider 接入点背后的应答来源。propose 的业务措辞 —— +要问哪些参数、如何命名 —— 属于 server agent(`server_flow._missing_items_from_errors` 加上它的 +槽位 schema)。 ```python class MyProvider: def respond(self, propose: NegotiationProposeView, session: NegotiationContext) -> NegotiationDecision: - values = my_system.look_up(propose.kind, propose.prompt_text) # 你的查询 + values = my_system.look_up(propose.kind, propose.prompt_text) # client 侧的查询 return NegotiationDecision(conclusion="accept", filled_params=values) ``` @@ -257,11 +264,11 @@ class MyProvider: 样例用的是**澄清**形态:服务端复述它理解的意图(`intent_understanding`),并请求调整时间 (`request_for_clarification`)。accept 把修正后的目标放进 `confirmed_intent`。 -**③ 哪些可以原样复用。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 +**③ client 侧可以原样复用的部分。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 -**④ 哪些必须你自己写。** propose 中澄清请求的措辞(`_negotiation_propose_data` 的 target 分支), -以及你这一侧如何得出 `confirmed_intent` —— 样例从 `scenario_data` 读取;你的来自把歧义解决掉的那 -一方。 +**④ client 侧必须自己实现的部分。** 如何得出 `confirmed_intent` —— 样例从 `scenario_data` +读取;真实 client 的值来自把歧义解决掉的那一方。propose 中澄清请求的措辞属于 server agent +(`_negotiation_propose_data` 的 target 分支)。 > `TargetProposeContent` 还支持确认请求形态(`target_confirm_request` 非空,三个条件列表为空 —— > SDK 强制的互斥规则),以及后续轮的 `alignment_and_clarification`。样例用的是首轮澄清形态。 @@ -283,10 +290,11 @@ class MyProvider: (`action=PROPOSE_ALTERNATIVE_ON_FAILURE` 加 `infeasibility_details_and_proposal`);accept 说明 它同意的调整。 -**③ 哪些可以原样复用。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 +**③ client 侧可以原样复用的部分。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 -**④ 哪些必须你自己写。** 评估本身 —— 不可行的结论和提案 —— 以及应答:两者都必须来自你的规划、 -仿真或备电容量系统。 +**④ client 侧必须自己实现的部分。** 应答:`feasibility_summary` 必须来自 client 自己的规划、 +仿真或备电容量系统。评估本身 —— 不可行的结论和提案 —— 属于 server agent,在 +`_negotiation_propose_data` 的 feasibility 分支构建。 > `FeasibilityProposeContent` 还支持 `REQUEST_FEASIBILITY_EVALUATION`(请对方评估 > `contents_to_evaluate`)与 `feasibility_confirm_request` 形态。样例用的是「不可行 + 提案」形态。 @@ -313,13 +321,16 @@ SDK 的 `generate_negotiation_reject_prompt_from_data` / `..._from_text` 生成 ## 5. 替换清单(demo 到生产) -| demo 组成 | demo 中的作用 | 保留还是替换 | -| --- | --- | --- | -| mock LLM —— [`resources/mock_llm/`](resources/mock_llm) + `src/common/` 中按 schema 路由的 mock | 脚本化的槽位抽取与内容校验应答,`A2AT_LLM_API_KEY` 为空时启用 | CI/离线运行**保留**;生产中移除并配置真实的 OpenAI 兼容端点 | -| 脚本化执行数据 —— [`src/server/constants_data.py`](src/server/constants_data.py) | demo 执行流式推送的 UC1 进度步骤与意图报告 | 经 `TaskExecutionService` 接入点**替换**([`src/server/task_execution.py`](src/server/task_execution.py));你的服务装配后,demo 服务与数据即可删除 | -| 脚本化应答 —— [`src/client/scenario_data.py`](src/client/scenario_data.py) | 同时喂两侧(见下) | 两侧都要**替换**:客户端应答经你的 `NegotiationResponseProvider`;你副本中 `server_flow._negotiation_propose_data` 的 propose 文案改为你的业务措辞 | -| mock 注册中心 —— [`src/registry/`](src/registry)(端口 5001) | AgentCard 注册/查询,用于发现 | **替换**为你的注册中心,或绕过发现、直接从已知 AgentCard 构造 `a2a-sdk` 客户端 | -| 静态 webhook token —— `A2AT_SAMPLE_WEBHOOK_TOKEN`(默认 `demo-webhook-token`) | `X-A2A-Notification-Token` 头中的共享静态 token | **替换**为生产鉴权(逐对密钥、轮换、TLS);把接收器当作暴露在外的 HTTP 端点对待 | +**归属**列标明该项由哪一端负责。client 团队只需处理标注为 client 的行;server 行说明对端换成真实系统 +时要替换什么。 + +| demo 组成 | 归属 | demo 中的作用 | 保留还是替换 | +| --- | --- | --- | --- | +| mock LLM —— [`resources/mock_llm/`](resources/mock_llm) + `src/common/` 中按 schema 路由的 mock | 两端 | 脚本化的槽位抽取与内容校验应答,`A2AT_LLM_API_KEY` 为空时启用 | CI/离线运行**保留**;生产中两端各自移除并配置真实的 OpenAI 兼容端点 | +| 脚本化执行数据 —— [`src/server/constants_data.py`](src/server/constants_data.py) | server | demo 执行流式推送的 UC1 进度步骤与意图报告 | server agent 经 `TaskExecutionService` 接入点**替换**([`src/server/task_execution.py`](src/server/task_execution.py));它的服务装配后,demo 服务与数据即可删除 | +| 脚本化应答 —— [`src/client/scenario_data.py`](src/client/scenario_data.py) | 两端(见下) | 同时喂两侧 | 两侧都要**替换**:client 侧应答经自己的 `NegotiationResponseProvider`;server 侧在其副本的 `server_flow._negotiation_propose_data` 中改为自己的业务措辞 | +| mock 注册中心 —— [`src/registry/`](src/registry)(端口 5001) | client | AgentCard 注册/查询,用于发现 | **替换**为 client 侧的注册中心,或绕过发现、直接从已知 AgentCard 构造 `a2a-sdk` 客户端 | +| 静态 webhook token —— `A2AT_SAMPLE_WEBHOOK_TOKEN`(默认 `demo-webhook-token`) | 两端 | `X-A2A-Notification-Token` 头中的共享静态 token | **替换**为生产鉴权(逐对密钥、轮换、TLS);把接收器当作暴露在外的 HTTP 端点对待 | > **`scenario_data` 同时喂两侧** —— 客户端的脚本化应答(补全参数、确认意图、可行性摘要)经 > provider 接入点使用;服务端的 propose 内容也由同一模块构建:`_negotiation_propose_data` diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 98be0bf5..951e9b35 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -3,6 +3,11 @@ An A2A-T sample with a client agent, a RAN domain server agent, and a mock registry center for AgentCard discovery. +**Audience.** This sample is written for the team implementing the **client agent**. The **server +agent** is a runnable reference counterpart — in production the two agents usually belong to +different parties, so the sections below name the responsible side instead of addressing an +unspecified reader. + ## Contents - [1. What this sample demonstrates](#1-what-this-sample-demonstrates) @@ -12,7 +17,7 @@ AgentCard discovery. - [3.1 The four steps](#31-the-four-steps) - [3.2 Client side](#32-client-side) - [3.3 Server side](#33-server-side) - - [3.4 What you replace](#34-what-you-replace) + - [3.4 What the client replaces](#34-what-the-client-replaces) - [4. Using the A2A-T SDK to implement Negotiation-T interaction](#4-using-the-a2a-t-sdk-to-implement-negotiation-t-interaction) - [4.1 When a negotiation happens](#41-when-a-negotiation-happens) - [4.2 How the demo decides which one applies](#42-how-the-demo-decides-which-one-applies) @@ -89,7 +94,7 @@ Use an interpreter that has the sample dependencies (the repository `.venv`), e. terminal live and interleaved, then the background processes are stopped. Add `--scenario negotiation` (or `--negotiation target` / `--negotiation feasibility`) to run a -negotiation instead. If your client cannot hold a stream open, add `--transport webhook` — see +negotiation instead. If the client cannot hold a stream open, add `--transport webhook` — see [§6.1](#61-webhook-transport). ## 3. Using the A2A-T SDK to implement Task-T interaction @@ -130,19 +135,22 @@ extracts ([`server_flow.py:82`](src/server/server_flow.py#L82), [`server_flow.py A rejected prompt is mapped to `TASK_STATE_REJECTED` instead of crashing the server; the validated parameters go into the execution integration point. -### 3.4 What you replace +### 3.4 What the client replaces -The template (`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`) and the -parameter schema are sample-specific — bring your own -([`scenario_data.py`](src/client/scenario_data.py)); the business execution runs behind the -server-side `TaskExecutionService` integration point (see the +The template and the parameter schema are the contract shared by both agents: the client renders +its task request from them, and the server validates against them. This sample's template +(`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`) and schema are +sample-specific, so a client implementation brings its own +([`scenario_data.py`](src/client/scenario_data.py)). The business execution behind the server's +`TaskExecutionService` integration point belongs to the server agent (see the [replace checklist](#5-replace-checklist-demo--production)). ## 4. Using the A2A-T SDK to implement Negotiation-T interaction The boundary in one sentence: the SDK owns the wire — rendering, validation, the negotiation -session map, the transport mechanics — your system owns the business: when a request cannot be -carried out as-is and a conversation would settle it, and how your side answers a propose. +session map, the transport mechanics. The two agents own the business, each on its own side: the +server agent decides when a request cannot be carried out as-is and a conversation would settle it; +the client agent decides how to answer the propose. ### 4.1 When a negotiation happens @@ -169,12 +177,13 @@ client accepts the adjusted target. ### 4.2 How the demo decides which one applies -Which situation applies — and whether to negotiate at all — is **your business decision; the SDK -has no opinion**. In this demo it is data-driven: the first content-validation error's code selects -the kind, via the `_NEGOTIATION_TYPE_BY_ERROR_CODE` table +Which situation applies — and whether to negotiate at all — is **the server agent's business +decision; the SDK has no opinion** — and the client only reacts to the propose it receives. In this +demo it is data-driven: the first content-validation error's code selects the kind, via the +`_NEGOTIATION_TYPE_BY_ERROR_CODE` table ([`server_flow.py:64`](src/server/server_flow.py#L64)). That table is a **sample convention, not an -SDK contract** — edit your copy freely (remap codes, add your own, or drop the mapping and decide -from your own business rules; an unmapped failure is simply rejected). +SDK contract** — the server implementation edits its copy freely (remap codes, add its own, or +drop the mapping and decide from its own business rules; an unmapped failure is simply rejected). The wire does not depend on that table. Each propose records its `templateUri` — the prompt template its message text was rendered from, such as @@ -199,7 +208,7 @@ of this scenario, not a property of the wire: a client could open a negotiation example when it cannot meet a constraint the server announced, and the server would answer with accept, reject or abort. The role only decides who sends the propose — the message forms, the session handling and the transport are identical. This demo wires the decision on the client side -(the provider integration point); if your client proposes, the equivalent decision belongs on the +(the provider integration point); if the client opens a negotiation, the equivalent decision belongs on the server side. **The closed loop, in four messages:** @@ -214,7 +223,7 @@ server side. The session (`id` / `round` / `maxRounds` / performative) travels with every negotiation message in the `negotiationContext` metadata map, so the SDK itself stays stateless. -**Where your decision plugs in.** When message 2 arrives, the client flow builds a lightweight +**Where the client's decision plugs in.** When message 2 arrives, the client flow builds a lightweight `NegotiationProposeView` (kind, `templateUri`, rendered text) and calls the provider ([`negotiation_response.py`](src/client/negotiation_response.py)): @@ -229,7 +238,7 @@ class NegotiationResponseProvider(Protocol): `NegotiationDecision` carries a `conclusion` (`accept` / `reject` / `abort`) plus the per-kind accept field: `filled_params` (information, name → value), `confirmed_intent` (target), `feasibility_summary` (feasibility), and `reason` (reject/abort). **`accept` is wired -end-to-end**: from your decision the flow regenerates the Task-T prompt, builds the typed accept +end-to-end**: from the client's decision the flow regenerates the Task-T prompt, builds the typed accept with the same round stamped as `ACCEPT`, and sends it over the configured transport. `respond` is synchronous — an implementation that needs asynchrony (human confirmation, an upstream call) owns that internally. @@ -253,18 +262,19 @@ that code is the sample's wiring, not part of the protocol. The propose's `items` are the missing parameters; the accept's `items` are the filled values. -**③ What you can copy as-is.** The prompt rendering, the `templateUri`/`negotiationContext` +**③ What the client implementation can copy as-is.** The prompt rendering, the `templateUri`/`negotiationContext` metadata assembly, the session handling, and the transport — everything in `client_flow.py` except the decision itself. -**④ What you must write yourself.** The propose's business phrasing (which parameters to ask for -and how to name them — `server_flow._missing_items_from_errors` plus your slot schema) and the -answer source behind the provider integration point: +**④ What the client implementation must provide.** The answer source behind the provider +integration point. The propose's business phrasing — which parameters to ask for and how to name +them — belongs to the server agent (`server_flow._missing_items_from_errors` plus its slot +schema). ```python class MyProvider: def respond(self, propose: NegotiationProposeView, session: NegotiationContext) -> NegotiationDecision: - values = my_system.look_up(propose.kind, propose.prompt_text) # your lookup + values = my_system.look_up(propose.kind, propose.prompt_text) # the client's lookup return NegotiationDecision(conclusion="accept", filled_params=values) ``` @@ -286,12 +296,13 @@ The demo proposes the *clarification* form: the server restates the intent it un (`intent_understanding`) and asks for the time adjustment (`request_for_clarification`). The accept carries the corrected target as `confirmed_intent`. -**③ What you can copy as-is.** The same as information: rendering, metadata assembly, session +**③ What the client implementation can copy as-is.** The same as information: rendering, metadata assembly, session handling, transport. -**④ What you must write yourself.** The clarification wording for the propose -(`_negotiation_propose_data`'s target branch) and how your side derives `confirmed_intent` — the -demo reads it from `scenario_data`; yours comes from whoever resolves the ambiguity. +**④ What the client implementation must provide.** How the client derives `confirmed_intent` — +the demo reads it from `scenario_data`; a real client gets it from whoever resolves the ambiguity. +The clarification wording of the propose belongs to the server agent (`_negotiation_propose_data`'s +target branch). > `TargetProposeContent` also supports the confirm-request form (`target_confirm_request`, with the > three conditional lists empty — the mutual-exclusion rule the SDK enforces) and @@ -315,11 +326,13 @@ The demo reports the completed assessment as infeasible and attaches an alternat (`action=PROPOSE_ALTERNATIVE_ON_FAILURE` with `infeasibility_details_and_proposal`); the accept states the adjustment it agrees to. -**③ What you can copy as-is.** The same as information: rendering, metadata assembly, session +**③ What the client implementation can copy as-is.** The same as information: rendering, metadata assembly, session handling, transport. -**④ What you must write yourself.** The assessment itself — the infeasibility findings and the -proposal — and the answer: both must come from your planning, simulation or power-capacity system. +**④ What the client implementation must provide.** The answer: `feasibility_summary` must come +from the client's own planning, simulation or power-capacity system. The assessment itself — the +infeasibility conclusion and the proposal — is the server agent's, built in +`_negotiation_propose_data`'s feasibility branch. > `FeasibilityProposeContent` also supports `REQUEST_FEASIBILITY_EVALUATION` (asking the client to > evaluate `contents_to_evaluate`) and the `feasibility_confirm_request` form. The demo uses the @@ -343,21 +356,24 @@ ending content's `failure_reason`), and generate the message with the SDK's There are two ways to produce a negotiation message, and both end up on the wire in the same form: -- **from structured data (default).** You build the typed content in code and the SDK renders the - template directly — deterministic, no model call. -- **from natural language** (`--channel fromText`). You write the message as plain text and the SDK +- **from structured data (default).** The implementation builds the typed content in code and the + SDK renders the template directly — deterministic, no model call. +- **from natural language** (`--channel fromText`). The implementation writes the message as plain text and the SDK asks the model to extract the structured content first, then renders the template — one extra extraction call per generated message. ## 5. Replace checklist (demo → production) -| Demo piece | What it does in the demo | Keep or replace | -| --- | --- | --- | -| mock LLM — [`resources/mock_llm/`](resources/mock_llm) + the schema-routed mock in `src/common/` | scripted slot-extraction and content-validation replies, selected when `A2AT_LLM_API_KEY` is empty | **KEEP** for CI/offline runs; in production remove it and configure a real OpenAI-compatible endpoint | -| scripted execution data — [`src/server/constants_data.py`](src/server/constants_data.py) | the UC1 progress steps and intent report the demo execution streams | **REPLACE** via the `TaskExecutionService` integration point ([`src/server/task_execution.py`](src/server/task_execution.py)); once your service is assembled, the demo service and data can be deleted | -| scripted answers — [`src/client/scenario_data.py`](src/client/scenario_data.py) | feeds BOTH sides (see below) | **REPLACE** on both sides: the client answers via your `NegotiationResponseProvider`; the server's propose texts in your copy of `server_flow._negotiation_propose_data` become your business phrasing | -| mock registry — [`src/registry/`](src/registry) (port 5001) | AgentCard register/query for discovery | **REPLACE** with your registry center, or bypass discovery and construct the `a2a-sdk` client from a known AgentCard | -| static webhook token — `A2AT_SAMPLE_WEBHOOK_TOKEN` (default `demo-webhook-token`) | shared static token in the `X-A2A-Notification-Token` header | **REPLACE** with production auth (per-pair secrets, rotation, TLS); treat the receiver as an exposed HTTP endpoint | +The **Side** column says which agent owns the piece. The client team's rows are the ones to act on; +the server rows show what the counterpart replaces when the server is a real system. + +| Demo piece | Side | What it does in the demo | Keep or replace | +| --- | --- | --- | --- | +| mock LLM — [`resources/mock_llm/`](resources/mock_llm) + the schema-routed mock in `src/common/` | both | scripted slot-extraction and content-validation replies, selected when `A2AT_LLM_API_KEY` is empty | **KEEP** for CI/offline runs; in production each side removes it and configures a real OpenAI-compatible endpoint | +| scripted execution data — [`src/server/constants_data.py`](src/server/constants_data.py) | server | the UC1 progress steps and intent report the demo execution streams | **REPLACE** by the server agent via the `TaskExecutionService` integration point ([`src/server/task_execution.py`](src/server/task_execution.py)); once its service is assembled, the demo service and data can be deleted | +| scripted answers — [`src/client/scenario_data.py`](src/client/scenario_data.py) | both (see below) | feeds BOTH sides | **REPLACE** on both sides: the client answers via its `NegotiationResponseProvider`; the server's propose texts in its copy of `server_flow._negotiation_propose_data` become its business phrasing | +| mock registry — [`src/registry/`](src/registry) (port 5001) | client | AgentCard register/query for discovery | **REPLACE** with the client's registry center, or bypass discovery and construct the `a2a-sdk` client from a known AgentCard | +| static webhook token — `A2AT_SAMPLE_WEBHOOK_TOKEN` (default `demo-webhook-token`) | both | shared static token in the `X-A2A-Notification-Token` header | **REPLACE** with production auth (per-pair secrets, rotation, TLS); treat the receiver as an exposed HTTP endpoint | > **`scenario_data` feeds BOTH sides** — the client's scripted answers (filled parameters, > confirmed intent, feasibility summary) go through the provider integration point, and the server's propose diff --git a/a2a-t-sample/ran-energy-saving/src/client/client_main.py b/a2a-t-sample/ran-energy-saving/src/client/client_main.py index bad695dc..6952d00d 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/client_main.py +++ b/a2a-t-sample/ran-energy-saving/src/client/client_main.py @@ -323,8 +323,8 @@ def main( negotiation_filled_params(negotiation_kind, env_path=sample_env_path) if negotiation_engaged else None ) negotiation_answers_map = negotiation_answers(env_path=sample_env_path) if negotiation_engaged else None - # INTEGRATION POINT: replace ScriptedNegotiationResponseProvider with your negotiation - # response provider (human confirmation, policy engine, upstream system...) + # INTEGRATION POINT: the client implementation replaces ScriptedNegotiationResponseProvider + # with its own negotiation response provider (human confirmation, policy engine, upstream system...) negotiation_response_provider = ( ScriptedNegotiationResponseProvider( negotiation_params=negotiation_params, diff --git a/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py b/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py index 77eb5d1e..66564623 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py +++ b/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py @@ -4,7 +4,7 @@ customer's system decides how to answer a negotiation propose. ``ScriptedNegotiationResponseProvider`` is the colocated demo implementation, wrapping the scripted answers of ``client.scenario_data`` (the data stays there so tests keep importing it unchanged). Replace the demo provider at the assembly -site in ``client.client_main`` with your own implementation — see the sample README +site in ``client.client_main`` with the client's own implementation — see the sample README (``a2a-t-sample/ran-energy-saving/README.md``), the Negotiation-T chapter. """ @@ -57,7 +57,7 @@ class NegotiationDecision: round without sending anything (see the sample README, Negotiation-T chapter, for the SDK reject/abort APIs a customer wires to actually emit them). ``reason`` is accepted for reject/abort but unused by the demo flow — it becomes the ending content's - ``failure_reason`` once you wire the reject/abort send yourself (see the sample README, + ``failure_reason`` once the client implementation wires the reject/abort send (see the sample README, Negotiation-T chapter). """ @@ -81,7 +81,7 @@ class NegotiationResponseProvider(Protocol): The DECISION of how to derive the answer is entirely yours (human confirmation, a policy engine, an upstream system call); the WIRE MECHANICS stay in the flow: the flow regenerates the - Task-T prompt, builds the typed accept from your decision with the same round stamped as + Task-T prompt, builds the typed accept from the client's decision with the same round stamped as ACCEPT, and sends it over the configured transport. ``respond`` is synchronous — the flow calls it at the decision point. A customer implementation diff --git a/a2a-t-sample/ran-energy-saving/src/server/server_main.py b/a2a-t-sample/ran-energy-saving/src/server/server_main.py index 2247f781..df6799da 100644 --- a/a2a-t-sample/ran-energy-saving/src/server/server_main.py +++ b/a2a-t-sample/ran-energy-saving/src/server/server_main.py @@ -137,7 +137,8 @@ def build_server_runtime( if resolved_logger is None and log_sink is not None and debug_enabled: resolved_logger = build_sample_logger(sink=log_sink, debug_enabled=True) prompt_server = A2ATServer(env_path=env_path, logger=resolved_logger) - # INTEGRATION POINT: replace Uc1DemoExecutionService with your business execution service + # INTEGRATION POINT: the server implementation replaces Uc1DemoExecutionService with its business + # execution service # (the server flow depends only on the TaskExecutionService Protocol it implements). execution_service: TaskExecutionService = Uc1DemoExecutionService() executor = SampleAgentExecutor( diff --git a/a2a-t-sample/ran-energy-saving/src/server/task_execution.py b/a2a-t-sample/ran-energy-saving/src/server/task_execution.py index cafc036f..39e4caa4 100644 --- a/a2a-t-sample/ran-energy-saving/src/server/task_execution.py +++ b/a2a-t-sample/ran-energy-saving/src/server/task_execution.py @@ -4,7 +4,7 @@ business system executes the negotiated task. ``Uc1DemoExecutionService`` is the colocated demo implementation, delegating to the static UC1 data in ``server.constants_data`` (the data stays there so tests keep importing it unchanged). Replace the demo service at the assembly site in -``server.server_main`` with your own implementation — see the sample README +``server.server_main`` with the server's own implementation — see the sample README (``a2a-t-sample/ran-energy-saving/README.md``), the Task-T chapter / the replace checklist. """ @@ -39,12 +39,12 @@ class TaskExecutionResult: @runtime_checkable class TaskExecutionService(Protocol): - """INTEGRATION POINT (server): execute the negotiated task in YOUR business system. + """INTEGRATION POINT (server): execute the negotiated task in the server's business system. The server flow calls this integration point once the Task-T prompt validated and the negotiation concluded: ``params`` carries the filled task parameters extracted by the Task-T content validation, and the implementation runs the real business logic (in this demo, the scripted - UC1 energy-saving execution; in production, your EMS/NMS northbound call). + UC1 energy-saving execution; in production, the server's EMS/NMS northbound call). The implementation only PRODUCES: progress texts and the final result. The flow owns all A2A emission — each ``emit_progress`` call becomes a ``TASK_STATE_WORKING`` status update on the From 679a699ade9fe946dc93282db8e1b659de794b7e Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Mon, 19 Oct 2026 23:45:00 +0800 Subject: [PATCH 10/30] docs(sample): reflow the negotiation-decision paragraph Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 37c5bc14..4721400d 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -162,8 +162,8 @@ agent 决定请求何时无法按原样执行、值得先谈一谈;client agen ### 4.2 样例如何判断属于哪一种 判断属于哪种情况、以及到底要不要协商,是**server agent 的业务决策,SDK 不持立场** —— client -只对收到的 propose 作出反应。本样例是数据驱动的: -首个内容校验错误的错误码通过 `_NEGOTIATION_TYPE_BY_ERROR_CODE` 表 +只对收到的 propose 作出反应。本样例是数据驱动的:首个内容校验错误的错误码通过 +`_NEGOTIATION_TYPE_BY_ERROR_CODE` 表 ([`server_flow.py:64`](src/server/server_flow.py#L64))选出类型。这张表是**样例约定,不是 SDK 契约** —— server 实现可随意改自己的副本(重映射、新增、或干脆不用映射、按自己的业务规则判断; 未映射的失败就是普通拒绝)。 From e492732b8df9d51d1a8082bbd1338aae7b3d875a Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 00:13:24 +0800 Subject: [PATCH 11/30] docs(sample): fix the per-kind reference tables and the feasibility trigger code - the tables listed the accept generation before the decision that produces it; the real order is server propose -> client decision -> client accept - the feasibility case is a target that cannot be achieved, not a semantic conflict: switch its trigger code from content.semantic_conflict to content.rule_violation (mock, mapping, comments, tests, docs) - replace the long inline signatures with short method names plus a syntax-highlighted Python block per kind, showing both sides in flow order - drop the "also supports other forms" notes for the target and feasibility propose content; the four-part template now covers only what the sample uses - refresh the stale server_flow line anchors Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 104 ++++++++++++++--- a2a-t-sample/ran-energy-saving/README.md | 106 ++++++++++++++---- .../en-US/content_validation_feasibility.json | 2 +- .../zh-CN/content_validation_feasibility.json | 2 +- .../src/client/scenario_data.py | 2 +- .../src/server/constants_data.py | 4 +- .../src/server/server_flow.py | 6 +- .../test/test_negotiation_scenario.py | 2 +- .../test/test_negotiation_server_flow.py | 2 +- 9 files changed, 182 insertions(+), 48 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 4721400d..35a2421b 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -126,7 +126,7 @@ async for stream_response in a2a_client.send_message(request, context=call_conte ### 3.3 服务端侧 `_extract_prompt_text` 从 metadata 读取提示词,`validate_task_prompt_and_data_filling` 校验并抽取 -([`server_flow.py:82`](src/server/server_flow.py#L82)、[`server_flow.py:486`](src/server/server_flow.py#L486))。 +([`server_flow.py:82`](src/server/server_flow.py#L82)、[`server_flow.py:495`](src/server/server_flow.py#L495))。 被拒绝的提示词映射为 `TASK_STATE_REJECTED`,而不是让服务崩溃;校验通过的参数进入执行接入点。 ### 3.4 client 侧要替换什么 @@ -228,9 +228,33 @@ flow 根据 client 侧的决策重新生成 Task-T 提示词、构建带同轮 ` | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | -| 服务端 propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, InformationProposeContent(items, relationship)), INFORMATION_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | -| 客户端 accept | `generate_negotiation_accept_prompt_from_data(NegotiationEndingData(context, InformationEndingContent(ACCEPT, items)), INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI)` | [`client_flow.py:607`](src/client/client_flow.py#L607)、[`client_flow.py:659`](src/client/client_flow.py#L659) | -| 应答来源 | `NegotiationDecision(conclusion="accept", filled_params={...})` | [`negotiation_response.py`](src/client/negotiation_response.py) | +| 服务端 propose | `generate_negotiation_propose_prompt_from_data` | [`server_flow.py:208`](src/server/server_flow.py#L208) | +| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:752`](src/client/client_flow.py#L752) | +| 客户端 accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | + +```python +# 服务端:用类型化内容渲染 propose +propose = server.generate_negotiation_propose_prompt_from_data( + NegotiationProposeData( + context, + InformationProposeContent(items=missing_items, relationship=None), + ), + INFORMATION_NEGOTIATION_PROPOSE_URI, +) + +# 客户端:先决策,再根据决策渲染 accept +decision = provider.respond(propose_view, session) # filled_params = 要补的取值 +accept = client.generate_negotiation_accept_prompt_from_data( + NegotiationEndingData( + context, + InformationEndingContent( + conclusion=ACCEPT, + items=[NegotiationItem(name=n, value=v) for n, v in decision.filled_params.items()], + ), + ), + INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, +) +``` propose 的 `items` 是缺失的参数;accept 的 `items` 是补全后的取值。 @@ -257,9 +281,34 @@ class MyProvider: | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | -| 服务端 propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, TargetProposeContent(description, intent_understanding=[...], request_for_clarification=[...])), TARGET_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | -| 客户端 accept | `generate_negotiation_accept_prompt_from_data(NegotiationEndingData(context, TargetEndingContent(ACCEPT, confirmed_intent, failure_reason=None)), TARGET_NEGOTIATION_ACCEPT_REJECT_URI)` | [`client_flow.py:607`](src/client/client_flow.py#L607) | -| 应答来源 | `NegotiationDecision(conclusion="accept", confirmed_intent="...")` | [`negotiation_response.py`](src/client/negotiation_response.py) | +| 服务端 propose | `generate_negotiation_propose_prompt_from_data` | [`server_flow.py:208`](src/server/server_flow.py#L208) | +| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:752`](src/client/client_flow.py#L752) | +| 客户端 accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | + +```python +# 服务端:复述意图并请求调整时间 +propose = server.generate_negotiation_propose_prompt_from_data( + NegotiationProposeData( + context, + TargetProposeContent( + target_negotiation_description=description, + intent_understanding=intent_items, + request_for_clarification=clarification_items, + ), + ), + TARGET_NEGOTIATION_PROPOSE_URI, +) + +# 客户端:先决策,再确认修正后的目标 +decision = provider.respond(propose_view, session) # confirmed_intent = 修正后的目标 +accept = client.generate_negotiation_accept_prompt_from_data( + NegotiationEndingData( + context, + TargetEndingContent(conclusion=ACCEPT, confirmed_intent=decision.confirmed_intent), + ), + TARGET_NEGOTIATION_ACCEPT_REJECT_URI, +) +``` 样例用的是**澄清**形态:服务端复述它理解的意图(`intent_understanding`),并请求调整时间 (`request_for_clarification`)。accept 把修正后的目标放进 `confirmed_intent`。 @@ -270,21 +319,43 @@ class MyProvider: 读取;真实 client 的值来自把歧义解决掉的那一方。propose 中澄清请求的措辞属于 server agent (`_negotiation_propose_data` 的 target 分支)。 -> `TargetProposeContent` 还支持确认请求形态(`target_confirm_request` 非空,三个条件列表为空 —— -> SDK 强制的互斥规则),以及后续轮的 `alignment_and_clarification`。样例用的是首轮澄清形态。 - ### 4.6 可行性协商(feasibility) **① 服务端何时开启。** 请求在当前条件下可能做不到:保障速率无法支撑所声明站点的停电时长。样例的 -触发条件是 `content.semantic_conflict` 校验错误。 +触发条件是 `content.rule_violation` 校验错误。 **② 参考代码做了什么。** | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | -| 服务端 propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, FeasibilityProposeContent(description, action=PROPOSE_ALTERNATIVE_ON_FAILURE, infeasibility_details_and_proposal=[...])), FEASIBILITY_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | -| 客户端 accept | `generate_negotiation_accept_prompt_from_data(NegotiationEndingData(context, FeasibilityEndingContent(ACCEPT, feasibility_summary)), FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI)` | [`client_flow.py:607`](src/client/client_flow.py#L607) | -| 应答来源 | `NegotiationDecision(conclusion="accept", feasibility_summary="...")` | [`negotiation_response.py`](src/client/negotiation_response.py) | +| 服务端 propose | `generate_negotiation_propose_prompt_from_data` | [`server_flow.py:208`](src/server/server_flow.py#L208) | +| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:752`](src/client/client_flow.py#L752) | +| 客户端 accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | + +```python +# 服务端:上报不可行结论并附替代提案 +propose = server.generate_negotiation_propose_prompt_from_data( + NegotiationProposeData( + context, + FeasibilityProposeContent( + feasibility_negotiation_description=description, + action=PROPOSE_ALTERNATIVE_ON_FAILURE, + infeasibility_details_and_proposal=infeasibility_items, + ), + ), + FEASIBILITY_NEGOTIATION_PROPOSE_URI, +) + +# 客户端:先决策,再应答同意的调整 +decision = provider.respond(propose_view, session) # feasibility_summary = 应答内容 +accept = client.generate_negotiation_accept_prompt_from_data( + NegotiationEndingData( + context, + FeasibilityEndingContent(conclusion=ACCEPT, feasibility_summary=decision.feasibility_summary), + ), + FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, +) +``` 样例把已完成的评估结论作为不可行上报,并附上替代提案 (`action=PROPOSE_ALTERNATIVE_ON_FAILURE` 加 `infeasibility_details_and_proposal`);accept 说明 @@ -296,9 +367,6 @@ class MyProvider: 仿真或备电容量系统。评估本身 —— 不可行的结论和提案 —— 属于 server agent,在 `_negotiation_propose_data` 的 feasibility 分支构建。 -> `FeasibilityProposeContent` 还支持 `REQUEST_FEASIBILITY_EVALUATION`(请对方评估 -> `contents_to_evaluate`)与 `feasibility_confirm_request` 形态。样例用的是「不可行 + 提案」形态。 - ### 4.7 reject 与 abort —— 扩展点 `reject`/`abort` 决策**在本 demo 中未接线**:flow 打日志 @@ -334,7 +402,7 @@ SDK 的 `generate_negotiation_reject_prompt_from_data` / `..._from_text` 生成 > **`scenario_data` 同时喂两侧** —— 客户端的脚本化应答(补全参数、确认意图、可行性摘要)经 > provider 接入点使用;服务端的 propose 内容也由同一模块构建:`_negotiation_propose_data` -> ([`server_flow.py:207`](src/server/server_flow.py#L207)) 用其访问器填充 target/feasibility 的 +> ([`server_flow.py:208`](src/server/server_flow.py#L208)) 用其访问器填充 target/feasibility 的 > propose 文案。只替换 provider 与执行服务,服务端仍会用 demo 的预制文案发起 propose —— > 该模块不可删除。 diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 951e9b35..dc7e6066 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -131,7 +131,7 @@ async for stream_response in a2a_client.send_message(request, context=call_conte ### 3.3 Server side `_extract_prompt_text` reads the metadata and `validate_task_prompt_and_data_filling` validates and -extracts ([`server_flow.py:82`](src/server/server_flow.py#L82), [`server_flow.py:486`](src/server/server_flow.py#L486)). +extracts ([`server_flow.py:82`](src/server/server_flow.py#L82), [`server_flow.py:495`](src/server/server_flow.py#L495)). A rejected prompt is mapped to `TASK_STATE_REJECTED` instead of crashing the server; the validated parameters go into the execution integration point. @@ -256,9 +256,33 @@ that code is the sample's wiring, not part of the protocol. | Step | SDK call | Reference code | | --- | --- | --- | -| Server propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, InformationProposeContent(items, relationship)), INFORMATION_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | -| Client accept | `generate_negotiation_accept_prompt_from_data(NegotiationEndingData(context, InformationEndingContent(ACCEPT, items)), INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI)` | [`client_flow.py:607`](src/client/client_flow.py#L607), [`client_flow.py:659`](src/client/client_flow.py#L659) | -| Answer source | `NegotiationDecision(conclusion="accept", filled_params={...})` | [`negotiation_response.py`](src/client/negotiation_response.py) | +| Server propose | `generate_negotiation_propose_prompt_from_data` | [`server_flow.py:208`](src/server/server_flow.py#L208) | +| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:752`](src/client/client_flow.py#L752) | +| Client accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | + +```python +# server: render the propose from typed content +propose = server.generate_negotiation_propose_prompt_from_data( + NegotiationProposeData( + context, + InformationProposeContent(items=missing_items, relationship=None), + ), + INFORMATION_NEGOTIATION_PROPOSE_URI, +) + +# client: decide, then render the accept from that decision +decision = provider.respond(propose_view, session) # filled_params = the values to deliver +accept = client.generate_negotiation_accept_prompt_from_data( + NegotiationEndingData( + context, + InformationEndingContent( + conclusion=ACCEPT, + items=[NegotiationItem(name=n, value=v) for n, v in decision.filled_params.items()], + ), + ), + INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, +) +``` The propose's `items` are the missing parameters; the accept's `items` are the filled values. @@ -288,9 +312,34 @@ ambiguous. The demo's trigger is a `content.value_not_allowed` validation error. | Step | SDK call | Reference code | | --- | --- | --- | -| Server propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, TargetProposeContent(description, intent_understanding=[...], request_for_clarification=[...])), TARGET_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | -| Client accept | `generate_negotiation_accept_prompt_from_data(NegotiationEndingData(context, TargetEndingContent(ACCEPT, confirmed_intent, failure_reason=None)), TARGET_NEGOTIATION_ACCEPT_REJECT_URI)` | [`client_flow.py:607`](src/client/client_flow.py#L607) | -| Answer source | `NegotiationDecision(conclusion="accept", confirmed_intent="...")` | [`negotiation_response.py`](src/client/negotiation_response.py) | +| Server propose | `generate_negotiation_propose_prompt_from_data` | [`server_flow.py:208`](src/server/server_flow.py#L208) | +| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:752`](src/client/client_flow.py#L752) | +| Client accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | + +```python +# server: restate the intent and ask for the time adjustment +propose = server.generate_negotiation_propose_prompt_from_data( + NegotiationProposeData( + context, + TargetProposeContent( + target_negotiation_description=description, + intent_understanding=intent_items, + request_for_clarification=clarification_items, + ), + ), + TARGET_NEGOTIATION_PROPOSE_URI, +) + +# client: decide, then confirm the corrected target +decision = provider.respond(propose_view, session) # confirmed_intent = the corrected target +accept = client.generate_negotiation_accept_prompt_from_data( + NegotiationEndingData( + context, + TargetEndingContent(conclusion=ACCEPT, confirmed_intent=decision.confirmed_intent), + ), + TARGET_NEGOTIATION_ACCEPT_REJECT_URI, +) +``` The demo proposes the *clarification* form: the server restates the intent it understood (`intent_understanding`) and asks for the time adjustment (`request_for_clarification`). The accept @@ -304,23 +353,44 @@ the demo reads it from `scenario_data`; a real client gets it from whoever resol The clarification wording of the propose belongs to the server agent (`_negotiation_propose_data`'s target branch). -> `TargetProposeContent` also supports the confirm-request form (`target_confirm_request`, with the -> three conditional lists empty — the mutual-exclusion rule the SDK enforces) and -> `alignment_and_clarification` for later rounds. The demo uses the first-round clarification form. - ### 4.6 Feasibility negotiation **① When the server opens it.** The request may be impossible under current conditions: the guaranteed rate cannot sustain the declared outage duration for the listed stations. The demo's -trigger is a `content.semantic_conflict` validation error. +trigger is a `content.rule_violation` validation error. **② What the reference code does.** | Step | SDK call | Reference code | | --- | --- | --- | -| Server propose | `generate_negotiation_propose_prompt_from_data(NegotiationProposeData(context, FeasibilityProposeContent(description, action=PROPOSE_ALTERNATIVE_ON_FAILURE, infeasibility_details_and_proposal=[...])), FEASIBILITY_NEGOTIATION_PROPOSE_URI)` | [`server_flow.py:207`](src/server/server_flow.py#L207) | -| Client accept | `generate_negotiation_accept_prompt_from_data(NegotiationEndingData(context, FeasibilityEndingContent(ACCEPT, feasibility_summary)), FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI)` | [`client_flow.py:607`](src/client/client_flow.py#L607) | -| Answer source | `NegotiationDecision(conclusion="accept", feasibility_summary="...")` | [`negotiation_response.py`](src/client/negotiation_response.py) | +| Server propose | `generate_negotiation_propose_prompt_from_data` | [`server_flow.py:208`](src/server/server_flow.py#L208) | +| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:752`](src/client/client_flow.py#L752) | +| Client accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | + +```python +# server: report the assessment as infeasible and attach the alternative proposal +propose = server.generate_negotiation_propose_prompt_from_data( + NegotiationProposeData( + context, + FeasibilityProposeContent( + feasibility_negotiation_description=description, + action=PROPOSE_ALTERNATIVE_ON_FAILURE, + infeasibility_details_and_proposal=infeasibility_items, + ), + ), + FEASIBILITY_NEGOTIATION_PROPOSE_URI, +) + +# client: decide, then answer with the agreed adjustment +decision = provider.respond(propose_view, session) # feasibility_summary = the answer +accept = client.generate_negotiation_accept_prompt_from_data( + NegotiationEndingData( + context, + FeasibilityEndingContent(conclusion=ACCEPT, feasibility_summary=decision.feasibility_summary), + ), + FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, +) +``` The demo reports the completed assessment as infeasible and attaches an alternative proposal (`action=PROPOSE_ALTERNATIVE_ON_FAILURE` with `infeasibility_details_and_proposal`); the accept @@ -334,10 +404,6 @@ from the client's own planning, simulation or power-capacity system. The assessm infeasibility conclusion and the proposal — is the server agent's, built in `_negotiation_propose_data`'s feasibility branch. -> `FeasibilityProposeContent` also supports `REQUEST_FEASIBILITY_EVALUATION` (asking the client to -> evaluate `contents_to_evaluate`) and the `feasibility_confirm_request` form. The demo uses the -> infeasibility-and-proposal form. - ### 4.7 Reject and abort — the extension point A `reject`/`abort` decision is **not wired in this demo**: the flow logs @@ -378,7 +444,7 @@ the server rows show what the counterpart replaces when the server is a real sys > **`scenario_data` feeds BOTH sides** — the client's scripted answers (filled parameters, > confirmed intent, feasibility summary) go through the provider integration point, and the server's propose > content is built from the same module: `_negotiation_propose_data` -> ([`server_flow.py:207`](src/server/server_flow.py#L207)) fills the target/feasibility propose +> ([`server_flow.py:208`](src/server/server_flow.py#L208)) fills the target/feasibility propose > texts from its accessors. Replacing only the provider and the execution service still leaves > the server proposing the demo's canned texts — the module is not removable. diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility.json index 36089cd7..44e21ea1 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility.json @@ -3,7 +3,7 @@ "errors": [ { "slot_name": "guaranteedThroughputTarget", - "code": "content.semantic_conflict", + "code": "content.rule_violation", "facts": { "section_label": "task_context" } diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility.json index a8e27556..d6137d5a 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility.json @@ -3,7 +3,7 @@ "errors": [ { "slot_name": "guaranteedThroughputTarget", - "code": "content.semantic_conflict", + "code": "content.rule_violation", "facts": { "section_label": "任务上下文" } diff --git a/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py b/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py index 185530c5..822f3155 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py +++ b/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py @@ -67,7 +67,7 @@ #: Negotiation kinds selecting the client scenario input; the server stays data-driven on the #: content-validation error code (``content.param_missing`` -> information, -#: ``content.value_not_allowed`` -> target, ``content.semantic_conflict`` -> feasibility). +#: ``content.value_not_allowed`` -> target, ``content.rule_violation`` -> feasibility). NEGOTIATION_KIND_INFORMATION = "information" NEGOTIATION_KIND_TARGET = "target" NEGOTIATION_KIND_FEASIBILITY = "feasibility" diff --git a/a2a-t-sample/ran-energy-saving/src/server/constants_data.py b/a2a-t-sample/ran-energy-saving/src/server/constants_data.py index 80fed7f9..03736995 100644 --- a/a2a-t-sample/ran-energy-saving/src/server/constants_data.py +++ b/a2a-t-sample/ran-energy-saving/src/server/constants_data.py @@ -56,10 +56,10 @@ #: Task-T content-validation error codes that select the negotiation type (demo convention, plan #: R1): the first structured error of a failed Task-T content validation carries the code — #: ``content.param_missing`` proposes information (v1), ``content.value_not_allowed`` target and -#: ``content.semantic_conflict`` feasibility. +#: ``content.rule_violation`` feasibility. CONTENT_ERROR_CODE_INFORMATION = "content.param_missing" CONTENT_ERROR_CODE_TARGET = "content.value_not_allowed" -CONTENT_ERROR_CODE_FEASIBILITY = "content.semantic_conflict" +CONTENT_ERROR_CODE_FEASIBILITY = "content.rule_violation" #: Generation channel variants selected by ``A2AT_SAMPLE_CHANNEL``: ``data`` generates the #: negotiation propose from typed content (no extraction LLM call), ``text`` from a natural-language diff --git a/a2a-t-sample/ran-energy-saving/src/server/server_flow.py b/a2a-t-sample/ran-energy-saving/src/server/server_flow.py index bac8a242..604dc619 100644 --- a/a2a-t-sample/ran-energy-saving/src/server/server_flow.py +++ b/a2a-t-sample/ran-energy-saving/src/server/server_flow.py @@ -58,7 +58,7 @@ #: Sample-level convention mapping the first content-validation error's code to the Negotiation-T #: type it proposes (plan R6): ``content.param_missing`` -> information, ``content.value_not_allowed`` -#: -> target, ``content.semantic_conflict`` -> feasibility. This is a sample convention, not an SDK +#: -> target, ``content.rule_violation`` -> feasibility. This is a sample convention, not an SDK #: rule — customers edit their copy of this table to their own error codes (see the sample README, #: Negotiation-T chapter); codes absent from the table keep the plain rejection. _NEGOTIATION_TYPE_BY_ERROR_CODE: dict[str, str] = { @@ -140,7 +140,7 @@ def _resolve_negotiation_type( The FIRST error entry's code selects the type through the sample-level ``_NEGOTIATION_TYPE_BY_ERROR_CODE`` table (plan R6): ``content.param_missing`` keeps the v1 information path (which still proposes only when the errors name slots), - ``content.value_not_allowed`` selects the target propose and ``content.semantic_conflict`` the + ``content.value_not_allowed`` selects the target propose and ``content.rule_violation`` the feasibility propose. Any other code — or a codeless error — selects no negotiation type, so the caller rejects the task exactly as v1 did (including the slotless case). """ @@ -432,7 +432,7 @@ async def execute_server_flow( then COMPLETED 4. Default flow: emit SUBMITTED, then on failure REJECTED — or, when the first validation error's code selects a negotiation type (plan R1: ``content.param_missing`` information - with slot-named errors, ``content.value_not_allowed`` target, ``content.semantic_conflict`` + with slot-named errors, ``content.value_not_allowed`` target, ``content.rule_violation`` feasibility), a Negotiation-T propose of that type on an INPUT_REQUIRED status update, generated from typed data or from a natural-language rendering per the configured channel (data-driven trigger, the server reads no scenario flag) diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py index d271b2bb..41518a3d 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py @@ -633,7 +633,7 @@ def test_content_validation_variant_resources_carry_the_trigger_codes(language: assert feasibility["errors"] == [ { "slot_name": "guaranteedThroughputTarget", - "code": "content.semantic_conflict", + "code": "content.rule_violation", "facts": {"section_label": _VARIANT_SECTION_LABELS[language]["task_context"]}, } ] diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py index 8f56a97c..b9120ab9 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py @@ -839,7 +839,7 @@ def test_negotiation_type_by_error_code_pins_the_sample_mapping() -> None: assert _NEGOTIATION_TYPE_BY_ERROR_CODE == { "content.param_missing": "information", "content.value_not_allowed": "target", - "content.semantic_conflict": "feasibility", + "content.rule_violation": "feasibility", } # Unrecognized codes stay out of the table (their tasks keep the plain rejection). assert "content.format_error" not in _NEGOTIATION_TYPE_BY_ERROR_CODE From 39c478d02da223556fe4bcf3b8e66f15ad8fd7b2 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 00:20:45 +0800 Subject: [PATCH 12/30] docs(sample): keep the client sections client-only and drop protocol jargon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the feasibility ④ paragraph no longer prescribes where the client's answer comes from (planning / simulation / power-capacity), and the client sections no longer describe what the server agent owns — the per-kind table already labels the server's propose step - "wire" replaced with plainer wording throughout the README and the sample docstrings (protocol details / message format / message handling), and the Chinese "接线" replaced with 实现 / 判断方式 Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 18 +++++-------- a2a-t-sample/ran-energy-saving/README.md | 27 ++++++++----------- .../src/client/client_flow.py | 16 +++++------ .../src/client/negotiation_response.py | 12 ++++----- .../src/common/webhook_receiver.py | 2 +- .../src/server/task_execution.py | 4 +-- 6 files changed, 35 insertions(+), 44 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 35a2421b..3e2f678d 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -212,7 +212,7 @@ class NegotiationResponseProvider(Protocol): `NegotiationDecision` 携带 `conclusion`(`accept` / `reject` / `abort`)加上对应类型的 accept 字段:`filled_params`(信息协商,name → value)、`confirmed_intent`(目标协商)、 -`feasibility_summary`(可行性协商),以及 `reason`(reject/abort)。**`accept` 已端到端接线**: +`feasibility_summary`(可行性协商),以及 `reason`(reject/abort)。**`accept` 已端到端实现**: flow 根据 client 侧的决策重新生成 Task-T 提示词、构建带同轮 `ACCEPT` 标记的类型化 accept,并经所选传输发送。 `respond` 是同步的 —— 需要异步的实现(人工确认、上游系统调用)在实现内部自行处理。 @@ -222,7 +222,7 @@ flow 根据 client 侧的决策重新生成 Task-T 提示词、构建带同轮 ` ### 4.4 信息协商(information) **① 服务端何时开启。** 请求不完整 —— 缺少服务端必需的参数(样例中为区域)。样例的触发条件是 -`content.param_missing` 校验错误,但这个错误码是样例的接线方式,不属于协议本身。 +`content.param_missing` 校验错误,但这个错误码是样例的判断方式,不属于协议本身。 **② 参考代码做了什么。** @@ -261,9 +261,7 @@ propose 的 `items` 是缺失的参数;accept 的 `items` 是补全后的取 **③ client 侧可以原样复用的部分。** 提示词渲染、`templateUri`/`negotiationContext` 的 metadata 组装、 会话处理与传输 —— `client_flow.py` 中除决策本身以外的全部内容。 -**④ client 侧必须自己实现的部分。** provider 接入点背后的应答来源。propose 的业务措辞 —— -要问哪些参数、如何命名 —— 属于 server agent(`server_flow._missing_items_from_errors` 加上它的 -槽位 schema)。 +**④ client 侧必须自己实现的部分。** provider 接入点背后的应答来源。 ```python class MyProvider: @@ -316,8 +314,7 @@ accept = client.generate_negotiation_accept_prompt_from_data( **③ client 侧可以原样复用的部分。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 **④ client 侧必须自己实现的部分。** 如何得出 `confirmed_intent` —— 样例从 `scenario_data` -读取;真实 client 的值来自把歧义解决掉的那一方。propose 中澄清请求的措辞属于 server agent -(`_negotiation_propose_data` 的 target 分支)。 +读取;真实 client 的值来自把歧义解决掉的那一方。 ### 4.6 可行性协商(feasibility) @@ -363,13 +360,12 @@ accept = client.generate_negotiation_accept_prompt_from_data( **③ client 侧可以原样复用的部分。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 -**④ client 侧必须自己实现的部分。** 应答:`feasibility_summary` 必须来自 client 自己的规划、 -仿真或备电容量系统。评估本身 —— 不可行的结论和提案 —— 属于 server agent,在 -`_negotiation_propose_data` 的 feasibility 分支构建。 +**④ client 侧必须自己实现的部分。** 应答内容 `feasibility_summary` —— 即 client 对服务端 +提案的答复。 ### 4.7 reject 与 abort —— 扩展点 -`reject`/`abort` 决策**在本 demo 中未接线**:flow 打日志 +`reject`/`abort` 决策**在本 demo 中未实现**:flow 打日志 `negotiation-decision: {conclusion} (not wired in this demo...)` 后结束、不发送任何报文。扩展点是 [`client_flow.py:752`](src/client/client_flow.py#L752) 中 `_plan_negotiation_accept` 的 reject/abort 分支:扩展它、仿照 `_run_negotiation_accept`([`client_flow.py:870`](src/client/client_flow.py#L870)) diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index dc7e6066..aeec7a59 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -60,7 +60,7 @@ Read it one of three ways: | Path | Core responsibility | | --- | --- | | [`src/client/client_main.py`](src/client/client_main.py) | Assembly root: reads `.env`, builds the runtime (`build_client_runtime`), discovers the server's AgentCard from the registry, starts/stops the webhook receiver, and assembles the demo `ScriptedNegotiationResponseProvider`. The client-side `INTEGRATION POINT` comment sits in `main()`. | -| [`src/client/client_flow.py`](src/client/client_flow.py) | Orchestration: the message sequence (generate → send → consume), the wire mechanics (metadata keys, `A2A-Extensions` header), the event pipeline, and the two transport drivers (SSE streaming / webhook queue). It calls the negotiation decision integration point but owns no decision. | +| [`src/client/client_flow.py`](src/client/client_flow.py) | Orchestration: the message sequence (generate → send → consume), the message handling (metadata keys, `A2A-Extensions` header), the event pipeline, and the two transport drivers (SSE streaming / webhook queue). It calls the negotiation decision integration point but owns no decision. | | [`src/client/negotiation_response.py`](src/client/negotiation_response.py) | The negotiation decision integration point: `NegotiationProposeView`, `NegotiationDecision`, the `NegotiationResponseProvider` Protocol, and the demo `ScriptedNegotiationResponseProvider`. | | [`src/client/scenario_data.py`](src/client/scenario_data.py) | The sample's inputs and scripted answers — it feeds **both** sides (see the [replace checklist](#5-replace-checklist-demo--production)); not removable as-is. | | [`src/server/server_main.py`](src/server/server_main.py) | a2a-sdk app assembly, with the push-notification machinery assembled unconditionally. The server-side `INTEGRATION POINT` comment sits in `build_server_runtime`. | @@ -147,8 +147,8 @@ sample-specific, so a client implementation brings its own ## 4. Using the A2A-T SDK to implement Negotiation-T interaction -The boundary in one sentence: the SDK owns the wire — rendering, validation, the negotiation -session map, the transport mechanics. The two agents own the business, each on its own side: the +The boundary in one sentence: the SDK handles the protocol details — rendering, validation, the +negotiation session map, the transport mechanics. The two agents own the business, each on its own side: the server agent decides when a request cannot be carried out as-is and a conversation would settle it; the client agent decides how to answer the propose. @@ -185,7 +185,7 @@ demo it is data-driven: the first content-validation error's code selects the ki SDK contract** — the server implementation edits its copy freely (remap codes, add its own, or drop the mapping and decide from its own business rules; an unmapped failure is simply rejected). -The wire does not depend on that table. Each propose records its `templateUri` — the prompt +The message format does not depend on that table. Each propose records its `templateUri` — the prompt template its message text was rendered from, such as `Negotiation-T/information-negotiation/propose/v1`. That URI also tells the client which kind of propose it received, so the client picks its answer — accept, reject or abort — from the message it @@ -204,7 +204,7 @@ request is not a negotiation round — the negotiation starts with the server's four generation methods (propose / accept / reject / abort) and the same validation methods, so either agent can open a negotiation and either can answer one. This demo has the **server** propose because the server is the side that discovers the request cannot be executed as-is. That is a choice -of this scenario, not a property of the wire: a client could open a negotiation just as well, for +of this scenario, not a property of the protocol: a client could open a negotiation just as well, for example when it cannot meet a constraint the server announced, and the server would answer with accept, reject or abort. The role only decides who sends the propose — the message forms, the session handling and the transport are identical. This demo wires the decision on the client side @@ -291,9 +291,7 @@ metadata assembly, the session handling, and the transport — everything in `cl the decision itself. **④ What the client implementation must provide.** The answer source behind the provider -integration point. The propose's business phrasing — which parameters to ask for and how to name -them — belongs to the server agent (`server_flow._missing_items_from_errors` plus its slot -schema). +integration point. ```python class MyProvider: @@ -349,9 +347,8 @@ carries the corrected target as `confirmed_intent`. handling, transport. **④ What the client implementation must provide.** How the client derives `confirmed_intent` — -the demo reads it from `scenario_data`; a real client gets it from whoever resolves the ambiguity. -The clarification wording of the propose belongs to the server agent (`_negotiation_propose_data`'s -target branch). +the demo reads it from `scenario_data`; a real client gets it from whatever resolves the +ambiguity. ### 4.6 Feasibility negotiation @@ -399,10 +396,8 @@ states the adjustment it agrees to. **③ What the client implementation can copy as-is.** The same as information: rendering, metadata assembly, session handling, transport. -**④ What the client implementation must provide.** The answer: `feasibility_summary` must come -from the client's own planning, simulation or power-capacity system. The assessment itself — the -infeasibility conclusion and the proposal — is the server agent's, built in -`_negotiation_propose_data`'s feasibility branch. +**④ What the client implementation must provide.** The answer — `feasibility_summary`, i.e. how +the client responds to the server's proposal. ### 4.7 Reject and abort — the extension point @@ -420,7 +415,7 @@ ending content's `failure_reason`), and generate the message with the SDK's ### 4.8 Generating messages from structured data or from natural language -There are two ways to produce a negotiation message, and both end up on the wire in the same form: +There are two ways to produce a negotiation message, and both produce the same message format: - **from structured data (default).** The implementation builds the typed content in code and the SDK renders the template directly — deterministic, no model call. diff --git a/a2a-t-sample/ran-energy-saving/src/client/client_flow.py b/a2a-t-sample/ran-energy-saving/src/client/client_flow.py index cfb17a62..ae3aa1fc 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/client_flow.py +++ b/a2a-t-sample/ran-energy-saving/src/client/client_flow.py @@ -98,7 +98,7 @@ def _parse_negotiation_context(metadata: dict[str, object]) -> NegotiationContex #: Negotiation kinds the client-side accept generation dispatches on. The kind resolves from the #: explicit flag when supplied, else from the propose's ``templateUri`` inside the INPUT_REQUIRED -#: metadata (dispatch on the wire); each kind addresses its own accept-reject template. +#: metadata (dispatch on the received message); each kind addresses its own accept-reject template. _NEGOTIATION_KIND_BY_PROPOSE_URI: dict[str, str] = { INFORMATION_NEGOTIATION_PROPOSE_URI: NEGOTIATION_KIND_INFORMATION, TARGET_NEGOTIATION_PROPOSE_URI: NEGOTIATION_KIND_TARGET, @@ -115,9 +115,9 @@ def _parse_negotiation_context(metadata: dict[str, object]) -> NegotiationContex def _resolve_negotiation_kind(metadata: dict[str, object], negotiation_kind: str | None) -> str | None: """Resolve the accept's negotiation kind: the explicit flag wins, else the propose's templateUri. - A ``None`` flag dispatches on the wire: the ``templateUri`` of the INPUT_REQUIRED metadata maps + A ``None`` flag dispatches on the received message: the ``templateUri`` of the INPUT_REQUIRED metadata maps to the negotiation kind, so the client accepts whatever type the server proposed. An unknown - flag, or a missing/unknown propose template URI when dispatching on the wire, returns ``None`` + flag, or a missing/unknown propose template URI when dispatching on the received message, returns ``None`` (the caller keeps the unhandled path). """ if negotiation_kind is not None: @@ -671,7 +671,7 @@ def _build_negotiation_accept_request( The accept is the second message of the current negotiation round: the session context parsed from the propose metadata drives it (same round, performative ACCEPT — a round is one propose plus the answer to it, so the accept does not advance the round), the Task-T prompt is - regenerated from the filled parameters (the flow-owned wire mechanic fed by the caller), and + regenerated from the filled parameters (the flow-owned message construction fed by the caller), and both travel in the accept message metadata under their extension URIs. The accept content comes from the provider's decision via ``plan``. The channel switches only the accept generation call: ``data`` builds the typed content and calls the deterministic from-data API (zero LLM calls), @@ -726,7 +726,7 @@ def _build_negotiation_accept_request( accept_request.message.message_id = str(uuid.uuid4()) accept_request.message.role = Role.ROLE_USER # task_id / context_id are filled by the caller from the initial-phase events (the builder sees - # no events); the wire shape is identical for both drivers once set. + # no events); the message shape is identical for both drivers once set. accept_request.message.parts.add().text = _build_request_metadata(initial_input) accept_request.message.metadata[TASK_T_EXTENSION_URI] = filled_text accept_request.message.metadata[NEGOTIATION_T_EXTENSION_URI] = accept_text @@ -960,9 +960,9 @@ async def run_client_flow( provider replaces the scripted answer derivation entirely: ``negotiation_answers`` is not consulted and the accept content comes from the provider's decision (a reject/abort decision ends the flow with the dedicated ``negotiation-decision`` log, no accept sent). The flow keeps - the wire mechanics: ``negotiation_params`` remains the Task-T prompt refill input of the + the message handling: ``negotiation_params`` remains the Task-T prompt refill input of the accept message and ``negotiation_kind`` keeps hinting the propose view's kind resolution (the - flag wins, else the propose's ``templateUri`` dispatches on the wire). Without a provider, the + flag wins, else the propose's ``templateUri`` dispatches on the received message). Without a provider, the legacy scripted arguments keep the pre-integration-point behavior unchanged: when both ``negotiation_params`` and ``negotiation_answers`` are supplied, the flow internally builds the demo ``ScriptedNegotiationResponseProvider`` around them (back-compat shim); without them an @@ -978,7 +978,7 @@ async def run_client_flow( ``[client] webhook-event`` (webhook) 5. Negotiation round trip: when the server answers INPUT_REQUIRED with a Negotiation-T propose and a negotiation response provider is in place, resolve the negotiation kind (the - explicit flag, else the propose's ``templateUri`` — dispatch on the wire), ask the + explicit flag, else the propose's ``templateUri`` — dispatch on the received message), ask the provider for the decision on the propose view, regenerate the Task-T prompt from the filled parameters, render the Negotiation-T accept of that type for the parsed session from the decision (from typed data, or from a natural-language rendering plus one diff --git a/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py b/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py index 66564623..30245381 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py +++ b/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py @@ -28,7 +28,7 @@ class NegotiationProposeView: """What the flow observed from one INPUT_REQUIRED propose, handed to the provider. - The A2A-T wire carries no structured per-type propose content — only the propose's + The A2A-T message carries no structured per-type propose content — only the propose's ``templateUri`` and its rendered text in the status metadata (plus the session context the flow parses separately). The view mirrors exactly that: ``kind`` is the negotiation type the flow resolved (the configured flag, else the dispatch on ``template_uri``), ``template_uri`` @@ -52,7 +52,7 @@ class NegotiationDecision: * ``"accept"`` — the flow builds the accept from the decision field of the proposed type: ``filled_params`` (information: the name->value items the client fills in), ``confirmed_intent`` (target) or ``feasibility_summary`` (feasibility). The accept goes - through the existing accept builders, so the wire behavior is the demo's accept. + through the existing accept builders, so the message behavior is the demo's accept. * ``"reject"`` / ``"abort"`` — not wired in this demo: the flow logs the decision and ends the round without sending anything (see the sample README, Negotiation-T chapter, for the SDK reject/abort APIs a customer wires to actually emit them). ``reason`` is accepted for @@ -74,13 +74,13 @@ class NegotiationResponseProvider(Protocol): The client flow calls this integration point once the server answers the initial task request with an INPUT_REQUIRED Negotiation-T propose: ``propose`` is the lightweight - :class:`NegotiationProposeView` observed from the wire (negotiation kind, template URI, rendered + :class:`NegotiationProposeView` observed from the message (negotiation kind, template URI, rendered text) and ``session`` the parsed :class:`NegotiationContext` of the server's session. The implementation returns the :class:`NegotiationDecision` — accept (with the per-type answer content), reject or abort. The DECISION of how to derive the answer is entirely yours (human confirmation, a policy - engine, an upstream system call); the WIRE MECHANICS stay in the flow: the flow regenerates the + engine, an upstream system call); the MESSAGE HANDLING stays in the flow: the flow regenerates the Task-T prompt, builds the typed accept from the client's decision with the same round stamped as ACCEPT, and sends it over the configured transport. @@ -103,7 +103,7 @@ class ScriptedNegotiationResponseProvider: as the filled parameters, a target propose with the scenario's confirmed-target sentence and a feasibility propose with the scenario's feasibility summary — the scenario accessors keep resolving the configured language through ``env_path``. The view's ``kind`` (resolved by the - flow: the flag, else the propose's ``templateUri``) selects the answer, so the wire dispatch + flow: the flag, else the propose's ``templateUri``) selects the answer, so the dispatch from the message works unchanged; ``session`` is unused by the scripted demo (a customer implementation may branch on the round/performative). @@ -129,7 +129,7 @@ def respond(self, propose: NegotiationProposeView, session: NegotiationContext) ``propose`` selects the scripted answer by kind; ``session`` is unused (see the class docstring). ``negotiation_params`` is kept for assembly parity — the Task-T prompt refill - stays a flow-owned wire mechanic fed from the caller, not part of the decision. + stays a flow-owned message construction fed from the caller, not part of the decision. """ if propose.kind == NEGOTIATION_KIND_TARGET: return NegotiationDecision( diff --git a/a2a-t-sample/ran-energy-saving/src/common/webhook_receiver.py b/a2a-t-sample/ran-energy-saving/src/common/webhook_receiver.py index 3e70ab81..67f76190 100644 --- a/a2a-t-sample/ran-energy-saving/src/common/webhook_receiver.py +++ b/a2a-t-sample/ran-energy-saving/src/common/webhook_receiver.py @@ -86,7 +86,7 @@ def _recognized_stream_key(payload: object) -> str | None: class WebhookReceiver: """In-process webhook receiver serving one authenticated ``POST /webhook`` route. - The wire contract is the a2a-sdk push-notification POST: a StreamResponse JSON body (one of + The message contract is the a2a-sdk push-notification POST: a StreamResponse JSON body (one of the ``task`` / ``statusUpdate`` / ``artifactUpdate`` / ``message`` top-level keys) with the push token in the ``X-A2A-Notification-Token`` header. A token mismatch answers ``401`` and the event is dropped; a malformed or unrecognized body answers ``400``; a valid event is diff --git a/a2a-t-sample/ran-energy-saving/src/server/task_execution.py b/a2a-t-sample/ran-energy-saving/src/server/task_execution.py index 39e4caa4..1e11bda1 100644 --- a/a2a-t-sample/ran-energy-saving/src/server/task_execution.py +++ b/a2a-t-sample/ran-energy-saving/src/server/task_execution.py @@ -47,8 +47,8 @@ class TaskExecutionService(Protocol): UC1 energy-saving execution; in production, the server's EMS/NMS northbound call). The implementation only PRODUCES: progress texts and the final result. The flow owns all A2A - emission — each ``emit_progress`` call becomes a ``TASK_STATE_WORKING`` status update on the - wire, the returned :class:`TaskExecutionResult` becomes the report artifact, and an exception + emission — each ``emit_progress`` call becomes a ``TASK_STATE_WORKING`` status update in the + event stream, the returned :class:`TaskExecutionResult` becomes the report artifact, and an exception raised by the implementation is caught by the flow, which emits ``TASK_STATE_FAILED`` and re-raises. A customer implementation therefore gets the streaming and terminal-state semantics for free. From 4e4142f49d8511d0073be98aba7ed350d44e939e Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 00:27:45 +0800 Subject: [PATCH 13/30] docs(sample): remove the remaining wire/wired/wiring wording The README and docstrings still used "wired", "wires" and "wiring" after the first pass. Replace them with plain wording, including the runtime decision log, which now reads "(not implemented in this demo; see the sample README)"; the three test assertions pinning that line are updated with it. Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 2 +- a2a-t-sample/ran-energy-saving/README.md | 12 ++++++------ .../ran-energy-saving/src/client/client_flow.py | 2 +- .../src/client/negotiation_response.py | 6 +++--- .../test/test_negotiation_client_flow.py | 12 ++++++------ .../test/test_webhook_client_flow.py | 12 ++++++------ 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 3e2f678d..6dab6ea0 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -366,7 +366,7 @@ accept = client.generate_negotiation_accept_prompt_from_data( ### 4.7 reject 与 abort —— 扩展点 `reject`/`abort` 决策**在本 demo 中未实现**:flow 打日志 -`negotiation-decision: {conclusion} (not wired in this demo...)` 后结束、不发送任何报文。扩展点是 +`negotiation-decision: {conclusion} (not implemented in this demo...)` 后结束、不发送任何报文。扩展点是 [`client_flow.py:752`](src/client/client_flow.py#L752) 中 `_plan_negotiation_accept` 的 reject/abort 分支:扩展它、仿照 `_run_negotiation_accept`([`client_flow.py:870`](src/client/client_flow.py#L870)) —— 以 `session.next_round().with_performative(NegotiationPerformative.REJECT)` 推进会话,构建该 diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index aeec7a59..5889fa6e 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -207,7 +207,7 @@ because the server is the side that discovers the request cannot be executed as- of this scenario, not a property of the protocol: a client could open a negotiation just as well, for example when it cannot meet a constraint the server announced, and the server would answer with accept, reject or abort. The role only decides who sends the propose — the message forms, the -session handling and the transport are identical. This demo wires the decision on the client side +session handling and the transport are identical. This demo implements the decision on the client side (the provider integration point); if the client opens a negotiation, the equivalent decision belongs on the server side. @@ -237,8 +237,8 @@ class NegotiationResponseProvider(Protocol): `NegotiationDecision` carries a `conclusion` (`accept` / `reject` / `abort`) plus the per-kind accept field: `filled_params` (information, name → value), `confirmed_intent` (target), -`feasibility_summary` (feasibility), and `reason` (reject/abort). **`accept` is wired -end-to-end**: from the client's decision the flow regenerates the Task-T prompt, builds the typed accept +`feasibility_summary` (feasibility), and `reason` (reject/abort). **`accept` is implemented +end to end**: from the client's decision the flow regenerates the Task-T prompt, builds the typed accept with the same round stamped as `ACCEPT`, and sends it over the configured transport. `respond` is synchronous — an implementation that needs asynchrony (human confirmation, an upstream call) owns that internally. @@ -250,7 +250,7 @@ that internally. **① When the server opens it.** The request is incomplete — it lacks a parameter the server needs (in the demo, the region). The demo's trigger is a `content.param_missing` validation error, but -that code is the sample's wiring, not part of the protocol. +that code is the sample's choice, not part of the protocol. **② What the reference code does.** @@ -401,8 +401,8 @@ the client responds to the server's proposal. ### 4.7 Reject and abort — the extension point -A `reject`/`abort` decision is **not wired in this demo**: the flow logs -`negotiation-decision: {conclusion} (not wired in this demo...)` and ends without sending. The +A `reject`/`abort` decision is **not implemented in this demo**: the flow logs +`negotiation-decision: {conclusion} (not implemented in this demo...)` and ends without sending. The extension point is the reject/abort branch of `_plan_negotiation_accept` in [`client_flow.py:752`](src/client/client_flow.py#L752): extend it to mirror `_run_negotiation_accept` ([`client_flow.py:870`](src/client/client_flow.py#L870)) — advance the diff --git a/a2a-t-sample/ran-energy-saving/src/client/client_flow.py b/a2a-t-sample/ran-energy-saving/src/client/client_flow.py index ae3aa1fc..eebace5e 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/client_flow.py +++ b/a2a-t-sample/ran-energy-saving/src/client/client_flow.py @@ -806,7 +806,7 @@ def _plan_negotiation_accept( format_stage_log( role="client", stage="negotiation-decision", - detail=f"{decision.conclusion} (not wired in this demo; see the sample README)", + detail=f"{decision.conclusion} (not implemented in this demo; see the sample README)", ) ) return None diff --git a/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py b/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py index 30245381..7574425d 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py +++ b/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py @@ -53,11 +53,11 @@ class NegotiationDecision: ``filled_params`` (information: the name->value items the client fills in), ``confirmed_intent`` (target) or ``feasibility_summary`` (feasibility). The accept goes through the existing accept builders, so the message behavior is the demo's accept. - * ``"reject"`` / ``"abort"`` — not wired in this demo: the flow logs the decision and ends the + * ``"reject"`` / ``"abort"`` — not implemented in this demo: the flow logs the decision and ends the round without sending anything (see the sample README, Negotiation-T chapter, for the SDK - reject/abort APIs a customer wires to actually emit them). ``reason`` is accepted for + reject/abort APIs a customer implements to actually emit them). ``reason`` is accepted for reject/abort but unused by the demo flow — it becomes the ending content's - ``failure_reason`` once the client implementation wires the reject/abort send (see the sample README, + ``failure_reason`` once the client implementation adds the reject/abort send (see the sample README, Negotiation-T chapter). """ diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_client_flow.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_client_flow.py index b9637294..f46d961e 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_client_flow.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_client_flow.py @@ -209,7 +209,7 @@ def _agent_message(*, text: str, metadata: dict[str, object] | None = None) -> M def _status_update(*, state: TaskState, text: str = "", metadata: dict[str, object] | None = None) -> StreamResponse: - """Build one streamed status update event (the wire shape the a2a-sdk client yields).""" + """Build one streamed status update event (the message shape the a2a-sdk client yields).""" return StreamResponse( status_update=TaskStatusUpdateEvent( task_id=_TASK_ID, @@ -597,7 +597,7 @@ def test_feasibility_propose_answers_with_the_typed_feasibility_accept(_offline_ def test_propose_template_uri_dispatches_the_accept_kind_without_a_flag( _offline_env: Path, propose_uri: str, accept_uri: str ) -> None: - """With no kind flag the accept type dispatches on the propose's templateUri (the wire).""" + """With no kind flag the accept type dispatches on the propose's templateUri (the received message).""" prompt_client = _ScriptedPromptClient() a2a_client = _ScriptedA2AClient( [_initial_events(metadata=_propose_metadata(template_uri=propose_uri)), _continuation_events()] @@ -707,7 +707,7 @@ def test_from_text_channel_renders_the_feasibility_summary_and_numbered_answers( def test_target_accept_accept_requests_are_equivalent_across_channels(_offline_env: Path) -> None: - """The from-data and from-text target accepts produce the same accept wire metadata.""" + """The from-data and from-text target accepts produce the same accept message metadata.""" propose_metadata = _propose_metadata(template_uri=TARGET_NEGOTIATION_PROPOSE_URI) accept_metadata: list[dict[str, object]] = [] for channel in (CHANNEL_DATA, CHANNEL_TEXT): @@ -1016,7 +1016,7 @@ def test_injected_provider_replaces_the_scripted_accept_end_to_end(_offline_env: assert len(prompt_client.accept_calls) == 1 accept_data = prompt_client.accept_calls[0] assert [(item.name, item.value) for item in accept_data.content.items] == [("region", "Custom filled region")] - # The Task-T prompt refill remains the flow's wire mechanic fed by the caller. + # The Task-T prompt refill remains the flow's message construction fed by the caller. assert prompt_client.from_data_calls == [(_NEGOTIATION_PARAMS, ENERGY_SAVING_TEMPLATE_URI)] assert _event_states(events)[-1] == "TASK_STATE_COMPLETED" @@ -1044,7 +1044,7 @@ def test_reject_decision_logs_negotiation_decision_and_stays_on_round_1(_offline assert prompt_client.from_data_calls == [] decision_logs = [line for line in logs if line.startswith("[client] negotiation-decision:")] assert decision_logs == [ - "[client] negotiation-decision: reject (not wired in this demo; see the sample README)" + "[client] negotiation-decision: reject (not implemented in this demo; see the sample README)" ] assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED"] summary_lines = [line for line in logs if line.startswith("[client] stream-completed:")] @@ -1072,7 +1072,7 @@ def test_abort_decision_logs_negotiation_decision_and_stays_on_round_1(_offline_ assert len(a2a_client.calls) == 1 assert prompt_client.accept_calls == [] decision_logs = [line for line in logs if line.startswith("[client] negotiation-decision:")] - assert decision_logs == ["[client] negotiation-decision: abort (not wired in this demo; see the sample README)"] + assert decision_logs == ["[client] negotiation-decision: abort (not implemented in this demo; see the sample README)"] assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED"] summary_lines = [line for line in logs if line.startswith("[client] stream-completed:")] assert summary_lines == ["[client] stream-completed: events=2 artifacts=0"] diff --git a/a2a-t-sample/ran-energy-saving/test/test_webhook_client_flow.py b/a2a-t-sample/ran-energy-saving/test/test_webhook_client_flow.py index f3ac9cbe..dce1035c 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_webhook_client_flow.py +++ b/a2a-t-sample/ran-energy-saving/test/test_webhook_client_flow.py @@ -16,7 +16,7 @@ end signals (a REJECTED in either phase ends promptly with ``end=`` on the summary — no idle hang, final fix), the idle-timeout backstop (R5), the ``normalize_webhook_payload``/``normalize_event`` parity, and the server -assembly (push store/sender wired unconditionally into ``build_rest_app``). +assembly (push store/sender assembled unconditionally into ``build_rest_app``). """ from __future__ import annotations @@ -273,7 +273,7 @@ def _agent_message(*, text: str, metadata: dict[str, object] | None = None) -> M def _status_update(*, state: TaskState, text: str = "", metadata: dict[str, object] | None = None) -> StreamResponse: - """Build one streamed status update event (the wire shape the a2a-sdk client yields).""" + """Build one streamed status update event (the message shape the a2a-sdk client yields).""" return StreamResponse( status_update=TaskStatusUpdateEvent( task_id=_TASK_ID, @@ -609,7 +609,7 @@ def test_target_from_text_channel_webhook_spot_check(_offline_env: Path) -> None assert context.performative == NegotiationPerformative.ACCEPT assert template_uri == TARGET_NEGOTIATION_ACCEPT_REJECT_URI - # Round-2 wire shape unchanged across channels: send -> register -> send, COMPLETED end. + # Round-2 message shape unchanged across channels: send -> register -> send, COMPLETED end. assert a2a_client.call_log == ["send:1", "register:1", "send:2"] accept_request = a2a_client.send_calls[1][0] metadata = _request_metadata(accept_request) @@ -731,7 +731,7 @@ def test_reject_decision_webhook_logs_and_ends_after_the_initial_phase(_offline_ assert prompt_client.accept_calls == [] decision_logs = [line for line in logs if line.startswith("[client] negotiation-decision:")] assert decision_logs == [ - "[client] negotiation-decision: reject (not wired in this demo; see the sample README)" + "[client] negotiation-decision: reject (not implemented in this demo; see the sample README)" ] assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED"] summary_lines = [line for line in logs if line.startswith("[client] stream-completed:")] @@ -876,7 +876,7 @@ def test_normalize_webhook_payload_keeps_metadata_so_int_coercion_absorbs_the_fl assert session.performative == NegotiationPerformative.PROPOSE -# --- server assembly: push machinery wired unconditionally (R1) ----------------------------------- +# --- server assembly: push machinery assembled unconditionally (R1) ----------------------------------- def test_build_rest_app_request_handler_carries_the_push_store_and_sender( @@ -892,7 +892,7 @@ def test_build_rest_app_request_handler_carries_the_push_store_and_sender( handler = app.state.request_handler assert isinstance(handler._push_config_store, InMemoryPushNotificationConfigStore) assert isinstance(handler._push_sender, BasePushNotificationSender) - # The sender reads configs from the very store wired into the handler. + # The sender reads configs from the very store passed to the handler. assert handler._push_sender._config_store is handler._push_config_store # The sender's httpx client is managed by the app lifespan (closed on shutdown). From c1cfe00bdcfd3ad1ad5a53ea6294352d09b1cf1c Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 00:38:09 +0800 Subject: [PATCH 14/30] refactor(sample): generate the server propose from a natural-language message The propose is a business message written by the server agent, so generate it through generate_negotiation_propose_prompt_from_text: the SDK extracts the structured content and renders the template. The accept remains the client's decision and keeps its --channel switch. - drop the from-data propose builder and the now-unused server-side channel plumbing; execute_server_flow no longer takes a channel - the propose-extraction mock mirrors the demo's single missing item and re-adds the relationship note when the input asks for multiple items - tests: call logs include the propose extraction, the from-channel pairs and the channel-plumbing tests are folded or removed Signed-off-by: codetalks-hw --- .../en-US/negotiation_propose_extraction.json | 6 +- .../zh-CN/negotiation_propose_extraction.json | 8 +- .../ran-energy-saving/src/common/mock_llm.py | 15 +- .../src/server/server_flow.py | 99 ++-------- .../src/server/server_main.py | 14 +- .../test/test_negotiation_scenario.py | 9 +- .../test/test_negotiation_server_flow.py | 170 ++---------------- 7 files changed, 50 insertions(+), 271 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_propose_extraction.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_propose_extraction.json index 8c3cd3ef..8027a1cc 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_propose_extraction.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/negotiation_propose_extraction.json @@ -2,11 +2,7 @@ "items": [ { "name": "region", - "value": "Please provide the coverage region of the energy saving task" - }, - { - "name": "energySavingGoal", - "value": "Please provide the energy saving goal of the energy saving task" + "value": "Please provide region: Coverage region (task object) of the energy-saving task; a concrete physical area name.." } ], "relationship": null diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_propose_extraction.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_propose_extraction.json index 4a57fa56..8027a1cc 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_propose_extraction.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/negotiation_propose_extraction.json @@ -1,12 +1,8 @@ { "items": [ { - "name": "区域", - "value": "请提供节能任务的覆盖区域" - }, - { - "name": "节能目标", - "value": "请提供节能任务的节能目标" + "name": "region", + "value": "Please provide region: Coverage region (task object) of the energy-saving task; a concrete physical area name.." } ], "relationship": null diff --git a/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py b/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py index 71ddd3c6..129a7644 100644 --- a/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py +++ b/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py @@ -295,6 +295,12 @@ def _is_feasibility_validation_head(head: str) -> bool: ) +#: Sentence the information-propose input carries when more than one item is missing; the canned +#: propose-extraction response has no relationship by default, so the mock re-adds it here to keep +#: the from-text propose equivalent to the content the caller wrote. +_PROPOSE_RELATIONSHIP_MARKER = "Multiple items must be provided together." + + def get_routed_response( messages: list[dict[str, str]], json_schema: dict[str, Any] | None, @@ -302,8 +308,15 @@ def get_routed_response( """Serve the canned response routed by the output schema of one structured call.""" stage = route_structured_call(messages, json_schema) llm_calls.append(stage) + content = _script[stage] + if stage == STAGE_NEGOTIATION_PROPOSE_EXTRACTION and _PROPOSE_RELATIONSHIP_MARKER in _first_user_content( + messages + ): + extracted = json.loads(content) + extracted["relationship"] = _PROPOSE_RELATIONSHIP_MARKER + content = json.dumps(extracted, ensure_ascii=False) return LLMResponse( - content=_script[stage], + content=content, model="mock-llm", usage={"prompt_tokens": 0, "completion_tokens": 0}, metadata={}, diff --git a/a2a-t-sample/ran-energy-saving/src/server/server_flow.py b/a2a-t-sample/ran-energy-saving/src/server/server_flow.py index 604dc619..6206d5f3 100644 --- a/a2a-t-sample/ran-energy-saving/src/server/server_flow.py +++ b/a2a-t-sample/ran-energy-saving/src/server/server_flow.py @@ -11,14 +11,7 @@ from a2a.types import Task, TaskArtifactUpdateEvent, TaskState from a2a_t.core.errors.exceptions import ContentValidationError, SlotValidationError from a2a_t.core.metadata import NegotiationContext, NegotiationPerformative -from a2a_t.negotiation.content.enums import NegotiationAction -from a2a_t.negotiation.content.models import ( - FeasibilityProposeContent, - InformationProposeContent, - NegotiationItem, - NegotiationProposeData, - TargetProposeContent, -) +from a2a_t.negotiation.content.models import NegotiationItem from client import scenario_data from common.a2a_adapter import build_metadata_artifact, build_status, emit_status_update from common.logging_utils import format_payload_log, format_stage_log @@ -29,8 +22,6 @@ from server.constants_data import ( ARTIFACT_SEND_INTERVAL_SECONDS, - CHANNEL_DATA, - CHANNEL_TEXT, COMPLETED_MESSAGE, CONTENT_ERROR_CODE_FEASIBILITY, CONTENT_ERROR_CODE_INFORMATION, @@ -205,61 +196,6 @@ def _negotiation_propose_template_uri(negotiation_type: str) -> str: return NEGOTIATION_PROPOSE_TEMPLATE_URI -def _negotiation_propose_data( - negotiation_type: str, - missing_items: list[NegotiationItem], - context: NegotiationContext, - *, - env_path: Path | None, -) -> NegotiationProposeData: - """Build the typed propose content of one negotiation type (spec scenario semantics). - - Target proposes the intent restatement plus the clarification request (the overlapping - time segments), and feasibility proposes the infeasibility assessment plus the alternative - proposal (lower the guaranteed rate to sustain the outage duration); both fill their - scenario-owned texts from the ``client.scenario_data`` accessors. Information keeps the - missing-item propose. - """ - if negotiation_type == NEGOTIATION_TYPE_TARGET: - return NegotiationProposeData( - context=context, - content=TargetProposeContent( - target_negotiation_description=scenario_data.negotiation_target_description(env_path=env_path), - intent_understanding=[ - NegotiationItem(name=name, value=value) - for name, value in scenario_data.negotiation_target_intent_understanding(env_path=env_path) - ], - alignment_and_clarification=None, - request_for_clarification=[ - NegotiationItem(name=name, value=value) - for name, value in scenario_data.negotiation_target_clarifications(env_path=env_path) - ], - target_confirm_request=None, - ), - ) - if negotiation_type == NEGOTIATION_TYPE_FEASIBILITY: - return NegotiationProposeData( - context=context, - content=FeasibilityProposeContent( - feasibility_negotiation_description=scenario_data.negotiation_feasibility_description( - env_path=env_path - ), - action=NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, - contents_to_evaluate=None, - infeasibility_details_and_proposal=[ - NegotiationItem(name=name, value=value) - for name, value in scenario_data.negotiation_feasibility_infeasibility(env_path=env_path) - ], - feasibility_confirm_request=None, - ), - ) - relationship = NEGOTIATION_RELATIONSHIP if len(missing_items) > 1 else None - return NegotiationProposeData( - context=context, - content=InformationProposeContent(items=missing_items, relationship=relationship), - ) - - async def _emit_negotiation_propose( *, request_context: RequestContext, @@ -267,7 +203,6 @@ async def _emit_negotiation_propose( prompt_server: A2ATServer, missing_items: list[NegotiationItem], negotiation_type: str = NEGOTIATION_TYPE_INFORMATION, - channel: str = CHANNEL_DATA, env_path: Path | None = None, task_id: str, context_id: str, @@ -275,14 +210,13 @@ async def _emit_negotiation_propose( ) -> None: """Generate the Negotiation-T propose for the negotiation type and emit INPUT_REQUIRED. - The type selects the propose template URI and the content (plan R1/R3). The channel switches - only the generation call: ``data`` builds the typed content and calls the deterministic - from-data API (zero LLM calls), ``text`` renders the same content as a natural-language message - and calls the from-text API, whose extraction step parses it back into the canonical typed - content (one LLM call) — both channels then render the addressed template from equivalent - content, so the produced messages are equivalent. The propose travels on the INPUT_REQUIRED - status event's message metadata (``propose.build_metadata_content()``); the templateUri inside - carries the per-type propose template URI, which is what the client dispatches its accept on. + The type selects the propose template URI and the content (plan R1/R3). The propose is written + as a business message and generated through the from-text API: the SDK first extracts the + canonical typed content (one LLM call), then renders the addressed template. The accept is the + client's decision; how it is generated is the client's choice. The propose travels on the + INPUT_REQUIRED status event's message metadata (``propose.build_metadata_content()``); the + templateUri inside carries the per-type propose template URI, which is what the client + dispatches its accept on. """ propose_context = NegotiationContext( id=str(uuid.uuid4()), @@ -291,20 +225,17 @@ async def _emit_negotiation_propose( performative=NegotiationPerformative.PROPOSE, ) template_uri = _negotiation_propose_template_uri(negotiation_type) - if channel == CHANNEL_TEXT: - text = _negotiation_propose_text(negotiation_type, missing_items, env_path=env_path) - propose = prompt_server.generate_negotiation_propose_prompt_from_text(text, propose_context, template_uri) - else: - data = _negotiation_propose_data(negotiation_type, missing_items, propose_context, env_path=env_path) - propose = prompt_server.generate_negotiation_propose_prompt_from_data(data, template_uri) + propose_text = _negotiation_propose_text(negotiation_type, missing_items, env_path=env_path) + propose = prompt_server.generate_negotiation_propose_prompt_from_text( + propose_text, propose_context, template_uri + ) if log_sink is not None: log_sink( format_stage_log( role="server", stage="negotiation-propose", detail=( - f"generated (type={negotiation_type} channel={channel} " - f"round={propose_context.round}) " + f"generated (type={negotiation_type} round={propose_context.round}) " f"items={[(item.name, item.value) for item in missing_items]}" ), ) @@ -410,7 +341,6 @@ async def execute_server_flow( sleep_fn: SleepFn | None = None, artifact_send_interval_seconds: float = ARTIFACT_SEND_INTERVAL_SECONDS, log_sink: object | None = None, - channel: str = CHANNEL_DATA, env_path: Path | None = None, execution_service: TaskExecutionService | None = None, ) -> None: @@ -434,7 +364,7 @@ async def execute_server_flow( error's code selects a negotiation type (plan R1: ``content.param_missing`` information with slot-named errors, ``content.value_not_allowed`` target, ``content.rule_violation`` feasibility), a Negotiation-T propose of that type on an INPUT_REQUIRED status update, - generated from typed data or from a natural-language rendering per the configured channel + generated from a natural-language business message through the from-text API (data-driven trigger, the server reads no scenario flag) 5. Default success: execute the negotiated task through the integration point — each progress line becomes a ``TASK_STATE_WORKING`` status update and the ``TaskExecutionResult`` becomes @@ -582,7 +512,6 @@ async def execute_server_flow( prompt_server=prompt_server, missing_items=missing_items, negotiation_type=negotiation_type, - channel=channel, env_path=env_path, task_id=resolved_task_id, context_id=resolved_context_id, diff --git a/a2a-t-sample/ran-energy-saving/src/server/server_main.py b/a2a-t-sample/ran-energy-saving/src/server/server_main.py index df6799da..840df586 100644 --- a/a2a-t-sample/ran-energy-saving/src/server/server_main.py +++ b/a2a-t-sample/ran-energy-saving/src/server/server_main.py @@ -25,7 +25,7 @@ from dotenv import dotenv_values from starlette.applications import Starlette -from server.constants_data import CHANNEL_DATA, get_public_agent_card, resolve_negotiation_channel +from server.constants_data import get_public_agent_card from server.server_flow import execute_server_flow from server.task_execution import TaskExecutionService, Uc1DemoExecutionService @@ -51,14 +51,12 @@ def __init__( prompt_server: object, log_sink: object | None = None, execute_flow: object, - channel: str = CHANNEL_DATA, env_path: Path | None = None, execution_service: TaskExecutionService | None = None, ) -> None: self._prompt_server = prompt_server self._log_sink = log_sink self._execute_flow = execute_flow - self._channel = channel self._env_path = env_path self._execution_service = execution_service @@ -68,7 +66,6 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non "event_queue": event_queue, "prompt_server": self._prompt_server, "log_sink": self._log_sink, - "channel": self._channel, "env_path": self._env_path, "execution_service": self._execution_service, } @@ -127,11 +124,9 @@ def build_server_runtime( ) -> dict[str, object]: """Build the server runtime: an A2ATServer prompt server and a SampleAgentExecutor. - The negotiation generation channel is resolved here from the run's ``.env`` - (``A2AT_SAMPLE_CHANNEL``, default ``data``) and, with the ``env_path`` itself, threaded into - the executor so the propose builders read the same scenario configuration. The execution - service of the task execution integration point is assembled here too and threaded through the executor into - ``execute_server_flow``. + The ``env_path`` is threaded into the executor so the propose builders read the same scenario + configuration. The execution service of the task execution integration point is assembled here + too and threaded through the executor into ``execute_server_flow``. """ resolved_logger = logger if resolved_logger is None and log_sink is not None and debug_enabled: @@ -145,7 +140,6 @@ def build_server_runtime( prompt_server=prompt_server, log_sink=log_sink, execute_flow=execute_server_flow, - channel=resolve_negotiation_channel(env_path=env_path), env_path=env_path, execution_service=execution_service, ) diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py index 41518a3d..452131f4 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py @@ -601,8 +601,9 @@ def test_information_extraction_resources_mirror_the_scenario_answers(language: propose = _resource(language, "negotiation_propose_extraction.json") accept = _resource(language, "negotiation_accept_extraction.json") - # The propose asks for the region (plus one more item) with no relationship note. - assert len(propose["items"]) == 2 # type: ignore[arg-type] + # The propose asks for the region the demo input omits, with no relationship note. + assert len(propose["items"]) == 1 # type: ignore[arg-type] + assert propose["items"][0]["name"] == "region" # type: ignore[index] assert propose["relationship"] is None # The accept confirms the region exactly as the scenario answers carry it. assert accept == { @@ -618,8 +619,8 @@ def test_content_validation_variant_resources_carry_the_trigger_codes(language: feasibility = _resource(language, "content_validation_feasibility.json") # The target variant reports the value-not-allowed error on the throughput param; the - # feasibility variant reports the semantic-conflict error on the throughput param too — the - # conflict is between the guaranteed rate and the outage duration, not the energy goal. + # feasibility variant reports the rule-violation error on the throughput param — the requested + # rate cannot sustain the outage duration, which is not a semantic conflict of values. assert target["semantic_verdict"] is False assert target["errors"] == [ { diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py index b9120ab9..246065ad 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py @@ -60,7 +60,6 @@ ENERGY_SAVING_TEMPLATE_URI, build_prompt_input, negotiation_answers, - negotiation_feasibility_description, negotiation_feasibility_infeasibility, negotiation_filled_params, negotiation_target_clarifications, @@ -154,7 +153,6 @@ async def _run_flow( metadata: dict[str, object], prompt_server: object, requested_extensions: set[str] | None = None, - channel: str = CHANNEL_DATA, env_path: Path | None = None, execution_service: TaskExecutionService | None = None, ) -> list[object]: @@ -169,7 +167,6 @@ async def _run_flow( event_queue=queue, prompt_server=prompt_server, sleep_fn=_no_sleep, - channel=channel, env_path=env_path, execution_service=execution_service, ) @@ -344,7 +341,7 @@ def _kind_initial_metadata(client: A2ATClient, env_path: Path, kind: str) -> dic } -# --- initial phase: per-type proposes (target / feasibility) over the from-data channel ------------ +# --- initial phase: per-type proposes (target / feasibility) ------------------------------------ def test_round_1_target_failure_answers_input_required_carrying_the_target_propose(_offline_env: Path) -> None: @@ -373,10 +370,12 @@ def test_round_1_target_failure_answers_input_required_carrying_the_target_propo propose_text = str(metadata[NEGOTIATION_T_EXTENSION_URI]) for name, _value in negotiation_target_clarifications(env_path=_offline_env): assert name in propose_text - # from-data propose generation is deterministic: no negotiation extraction call is logged. + # The propose is generated from a natural-language message: one extraction call follows the + # initial validation. assert mock_llm.llm_calls == [ mock_llm.STAGE_SLOT_EXTRACTION_TARGET, mock_llm.STAGE_CONTENT_VALIDATION_TARGET, + mock_llm.STAGE_NEGOTIATION_TARGET_PROPOSE_EXTRACTION, ] @@ -403,68 +402,6 @@ def test_round_1_feasibility_failure_answers_input_required_carrying_the_feasibi propose_text = str(metadata[NEGOTIATION_T_EXTENSION_URI]) for name, _value in negotiation_feasibility_infeasibility(env_path=_offline_env): assert name in propose_text - assert mock_llm.llm_calls == [ - mock_llm.STAGE_SLOT_EXTRACTION_FEASIBILITY, - mock_llm.STAGE_CONTENT_VALIDATION_FEASIBILITY, - ] - - -# --- initial phase: per-type proposes over the from-text channel ----------------------------------- - - -def test_round_1_target_failure_from_text_channel_generates_via_extraction(_offline_env: Path) -> None: - mock_llm.reset_call_log() - metadata = _kind_initial_metadata( - A2ATClient(env_path=_offline_env), _offline_env, scenario_data.NEGOTIATION_KIND_TARGET - ) - - events = asyncio.run( - _run_flow( - metadata=metadata, - prompt_server=A2ATServer(env_path=_offline_env), - channel=CHANNEL_TEXT, - env_path=_offline_env, - ) - ) - - assert _event_states(events) == [TaskState.TASK_STATE_SUBMITTED, TaskState.TASK_STATE_INPUT_REQUIRED] - propose_metadata = _message_metadata(events[-1]) - # The templateUri still carries the target propose URI — the client's dispatch key is unchanged. - assert propose_metadata[TEMPLATE_URI_METADATA_KEY] == TARGET_NEGOTIATION_PROPOSE_URI - assert negotiation_target_clarifications(env_path=_offline_env)[0][0] in str( - propose_metadata[NEGOTIATION_T_EXTENSION_URI] - ) - # from-text propose generation runs exactly one extraction call after the initial validation. - assert mock_llm.llm_calls == [ - mock_llm.STAGE_SLOT_EXTRACTION_TARGET, - mock_llm.STAGE_CONTENT_VALIDATION_TARGET, - mock_llm.STAGE_NEGOTIATION_TARGET_PROPOSE_EXTRACTION, - ] - - -def test_round_1_feasibility_failure_from_text_channel_generates_via_extraction(_offline_env: Path) -> None: - mock_llm.reset_call_log() - metadata = _kind_initial_metadata( - A2ATClient(env_path=_offline_env), _offline_env, scenario_data.NEGOTIATION_KIND_FEASIBILITY - ) - - events = asyncio.run( - _run_flow( - metadata=metadata, - prompt_server=A2ATServer(env_path=_offline_env), - channel=CHANNEL_TEXT, - env_path=_offline_env, - ) - ) - - assert _event_states(events) == [TaskState.TASK_STATE_SUBMITTED, TaskState.TASK_STATE_INPUT_REQUIRED] - propose_metadata = _message_metadata(events[-1]) - assert propose_metadata[TEMPLATE_URI_METADATA_KEY] == FEASIBILITY_NEGOTIATION_PROPOSE_URI - # The rendered text carries the description and the contents to evaluate of the scenario data. - propose_text = str(propose_metadata[NEGOTIATION_T_EXTENSION_URI]) - assert negotiation_feasibility_description(env_path=_offline_env) in propose_text - for name, value in negotiation_feasibility_infeasibility(env_path=_offline_env): - assert f"{name}: {value}" in propose_text assert mock_llm.llm_calls == [ mock_llm.STAGE_SLOT_EXTRACTION_FEASIBILITY, mock_llm.STAGE_CONTENT_VALIDATION_FEASIBILITY, @@ -472,59 +409,12 @@ def test_round_1_feasibility_failure_from_text_channel_generates_via_extraction( ] -def test_target_propose_messages_are_equivalent_across_channels(_offline_env: Path) -> None: - """The from-data and from-text channels produce equivalent messages (the mock extraction - returns the canonical content the scenario accessors mirror); only the fresh session id - differs.""" - metadata = _kind_initial_metadata( - A2ATClient(env_path=_offline_env), _offline_env, scenario_data.NEGOTIATION_KIND_TARGET - ) - server = A2ATServer(env_path=_offline_env) - - mock_llm.reset_call_log() - data_events = asyncio.run(_run_flow(metadata=metadata, prompt_server=server, env_path=_offline_env)) - mock_llm.reset_call_log() - text_events = asyncio.run( - _run_flow(metadata=metadata, prompt_server=server, channel=CHANNEL_TEXT, env_path=_offline_env) - ) - - data_metadata = _message_metadata(data_events[-1]) - text_metadata = _message_metadata(text_events[-1]) - assert data_metadata[NEGOTIATION_T_EXTENSION_URI] == text_metadata[NEGOTIATION_T_EXTENSION_URI] - assert data_metadata[TEMPLATE_URI_METADATA_KEY] == text_metadata[TEMPLATE_URI_METADATA_KEY] - data_context = dict(data_metadata[NEGOTIATION_CONTEXT_METADATA_KEY]) - text_context = dict(text_metadata[NEGOTIATION_CONTEXT_METADATA_KEY]) - assert data_context.pop("id") != text_context.pop("id") # each channel opens its own session - assert data_context == text_context - -def test_feasibility_propose_messages_are_equivalent_across_channels(_offline_env: Path) -> None: - """The from-data and from-text feasibility proposes produce equivalent messages (the mock - extraction returns the canonical content the scenario accessors mirror); only the fresh - session id differs.""" - metadata = _kind_initial_metadata( - A2ATClient(env_path=_offline_env), _offline_env, scenario_data.NEGOTIATION_KIND_FEASIBILITY - ) - server = A2ATServer(env_path=_offline_env) - mock_llm.reset_call_log() - data_events = asyncio.run(_run_flow(metadata=metadata, prompt_server=server, env_path=_offline_env)) - mock_llm.reset_call_log() - text_events = asyncio.run( - _run_flow(metadata=metadata, prompt_server=server, channel=CHANNEL_TEXT, env_path=_offline_env) - ) - data_metadata = _message_metadata(data_events[-1]) - text_metadata = _message_metadata(text_events[-1]) - assert data_metadata[NEGOTIATION_T_EXTENSION_URI] == text_metadata[NEGOTIATION_T_EXTENSION_URI] - assert data_metadata[TEMPLATE_URI_METADATA_KEY] == text_metadata[TEMPLATE_URI_METADATA_KEY] - data_context = dict(data_metadata[NEGOTIATION_CONTEXT_METADATA_KEY]) - text_context = dict(text_metadata[NEGOTIATION_CONTEXT_METADATA_KEY]) - assert data_context.pop("id") != text_context.pop("id") # each channel opens its own session - assert data_context == text_context -# --- per-type propose text renderings of the from-text channel ------------------------------- +# --- per-type propose text renderings --------------------------------------------------------- def test_information_propose_text_renders_numbered_items_and_the_relationship() -> None: @@ -591,11 +481,13 @@ def test_two_round_sequence_pins_the_mock_call_log(_offline_env: Path) -> None: _run_two_phase_flow(_offline_env) - # Initial phase: extraction of the missing input + its failing content validation; the accept: - # from-data filled-params extraction (no scenario input) + its passing content validation. + # Initial phase: extraction of the missing input + its failing content validation + the + # propose extraction; the accept: from-data filled-params extraction (no scenario input) + its + # passing content validation. assert mock_llm.llm_calls == [ mock_llm.STAGE_SLOT_EXTRACTION_MISSING, mock_llm.STAGE_CONTENT_VALIDATION_MISSING, + mock_llm.STAGE_NEGOTIATION_PROPOSE_EXTRACTION, mock_llm.STAGE_SLOT_EXTRACTION, mock_llm.STAGE_CONTENT_VALIDATION, ] @@ -900,7 +792,7 @@ async def _emit(items: list[NegotiationItem]) -> list[object]: assert NEGOTIATION_RELATIONSHIP in multiple_text -# --- channel resolution and executor plumbing ------------------------------------------------ +# --- executor plumbing ------------------------------------------------------------------------ def test_resolve_negotiation_channel_defaults_and_overrides(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -928,46 +820,4 @@ def test_resolve_negotiation_channel_defaults_and_overrides(tmp_path: Path, monk assert resolve_negotiation_channel(env_path=env_path) == CHANNEL_DATA -def test_executor_threads_the_channel_and_env_path_into_the_flow( - _offline_env: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.chdir(tmp_path) # server_main's import-time mock install reads the cwd .env - from server.server_main import SampleAgentExecutor - - captured: dict[str, object] = {} - - async def _capture_flow(**kwargs: object) -> None: - captured.update(kwargs) - - executor = SampleAgentExecutor( - prompt_server=object(), - log_sink=None, - execute_flow=_capture_flow, - channel=CHANNEL_TEXT, - env_path=_offline_env, - ) - request_context = _build_request_context(metadata={}, requested_extensions={TASK_T_EXTENSION_URI}) - asyncio.run(executor.execute(request_context, EventQueueLegacy())) - - assert captured["channel"] == CHANNEL_TEXT - assert captured["env_path"] == _offline_env - assert captured["prompt_server"] is getattr(executor, "_prompt_server") - - -def test_build_server_runtime_resolves_the_channel_from_the_env( - _offline_env: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.chdir(tmp_path) # server_main's import-time mock install reads the cwd .env - from server.server_main import build_server_runtime - - runtime = build_server_runtime(agent_card=None, env_path=_offline_env) - executor = runtime["executor"] - - # The offline .env carries no channel key -> the default data channel. - assert getattr(executor, "_channel") == CHANNEL_DATA - assert getattr(executor, "_env_path") == _offline_env - text_env_path = tmp_path / "text-channel.env" - text_env_path.write_text(_OFFLINE_ENV_TEXT.format(language="en-US") + f"{CHANNEL_ENV_KEY}=text\n", encoding="utf-8") - text_runtime = build_server_runtime(agent_card=None, env_path=text_env_path) - assert getattr(text_runtime["executor"], "_channel") == CHANNEL_TEXT From 303c34d76376d9bf48b5d26250dd85b2b4ed13b7 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 00:38:09 +0800 Subject: [PATCH 15/30] docs(sample): simplify the reject/abort section and describe the propose generation - "reject and abort" is now a short paragraph: the client may answer with reject/abort, the demo only implements accept, the SDK provides the methods when needed; the "extension point" wording and the implementation walkthrough are removed - 4.8 states that the propose is always generated from a business message and that --channel selects how the client generates its accept - the channel descriptions in the flags/config tables follow that scope - line anchors refreshed after the code removal Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 85 ++++++++----------- a2a-t-sample/ran-energy-saving/README.md | 94 +++++++++------------ 2 files changed, 72 insertions(+), 107 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 6dab6ea0..b7b79729 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -23,7 +23,7 @@ - [4.4 信息协商(information)](#44-信息协商information) - [4.5 目标协商(target)](#45-目标协商target) - [4.6 可行性协商(feasibility)](#46-可行性协商feasibility) - - [4.7 reject 与 abort —— 扩展点](#47-reject-与-abort--扩展点) + - [4.7 reject 与 abort](#47-reject-与-abort) - [4.8 从结构化数据生成,还是从自然语言生成](#48-从结构化数据生成还是从自然语言生成) - [5. 替换清单(demo 到生产)](#5-替换清单demo-到生产) - [6. 附录](#6-附录) @@ -126,7 +126,7 @@ async for stream_response in a2a_client.send_message(request, context=call_conte ### 3.3 服务端侧 `_extract_prompt_text` 从 metadata 读取提示词,`validate_task_prompt_and_data_filling` 校验并抽取 -([`server_flow.py:82`](src/server/server_flow.py#L82)、[`server_flow.py:495`](src/server/server_flow.py#L495))。 +([`server_flow.py:73`](src/server/server_flow.py#L73)、[`server_flow.py:425`](src/server/server_flow.py#L425))。 被拒绝的提示词映射为 `TASK_STATE_REJECTED`,而不是让服务崩溃;校验通过的参数进入执行接入点。 ### 3.4 client 侧要替换什么 @@ -164,7 +164,7 @@ agent 决定请求何时无法按原样执行、值得先谈一谈;client agen 判断属于哪种情况、以及到底要不要协商,是**server agent 的业务决策,SDK 不持立场** —— client 只对收到的 propose 作出反应。本样例是数据驱动的:首个内容校验错误的错误码通过 `_NEGOTIATION_TYPE_BY_ERROR_CODE` 表 -([`server_flow.py:64`](src/server/server_flow.py#L64))选出类型。这张表是**样例约定,不是 SDK +([`server_flow.py:55`](src/server/server_flow.py#L55))选出类型。这张表是**样例约定,不是 SDK 契约** —— server 实现可随意改自己的副本(重映射、新增、或干脆不用映射、按自己的业务规则判断; 未映射的失败就是普通拒绝)。 @@ -228,17 +228,15 @@ flow 根据 client 侧的决策重新生成 Task-T 提示词、构建带同轮 ` | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | -| 服务端 propose | `generate_negotiation_propose_prompt_from_data` | [`server_flow.py:208`](src/server/server_flow.py#L208) | +| 服务端 propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | | 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:752`](src/client/client_flow.py#L752) | | 客户端 accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | ```python -# 服务端:用类型化内容渲染 propose -propose = server.generate_negotiation_propose_prompt_from_data( - NegotiationProposeData( - context, - InformationProposeContent(items=missing_items, relationship=None), - ), +# 服务端:把 propose 写成业务报文,再经 from-text API 渲染 +propose = server.generate_negotiation_propose_prompt_from_text( + propose_text, # 缺失的参数,写成编号列表 + context, INFORMATION_NEGOTIATION_PROPOSE_URI, ) @@ -279,21 +277,15 @@ class MyProvider: | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | -| 服务端 propose | `generate_negotiation_propose_prompt_from_data` | [`server_flow.py:208`](src/server/server_flow.py#L208) | +| 服务端 propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | | 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:752`](src/client/client_flow.py#L752) | | 客户端 accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | ```python -# 服务端:复述意图并请求调整时间 -propose = server.generate_negotiation_propose_prompt_from_data( - NegotiationProposeData( - context, - TargetProposeContent( - target_negotiation_description=description, - intent_understanding=intent_items, - request_for_clarification=clarification_items, - ), - ), +# 服务端:把 propose 写成业务报文,再经 from-text API 渲染 +propose = server.generate_negotiation_propose_prompt_from_text( + propose_text, # 意图复述 + 待澄清请求 + context, TARGET_NEGOTIATION_PROPOSE_URI, ) @@ -325,21 +317,15 @@ accept = client.generate_negotiation_accept_prompt_from_data( | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | -| 服务端 propose | `generate_negotiation_propose_prompt_from_data` | [`server_flow.py:208`](src/server/server_flow.py#L208) | +| 服务端 propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | | 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:752`](src/client/client_flow.py#L752) | | 客户端 accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | ```python -# 服务端:上报不可行结论并附替代提案 -propose = server.generate_negotiation_propose_prompt_from_data( - NegotiationProposeData( - context, - FeasibilityProposeContent( - feasibility_negotiation_description=description, - action=PROPOSE_ALTERNATIVE_ON_FAILURE, - infeasibility_details_and_proposal=infeasibility_items, - ), - ), +# 服务端:把 propose 写成业务报文,再经 from-text API 渲染 +propose = server.generate_negotiation_propose_prompt_from_text( + propose_text, # 不可行结论 + 替代提案 + context, FEASIBILITY_NEGOTIATION_PROPOSE_URI, ) @@ -363,25 +349,22 @@ accept = client.generate_negotiation_accept_prompt_from_data( **④ client 侧必须自己实现的部分。** 应答内容 `feasibility_summary` —— 即 client 对服务端 提案的答复。 -### 4.7 reject 与 abort —— 扩展点 +### 4.7 reject 与 abort -`reject`/`abort` 决策**在本 demo 中未实现**:flow 打日志 -`negotiation-decision: {conclusion} (not implemented in this demo...)` 后结束、不发送任何报文。扩展点是 -[`client_flow.py:752`](src/client/client_flow.py#L752) 中 `_plan_negotiation_accept` 的 reject/abort -分支:扩展它、仿照 `_run_negotiation_accept`([`client_flow.py:870`](src/client/client_flow.py#L870)) -—— 以 `session.next_round().with_performative(NegotiationPerformative.REJECT)` 推进会话,构建该 -类型的结束内容、conclusion 置为 `Reject`(决策的 `reason` 写入结束内容的 `failure_reason`),再用 -SDK 的 `generate_negotiation_reject_prompt_from_data` / `..._from_text` 生成报文。abort 与类型无关: -`NegotiationAbortContent(termination_reason)` 经 `generate_negotiation_abort_prompt_from_data` 渲染到 -公共的 `Negotiation-T/common/abort/v1` 模板。 +客户端也可以用 `reject` 或 `abort` 回应 propose,而不只是 `accept`。本样例只实现了 accept 路径: +reject/abort 决策只会打日志并结束流程,不发送报文。需要发送时,SDK 提供对应的生成方法 +(`generate_negotiation_reject_prompt_from_data` / `..._from_text`,以及 +`generate_negotiation_abort_prompt_from_data` / `..._from_text`)。 ### 4.8 从结构化数据生成,还是从自然语言生成 -协商消息有两种产生方式,最终报文格式完全一致: +**propose 固定按业务报文生成**:服务端写好文本,SDK 抽取成结构化内容后渲染模板(一次抽取调用)。 + +**accept** 两种方式都支持,由 `--channel` 选择: -- **从结构化数据生成(默认)**:在代码里构造类型化内容,SDK 直接渲染模板 —— 确定性,不调用模型。 -- **从自然语言生成**(`--channel fromText`):先写一段自然语言,SDK 让模型抽取成结构化内容,再渲染 - 模板 —— 每条生成的消息多一次抽取调用。 +- **从结构化数据生成(默认)**:client 在代码里构造类型化内容,SDK 直接渲染模板,不调用模型。 +- **从自然语言生成**(`--channel fromText`):client 把 accept 写成一段自然语言,SDK 先让模型抽取成 + 结构化内容,再渲染模板。 ## 5. 替换清单(demo 到生产) @@ -392,13 +375,13 @@ SDK 的 `generate_negotiation_reject_prompt_from_data` / `..._from_text` 生成 | --- | --- | --- | --- | | mock LLM —— [`resources/mock_llm/`](resources/mock_llm) + `src/common/` 中按 schema 路由的 mock | 两端 | 脚本化的槽位抽取与内容校验应答,`A2AT_LLM_API_KEY` 为空时启用 | CI/离线运行**保留**;生产中两端各自移除并配置真实的 OpenAI 兼容端点 | | 脚本化执行数据 —— [`src/server/constants_data.py`](src/server/constants_data.py) | server | demo 执行流式推送的 UC1 进度步骤与意图报告 | server agent 经 `TaskExecutionService` 接入点**替换**([`src/server/task_execution.py`](src/server/task_execution.py));它的服务装配后,demo 服务与数据即可删除 | -| 脚本化应答 —— [`src/client/scenario_data.py`](src/client/scenario_data.py) | 两端(见下) | 同时喂两侧 | 两侧都要**替换**:client 侧应答经自己的 `NegotiationResponseProvider`;server 侧在其副本的 `server_flow._negotiation_propose_data` 中改为自己的业务措辞 | +| 脚本化应答 —— [`src/client/scenario_data.py`](src/client/scenario_data.py) | 两端(见下) | 同时喂两侧 | 两侧都要**替换**:client 侧应答经自己的 `NegotiationResponseProvider`;server 侧在其副本的 `server_flow._negotiation_propose_text` 中改为自己的业务措辞 | | mock 注册中心 —— [`src/registry/`](src/registry)(端口 5001) | client | AgentCard 注册/查询,用于发现 | **替换**为 client 侧的注册中心,或绕过发现、直接从已知 AgentCard 构造 `a2a-sdk` 客户端 | | 静态 webhook token —— `A2AT_SAMPLE_WEBHOOK_TOKEN`(默认 `demo-webhook-token`) | 两端 | `X-A2A-Notification-Token` 头中的共享静态 token | **替换**为生产鉴权(逐对密钥、轮换、TLS);把接收器当作暴露在外的 HTTP 端点对待 | > **`scenario_data` 同时喂两侧** —— 客户端的脚本化应答(补全参数、确认意图、可行性摘要)经 -> provider 接入点使用;服务端的 propose 内容也由同一模块构建:`_negotiation_propose_data` -> ([`server_flow.py:208`](src/server/server_flow.py#L208)) 用其访问器填充 target/feasibility 的 +> provider 接入点使用;服务端的 propose 文本也由同一模块构建:`_negotiation_propose_text` +> ([`server_flow.py:162`](src/server/server_flow.py#L162)) 用其访问器填充 target/feasibility 的 > propose 文案。只替换 provider 与执行服务,服务端仍会用 demo 的预制文案发起 propose —— > 该模块不可删除。 @@ -447,7 +430,7 @@ webhook 模式下的变化:客户端非阻塞发送(`return_immediately`) | `A2AT_LANGUAGE` | `zh-CN` / `en-US`;决定自然语言输入与 mock/模板语言树 | | `A2AT_SAMPLE_SCENARIO` | `default` / `negotiation`;样例场景变体(与 `--scenario` 语义相同) | | `A2AT_SAMPLE_NEGOTIATION_KIND` | `information` / `target` / `feasibility`(默认 `information`);协商场景输入,与 `--negotiation` 语义相同 | -| `A2AT_SAMPLE_CHANNEL` | `data` / `text`(默认 `data`);协商消息是从结构化数据生成还是从自然语言生成,与 `--channel` 语义相同 | +| `A2AT_SAMPLE_CHANNEL` | `data` / `text`(默认 `data`);client 的 accept 消息从结构化数据生成还是从自然语言生成,与 `--channel` 语义相同 | | `A2AT_SAMPLE_TRANSPORT` | `sse` / `webhook`(默认 `sse`);客户端事件传输方式,与 `--transport` 语义相同 | | `A2AT_SAMPLE_WEBHOOK_PORT` | 内嵌 webhook 接收器的绑定端口(默认 `9000`;`0` = 操作系统分配临时端口) | | `A2AT_SAMPLE_WEBHOOK_TOKEN` | 服务端在 `X-A2A-Notification-Token` 头中必须携带的推送 token(默认 `demo-webhook-token`);不匹配回 `401` | @@ -507,7 +490,7 @@ uv run --no-sync python -m client.client_main | `--language {en-US,zh-CN}` | 运行语言(默认 `en-US`) | | `--scenario {default,negotiation}` | 场景变体(默认 `default`;`negotiation` 运行缺失 region 的信息协商) | | `--negotiation {information,target,feasibility}` | 协商类型,选择客户端场景输入(默认 `information` = 缺失 region;`target` = 分时段保障速率目标中两个时段重叠;`feasibility` = 保障速率无法支撑 18 小时停电)。`information` 仍受 `--scenario` 开关控制(只有 `--scenario negotiation` 运行缺失 region 流程);`target`/`feasibility` 无需该开关即可触发。该参数只选择输入 —— 服务端依据内容校验错误码数据驱动地分派 | -| `--channel {fromData,fromText}` | 协商消息从结构化数据生成(`fromData`,默认)还是从自然语言生成(`fromText`,每条消息多一次抽取模型调用) | +| `--channel {fromData,fromText}` | client 的 accept 消息怎么生成:从结构化数据(`fromData`,默认)或从自然语言(`fromText`,多一次抽取模型调用) | | `--transport {sse,webhook}` | 客户端的事件传输方式(默认 `sse`);见 [§6.1](#61-webhook-传输) | | `--webhook-port N` | `--transport webhook` 时内嵌 webhook 接收器的绑定端口(默认 9000;`0` 表示由操作系统分配临时端口) | | `--keep-alive` | 保留注册中心/服务端不停止;之后用 `--stop` 关闭 | diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 5889fa6e..9f0d5360 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -25,7 +25,7 @@ unspecified reader. - [4.4 Information negotiation](#44-information-negotiation) - [4.5 Target negotiation](#45-target-negotiation) - [4.6 Feasibility negotiation](#46-feasibility-negotiation) - - [4.7 Reject and abort — the extension point](#47-reject-and-abort--the-extension-point) + - [4.7 Reject and abort](#47-reject-and-abort) - [4.8 Generating messages from structured data or from natural language](#48-generating-messages-from-structured-data-or-from-natural-language) - [5. Replace checklist (demo → production)](#5-replace-checklist-demo--production) - [6. Appendix](#6-appendix) @@ -131,7 +131,7 @@ async for stream_response in a2a_client.send_message(request, context=call_conte ### 3.3 Server side `_extract_prompt_text` reads the metadata and `validate_task_prompt_and_data_filling` validates and -extracts ([`server_flow.py:82`](src/server/server_flow.py#L82), [`server_flow.py:495`](src/server/server_flow.py#L495)). +extracts ([`server_flow.py:73`](src/server/server_flow.py#L73), [`server_flow.py:425`](src/server/server_flow.py#L425)). A rejected prompt is mapped to `TASK_STATE_REJECTED` instead of crashing the server; the validated parameters go into the execution integration point. @@ -181,7 +181,7 @@ Which situation applies — and whether to negotiate at all — is **the server decision; the SDK has no opinion** — and the client only reacts to the propose it receives. In this demo it is data-driven: the first content-validation error's code selects the kind, via the `_NEGOTIATION_TYPE_BY_ERROR_CODE` table -([`server_flow.py:64`](src/server/server_flow.py#L64)). That table is a **sample convention, not an +([`server_flow.py:55`](src/server/server_flow.py#L55)). That table is a **sample convention, not an SDK contract** — the server implementation edits its copy freely (remap codes, add its own, or drop the mapping and decide from its own business rules; an unmapped failure is simply rejected). @@ -256,17 +256,15 @@ that code is the sample's choice, not part of the protocol. | Step | SDK call | Reference code | | --- | --- | --- | -| Server propose | `generate_negotiation_propose_prompt_from_data` | [`server_flow.py:208`](src/server/server_flow.py#L208) | +| Server propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | | Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:752`](src/client/client_flow.py#L752) | | Client accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | ```python -# server: render the propose from typed content -propose = server.generate_negotiation_propose_prompt_from_data( - NegotiationProposeData( - context, - InformationProposeContent(items=missing_items, relationship=None), - ), +# server: write the propose as a business message, then render it through the from-text API +propose = server.generate_negotiation_propose_prompt_from_text( + propose_text, # the missing parameters, written as a numbered list + context, INFORMATION_NEGOTIATION_PROPOSE_URI, ) @@ -310,21 +308,15 @@ ambiguous. The demo's trigger is a `content.value_not_allowed` validation error. | Step | SDK call | Reference code | | --- | --- | --- | -| Server propose | `generate_negotiation_propose_prompt_from_data` | [`server_flow.py:208`](src/server/server_flow.py#L208) | +| Server propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | | Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:752`](src/client/client_flow.py#L752) | | Client accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | ```python -# server: restate the intent and ask for the time adjustment -propose = server.generate_negotiation_propose_prompt_from_data( - NegotiationProposeData( - context, - TargetProposeContent( - target_negotiation_description=description, - intent_understanding=intent_items, - request_for_clarification=clarification_items, - ), - ), +# server: write the propose as a business message, then render it through the from-text API +propose = server.generate_negotiation_propose_prompt_from_text( + propose_text, # intent restatement + the clarification request + context, TARGET_NEGOTIATION_PROPOSE_URI, ) @@ -360,21 +352,15 @@ trigger is a `content.rule_violation` validation error. | Step | SDK call | Reference code | | --- | --- | --- | -| Server propose | `generate_negotiation_propose_prompt_from_data` | [`server_flow.py:208`](src/server/server_flow.py#L208) | +| Server propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | | Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:752`](src/client/client_flow.py#L752) | | Client accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | ```python -# server: report the assessment as infeasible and attach the alternative proposal -propose = server.generate_negotiation_propose_prompt_from_data( - NegotiationProposeData( - context, - FeasibilityProposeContent( - feasibility_negotiation_description=description, - action=PROPOSE_ALTERNATIVE_ON_FAILURE, - infeasibility_details_and_proposal=infeasibility_items, - ), - ), +# server: write the propose as a business message, then render it through the from-text API +propose = server.generate_negotiation_propose_prompt_from_text( + propose_text, # infeasibility conclusion + the alternative proposal + context, FEASIBILITY_NEGOTIATION_PROPOSE_URI, ) @@ -399,29 +385,25 @@ handling, transport. **④ What the client implementation must provide.** The answer — `feasibility_summary`, i.e. how the client responds to the server's proposal. -### 4.7 Reject and abort — the extension point +### 4.7 Reject and abort -A `reject`/`abort` decision is **not implemented in this demo**: the flow logs -`negotiation-decision: {conclusion} (not implemented in this demo...)` and ends without sending. The -extension point is the reject/abort branch of `_plan_negotiation_accept` in -[`client_flow.py:752`](src/client/client_flow.py#L752): extend it to mirror -`_run_negotiation_accept` ([`client_flow.py:870`](src/client/client_flow.py#L870)) — advance the -session with `session.next_round().with_performative(NegotiationPerformative.REJECT)`, build the -proposed kind's ending content with conclusion `Reject` (the decision's `reason` becomes the -ending content's `failure_reason`), and generate the message with the SDK's -`generate_negotiation_reject_prompt_from_data` / `..._from_text`. Abort is kind-independent: -`NegotiationAbortContent(termination_reason)` renders against the common -`Negotiation-T/common/abort/v1` template via `generate_negotiation_abort_prompt_from_data`. +The client can also answer a propose with `reject` or `abort` instead of `accept`. This demo only +implements the accept path: a reject/abort decision is logged and the flow ends without sending a +message. The SDK provides the matching generation methods +(`generate_negotiation_reject_prompt_from_data` / `..._from_text`, and +`generate_negotiation_abort_prompt_from_data` / `..._from_text`) when a client needs to send one. ### 4.8 Generating messages from structured data or from natural language -There are two ways to produce a negotiation message, and both produce the same message format: +The **propose** is always written as a business message: the server composes the text, and the SDK +extracts the structured content and renders the template (one extraction call). + +The **accept** can be generated either way, selected by `--channel`: -- **from structured data (default).** The implementation builds the typed content in code and the - SDK renders the template directly — deterministic, no model call. -- **from natural language** (`--channel fromText`). The implementation writes the message as plain text and the SDK - asks the model to extract the structured content first, then renders the template — one extra - extraction call per generated message. +- **from structured data (default)** — the client builds the typed content in code; the SDK renders + the template directly, with no model call. +- **from natural language** (`--channel fromText`) — the client writes the accept as plain text; the + SDK asks the model to extract the structured content first, then renders the template. ## 5. Replace checklist (demo → production) @@ -432,14 +414,14 @@ the server rows show what the counterpart replaces when the server is a real sys | --- | --- | --- | --- | | mock LLM — [`resources/mock_llm/`](resources/mock_llm) + the schema-routed mock in `src/common/` | both | scripted slot-extraction and content-validation replies, selected when `A2AT_LLM_API_KEY` is empty | **KEEP** for CI/offline runs; in production each side removes it and configures a real OpenAI-compatible endpoint | | scripted execution data — [`src/server/constants_data.py`](src/server/constants_data.py) | server | the UC1 progress steps and intent report the demo execution streams | **REPLACE** by the server agent via the `TaskExecutionService` integration point ([`src/server/task_execution.py`](src/server/task_execution.py)); once its service is assembled, the demo service and data can be deleted | -| scripted answers — [`src/client/scenario_data.py`](src/client/scenario_data.py) | both (see below) | feeds BOTH sides | **REPLACE** on both sides: the client answers via its `NegotiationResponseProvider`; the server's propose texts in its copy of `server_flow._negotiation_propose_data` become its business phrasing | +| scripted answers — [`src/client/scenario_data.py`](src/client/scenario_data.py) | both (see below) | feeds BOTH sides | **REPLACE** on both sides: the client answers via its `NegotiationResponseProvider`; the server's propose texts in its copy of `server_flow._negotiation_propose_text` become its business phrasing | | mock registry — [`src/registry/`](src/registry) (port 5001) | client | AgentCard register/query for discovery | **REPLACE** with the client's registry center, or bypass discovery and construct the `a2a-sdk` client from a known AgentCard | | static webhook token — `A2AT_SAMPLE_WEBHOOK_TOKEN` (default `demo-webhook-token`) | both | shared static token in the `X-A2A-Notification-Token` header | **REPLACE** with production auth (per-pair secrets, rotation, TLS); treat the receiver as an exposed HTTP endpoint | > **`scenario_data` feeds BOTH sides** — the client's scripted answers (filled parameters, -> confirmed intent, feasibility summary) go through the provider integration point, and the server's propose -> content is built from the same module: `_negotiation_propose_data` -> ([`server_flow.py:208`](src/server/server_flow.py#L208)) fills the target/feasibility propose +> confirmed intent, feasibility summary) go through the provider integration point, and the server's +> propose text is built from the same module: `_negotiation_propose_text` +> ([`server_flow.py:162`](src/server/server_flow.py#L162)) fills the target/feasibility propose > texts from its accessors. Replacing only the provider and the execution service still leaves > the server proposing the demo's canned texts — the module is not removable. @@ -497,7 +479,7 @@ transports. | `A2AT_LANGUAGE` | `zh-CN` / `en-US`; selects the natural-language input and the mock/template tree | | `A2AT_SAMPLE_SCENARIO` | `default` / `negotiation`; selects the sample scenario variant (same semantics as `--scenario`) | | `A2AT_SAMPLE_NEGOTIATION_KIND` | `information` / `target` / `feasibility` (default `information`); negotiation scenario input, same semantics as `--negotiation` | -| `A2AT_SAMPLE_CHANNEL` | `data` / `text` (default `data`); whether negotiation messages are generated from structured data or from natural language, same semantics as `--channel` | +| `A2AT_SAMPLE_CHANNEL` | `data` / `text` (default `data`); how the client generates its accept message — from structured data or from natural language, same semantics as `--channel` | | `A2AT_SAMPLE_TRANSPORT` | `sse` / `webhook` (default `sse`); client event transport, same semantics as `--transport` | | `A2AT_SAMPLE_WEBHOOK_PORT` | Bind port of the embedded webhook receiver (default `9000`; `0` = OS-assigned ephemeral port) | | `A2AT_SAMPLE_WEBHOOK_TOKEN` | Push token the server must carry in the `X-A2A-Notification-Token` header (default `demo-webhook-token`); a mismatch answers `401` | @@ -560,7 +542,7 @@ content-validation responses of [`resources/mock_llm/{zh-CN,en-US}/`](resources/ | `--language {en-US,zh-CN}` | Run language (default `en-US`) | | `--scenario {default,negotiation}` | Scenario variant (default `default`; `negotiation` runs the missing-region information negotiation) | | `--negotiation {information,target,feasibility}` | Negotiation kind selecting the client scenario input (default `information` = missing region; `target` = two overlapping time segments in the guaranteed throughput target; `feasibility` = the guaranteed rate cannot sustain an 18-hour power outage). `information` keeps the `--scenario` switch (only `--scenario negotiation` runs the missing-region flow); `target`/`feasibility` engage without it. The flag only selects the input — the server stays data-driven on the validation error code | -| `--channel {fromData,fromText}` | Whether negotiation messages are generated from structured data (`fromData`, default) or from natural language (`fromText`, one extra extraction model call per message) | +| `--channel {fromData,fromText}` | How the client generates its accept message: from structured data (`fromData`, default) or from natural language (`fromText`, one extra extraction model call) | | `--transport {sse,webhook}` | Event transport of the client (default `sse`); see [§6.1](#61-webhook-transport) | | `--webhook-port N` | Bind port of the embedded webhook receiver used with `--transport webhook` (default 9000; `0` selects an OS-assigned ephemeral port) | | `--keep-alive` | Leave registry/server running; stop later with `--stop` | From 2702c6d83b359aca8d7cf1481eea1258793729ba Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 00:46:28 +0800 Subject: [PATCH 16/30] docs(sample): say which SDK owns which part The boundary statement said "the SDK handles the protocol details", but two SDKs are involved: the A2A-T SDK generates and validates the negotiation messages, and the A2A SDK carries them. The A2A-T SDK has no transport layer of its own. State that once at the chapter boundary and use "the A2A-T SDK" in the places that refer to message generation only. Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 18 ++++++++++-------- a2a-t-sample/ran-energy-saving/README.md | 18 ++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index b7b79729..0705b984 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -139,8 +139,10 @@ async for stream_response in a2a_client.send_message(request, context=call_conte ## 4. 使用 A2A-T SDK 实现 Negotiation-T 交互 -一句话分界:SDK 拥有报文 —— 渲染、校验、协商会话映射、传输机制。业务由两端各自负责:server -agent 决定请求何时无法按原样执行、值得先谈一谈;client agent 决定如何应答收到的 propose。 +一句话分界:报文由两个 SDK 共同承担 —— **A2A-T SDK** 负责生成与校验协商消息(渲染、协商会话 +映射),**A2A SDK** 负责把它们传输出去。A2A-T SDK 本身不含传输层,所以部署时总是两者配合使用。 +业务由两端各自负责:server agent 决定请求何时无法按原样执行、值得先谈一谈;client agent 决定如何 +应答收到的 propose。 ### 4.1 什么时候会发生协商 @@ -161,7 +163,7 @@ agent 决定请求何时无法按原样执行、值得先谈一谈;client agen ### 4.2 样例如何判断属于哪一种 -判断属于哪种情况、以及到底要不要协商,是**server agent 的业务决策,SDK 不持立场** —— client +判断属于哪种情况、以及到底要不要协商,是**server agent 的业务决策,两个 SDK 都不持立场** —— client 只对收到的 propose 作出反应。本样例是数据驱动的:首个内容校验错误的错误码通过 `_NEGOTIATION_TYPE_BY_ERROR_CODE` 表 ([`server_flow.py:55`](src/server/server_flow.py#L55))选出类型。这张表是**样例约定,不是 SDK @@ -197,7 +199,7 @@ abort。角色只决定谁发 propose;报文形态、会话处理与传输完 4. 服务端 → 客户端:结果 —— 先进度,后报告。 会话(`id` / `round` / `maxRounds` / performative)随每条协商消息放在 `negotiationContext` metadata -中,因此 SDK 本身无状态。 +中,因此 A2A-T SDK 本身无状态。 **client 侧要接的地方。** 收到第 2 条报文时,客户端 flow 构建一个轻量的 `NegotiationProposeView`(kind、 `templateUri`、渲染文本)并调用([`negotiation_response.py`](src/client/negotiation_response.py)): @@ -352,18 +354,18 @@ accept = client.generate_negotiation_accept_prompt_from_data( ### 4.7 reject 与 abort 客户端也可以用 `reject` 或 `abort` 回应 propose,而不只是 `accept`。本样例只实现了 accept 路径: -reject/abort 决策只会打日志并结束流程,不发送报文。需要发送时,SDK 提供对应的生成方法 +reject/abort 决策只会打日志并结束流程,不发送报文。需要发送时,A2A-T SDK 提供对应的生成方法 (`generate_negotiation_reject_prompt_from_data` / `..._from_text`,以及 `generate_negotiation_abort_prompt_from_data` / `..._from_text`)。 ### 4.8 从结构化数据生成,还是从自然语言生成 -**propose 固定按业务报文生成**:服务端写好文本,SDK 抽取成结构化内容后渲染模板(一次抽取调用)。 +**propose 固定按业务报文生成**:服务端写好文本,A2A-T SDK 抽取成结构化内容后渲染模板(一次抽取调用)。 **accept** 两种方式都支持,由 `--channel` 选择: -- **从结构化数据生成(默认)**:client 在代码里构造类型化内容,SDK 直接渲染模板,不调用模型。 -- **从自然语言生成**(`--channel fromText`):client 把 accept 写成一段自然语言,SDK 先让模型抽取成 +- **从结构化数据生成(默认)**:client 在代码里构造类型化内容,A2A-T SDK 直接渲染模板,不调用模型。 +- **从自然语言生成**(`--channel fromText`):client 把 accept 写成一段自然语言,A2A-T SDK 先让模型抽取成 结构化内容,再渲染模板。 ## 5. 替换清单(demo 到生产) diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 9f0d5360..ab7ecaab 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -147,10 +147,12 @@ sample-specific, so a client implementation brings its own ## 4. Using the A2A-T SDK to implement Negotiation-T interaction -The boundary in one sentence: the SDK handles the protocol details — rendering, validation, the -negotiation session map, the transport mechanics. The two agents own the business, each on its own side: the -server agent decides when a request cannot be carried out as-is and a conversation would settle it; -the client agent decides how to answer the propose. +The boundary in one sentence: the SDKs handle the protocol details — the **A2A-T SDK** generates and +validates the negotiation messages (rendering, the negotiation session map), and the **A2A SDK** +carries them (the transport). The A2A-T SDK has no transport layer of its own, so a deployment always +pairs the two. The two agents own the business, each on its own side: the server agent decides when +a request cannot be carried out as-is and a conversation would settle it; the client agent decides +how to answer the propose. ### 4.1 When a negotiation happens @@ -178,7 +180,7 @@ client accepts the adjusted target. ### 4.2 How the demo decides which one applies Which situation applies — and whether to negotiate at all — is **the server agent's business -decision; the SDK has no opinion** — and the client only reacts to the propose it receives. In this +decision; the SDKs have no opinion** — and the client only reacts to the propose it receives. In this demo it is data-driven: the first content-validation error's code selects the kind, via the `_NEGOTIATION_TYPE_BY_ERROR_CODE` table ([`server_flow.py:55`](src/server/server_flow.py#L55)). That table is a **sample convention, not an @@ -221,7 +223,7 @@ server side. 4. server → client: the result — progress, then the report. The session (`id` / `round` / `maxRounds` / performative) travels with every negotiation message in -the `negotiationContext` metadata map, so the SDK itself stays stateless. +the `negotiationContext` metadata map, so the A2A-T SDK itself stays stateless. **Where the client's decision plugs in.** When message 2 arrives, the client flow builds a lightweight `NegotiationProposeView` (kind, `templateUri`, rendered text) and calls the provider @@ -395,12 +397,12 @@ message. The SDK provides the matching generation methods ### 4.8 Generating messages from structured data or from natural language -The **propose** is always written as a business message: the server composes the text, and the SDK +The **propose** is always written as a business message: the server composes the text, and the A2A-T SDK extracts the structured content and renders the template (one extraction call). The **accept** can be generated either way, selected by `--channel`: -- **from structured data (default)** — the client builds the typed content in code; the SDK renders +- **from structured data (default)** — the client builds the typed content in code; the A2A-T SDK renders the template directly, with no model call. - **from natural language** (`--channel fromText`) — the client writes the accept as plain text; the SDK asks the model to extract the structured content first, then renders the template. From 82632543b70b49d3bacfabbabd9a7fc5f2155f2b Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 00:52:20 +0800 Subject: [PATCH 17/30] docs(sample): start the example comments with the step names The per-kind code blocks now open each part with the same wording as the step table (Server propose / Client decision / Client accept), and the client part is split in two so all three rows have a matching comment. Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 18 ++++++++++++------ a2a-t-sample/ran-energy-saving/README.md | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 0705b984..9f8c64b4 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -235,15 +235,17 @@ flow 根据 client 侧的决策重新生成 Task-T 提示词、构建带同轮 ` | 客户端 accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | ```python -# 服务端:把 propose 写成业务报文,再经 from-text API 渲染 +# 服务端 propose:把业务报文写成文本,再经 from-text API 渲染 propose = server.generate_negotiation_propose_prompt_from_text( propose_text, # 缺失的参数,写成编号列表 context, INFORMATION_NEGOTIATION_PROPOSE_URI, ) -# 客户端:先决策,再根据决策渲染 accept +# 客户端决策:询问 provider 如何应答 propose decision = provider.respond(propose_view, session) # filled_params = 要补的取值 + +# 客户端 accept:根据决策渲染 accept accept = client.generate_negotiation_accept_prompt_from_data( NegotiationEndingData( context, @@ -284,15 +286,17 @@ class MyProvider: | 客户端 accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | ```python -# 服务端:把 propose 写成业务报文,再经 from-text API 渲染 +# 服务端 propose:把业务报文写成文本,再经 from-text API 渲染 propose = server.generate_negotiation_propose_prompt_from_text( propose_text, # 意图复述 + 待澄清请求 context, TARGET_NEGOTIATION_PROPOSE_URI, ) -# 客户端:先决策,再确认修正后的目标 +# 客户端决策:询问 provider 如何应答 propose decision = provider.respond(propose_view, session) # confirmed_intent = 修正后的目标 + +# 客户端 accept:根据决策渲染 accept accept = client.generate_negotiation_accept_prompt_from_data( NegotiationEndingData( context, @@ -324,15 +328,17 @@ accept = client.generate_negotiation_accept_prompt_from_data( | 客户端 accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | ```python -# 服务端:把 propose 写成业务报文,再经 from-text API 渲染 +# 服务端 propose:把业务报文写成文本,再经 from-text API 渲染 propose = server.generate_negotiation_propose_prompt_from_text( propose_text, # 不可行结论 + 替代提案 context, FEASIBILITY_NEGOTIATION_PROPOSE_URI, ) -# 客户端:先决策,再应答同意的调整 +# 客户端决策:询问 provider 如何应答 propose decision = provider.respond(propose_view, session) # feasibility_summary = 应答内容 + +# 客户端 accept:根据决策渲染 accept accept = client.generate_negotiation_accept_prompt_from_data( NegotiationEndingData( context, diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index ab7ecaab..8fdca9eb 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -263,15 +263,17 @@ that code is the sample's choice, not part of the protocol. | Client accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | ```python -# server: write the propose as a business message, then render it through the from-text API +# Server propose: write the business message, then render it through the from-text API propose = server.generate_negotiation_propose_prompt_from_text( propose_text, # the missing parameters, written as a numbered list context, INFORMATION_NEGOTIATION_PROPOSE_URI, ) -# client: decide, then render the accept from that decision +# Client decision: ask the provider how to answer the propose decision = provider.respond(propose_view, session) # filled_params = the values to deliver + +# Client accept: render the typed accept from the decision accept = client.generate_negotiation_accept_prompt_from_data( NegotiationEndingData( context, @@ -315,15 +317,17 @@ ambiguous. The demo's trigger is a `content.value_not_allowed` validation error. | Client accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | ```python -# server: write the propose as a business message, then render it through the from-text API +# Server propose: write the business message, then render it through the from-text API propose = server.generate_negotiation_propose_prompt_from_text( propose_text, # intent restatement + the clarification request context, TARGET_NEGOTIATION_PROPOSE_URI, ) -# client: decide, then confirm the corrected target +# Client decision: ask the provider how to answer the propose decision = provider.respond(propose_view, session) # confirmed_intent = the corrected target + +# Client accept: render the typed accept from the decision accept = client.generate_negotiation_accept_prompt_from_data( NegotiationEndingData( context, @@ -359,15 +363,17 @@ trigger is a `content.rule_violation` validation error. | Client accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | ```python -# server: write the propose as a business message, then render it through the from-text API +# Server propose: write the business message, then render it through the from-text API propose = server.generate_negotiation_propose_prompt_from_text( propose_text, # infeasibility conclusion + the alternative proposal context, FEASIBILITY_NEGOTIATION_PROPOSE_URI, ) -# client: decide, then answer with the agreed adjustment +# Client decision: ask the provider how to answer the propose decision = provider.respond(propose_view, session) # feasibility_summary = the answer + +# Client accept: render the typed accept from the decision accept = client.generate_negotiation_accept_prompt_from_data( NegotiationEndingData( context, From b5a5702cbb492a80c9c3eb250abe35f61119ac33 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 01:09:07 +0800 Subject: [PATCH 18/30] refactor(sample): generate the accept from a natural-language message The client writes the accept as a business message ("Accept." plus the values, the confirmed intent or the feasibility summary) and generates it through generate_negotiation_accept_prompt_from_text, mirroring the propose. With both messages now generated from text, the generation-channel switch has no remaining effect: remove --channel, A2AT_SAMPLE_CHANNEL, the channel resolvers and the from-data accept builder. The client flow, assembly, launcher and tests drop the channel plumbing; the accept assertions now pin the rendered text and the extraction call. Signed-off-by: codetalks-hw --- a2a-t-sample/env.example | 6 - a2a-t-sample/ran-energy-saving/run_demo.py | 30 +--- .../src/client/client_flow.py | 83 ++-------- .../src/client/client_main.py | 8 +- .../src/client/scenario_data.py | 30 +--- .../src/server/constants_data.py | 26 ---- .../test/test_negotiation_client_flow.py | 146 ++++++------------ .../test/test_negotiation_scenario.py | 90 ++--------- .../test/test_negotiation_server_flow.py | 31 ---- .../test/test_webhook_client_flow.py | 76 +++------ 10 files changed, 108 insertions(+), 418 deletions(-) diff --git a/a2a-t-sample/env.example b/a2a-t-sample/env.example index d0aa68db..c3d525e9 100644 --- a/a2a-t-sample/env.example +++ b/a2a-t-sample/env.example @@ -21,12 +21,6 @@ A2AT_SAMPLE_SCENARIO=default # (run_demo.py --negotiation writes this key into the per-run .env) A2AT_SAMPLE_NEGOTIATION_KIND=information -# ran-energy-saving generation channel of the negotiation propose/accept messages: "data" -# generates them from typed content (no extraction LLM call), "text" from a natural-language -# rendering plus one extraction LLM call -# (run_demo.py --channel writes this key into the per-run .env) -A2AT_SAMPLE_CHANNEL=data - # ran-energy-saving event transport: "sse" consumes the streamed response, "webhook" embeds the # client-side webhook receiver and receives the task events as push-notification callbacks # (run_demo.py --transport writes this key into the per-run .env) diff --git a/a2a-t-sample/ran-energy-saving/run_demo.py b/a2a-t-sample/ran-energy-saving/run_demo.py index 4afcaa02..dc36bb15 100644 --- a/a2a-t-sample/ran-energy-saving/run_demo.py +++ b/a2a-t-sample/ran-energy-saving/run_demo.py @@ -11,11 +11,11 @@ the terminal as it happens, so the server prints when the server runs and the client prints when the client runs. -The language, the sample scenario, the negotiation kind, the generation channel and the event -transport default to English / ``default`` / ``information`` / ``fromData`` / ``sse`` and are +The language, the sample scenario, the negotiation kind and the event transport default to +English / ``default`` / ``information`` / ``sse`` and are applied without touching the shared ``a2a-t-sample/.env``: the launcher writes a temporary ``.env`` (a copy of the shared one with ``A2AT_LANGUAGE``, ``A2AT_SAMPLE_SCENARIO``, -``A2AT_SAMPLE_NEGOTIATION_KIND``, ``A2AT_SAMPLE_CHANNEL``, ``A2AT_SAMPLE_TRANSPORT`` and +``A2AT_SAMPLE_NEGOTIATION_KIND``, ``A2AT_SAMPLE_TRANSPORT`` and ``A2AT_SAMPLE_WEBHOOK_PORT`` overridden) into its own run directory and points the child processes at that working directory. Temporary files are removed on exit (see ``--keep-logs`` / ``--keep-alive``). @@ -28,7 +28,7 @@ python run_demo.py --language zh-CN python run_demo.py --scenario negotiation # missing-region input; information negotiation python run_demo.py --negotiation target # overlapping time segments; target negotiation - python run_demo.py --negotiation feasibility --channel fromText + python run_demo.py --negotiation feasibility python run_demo.py --transport webhook # task events via the embedded webhook receiver python run_demo.py --keep-alive # leave registry/server running; stop with --stop python run_demo.py --stop # stop a previous --keep-alive run @@ -62,10 +62,6 @@ REGISTRY_PORT = 5001 SERVER_PORT = 8000 -#: ``--channel`` value -> ``A2AT_SAMPLE_CHANNEL`` written into the per-run ``.env``. -_CHANNEL_VALUES = {"fromData": "data", "fromText": "text"} - - def _port_open(port: int, host: str = "127.0.0.1") -> bool: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(0.3) @@ -97,21 +93,19 @@ def _child_env() -> dict[str, str]: "A2AT_LANGUAGE", "A2AT_SAMPLE_SCENARIO", "A2AT_SAMPLE_NEGOTIATION_KIND", - "A2AT_SAMPLE_CHANNEL", "A2AT_SAMPLE_TRANSPORT", "A2AT_SAMPLE_WEBHOOK_PORT", ) def _write_run_env( - language: str, scenario: str, negotiation_kind: str, channel: str, transport: str, webhook_port: int + language: str, scenario: str, negotiation_kind: str, transport: str, webhook_port: int ) -> None: """Write the per-run ``.env`` (shared .env copy with the run-specific keys overridden).""" values = { "A2AT_LANGUAGE": language, "A2AT_SAMPLE_SCENARIO": scenario, "A2AT_SAMPLE_NEGOTIATION_KIND": negotiation_kind, - "A2AT_SAMPLE_CHANNEL": channel, "A2AT_SAMPLE_TRANSPORT": transport, "A2AT_SAMPLE_WEBHOOK_PORT": str(webhook_port), } @@ -289,16 +283,6 @@ def main() -> int: "(default: information)" ), ) - parser.add_argument( - "--channel", - default="fromData", - choices=["fromData", "fromText"], - help=( - "generation channel of the negotiation propose/accept messages: fromData generates " - "them from typed content, fromText from a natural-language rendering plus one " - "extraction LLM call (default: fromData)" - ), - ) parser.add_argument( "--transport", default="sse", @@ -346,7 +330,7 @@ def main() -> int: existing = [] _write_run_env( - args.language, args.scenario, args.negotiation, _CHANNEL_VALUES[args.channel], args.transport, args.webhook_port + args.language, args.scenario, args.negotiation, args.transport, args.webhook_port ) managed: list[int] = list(existing) started_pids: list[int] = [] @@ -433,7 +417,7 @@ def start(name: str, module: str, port: int) -> str | None: print( f"[run_demo] running client (language = {args.language}, scenario = {args.scenario}, " - f"negotiation = {args.negotiation}, channel = {args.channel}, " + f"negotiation = {args.negotiation}, " f"transport = {args.transport}, webhook_port = {args.webhook_port}) ..." ) client_returncode = subprocess.run( diff --git a/a2a-t-sample/ran-energy-saving/src/client/client_flow.py b/a2a-t-sample/ran-energy-saving/src/client/client_flow.py index eebace5e..594e287b 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/client_flow.py +++ b/a2a-t-sample/ran-energy-saving/src/client/client_flow.py @@ -26,14 +26,6 @@ TARGET_NEGOTIATION_ACCEPT_REJECT_URI, TARGET_NEGOTIATION_PROPOSE_URI, ) -from a2a_t.negotiation.content.enums import NegotiationConclusion -from a2a_t.negotiation.content.models import ( - FeasibilityEndingContent, - InformationEndingContent, - NegotiationEndingData, - NegotiationItem, - TargetEndingContent, -) from common.logging_utils import format_payload_log, format_stage_log from common.sse_event_consumer import normalize_event from common.task_schema import TASK_PARAM_SCHEMA @@ -45,8 +37,6 @@ ScriptedNegotiationResponseProvider, ) from client.scenario_data import ( - CHANNEL_DATA, - CHANNEL_TEXT, ENERGY_SAVING_TEMPLATE_URI, NEGOTIATION_KIND_FEASIBILITY, NEGOTIATION_KIND_INFORMATION, @@ -419,7 +409,6 @@ async def _run_webhook_flow( negotiation_params: dict[str, object] | None, negotiation_kind: str | None, negotiation_response_provider: NegotiationResponseProvider | None, - channel: str, normalized_events: list[dict[str, object]], artifact_count: int, log_sink: Callable[[str], None] | None, @@ -513,7 +502,6 @@ async def _run_webhook_flow( initial_input=initial_input, session=session, plan=plan, - channel=channel, negotiation_params=negotiation_params, log_sink=log_sink, ) @@ -604,40 +592,12 @@ def _stringify_filled_params(filled_params: dict[str, object]) -> dict[str, str] return {name: value if isinstance(value, str) else str(value) for name, value in filled_params.items()} -def _negotiation_accept_content( - negotiation_kind: str, - *, - plan: _NegotiationAcceptPlan, -) -> InformationEndingContent | TargetEndingContent | FeasibilityEndingContent: - """Build the typed accept content of one negotiation type from the provider's decision (plan R3). - - Information keeps the v1 path: the accept items come from the decision's filled parameters. - Target accepts the clarified target and feasibility confirms the feasibility evaluation, both - with the decision's text — the scripted provider supplies the scenario-owned content of the - resolved language, a customer provider its own. - """ - if negotiation_kind == NEGOTIATION_KIND_TARGET: - return TargetEndingContent( - conclusion=NegotiationConclusion.ACCEPT, - confirmed_intent=plan.confirmed_intent, - failure_reason=None, - ) - if negotiation_kind == NEGOTIATION_KIND_FEASIBILITY: - return FeasibilityEndingContent( - conclusion=NegotiationConclusion.ACCEPT, - feasibility_summary=plan.feasibility_summary, - ) - return InformationEndingContent( - conclusion=NegotiationConclusion.ACCEPT, - items=[NegotiationItem(name=name, value=value) for name, value in plan.filled_params.items()], - ) - def _negotiation_accept_text(negotiation_kind: str, *, plan: _NegotiationAcceptPlan) -> str: """Render the natural-language input of the from-text accept generation for one type. The rendering carries the same content as the typed accept builders (so the extraction step of - the from-text channel returns the canonical content and both channels produce equivalent + the from-text accept returns the canonical content and the rendering stays equivalent messages). """ if negotiation_kind == NEGOTIATION_KIND_TARGET: @@ -662,7 +622,6 @@ def _build_negotiation_accept_request( initial_input: dict[str, object], session: NegotiationContext, plan: _NegotiationAcceptPlan, - channel: str, negotiation_params: dict[str, object], log_sink: object | None, ) -> tuple[SendMessageRequest, ClientCallContext]: @@ -673,12 +632,10 @@ def _build_negotiation_accept_request( plus the answer to it, so the accept does not advance the round), the Task-T prompt is regenerated from the filled parameters (the flow-owned message construction fed by the caller), and both travel in the accept message metadata under their extension URIs. The accept content comes - from the provider's decision via ``plan``. The channel switches only the accept generation call: - ``data`` builds the typed content and calls the deterministic from-data API (zero LLM calls), - ``text`` renders the same content as a natural-language message and calls the from-text API, - whose extraction step parses it back into the canonical typed content (one LLM call). Returns - the request and its call context; the caller decides HOW to send it (SSE streaming / webhook - non-blocking). + from the provider's decision via ``plan``. The accept is written as a natural-language message + and generated through the from-text API: the SDK extracts the canonical typed content (one LLM + call), then renders the template. Returns the request and its call context; the caller decides + HOW to send it (SSE streaming / webhook non-blocking). """ # Filled Task-T prompt: the parameters the propose asked for, regenerated from data. All types # complete the same four parameters, so one regeneration serves every negotiation kind. @@ -691,20 +648,11 @@ def _build_negotiation_accept_request( # plus its answer; only a new propose advances the round), performative ACCEPT. accept_context = session.with_performative(NegotiationPerformative.ACCEPT) accept_template_uri = _NEGOTIATION_ACCEPT_URI_BY_KIND[plan.negotiation_kind] - if channel == CHANNEL_TEXT: - accept = prompt_client.generate_negotiation_accept_prompt_from_text( - _negotiation_accept_text(plan.negotiation_kind, plan=plan), - accept_context, - accept_template_uri, - ) - else: - accept = prompt_client.generate_negotiation_accept_prompt_from_data( - NegotiationEndingData( - context=accept_context, - content=_negotiation_accept_content(plan.negotiation_kind, plan=plan), - ), - accept_template_uri, - ) + accept = prompt_client.generate_negotiation_accept_prompt_from_text( + _negotiation_accept_text(plan.negotiation_kind, plan=plan), + accept_context, + accept_template_uri, + ) accept_text, _ = _require_prompt_text(accept) accept_template_uri = str(getattr(accept, "template_uri", None) or accept_template_uri) accept_negotiation_context = getattr(accept, "negotiation_context", None) @@ -876,7 +824,6 @@ async def _run_negotiation_accept( task_id: str, context_id: str, plan: _NegotiationAcceptPlan, - channel: str, negotiation_params: dict[str, object], normalized_events: list[dict[str, object]], artifact_count: int, @@ -894,7 +841,6 @@ async def _run_negotiation_accept( initial_input=initial_input, session=session, plan=plan, - channel=channel, negotiation_params=negotiation_params, log_sink=log_sink, ) @@ -934,7 +880,6 @@ async def run_client_flow( negotiation_answers: dict[str, str] | None = None, negotiation_kind: str | None = None, negotiation_response_provider: NegotiationResponseProvider | None = None, - channel: str = CHANNEL_DATA, env_path: Path | None = None, transport: str = TRANSPORT_SSE, webhook_queue: asyncio.Queue[dict[str, object]] | None = None, @@ -982,7 +927,7 @@ async def run_client_flow( provider for the decision on the propose view, regenerate the Task-T prompt from the filled parameters, render the Negotiation-T accept of that type for the parsed session from the decision (from typed data, or from a natural-language rendering plus one - extraction LLM call per the configured channel), and re-send over a second request + extraction LLM call), and re-send over a second request -> ``[client] negotiation-accept`` / ``[client] a2a-send-message`` (phase=continuation) @@ -1078,8 +1023,7 @@ async def run_client_flow( negotiation_params=negotiation_params, negotiation_kind=negotiation_kind, negotiation_response_provider=negotiation_provider, - channel=channel, - normalized_events=normalized_events, + normalized_events=normalized_events, artifact_count=artifact_count, log_sink=log_sink, ) @@ -1136,8 +1080,7 @@ async def run_client_flow( task_id=_first_task_id(normalized_events), context_id=str(input_required_event.get("context_id", "")), plan=plan, - channel=channel, - negotiation_params=negotiation_params, + negotiation_params=negotiation_params, normalized_events=normalized_events, artifact_count=artifact_count, max_artifacts=max_artifacts, diff --git a/a2a-t-sample/ran-energy-saving/src/client/client_main.py b/a2a-t-sample/ran-energy-saving/src/client/client_main.py index 6952d00d..1fbd0159 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/client_main.py +++ b/a2a-t-sample/ran-energy-saving/src/client/client_main.py @@ -26,7 +26,6 @@ from client.client_flow import run_client_flow from client.negotiation_response import NegotiationResponseProvider, ScriptedNegotiationResponseProvider from client.scenario_data import ( - CHANNEL_DATA, NEGOTIATION_KIND_FEASIBILITY, NEGOTIATION_KIND_TARGET, SCENARIO_NEGOTIATION, @@ -36,7 +35,6 @@ build_task_request, negotiation_answers, negotiation_filled_params, - resolve_channel, resolve_negotiation_kind, resolve_scenario, resolve_transport, @@ -165,7 +163,6 @@ async def run_main( negotiation_answers: dict[str, str] | None = None, negotiation_kind: str | None = None, negotiation_response_provider: NegotiationResponseProvider | None = None, - channel: str = CHANNEL_DATA, transport: str = TRANSPORT_SSE, webhook_port: int | None = None, webhook_token: str | None = None, @@ -271,7 +268,6 @@ async def run_main( "negotiation_answers": negotiation_answers, "negotiation_kind": negotiation_kind, "negotiation_response_provider": negotiation_response_provider, - "channel": channel, "env_path": env_path, } if transport == TRANSPORT_WEBHOOK: @@ -305,12 +301,11 @@ def main( raw_max_artifacts = int(os.environ.get("A2AT_SAMPLE_MAX_ARTIFACTS", "0") or "0") max_artifacts = raw_max_artifacts if raw_max_artifacts > 0 else None scenario = resolve_scenario(env_path=resolved_env_path) - channel = resolve_channel(env_path=sample_env_path) negotiation_kind = resolve_negotiation_kind(env_path=sample_env_path) transport = resolve_transport(env_path=sample_env_path) print( f"[client] startup: server_url={resolve_server_url(env_path=resolved_env_path)} " - f"debug={'true' if debug_enabled else 'false'} scenario={scenario} channel={channel} " + f"debug={'true' if debug_enabled else 'false'} scenario={scenario} " f"transport={transport}" ) # The target/feasibility kinds select their own (complete) scenario input regardless of the @@ -347,7 +342,6 @@ def main( negotiation_answers=negotiation_answers_map, negotiation_kind=negotiation_kind, negotiation_response_provider=negotiation_response_provider, - channel=channel, transport=transport, ) ) diff --git a/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py b/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py index 822f3155..850d6f7f 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py +++ b/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py @@ -11,10 +11,9 @@ information-negotiation variant (the client input omits the region, the server opens the negotiation, the client answers with ``negotiation_answers``), and by the negotiation kind (``A2AT_SAMPLE_NEGOTIATION_KIND``): ``target`` / ``feasibility`` select their own complete -inputs whose Task-T validation fails with the value-not-allowed / semantic-conflict content -error that triggers the target / feasibility negotiation (the server stays data-driven on the -error code). The generation channel (``A2AT_SAMPLE_CHANNEL``) switches the negotiation -propose/accept generation between typed data and natural-language text. The transport +inputs whose Task-T validation fails with the value-not-allowed / rule-violation content error +that triggers the target / feasibility negotiation (the server stays data-driven on the error +code). The transport (``A2AT_SAMPLE_TRANSPORT``) switches how the client receives the task events: the default SSE stream or the embedded webhook receiver (``common.webhook_receiver``). """ @@ -37,16 +36,6 @@ #: ``.env`` key selecting the sample scenario variant. SCENARIO_ENV_KEY = "A2AT_SAMPLE_SCENARIO" -#: Generation channel variants selected by ``A2AT_SAMPLE_CHANNEL``: ``data`` generates the -#: negotiation propose/accept from typed content (no extraction LLM call), ``text`` from a -#: natural-language rendering plus one extraction LLM call. -CHANNEL_DATA = "data" -CHANNEL_TEXT = "text" -SUPPORTED_CHANNELS = (CHANNEL_DATA, CHANNEL_TEXT) - -#: ``.env`` key selecting the generation channel. -CHANNEL_ENV_KEY = "A2AT_SAMPLE_CHANNEL" - #: Event transport variants selected by ``A2AT_SAMPLE_TRANSPORT``: ``sse`` consumes the streamed #: response as before, ``webhook`` embeds the client-side receiver #: (``common.webhook_receiver.WebhookReceiver``) and receives the task events as push-notification @@ -125,7 +114,7 @@ #: Chinese natural-language task request of the feasibility negotiation: two stations must keep #: a guaranteed rate through an 18-hour power outage, so the Task-T validation reports the -#: semantic-conflict content error that triggers the feasibility negotiation (spec case 3: the +#: rule-violation content error that triggers the feasibility negotiation (spec case 3: the #: guaranteed rate cannot sustain the outage duration). NATURAL_LANGUAGE_PROMPT_INPUT_FEASIBILITY_ZH = ( "在松山湖管委会创建无线网络节能任务:站点列表1为[站点名称1, 站点名称2];" @@ -324,17 +313,6 @@ def resolve_scenario(*, env_path: Path | None = None) -> str: return raw_value if raw_value in SUPPORTED_SCENARIOS else SCENARIO_DEFAULT -def resolve_channel(*, env_path: Path | None = None) -> str: - """Resolve the configured ``A2AT_SAMPLE_CHANNEL`` (defaults to ``data``). - - The channel switches only the negotiation propose/accept generation (typed data vs an - NL rendering plus one extraction LLM call); an unset or unknown value resolves to ``data``. - """ - resolved = env_path or Path.cwd() / ".env" - values = dotenv_values(resolved) if resolved.exists() else {} - raw_value = str(values.get(CHANNEL_ENV_KEY, "")).strip() or os.environ.get(CHANNEL_ENV_KEY, "").strip() - return raw_value if raw_value in SUPPORTED_CHANNELS else CHANNEL_DATA - def resolve_negotiation_kind(*, env_path: Path | None = None) -> str: """Resolve the configured ``A2AT_SAMPLE_NEGOTIATION_KIND`` (defaults to ``information``). diff --git a/a2a-t-sample/ran-energy-saving/src/server/constants_data.py b/a2a-t-sample/ran-energy-saving/src/server/constants_data.py index 03736995..39b0e872 100644 --- a/a2a-t-sample/ran-energy-saving/src/server/constants_data.py +++ b/a2a-t-sample/ran-energy-saving/src/server/constants_data.py @@ -2,9 +2,7 @@ from __future__ import annotations -import os from copy import deepcopy -from pathlib import Path from typing import Any from a2a_t.core.standard_templates import ( @@ -12,7 +10,6 @@ INFORMATION_NEGOTIATION_PROPOSE_URI, TARGET_NEGOTIATION_PROPOSE_URI, ) -from dotenv import dotenv_values SUBMITTED_MESSAGE = "Energy saving task accepted, starting RAN energy saving task" ARTIFACT_SEND_INTERVAL_SECONDS = 1.0 @@ -61,17 +58,6 @@ CONTENT_ERROR_CODE_TARGET = "content.value_not_allowed" CONTENT_ERROR_CODE_FEASIBILITY = "content.rule_violation" -#: Generation channel variants selected by ``A2AT_SAMPLE_CHANNEL``: ``data`` generates the -#: negotiation propose from typed content (no extraction LLM call), ``text`` from a natural-language -#: rendering plus one extraction LLM call. Server-side mirror of the ``client.scenario_data`` -#: resolver — the server imports no client resolver (only its scenario accessors). -CHANNEL_DATA = "data" -CHANNEL_TEXT = "text" -SUPPORTED_CHANNELS = (CHANNEL_DATA, CHANNEL_TEXT) - -#: ``.env`` key selecting the generation channel. -CHANNEL_ENV_KEY = "A2AT_SAMPLE_CHANNEL" - #: Area the sample energy-saving intent is scoped to (from the RAN energy-saving interface spec). ENERGY_SAVING_REGION = "Songshanhu Administration Committee" @@ -198,15 +184,3 @@ def get_energy_saving_intent_report() -> dict[str, str]: return report -def resolve_negotiation_channel(*, env_path: Path | None = None) -> str: - """Resolve the configured ``A2AT_SAMPLE_CHANNEL`` (defaults to ``data``). - - The channel switches only the negotiation propose generation (typed data vs a natural-language - rendering plus one extraction LLM call); an unset or unknown value resolves to ``data``. The - ``.env`` value wins over the OS environment (the sample resolver convention, mirroring the - client-side ``client.scenario_data.resolve_channel`` without importing it). - """ - resolved = env_path or Path.cwd() / ".env" - values = dotenv_values(resolved) if resolved.exists() else {} - raw_value = str(values.get(CHANNEL_ENV_KEY, "")).strip() or os.environ.get(CHANNEL_ENV_KEY, "").strip() - return raw_value if raw_value in SUPPORTED_CHANNELS else CHANNEL_DATA diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_client_flow.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_client_flow.py index f46d961e..204c79f2 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_client_flow.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_client_flow.py @@ -49,12 +49,6 @@ TARGET_NEGOTIATION_ACCEPT_REJECT_URI, TARGET_NEGOTIATION_PROPOSE_URI, ) -from a2a_t.negotiation.content.enums import NegotiationConclusion -from a2a_t.negotiation.content.models import ( - FeasibilityEndingContent, - NegotiationEndingData, - TargetEndingContent, -) from google.protobuf.json_format import MessageToDict PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -72,8 +66,6 @@ ScriptedNegotiationResponseProvider, ) from client.scenario_data import ( - CHANNEL_DATA, - CHANNEL_TEXT, ENERGY_SAVING_TEMPLATE_URI, negotiation_feasibility_summary, negotiation_target_confirmation, @@ -127,7 +119,6 @@ class _ScriptedPromptClient: def __init__(self) -> None: self.from_text_calls: list[tuple[str, str]] = [] self.from_data_calls: list[tuple[dict[str, object], str]] = [] - self.accept_calls: list[NegotiationEndingData] = [] self.accept_from_text_calls: list[tuple[str, NegotiationContext, str]] = [] def generate_task_prompt_from_text(self, text: str, template_uri: str) -> MetadataContent: @@ -161,11 +152,6 @@ def _accept_result(self, template_uri: str, context: NegotiationContext) -> Meta ), ) - def generate_negotiation_accept_prompt_from_data( - self, data: NegotiationEndingData, template_uri: str - ) -> MetadataContent: - self.accept_calls.append(data) - return self._accept_result(str(template_uri), data.context) def generate_negotiation_accept_prompt_from_text( self, text: str, context: NegotiationContext, template_uri: str @@ -275,7 +261,6 @@ async def _run_flow( negotiation_answers: dict[str, str] | None = None, negotiation_kind: str | None = None, negotiation_response_provider: NegotiationResponseProvider | None = None, - channel: str = CHANNEL_DATA, env_path: Path | None = None, log_sink: object | None = None, ) -> list[dict[str, object]]: @@ -289,7 +274,6 @@ async def _run_flow( negotiation_answers=negotiation_answers, negotiation_kind=negotiation_kind, negotiation_response_provider=negotiation_response_provider, - channel=channel, env_path=env_path, log_sink=log_sink, ) @@ -365,14 +349,14 @@ def test_negotiation_round_trip_answers_the_propose_and_completes() -> None: # The filled Task-T prompt was regenerated from the supplied parameters and schema. assert prompt_client.from_data_calls == [(_NEGOTIATION_PARAMS, ENERGY_SAVING_TEMPLATE_URI)] - # The accept carries conclusion ACCEPT, the advanced session context and the region answer. - assert len(prompt_client.accept_calls) == 1 - accept_data = prompt_client.accept_calls[0] - assert accept_data.content.conclusion == NegotiationConclusion.ACCEPT - assert accept_data.context.id == session_id - assert accept_data.context.round == 1 - assert accept_data.context.performative == NegotiationPerformative.ACCEPT - assert [(item.name, item.value) for item in accept_data.content.items] == list(_NEGOTIATION_ANSWERS.items()) + # The accept is rendered from the numbered answers and the same-round ACCEPT session. + assert len(prompt_client.accept_from_text_calls) == 1 + accept_text, accept_session, accept_uri = prompt_client.accept_from_text_calls[0] + assert accept_text == "Accept. 1. region:Songshanhu Administration Committee;" + assert accept_uri == INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI + assert accept_session.id == session_id + assert accept_session.round == 1 + assert accept_session.performative == NegotiationPerformative.ACCEPT # The consumed stream spans both rounds and closes with the COMPLETED state. assert _event_states(events) == [ @@ -419,7 +403,7 @@ def test_input_required_without_answers_is_logged_unhandled_and_stays_on_round_1 events = asyncio.run(_run_flow(a2a_client, prompt_client, log_sink=logs.append)) assert len(a2a_client.calls) == 1 - assert prompt_client.accept_calls == [] + assert prompt_client.accept_from_text_calls == [] assert prompt_client.from_data_calls == [] assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED"] assert any("negotiation-unhandled" in line for line in logs) @@ -442,7 +426,7 @@ def test_malformed_negotiation_context_is_logged_unhandled_and_stays_on_round_1( ) assert len(a2a_client.calls) == 1 - assert prompt_client.accept_calls == [] + assert prompt_client.accept_from_text_calls == [] assert prompt_client.from_data_calls == [] assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED"] unhandled_logs = [line for line in logs if line.startswith("[client] negotiation-unhandled:")] @@ -458,7 +442,7 @@ def test_default_path_without_input_required_sends_exactly_once_and_keeps_the_st events = asyncio.run(_run_flow(a2a_client, prompt_client, log_sink=logs.append)) assert len(a2a_client.calls) == 1 - assert prompt_client.accept_calls == [] + assert prompt_client.accept_from_text_calls == [] assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_WORKING"] assert any(event["kind"] == "artifact" for event in events) # The default path's stage logs are unchanged (byte-identical flow: no negotiation stages). @@ -535,18 +519,14 @@ def test_target_propose_answers_with_the_typed_target_accept(_offline_env: Path) assert context["maxRounds"] == NegotiationContext.DEFAULT_MAX_ROUNDS assert context["performative"] == "ACCEPT" - # The typed accept carries the confirmed intent of the scenario data (canned extraction text). - assert len(prompt_client.accept_calls) == 1 - accept_data = prompt_client.accept_calls[0] - assert isinstance(accept_data.content, TargetEndingContent) - assert accept_data.content.conclusion == NegotiationConclusion.ACCEPT - assert accept_data.content.confirmed_intent == negotiation_target_confirmation(env_path=_offline_env) - assert accept_data.content.failure_reason is None - assert accept_data.context.id == session_id - assert accept_data.context.round == 1 - assert accept_data.context.performative == NegotiationPerformative.ACCEPT - # The from-text accept API was not used on the data channel; the flow closes with COMPLETED. - assert prompt_client.accept_from_text_calls == [] + # The accept text carries the confirmed intent of the scenario data. + assert len(prompt_client.accept_from_text_calls) == 1 + accept_text, accept_session, accept_uri = prompt_client.accept_from_text_calls[0] + assert accept_text == f"Accept. {negotiation_target_confirmation(env_path=_offline_env)}" + assert accept_uri == TARGET_NEGOTIATION_ACCEPT_REJECT_URI + assert accept_session.id == session_id + assert accept_session.round == 1 + assert accept_session.performative == NegotiationPerformative.ACCEPT assert _event_states(events)[-1] == "TASK_STATE_COMPLETED" accept_logs = [line for line in logs if line.startswith("[client] negotiation-accept:")] assert len(accept_logs) == 1 @@ -576,13 +556,13 @@ def test_feasibility_propose_answers_with_the_typed_feasibility_accept(_offline_ assert metadata[NEGOTIATION_CONTEXT_METADATA_KEY]["round"] == 1 assert metadata[NEGOTIATION_CONTEXT_METADATA_KEY]["performative"] == "ACCEPT" - # The typed accept carries the feasibility summary of the scenario data. - assert len(prompt_client.accept_calls) == 1 - accept_data = prompt_client.accept_calls[0] - assert isinstance(accept_data.content, FeasibilityEndingContent) - assert accept_data.content.conclusion == NegotiationConclusion.ACCEPT - assert accept_data.content.feasibility_summary == negotiation_feasibility_summary(env_path=_offline_env) - assert prompt_client.accept_from_text_calls == [] + # The accept text carries the feasibility summary of the scenario data. + assert len(prompt_client.accept_from_text_calls) == 1 + accept_text, accept_session, accept_uri = prompt_client.accept_from_text_calls[0] + assert accept_text == f"Accept. {negotiation_feasibility_summary(env_path=_offline_env)}" + assert accept_uri == FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI + assert accept_session.round == 1 + assert accept_session.performative == NegotiationPerformative.ACCEPT assert _event_states(events)[-1] == "TASK_STATE_COMPLETED" @@ -619,14 +599,13 @@ def test_propose_template_uri_dispatches_the_accept_kind_without_a_flag( assert metadata[TEMPLATE_URI_METADATA_KEY] == accept_uri assert metadata[NEGOTIATION_CONTEXT_METADATA_KEY]["round"] == 1 assert metadata[NEGOTIATION_CONTEXT_METADATA_KEY]["performative"] == "ACCEPT" - assert len(prompt_client.accept_calls) == 1 - assert prompt_client.accept_from_text_calls == [] + assert len(prompt_client.accept_from_text_calls) == 1 -# --- from-text channel: the accept goes through the extraction API --------------------------- +# --- accept generation: the accept goes through the extraction API -------------------------- -def test_from_text_channel_generates_the_target_accept_via_extraction(_offline_env: Path) -> None: +def test_target_accept_is_generated_via_extraction(_offline_env: Path) -> None: prompt_client = _ScriptedPromptClient() a2a_client = _ScriptedA2AClient( [_initial_events(metadata=_propose_metadata(template_uri=TARGET_NEGOTIATION_PROPOSE_URI)), _continuation_events()] @@ -639,13 +618,11 @@ def test_from_text_channel_generates_the_target_accept_via_extraction(_offline_e negotiation_params=dict(_NEGOTIATION_PARAMS), negotiation_answers=dict(_NEGOTIATION_ANSWERS), negotiation_kind=scenario_data.NEGOTIATION_KIND_TARGET, - channel=CHANNEL_TEXT, env_path=_offline_env, ) ) - # The from-text accept API replaced the from-data call; the rendered text carries the content. - assert prompt_client.accept_calls == [] + # The accept goes through the extraction API; the rendered text carries the content. assert len(prompt_client.accept_from_text_calls) == 1 text, context, template_uri = prompt_client.accept_from_text_calls[0] assert text == f"Accept. {negotiation_target_confirmation(env_path=_offline_env)}" @@ -665,7 +642,7 @@ def test_from_text_channel_generates_the_target_accept_via_extraction(_offline_e assert metadata[NEGOTIATION_CONTEXT_METADATA_KEY]["performative"] == "ACCEPT" -def test_from_text_channel_renders_the_feasibility_summary_and_numbered_answers(_offline_env: Path) -> None: +def test_accept_renders_the_feasibility_summary_and_numbered_answers(_offline_env: Path) -> None: feasibility_prompt_client = _ScriptedPromptClient() feasibility_a2a_client = _ScriptedA2AClient( [ @@ -680,7 +657,6 @@ def test_from_text_channel_renders_the_feasibility_summary_and_numbered_answers( negotiation_params=dict(_NEGOTIATION_PARAMS), negotiation_answers=dict(_NEGOTIATION_ANSWERS), negotiation_kind=scenario_data.NEGOTIATION_KIND_FEASIBILITY, - channel=CHANNEL_TEXT, env_path=_offline_env, ) ) @@ -697,7 +673,6 @@ def test_from_text_channel_renders_the_feasibility_summary_and_numbered_answers( information_prompt_client, negotiation_params=dict(_NEGOTIATION_PARAMS), negotiation_answers=dict(_NEGOTIATION_ANSWERS), - channel=CHANNEL_TEXT, env_path=_offline_env, ) ) @@ -706,30 +681,6 @@ def test_from_text_channel_renders_the_feasibility_summary_and_numbered_answers( assert template_uri == INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI -def test_target_accept_accept_requests_are_equivalent_across_channels(_offline_env: Path) -> None: - """The from-data and from-text target accepts produce the same accept message metadata.""" - propose_metadata = _propose_metadata(template_uri=TARGET_NEGOTIATION_PROPOSE_URI) - accept_metadata: list[dict[str, object]] = [] - for channel in (CHANNEL_DATA, CHANNEL_TEXT): - prompt_client = _ScriptedPromptClient() - a2a_client = _ScriptedA2AClient([_initial_events(metadata=propose_metadata), _continuation_events()]) - asyncio.run( - _run_flow( - a2a_client, - prompt_client, - negotiation_params=dict(_NEGOTIATION_PARAMS), - negotiation_answers=dict(_NEGOTIATION_ANSWERS), - negotiation_kind=scenario_data.NEGOTIATION_KIND_TARGET, - channel=channel, - env_path=_offline_env, - ) - ) - accept_metadata.append(_request_metadata(a2a_client.calls[1][0])) - - data_metadata, text_metadata = accept_metadata - assert data_metadata[TEMPLATE_URI_METADATA_KEY] == text_metadata[TEMPLATE_URI_METADATA_KEY] - assert data_metadata[NEGOTIATION_CONTEXT_METADATA_KEY] == text_metadata[NEGOTIATION_CONTEXT_METADATA_KEY] - # --- unhandled degradations stay type-agnostic ----------------------------------------------- @@ -761,7 +712,7 @@ def test_unknown_propose_template_uri_without_a_kind_flag_stays_unhandled(_offli ) assert len(a2a_client.calls) == 1 - assert prompt_client.accept_calls == [] + assert prompt_client.accept_from_text_calls == [] assert prompt_client.accept_from_text_calls == [] unhandled_logs = [line for line in logs if line.startswith("[client] negotiation-unhandled:")] assert len(unhandled_logs) == 1 @@ -787,7 +738,7 @@ def test_malformed_context_degrades_to_unhandled_with_a_kind_flag_too(_offline_e ) assert len(a2a_client.calls) == 1 - assert prompt_client.accept_calls == [] + assert prompt_client.accept_from_text_calls == [] assert prompt_client.accept_from_text_calls == [] unhandled_logs = [line for line in logs if line.startswith("[client] negotiation-unhandled:")] assert len(unhandled_logs) == 1 @@ -952,18 +903,14 @@ def test_explicit_scripted_provider_matches_the_legacy_shim_outputs(_offline_env assert _event_states(seam_events) == _event_states(legacy_events) assert _stage_names(seam_logs) == _stage_names(legacy_logs) - # The accept content is identical (items from the legacy answers == the provider's decision). - assert len(legacy_prompt_client.accept_calls) == len(seam_prompt_client.accept_calls) == 1 - legacy_accept = legacy_prompt_client.accept_calls[0] - seam_accept = seam_prompt_client.accept_calls[0] - assert seam_accept.context.id == legacy_accept.context.id - assert seam_accept.context.round == legacy_accept.context.round == 1 - assert seam_accept.context.performative == NegotiationPerformative.ACCEPT - assert ( - [(item.name, item.value) for item in seam_accept.content.items] - == [(item.name, item.value) for item in legacy_accept.content.items] - == list(_NEGOTIATION_ANSWERS.items()) - ) + # The accept text is identical (items from the legacy answers == the provider's decision). + assert len(legacy_prompt_client.accept_from_text_calls) == len(seam_prompt_client.accept_from_text_calls) == 1 + legacy_accept = legacy_prompt_client.accept_from_text_calls[0] + seam_accept = seam_prompt_client.accept_from_text_calls[0] + assert seam_accept[1].id == legacy_accept[1].id + assert seam_accept[1].round == legacy_accept[1].round == 1 + assert seam_accept[1].performative == NegotiationPerformative.ACCEPT + assert seam_accept[0] == legacy_accept[0] == "Accept. 1. region:Songshanhu Administration Committee;" class _RecordingResponseProvider: @@ -1013,9 +960,8 @@ def test_injected_provider_replaces_the_scripted_accept_end_to_end(_offline_env: # The accept was built from the decision (the seam replaced the scripted answer content). assert len(a2a_client.calls) == 2 - assert len(prompt_client.accept_calls) == 1 - accept_data = prompt_client.accept_calls[0] - assert [(item.name, item.value) for item in accept_data.content.items] == [("region", "Custom filled region")] + assert len(prompt_client.accept_from_text_calls) == 1 + assert prompt_client.accept_from_text_calls[0][0] == "Accept. 1. region:Custom filled region;" # The Task-T prompt refill remains the flow's message construction fed by the caller. assert prompt_client.from_data_calls == [(_NEGOTIATION_PARAMS, ENERGY_SAVING_TEMPLATE_URI)] assert _event_states(events)[-1] == "TASK_STATE_COMPLETED" @@ -1040,7 +986,7 @@ def test_reject_decision_logs_negotiation_decision_and_stays_on_round_1(_offline ) assert len(a2a_client.calls) == 1 # no accept sent - assert prompt_client.accept_calls == [] + assert prompt_client.accept_from_text_calls == [] assert prompt_client.from_data_calls == [] decision_logs = [line for line in logs if line.startswith("[client] negotiation-decision:")] assert decision_logs == [ @@ -1070,7 +1016,7 @@ def test_abort_decision_logs_negotiation_decision_and_stays_on_round_1(_offline_ ) assert len(a2a_client.calls) == 1 - assert prompt_client.accept_calls == [] + assert prompt_client.accept_from_text_calls == [] decision_logs = [line for line in logs if line.startswith("[client] negotiation-decision:")] assert decision_logs == ["[client] negotiation-decision: abort (not implemented in this demo; see the sample README)"] assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED"] @@ -1099,7 +1045,7 @@ def test_target_accept_missing_confirmed_intent_is_logged_and_stays_unhandled(_o ) assert len(a2a_client.calls) == 1 - assert prompt_client.accept_calls == [] + assert prompt_client.accept_from_text_calls == [] assert prompt_client.accept_from_text_calls == [] decision_logs = [line for line in logs if line.startswith("[client] negotiation-decision:")] assert len(decision_logs) == 1 diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py index 452131f4..f29a2619 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py @@ -30,9 +30,6 @@ from client import scenario_data from client.scenario_data import ( - CHANNEL_DATA, - CHANNEL_ENV_KEY, - CHANNEL_TEXT, ENERGY_SAVING_TEMPLATE_URI, NATURAL_LANGUAGE_PROMPT_INPUT_EN, NATURAL_LANGUAGE_PROMPT_INPUT_ZH, @@ -42,7 +39,6 @@ SCENARIO_DEFAULT, SCENARIO_ENV_KEY, SCENARIO_NEGOTIATION, - SUPPORTED_CHANNELS, SUPPORTED_NEGOTIATION_KINDS, SUPPORTED_SCENARIOS, build_prompt_input, @@ -55,7 +51,6 @@ negotiation_target_confirmation, negotiation_target_description, negotiation_target_intent_understanding, - resolve_channel, resolve_negotiation_kind, resolve_scenario, ) @@ -88,7 +83,6 @@ def _offline_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: """Force-install the scripted mock LLM (en-US) for every test, regardless of any real ``.env``.""" monkeypatch.chdir(PROJECT_ROOT) monkeypatch.delenv(SCENARIO_ENV_KEY, raising=False) - monkeypatch.delenv(CHANNEL_ENV_KEY, raising=False) monkeypatch.delenv(NEGOTIATION_KIND_ENV_KEY, raising=False) env_path = _write_offline_env(tmp_path, "en-US") mock_llm.install_mock_llm(env_path=env_path) @@ -391,34 +385,17 @@ def _run_mock_sequence(steps: list[tuple[list[dict[str, str]], dict[str, object] mock_llm.get_routed_response(messages, schema) -def test_target_negotiation_call_sequences_are_pinned_from_data_and_from_text() -> None: - """fromData target run; fromText adds the server propose and the client accept extractions.""" +def test_target_negotiation_call_sequence_is_pinned() -> None: + """The run adds one propose extraction and one accept extraction to the Task-T calls.""" target_input = scenario_data.NATURAL_LANGUAGE_PROMPT_INPUT_TARGET_EN from_data_slot_messages = _slot_extraction_messages('{"operationType": "create"}') filled_validation_messages = _content_validation_messages(_FILLED_REGION_PROMPT, _REGION_TEMPLATE_TAIL) propose_schema = build_extraction_schema(NegotiationType.TARGET, NegotiationPerformative.PROPOSE) accept_schema = build_extraction_schema(NegotiationType.TARGET, NegotiationPerformative.ACCEPT) - # fromData: client slot extraction (target input) -> server target validation -> the client's - # from-data prompt regeneration slot call -> the server's filled-prompt validation. - mock_llm.reset_call_log() - _run_mock_sequence( - [ - (_slot_extraction_messages(target_input), _slot_extraction_schema()), - (_content_validation_messages(target_input, _REGION_TEMPLATE_TAIL), _content_validation_schema()), - (from_data_slot_messages, _slot_extraction_schema()), - (filled_validation_messages, _content_validation_schema()), - ] - ) - assert llm_calls == [ - mock_llm.STAGE_SLOT_EXTRACTION_TARGET, - mock_llm.STAGE_CONTENT_VALIDATION_TARGET, - mock_llm.STAGE_SLOT_EXTRACTION, - mock_llm.STAGE_CONTENT_VALIDATION, - ] - - # fromText: the server propose extraction joins between the target validation and the accept - # slot call, and the client accept extraction joins before the final validation. + # client slot extraction (target input) -> server target validation -> the server propose + # extraction -> the client's prompt-regeneration slot call -> the client accept extraction -> + # the server's filled-prompt validation. mock_llm.reset_call_log() _run_mock_sequence( [ @@ -440,34 +417,17 @@ def test_target_negotiation_call_sequences_are_pinned_from_data_and_from_text() ] -def test_feasibility_negotiation_call_sequences_are_pinned_from_data_and_from_text() -> None: - """fromData feasibility run; fromText adds the server propose and the client accept extractions.""" +def test_feasibility_negotiation_call_sequence_is_pinned() -> None: + """The run adds one propose extraction and one accept extraction to the Task-T calls.""" feasibility_input = scenario_data.NATURAL_LANGUAGE_PROMPT_INPUT_FEASIBILITY_EN from_data_slot_messages = _slot_extraction_messages('{"operationType": "create"}') filled_validation_messages = _content_validation_messages(_FILLED_REGION_PROMPT, _REGION_TEMPLATE_TAIL) propose_schema = build_extraction_schema(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE) accept_schema = build_extraction_schema(NegotiationType.FEASIBILITY, NegotiationPerformative.ACCEPT) - # fromData: client slot extraction (feasibility input) -> server feasibility validation -> - # the client's from-data prompt regeneration slot call -> the server's filled-prompt validation. - mock_llm.reset_call_log() - _run_mock_sequence( - [ - (_slot_extraction_messages(feasibility_input), _slot_extraction_schema()), - (_content_validation_messages(feasibility_input, _REGION_TEMPLATE_TAIL), _content_validation_schema()), - (from_data_slot_messages, _slot_extraction_schema()), - (filled_validation_messages, _content_validation_schema()), - ] - ) - assert llm_calls == [ - mock_llm.STAGE_SLOT_EXTRACTION_FEASIBILITY, - mock_llm.STAGE_CONTENT_VALIDATION_FEASIBILITY, - mock_llm.STAGE_SLOT_EXTRACTION, - mock_llm.STAGE_CONTENT_VALIDATION, - ] - - # fromText: the server propose extraction joins between the feasibility validation and the - # accept slot call, and the client accept extraction joins before the final validation. + # client slot extraction (feasibility input) -> server feasibility validation -> the server + # propose extraction -> the client's prompt-regeneration slot call -> the client accept + # extraction -> the server's filled-prompt validation. mock_llm.reset_call_log() _run_mock_sequence( [ @@ -649,8 +609,7 @@ def test_negotiation_accessors_match_the_canned_extraction_responses(tmp_path: P feasibility_propose = _resource(language, "negotiation_feasibility_propose_extraction.json") feasibility_accept = _resource(language, "negotiation_feasibility_accept_extraction.json") - # The client-generated accept texts equal the canned accept-extraction content, so the - # from-data and from-text channels produce equivalent messages. + # The client-generated accept texts equal the canned accept-extraction content. assert target_accept["conclusion"] == "Accept" assert target_accept["failure_reason"] is None assert negotiation_target_confirmation(env_path=env_path) == target_accept["confirmed_intent"] @@ -751,9 +710,8 @@ def test_negotiation_filled_params_and_answers_are_region_filled(tmp_path: Path, assert params == resource_params -def test_negotiation_kinds_and_channels_constants() -> None: +def test_negotiation_kind_constants() -> None: assert SUPPORTED_NEGOTIATION_KINDS == ("information", "target", "feasibility") - assert SUPPORTED_CHANNELS == ("data", "text") assert set(NEGOTIATION_INPUT_KEYS) == set(SUPPORTED_NEGOTIATION_KINDS) @@ -763,30 +721,6 @@ def _kind_input(kind: str, language: str) -> str: return en_input if language == "en-US" else zh_input -def test_resolve_channel_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(tmp_path) # no .env next to the cwd - - # No .env and no OS environment entry -> data. - assert resolve_channel() == CHANNEL_DATA - - # Unknown value -> data. - env_path = tmp_path / ".env" - env_path.write_text("A2AT_SAMPLE_CHANNEL=bogus\n", encoding="utf-8") - assert resolve_channel(env_path=env_path) == CHANNEL_DATA - - # Known value -> itself. - env_path.write_text("A2AT_SAMPLE_CHANNEL=text\n", encoding="utf-8") - assert resolve_channel(env_path=env_path) == CHANNEL_TEXT - - # OS environment fallback when the .env does not carry the key. - env_path.write_text("A2AT_LANGUAGE=en-US\n", encoding="utf-8") - monkeypatch.setenv(CHANNEL_ENV_KEY, CHANNEL_TEXT) - assert resolve_channel(env_path=env_path) == CHANNEL_TEXT - - # The .env value wins over the OS environment. - env_path.write_text("A2AT_SAMPLE_CHANNEL=data\n", encoding="utf-8") - assert resolve_channel(env_path=env_path) == CHANNEL_DATA - def test_resolve_negotiation_kind_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) # no .env next to the cwd diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py index 246065ad..8b7a2f67 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py @@ -69,9 +69,6 @@ from common import mock_llm from common.llm_logger import install_llm_logger from server.constants_data import ( - CHANNEL_DATA, - CHANNEL_ENV_KEY, - CHANNEL_TEXT, COMPLETED_MESSAGE, ENERGY_SAVING_REGION, INPUT_REQUIRED_MESSAGE, @@ -80,7 +77,6 @@ TASK_T_EXTENSION_URI, get_energy_saving_intent_report, get_energy_saving_progress_steps, - resolve_negotiation_channel, ) from server.server_flow import ( _NEGOTIATION_TYPE_BY_ERROR_CODE, @@ -113,7 +109,6 @@ def _offline_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: """Force-install the scripted mock LLM (en-US) for every test, regardless of any real ``.env``.""" monkeypatch.chdir(PROJECT_ROOT) monkeypatch.delenv(scenario_data.SCENARIO_ENV_KEY, raising=False) - monkeypatch.delenv(scenario_data.CHANNEL_ENV_KEY, raising=False) env_path = _write_offline_env(tmp_path, "en-US") mock_llm.install_mock_llm(env_path=env_path) install_llm_logger(role="test", verbose=False) @@ -795,29 +790,3 @@ async def _emit(items: list[NegotiationItem]) -> list[object]: # --- executor plumbing ------------------------------------------------------------------------ -def test_resolve_negotiation_channel_defaults_and_overrides(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(tmp_path) # isolate the cwd; this test writes its own .env files below - - # No .env and no OS environment entry -> data. - assert resolve_negotiation_channel() == CHANNEL_DATA - - # Unknown value -> data. - env_path = tmp_path / ".env" - env_path.write_text(f"{CHANNEL_ENV_KEY}=bogus\n", encoding="utf-8") - assert resolve_negotiation_channel(env_path=env_path) == CHANNEL_DATA - - # Known value -> itself. - env_path.write_text(f"{CHANNEL_ENV_KEY}=text\n", encoding="utf-8") - assert resolve_negotiation_channel(env_path=env_path) == CHANNEL_TEXT - - # OS environment fallback when the .env does not carry the key. - env_path.write_text("A2AT_LANGUAGE=en-US\n", encoding="utf-8") - monkeypatch.setenv(CHANNEL_ENV_KEY, CHANNEL_TEXT) - assert resolve_negotiation_channel(env_path=env_path) == CHANNEL_TEXT - - # The .env value wins over the OS environment. - env_path.write_text(f"A2AT_LANGUAGE=en-US\n{CHANNEL_ENV_KEY}=data\n", encoding="utf-8") - assert resolve_negotiation_channel(env_path=env_path) == CHANNEL_DATA - - - diff --git a/a2a-t-sample/ran-energy-saving/test/test_webhook_client_flow.py b/a2a-t-sample/ran-energy-saving/test/test_webhook_client_flow.py index dce1035c..0dace20e 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_webhook_client_flow.py +++ b/a2a-t-sample/ran-energy-saving/test/test_webhook_client_flow.py @@ -10,9 +10,9 @@ (R4) with the same url/token, and the call order send→register→send is pinned. Covered: the default scenario (artifact end, no accept sent, summary counts WEBHOOK-received events -only — the RPC reply is logged separately per R6), the 3-type negotiation matrix over the fromData -channel (per-type accept content + re-registration + COMPLETED end), the target fromText spot -check, the unhandled/malformed INPUT_REQUIRED degradations (identical to SSE), the terminal-state +only — the RPC reply is logged separately per R6), the 3-type negotiation matrix (per-type accept +content + re-registration + COMPLETED end), the target accept spot check, the unhandled/malformed +INPUT_REQUIRED degradations (identical to SSE), the terminal-state end signals (a REJECTED in either phase ends promptly with ``end=`` on the summary — no idle hang, final fix), the idle-timeout backstop (R5), the ``normalize_webhook_payload``/``normalize_event`` parity, and the server @@ -61,13 +61,6 @@ TARGET_NEGOTIATION_ACCEPT_REJECT_URI, TARGET_NEGOTIATION_PROPOSE_URI, ) -from a2a_t.negotiation.content.enums import NegotiationConclusion -from a2a_t.negotiation.content.models import ( - FeasibilityEndingContent, - InformationEndingContent, - NegotiationEndingData, - TargetEndingContent, -) from google.protobuf.json_format import MessageToDict PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -83,8 +76,6 @@ NegotiationResponseProvider, ) from client.scenario_data import ( - CHANNEL_DATA, - CHANNEL_TEXT, ENERGY_SAVING_TEMPLATE_URI, NEGOTIATION_KIND_TARGET, negotiation_feasibility_summary, @@ -128,7 +119,6 @@ class _ScriptedPromptClient: def __init__(self) -> None: self.from_text_calls: list[tuple[str, str]] = [] self.from_data_calls: list[tuple[dict[str, object], str]] = [] - self.accept_calls: list[NegotiationEndingData] = [] self.accept_from_text_calls: list[tuple[str, NegotiationContext, str]] = [] def generate_task_prompt_from_text(self, text: str, template_uri: str) -> MetadataContent: @@ -162,11 +152,6 @@ def _accept_result(self, template_uri: str, context: NegotiationContext) -> Meta ), ) - def generate_negotiation_accept_prompt_from_data( - self, data: NegotiationEndingData, template_uri: str - ) -> MetadataContent: - self.accept_calls.append(data) - return self._accept_result(str(template_uri), data.context) def generate_negotiation_accept_prompt_from_text( self, text: str, context: NegotiationContext, template_uri: str @@ -339,7 +324,6 @@ async def _run_webhook_scenario( negotiation_answers: dict[str, str] | None = None, negotiation_kind: str | None = None, negotiation_response_provider: NegotiationResponseProvider | None = None, - channel: str = CHANNEL_DATA, idle_timeout: float = 2.0, token: str = _DEMO_TOKEN, ) -> tuple[list[dict[str, object]], _WebhookFakeA2AClient, _ScriptedPromptClient, list[str], WebhookReceiver]: @@ -366,7 +350,6 @@ async def _run_webhook_scenario( negotiation_answers=negotiation_answers, negotiation_kind=negotiation_kind, negotiation_response_provider=negotiation_response_provider, - channel=channel, env_path=env_path, transport="webhook", webhook_queue=queue, @@ -412,7 +395,7 @@ def test_default_scenario_webhook_ends_on_artifact_and_counts_webhook_events(_of assert len(events) == 3 # SUBMITTED + WORKING + artifact, all WEBHOOK-received assert len(a2a_client.send_calls) == 1 # no accept sent assert a2a_client.register_calls == [] - assert prompt_client.accept_calls == [] + assert prompt_client.accept_from_text_calls == [] # Initial request carries return_immediately + the inline push config (R2/R4). request, context = a2a_client.send_calls[0] @@ -447,7 +430,7 @@ def test_default_scenario_webhook_ends_on_artifact_and_counts_webhook_events(_of ] -# --- negotiation matrix: 3 types x webhook (fromData) ------------------------------------------ +# --- negotiation matrix: 3 types x webhook ----------------------------------------------------- @pytest.mark.parametrize( @@ -517,12 +500,10 @@ def test_negotiation_webhook_round_trip_re_registers_and_completes( reregister_logs = [line for line in logs if line.startswith("[client] webhook-push-reregister:")] assert len(reregister_logs) == 1 - # The accept was generated from typed data (fromData channel) for the proposed type. + # The accept goes through the extraction API for the proposed type. assert prompt_client.from_data_calls == [(_NEGOTIATION_PARAMS, ENERGY_SAVING_TEMPLATE_URI)] - assert len(prompt_client.accept_calls) == 1 - assert prompt_client.accept_from_text_calls == [] - assert prompt_client.accept_calls[0].content.conclusion == NegotiationConclusion.ACCEPT - assert prompt_client.accept_calls[0].context.round == 1 + assert len(prompt_client.accept_from_text_calls) == 1 + assert prompt_client.accept_from_text_calls[0][1].round == 1 # Summary: 2 webhook events (initial) + 4 (continuation), 1 artifact, terminal COMPLETED end. summary_lines = [line for line in logs if line.startswith("[client] stream-completed:")] @@ -541,9 +522,8 @@ def test_information_webhook_accept_carries_the_answer_items(_offline_env: Path) negotiation_answers=dict(_NEGOTIATION_ANSWERS), ) ) - accept_data = prompt_client.accept_calls[0] - assert isinstance(accept_data.content, InformationEndingContent) - assert [(item.name, item.value) for item in accept_data.content.items] == list(_NEGOTIATION_ANSWERS.items()) + accept_text = prompt_client.accept_from_text_calls[0][0] + assert accept_text == "Accept. 1. region:Songshanhu Administration Committee;" def test_target_webhook_accept_carries_the_confirmed_intent(_offline_env: Path) -> None: @@ -558,9 +538,8 @@ def test_target_webhook_accept_carries_the_confirmed_intent(_offline_env: Path) negotiation_answers=dict(_NEGOTIATION_ANSWERS), ) ) - accept_data = prompt_client.accept_calls[0] - assert isinstance(accept_data.content, TargetEndingContent) - assert accept_data.content.confirmed_intent == negotiation_target_confirmation(env_path=_offline_env) + accept_text = prompt_client.accept_from_text_calls[0][0] + assert accept_text == f"Accept. {negotiation_target_confirmation(env_path=_offline_env)}" accept_logs = [line for line in logs if line.startswith("[client] negotiation-accept:")] assert "Negotiation-T/target-negotiation/accept-reject/v1" in accept_logs[0] @@ -577,16 +556,15 @@ def test_feasibility_webhook_accept_carries_the_feasibility_summary(_offline_env negotiation_answers=dict(_NEGOTIATION_ANSWERS), ) ) - accept_data = prompt_client.accept_calls[0] - assert isinstance(accept_data.content, FeasibilityEndingContent) - assert accept_data.content.feasibility_summary == negotiation_feasibility_summary(env_path=_offline_env) + accept_text = prompt_client.accept_from_text_calls[0][0] + assert accept_text == f"Accept. {negotiation_feasibility_summary(env_path=_offline_env)}" -# --- target fromText x webhook spot check (D8) --------------------------------------------------- +# --- target accept x webhook spot check ---------------------------------------------------------- -def test_target_from_text_channel_webhook_spot_check(_offline_env: Path) -> None: - """fromText accept generation works under webhook: rendered text -> extraction API call.""" +def test_target_accept_webhook_spot_check(_offline_env: Path) -> None: + """The accept generation works under webhook: rendered text -> extraction API call.""" _, a2a_client, prompt_client, _logs, _receiver = asyncio.run( _run_webhook_scenario( rounds=[ @@ -597,11 +575,9 @@ def test_target_from_text_channel_webhook_spot_check(_offline_env: Path) -> None negotiation_params=dict(_NEGOTIATION_PARAMS), negotiation_answers=dict(_NEGOTIATION_ANSWERS), negotiation_kind="target", - channel=CHANNEL_TEXT, ) ) - assert prompt_client.accept_calls == [] assert len(prompt_client.accept_from_text_calls) == 1 text, context, template_uri = prompt_client.accept_from_text_calls[0] assert text == f"Accept. {negotiation_target_confirmation(env_path=_offline_env)}" @@ -609,7 +585,7 @@ def test_target_from_text_channel_webhook_spot_check(_offline_env: Path) -> None assert context.performative == NegotiationPerformative.ACCEPT assert template_uri == TARGET_NEGOTIATION_ACCEPT_REJECT_URI - # Round-2 message shape unchanged across channels: send -> register -> send, COMPLETED end. + # Round-2 message shape: send -> register -> send, COMPLETED end. assert a2a_client.call_log == ["send:1", "register:1", "send:2"] accept_request = a2a_client.send_calls[1][0] metadata = _request_metadata(accept_request) @@ -630,7 +606,7 @@ def test_input_required_without_answers_is_unhandled_and_ends_after_the_initial_ assert len(a2a_client.send_calls) == 1 assert a2a_client.register_calls == [] - assert prompt_client.accept_calls == [] + assert prompt_client.accept_from_text_calls == [] unhandled_logs = [line for line in logs if line.startswith("[client] negotiation-unhandled:")] assert len(unhandled_logs) == 1 assert "no negotiation answers supplied" in unhandled_logs[0] @@ -649,7 +625,7 @@ def test_malformed_context_is_unhandled_and_ends_after_the_initial_phase_webhook ) assert len(a2a_client.send_calls) == 1 - assert prompt_client.accept_calls == [] + assert prompt_client.accept_from_text_calls == [] unhandled_logs = [line for line in logs if line.startswith("[client] negotiation-unhandled:")] assert len(unhandled_logs) == 1 assert "malformed/missing session context" in unhandled_logs[0] @@ -701,11 +677,9 @@ def test_injected_provider_webhook_replaces_the_scripted_accept_end_to_end(_offl # Order evidence: initial send -> endpoint re-registration -> accept send. assert a2a_client.call_log == ["send:1", "register:1", "send:2"] - # The typed accept carries the decision's confirmed intent (not the scenario text). - assert len(prompt_client.accept_calls) == 1 - accept_data = prompt_client.accept_calls[0] - assert isinstance(accept_data.content, TargetEndingContent) - assert accept_data.content.confirmed_intent == "Custom confirmed target" + # The accept text carries the decision's confirmed intent (not the scenario text). + assert len(prompt_client.accept_from_text_calls) == 1 + assert prompt_client.accept_from_text_calls[0][0] == "Accept. Custom confirmed target" assert _event_states(events)[-1] == "TASK_STATE_COMPLETED" summary_lines = [line for line in logs if line.startswith("[client] stream-completed:")] assert summary_lines == [ @@ -728,7 +702,7 @@ def test_reject_decision_webhook_logs_and_ends_after_the_initial_phase(_offline_ assert a2a_client.call_log == ["send:1"] assert a2a_client.register_calls == [] - assert prompt_client.accept_calls == [] + assert prompt_client.accept_from_text_calls == [] decision_logs = [line for line in logs if line.startswith("[client] negotiation-decision:")] assert decision_logs == [ "[client] negotiation-decision: reject (not implemented in this demo; see the sample README)" @@ -782,7 +756,7 @@ def test_initial_phase_rejected_ends_the_flow_promptly_with_no_accept_sent(_offl # The answers were supplied, but a terminal REJECTED preempts the negotiation round trip. assert len(a2a_client.send_calls) == 1 assert a2a_client.register_calls == [] - assert prompt_client.accept_calls == [] + assert prompt_client.accept_from_text_calls == [] summary_lines = [line for line in logs if line.startswith("[client] stream-completed:")] assert summary_lines == [ "[client] stream-completed: events=2 artifacts=0 transport=webhook end=TASK_STATE_REJECTED" From ee2cec546acb3629e782634cb17bcb6aba6ce8c2 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 01:09:07 +0800 Subject: [PATCH 19/30] docs(sample): document the from-text accept and drop the channel switch - the per-kind tables and code blocks use generate_negotiation_accept_prompt_from_text, with the accept text built from the decision (numbered values / confirmed intent / summary) - section 4.8 ("structured data or natural language") and the --channel / A2AT_SAMPLE_CHANNEL rows are removed, since every negotiation message is now generated from text - the "remap codes / unmapped failure is rejected" parenthetical in 4.2 is removed as requested Signed-off-by: codetalks-hw --- a2a-t-sample/README-zh.md | 2 +- a2a-t-sample/README.md | 2 +- a2a-t-sample/ran-energy-saving/README-zh.md | 66 +++++++------------- a2a-t-sample/ran-energy-saving/README.md | 68 +++++++-------------- 4 files changed, 48 insertions(+), 90 deletions(-) diff --git a/a2a-t-sample/README-zh.md b/a2a-t-sample/README-zh.md index 07b1b064..a80117b6 100644 --- a/a2a-t-sample/README-zh.md +++ b/a2a-t-sample/README-zh.md @@ -13,4 +13,4 @@ | 目录 | 说明 | | --- | --- | | [fault-management/](fault-management/) | 事件订阅样例——流式推送 Incident 通知 | -| [ran-energy-saving/](ran-energy-saving/) | 无线网络节能样例——运行 RAN 节能任务,并包含三类 Negotiation-T 协商(信息 / 目标 / 可行性),协商消息可从结构化数据或自然语言生成,另支持可选的 webhook 传输(`--transport webhook`):全部任务事件以 A2A 推送通知回调送达客户端内嵌的接收器 | +| [ran-energy-saving/](ran-energy-saving/) | 无线网络节能样例——运行 RAN 节能任务,并包含三类 Negotiation-T 协商(信息 / 目标 / 可行性),协商消息由自然语言业务文本生成,另支持可选的 webhook 传输(`--transport webhook`):全部任务事件以 A2A 推送通知回调送达客户端内嵌的接收器 | diff --git a/a2a-t-sample/README.md b/a2a-t-sample/README.md index be0a826d..9b6b722a 100644 --- a/a2a-t-sample/README.md +++ b/a2a-t-sample/README.md @@ -13,4 +13,4 @@ See the repository root [`README.md`](../README.md) for setup and how to run. | Directory | Description | | --- | --- | | [fault-management/](fault-management/) | Event subscription sample — stream Incident notifications | -| [ran-energy-saving/](ran-energy-saving/) | RAN energy saving sample — run a RAN energy-saving task, including Negotiation-T negotiations in three kinds (information / target / feasibility), generated either from structured data or from natural language, plus an optional webhook transport (`--transport webhook`) that delivers every task event as an A2A push-notification callback to a client-embedded receiver | +| [ran-energy-saving/](ran-energy-saving/) | RAN energy saving sample — run a RAN energy-saving task, including Negotiation-T negotiations in three kinds (information / target / feasibility), with messages generated from natural-language business text, plus an optional webhook transport (`--transport webhook`) that delivers every task event as an A2A push-notification callback to a client-embedded receiver | diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 9f8c64b4..563c8a6d 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -24,7 +24,6 @@ - [4.5 目标协商(target)](#45-目标协商target) - [4.6 可行性协商(feasibility)](#46-可行性协商feasibility) - [4.7 reject 与 abort](#47-reject-与-abort) - - [4.8 从结构化数据生成,还是从自然语言生成](#48-从结构化数据生成还是从自然语言生成) - [5. 替换清单(demo 到生产)](#5-替换清单demo-到生产) - [6. 附录](#6-附录) - [6.1 Webhook 传输](#61-webhook-传输) @@ -167,8 +166,7 @@ async for stream_response in a2a_client.send_message(request, context=call_conte 只对收到的 propose 作出反应。本样例是数据驱动的:首个内容校验错误的错误码通过 `_NEGOTIATION_TYPE_BY_ERROR_CODE` 表 ([`server_flow.py:55`](src/server/server_flow.py#L55))选出类型。这张表是**样例约定,不是 SDK -契约** —— server 实现可随意改自己的副本(重映射、新增、或干脆不用映射、按自己的业务规则判断; -未映射的失败就是普通拒绝)。 +契约**。 报文并不依赖这张表。每个 propose 都记录自己的 `templateUri` —— 即 metadata 中这段报文由哪个 Prompt 模板渲染而来,例如 `Negotiation-T/information-negotiation/propose/v1`。这个 URI 同时告诉 @@ -231,8 +229,8 @@ flow 根据 client 侧的决策重新生成 Task-T 提示词、构建带同轮 ` | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | | 服务端 propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | -| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:752`](src/client/client_flow.py#L752) | -| 客户端 accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | +| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:700`](src/client/client_flow.py#L700) | +| 客户端 accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py:619`](src/client/client_flow.py#L619) | ```python # 服务端 propose:把业务报文写成文本,再经 from-text API 渲染 @@ -245,20 +243,16 @@ propose = server.generate_negotiation_propose_prompt_from_text( # 客户端决策:询问 provider 如何应答 propose decision = provider.respond(propose_view, session) # filled_params = 要补的取值 -# 客户端 accept:根据决策渲染 accept -accept = client.generate_negotiation_accept_prompt_from_data( - NegotiationEndingData( - context, - InformationEndingContent( - conclusion=ACCEPT, - items=[NegotiationItem(name=n, value=v) for n, v in decision.filled_params.items()], - ), - ), +# 客户端 accept:把应答写成业务报文,再经 from-text API 渲染 +accept_text = f"Accept. {_numbered_items(decision.filled_params.items())}" +accept = client.generate_negotiation_accept_prompt_from_text( + accept_text, + context, INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, ) ``` -propose 的 `items` 是缺失的参数;accept 的 `items` 是补全后的取值。 +propose 列出缺失的参数;accept 文本列出补全后的取值。 **③ client 侧可以原样复用的部分。** 提示词渲染、`templateUri`/`negotiationContext` 的 metadata 组装、 会话处理与传输 —— `client_flow.py` 中除决策本身以外的全部内容。 @@ -282,8 +276,8 @@ class MyProvider: | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | | 服务端 propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | -| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:752`](src/client/client_flow.py#L752) | -| 客户端 accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | +| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:700`](src/client/client_flow.py#L700) | +| 客户端 accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py:619`](src/client/client_flow.py#L619) | ```python # 服务端 propose:把业务报文写成文本,再经 from-text API 渲染 @@ -296,12 +290,11 @@ propose = server.generate_negotiation_propose_prompt_from_text( # 客户端决策:询问 provider 如何应答 propose decision = provider.respond(propose_view, session) # confirmed_intent = 修正后的目标 -# 客户端 accept:根据决策渲染 accept -accept = client.generate_negotiation_accept_prompt_from_data( - NegotiationEndingData( - context, - TargetEndingContent(conclusion=ACCEPT, confirmed_intent=decision.confirmed_intent), - ), +# 客户端 accept:把应答写成业务报文,再经 from-text API 渲染 +accept_text = f"Accept. {decision.confirmed_intent}" +accept = client.generate_negotiation_accept_prompt_from_text( + accept_text, + context, TARGET_NEGOTIATION_ACCEPT_REJECT_URI, ) ``` @@ -324,8 +317,8 @@ accept = client.generate_negotiation_accept_prompt_from_data( | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | | 服务端 propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | -| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:752`](src/client/client_flow.py#L752) | -| 客户端 accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | +| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:700`](src/client/client_flow.py#L700) | +| 客户端 accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py:619`](src/client/client_flow.py#L619) | ```python # 服务端 propose:把业务报文写成文本,再经 from-text API 渲染 @@ -338,12 +331,11 @@ propose = server.generate_negotiation_propose_prompt_from_text( # 客户端决策:询问 provider 如何应答 propose decision = provider.respond(propose_view, session) # feasibility_summary = 应答内容 -# 客户端 accept:根据决策渲染 accept -accept = client.generate_negotiation_accept_prompt_from_data( - NegotiationEndingData( - context, - FeasibilityEndingContent(conclusion=ACCEPT, feasibility_summary=decision.feasibility_summary), - ), +# 客户端 accept:把应答写成业务报文,再经 from-text API 渲染 +accept_text = f"Accept. {decision.feasibility_summary}" +accept = client.generate_negotiation_accept_prompt_from_text( + accept_text, + context, FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, ) ``` @@ -364,16 +356,6 @@ reject/abort 决策只会打日志并结束流程,不发送报文。需要发 (`generate_negotiation_reject_prompt_from_data` / `..._from_text`,以及 `generate_negotiation_abort_prompt_from_data` / `..._from_text`)。 -### 4.8 从结构化数据生成,还是从自然语言生成 - -**propose 固定按业务报文生成**:服务端写好文本,A2A-T SDK 抽取成结构化内容后渲染模板(一次抽取调用)。 - -**accept** 两种方式都支持,由 `--channel` 选择: - -- **从结构化数据生成(默认)**:client 在代码里构造类型化内容,A2A-T SDK 直接渲染模板,不调用模型。 -- **从自然语言生成**(`--channel fromText`):client 把 accept 写成一段自然语言,A2A-T SDK 先让模型抽取成 - 结构化内容,再渲染模板。 - ## 5. 替换清单(demo 到生产) **归属**列标明该项由哪一端负责。client 团队只需处理标注为 client 的行;server 行说明对端换成真实系统 @@ -438,7 +420,6 @@ webhook 模式下的变化:客户端非阻塞发送(`return_immediately`) | `A2AT_LANGUAGE` | `zh-CN` / `en-US`;决定自然语言输入与 mock/模板语言树 | | `A2AT_SAMPLE_SCENARIO` | `default` / `negotiation`;样例场景变体(与 `--scenario` 语义相同) | | `A2AT_SAMPLE_NEGOTIATION_KIND` | `information` / `target` / `feasibility`(默认 `information`);协商场景输入,与 `--negotiation` 语义相同 | -| `A2AT_SAMPLE_CHANNEL` | `data` / `text`(默认 `data`);client 的 accept 消息从结构化数据生成还是从自然语言生成,与 `--channel` 语义相同 | | `A2AT_SAMPLE_TRANSPORT` | `sse` / `webhook`(默认 `sse`);客户端事件传输方式,与 `--transport` 语义相同 | | `A2AT_SAMPLE_WEBHOOK_PORT` | 内嵌 webhook 接收器的绑定端口(默认 `9000`;`0` = 操作系统分配临时端口) | | `A2AT_SAMPLE_WEBHOOK_TOKEN` | 服务端在 `X-A2A-Notification-Token` 头中必须携带的推送 token(默认 `demo-webhook-token`);不匹配回 `401` | @@ -498,7 +479,6 @@ uv run --no-sync python -m client.client_main | `--language {en-US,zh-CN}` | 运行语言(默认 `en-US`) | | `--scenario {default,negotiation}` | 场景变体(默认 `default`;`negotiation` 运行缺失 region 的信息协商) | | `--negotiation {information,target,feasibility}` | 协商类型,选择客户端场景输入(默认 `information` = 缺失 region;`target` = 分时段保障速率目标中两个时段重叠;`feasibility` = 保障速率无法支撑 18 小时停电)。`information` 仍受 `--scenario` 开关控制(只有 `--scenario negotiation` 运行缺失 region 流程);`target`/`feasibility` 无需该开关即可触发。该参数只选择输入 —— 服务端依据内容校验错误码数据驱动地分派 | -| `--channel {fromData,fromText}` | client 的 accept 消息怎么生成:从结构化数据(`fromData`,默认)或从自然语言(`fromText`,多一次抽取模型调用) | | `--transport {sse,webhook}` | 客户端的事件传输方式(默认 `sse`);见 [§6.1](#61-webhook-传输) | | `--webhook-port N` | `--transport webhook` 时内嵌 webhook 接收器的绑定端口(默认 9000;`0` 表示由操作系统分配临时端口) | | `--keep-alive` | 保留注册中心/服务端不停止;之后用 `--stop` 关闭 | diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 8fdca9eb..44c437d3 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -26,7 +26,6 @@ unspecified reader. - [4.5 Target negotiation](#45-target-negotiation) - [4.6 Feasibility negotiation](#46-feasibility-negotiation) - [4.7 Reject and abort](#47-reject-and-abort) - - [4.8 Generating messages from structured data or from natural language](#48-generating-messages-from-structured-data-or-from-natural-language) - [5. Replace checklist (demo → production)](#5-replace-checklist-demo--production) - [6. Appendix](#6-appendix) - [6.1 Webhook transport](#61-webhook-transport) @@ -184,8 +183,7 @@ decision; the SDKs have no opinion** — and the client only reacts to the propo demo it is data-driven: the first content-validation error's code selects the kind, via the `_NEGOTIATION_TYPE_BY_ERROR_CODE` table ([`server_flow.py:55`](src/server/server_flow.py#L55)). That table is a **sample convention, not an -SDK contract** — the server implementation edits its copy freely (remap codes, add its own, or -drop the mapping and decide from its own business rules; an unmapped failure is simply rejected). +SDK contract**. The message format does not depend on that table. Each propose records its `templateUri` — the prompt template its message text was rendered from, such as @@ -259,8 +257,8 @@ that code is the sample's choice, not part of the protocol. | Step | SDK call | Reference code | | --- | --- | --- | | Server propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | -| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:752`](src/client/client_flow.py#L752) | -| Client accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | +| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:700`](src/client/client_flow.py#L700) | +| Client accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py:619`](src/client/client_flow.py#L619) | ```python # Server propose: write the business message, then render it through the from-text API @@ -273,20 +271,16 @@ propose = server.generate_negotiation_propose_prompt_from_text( # Client decision: ask the provider how to answer the propose decision = provider.respond(propose_view, session) # filled_params = the values to deliver -# Client accept: render the typed accept from the decision -accept = client.generate_negotiation_accept_prompt_from_data( - NegotiationEndingData( - context, - InformationEndingContent( - conclusion=ACCEPT, - items=[NegotiationItem(name=n, value=v) for n, v in decision.filled_params.items()], - ), - ), +# Client accept: write the answer as a business message and render it through the from-text API +accept_text = f"Accept. {_numbered_items(decision.filled_params.items())}" +accept = client.generate_negotiation_accept_prompt_from_text( + accept_text, + context, INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, ) ``` -The propose's `items` are the missing parameters; the accept's `items` are the filled values. +The propose lists the missing parameters; the accept text lists the filled values. **③ What the client implementation can copy as-is.** The prompt rendering, the `templateUri`/`negotiationContext` metadata assembly, the session handling, and the transport — everything in `client_flow.py` except @@ -313,8 +307,8 @@ ambiguous. The demo's trigger is a `content.value_not_allowed` validation error. | Step | SDK call | Reference code | | --- | --- | --- | | Server propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | -| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:752`](src/client/client_flow.py#L752) | -| Client accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | +| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:700`](src/client/client_flow.py#L700) | +| Client accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py:619`](src/client/client_flow.py#L619) | ```python # Server propose: write the business message, then render it through the from-text API @@ -327,12 +321,11 @@ propose = server.generate_negotiation_propose_prompt_from_text( # Client decision: ask the provider how to answer the propose decision = provider.respond(propose_view, session) # confirmed_intent = the corrected target -# Client accept: render the typed accept from the decision -accept = client.generate_negotiation_accept_prompt_from_data( - NegotiationEndingData( - context, - TargetEndingContent(conclusion=ACCEPT, confirmed_intent=decision.confirmed_intent), - ), +# Client accept: write the answer as a business message and render it through the from-text API +accept_text = f"Accept. {decision.confirmed_intent}" +accept = client.generate_negotiation_accept_prompt_from_text( + accept_text, + context, TARGET_NEGOTIATION_ACCEPT_REJECT_URI, ) ``` @@ -359,8 +352,8 @@ trigger is a `content.rule_violation` validation error. | Step | SDK call | Reference code | | --- | --- | --- | | Server propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | -| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:752`](src/client/client_flow.py#L752) | -| Client accept | `generate_negotiation_accept_prompt_from_data` | [`client_flow.py:659`](src/client/client_flow.py#L659) | +| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:700`](src/client/client_flow.py#L700) | +| Client accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py:619`](src/client/client_flow.py#L619) | ```python # Server propose: write the business message, then render it through the from-text API @@ -373,12 +366,11 @@ propose = server.generate_negotiation_propose_prompt_from_text( # Client decision: ask the provider how to answer the propose decision = provider.respond(propose_view, session) # feasibility_summary = the answer -# Client accept: render the typed accept from the decision -accept = client.generate_negotiation_accept_prompt_from_data( - NegotiationEndingData( - context, - FeasibilityEndingContent(conclusion=ACCEPT, feasibility_summary=decision.feasibility_summary), - ), +# Client accept: write the answer as a business message and render it through the from-text API +accept_text = f"Accept. {decision.feasibility_summary}" +accept = client.generate_negotiation_accept_prompt_from_text( + accept_text, + context, FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, ) ``` @@ -401,18 +393,6 @@ message. The SDK provides the matching generation methods (`generate_negotiation_reject_prompt_from_data` / `..._from_text`, and `generate_negotiation_abort_prompt_from_data` / `..._from_text`) when a client needs to send one. -### 4.8 Generating messages from structured data or from natural language - -The **propose** is always written as a business message: the server composes the text, and the A2A-T SDK -extracts the structured content and renders the template (one extraction call). - -The **accept** can be generated either way, selected by `--channel`: - -- **from structured data (default)** — the client builds the typed content in code; the A2A-T SDK renders - the template directly, with no model call. -- **from natural language** (`--channel fromText`) — the client writes the accept as plain text; the - SDK asks the model to extract the structured content first, then renders the template. - ## 5. Replace checklist (demo → production) The **Side** column says which agent owns the piece. The client team's rows are the ones to act on; @@ -487,7 +467,6 @@ transports. | `A2AT_LANGUAGE` | `zh-CN` / `en-US`; selects the natural-language input and the mock/template tree | | `A2AT_SAMPLE_SCENARIO` | `default` / `negotiation`; selects the sample scenario variant (same semantics as `--scenario`) | | `A2AT_SAMPLE_NEGOTIATION_KIND` | `information` / `target` / `feasibility` (default `information`); negotiation scenario input, same semantics as `--negotiation` | -| `A2AT_SAMPLE_CHANNEL` | `data` / `text` (default `data`); how the client generates its accept message — from structured data or from natural language, same semantics as `--channel` | | `A2AT_SAMPLE_TRANSPORT` | `sse` / `webhook` (default `sse`); client event transport, same semantics as `--transport` | | `A2AT_SAMPLE_WEBHOOK_PORT` | Bind port of the embedded webhook receiver (default `9000`; `0` = OS-assigned ephemeral port) | | `A2AT_SAMPLE_WEBHOOK_TOKEN` | Push token the server must carry in the `X-A2A-Notification-Token` header (default `demo-webhook-token`); a mismatch answers `401` | @@ -550,7 +529,6 @@ content-validation responses of [`resources/mock_llm/{zh-CN,en-US}/`](resources/ | `--language {en-US,zh-CN}` | Run language (default `en-US`) | | `--scenario {default,negotiation}` | Scenario variant (default `default`; `negotiation` runs the missing-region information negotiation) | | `--negotiation {information,target,feasibility}` | Negotiation kind selecting the client scenario input (default `information` = missing region; `target` = two overlapping time segments in the guaranteed throughput target; `feasibility` = the guaranteed rate cannot sustain an 18-hour power outage). `information` keeps the `--scenario` switch (only `--scenario negotiation` runs the missing-region flow); `target`/`feasibility` engage without it. The flag only selects the input — the server stays data-driven on the validation error code | -| `--channel {fromData,fromText}` | How the client generates its accept message: from structured data (`fromData`, default) or from natural language (`fromText`, one extra extraction model call) | | `--transport {sse,webhook}` | Event transport of the client (default `sse`); see [§6.1](#61-webhook-transport) | | `--webhook-port N` | Bind port of the embedded webhook receiver used with `--transport webhook` (default 9000; `0` selects an OS-assigned ephemeral port) | | `--keep-alive` | Leave registry/server running; stop later with `--stop` | From 84e12ba85178a9e4dc1ffc5cbd40d3a403f25b98 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 09:51:26 +0800 Subject: [PATCH 20/30] docs(sample): use formal section headings in the negotiation chapter Signed-off-by: codetalks-hw --- a2a-t-sample/README-zh.md | 2 +- a2a-t-sample/README.md | 2 +- a2a-t-sample/ran-energy-saving/README-zh.md | 80 +++++++++--------- a2a-t-sample/ran-energy-saving/README.md | 91 +++++++++++---------- 4 files changed, 88 insertions(+), 87 deletions(-) diff --git a/a2a-t-sample/README-zh.md b/a2a-t-sample/README-zh.md index a80117b6..f9d5d495 100644 --- a/a2a-t-sample/README-zh.md +++ b/a2a-t-sample/README-zh.md @@ -13,4 +13,4 @@ | 目录 | 说明 | | --- | --- | | [fault-management/](fault-management/) | 事件订阅样例——流式推送 Incident 通知 | -| [ran-energy-saving/](ran-energy-saving/) | 无线网络节能样例——运行 RAN 节能任务,并包含三类 Negotiation-T 协商(信息 / 目标 / 可行性),协商消息由自然语言业务文本生成,另支持可选的 webhook 传输(`--transport webhook`):全部任务事件以 A2A 推送通知回调送达客户端内嵌的接收器 | +| [ran-energy-saving/](ran-energy-saving/) | 无线网络节能样例——运行 RAN 节能任务,并包含三类 Negotiation-T 协商(信息 / 目标 / 可行性) | diff --git a/a2a-t-sample/README.md b/a2a-t-sample/README.md index 9b6b722a..0e975e08 100644 --- a/a2a-t-sample/README.md +++ b/a2a-t-sample/README.md @@ -13,4 +13,4 @@ See the repository root [`README.md`](../README.md) for setup and how to run. | Directory | Description | | --- | --- | | [fault-management/](fault-management/) | Event subscription sample — stream Incident notifications | -| [ran-energy-saving/](ran-energy-saving/) | RAN energy saving sample — run a RAN energy-saving task, including Negotiation-T negotiations in three kinds (information / target / feasibility), with messages generated from natural-language business text, plus an optional webhook transport (`--transport webhook`) that delivers every task event as an A2A push-notification callback to a client-embedded receiver | +| [ran-energy-saving/](ran-energy-saving/) | RAN energy saving sample — run a RAN energy-saving task, including Negotiation-T negotiations in three kinds (information / target / feasibility) | diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 563c8a6d..11fba9aa 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -17,14 +17,14 @@ - [3.3 服务端侧](#33-服务端侧) - [3.4 client 侧要替换什么](#34-client-侧要替换什么) - [4. 使用 A2A-T SDK 实现 Negotiation-T 交互](#4-使用-a2a-t-sdk-实现-negotiation-t-交互) - - [4.1 什么时候会发生协商](#41-什么时候会发生协商) - - [4.2 样例如何判断属于哪一种](#42-样例如何判断属于哪一种) - - [4.3 共享机制](#43-共享机制) + - [4.1 协商场景](#41-协商场景) + - [4.2 样例如何选择协商类型](#42-样例如何选择协商类型) + - [4.3 通用机制](#43-通用机制) - [4.4 信息协商(information)](#44-信息协商information) - [4.5 目标协商(target)](#45-目标协商target) - [4.6 可行性协商(feasibility)](#46-可行性协商feasibility) - [4.7 reject 与 abort](#47-reject-与-abort) -- [5. 替换清单(demo 到生产)](#5-替换清单demo-到生产) +- [5. 部署清单](#5-部署清单) - [6. 附录](#6-附录) - [6.1 Webhook 传输](#61-webhook-传输) - [6.2 配置(a2a-t-sample/.env)](#62-配置a2a-t-sampleenv) @@ -34,22 +34,21 @@ ## 1. 本样例演示了什么 -一个客户端 Agent 通过 A2A 委托一个 RAN 域 Agent 执行节能任务。在这一个场景上,样例按顺序覆盖客户 -需要集成的两项 A2A-T 能力: +一个客户端 Agent 通过 A2A 协议委托一个 RAN 域 Agent 执行节能任务。在这一个场景上,样例按顺序覆盖 +客户需要集成的两项 A2A-T 能力: ```text Task-T 自然语言 ──> 任务提示词 ──> 校验 ──> 执行 ──> 报告 -Negotiation-T 请求不能直接执行时(信息缺失 / 目标不清晰 / 可行性不确定) - ──> 双方 Agent 协商 ──> 达成一致 ──> 任务随后按 Task-T 执行 +Negotiation-T 请求需要补全或修正后才能执行时(信息缺失 / 目标不明确 / 目标不可行) + ──> 双方协商 ──> 达成一致 ──> 任务执行 ``` -按三种阅读路径之一阅读: +本文后续内容按三种使用路径组织: -- **"先跑起来"** —— [§2 快速开始](#2-快速开始)。 -- **"我要集成一次性任务"** —— [§3 Task-T 交互](#3-使用-a2a-t-sdk-实现-task-t-交互)加上 - [替换清单](#5-替换清单demo-到生产)。 -- **"我要集成协商"** —— [§4 Negotiation-T 交互](#4-使用-a2a-t-sdk-实现-negotiation-t-交互)。 +- **运行样例** —— [§2 快速开始](#2-快速开始)。 +- **实现 Task-T 交互** —— [§3](#3-使用-a2a-t-sdk-实现-task-t-交互)与[部署清单](#5-部署清单)。 +- **实现 Negotiation-T 交互** —— [§4](#4-使用-a2a-t-sdk-实现-negotiation-t-交互)。 ### 1.1 目录结构 @@ -58,7 +57,7 @@ Negotiation-T 请求不能直接执行时(信息缺失 / 目标不清晰 / | [`src/client/client_main.py`](src/client/client_main.py) | 装配根:读取 `.env`、构建运行时(`build_client_runtime`)、从注册中心发现服务端 AgentCard、启动/停止 webhook 接收器、装配 demo 的 `ScriptedNegotiationResponseProvider`。客户端侧 `INTEGRATION POINT` 标注位于 `main()`。 | | [`src/client/client_flow.py`](src/client/client_flow.py) | 编排:消息序列(生成 → 发送 → 消费)、报文机制(metadata 键、`A2A-Extensions` 头)、事件管线,以及两种传输驱动(SSE 流式 / webhook 队列)。它调用协商决策接入点,但不持有任何决策。 | | [`src/client/negotiation_response.py`](src/client/negotiation_response.py) | 协商决策接入点:`NegotiationProposeView`、`NegotiationDecision`、`NegotiationResponseProvider` Protocol,以及 demo 实现 `ScriptedNegotiationResponseProvider`。 | -| [`src/client/scenario_data.py`](src/client/scenario_data.py) | 样例输入与脚本化应答 —— 它**同时喂两侧**(见[替换清单](#5-替换清单demo-到生产)),不能原样删除。 | +| [`src/client/scenario_data.py`](src/client/scenario_data.py) | 样例输入与脚本化应答 —— 它**同时喂两侧**(见[部署清单](#5-部署清单)),不能原样删除。 | | [`src/server/server_main.py`](src/server/server_main.py) | a2a-sdk 应用装配,推送通知机制无条件装配。服务端侧 `INTEGRATION POINT` 标注位于 `build_server_runtime`。 | | [`src/server/server_flow.py`](src/server/server_flow.py) | 服务端流程:校验 → 协商或执行 → 流式推送进度与报告。 | | [`src/server/task_execution.py`](src/server/task_execution.py) | `TaskExecutionService` 执行接入点,以及 demo 的 UC1 服务。 | @@ -134,7 +133,7 @@ async for stream_response in a2a_client.send_message(request, context=call_conte (`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`)与 schema 都是样例 特有的,因此 client 实现要换成自己的([`scenario_data.py`](src/client/scenario_data.py));服务端 `TaskExecutionService` 接入点之后的业务执行属于 server agent(见 -[替换清单](#5-替换清单demo-到生产))。 +[部署清单](#5-部署清单))。 ## 4. 使用 A2A-T SDK 实现 Negotiation-T 交互 @@ -143,10 +142,10 @@ async for stream_response in a2a_client.send_message(request, context=call_conte 业务由两端各自负责:server agent 决定请求何时无法按原样执行、值得先谈一谈;client agent 决定如何 应答收到的 propose。 -### 4.1 什么时候会发生协商 +### 4.1 协商场景 -服务端无法按请求原样执行,但这个差距又是双方**谈一谈就能补齐**、而不是只能拒绝的时候。样例覆盖三种 -情况: +服务端无法按收到的请求执行,且该差距可以通过协商解决而不必直接拒绝请求时,由服务端开启协商。样例 +覆盖三种情况: **信息不完整。** 客户端要求执行节能,给了目标、时段和吞吐目标,却没说在哪个区域。服务端缺这一项 无法开工,于是告诉客户端缺什么;客户端补上,任务继续。 @@ -160,7 +159,7 @@ async for stream_response in a2a_client.send_message(request, context=call_conte 速率执行,电池撑不到停电结束。服务端给出不可行的结论和提案(把 06:00~18:00 的保障速率从 10Mbps 下调 到 2Mbps);客户端接受调整后的目标。 -### 4.2 样例如何判断属于哪一种 +### 4.2 样例如何选择协商类型 判断属于哪种情况、以及到底要不要协商,是**server agent 的业务决策,两个 SDK 都不持立场** —— client 只对收到的 propose 作出反应。本样例是数据驱动的:首个内容校验错误的错误码通过 @@ -173,13 +172,13 @@ Prompt 模板渲染而来,例如 `Negotiation-T/information-negotiation/propos 客户端收到的是哪一类协商,因此客户端按收到的消息决定应答 —— accept、reject 或 abort —— 而不是按 错误码。 -### 4.3 共享机制 +### 4.3 通用机制 -以下对三类协商通用,各类型小节不再重复。 +以下机制对三类协商均适用,各类型小节不再重复。 -**一轮 = 一次 propose 加上对它的应答。** 会话上下文携带 1 起始的 `round`;应答沿用 propose 的轮次, -只有**新的** propose 才会推进轮次。客户端最初那条请求不算协商轮 —— 协商从服务端的 propose 才开始。 -`maxRounds`(默认 5)限制会话轮次。 +**轮次。** 一轮由一次 propose 和针对该 propose 的应答(accept 或 reject)构成。会话上下文携带从 1 +开始的 `round`:应答沿用其所回应的 propose 的轮次,只有**新的** propose 才推进轮次。客户端最初的 +请求不计入协商轮次 —— 第一轮从服务端的 propose 开始。`maxRounds`(默认 5)限制会话轮数。 **双方都可以发起 propose。** 协商是对称的:客户端与服务端 SDK 都提供同样的四个生成方法 (propose / accept / reject / abort)和同样的校验方法,任何一方都能发起协商,任何一方也都能应答。 @@ -221,10 +220,10 @@ flow 根据 client 侧的决策重新生成 Task-T 提示词、构建带同轮 ` ### 4.4 信息协商(information) -**① 服务端何时开启。** 请求不完整 —— 缺少服务端必需的参数(样例中为区域)。样例的触发条件是 +**① 触发条件。** 请求不完整 —— 缺少服务端必需的参数(样例中为区域)。样例的触发条件是 `content.param_missing` 校验错误,但这个错误码是样例的判断方式,不属于协议本身。 -**② 参考代码做了什么。** +**② 参考实现。** | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | @@ -254,10 +253,10 @@ accept = client.generate_negotiation_accept_prompt_from_text( propose 列出缺失的参数;accept 文本列出补全后的取值。 -**③ client 侧可以原样复用的部分。** 提示词渲染、`templateUri`/`negotiationContext` 的 metadata 组装、 +**③ 可复用的客户端代码。** 提示词渲染、`templateUri`/`negotiationContext` 的 metadata 组装、 会话处理与传输 —— `client_flow.py` 中除决策本身以外的全部内容。 -**④ client 侧必须自己实现的部分。** provider 接入点背后的应答来源。 +**④ 客户端自行实现的代码。** provider 接入点背后的应答来源。 ```python class MyProvider: @@ -268,10 +267,10 @@ class MyProvider: ### 4.5 目标协商(target) -**① 服务端何时开启。** 请求自相矛盾:分时段保障速率目标里有两个时段重叠,重叠时段的保障速率无法 +**① 触发条件。** 请求自相矛盾:分时段保障速率目标里有两个时段重叠,重叠时段的保障速率无法 唯一确定。样例的触发条件是 `content.value_not_allowed` 校验错误。 -**② 参考代码做了什么。** +**② 参考实现。** | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | @@ -302,17 +301,17 @@ accept = client.generate_negotiation_accept_prompt_from_text( 样例用的是**澄清**形态:服务端复述它理解的意图(`intent_understanding`),并请求调整时间 (`request_for_clarification`)。accept 把修正后的目标放进 `confirmed_intent`。 -**③ client 侧可以原样复用的部分。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 +**③ 可复用的客户端代码。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 -**④ client 侧必须自己实现的部分。** 如何得出 `confirmed_intent` —— 样例从 `scenario_data` -读取;真实 client 的值来自把歧义解决掉的那一方。 +**④ 客户端自行实现的代码。** 如何得出 `confirmed_intent`:样例从 `scenario_data` 读取,客户端 +实现则从消解歧义的组件获取。 ### 4.6 可行性协商(feasibility) -**① 服务端何时开启。** 请求在当前条件下可能做不到:保障速率无法支撑所声明站点的停电时长。样例的 +**① 触发条件。** 请求在当前条件下可能做不到:保障速率无法支撑所声明站点的停电时长。样例的 触发条件是 `content.rule_violation` 校验错误。 -**② 参考代码做了什么。** +**② 参考实现。** | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | @@ -344,10 +343,9 @@ accept = client.generate_negotiation_accept_prompt_from_text( (`action=PROPOSE_ALTERNATIVE_ON_FAILURE` 加 `infeasibility_details_and_proposal`);accept 说明 它同意的调整。 -**③ client 侧可以原样复用的部分。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 +**③ 可复用的客户端代码。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 -**④ client 侧必须自己实现的部分。** 应答内容 `feasibility_summary` —— 即 client 对服务端 -提案的答复。 +**④ 客户端自行实现的代码。** `feasibility_summary` 中的应答内容,即客户端对服务端提案的答复。 ### 4.7 reject 与 abort @@ -356,10 +354,10 @@ reject/abort 决策只会打日志并结束流程,不发送报文。需要发 (`generate_negotiation_reject_prompt_from_data` / `..._from_text`,以及 `generate_negotiation_abort_prompt_from_data` / `..._from_text`)。 -## 5. 替换清单(demo 到生产) +## 5. 部署清单 -**归属**列标明该项由哪一端负责。client 团队只需处理标注为 client 的行;server 行说明对端换成真实系统 -时要替换什么。 +**归属**列标明各组件由哪一端负责。client 团队处理标注为 client 的行;server 行说明对端为真实系统时 +需要替换的内容。 | demo 组成 | 归属 | demo 中的作用 | 保留还是替换 | | --- | --- | --- | --- | diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 44c437d3..c3665f6b 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -19,14 +19,14 @@ unspecified reader. - [3.3 Server side](#33-server-side) - [3.4 What the client replaces](#34-what-the-client-replaces) - [4. Using the A2A-T SDK to implement Negotiation-T interaction](#4-using-the-a2a-t-sdk-to-implement-negotiation-t-interaction) - - [4.1 When a negotiation happens](#41-when-a-negotiation-happens) - - [4.2 How the demo decides which one applies](#42-how-the-demo-decides-which-one-applies) - - [4.3 Shared mechanics](#43-shared-mechanics) + - [4.1 Negotiation scenarios](#41-negotiation-scenarios) + - [4.2 How the demo selects the negotiation kind](#42-how-the-demo-selects-the-negotiation-kind) + - [4.3 Mechanics common to all kinds](#43-mechanics-common-to-all-kinds) - [4.4 Information negotiation](#44-information-negotiation) - [4.5 Target negotiation](#45-target-negotiation) - [4.6 Feasibility negotiation](#46-feasibility-negotiation) - [4.7 Reject and abort](#47-reject-and-abort) -- [5. Replace checklist (demo → production)](#5-replace-checklist-demo--production) +- [5. Deployment checklist](#5-deployment-checklist) - [6. Appendix](#6-appendix) - [6.1 Webhook transport](#61-webhook-transport) - [6.2 Configuration (a2a-t-sample/.env)](#62-configuration-a2a-t-sampleenv) @@ -36,23 +36,25 @@ unspecified reader. ## 1. What this sample demonstrates -A client agent commissions a RAN domain agent to run an energy-saving task over A2A. On this one -scenario the sample covers the two A2A-T capabilities a customer integrates, in order: +A client agent commissions a RAN domain agent to run an energy-saving task over the A2A protocol. +On this single scenario the sample covers the two A2A-T capabilities a customer integrates, in +order: ```text -Task-T natural language ──> task prompt ──> validated ──> executed ──> reported +Task-T natural language ──> task prompt ──> validation ──> execution ──> report -Negotiation-T when the request is not executable as-is - (information missing / target unclear / feasibility uncertain) - ──> the two agents negotiate ──> agreed ──> the task then runs as Task-T +Negotiation-T when the request needs completion or correction before it can be executed + (information missing / target ambiguous / target infeasible) + ──> negotiation ──> agreement ──> task execution ``` -Read it one of three ways: +The remaining sections follow three usage paths: -- **"Just run it"** — [§2 Quickstart](#2-quickstart). -- **"I integrate the one-shot task"** — [§3 Task-T interaction](#3-using-the-a2a-t-sdk-to-implement-task-t-interaction) - plus the [replace checklist](#5-replace-checklist-demo--production). -- **"I integrate negotiation"** — [§4 Negotiation-T interaction](#4-using-the-a2a-t-sdk-to-implement-negotiation-t-interaction). +- **Run the sample** — [§2 Quickstart](#2-quickstart). +- **Implement the Task-T interaction** — [§3](#3-using-the-a2a-t-sdk-to-implement-task-t-interaction) + together with the [deployment checklist](#5-deployment-checklist). +- **Implement the Negotiation-T interaction** — + [§4](#4-using-the-a2a-t-sdk-to-implement-negotiation-t-interaction). ### 1.1 Project layout @@ -61,7 +63,7 @@ Read it one of three ways: | [`src/client/client_main.py`](src/client/client_main.py) | Assembly root: reads `.env`, builds the runtime (`build_client_runtime`), discovers the server's AgentCard from the registry, starts/stops the webhook receiver, and assembles the demo `ScriptedNegotiationResponseProvider`. The client-side `INTEGRATION POINT` comment sits in `main()`. | | [`src/client/client_flow.py`](src/client/client_flow.py) | Orchestration: the message sequence (generate → send → consume), the message handling (metadata keys, `A2A-Extensions` header), the event pipeline, and the two transport drivers (SSE streaming / webhook queue). It calls the negotiation decision integration point but owns no decision. | | [`src/client/negotiation_response.py`](src/client/negotiation_response.py) | The negotiation decision integration point: `NegotiationProposeView`, `NegotiationDecision`, the `NegotiationResponseProvider` Protocol, and the demo `ScriptedNegotiationResponseProvider`. | -| [`src/client/scenario_data.py`](src/client/scenario_data.py) | The sample's inputs and scripted answers — it feeds **both** sides (see the [replace checklist](#5-replace-checklist-demo--production)); not removable as-is. | +| [`src/client/scenario_data.py`](src/client/scenario_data.py) | The sample's inputs and scripted answers — it feeds **both** sides (see the [deployment checklist](#5-deployment-checklist)); not removable as-is. | | [`src/server/server_main.py`](src/server/server_main.py) | a2a-sdk app assembly, with the push-notification machinery assembled unconditionally. The server-side `INTEGRATION POINT` comment sits in `build_server_runtime`. | | [`src/server/server_flow.py`](src/server/server_flow.py) | The server flow: validate → negotiate-or-execute → stream progress and the report. | | [`src/server/task_execution.py`](src/server/task_execution.py) | The `TaskExecutionService` execution integration point plus the demo UC1 service. | @@ -142,7 +144,7 @@ its task request from them, and the server validates against them. This sample's sample-specific, so a client implementation brings its own ([`scenario_data.py`](src/client/scenario_data.py)). The business execution behind the server's `TaskExecutionService` integration point belongs to the server agent (see the -[replace checklist](#5-replace-checklist-demo--production)). +[deployment checklist](#5-deployment-checklist)). ## 4. Using the A2A-T SDK to implement Negotiation-T interaction @@ -153,11 +155,11 @@ pairs the two. The two agents own the business, each on its own side: the server a request cannot be carried out as-is and a conversation would settle it; the client agent decides how to answer the propose. -### 4.1 When a negotiation happens +### 4.1 Negotiation scenarios -A negotiation opens when the server cannot carry out the request as it stands, but the gap is -something the two agents can settle by talking rather than by refusing to act. The demo covers -three such situations: +The server opens a negotiation when it cannot execute the request as received, and the gap can be +resolved through negotiation rather than by rejecting the request. The sample covers three such +situations: **Information — the request is incomplete.** The client asks to run energy saving and names a goal, a time window and a throughput target, but never says which region. The server cannot start without @@ -176,7 +178,7 @@ that rate would drain the batteries faster than the outage lasts. The server rep is infeasible and proposes an alternative (lower the 06:00~18:00 rate from 10 Mbps to 2 Mbps); the client accepts the adjusted target. -### 4.2 How the demo decides which one applies +### 4.2 How the demo selects the negotiation kind Which situation applies — and whether to negotiate at all — is **the server agent's business decision; the SDKs have no opinion** — and the client only reacts to the propose it receives. In this @@ -191,14 +193,14 @@ template its message text was rendered from, such as propose it received, so the client picks its answer — accept, reject or abort — from the message it got, not from any error code. -### 4.3 Shared mechanics +### 4.3 Mechanics common to all kinds These apply to all three kinds; the per-kind sections below do not repeat them. -**A round is one propose plus the answer to it.** The session context carries a 1-based `round`; -the answer keeps the propose's round, and only a *new* propose advances it. The client's opening -request is not a negotiation round — the negotiation starts with the server's propose. -`maxRounds` (default 5) bounds the session. +**Round.** A round consists of one propose and the response to that propose (accept or reject). The +session context carries a 1-based `round`: a response keeps the round of the propose it answers, and +only a *new* propose advances the round. The client's opening request is not a negotiation round — +the first round begins with the server's propose. `maxRounds` (default 5) bounds the session. **Either side can propose.** Negotiation is symmetric: the client and the server SDK expose the same four generation methods (propose / accept / reject / abort) and the same validation methods, so @@ -248,11 +250,11 @@ that internally. ### 4.4 Information negotiation -**① When the server opens it.** The request is incomplete — it lacks a parameter the server needs +**① Trigger condition.** The request is incomplete — it lacks a parameter the server needs (in the demo, the region). The demo's trigger is a `content.param_missing` validation error, but that code is the sample's choice, not part of the protocol. -**② What the reference code does.** +**② Reference implementation.** | Step | SDK call | Reference code | | --- | --- | --- | @@ -282,11 +284,11 @@ accept = client.generate_negotiation_accept_prompt_from_text( The propose lists the missing parameters; the accept text lists the filled values. -**③ What the client implementation can copy as-is.** The prompt rendering, the `templateUri`/`negotiationContext` +**③ Reusable client code.** The prompt rendering, the `templateUri`/`negotiationContext` metadata assembly, the session handling, and the transport — everything in `client_flow.py` except the decision itself. -**④ What the client implementation must provide.** The answer source behind the provider +**④ Client-specific code.** The answer source behind the provider integration point. ```python @@ -298,11 +300,11 @@ class MyProvider: ### 4.5 Target negotiation -**① When the server opens it.** The request contradicts itself: two time windows of the segmented +**① Trigger condition.** The request contradicts itself: two time windows of the segmented guaranteed throughput target overlap, so the guaranteed rate for the overlapping period is ambiguous. The demo's trigger is a `content.value_not_allowed` validation error. -**② What the reference code does.** +**② Reference implementation.** | Step | SDK call | Reference code | | --- | --- | --- | @@ -334,20 +336,20 @@ The demo proposes the *clarification* form: the server restates the intent it un (`intent_understanding`) and asks for the time adjustment (`request_for_clarification`). The accept carries the corrected target as `confirmed_intent`. -**③ What the client implementation can copy as-is.** The same as information: rendering, metadata assembly, session +**③ Reusable client code.** The same as information: rendering, metadata assembly, session handling, transport. -**④ What the client implementation must provide.** How the client derives `confirmed_intent` — -the demo reads it from `scenario_data`; a real client gets it from whatever resolves the +**④ Client-specific code.** How the client derives `confirmed_intent`: the demo reads it from +`scenario_data`, while a client implementation obtains it from the component that resolves the ambiguity. ### 4.6 Feasibility negotiation -**① When the server opens it.** The request may be impossible under current conditions: the +**① Trigger condition.** The request may be impossible under current conditions: the guaranteed rate cannot sustain the declared outage duration for the listed stations. The demo's trigger is a `content.rule_violation` validation error. -**② What the reference code does.** +**② Reference implementation.** | Step | SDK call | Reference code | | --- | --- | --- | @@ -379,11 +381,11 @@ The demo reports the completed assessment as infeasible and attaches an alternat (`action=PROPOSE_ALTERNATIVE_ON_FAILURE` with `infeasibility_details_and_proposal`); the accept states the adjustment it agrees to. -**③ What the client implementation can copy as-is.** The same as information: rendering, metadata assembly, session +**③ Reusable client code.** The same as information: rendering, metadata assembly, session handling, transport. -**④ What the client implementation must provide.** The answer — `feasibility_summary`, i.e. how -the client responds to the server's proposal. +**④ Client-specific code.** The answer carried in `feasibility_summary`, i.e. the client's response +to the server's proposal. ### 4.7 Reject and abort @@ -393,10 +395,11 @@ message. The SDK provides the matching generation methods (`generate_negotiation_reject_prompt_from_data` / `..._from_text`, and `generate_negotiation_abort_prompt_from_data` / `..._from_text`) when a client needs to send one. -## 5. Replace checklist (demo → production) +## 5. Deployment checklist -The **Side** column says which agent owns the piece. The client team's rows are the ones to act on; -the server rows show what the counterpart replaces when the server is a real system. +The **Side** column identifies the agent responsible for each component. The client team acts on +the rows marked `client`; the `server` rows describe what the counterpart replaces when it is a real +system. | Demo piece | Side | What it does in the demo | Keep or replace | | --- | --- | --- | --- | From 40f3ca2e4aa5308f7db8b4774deaddec12360f40 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 10:32:37 +0800 Subject: [PATCH 21/30] refactor(sample): rename the request text helper Signed-off-by: codetalks-hw --- .../ran-energy-saving/src/client/client_flow.py | 16 ++++++++-------- .../ran-energy-saving/src/client/client_main.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/src/client/client_flow.py b/a2a-t-sample/ran-energy-saving/src/client/client_flow.py index 594e287b..072523d3 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/client_flow.py +++ b/a2a-t-sample/ran-energy-saving/src/client/client_flow.py @@ -58,7 +58,7 @@ def _require_prompt_text(prompt_content: object) -> tuple[str, str]: raise ValueError("prompt generation did not produce text") -def _build_request_metadata(initial_input: dict[str, object]) -> str: +def _build_request_text(initial_input: dict[str, object]) -> str: return str(initial_input.get("scenario", "")) @@ -368,7 +368,7 @@ async def _send_webhook_message( *, # Any (not object): the fake-friendly client of the SSE helpers is `object`, but the SSE path # tolerates the resulting baseline diagnostics while this new code must add none (plan: ty - # total stays at the 52 baseline) — Any keeps attribute access on the a2a client clean. + # total stays at the 52 baseline) — Any keeps attribute access on the a2a-sdk client clean. a2a_client: Any, request: SendMessageRequest, call_context: ClientCallContext, @@ -439,7 +439,7 @@ async def _run_webhook_flow( role="client", stage="a2a-send-message", detail=( - f"phase=initial webhook text_part={_build_request_metadata(initial_input)} " + f"phase=initial webhook text_part={_build_request_text(initial_input)} " f"metadata_keys=[{TASK_T_EXTENSION_URI}, templateUri] " f"return_immediately=true push_registration=inline" ), @@ -531,7 +531,7 @@ async def _run_webhook_flow( role="client", stage="a2a-send-message", detail=( - f"phase=continuation webhook text_part={_build_request_metadata(initial_input)} " + f"phase=continuation webhook text_part={_build_request_text(initial_input)} " f"metadata_keys=[{TASK_T_EXTENSION_URI}, {NEGOTIATION_T_EXTENSION_URI}, " f"{TEMPLATE_URI_METADATA_KEY}, {NEGOTIATION_CONTEXT_METADATA_KEY}] " f"header[A2A-Extensions]={','.join(sorted({TASK_T_EXTENSION_URI, NEGOTIATION_T_EXTENSION_URI}))} " @@ -675,7 +675,7 @@ def _build_negotiation_accept_request( accept_request.message.role = Role.ROLE_USER # task_id / context_id are filled by the caller from the initial-phase events (the builder sees # no events); the message shape is identical for both drivers once set. - accept_request.message.parts.add().text = _build_request_metadata(initial_input) + accept_request.message.parts.add().text = _build_request_text(initial_input) accept_request.message.metadata[TASK_T_EXTENSION_URI] = filled_text accept_request.message.metadata[NEGOTIATION_T_EXTENSION_URI] = accept_text accept_request.message.metadata[TEMPLATE_URI_METADATA_KEY] = accept_template_uri @@ -856,7 +856,7 @@ async def _run_negotiation_accept( request=accept_request, call_context=accept_call_context, send_log_detail=( - f"phase=continuation streaming text_part={_build_request_metadata(initial_input)} " + f"phase=continuation streaming text_part={_build_request_text(initial_input)} " f"metadata_keys=[{TASK_T_EXTENSION_URI}, {NEGOTIATION_T_EXTENSION_URI}, " f"{TEMPLATE_URI_METADATA_KEY}, {NEGOTIATION_CONTEXT_METADATA_KEY}] " f"header[A2A-Extensions]={','.join(extension_uris)}" @@ -990,7 +990,7 @@ async def run_client_flow( request = SendMessageRequest() request.message.message_id = str(uuid.uuid4()) request.message.role = Role.ROLE_USER - request.message.parts.add().text = _build_request_metadata(initial_input) + request.message.parts.add().text = _build_request_text(initial_input) request.message.metadata[extension_uri] = prompt_text request.message.metadata["templateUri"] = ( getattr(prompt_content, "template_uri", None) or ENERGY_SAVING_TEMPLATE_URI @@ -1033,7 +1033,7 @@ async def run_client_flow( request=request, call_context=call_context, send_log_detail=( - f"streaming text_part={_build_request_metadata(initial_input)} " + f"streaming text_part={_build_request_text(initial_input)} " f"metadata_keys=[{extension_uri}, templateUri] " f"header[A2A-Extensions]={extension_uri}" ), diff --git a/a2a-t-sample/ran-energy-saving/src/client/client_main.py b/a2a-t-sample/ran-energy-saving/src/client/client_main.py index 1fbd0159..f7346b24 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/client_main.py +++ b/a2a-t-sample/ran-energy-saving/src/client/client_main.py @@ -181,7 +181,7 @@ async def run_main( Transport (plan v3): ``sse`` (default) consumes the streaming response unchanged; ``webhook`` starts the embedded ``WebhookReceiver`` on THIS asyncio loop before the flow runs (lifecycle: stopped in the ``finally`` after the flow ends or Ctrl+C unwinds), feeds its events through an - ``asyncio.Queue`` into the flow, and builds the a2a client with ``ClientConfig(streaming=False)`` + ``asyncio.Queue`` into the flow, and builds the a2a-sdk client with ``ClientConfig(streaming=False)`` (R2 — required to select non-blocking ``message:send``). The receiver's port/token/idle-timeout resolve from the run ``.env`` unless explicitly supplied. """ From 7a29d669c49f174116544c853c42ba5c63c666f7 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 10:32:37 +0800 Subject: [PATCH 22/30] docs(sample): apply the external README review findings Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 227 ++++++------- a2a-t-sample/ran-energy-saving/README.md | 332 +++++++++++--------- 2 files changed, 298 insertions(+), 261 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 11fba9aa..8c1125c3 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -3,7 +3,7 @@ 一个 A2A-T 样例:包含一个客户端 Agent、一个 RAN 域服务端 Agent,以及用于发现 AgentCard 的 mock 注册中心。 -**读者定位。** 本文档面向实现 **client agent** 的团队;**server agent** 是可运行的参考对端 —— +**读者定位。** 本文档面向实现 **client agent** 的团队;**server agent** 是可运行的参考实现。 生产中两端通常归属不同厂商,因此下文统一按角色说明各自负责什么,不使用含混的第二人称。 ## 目录 @@ -34,13 +34,13 @@ ## 1. 本样例演示了什么 -一个客户端 Agent 通过 A2A 协议委托一个 RAN 域 Agent 执行节能任务。在这一个场景上,样例按顺序覆盖 -客户需要集成的两项 A2A-T 能力: +一个客户端 Agent 通过 A2A 协议委托一个 RAN(Radio Access Network,无线接入网)域 Agent 执行节能 +任务。借助这一个场景,样例按顺序覆盖客户需要集成的两项 A2A-T 能力: ```text Task-T 自然语言 ──> 任务提示词 ──> 校验 ──> 执行 ──> 报告 -Negotiation-T 请求需要补全或修正后才能执行时(信息缺失 / 目标不明确 / 目标不可行) +Negotiation-T 请求需要补全或修正后才能执行时(信息缺失 / 目标自相矛盾 / 目标不可行) ──> 双方协商 ──> 达成一致 ──> 任务执行 ``` @@ -57,10 +57,10 @@ Negotiation-T 请求需要补全或修正后才能执行时(信息缺失 / | [`src/client/client_main.py`](src/client/client_main.py) | 装配根:读取 `.env`、构建运行时(`build_client_runtime`)、从注册中心发现服务端 AgentCard、启动/停止 webhook 接收器、装配 demo 的 `ScriptedNegotiationResponseProvider`。客户端侧 `INTEGRATION POINT` 标注位于 `main()`。 | | [`src/client/client_flow.py`](src/client/client_flow.py) | 编排:消息序列(生成 → 发送 → 消费)、报文机制(metadata 键、`A2A-Extensions` 头)、事件管线,以及两种传输驱动(SSE 流式 / webhook 队列)。它调用协商决策接入点,但不持有任何决策。 | | [`src/client/negotiation_response.py`](src/client/negotiation_response.py) | 协商决策接入点:`NegotiationProposeView`、`NegotiationDecision`、`NegotiationResponseProvider` Protocol,以及 demo 实现 `ScriptedNegotiationResponseProvider`。 | -| [`src/client/scenario_data.py`](src/client/scenario_data.py) | 样例输入与脚本化应答 —— 它**同时喂两侧**(见[部署清单](#5-部署清单)),不能原样删除。 | +| [`src/client/scenario_data.py`](src/client/scenario_data.py) | 样例输入与脚本化应答 —— 它**同时喂两侧**(见[部署清单](#5-部署清单)),在两侧都完成替换前不能删除。 | | [`src/server/server_main.py`](src/server/server_main.py) | a2a-sdk 应用装配,推送通知机制无条件装配。服务端侧 `INTEGRATION POINT` 标注位于 `build_server_runtime`。 | | [`src/server/server_flow.py`](src/server/server_flow.py) | 服务端流程:校验 → 协商或执行 → 流式推送进度与报告。 | -| [`src/server/task_execution.py`](src/server/task_execution.py) | `TaskExecutionService` 执行接入点,以及 demo 的 UC1 服务。 | +| [`src/server/task_execution.py`](src/server/task_execution.py) | `TaskExecutionService` 执行接入点,以及 demo 的用例(UC1)服务。 | | [`src/common/`](src/common) | 共享适配器、按 schema 路由的 mock LLM、`WebhookReceiver`,以及双端共享的 `TASK_PARAM_SCHEMA`。 | | [`src/registry/`](src/registry) | mock 注册中心(AgentCard 注册/查询,端口 5001)。 | | [`resources/mock_llm/`](resources/mock_llm) | 脚本化的槽位抽取与内容校验应答。 | @@ -68,7 +68,8 @@ Negotiation-T 请求需要补全或修正后才能执行时(信息缺失 / ## 2. 快速开始 -前置条件:Python(使用仓库 `.venv`)与 [`uv`](https://docs.astral.sh/uv/)。 +前置条件:Python 3.12 或更高版本与 [`uv`](https://docs.astral.sh/uv/)。第 1 步会创建仓库的 +`.venv`。 ```bash # 1) 仓库根目录:创建 .venv 并安装 SDK @@ -84,11 +85,13 @@ cd ran-energy-saving python run_demo.py # 默认英文;加 --language zh-CN 运行中文 ``` -请使用装有样例依赖的解释器(仓库 `.venv`),例如 Windows 下 -`..\..\.venv\Scripts\python.exe run_demo.py`;两端输出会实时交错打印到终端,随后后台进程被停止。 +请用仓库 `.venv` 中的解释器运行 `run_demo.py`(激活该环境,或直接调用,例如 Windows 下 +`..\..\.venv\Scripts\python.exe run_demo.py`)。两端输出会实时交错打印到终端;客户端退出后,后台 +进程被停止。 -加 `--scenario negotiation`(或 `--negotiation target` / `--negotiation feasibility`)改为运行 -协商。若客户端无法保持流式连接,加 `--transport webhook` —— 见 [§6.1](#61-webhook-传输)。 +加 `--scenario negotiation` 运行信息协商(缺失 region),或加 `--negotiation target` / +`--negotiation feasibility` 运行另外两类。若客户端无法保持流式连接,加 `--transport webhook` —— +见 [§6.1](#61-webhook-传输)。 ## 3. 使用 A2A-T SDK 实现 Task-T 交互 @@ -99,21 +102,21 @@ python run_demo.py # 默认英文;加 --language zh-CN 运行中文 2. **发送** —— 提示词放在消息 `metadata` 中发给服务端。 3. **校验** —— 服务端从 metadata 中取出提示词,调用 A2A-T 服务端 SDK 的 `validate_task_prompt_and_data_filling` 校验并抽取参数(`operationType`、`region`、 - `energySavingGoal`、`guaranteedThroughputTarget` —— 双端共享的调用方 schema `TASK_PARAM_SCHEMA`, - 见 [`src/common/task_schema.py`](src/common/task_schema.py))。 + `energySavingGoal`、`guaranteedThroughputTarget`)。双端共享的调用方 schema `TASK_PARAM_SCHEMA` + 位于 [`src/common/task_schema.py`](src/common/task_schema.py)。 4. **执行并报告** —— 抽取出的参数交给业务执行;进度以状态更新返回、结果以报告 artifact 返回, 由客户端呈现。 ### 3.2 客户端侧 -整段交互如下([`client_flow.py`](src/client/client_flow.py)): +交互的核心步骤如下(摘自 [`client_flow.py`](src/client/client_flow.py),有简化): ```python prompt_content = prompt_client.generate_task_prompt_from_text(input_text, ENERGY_SAVING_TEMPLATE_URI) prompt_text, extension_uri = _require_prompt_text(prompt_content) request = SendMessageRequest() ... # 此处省略 message_id / role -request.message.parts.add().text = _build_request_metadata(initial_input) +request.message.parts.add().text = _build_request_text(initial_input) # 承载场景文本的 text 部分 request.message.metadata[extension_uri] = prompt_text # 提示词放在 metadata 中传输 request.message.metadata["templateUri"] = getattr(prompt_content, "template_uri", None) or ENERGY_SAVING_TEMPLATE_URI call_context = ClientCallContext(service_parameters={"A2A-Extensions": extension_uri}) @@ -123,29 +126,35 @@ async for stream_response in a2a_client.send_message(request, context=call_conte ### 3.3 服务端侧 -`_extract_prompt_text` 从 metadata 读取提示词,`validate_task_prompt_and_data_filling` 校验并抽取 -([`server_flow.py:73`](src/server/server_flow.py#L73)、[`server_flow.py:425`](src/server/server_flow.py#L425))。 -被拒绝的提示词映射为 `TASK_STATE_REJECTED`,而不是让服务崩溃;校验通过的参数进入执行接入点。 +`_extract_prompt_text` 从 metadata 读取提示词,`execute_server_flow` 调用 +`validate_task_prompt_and_data_filling` 完成校验与抽取([`server_flow.py`](src/server/server_flow.py))。 +被拒绝的提示词映射为 `TASK_STATE_REJECTED`,而不是让服务崩溃;校验通过的参数会传给执行接入点 +(`TaskExecutionService`)。 ### 3.4 client 侧要替换什么 -模板与参数 schema 是两端共享的契约:客户端用它渲染任务请求,服务端用它做校验。本样例的模板 +模板与参数 schema 是两端之间的契约:客户端用它渲染任务请求,服务端用它做校验。本样例的模板 (`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`)与 schema 都是样例 -特有的,因此 client 实现要换成自己的([`scenario_data.py`](src/client/scenario_data.py));服务端 -`TaskExecutionService` 接入点之后的业务执行属于 server agent(见 -[部署清单](#5-部署清单))。 +特有的 —— 模板常量位于 [`scenario_data.py`](src/client/scenario_data.py),schema 位于 +[`src/common/task_schema.py`](src/common/task_schema.py)。真实部署中,双方约定共同使用的模板与 +schema,两端使用约定后的版本。服务端 `TaskExecutionService` 接入点之后的业务执行属于 server agent +(见[部署清单](#5-部署清单))。 ## 4. 使用 A2A-T SDK 实现 Negotiation-T 交互 -一句话分界:报文由两个 SDK 共同承担 —— **A2A-T SDK** 负责生成与校验协商消息(渲染、协商会话 -映射),**A2A SDK** 负责把它们传输出去。A2A-T SDK 本身不含传输层,所以部署时总是两者配合使用。 -业务由两端各自负责:server agent 决定请求何时无法按原样执行、值得先谈一谈;client agent 决定如何 -应答收到的 propose。 +两个 SDK 负责协议细节,业务决策由两端 Agent 各自负责。 + +- **A2A-T SDK** 负责生成与校验协商消息(propose、accept、reject、abort):渲染提示词文本,并把 + 调用方传入的 `negotiationContext` 带入生成的消息。它不保存会话状态,也不含传输层。 +- **A2A SDK** 负责传输这些消息,因此部署时总是两者配合使用。 +- 在本样例中,server agent 决定请求何时无法按收到的内容执行、且值得通过协商解决;client agent + 决定如何应答收到的 propose。协议本身允许任何一方发起 propose(见 + [§4.3](#43-通用机制))。 ### 4.1 协商场景 -服务端无法按收到的请求执行,且该差距可以通过协商解决而不必直接拒绝请求时,由服务端开启协商。样例 -覆盖三种情况: +在本样例中,服务端无法按收到的请求执行、且该问题可以通过协商解决而不必直接拒绝时,由服务端开启 +协商。样例覆盖三种情况: **信息不完整。** 客户端要求执行节能,给了目标、时段和吞吐目标,却没说在哪个区域。服务端缺这一项 无法开工,于是告诉客户端缺什么;客户端补上,任务继续。 @@ -154,18 +163,17 @@ async for stream_response in a2a_client.send_message(request, context=call_conte `{17:30~23:00,20Mbps}` 与 `{20:00~00:00,10Mbps}`。请求本身格式没问题,但重叠时段的保障速率无法 唯一确定。服务端复述它理解的意图,请客户端调整时间;客户端带着修正后的时段回应。 -**目标在当前条件下可能做不到。** 客户端要求两个站点在 00:00~18:00 的停电期间保障 +**目标在当前条件下无法满足。** 客户端要求两个站点在 00:00~18:00 停电期间保障 `{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}`,而站点既有的备电要求是至少支撑 18 小时。按这个 速率执行,电池撑不到停电结束。服务端给出不可行的结论和提案(把 06:00~18:00 的保障速率从 10Mbps 下调 到 2Mbps);客户端接受调整后的目标。 ### 4.2 样例如何选择协商类型 -判断属于哪种情况、以及到底要不要协商,是**server agent 的业务决策,两个 SDK 都不持立场** —— client +是否需要协商、以及属于哪种类型,是**server agent 的业务决策,两个 SDK 都不作规定** —— client 只对收到的 propose 作出反应。本样例是数据驱动的:首个内容校验错误的错误码通过 -`_NEGOTIATION_TYPE_BY_ERROR_CODE` 表 -([`server_flow.py:55`](src/server/server_flow.py#L55))选出类型。这张表是**样例约定,不是 SDK -契约**。 +`_NEGOTIATION_TYPE_BY_ERROR_CODE` 表([`server_flow.py`](src/server/server_flow.py))选出类型。 +这张表是**样例约定,不是 SDK 契约**。 报文并不依赖这张表。每个 propose 都记录自己的 `templateUri` —— 即 metadata 中这段报文由哪个 Prompt 模板渲染而来,例如 `Negotiation-T/information-negotiation/propose/v1`。这个 URI 同时告诉 @@ -176,24 +184,25 @@ Prompt 模板渲染而来,例如 `Negotiation-T/information-negotiation/propos 以下机制对三类协商均适用,各类型小节不再重复。 -**轮次。** 一轮由一次 propose 和针对该 propose 的应答(accept 或 reject)构成。会话上下文携带从 1 -开始的 `round`:应答沿用其所回应的 propose 的轮次,只有**新的** propose 才推进轮次。客户端最初的 -请求不计入协商轮次 —— 第一轮从服务端的 propose 开始。`maxRounds`(默认 5)限制会话轮数。 +**轮次。** 一轮由一次 propose 和针对该 propose 的应答(accept、reject 或 abort)构成。会话上下文 +携带从 1 开始的 `round`:应答沿用其所回应的 propose 的轮次,只有**新的** propose 才推进轮次。 +客户端最初的请求不计入协商轮次 —— 第一轮从服务端的 propose 开始。`maxRounds`(默认 5)限制会话 +轮数。 -**双方都可以发起 propose。** 协商是对称的:客户端与服务端 SDK 都提供同样的四个生成方法 -(propose / accept / reject / abort)和同样的校验方法,任何一方都能发起协商,任何一方也都能应答。 -本样例让**服务端**发起,是因为发现"请求无法按原样执行"的是服务端 —— 这是本场景的选择,不是报文本身 -的约束:客户端同样可以发起协商,例如它无法满足服务端公布的某项约束时,由服务端来 accept / reject / -abort。角色只决定谁发 propose;报文形态、会话处理与传输完全一致。本样例只在客户端侧接了决策 -(provider 接入点);如果由客户端发起协商,对等的决策逻辑要放在服务端侧。 +**双方都可以发起 propose。** 协商是对称的:A2A-T 客户端 SDK 与 A2A-T 服务端 SDK 都提供同样的四个 +生成方法(propose / accept / reject / abort)和同样的校验方法,任何一方都能发起协商,任何一方也都 +能应答。区别只在于谁发送 propose;报文形态、会话处理与传输完全一致。本样例由**服务端**发起,因为 +发现"请求无法按收到的内容执行"的是服务端。客户端同样可以发起协商,例如它无法满足服务端公布的某项 +约束时,由服务端来 accept / reject / abort。本样例只在客户端侧实现了决策(provider 接入点);如果 +由客户端发起协商,对等的决策逻辑需要放在服务端侧。 **四条报文的闭环:** -1. 客户端 → 服务端:按原样的节能请求 —— 样例中分别是缺区域、时段重叠、停电时长无法支撑。 -2. 服务端 → 客户端:propose —— 缺什么、需要澄清什么、或需要评估什么。 -3. 客户端 → 服务端:把缺失或需修正的内容补全后重新发送的那条请求;应答随同一条消息一起发出 - (是一条消息,不是两条)。 -4. 服务端 → 客户端:结果 —— 先进度,后报告。 +1. 客户端 → 服务端:节能请求。样例中它故意在以下三处之一存在缺陷:缺少区域、目标中时段重叠、或 + 保障速率无法支撑停电时长。 +2. 服务端 → 客户端:propose —— 缺什么、需要澄清什么、或哪个目标不可行以及提供了什么替代方案。 +3. 客户端 → 服务端:一条消息,同时承载 accept 和补全/修正后重新发送的请求。 +4. 服务端 → 客户端:结果 —— 先进度更新,后报告。 会话(`id` / `round` / `maxRounds` / performative)随每条协商消息放在 `negotiationContext` metadata 中,因此 A2A-T SDK 本身无状态。 @@ -209,14 +218,17 @@ class NegotiationResponseProvider(Protocol): ) -> NegotiationDecision: ... ``` -`NegotiationDecision` 携带 `conclusion`(`accept` / `reject` / `abort`)加上对应类型的 accept -字段:`filled_params`(信息协商,name → value)、`confirmed_intent`(目标协商)、 -`feasibility_summary`(可行性协商),以及 `reason`(reject/abort)。**`accept` 已端到端实现**: -flow 根据 client 侧的决策重新生成 Task-T 提示词、构建带同轮 `ACCEPT` 标记的类型化 accept,并经所选传输发送。 -`respond` 是同步的 —— 需要异步的实现(人工确认、上游系统调用)在实现内部自行处理。 +`NegotiationDecision` 携带 `conclusion`(`accept` / `reject` / `abort`)以及对应的载荷:`accept` +时是 `filled_params`(信息协商,name → value)、`confirmed_intent`(目标协商)或 +`feasibility_summary`(可行性协商);`reject` / `abort` 时是 `reason`。**`accept` 已端到端实现**: +flow 根据 client 侧的决策重新生成 Task-T 提示词、构建 accept 消息(同轮、performative 为 +`ACCEPT`),并经所选传输发送。`respond` 是同步的,会阻塞 flow 直到返回;需要等待的实现(人工确认、 +上游系统调用)在 `respond` 内部自行处理。 -> Provider 只替换对 propose 的脚本化**应答** —— Task-T 补全参数(`negotiation_params`)始终是 -> flow 的调用方输入,缺了它们协商不会开启。 +> Provider 只替换对 propose 的脚本化**应答** —— 信息协商中就是 `filled_params` 返回的取值。 +> Task-T 补全参数(`negotiation_params`)仍由调用方传入:flow 用它们重新生成 accept 消息中的 +> Task-T 提示词,缺少它们时 flow 不会应答该 propose。样例中两者都来自 `scenario_data`,因此保持 +> 一致。 ### 4.4 信息协商(information) @@ -227,9 +239,9 @@ flow 根据 client 侧的决策重新生成 Task-T 提示词、构建带同轮 ` | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | -| 服务端 propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | -| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:700`](src/client/client_flow.py#L700) | -| 客户端 accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py:619`](src/client/client_flow.py#L619) | +| 服务端 propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py`](src/server/server_flow.py)(`_emit_negotiation_propose`) | +| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py`](src/client/client_flow.py)(`_plan_negotiation_accept`) | +| 客户端 accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py`](src/client/client_flow.py)(`_build_negotiation_accept_request`) | ```python # 服务端 propose:把业务报文写成文本,再经 from-text API 渲染 @@ -274,9 +286,9 @@ class MyProvider: | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | -| 服务端 propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | -| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:700`](src/client/client_flow.py#L700) | -| 客户端 accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py:619`](src/client/client_flow.py#L619) | +| 服务端 propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py`](src/server/server_flow.py)(`_emit_negotiation_propose`) | +| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py`](src/client/client_flow.py)(`_plan_negotiation_accept`) | +| 客户端 accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py`](src/client/client_flow.py)(`_build_negotiation_accept_request`) | ```python # 服务端 propose:把业务报文写成文本,再经 from-text API 渲染 @@ -298,8 +310,8 @@ accept = client.generate_negotiation_accept_prompt_from_text( ) ``` -样例用的是**澄清**形态:服务端复述它理解的意图(`intent_understanding`),并请求调整时间 -(`request_for_clarification`)。accept 把修正后的目标放进 `confirmed_intent`。 +样例的 propose 请求澄清:服务端复述它理解的意图(`intent_understanding`),并询问需要调整哪个 +时段(`request_for_clarification`)。accept 把修正后的目标放进 `confirmed_intent`。 **③ 可复用的客户端代码。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 @@ -308,16 +320,16 @@ accept = client.generate_negotiation_accept_prompt_from_text( ### 4.6 可行性协商(feasibility) -**① 触发条件。** 请求在当前条件下可能做不到:保障速率无法支撑所声明站点的停电时长。样例的 +**① 触发条件。** 请求在当前条件下无法满足:保障速率无法支撑请求中所列站点的停电时长。样例的 触发条件是 `content.rule_violation` 校验错误。 **② 参考实现。** | 步骤 | SDK 调用 | 参考代码 | | --- | --- | --- | -| 服务端 propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | -| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py:700`](src/client/client_flow.py#L700) | -| 客户端 accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py:619`](src/client/client_flow.py#L619) | +| 服务端 propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py`](src/server/server_flow.py)(`_emit_negotiation_propose`) | +| 客户端决策 | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py)、[`client_flow.py`](src/client/client_flow.py)(`_plan_negotiation_accept`) | +| 客户端 accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py`](src/client/client_flow.py)(`_build_negotiation_accept_request`) | ```python # 服务端 propose:把业务报文写成文本,再经 from-text API 渲染 @@ -339,9 +351,9 @@ accept = client.generate_negotiation_accept_prompt_from_text( ) ``` -样例把已完成的评估结论作为不可行上报,并附上替代提案 +样例的 propose 说明评估结论为该目标不可行,并附上替代方案 (`action=PROPOSE_ALTERNATIVE_ON_FAILURE` 加 `infeasibility_details_and_proposal`);accept 说明 -它同意的调整。 +客户端同意的调整。 **③ 可复用的客户端代码。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 @@ -349,66 +361,68 @@ accept = client.generate_negotiation_accept_prompt_from_text( ### 4.7 reject 与 abort -客户端也可以用 `reject` 或 `abort` 回应 propose,而不只是 `accept`。本样例只实现了 accept 路径: -reject/abort 决策只会打日志并结束流程,不发送报文。需要发送时,A2A-T SDK 提供对应的生成方法 -(`generate_negotiation_reject_prompt_from_data` / `..._from_text`,以及 +客户端可以用 `reject`(拒绝该 propose)或 `abort`(终止协商)回应 propose,而不只是 `accept`; +两者在协议中都是终态。本样例只实现了 accept 路径:reject/abort 决策只记录日志并结束客户端流程, +不发送报文,因此服务端不会收到通知。生产环境的客户端应发送对应的报文;A2A-T SDK 提供相应的生成 +方法(`generate_negotiation_reject_prompt_from_data` / `..._from_text`,以及 `generate_negotiation_abort_prompt_from_data` / `..._from_text`)。 ## 5. 部署清单 -**归属**列标明各组件由哪一端负责。client 团队处理标注为 client 的行;server 行说明对端为真实系统时 -需要替换的内容。 +**归属**列标明各组件由哪一端负责。client 团队负责标注为 client 与 both 的行;标注为 server 的行 +说明对端为真实系统时需要替换的内容。 | demo 组成 | 归属 | demo 中的作用 | 保留还是替换 | | --- | --- | --- | --- | -| mock LLM —— [`resources/mock_llm/`](resources/mock_llm) + `src/common/` 中按 schema 路由的 mock | 两端 | 脚本化的槽位抽取与内容校验应答,`A2AT_LLM_API_KEY` 为空时启用 | CI/离线运行**保留**;生产中两端各自移除并配置真实的 OpenAI 兼容端点 | -| 脚本化执行数据 —— [`src/server/constants_data.py`](src/server/constants_data.py) | server | demo 执行流式推送的 UC1 进度步骤与意图报告 | server agent 经 `TaskExecutionService` 接入点**替换**([`src/server/task_execution.py`](src/server/task_execution.py));它的服务装配后,demo 服务与数据即可删除 | -| 脚本化应答 —— [`src/client/scenario_data.py`](src/client/scenario_data.py) | 两端(见下) | 同时喂两侧 | 两侧都要**替换**:client 侧应答经自己的 `NegotiationResponseProvider`;server 侧在其副本的 `server_flow._negotiation_propose_text` 中改为自己的业务措辞 | -| mock 注册中心 —— [`src/registry/`](src/registry)(端口 5001) | client | AgentCard 注册/查询,用于发现 | **替换**为 client 侧的注册中心,或绕过发现、直接从已知 AgentCard 构造 `a2a-sdk` 客户端 | -| 静态 webhook token —— `A2AT_SAMPLE_WEBHOOK_TOKEN`(默认 `demo-webhook-token`) | 两端 | `X-A2A-Notification-Token` 头中的共享静态 token | **替换**为生产鉴权(逐对密钥、轮换、TLS);把接收器当作暴露在外的 HTTP 端点对待 | +| mock LLM —— [`resources/mock_llm/`](resources/mock_llm) + `src/common/` 中按 schema 路由的 mock | 两端 | 脚本化的槽位抽取与内容校验应答,`A2AT_LLM_API_KEY` 为空时启用 | CI/离线运行**保留**;真实部署中两端各自替换为真实的 OpenAI 兼容端点 | +| 脚本化执行数据 —— [`src/server/constants_data.py`](src/server/constants_data.py) | server | demo 执行流式推送的用例(UC1)进度步骤与意图报告 | server agent 经 `TaskExecutionService` 接入点**替换**([`src/server/task_execution.py`](src/server/task_execution.py));它的服务装配后,demo 服务与数据即可删除 | +| 脚本化应答 —— [`src/client/scenario_data.py`](src/client/scenario_data.py) | 两端(见下) | 同时喂两侧 | 两侧都要**替换**:client 侧应答经自己的 `NegotiationResponseProvider`;server 侧把 `server_flow._negotiation_propose_text` 中的预制 propose 文案改为自己的业务措辞 | +| mock 注册中心 —— [`src/registry/`](src/registry)(端口 5001) | 两端 | AgentCard 注册(server)与查询发现(client) | **替换**为部署环境的注册中心,或绕过发现、直接从已知 AgentCard 构造 `a2a-sdk` 客户端 | +| 静态 webhook token —— `A2AT_SAMPLE_WEBHOOK_TOKEN`(默认 `demo-webhook-token`) | 两端 | `X-A2A-Notification-Token` 头中的共享静态 token | **替换**为生产鉴权(逐客户端密钥、轮换、TLS);把接收器当作暴露在外的 HTTP 端点对待 | > **`scenario_data` 同时喂两侧** —— 客户端的脚本化应答(补全参数、确认意图、可行性摘要)经 > provider 接入点使用;服务端的 propose 文本也由同一模块构建:`_negotiation_propose_text` -> ([`server_flow.py:162`](src/server/server_flow.py#L162)) 用其访问器填充 target/feasibility 的 -> propose 文案。只替换 provider 与执行服务,服务端仍会用 demo 的预制文案发起 propose —— -> 该模块不可删除。 +> ([`server_flow.py`](src/server/server_flow.py)) 用其访问器填充 target/feasibility 的 propose +> 文案。只替换 provider 与执行服务,服务端仍会用 demo 的预制文案发起 propose —— 因此两侧都完成 +> 替换前,`scenario_data.py` 不能删除。 ## 6. 附录 ### 6.1 Webhook 传输 -两种传输投递相同的任务事件;区别在客户端如何接收。默认是 SSE 流式:客户端保持流式 `message:send` -连接,事件到达即消费。Webhook 推送通知面向不保持流式连接的客户端(长时任务、间歇在线的客户端): -`--transport webhook`(或 `A2AT_SAMPLE_TRANSPORT=webhook`)。 +两种传输投递相同的任务事件,区别在于客户端如何接收。默认是 SSE 流式:客户端保持流式 +`message:stream` 调用,事件到达即消费。Webhook 推送通知面向无法保持流式连接的客户端(长时任务、间歇 +在线的客户端),通过 `--transport webhook` 或 `A2AT_SAMPLE_TRANSPORT=webhook` 启用。 webhook 模式下的变化:客户端非阻塞发送(`return_immediately`),每个任务事件以 webhook POST 形式 送达客户端进程内嵌的接收器([`src/common/webhook_receiver.py`](src/common/webhook_receiver.py) 的 `WebhookReceiver`),它额外绑定一个监听端口(`--webhook-port`,默认 9000)。服务端推送在 -`X-A2A-Notification-Token` 头中携带 `A2AT_SAMPLE_WEBHOOK_TOKEN`(默认 `demo-webhook-token`)—— -不匹配回 `401`、事件被丢弃。流程在任一 SDK 终态结束,另有 30 秒空闲兜底。协商往返在两种传输上 -逐语句一致。 +`X-A2A-Notification-Token` 头中携带 `A2AT_SAMPLE_WEBHOOK_TOKEN`(默认 `demo-webhook-token`)。 +流程在任一 SDK 终态结束,另有 30 秒空闲兜底。协商往返在两种传输上的行为一致。 - **两条注册路径。** 初始请求在请求上内联注册推送配置 (`SendMessageConfiguration.task_push_notification_config`,即扁平的 - `TaskPushNotificationConfig(url, token)` —— 此时 task id 尚未知);发送 accept 前,经 - `POST /tasks/{id}/pushNotificationConfigs` REST 端点(a2a-sdk - `BaseClient.create_task_push_notification_config`)重注册同一 URL/token。 + `TaskPushNotificationConfig(url, token)`),因为此时 task id 尚不存在。发送 accept 前,客户端流程 + 经 `POST /tasks/{id}/pushNotificationConfigs`(a2a-sdk + `BaseClient.create_task_push_notification_config`)重注册同一 URL/token —— 该端点路径在 task id + 已知后才可用。 - **没有服务端模式开关。** 服务端无条件装配推送机制(a2a-sdk `DefaultRequestHandler` 携带 `InMemoryPushNotificationConfigStore` + `BasePushNotificationSender`, [`server_main.py`](src/server/server_main.py));SSE 模式下它只是闲置。 -- **非阻塞路径。** a2a 客户端以 `ClientConfig(streaming=False)` 构建 —— 这是选择非阻塞 +- **非阻塞路径。** a2a-sdk 客户端以 `ClientConfig(streaming=False)` 构建 —— 这是选择非阻塞 `message:send` 路径的必要条件(仅设置 `return_immediately` 不会切换路径)。推送报文是与 SSE 事件 同构的 StreamResponse JSON —— 因此协商检测、artifact 计数与展示逻辑与 SSE 共享。 - **Token 鉴权。** 接收器对每个 POST 校验 `X-A2A-Notification-Token`:不匹配回 `401`(事件被丢弃), 畸形或无法识别的报文回 `400`。demo 的静态共享 token 是 demo 级的 —— 生产请使用 TLS 之上可轮换的 逐客户端密钥。 -- **结束信号。** 任一 SDK 终态(`TASK_STATE_COMPLETED` / `FAILED` / `CANCELED` / `REJECTED`)都会在 - 两个阶段立即结束流程 —— 协商场景正常以 `COMPLETED` 结束,校验失败或流异常以 `REJECTED` / - `FAILED` 直接结束,summary 行以 `end=` 记录终态结束。默认场景仍以报告 artifact 结束; - `INPUT_REQUIRED`(协商 propose)只是初始阶段的中断点,不是流程结束。空闲兜底 +- **结束信号。** 任一 SDK 终态(`TASK_STATE_COMPLETED` / `FAILED` / `CANCELED` / `REJECTED`)都会结束 + 当前正在消费事件的阶段:协商场景正常以 `COMPLETED` 结束;校验失败或流异常以 `REJECTED` / + `FAILED` 结束,summary 行以 `end=` 记录。默认场景(无协商)以报告 artifact 结束。 + `INPUT_REQUIRED`(协商 propose)是初始阶段的中断点,不是流程结束:协商已接入时,flow 会进入续传 + 阶段。空闲兜底 (`A2AT_SAMPLE_WEBHOOK_IDLE_TIMEOUT`,默认 30 秒,每收到一个事件重置)打印 `webhook-idle-timeout` - 行后带着已收集的事件正常结束。`A2AT_SAMPLE_MAX_ARTIFACTS` 在 webhook 模式下不生效;每个阶段的唯一 - RPC 应答以 `webhook-rpc-reply` 记录一次、不重复计数,`stream-completed` 汇总统计经 webhook 收到 + 行后带着已收集的事件正常结束。`A2AT_SAMPLE_MAX_ARTIFACTS` 在 webhook 模式下不生效。每个阶段的唯一 + RPC 应答以 `webhook-rpc-reply` 记录一次,不重复计数。`stream-completed` 汇总统计经 webhook 收到 的事件并追加 `transport=webhook`。 ### 6.2 配置(a2a-t-sample/.env) @@ -431,16 +445,19 @@ webhook 模式下的变化:客户端非阻塞发送(`return_immediately`) | `A2AT_SAMPLE_MAX_ARTIFACTS`(进程环境变量,非 `.env`) | 收到 N 个 artifact 后停止(默认 0 = 不限制) | 说明:`run_demo.py` 的就绪检查固定使用 `5001` / `8000`,不读取 `A2AT_SAMPLE_PORT` / -`REGISTRY_CENTER_PORT` —— 这两对仅在手动 `python -m` 路径下设置。服务端的进度与报告文案是内置的 -英文,与 `--language` 无关;该开关只切换客户端输入、模板与 mock 应答,协商消息文本则跟随运行语言。 -模板、参数 schema、AgentCard 与自然语言输入都是本样例特有的;模板必须属于 `Task-T` 扩展且使用 -`v1` 版本。远程 prompt 资源加载不属于 SDK;本处的注册中心是样例侧用于 AgentCard 发现的 mock, +`REGISTRY_CENTER_PORT` —— 这两对仅在手动 `python -m` 路径下设置。服务端的进度与报告文案始终是 +英文,与 `--language` 无关。该开关切换客户端输入、模板、mock 应答与协商消息文本。模板、参数 +schema、AgentCard 与自然语言输入都是本样例特有的;替换后的模板必须属于 `Task-T` 扩展且使用 `v1` +版本。SDK 不负责加载远程 prompt 资源;本处的注册中心是样例专用的 mock,用于 AgentCard 发现, 不是 SDK 能力。 ### 6.3 分别启动两端 -把 `PYTHONPATH` 指向用例的 `src/`,在各自终端分别启动(`.env` 从当前目录读取;缺失 `.env` 对客户端 -与服务端而言会**直接报错**)。先启动注册中心与服务端 —— 客户端要从注册中心发现服务端的 AgentCard。 +把 `PYTHONPATH` 指向样例的 `src/` 目录,在各自终端分别启动(`.env` 从当前目录读取;缺失 `.env` 对 +客户端与服务端而言会**直接报错**)。启动顺序为注册中心 → 服务端 → 客户端(客户端要从注册中心发现 +服务端的 AgentCard)。 + +以下命令在仓库根目录执行: ```bash # bash / zsh @@ -475,8 +492,8 @@ uv run --no-sync python -m client.client_main | 参数 | 作用 | | --- | --- | | `--language {en-US,zh-CN}` | 运行语言(默认 `en-US`) | -| `--scenario {default,negotiation}` | 场景变体(默认 `default`;`negotiation` 运行缺失 region 的信息协商) | -| `--negotiation {information,target,feasibility}` | 协商类型,选择客户端场景输入(默认 `information` = 缺失 region;`target` = 分时段保障速率目标中两个时段重叠;`feasibility` = 保障速率无法支撑 18 小时停电)。`information` 仍受 `--scenario` 开关控制(只有 `--scenario negotiation` 运行缺失 region 流程);`target`/`feasibility` 无需该开关即可触发。该参数只选择输入 —— 服务端依据内容校验错误码数据驱动地分派 | +| `--scenario {default,negotiation}` | 场景变体(默认:`default`;`negotiation` 运行缺失 region 的信息协商) | +| `--negotiation {information,target,feasibility}` | 协商类型:`information`(默认;缺失 region)、`target`(分时段保障速率目标中两个时段重叠)或 `feasibility`(保障速率无法支撑 18 小时停电)。`target` 与 `feasibility` 可单独触发协商;`information` 还需配合 `--scenario negotiation`。该参数只选择客户端输入;服务端依据内容校验错误码决定类型 | | `--transport {sse,webhook}` | 客户端的事件传输方式(默认 `sse`);见 [§6.1](#61-webhook-传输) | | `--webhook-port N` | `--transport webhook` 时内嵌 webhook 接收器的绑定端口(默认 9000;`0` 表示由操作系统分配临时端口) | | `--keep-alive` | 保留注册中心/服务端不停止;之后用 `--stop` 关闭 | diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index c3665f6b..f78383c2 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -4,8 +4,8 @@ An A2A-T sample with a client agent, a RAN domain server agent, and a mock regis AgentCard discovery. **Audience.** This sample is written for the team implementing the **client agent**. The **server -agent** is a runnable reference counterpart — in production the two agents usually belong to -different parties, so the sections below name the responsible side instead of addressing an +agent** is a runnable reference implementation. In production the two agents usually belong to +different parties, so the sections below name the responsible side rather than address an unspecified reader. ## Contents @@ -13,12 +13,12 @@ unspecified reader. - [1. What this sample demonstrates](#1-what-this-sample-demonstrates) - [1.1 Project layout](#11-project-layout) - [2. Quickstart](#2-quickstart) -- [3. Using the A2A-T SDK to implement Task-T interaction](#3-using-the-a2a-t-sdk-to-implement-task-t-interaction) +- [3. Using the A2A-T SDK to implement the Task-T interaction](#3-using-the-a2a-t-sdk-to-implement-the-task-t-interaction) - [3.1 The four steps](#31-the-four-steps) - [3.2 Client side](#32-client-side) - [3.3 Server side](#33-server-side) - [3.4 What the client replaces](#34-what-the-client-replaces) -- [4. Using the A2A-T SDK to implement Negotiation-T interaction](#4-using-the-a2a-t-sdk-to-implement-negotiation-t-interaction) +- [4. Using the A2A-T SDK to implement the Negotiation-T interaction](#4-using-the-a2a-t-sdk-to-implement-the-negotiation-t-interaction) - [4.1 Negotiation scenarios](#41-negotiation-scenarios) - [4.2 How the demo selects the negotiation kind](#42-how-the-demo-selects-the-negotiation-kind) - [4.3 Mechanics common to all kinds](#43-mechanics-common-to-all-kinds) @@ -36,25 +36,25 @@ unspecified reader. ## 1. What this sample demonstrates -A client agent commissions a RAN domain agent to run an energy-saving task over the A2A protocol. -On this single scenario the sample covers the two A2A-T capabilities a customer integrates, in -order: +A client agent commissions a RAN (Radio Access Network) domain agent to run an energy-saving task +over the A2A protocol. Using this single scenario, the sample covers the two A2A-T capabilities a +customer needs to integrate, in the following order: ```text Task-T natural language ──> task prompt ──> validation ──> execution ──> report Negotiation-T when the request needs completion or correction before it can be executed - (information missing / target ambiguous / target infeasible) + (information missing / target self-contradictory / target infeasible) ──> negotiation ──> agreement ──> task execution ``` The remaining sections follow three usage paths: - **Run the sample** — [§2 Quickstart](#2-quickstart). -- **Implement the Task-T interaction** — [§3](#3-using-the-a2a-t-sdk-to-implement-task-t-interaction) +- **Implement the Task-T interaction** — [§3](#3-using-the-a2a-t-sdk-to-implement-the-task-t-interaction) together with the [deployment checklist](#5-deployment-checklist). - **Implement the Negotiation-T interaction** — - [§4](#4-using-the-a2a-t-sdk-to-implement-negotiation-t-interaction). + [§4](#4-using-the-a2a-t-sdk-to-implement-the-negotiation-t-interaction). ### 1.1 Project layout @@ -63,10 +63,10 @@ The remaining sections follow three usage paths: | [`src/client/client_main.py`](src/client/client_main.py) | Assembly root: reads `.env`, builds the runtime (`build_client_runtime`), discovers the server's AgentCard from the registry, starts/stops the webhook receiver, and assembles the demo `ScriptedNegotiationResponseProvider`. The client-side `INTEGRATION POINT` comment sits in `main()`. | | [`src/client/client_flow.py`](src/client/client_flow.py) | Orchestration: the message sequence (generate → send → consume), the message handling (metadata keys, `A2A-Extensions` header), the event pipeline, and the two transport drivers (SSE streaming / webhook queue). It calls the negotiation decision integration point but owns no decision. | | [`src/client/negotiation_response.py`](src/client/negotiation_response.py) | The negotiation decision integration point: `NegotiationProposeView`, `NegotiationDecision`, the `NegotiationResponseProvider` Protocol, and the demo `ScriptedNegotiationResponseProvider`. | -| [`src/client/scenario_data.py`](src/client/scenario_data.py) | The sample's inputs and scripted answers — it feeds **both** sides (see the [deployment checklist](#5-deployment-checklist)); not removable as-is. | +| [`src/client/scenario_data.py`](src/client/scenario_data.py) | The sample's inputs and scripted answers — it feeds **both** sides (see the [deployment checklist](#5-deployment-checklist)); not removable until both sides are replaced. | | [`src/server/server_main.py`](src/server/server_main.py) | a2a-sdk app assembly, with the push-notification machinery assembled unconditionally. The server-side `INTEGRATION POINT` comment sits in `build_server_runtime`. | | [`src/server/server_flow.py`](src/server/server_flow.py) | The server flow: validate → negotiate-or-execute → stream progress and the report. | -| [`src/server/task_execution.py`](src/server/task_execution.py) | The `TaskExecutionService` execution integration point plus the demo UC1 service. | +| [`src/server/task_execution.py`](src/server/task_execution.py) | The `TaskExecutionService` execution integration point plus the demo use-case (UC1) service. | | [`src/common/`](src/common) | Shared adapters, the schema-routed mock LLM, the `WebhookReceiver`, and the shared `TASK_PARAM_SCHEMA`. | | [`src/registry/`](src/registry) | Mock registry center (AgentCard register/query, port 5001). | | [`resources/mock_llm/`](resources/mock_llm) | Scripted slot-extraction and content-validation replies. | @@ -74,7 +74,8 @@ The remaining sections follow three usage paths: ## 2. Quickstart -Prerequisites: Python (the repository `.venv`) and [`uv`](https://docs.astral.sh/uv/). +Prerequisites: Python 3.12 or newer and [`uv`](https://docs.astral.sh/uv/). Step 1 creates the +repository `.venv`. ```bash # 1) repository root: create .venv and install the SDK @@ -90,15 +91,15 @@ cd ran-energy-saving python run_demo.py # English (default); add --language zh-CN for the Chinese run ``` -Use an interpreter that has the sample dependencies (the repository `.venv`), e.g. -`..\..\.venv\Scripts\python.exe run_demo.py` on Windows; both sides' output streams to the -terminal live and interleaved, then the background processes are stopped. +Run `run_demo.py` with the interpreter from the repository `.venv` (activate it, or call it +directly, e.g. `..\..\.venv\Scripts\python.exe run_demo.py` on Windows). Both sides' output streams +to the terminal live and interleaved; when the client exits, the background processes are stopped. -Add `--scenario negotiation` (or `--negotiation target` / `--negotiation feasibility`) to run a -negotiation instead. If the client cannot hold a stream open, add `--transport webhook` — see -[§6.1](#61-webhook-transport). +Add `--scenario negotiation` to run the information negotiation (missing region), or +`--negotiation target` / `--negotiation feasibility` to run the other two kinds. If the client +cannot keep a stream open, add `--transport webhook` — see [§6.1](#61-webhook-transport). -## 3. Using the A2A-T SDK to implement Task-T interaction +## 3. Using the A2A-T SDK to implement the Task-T interaction ### 3.1 The four steps @@ -107,21 +108,23 @@ negotiation instead. If the client cannot hold a stream open, add `--transport w 2. **Send** — the prompt travels to the server in the message `metadata`. 3. **Validate** — the server reads the prompt from the metadata and calls the A2A-T server SDK's `validate_task_prompt_and_data_filling`, which validates it and extracts the parameters - (`operationType`, `region`, `energySavingGoal`, `guaranteedThroughputTarget` — the caller schema - `TASK_PARAM_SCHEMA` shared by both sides in [`src/common/task_schema.py`](src/common/task_schema.py)). + (`operationType`, `region`, `energySavingGoal`, `guaranteedThroughputTarget`). The caller schema + `TASK_PARAM_SCHEMA`, shared by both sides, lives in + [`src/common/task_schema.py`](src/common/task_schema.py). 4. **Execute and report** — the extracted parameters feed the business execution; progress comes back as status updates and the result as a report artifact, which the client presents. ### 3.2 Client side -The whole exchange is ([`client_flow.py`](src/client/client_flow.py)): +The core of the exchange is shown below, simplified from +[`client_flow.py`](src/client/client_flow.py): ```python prompt_content = prompt_client.generate_task_prompt_from_text(input_text, ENERGY_SAVING_TEMPLATE_URI) prompt_text, extension_uri = _require_prompt_text(prompt_content) request = SendMessageRequest() ... # message_id / role omitted -request.message.parts.add().text = _build_request_metadata(initial_input) +request.message.parts.add().text = _build_request_text(initial_input) # the scenario text part request.message.metadata[extension_uri] = prompt_text # the prompt travels in the metadata request.message.metadata["templateUri"] = getattr(prompt_content, "template_uri", None) or ENERGY_SAVING_TEMPLATE_URI call_context = ClientCallContext(service_parameters={"A2A-Extensions": extension_uri}) @@ -131,61 +134,67 @@ async for stream_response in a2a_client.send_message(request, context=call_conte ### 3.3 Server side -`_extract_prompt_text` reads the metadata and `validate_task_prompt_and_data_filling` validates and -extracts ([`server_flow.py:73`](src/server/server_flow.py#L73), [`server_flow.py:425`](src/server/server_flow.py#L425)). -A rejected prompt is mapped to `TASK_STATE_REJECTED` instead of crashing the server; the validated -parameters go into the execution integration point. +`_extract_prompt_text` reads the metadata and `execute_server_flow` calls +`validate_task_prompt_and_data_filling` +([`server_flow.py`](src/server/server_flow.py)). A rejected prompt is mapped to +`TASK_STATE_REJECTED` instead of crashing the server; the validated parameters are passed to the +execution integration point (`TaskExecutionService`). ### 3.4 What the client replaces -The template and the parameter schema are the contract shared by both agents: the client renders +The template and the parameter schema are the contract between the two agents: the client renders its task request from them, and the server validates against them. This sample's template (`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`) and schema are -sample-specific, so a client implementation brings its own -([`scenario_data.py`](src/client/scenario_data.py)). The business execution behind the server's -`TaskExecutionService` integration point belongs to the server agent (see the +sample-specific — the template constant lives in +[`scenario_data.py`](src/client/scenario_data.py), the schema in +[`src/common/task_schema.py`](src/common/task_schema.py). In a real deployment the two parties agree +on their own template and schema, and each side uses the agreed versions. The business execution +behind the server's `TaskExecutionService` integration point belongs to the server agent (see the [deployment checklist](#5-deployment-checklist)). -## 4. Using the A2A-T SDK to implement Negotiation-T interaction +## 4. Using the A2A-T SDK to implement the Negotiation-T interaction -The boundary in one sentence: the SDKs handle the protocol details — the **A2A-T SDK** generates and -validates the negotiation messages (rendering, the negotiation session map), and the **A2A SDK** -carries them (the transport). The A2A-T SDK has no transport layer of its own, so a deployment always -pairs the two. The two agents own the business, each on its own side: the server agent decides when -a request cannot be carried out as-is and a conversation would settle it; the client agent decides -how to answer the propose. +The SDKs handle the protocol details; the two agents own the business decisions. + +- The **A2A-T SDK** generates and validates the negotiation messages (propose, accept, reject and + abort): it renders the prompt text and carries the caller-supplied `negotiationContext` in the + generated message. It keeps no session state and has no transport layer. +- The **A2A SDK** carries the messages, so a deployment always pairs the two. +- In this sample, the server agent decides when a request cannot be carried out as received and a + negotiation could settle it; the client agent decides how to answer the propose. The protocol + itself lets either side propose (see [§4.3](#43-mechanics-common-to-all-kinds)). ### 4.1 Negotiation scenarios -The server opens a negotiation when it cannot execute the request as received, and the gap can be -resolved through negotiation rather than by rejecting the request. The sample covers three such -situations: +In this sample the server opens a negotiation when it cannot execute the request as received, and +the issue can be resolved through negotiation instead of immediate rejection. The sample covers +three such situations: **Information — the request is incomplete.** The client asks to run energy saving and names a goal, a time window and a throughput target, but never says which region. The server cannot start without it, so it says which piece is missing; the client supplies it and the task proceeds. -**Target — the request is internally inconsistent.** The client sends a segmented guaranteed -throughput target in which two time windows overlap: `{17:30~23:00,20Mbps}` and -`{20:00~00:00,10Mbps}`. The request is well-formed, but the guaranteed rate for the overlapping -period cannot be determined. The server restates the intent it understood and asks which time -window should be adjusted; the client answers with the corrected segments. +**Target — the request is self-contradictory.** The client sends a segmented guaranteed throughput +target in which two time windows overlap: `{17:30~23:00,20Mbps}` and `{20:00~00:00,10Mbps}`. The +request is well-formed, but the guaranteed rate for the overlapping period cannot be determined. +The server restates the intent it understood and asks which time window should be adjusted; the +client answers with the corrected segments. -**Feasibility — the request may be impossible under current conditions.** The client asks two -stations to hold `{00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}` through an outage that lasts from -00:00 to 18:00, while an existing constraint requires at least 18 hours of power autonomy. Holding -that rate would drain the batteries faster than the outage lasts. The server reports that the target -is infeasible and proposes an alternative (lower the 06:00~18:00 rate from 10 Mbps to 2 Mbps); the -client accepts the adjusted target. +**Feasibility — the request cannot be satisfied under current conditions.** The client asks two +stations to guarantee `{00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}` during a mains power outage that +lasts from 00:00 to 18:00, while an existing constraint requires at least 18 hours of battery +autonomy. At that rate the batteries would run out before the outage ends. The server reports that +the target is infeasible and proposes an alternative (lower the 06:00~18:00 rate from 10 Mbps to +2 Mbps); the client accepts the adjusted target. ### 4.2 How the demo selects the negotiation kind -Which situation applies — and whether to negotiate at all — is **the server agent's business -decision; the SDKs have no opinion** — and the client only reacts to the propose it receives. In this -demo it is data-driven: the first content-validation error's code selects the kind, via the +Whether to negotiate at all, and which kind applies, is **the server agent's business decision; the +SDKs do not prescribe it**, and the client only reacts to the propose it receives. In this demo the +choice is data-driven: the code of the first content-validation error selects the kind through the `_NEGOTIATION_TYPE_BY_ERROR_CODE` table -([`server_flow.py:55`](src/server/server_flow.py#L55)). That table is a **sample convention, not an -SDK contract**. +([`server_flow.py`](src/server/server_flow.py)). That table is a **sample convention, not an SDK +contract**. The message format does not depend on that table. Each propose records its `templateUri` — the prompt template its message text was rendered from, such as @@ -197,30 +206,32 @@ got, not from any error code. These apply to all three kinds; the per-kind sections below do not repeat them. -**Round.** A round consists of one propose and the response to that propose (accept or reject). The -session context carries a 1-based `round`: a response keeps the round of the propose it answers, and -only a *new* propose advances the round. The client's opening request is not a negotiation round — -the first round begins with the server's propose. `maxRounds` (default 5) bounds the session. - -**Either side can propose.** Negotiation is symmetric: the client and the server SDK expose the same -four generation methods (propose / accept / reject / abort) and the same validation methods, so -either agent can open a negotiation and either can answer one. This demo has the **server** propose -because the server is the side that discovers the request cannot be executed as-is. That is a choice -of this scenario, not a property of the protocol: a client could open a negotiation just as well, for -example when it cannot meet a constraint the server announced, and the server would answer with -accept, reject or abort. The role only decides who sends the propose — the message forms, the -session handling and the transport are identical. This demo implements the decision on the client side -(the provider integration point); if the client opens a negotiation, the equivalent decision belongs on the -server side. +**Round.** A round consists of one propose and the response to that propose (accept, reject or +abort). The session context carries a 1-based `round`: a response keeps the round of the propose it +answers, and only a *new* propose advances the round. The client's initial request is not a +negotiation round — the first round begins with the server's propose. `maxRounds` (default 5) bounds +the session. + +**Either side can propose.** Negotiation is symmetric: the A2A-T client SDK and the A2A-T server SDK +expose the same generation methods (propose / accept / reject / abort) and the same validation +methods, so either agent can open a negotiation and either can answer one. Only the sender of the +propose differs; message forms, session handling and transport are identical. In this demo the +**server** proposes, because it is the side that discovers the request cannot be executed as +received. A client could equally open a negotiation, for example when it cannot meet a constraint +the server announced; the server would then answer with accept, reject or abort. The demo implements +only the client-side decision (the provider integration point); a client-initiated negotiation would +need the equivalent decision on the server side. **The closed loop, in four messages:** -1. client → server: the energy-saving request as it stands — in the demo: a missing region, an - overlapping target, or an outage the guaranteed rate cannot sustain. -2. server → client: the propose — what is missing, what needs clarifying, or what needs evaluating. -3. client → server: the request re-sent with the missing or corrected content filled in; the - answer travels in that same message (it is one message, not two). -4. server → client: the result — progress, then the report. +1. client → server: the energy-saving request. In the demo it is deliberately flawed in one of three + ways: a missing region, overlapping time windows in the target, or a target the batteries cannot + sustain during the outage. +2. server → client: the propose — what is missing, what needs clarification, or which target is + infeasible and what alternative is offered. +3. client → server: a single message that carries both the accept and the re-sent request with the + missing or corrected content filled in. +4. server → client: the result — progress updates, then the report. The session (`id` / `round` / `maxRounds` / performative) travels with every negotiation message in the `negotiationContext` metadata map, so the A2A-T SDK itself stays stateless. @@ -237,16 +248,19 @@ class NegotiationResponseProvider(Protocol): ) -> NegotiationDecision: ... ``` -`NegotiationDecision` carries a `conclusion` (`accept` / `reject` / `abort`) plus the per-kind -accept field: `filled_params` (information, name → value), `confirmed_intent` (target), -`feasibility_summary` (feasibility), and `reason` (reject/abort). **`accept` is implemented -end to end**: from the client's decision the flow regenerates the Task-T prompt, builds the typed accept -with the same round stamped as `ACCEPT`, and sends it over the configured transport. `respond` is -synchronous — an implementation that needs asynchrony (human confirmation, an upstream call) owns -that internally. +`NegotiationDecision` carries a `conclusion` (`accept` / `reject` / `abort`) plus the payload for +it: for `accept`, `filled_params` (information, name → value), `confirmed_intent` (target) or +`feasibility_summary` (feasibility); for `reject` / `abort`, a `reason`. **`accept` is implemented +end to end**: from the client's decision the flow regenerates the Task-T prompt, builds the accept +message (same round, performative `ACCEPT`) and sends it over the configured transport. `respond` is +synchronous, so it blocks the flow until it returns; an implementation that needs to wait (human +confirmation, an upstream call) handles that inside `respond`. -> The provider replaces only the scripted **answer** to a propose — the Task-T refill parameters -> (`negotiation_params`) stay caller input to the flow, which will not engage a negotiation without them. +> The provider replaces only the scripted **answer** to a propose — for information, the values +> returned in `filled_params`. The Task-T refill parameters (`negotiation_params`) remain +> caller-supplied inputs: the flow uses them to regenerate the Task-T prompt in the accept message, +> and without them the flow leaves the propose unanswered. In the demo both come from +> `scenario_data`, so they agree. ### 4.4 Information negotiation @@ -258,9 +272,9 @@ that code is the sample's choice, not part of the protocol. | Step | SDK call | Reference code | | --- | --- | --- | -| Server propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | -| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:700`](src/client/client_flow.py#L700) | -| Client accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py:619`](src/client/client_flow.py#L619) | +| Server propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py`](src/server/server_flow.py) (`_emit_negotiation_propose`) | +| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py`](src/client/client_flow.py) (`_plan_negotiation_accept`) | +| Client accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py`](src/client/client_flow.py) (`_build_negotiation_accept_request`) | ```python # Server propose: write the business message, then render it through the from-text API @@ -300,7 +314,7 @@ class MyProvider: ### 4.5 Target negotiation -**① Trigger condition.** The request contradicts itself: two time windows of the segmented +**① Trigger condition.** The request is self-contradictory: two time windows of the segmented guaranteed throughput target overlap, so the guaranteed rate for the overlapping period is ambiguous. The demo's trigger is a `content.value_not_allowed` validation error. @@ -308,9 +322,9 @@ ambiguous. The demo's trigger is a `content.value_not_allowed` validation error. | Step | SDK call | Reference code | | --- | --- | --- | -| Server propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | -| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:700`](src/client/client_flow.py#L700) | -| Client accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py:619`](src/client/client_flow.py#L619) | +| Server propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py`](src/server/server_flow.py) (`_emit_negotiation_propose`) | +| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py`](src/client/client_flow.py) (`_plan_negotiation_accept`) | +| Client accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py`](src/client/client_flow.py) (`_build_negotiation_accept_request`) | ```python # Server propose: write the business message, then render it through the from-text API @@ -332,9 +346,9 @@ accept = client.generate_negotiation_accept_prompt_from_text( ) ``` -The demo proposes the *clarification* form: the server restates the intent it understood -(`intent_understanding`) and asks for the time adjustment (`request_for_clarification`). The accept -carries the corrected target as `confirmed_intent`. +The demo's propose asks for clarification: the server restates the intent it understood +(`intent_understanding`) and asks which time window to adjust (`request_for_clarification`). The +accept carries the corrected target as `confirmed_intent`. **③ Reusable client code.** The same as information: rendering, metadata assembly, session handling, transport. @@ -345,17 +359,17 @@ ambiguity. ### 4.6 Feasibility negotiation -**① Trigger condition.** The request may be impossible under current conditions: the -guaranteed rate cannot sustain the declared outage duration for the listed stations. The demo's +**① Trigger condition.** The request cannot be satisfied under current conditions: the guaranteed +rate cannot sustain the declared outage duration for the stations named in the request. The demo's trigger is a `content.rule_violation` validation error. **② Reference implementation.** | Step | SDK call | Reference code | | --- | --- | --- | -| Server propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py:199`](src/server/server_flow.py#L199) | -| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py:700`](src/client/client_flow.py#L700) | -| Client accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py:619`](src/client/client_flow.py#L619) | +| Server propose | `generate_negotiation_propose_prompt_from_text` | [`server_flow.py`](src/server/server_flow.py) (`_emit_negotiation_propose`) | +| Client decision | `NegotiationResponseProvider.respond` | [`negotiation_response.py`](src/client/negotiation_response.py), [`client_flow.py`](src/client/client_flow.py) (`_plan_negotiation_accept`) | +| Client accept | `generate_negotiation_accept_prompt_from_text` | [`client_flow.py`](src/client/client_flow.py) (`_build_negotiation_accept_request`) | ```python # Server propose: write the business message, then render it through the from-text API @@ -377,9 +391,9 @@ accept = client.generate_negotiation_accept_prompt_from_text( ) ``` -The demo reports the completed assessment as infeasible and attaches an alternative proposal -(`action=PROPOSE_ALTERNATIVE_ON_FAILURE` with `infeasibility_details_and_proposal`); the accept -states the adjustment it agrees to. +The demo's propose states that the assessment concluded the target is infeasible and attaches an +alternative (`action=PROPOSE_ALTERNATIVE_ON_FAILURE` with `infeasibility_details_and_proposal`); the +accept states the adjustment the client agrees to. **③ Reusable client code.** The same as information: rendering, metadata assembly, session handling, transport. @@ -389,78 +403,81 @@ to the server's proposal. ### 4.7 Reject and abort -The client can also answer a propose with `reject` or `abort` instead of `accept`. This demo only -implements the accept path: a reject/abort decision is logged and the flow ends without sending a -message. The SDK provides the matching generation methods +The client can answer a propose with `reject` (decline the propose) or `abort` (terminate the +negotiation) instead of `accept`; both are terminal in the protocol. This demo implements only the +accept path: for a reject/abort decision it logs the decision and ends the client flow without +sending a message, so the server is not notified. A production client sends the corresponding +message; the SDK provides the matching generation methods (`generate_negotiation_reject_prompt_from_data` / `..._from_text`, and -`generate_negotiation_abort_prompt_from_data` / `..._from_text`) when a client needs to send one. +`generate_negotiation_abort_prompt_from_data` / `..._from_text`). ## 5. Deployment checklist -The **Side** column identifies the agent responsible for each component. The client team acts on -the rows marked `client`; the `server` rows describe what the counterpart replaces when it is a real -system. +The **Side** column identifies the agent responsible for each component. The client team is +responsible for the rows marked `client` and `both`; the rows marked `server` describe what the +counterpart replaces in a real system. | Demo piece | Side | What it does in the demo | Keep or replace | | --- | --- | --- | --- | -| mock LLM — [`resources/mock_llm/`](resources/mock_llm) + the schema-routed mock in `src/common/` | both | scripted slot-extraction and content-validation replies, selected when `A2AT_LLM_API_KEY` is empty | **KEEP** for CI/offline runs; in production each side removes it and configures a real OpenAI-compatible endpoint | -| scripted execution data — [`src/server/constants_data.py`](src/server/constants_data.py) | server | the UC1 progress steps and intent report the demo execution streams | **REPLACE** by the server agent via the `TaskExecutionService` integration point ([`src/server/task_execution.py`](src/server/task_execution.py)); once its service is assembled, the demo service and data can be deleted | -| scripted answers — [`src/client/scenario_data.py`](src/client/scenario_data.py) | both (see below) | feeds BOTH sides | **REPLACE** on both sides: the client answers via its `NegotiationResponseProvider`; the server's propose texts in its copy of `server_flow._negotiation_propose_text` become its business phrasing | -| mock registry — [`src/registry/`](src/registry) (port 5001) | client | AgentCard register/query for discovery | **REPLACE** with the client's registry center, or bypass discovery and construct the `a2a-sdk` client from a known AgentCard | -| static webhook token — `A2AT_SAMPLE_WEBHOOK_TOKEN` (default `demo-webhook-token`) | both | shared static token in the `X-A2A-Notification-Token` header | **REPLACE** with production auth (per-pair secrets, rotation, TLS); treat the receiver as an exposed HTTP endpoint | +| mock LLM — [`resources/mock_llm/`](resources/mock_llm) + the schema-routed mock in `src/common/` | both | scripted slot-extraction and content-validation replies, selected when `A2AT_LLM_API_KEY` is empty | **KEEP** for CI / offline runs. In a real deployment, each side replaces it with a real OpenAI-compatible endpoint | +| scripted execution data — [`src/server/constants_data.py`](src/server/constants_data.py) | server | the use-case (UC1) progress steps and intent report the demo execution streams | **REPLACE** by the server agent via the `TaskExecutionService` integration point ([`src/server/task_execution.py`](src/server/task_execution.py)); once its service is assembled, the demo service and data can be deleted | +| scripted answers — [`src/client/scenario_data.py`](src/client/scenario_data.py) | both (see below) | feeds BOTH sides | **REPLACE** on both sides: the client answers through its own `NegotiationResponseProvider`; the server replaces the canned propose texts in `server_flow._negotiation_propose_text` with its own business wording | +| mock registry — [`src/registry/`](src/registry) (port 5001) | both | AgentCard registration (server) and query for discovery (client) | **REPLACE** with the deployment's registry center, or bypass discovery and construct the `a2a-sdk` client from a known AgentCard | +| static webhook token — `A2AT_SAMPLE_WEBHOOK_TOKEN` (default `demo-webhook-token`) | both | shared static token in the `X-A2A-Notification-Token` header | **REPLACE** with production auth (per-client secrets, rotation, TLS); treat the receiver as an exposed HTTP endpoint | > **`scenario_data` feeds BOTH sides** — the client's scripted answers (filled parameters, > confirmed intent, feasibility summary) go through the provider integration point, and the server's > propose text is built from the same module: `_negotiation_propose_text` -> ([`server_flow.py:162`](src/server/server_flow.py#L162)) fills the target/feasibility propose -> texts from its accessors. Replacing only the provider and the execution service still leaves -> the server proposing the demo's canned texts — the module is not removable. +> ([`server_flow.py`](src/server/server_flow.py)) fills the target/feasibility propose texts from +> its accessors. Replacing only the provider and the execution service still leaves the server +> proposing the demo's canned texts, so `scenario_data.py` cannot be deleted until both sides have +> also been replaced. ## 6. Appendix ### 6.1 Webhook transport -Both transports deliver the same task events; the difference is how the client receives them. -SSE streaming is the default: the client holds the streaming `message:send` open and consumes the -events as they arrive. Webhook push notifications are for clients that do not hold a streaming -connection open (long-running tasks, intermittently connected clients): `--transport webhook` (or -`A2AT_SAMPLE_TRANSPORT=webhook`). +Both transports deliver the same task events; they differ in how the client receives them. SSE +streaming is the default: the client keeps the streaming `message:stream` call open and consumes +the events as they arrive. Webhook push notifications are for clients that cannot keep a streaming +connection open (long-running tasks, intermittently connected clients). Enable them with +`--transport webhook` or `A2AT_SAMPLE_TRANSPORT=webhook`. What changes under webhook: the client sends non-blocking (`return_immediately`) and every task -event arrives as a webhook POST on a receiver embedded in the client process -(`WebhookReceiver` in [`src/common/webhook_receiver.py`](src/common/webhook_receiver.py)), which -binds one extra listening port (`--webhook-port`, default 9000). The server's pushes carry +event arrives as a webhook POST on a receiver embedded in the client process (`WebhookReceiver` in +[`src/common/webhook_receiver.py`](src/common/webhook_receiver.py)), which binds one extra +listening port (`--webhook-port`, default 9000). The server's pushes carry `A2AT_SAMPLE_WEBHOOK_TOKEN` (default `demo-webhook-token`) in the `X-A2A-Notification-Token` -header — a mismatch answers `401` and the event is dropped. The flow ends on any SDK terminal -state, with a 30 s idle backstop. The negotiation round trip is statement-identical on both -transports. +header. The flow ends on any SDK terminal state, with a 30 s idle backstop. The negotiation round +trip behaves identically on both transports. - **Two registration paths.** The initial request registers the push config inline (`SendMessageConfiguration.task_push_notification_config`, a flat - `TaskPushNotificationConfig(url, token)` — the task id is still unknown at this point); before - sending the accept the flow re-registers the same URL/token through the - `POST /tasks/{id}/pushNotificationConfigs` REST endpoint (a2a-sdk - `BaseClient.create_task_push_notification_config`). + `TaskPushNotificationConfig(url, token)`) because the task id does not exist yet. Before sending + the accept, the flow re-registers the same URL/token through + `POST /tasks/{id}/pushNotificationConfigs` (a2a-sdk `BaseClient.create_task_push_notification_config`) + — the endpoint path that becomes available once the task id is known. - **No server-side mode switch.** The server assembles the push machinery unconditionally (a2a-sdk `DefaultRequestHandler` with `InMemoryPushNotificationConfigStore` + `BasePushNotificationSender`, [`server_main.py`](src/server/server_main.py)); in SSE mode it simply idles. -- **Non-blocking path.** The a2a client is built with `ClientConfig(streaming=False)`, required to - select the non-blocking `message:send` path (the `return_immediately` flag alone does not switch - it). Push bodies are StreamResponse-shaped JSON — the same shape as the SSE events — so +- **Non-blocking path.** The a2a-sdk client is built with `ClientConfig(streaming=False)`, required + to select the non-blocking `message:send` path (the `return_immediately` flag alone does not + switch it). Push bodies are StreamResponse-shaped JSON — the same shape as the SSE events — so negotiation detection, artifact counting, and presentation are shared with SSE. - **Token auth.** The receiver validates `X-A2A-Notification-Token` on every POST: a mismatch answers `401` (the event is dropped), a malformed or unrecognized body answers `400`. The demo's static shared token is demo-grade — for production use per-client secrets with rotation over TLS. - **End signals.** Any SDK terminal state (`TASK_STATE_COMPLETED` / `FAILED` / `CANCELED` / - `REJECTED`) ends the flow in both phases — negotiation scenarios normally end on `COMPLETED`, a - failed validation or stream error on `REJECTED` / `FAILED`; the summary notes a terminal end as - `end=`. The default scenario still ends on the report artifact; `INPUT_REQUIRED` (a - negotiation propose) is an initial-phase interrupt, not a flow end. The idle backstop + `REJECTED`) ends the phase that is consuming events: a negotiation scenario normally ends on + `COMPLETED`; a failed validation or a stream error ends on `REJECTED` / `FAILED`, and the summary + records it as `end=`. The default scenario (no negotiation) ends on the report artifact. + `INPUT_REQUIRED` (a negotiation propose) is an initial-phase interruption, not a flow end: when + the negotiation is engaged, the flow proceeds to the continuation phase. The idle backstop (`A2AT_SAMPLE_WEBHOOK_IDLE_TIMEOUT`, default 30 s, reset per received event) logs `webhook-idle-timeout` and ends the flow with what was collected. `A2AT_SAMPLE_MAX_ARTIFACTS` - does not apply in webhook mode; each phase's single RPC reply is logged once as - `webhook-rpc-reply` and not counted again, and the `stream-completed` summary counts the + does not apply in webhook mode. Each phase's single RPC reply is logged once as + `webhook-rpc-reply` and is not counted again. The `stream-completed` summary counts the webhook-received events and appends `transport=webhook`. ### 6.2 Configuration (a2a-t-sample/.env) @@ -484,18 +501,21 @@ transports. Notes: `run_demo.py` always uses `5001` / `8000` for its readiness checks and ignores `A2AT_SAMPLE_PORT` / `REGISTRY_CENTER_PORT` — set those only for the manual `python -m` path. -Server-side progress and report text is fixed English regardless of `--language`; the flag only -switches the client input, templates, and mock replies, while the negotiation message texts -follow the run language. The template, parameter schema, AgentCard, and natural-language input -are sample-specific; the template must belong to the `Task-T` extension and use version `v1`. -Remote prompt-resource loading is not part of the SDK, and the registry center here is a -sample-side mock for AgentCard discovery, not an SDK capability. +Server-side progress and report text is always English, regardless of `--language`. The flag +switches the client input, the templates, the mock replies and the negotiation message texts. The +template, parameter schema, AgentCard and natural-language input are sample-specific; a replaced +template must belong to the `Task-T` extension and use version `v1`. The SDK does not load remote +prompt resources; the registry center in this sample is a sample-only mock used for AgentCard +discovery, not an SDK capability. ### 6.3 Run the two sides separately -Point `PYTHONPATH` at the case `src/` and start each part in its own terminal. `.env` is read from -the current directory, and a missing `.env` is a **hard error** for the client and server. Start -the registry and the server first — the client discovers the server's AgentCard from the registry. +Point `PYTHONPATH` at the sample's `src/` directory and start each part in its own terminal. `.env` +is read from the current directory, and a missing `.env` is a **hard error** for the client and +server. Start the registry first, then the server, then the client (the client discovers the +server's AgentCard from the registry). + +From the repository root: ```bash # bash / zsh @@ -530,8 +550,8 @@ content-validation responses of [`resources/mock_llm/{zh-CN,en-US}/`](resources/ | Flag | Effect | | --- | --- | | `--language {en-US,zh-CN}` | Run language (default `en-US`) | -| `--scenario {default,negotiation}` | Scenario variant (default `default`; `negotiation` runs the missing-region information negotiation) | -| `--negotiation {information,target,feasibility}` | Negotiation kind selecting the client scenario input (default `information` = missing region; `target` = two overlapping time segments in the guaranteed throughput target; `feasibility` = the guaranteed rate cannot sustain an 18-hour power outage). `information` keeps the `--scenario` switch (only `--scenario negotiation` runs the missing-region flow); `target`/`feasibility` engage without it. The flag only selects the input — the server stays data-driven on the validation error code | +| `--scenario {default,negotiation}` | Scenario variant (default: `default`; `negotiation` runs the missing-region information negotiation) | +| `--negotiation {information,target,feasibility}` | Negotiation kind: `information` (default; missing region), `target` (two overlapping time segments in the guaranteed throughput target) or `feasibility` (the guaranteed rate cannot sustain the 18-hour power outage). `target` and `feasibility` run a negotiation on their own; `information` additionally needs `--scenario negotiation`. The flag only selects the client input; the server decides the kind from the validation error code | | `--transport {sse,webhook}` | Event transport of the client (default `sse`); see [§6.1](#61-webhook-transport) | | `--webhook-port N` | Bind port of the embedded webhook receiver used with `--transport webhook` (default 9000; `0` selects an OS-assigned ephemeral port) | | `--keep-alive` | Leave registry/server running; stop later with `--stop` | From 2e89035de9c9e74640a5cc99bc5252f7f7e58901 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 10:55:07 +0800 Subject: [PATCH 23/30] docs(sample): restructure the overview around the two workflows Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 28 +++++++------ a2a-t-sample/ran-energy-saving/README.md | 45 ++++++++++----------- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 8c1125c3..0ded0e05 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -34,21 +34,23 @@ ## 1. 本样例演示了什么 -一个客户端 Agent 通过 A2A 协议委托一个 RAN(Radio Access Network,无线接入网)域 Agent 执行节能 -任务。借助这一个场景,样例按顺序覆盖客户需要集成的两项 A2A-T 能力: +本样例演示 A2A-T Agent 协作:客户端 Agent 与 RAN(Radio Access Network,无线接入网)节能 Agent +交互,执行节能任务。样例同时使用 Task-T 与 Negotiation-T。 -```text -Task-T 自然语言 ──> 任务提示词 ──> 校验 ──> 执行 ──> 报告 +关键工作流: -Negotiation-T 请求需要补全或修正后才能执行时(信息缺失 / 目标自相矛盾 / 目标不可行) - ──> 双方协商 ──> 达成一致 ──> 任务执行 -``` +- **Task-T(不含协商):** + + ```text + 自然语言 ──> 任务提示词 ──> 校验 ──> 任务执行 + ``` -本文后续内容按三种使用路径组织: +- **Task-T + Negotiation-T(含协商):** -- **运行样例** —— [§2 快速开始](#2-快速开始)。 -- **实现 Task-T 交互** —— [§3](#3-使用-a2a-t-sdk-实现-task-t-交互)与[部署清单](#5-部署清单)。 -- **实现 Negotiation-T 交互** —— [§4](#4-使用-a2a-t-sdk-实现-negotiation-t-交互)。 + ```text + 自然语言(不完整 / 存在歧义 / 不可行) + ──> 任务提示词 ──> 校验 ──> 协商 ──> 达成一致 ──> 任务执行 + ``` ### 1.1 目录结构 @@ -159,7 +161,7 @@ schema,两端使用约定后的版本。服务端 `TaskExecutionService` 接 **信息不完整。** 客户端要求执行节能,给了目标、时段和吞吐目标,却没说在哪个区域。服务端缺这一项 无法开工,于是告诉客户端缺什么;客户端补上,任务继续。 -**目标自相矛盾。** 客户端给出的分时段保障速率目标里,两个时段重叠: +**目标存在歧义。** 客户端给出的分时段保障速率目标里,两个时段重叠: `{17:30~23:00,20Mbps}` 与 `{20:00~00:00,10Mbps}`。请求本身格式没问题,但重叠时段的保障速率无法 唯一确定。服务端复述它理解的意图,请客户端调整时间;客户端带着修正后的时段回应。 @@ -279,7 +281,7 @@ class MyProvider: ### 4.5 目标协商(target) -**① 触发条件。** 请求自相矛盾:分时段保障速率目标里有两个时段重叠,重叠时段的保障速率无法 +**① 触发条件。** 目标存在歧义:分时段保障速率目标里有两个时段重叠,重叠时段的保障速率无法 唯一确定。样例的触发条件是 `content.value_not_allowed` 校验错误。 **② 参考实现。** diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index f78383c2..4efc89af 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -36,25 +36,24 @@ unspecified reader. ## 1. What this sample demonstrates -A client agent commissions a RAN (Radio Access Network) domain agent to run an energy-saving task -over the A2A protocol. Using this single scenario, the sample covers the two A2A-T capabilities a -customer needs to integrate, in the following order: +This sample demonstrates A2A-T agent collaboration, where a client agent interacts with a RAN +(Radio Access Network) energy-saving agent to execute an energy-saving task. It uses both Task-T and +Negotiation-T. -```text -Task-T natural language ──> task prompt ──> validation ──> execution ──> report +Key workflows: -Negotiation-T when the request needs completion or correction before it can be executed - (information missing / target self-contradictory / target infeasible) - ──> negotiation ──> agreement ──> task execution -``` +- **Task-T (without negotiation):** + + ```text + natural language ──> task prompt ──> validation ──> task execution + ``` -The remaining sections follow three usage paths: +- **Task-T + Negotiation-T (with negotiation):** -- **Run the sample** — [§2 Quickstart](#2-quickstart). -- **Implement the Task-T interaction** — [§3](#3-using-the-a2a-t-sdk-to-implement-the-task-t-interaction) - together with the [deployment checklist](#5-deployment-checklist). -- **Implement the Negotiation-T interaction** — - [§4](#4-using-the-a2a-t-sdk-to-implement-the-negotiation-t-interaction). + ```text + natural language (incomplete / ambiguous / infeasible) + ──> task prompt ──> validation ──> negotiation ──> agreement ──> task execution + ``` ### 1.1 Project layout @@ -174,11 +173,11 @@ three such situations: a time window and a throughput target, but never says which region. The server cannot start without it, so it says which piece is missing; the client supplies it and the task proceeds. -**Target — the request is self-contradictory.** The client sends a segmented guaranteed throughput -target in which two time windows overlap: `{17:30~23:00,20Mbps}` and `{20:00~00:00,10Mbps}`. The -request is well-formed, but the guaranteed rate for the overlapping period cannot be determined. -The server restates the intent it understood and asks which time window should be adjusted; the -client answers with the corrected segments. +**Target — the target is ambiguous.** The client sends a segmented guaranteed throughput target in +which two time windows overlap: `{17:30~23:00,20Mbps}` and `{20:00~00:00,10Mbps}`. The request is +well-formed, but the guaranteed rate for the overlapping period cannot be determined. The server +restates the intent it understood and asks which time window should be adjusted; the client answers +with the corrected segments. **Feasibility — the request cannot be satisfied under current conditions.** The client asks two stations to guarantee `{00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}` during a mains power outage that @@ -314,9 +313,9 @@ class MyProvider: ### 4.5 Target negotiation -**① Trigger condition.** The request is self-contradictory: two time windows of the segmented -guaranteed throughput target overlap, so the guaranteed rate for the overlapping period is -ambiguous. The demo's trigger is a `content.value_not_allowed` validation error. +**① Trigger condition.** The target is ambiguous: two time windows of the segmented guaranteed +throughput target overlap, so the guaranteed rate for the overlapping period cannot be determined. +The demo's trigger is a `content.value_not_allowed` validation error. **② Reference implementation.** From 5fd253533cc31bb7bb0c590c2ef035a3c8b4c082 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 11:15:58 +0800 Subject: [PATCH 24/30] docs(sample): use plain wording for the SDK role in section 4.2 Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 4efc89af..b4f53f00 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -189,7 +189,7 @@ the target is infeasible and proposes an alternative (lower the 06:00~18:00 rate ### 4.2 How the demo selects the negotiation kind Whether to negotiate at all, and which kind applies, is **the server agent's business decision; the -SDKs do not prescribe it**, and the client only reacts to the propose it receives. In this demo the +SDKs do not dictate it**, and the client only reacts to the propose it receives. In this demo the choice is data-driven: the code of the first content-validation error selects the kind through the `_NEGOTIATION_TYPE_BY_ERROR_CODE` table ([`server_flow.py`](src/server/server_flow.py)). That table is a **sample convention, not an SDK From aee27294f3f4da071cc22f029ca1e2e026a61302 Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 11:24:37 +0800 Subject: [PATCH 25/30] docs(sample): restructure the custom client agent notes Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 31 ++++++++++++++---- a2a-t-sample/ran-energy-saving/README.md | 36 +++++++++++++++------ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 0ded0e05..c3e5bea3 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -15,7 +15,10 @@ - [3.1 四个步骤](#31-四个步骤) - [3.2 客户端侧](#32-客户端侧) - [3.3 服务端侧](#33-服务端侧) - - [3.4 client 侧要替换什么](#34-client-侧要替换什么) + - [3.4 自定义客户端 Agent 开发说明](#34-自定义客户端-agent-开发说明) + - [3.4.1 客户端输入](#341-客户端输入) + - [3.4.2 模板与参数 schema](#342-模板与参数-schema) + - [3.4.3 服务端执行](#343-服务端执行) - [4. 使用 A2A-T SDK 实现 Negotiation-T 交互](#4-使用-a2a-t-sdk-实现-negotiation-t-交互) - [4.1 协商场景](#41-协商场景) - [4.2 样例如何选择协商类型](#42-样例如何选择协商类型) @@ -133,14 +136,28 @@ async for stream_response in a2a_client.send_message(request, context=call_conte 被拒绝的提示词映射为 `TASK_STATE_REJECTED`,而不是让服务崩溃;校验通过的参数会传给执行接入点 (`TaskExecutionService`)。 -### 3.4 client 侧要替换什么 +### 3.4 自定义客户端 Agent 开发说明 + +样例中的取值都是演示数据。客户端实现需要用自身业务内容替换客户端侧的取值,并使用与服务端约定后 +的共享取值。 + +#### 3.4.1 客户端输入 + +自然语言任务请求属于客户端自身的输入:`scenario_data.py` 中是样例文案,客户端实现应替换为自己 +需要发送的意图。任务提示词由该输入经 `generate_task_prompt_from_text` 渲染(见 §3.1)。 + +#### 3.4.2 模板与参数 schema 模板与参数 schema 是两端之间的契约:客户端用它渲染任务请求,服务端用它做校验。本样例的模板 -(`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`)与 schema 都是样例 -特有的 —— 模板常量位于 [`scenario_data.py`](src/client/scenario_data.py),schema 位于 -[`src/common/task_schema.py`](src/common/task_schema.py)。真实部署中,双方约定共同使用的模板与 -schema,两端使用约定后的版本。服务端 `TaskExecutionService` 接入点之后的业务执行属于 server agent -(见[部署清单](#5-部署清单))。 +(`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`,位于 +[`scenario_data.py`](src/client/scenario_data.py))与 schema(位于 +[`src/common/task_schema.py`](src/common/task_schema.py))都是样例特有的。真实部署中,双方约定 +模板与 schema,两端使用约定后的版本。 + +#### 3.4.3 服务端执行 + +服务端 `TaskExecutionService` 接入点之后的业务执行属于 server agent。客户端只发送请求并消费进度 +更新与报告,不实现任何执行逻辑。见[部署清单](#5-部署清单)。 ## 4. 使用 A2A-T SDK 实现 Negotiation-T 交互 diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index b4f53f00..c5760ef7 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -17,7 +17,10 @@ unspecified reader. - [3.1 The four steps](#31-the-four-steps) - [3.2 Client side](#32-client-side) - [3.3 Server side](#33-server-side) - - [3.4 What the client replaces](#34-what-the-client-replaces) + - [3.4 Notes for custom client agent development](#34-notes-for-custom-client-agent-development) + - [3.4.1 Client input](#341-client-input) + - [3.4.2 Template and parameter schema](#342-template-and-parameter-schema) + - [3.4.3 Server-side execution](#343-server-side-execution) - [4. Using the A2A-T SDK to implement the Negotiation-T interaction](#4-using-the-a2a-t-sdk-to-implement-the-negotiation-t-interaction) - [4.1 Negotiation scenarios](#41-negotiation-scenarios) - [4.2 How the demo selects the negotiation kind](#42-how-the-demo-selects-the-negotiation-kind) @@ -139,17 +142,32 @@ async for stream_response in a2a_client.send_message(request, context=call_conte `TASK_STATE_REJECTED` instead of crashing the server; the validated parameters are passed to the execution integration point (`TaskExecutionService`). -### 3.4 What the client replaces +### 3.4 Notes for custom client agent development + +The sample ships demo values. A client implementation replaces the client-side values with its own +business content, and uses the agreed versions of the values shared with the server. + +#### 3.4.1 Client input + +The natural-language task request is the client's own input: `scenario_data.py` holds the demo +wording, which a client implementation replaces with the intent it needs to send. The task prompt +is rendered from this input by `generate_task_prompt_from_text` (§3.1). + +#### 3.4.2 Template and parameter schema The template and the parameter schema are the contract between the two agents: the client renders its task request from them, and the server validates against them. This sample's template -(`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`) and schema are -sample-specific — the template constant lives in -[`scenario_data.py`](src/client/scenario_data.py), the schema in -[`src/common/task_schema.py`](src/common/task_schema.py). In a real deployment the two parties agree -on their own template and schema, and each side uses the agreed versions. The business execution -behind the server's `TaskExecutionService` integration point belongs to the server agent (see the -[deployment checklist](#5-deployment-checklist)). +(`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`, in +[`scenario_data.py`](src/client/scenario_data.py)) and schema (in +[`src/common/task_schema.py`](src/common/task_schema.py)) are sample-specific. In a real deployment +the two parties agree on the template and the schema, and each side uses the agreed versions. + +#### 3.4.3 Server-side execution + +The business execution behind the server's `TaskExecutionService` integration point belongs to the +server agent. The client sends the request and consumes the progress updates and the report; it +implements no part of the execution. See the +[deployment checklist](#5-deployment-checklist). ## 4. Using the A2A-T SDK to implement the Negotiation-T interaction From 20ce5b27394f32b4f55b24fa6e0c94071111ee9b Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 11:26:45 +0800 Subject: [PATCH 26/30] docs(sample): merge the custom agent notes into the deployment checklist Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 35 +++--------------- a2a-t-sample/ran-energy-saving/README.md | 41 ++++----------------- 2 files changed, 13 insertions(+), 63 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index c3e5bea3..1f9df9b1 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -15,10 +15,6 @@ - [3.1 四个步骤](#31-四个步骤) - [3.2 客户端侧](#32-客户端侧) - [3.3 服务端侧](#33-服务端侧) - - [3.4 自定义客户端 Agent 开发说明](#34-自定义客户端-agent-开发说明) - - [3.4.1 客户端输入](#341-客户端输入) - - [3.4.2 模板与参数 schema](#342-模板与参数-schema) - - [3.4.3 服务端执行](#343-服务端执行) - [4. 使用 A2A-T SDK 实现 Negotiation-T 交互](#4-使用-a2a-t-sdk-实现-negotiation-t-交互) - [4.1 协商场景](#41-协商场景) - [4.2 样例如何选择协商类型](#42-样例如何选择协商类型) @@ -136,28 +132,8 @@ async for stream_response in a2a_client.send_message(request, context=call_conte 被拒绝的提示词映射为 `TASK_STATE_REJECTED`,而不是让服务崩溃;校验通过的参数会传给执行接入点 (`TaskExecutionService`)。 -### 3.4 自定义客户端 Agent 开发说明 - -样例中的取值都是演示数据。客户端实现需要用自身业务内容替换客户端侧的取值,并使用与服务端约定后 -的共享取值。 - -#### 3.4.1 客户端输入 - -自然语言任务请求属于客户端自身的输入:`scenario_data.py` 中是样例文案,客户端实现应替换为自己 -需要发送的意图。任务提示词由该输入经 `generate_task_prompt_from_text` 渲染(见 §3.1)。 - -#### 3.4.2 模板与参数 schema - -模板与参数 schema 是两端之间的契约:客户端用它渲染任务请求,服务端用它做校验。本样例的模板 -(`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`,位于 -[`scenario_data.py`](src/client/scenario_data.py))与 schema(位于 -[`src/common/task_schema.py`](src/common/task_schema.py))都是样例特有的。真实部署中,双方约定 -模板与 schema,两端使用约定后的版本。 - -#### 3.4.3 服务端执行 - -服务端 `TaskExecutionService` 接入点之后的业务执行属于 server agent。客户端只发送请求并消费进度 -更新与报告,不实现任何执行逻辑。见[部署清单](#5-部署清单)。 +模板、参数 schema 与自然语言输入都是样例特有的;各端需要替换的内容见 +[部署清单](#5-部署清单)。 ## 4. 使用 A2A-T SDK 实现 Negotiation-T 交互 @@ -393,14 +369,15 @@ accept = client.generate_negotiation_accept_prompt_from_text( | demo 组成 | 归属 | demo 中的作用 | 保留还是替换 | | --- | --- | --- | --- | +| 任务模板与参数 schema —— `ENERGY_SAVING_TEMPLATE_URI`([`scenario_data.py`](src/client/scenario_data.py))、[`src/common/task_schema.py`](src/common/task_schema.py) | 两端 | 客户端据此渲染请求、服务端据此校验的契约 | **替换**为双方约定的模板与 schema | | mock LLM —— [`resources/mock_llm/`](resources/mock_llm) + `src/common/` 中按 schema 路由的 mock | 两端 | 脚本化的槽位抽取与内容校验应答,`A2AT_LLM_API_KEY` 为空时启用 | CI/离线运行**保留**;真实部署中两端各自替换为真实的 OpenAI 兼容端点 | | 脚本化执行数据 —— [`src/server/constants_data.py`](src/server/constants_data.py) | server | demo 执行流式推送的用例(UC1)进度步骤与意图报告 | server agent 经 `TaskExecutionService` 接入点**替换**([`src/server/task_execution.py`](src/server/task_execution.py));它的服务装配后,demo 服务与数据即可删除 | -| 脚本化应答 —— [`src/client/scenario_data.py`](src/client/scenario_data.py) | 两端(见下) | 同时喂两侧 | 两侧都要**替换**:client 侧应答经自己的 `NegotiationResponseProvider`;server 侧把 `server_flow._negotiation_propose_text` 中的预制 propose 文案改为自己的业务措辞 | +| 样例输入与脚本化应答 —— [`src/client/scenario_data.py`](src/client/scenario_data.py) | 两端(见下) | Task-T 自然语言输入、协商应答与预制 propose 文案 | 两侧都要**替换**:client 提供自己的任务意图,并经自己的 `NegotiationResponseProvider` 应答;server 把 `server_flow._negotiation_propose_text` 中的预制 propose 文案改为自己的业务措辞 | | mock 注册中心 —— [`src/registry/`](src/registry)(端口 5001) | 两端 | AgentCard 注册(server)与查询发现(client) | **替换**为部署环境的注册中心,或绕过发现、直接从已知 AgentCard 构造 `a2a-sdk` 客户端 | | 静态 webhook token —— `A2AT_SAMPLE_WEBHOOK_TOKEN`(默认 `demo-webhook-token`) | 两端 | `X-A2A-Notification-Token` 头中的共享静态 token | **替换**为生产鉴权(逐客户端密钥、轮换、TLS);把接收器当作暴露在外的 HTTP 端点对待 | -> **`scenario_data` 同时喂两侧** —— 客户端的脚本化应答(补全参数、确认意图、可行性摘要)经 -> provider 接入点使用;服务端的 propose 文本也由同一模块构建:`_negotiation_propose_text` +> **`scenario_data` 同时喂两侧** —— 客户端的 Task-T 输入与脚本化协商应答(补全参数、确认意图、 +> 可行性摘要)经 provider 接入点使用;服务端的 propose 文本也由同一模块构建:`_negotiation_propose_text` > ([`server_flow.py`](src/server/server_flow.py)) 用其访问器填充 target/feasibility 的 propose > 文案。只替换 provider 与执行服务,服务端仍会用 demo 的预制文案发起 propose —— 因此两侧都完成 > 替换前,`scenario_data.py` 不能删除。 diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index c5760ef7..37008435 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -17,10 +17,6 @@ unspecified reader. - [3.1 The four steps](#31-the-four-steps) - [3.2 Client side](#32-client-side) - [3.3 Server side](#33-server-side) - - [3.4 Notes for custom client agent development](#34-notes-for-custom-client-agent-development) - - [3.4.1 Client input](#341-client-input) - - [3.4.2 Template and parameter schema](#342-template-and-parameter-schema) - - [3.4.3 Server-side execution](#343-server-side-execution) - [4. Using the A2A-T SDK to implement the Negotiation-T interaction](#4-using-the-a2a-t-sdk-to-implement-the-negotiation-t-interaction) - [4.1 Negotiation scenarios](#41-negotiation-scenarios) - [4.2 How the demo selects the negotiation kind](#42-how-the-demo-selects-the-negotiation-kind) @@ -142,32 +138,8 @@ async for stream_response in a2a_client.send_message(request, context=call_conte `TASK_STATE_REJECTED` instead of crashing the server; the validated parameters are passed to the execution integration point (`TaskExecutionService`). -### 3.4 Notes for custom client agent development - -The sample ships demo values. A client implementation replaces the client-side values with its own -business content, and uses the agreed versions of the values shared with the server. - -#### 3.4.1 Client input - -The natural-language task request is the client's own input: `scenario_data.py` holds the demo -wording, which a client implementation replaces with the intent it needs to send. The task prompt -is rendered from this input by `generate_task_prompt_from_text` (§3.1). - -#### 3.4.2 Template and parameter schema - -The template and the parameter schema are the contract between the two agents: the client renders -its task request from them, and the server validates against them. This sample's template -(`ENERGY_SAVING_TEMPLATE_URI` = `Task-T/network-layer/ran-energy-saving/v1`, in -[`scenario_data.py`](src/client/scenario_data.py)) and schema (in -[`src/common/task_schema.py`](src/common/task_schema.py)) are sample-specific. In a real deployment -the two parties agree on the template and the schema, and each side uses the agreed versions. - -#### 3.4.3 Server-side execution - -The business execution behind the server's `TaskExecutionService` integration point belongs to the -server agent. The client sends the request and consumes the progress updates and the report; it -implements no part of the execution. See the -[deployment checklist](#5-deployment-checklist). +The template, the parameter schema and the natural-language input are sample-specific; the +[deployment checklist](#5-deployment-checklist) lists what each side replaces. ## 4. Using the A2A-T SDK to implement the Negotiation-T interaction @@ -436,15 +408,16 @@ counterpart replaces in a real system. | Demo piece | Side | What it does in the demo | Keep or replace | | --- | --- | --- | --- | +| task template and parameter schema — `ENERGY_SAVING_TEMPLATE_URI` ([`scenario_data.py`](src/client/scenario_data.py)), [`src/common/task_schema.py`](src/common/task_schema.py) | both | the contract the client renders its request from and the server validates against | **REPLACE** with the template and schema agreed by both parties | | mock LLM — [`resources/mock_llm/`](resources/mock_llm) + the schema-routed mock in `src/common/` | both | scripted slot-extraction and content-validation replies, selected when `A2AT_LLM_API_KEY` is empty | **KEEP** for CI / offline runs. In a real deployment, each side replaces it with a real OpenAI-compatible endpoint | | scripted execution data — [`src/server/constants_data.py`](src/server/constants_data.py) | server | the use-case (UC1) progress steps and intent report the demo execution streams | **REPLACE** by the server agent via the `TaskExecutionService` integration point ([`src/server/task_execution.py`](src/server/task_execution.py)); once its service is assembled, the demo service and data can be deleted | -| scripted answers — [`src/client/scenario_data.py`](src/client/scenario_data.py) | both (see below) | feeds BOTH sides | **REPLACE** on both sides: the client answers through its own `NegotiationResponseProvider`; the server replaces the canned propose texts in `server_flow._negotiation_propose_text` with its own business wording | +| sample input and scripted answers — [`src/client/scenario_data.py`](src/client/scenario_data.py) | both (see below) | the Task-T natural-language input, the negotiation answers, and the canned propose texts | **REPLACE** on both sides: the client supplies its own task intent and answers through its own `NegotiationResponseProvider`; the server replaces the canned propose texts in `server_flow._negotiation_propose_text` with its own business wording | | mock registry — [`src/registry/`](src/registry) (port 5001) | both | AgentCard registration (server) and query for discovery (client) | **REPLACE** with the deployment's registry center, or bypass discovery and construct the `a2a-sdk` client from a known AgentCard | | static webhook token — `A2AT_SAMPLE_WEBHOOK_TOKEN` (default `demo-webhook-token`) | both | shared static token in the `X-A2A-Notification-Token` header | **REPLACE** with production auth (per-client secrets, rotation, TLS); treat the receiver as an exposed HTTP endpoint | -> **`scenario_data` feeds BOTH sides** — the client's scripted answers (filled parameters, -> confirmed intent, feasibility summary) go through the provider integration point, and the server's -> propose text is built from the same module: `_negotiation_propose_text` +> **`scenario_data` feeds BOTH sides** — the client's Task-T input and scripted negotiation answers +> (filled parameters, confirmed intent, feasibility summary) go through the provider integration +> point, and the server's propose text is built from the same module: `_negotiation_propose_text` > ([`server_flow.py`](src/server/server_flow.py)) fills the target/feasibility propose texts from > its accessors. Replacing only the provider and the execution service still leaves the server > proposing the demo's canned texts, so `scenario_data.py` cannot be deleted until both sides have From a848959263835eb345ef5a93e762fdf12ca80a6e Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 11:28:43 +0800 Subject: [PATCH 27/30] docs(sample): rename the deployment checklist to custom agent development notes Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 8 ++++---- a2a-t-sample/ran-energy-saving/README.md | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 1f9df9b1..1baeb640 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -23,7 +23,7 @@ - [4.5 目标协商(target)](#45-目标协商target) - [4.6 可行性协商(feasibility)](#46-可行性协商feasibility) - [4.7 reject 与 abort](#47-reject-与-abort) -- [5. 部署清单](#5-部署清单) +- [5. 自定义 Agent 开发说明](#5-自定义-agent-开发说明) - [6. 附录](#6-附录) - [6.1 Webhook 传输](#61-webhook-传输) - [6.2 配置(a2a-t-sample/.env)](#62-配置a2a-t-sampleenv) @@ -58,7 +58,7 @@ | [`src/client/client_main.py`](src/client/client_main.py) | 装配根:读取 `.env`、构建运行时(`build_client_runtime`)、从注册中心发现服务端 AgentCard、启动/停止 webhook 接收器、装配 demo 的 `ScriptedNegotiationResponseProvider`。客户端侧 `INTEGRATION POINT` 标注位于 `main()`。 | | [`src/client/client_flow.py`](src/client/client_flow.py) | 编排:消息序列(生成 → 发送 → 消费)、报文机制(metadata 键、`A2A-Extensions` 头)、事件管线,以及两种传输驱动(SSE 流式 / webhook 队列)。它调用协商决策接入点,但不持有任何决策。 | | [`src/client/negotiation_response.py`](src/client/negotiation_response.py) | 协商决策接入点:`NegotiationProposeView`、`NegotiationDecision`、`NegotiationResponseProvider` Protocol,以及 demo 实现 `ScriptedNegotiationResponseProvider`。 | -| [`src/client/scenario_data.py`](src/client/scenario_data.py) | 样例输入与脚本化应答 —— 它**同时喂两侧**(见[部署清单](#5-部署清单)),在两侧都完成替换前不能删除。 | +| [`src/client/scenario_data.py`](src/client/scenario_data.py) | 样例输入与脚本化应答 —— 它**同时喂两侧**(见 [§5](#5-自定义-agent-开发说明)),在两侧都完成替换前不能删除。 | | [`src/server/server_main.py`](src/server/server_main.py) | a2a-sdk 应用装配,推送通知机制无条件装配。服务端侧 `INTEGRATION POINT` 标注位于 `build_server_runtime`。 | | [`src/server/server_flow.py`](src/server/server_flow.py) | 服务端流程:校验 → 协商或执行 → 流式推送进度与报告。 | | [`src/server/task_execution.py`](src/server/task_execution.py) | `TaskExecutionService` 执行接入点,以及 demo 的用例(UC1)服务。 | @@ -133,7 +133,7 @@ async for stream_response in a2a_client.send_message(request, context=call_conte (`TaskExecutionService`)。 模板、参数 schema 与自然语言输入都是样例特有的;各端需要替换的内容见 -[部署清单](#5-部署清单)。 +[§5](#5-自定义-agent-开发说明)。 ## 4. 使用 A2A-T SDK 实现 Negotiation-T 交互 @@ -362,7 +362,7 @@ accept = client.generate_negotiation_accept_prompt_from_text( 方法(`generate_negotiation_reject_prompt_from_data` / `..._from_text`,以及 `generate_negotiation_abort_prompt_from_data` / `..._from_text`)。 -## 5. 部署清单 +## 5. 自定义 Agent 开发说明 **归属**列标明各组件由哪一端负责。client 团队负责标注为 client 与 both 的行;标注为 server 的行 说明对端为真实系统时需要替换的内容。 diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 37008435..9174e0a3 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -25,7 +25,7 @@ unspecified reader. - [4.5 Target negotiation](#45-target-negotiation) - [4.6 Feasibility negotiation](#46-feasibility-negotiation) - [4.7 Reject and abort](#47-reject-and-abort) -- [5. Deployment checklist](#5-deployment-checklist) +- [5. Notes for Custom Agent Development](#5-notes-for-custom-agent-development) - [6. Appendix](#6-appendix) - [6.1 Webhook transport](#61-webhook-transport) - [6.2 Configuration (a2a-t-sample/.env)](#62-configuration-a2a-t-sampleenv) @@ -61,7 +61,7 @@ Key workflows: | [`src/client/client_main.py`](src/client/client_main.py) | Assembly root: reads `.env`, builds the runtime (`build_client_runtime`), discovers the server's AgentCard from the registry, starts/stops the webhook receiver, and assembles the demo `ScriptedNegotiationResponseProvider`. The client-side `INTEGRATION POINT` comment sits in `main()`. | | [`src/client/client_flow.py`](src/client/client_flow.py) | Orchestration: the message sequence (generate → send → consume), the message handling (metadata keys, `A2A-Extensions` header), the event pipeline, and the two transport drivers (SSE streaming / webhook queue). It calls the negotiation decision integration point but owns no decision. | | [`src/client/negotiation_response.py`](src/client/negotiation_response.py) | The negotiation decision integration point: `NegotiationProposeView`, `NegotiationDecision`, the `NegotiationResponseProvider` Protocol, and the demo `ScriptedNegotiationResponseProvider`. | -| [`src/client/scenario_data.py`](src/client/scenario_data.py) | The sample's inputs and scripted answers — it feeds **both** sides (see the [deployment checklist](#5-deployment-checklist)); not removable until both sides are replaced. | +| [`src/client/scenario_data.py`](src/client/scenario_data.py) | The sample's inputs and scripted answers — it feeds **both** sides (see [§5](#5-notes-for-custom-agent-development)); not removable until both sides are replaced. | | [`src/server/server_main.py`](src/server/server_main.py) | a2a-sdk app assembly, with the push-notification machinery assembled unconditionally. The server-side `INTEGRATION POINT` comment sits in `build_server_runtime`. | | [`src/server/server_flow.py`](src/server/server_flow.py) | The server flow: validate → negotiate-or-execute → stream progress and the report. | | [`src/server/task_execution.py`](src/server/task_execution.py) | The `TaskExecutionService` execution integration point plus the demo use-case (UC1) service. | @@ -138,8 +138,8 @@ async for stream_response in a2a_client.send_message(request, context=call_conte `TASK_STATE_REJECTED` instead of crashing the server; the validated parameters are passed to the execution integration point (`TaskExecutionService`). -The template, the parameter schema and the natural-language input are sample-specific; the -[deployment checklist](#5-deployment-checklist) lists what each side replaces. +The template, the parameter schema and the natural-language input are sample-specific; +[§5](#5-notes-for-custom-agent-development) lists what each side replaces. ## 4. Using the A2A-T SDK to implement the Negotiation-T interaction @@ -400,7 +400,7 @@ message; the SDK provides the matching generation methods (`generate_negotiation_reject_prompt_from_data` / `..._from_text`, and `generate_negotiation_abort_prompt_from_data` / `..._from_text`). -## 5. Deployment checklist +## 5. Notes for Custom Agent Development The **Side** column identifies the agent responsible for each component. The client team is responsible for the rows marked `client` and `both`; the rows marked `server` describe what the From 23e47fcd3ca087137b3d86ba3af0c8e6746e8ffd Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 11:31:59 +0800 Subject: [PATCH 28/30] docs(sample): describe the server rows like the client rows Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 4 ++-- a2a-t-sample/ran-energy-saving/README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 1baeb640..1ae74cac 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -364,8 +364,8 @@ accept = client.generate_negotiation_accept_prompt_from_text( ## 5. 自定义 Agent 开发说明 -**归属**列标明各组件由哪一端负责。client 团队负责标注为 client 与 both 的行;标注为 server 的行 -说明对端为真实系统时需要替换的内容。 +**归属**列标明各组件由哪一端负责。client 团队负责标注为 client 与 both 的行;server agent 负责 +标注为 server 的行。 | demo 组成 | 归属 | demo 中的作用 | 保留还是替换 | | --- | --- | --- | --- | diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 9174e0a3..596fd8ba 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -403,8 +403,8 @@ message; the SDK provides the matching generation methods ## 5. Notes for Custom Agent Development The **Side** column identifies the agent responsible for each component. The client team is -responsible for the rows marked `client` and `both`; the rows marked `server` describe what the -counterpart replaces in a real system. +responsible for the rows marked `client` and `both`; the server agent is responsible for the rows +marked `server`. | Demo piece | Side | What it does in the demo | Keep or replace | | --- | --- | --- | --- | From 60111acab5de57bc7bcf6e5b596ec900e2eb340c Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 12:06:50 +0800 Subject: [PATCH 29/30] fix(sample): make negotiation pre-task and keep the task long-running The propose is a standalone Message before task creation (no INPUT_REQUIRED); the task is created on the accepted prompt and stays WORKING (no COMPLETED). The parameter schema covers the site-level outage, and the refill mocks render the accepted values. Chinese code comments and docstrings are removed. Signed-off-by: codetalks-hw --- .../en-US/content_validation_feasibility.json | 4 +- ...content_validation_feasibility_refill.json | 12 + .../content_validation_target_refill.json | 10 + .../slot_extraction_feasibility_refill.json | 11 + .../en-US/slot_extraction_target_refill.json | 11 + .../zh-CN/content_validation_feasibility.json | 4 +- ...content_validation_feasibility_refill.json | 12 + .../content_validation_target_refill.json | 10 + .../slot_extraction_feasibility_refill.json | 11 + .../zh-CN/slot_extraction_target_refill.json | 11 + .../src/client/client_flow.py | 346 +++++++++--------- .../src/client/client_main.py | 2 +- .../src/client/negotiation_response.py | 6 +- .../src/client/scenario_data.py | 16 + .../ran-energy-saving/src/common/mock_llm.py | 45 ++- .../src/common/sse_event_consumer.py | 13 +- .../src/common/task_schema.py | 16 +- .../src/common/webhook_receiver.py | 16 +- .../src/server/constants_data.py | 11 +- .../src/server/server_flow.py | 130 +++---- .../test/test_negotiation_client_flow.py | 96 ++--- .../test/test_negotiation_scenario.py | 14 +- .../test/test_negotiation_server_flow.py | 97 ++--- .../test/test_webhook_client_flow.py | 147 ++++---- 24 files changed, 594 insertions(+), 457 deletions(-) create mode 100644 a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility_refill.json create mode 100644 a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_target_refill.json create mode 100644 a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_feasibility_refill.json create mode 100644 a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_target_refill.json create mode 100644 a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility_refill.json create mode 100644 a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_target_refill.json create mode 100644 a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_feasibility_refill.json create mode 100644 a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_target_refill.json diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility.json index 44e21ea1..5c528750 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility.json @@ -13,6 +13,8 @@ "operationType": "create", "region": "Songshanhu Administration Committee", "energySavingGoal": "Maximize energy saving under the condition that the task context is met.", - "guaranteedThroughputTarget": null + "guaranteedThroughputTarget": null, + "siteList": "Station 1,Station 2", + "backgroundEvent": "power outage from 00:00 to 18:00" } } diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility_refill.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility_refill.json new file mode 100644 index 00000000..cc98cdf9 --- /dev/null +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_feasibility_refill.json @@ -0,0 +1,12 @@ +{ + "semantic_verdict": true, + "errors": [], + "params": { + "operationType": "create", + "region": "Songshanhu Administration Committee", + "energySavingGoal": "Maximize energy saving under the condition that the task context is met.", + "guaranteedThroughputTarget": "{00:00~06:00,2Mbps}, {06:00~18:00,2Mbps}", + "siteList": "Station 1,Station 2", + "backgroundEvent": "power outage from 00:00 to 18:00" + } +} diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_target_refill.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_target_refill.json new file mode 100644 index 00000000..1a9ca797 --- /dev/null +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/content_validation_target_refill.json @@ -0,0 +1,10 @@ +{ + "semantic_verdict": true, + "errors": [], + "params": { + "operationType": "create", + "region": "Songshanhu Administration Committee", + "energySavingGoal": "Maximize energy saving under the condition that the task context is met.", + "guaranteedThroughputTarget": "{00:00~07:00,2Mbps}, {07:00~09:00,10Mbps}, {09:00~17:30,10Mbps}, {17:30~23:00,20Mbps}, {23:00~00:00,10Mbps}" + } +} diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_feasibility_refill.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_feasibility_refill.json new file mode 100644 index 00000000..adbbcdbd --- /dev/null +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_feasibility_refill.json @@ -0,0 +1,11 @@ +{ + "slots": { + "operation_type": "Create", + "task_description": "Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in .", + "task_object": "The area is the Songshanhu Administration Committee.", + "task_target": "Maximize energy saving under the condition that the task context is met.", + "task_context": "Station list 1: [\"Station 1\", \"Station 2\"], time-segment-based guaranteed throughput target: {00:00~06:00,2Mbps}, {06:00~18:00,2Mbps}, background event: power outage from 00:00 to 18:00.", + "expected_output": "Output the intent report for the network energy saving task." + }, + "slot_errors": [] +} diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_target_refill.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_target_refill.json new file mode 100644 index 00000000..dae97e73 --- /dev/null +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/en-US/slot_extraction_target_refill.json @@ -0,0 +1,11 @@ +{ + "slots": { + "operation_type": "Create", + "task_description": "Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in .", + "task_object": "The area is the Songshanhu Administration Committee.", + "task_target": "Maximize energy saving under the condition that the task context is met.", + "task_context": "Time-segment-based guaranteed throughput target: {00:00~07:00,2Mbps}, {07:00~09:00,10Mbps}, {09:00~17:30,10Mbps}, {17:30~23:00,20Mbps}, {23:00~00:00,10Mbps}.", + "expected_output": "Output the intent report for the network energy saving task." + }, + "slot_errors": [] +} diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility.json index d6137d5a..74059129 100644 --- a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility.json +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility.json @@ -13,6 +13,8 @@ "operationType": "create", "region": "松山湖管委会", "energySavingGoal": "在满足任务上下文的前提下最大化节能。", - "guaranteedThroughputTarget": null + "guaranteedThroughputTarget": null, + "siteList": "站点名称1,站点名称2", + "backgroundEvent": "00:00~18:00 停电" } } diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility_refill.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility_refill.json new file mode 100644 index 00000000..eb16df10 --- /dev/null +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_feasibility_refill.json @@ -0,0 +1,12 @@ +{ + "semantic_verdict": true, + "errors": [], + "params": { + "operationType": "create", + "region": "松山湖管委会", + "energySavingGoal": "在满足任务上下文的前提下最大化节能。", + "guaranteedThroughputTarget": "{00:00~06:00,2Mbps}、{06:00~18:00,2Mbps}", + "siteList": "站点名称1,站点名称2", + "backgroundEvent": "00:00~18:00 停电" + } +} diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_target_refill.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_target_refill.json new file mode 100644 index 00000000..831767e7 --- /dev/null +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/content_validation_target_refill.json @@ -0,0 +1,10 @@ +{ + "semantic_verdict": true, + "errors": [], + "params": { + "operationType": "create", + "region": "松山湖管委会", + "energySavingGoal": "在满足任务上下文的前提下最大化节能。", + "guaranteedThroughputTarget": "{00:00~07:00,2Mbps}、{07:00~09:00,10Mbps}、{09:00~17:30,10Mbps}、{17:30~23:00,20Mbps}、{23:00~00:00,10Mbps}" + } +} diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_feasibility_refill.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_feasibility_refill.json new file mode 100644 index 00000000..f715d9b0 --- /dev/null +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_feasibility_refill.json @@ -0,0 +1,11 @@ +{ + "slots": { + "操作类型": "创建", + "任务描述": "基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。", + "任务对象": "区域为松山湖管委会。", + "任务目标": "在满足任务上下文的前提下最大化节能。", + "任务上下文": "站点列表1:[\"站点名称1\",\"站点名称2\"],分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,2Mbps},背景事件:在00:00~18:00停电。", + "预期输出": "输出网络节能任务的意图报告。" + }, + "slot_errors": [] +} diff --git a/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_target_refill.json b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_target_refill.json new file mode 100644 index 00000000..31433d7b --- /dev/null +++ b/a2a-t-sample/ran-energy-saving/resources/mock_llm/zh-CN/slot_extraction_target_refill.json @@ -0,0 +1,11 @@ +{ + "slots": { + "操作类型": "创建", + "任务描述": "基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。", + "任务对象": "区域为松山湖管委会。", + "任务目标": "在满足任务上下文的前提下最大化节能。", + "任务上下文": "分时段保障速率目标:{00:00~07:00,2Mbps}、{07:00~09:00,10Mbps}、{09:00~17:30,10Mbps}、{17:30~23:00,20Mbps}、{23:00~00:00,10Mbps}。", + "预期输出": "输出网络节能任务的意图报告。" + }, + "slot_errors": [] +} diff --git a/a2a-t-sample/ran-energy-saving/src/client/client_flow.py b/a2a-t-sample/ran-energy-saving/src/client/client_flow.py index 072523d3..eb552bf8 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/client_flow.py +++ b/a2a-t-sample/ran-energy-saving/src/client/client_flow.py @@ -87,7 +87,7 @@ def _parse_negotiation_context(metadata: dict[str, object]) -> NegotiationContex #: Negotiation kinds the client-side accept generation dispatches on. The kind resolves from the -#: explicit flag when supplied, else from the propose's ``templateUri`` inside the INPUT_REQUIRED +#: explicit flag when supplied, else from the propose's ``templateUri`` inside the propose message #: metadata (dispatch on the received message); each kind addresses its own accept-reject template. _NEGOTIATION_KIND_BY_PROPOSE_URI: dict[str, str] = { INFORMATION_NEGOTIATION_PROPOSE_URI: NEGOTIATION_KIND_INFORMATION, @@ -105,8 +105,8 @@ def _parse_negotiation_context(metadata: dict[str, object]) -> NegotiationContex def _resolve_negotiation_kind(metadata: dict[str, object], negotiation_kind: str | None) -> str | None: """Resolve the accept's negotiation kind: the explicit flag wins, else the propose's templateUri. - A ``None`` flag dispatches on the received message: the ``templateUri`` of the INPUT_REQUIRED metadata maps - to the negotiation kind, so the client accepts whatever type the server proposed. An unknown + A ``None`` flag dispatches on the received message: the ``templateUri`` of the propose message + metadata maps to the negotiation kind, so the client accepts whatever type the server proposed. An unknown flag, or a missing/unknown propose template URI when dispatching on the received message, returns ``None`` (the caller keeps the unhandled path). """ @@ -118,16 +118,17 @@ def _resolve_negotiation_kind(metadata: dict[str, object], negotiation_kind: str return _NEGOTIATION_KIND_BY_PROPOSE_URI.get(propose_uri) -def _find_negotiation_input_required(events: list[dict[str, object]]) -> dict[str, object] | None: - """Find the INPUT_REQUIRED status event carrying a Negotiation-T propose. +def _find_negotiation_propose(events: list[dict[str, object]]) -> dict[str, object] | None: + """Find the standalone Negotiation-T propose message. - The propose travels in the status message metadata under the Negotiation-T extension URI - together with the nested ``negotiationContext`` session map (any negotiation type; the - ``templateUri`` inside selects the client's accept type); ``None`` keeps the plain - single-round flow. + Negotiation runs before the task is created, so the propose arrives as an A2A ``Message`` + (role ``ROLE_AGENT``), not a Task status event. The propose content travels in the message + metadata under the Negotiation-T extension URI together with the nested + ``negotiationContext`` session map (any negotiation type; the ``templateUri`` inside selects + the client's accept type); ``None`` keeps the plain single-round flow. """ for event in events: - if event.get("kind") != "status" or event.get("state") != TaskState.Name(TaskState.TASK_STATE_INPUT_REQUIRED): + if event.get("kind") != "message": continue metadata = event.get("metadata") if not isinstance(metadata, dict) or NEGOTIATION_T_EXTENSION_URI not in metadata: @@ -136,15 +137,6 @@ def _find_negotiation_input_required(events: list[dict[str, object]]) -> dict[st return None -def _first_task_id(events: list[dict[str, object]]) -> str: - """Return the first non-blank task id carried by the initial-phase events.""" - for event in events: - task_id = event.get("task_id") - if isinstance(task_id, str) and task_id.strip(): - return task_id - return "" - - async def _send_and_consume( *, a2a_client: object, @@ -187,17 +179,17 @@ async def _send_and_consume( return artifact_count -# --- webhook transport (v3):事件管线复用 ------------------------------------------------------ +# --- webhook transport (v3): shared event pipeline --------------------------------------------- -#: Webhook 事件循环的结束信号(plan R5/D7 + final fix)。初始发送阶段(initial phase:初次 Task-T -#: 请求)以 artifact(默认场景结束信号)、INPUT_REQUIRED(协商场景的应答点——中断信号,非终态)或任一 -#: SDK 终态结束;协商续传阶段(continuation phase:filled Task-T + accept 之后)以任一 SDK 终态结束。 -#: 终态集 COMPLETED / CANCELED / FAILED / REJECTED 对齐 a2a-sdk -#: ``DefaultRequestHandler.TERMINAL_TASK_STATES``:服务端在协商拒绝(accept 校验失败、初始请求未映射 -#: 错误码)时推送 REJECTED、流异常时推送 FAILED——webhook 客户端必须立即结束流程(summary 记 -#: ``end=``),而不是等空闲超时兜底。空闲超时(idle timeout)是所有场景的兜底结束信号。 +#: Webhook event-loop end signals (plan R5/D7 + final fix). Both the initial send phase (initial +#: Task-T request) and the negotiation continuation phase (filled Task-T + accept) end on the report +#: artifact — the energy-saving task is long-running and stays WORKING after the report — or on any +#: SDK terminal state. The terminal set COMPLETED / CANCELED / FAILED / REJECTED aligns with +#: a2a-sdk ``DefaultRequestHandler.TERMINAL_TASK_STATES``: the server pushes REJECTED when a prompt +#: fails validation (an accept that still fails) and FAILED on a stream error, so the webhook client +#: must end immediately (the summary records ``end=``) instead of waiting for the idle +#: backstop. The idle timeout is the backstop end signal for every scenario. _WEBHOOK_SIGNAL_ARTIFACT = "artifact" -_WEBHOOK_SIGNAL_INPUT_REQUIRED = "input-required" _WEBHOOK_SIGNAL_TIMEOUT = "timeout" _TERMINAL_STATE_SIGNALS: frozenset[str] = frozenset( TaskState.Name(state) @@ -208,8 +200,9 @@ async def _send_and_consume( TaskState.TASK_STATE_REJECTED, ) ) -_INITIAL_PHASE_END_SIGNALS = _TERMINAL_STATE_SIGNALS | {_WEBHOOK_SIGNAL_ARTIFACT, _WEBHOOK_SIGNAL_INPUT_REQUIRED} -_CONTINUATION_PHASE_END_SIGNALS = _TERMINAL_STATE_SIGNALS # 终态即协商续传阶段的全部结束信号 +_PHASE_END_SIGNALS = _TERMINAL_STATE_SIGNALS | {_WEBHOOK_SIGNAL_ARTIFACT} +_INITIAL_PHASE_END_SIGNALS = _PHASE_END_SIGNALS +_CONTINUATION_PHASE_END_SIGNALS = _PHASE_END_SIGNALS def normalize_webhook_payload(payload: dict[str, object]) -> dict[str, object]: @@ -218,7 +211,7 @@ def normalize_webhook_payload(payload: dict[str, object]) -> dict[str, object]: The receiver hands over the raw parsed JSON dict — the ``MessageToDict`` of a ``StreamResponse`` (camelCase: ``task`` / ``statusUpdate`` / ``artifactUpdate`` / ``message`` top-level key) — and this adapts it to the flat event dict (``kind`` / ``task_id`` / ``state`` / ...) that the - negotiation detection (INPUT_REQUIRED + Negotiation-T URI + negotiationContext), the + negotiation detection (propose message + Negotiation-T URI + negotiationContext), the templateUri→kind dispatch and the summary counting already consume, so the SSE and webhook drivers share the whole event pipeline unchanged. @@ -257,7 +250,16 @@ def normalize_webhook_payload(payload: dict[str, object]) -> dict[str, object]: parts = message.get("parts") if isinstance(parts, list) and parts and isinstance(parts[0], dict): text = str(parts[0].get("text", "")) - return {"kind": "message", "text": text} + event: dict[str, object] = { + "kind": "message", + "text": text, + "context_id": str(message.get("contextId", "")), + "message_id": str(message.get("messageId", "")), + } + metadata = message.get("metadata") + if isinstance(metadata, dict) and metadata: + event["metadata"] = metadata + return event artifact_update = payload.get("artifactUpdate") if isinstance(artifact_update, dict): @@ -298,19 +300,16 @@ def normalize_webhook_payload(payload: dict[str, object]) -> dict[str, object]: def _webhook_end_signal(event: dict[str, object], end_signals: frozenset[str]) -> str | None: """Return the webhook end signal this event matches, ``None`` to keep consuming (plan R5/D7). - Terminal task states (the SDK's COMPLETED / CANCELED / FAILED / REJECTED set) end BOTH phases - and are reported under their TaskState name (the summary notes ``end=``) — a REJECTED - validation failure or a FAILED stream must end the flow instead of hanging to the idle - backstop. INPUT_REQUIRED stays the phase-1-only interrupt (not a terminal state); the artifact - signal stays phase-1-only (the default scenario's end). + The report artifact ends both phases (the task stays WORKING afterwards). Terminal task states + (the SDK's COMPLETED / CANCELED / FAILED / REJECTED set) also end both phases and are reported + under their TaskState name (the summary notes ``end=``) — a REJECTED validation failure + or a FAILED stream must end the flow instead of hanging to the idle backstop. """ if event.get("kind") == "artifact": return _WEBHOOK_SIGNAL_ARTIFACT if _WEBHOOK_SIGNAL_ARTIFACT in end_signals else None if event.get("kind") != "status": return None state = event.get("state") - if state == TaskState.Name(TaskState.TASK_STATE_INPUT_REQUIRED): - return _WEBHOOK_SIGNAL_INPUT_REQUIRED if _WEBHOOK_SIGNAL_INPUT_REQUIRED in end_signals else None if isinstance(state, str) and state in end_signals: return state return None @@ -373,19 +372,22 @@ async def _send_webhook_message( request: SendMessageRequest, call_context: ClientCallContext, log_sink: Callable[[str], None] | None, -) -> None: - """Send one non-blocking ``message:send`` and log the single RPC reply (plan R6). +) -> dict[str, object] | None: + """Send one non-blocking ``message:send`` and return the normalized RPC reply (plan R6). With ``return_immediately=True`` the RPC returns after the first event; the server's background consumer keeps pushing every event to the webhook. The reply overlaps with the webhook pushes (the SUBMITTED Task arrives both ways), so it is logged ONCE here as ``webhook-rpc-reply`` and deliberately NOT appended to ``normalized_events`` — the events - summary counts WEBHOOK-received events only (SSE parity). + summary counts WEBHOOK-received events only (SSE parity). A pre-task negotiation propose is the + exception: it is a Message and is never pushed, so the caller consumes the returned reply. """ rpc_reply: StreamResponse | None = None async for stream_response in a2a_client.send_message(request, context=call_context): rpc_reply = stream_response - if log_sink is not None and rpc_reply is not None: + if rpc_reply is None: + return None + if log_sink is not None: log_sink( format_stage_log( role="client", @@ -393,6 +395,7 @@ async def _send_webhook_message( detail=json.dumps(MessageToDict(rpc_reply), ensure_ascii=False), ) ) + return normalize_event(rpc_reply) async def _run_webhook_flow( @@ -417,16 +420,15 @@ async def _run_webhook_flow( Initial phase: register the push config INLINE on the request (the task id is unknown before the send — the only registration path available here), send ``message:send`` with - ``return_immediately=True`` through the ``streaming=False`` client, then consume webhook - events. An INPUT_REQUIRED propose opens the negotiation: re-register the SAME url/token via - ``create_task_push_notification_config`` (the REST endpoint — the second registration path), - send the accept (non-blocking, NO inline config — already registered), and keep consuming. The - default scenario ends on the report artifact; any terminal state (COMPLETED / FAILED / - CANCELED / REJECTED) ends whichever phase is consuming right there — a REJECTED/FAILED push - must not hang until the idle backstop. The idle timeout is the backstop that ends any phase - with a diagnostic line and a normal exit. Returns the artifact count and the end signal of the - last consumed phase (``artifact`` / ``input-required`` / a ``TASK_STATE_*`` terminal state / - ``timeout``) — the caller derives the timeout flag and the ``end=`` summary note from it. + ``return_immediately=True`` through the ``streaming=False`` client, and inspect the RPC reply. + A pre-task Negotiation-T propose arrives as a Message reply (Messages are never pushed): the + flow answers it with the accept, registers the push config inline on that new task, and keeps + consuming. Otherwise the flow consumes the pushed task events. The scenario ends on the report + artifact; any terminal state (FAILED / CANCELED / REJECTED) ends whichever phase is consuming + right there — a REJECTED/FAILED push must not hang until the idle backstop. The task itself + stays long-running (WORKING). The idle timeout is the backstop that ends any phase with a + diagnostic line and a normal exit. Returns the artifact count and the end signal of the last + consumed phase (``artifact`` / a ``TASK_STATE_*`` terminal state / ``timeout``). """ # Initial-phase request additions (R2/R4): non-blocking + inline push registration. request.configuration.return_immediately = True @@ -445,114 +447,99 @@ async def _run_webhook_flow( ), ) ) - await _send_webhook_message(a2a_client=a2a_client, request=request, call_context=call_context, log_sink=log_sink) - - # Initial-phase consume: default scenario ends on the artifact; a negotiation propose pauses on - # INPUT_REQUIRED; a terminal state (e.g. a REJECTED validation failure on the initial request) - # ends the whole flow right here — the accept is never sent; the idle timeout backstops all of - # them (R5 + final fix). - end_signal, artifact_count = await _consume_webhook_events( - webhook_queue=webhook_queue, - webhook_idle_timeout=webhook_idle_timeout, - normalized_events=normalized_events, - artifact_count=artifact_count, - log_sink=log_sink, - end_signals=_INITIAL_PHASE_END_SIGNALS, + reply = await _send_webhook_message( + a2a_client=a2a_client, request=request, call_context=call_context, log_sink=log_sink ) - if end_signal != _WEBHOOK_SIGNAL_INPUT_REQUIRED: - return artifact_count, end_signal - # Negotiation continuation (reuses the SSE-path detection/provider integration point/builders verbatim; same - # unhandled logs). - input_required_event = _find_negotiation_input_required(normalized_events) - if input_required_event is None: - # Plain INPUT_REQUIRED without a Negotiation-T propose: SSE parity — the flow just ends. - return artifact_count, end_signal - if negotiation_response_provider is None or negotiation_params is None: + # Pre-task negotiation: the propose is the RPC reply (a Message is never pushed). It is appended + # to the normalized events so the summary and the logs see it exactly as on the SSE path. + reply_metadata = reply.get("metadata") if reply is not None else None + if ( + reply is not None + and reply.get("kind") == "message" + and isinstance(reply_metadata, dict) + and NEGOTIATION_T_EXTENSION_URI in reply_metadata + ): + normalized_events.append(reply) + if negotiation_response_provider is None or negotiation_params is None: + if log_sink is not None: + log_sink( + format_stage_log( + role="client", + stage="negotiation-unhandled", + detail=( + "Negotiation-T propose received; no negotiation answers supplied, " + "the propose stays unanswered" + ), + ) + ) + return artifact_count, "" + session = _parse_negotiation_context(reply_metadata) + plan = _plan_negotiation_accept( + negotiation_response_provider=negotiation_response_provider, + metadata=reply_metadata, + session=session, + negotiation_kind=negotiation_kind, + log_sink=log_sink, + ) + if plan is None or session is None: + # plan None covers the unhandled/decision ends; the session check only narrows the type. + return artifact_count, "" + + accept_request, accept_call_context = _build_negotiation_accept_request( + prompt_client=prompt_client, + initial_input=initial_input, + session=session, + plan=plan, + negotiation_params=negotiation_params, + log_sink=log_sink, + ) + # The accept is a new message — no task exists yet — so its push config is registered + # inline against the task the server creates for it. + accept_request.message.context_id = str(reply.get("context_id", "")) + accept_request.configuration.return_immediately = True + accept_request.configuration.task_push_notification_config.CopyFrom( + TaskPushNotificationConfig(url=push_url, token=push_token) + ) if log_sink is not None: + extension_header = ",".join(sorted({TASK_T_EXTENSION_URI, NEGOTIATION_T_EXTENSION_URI})) log_sink( format_stage_log( role="client", - stage="negotiation-unhandled", + stage="a2a-send-message", detail=( - "Negotiation-T INPUT_REQUIRED received; no negotiation answers supplied, " - "the propose stays unanswered" + f"phase=continuation webhook text_part={_build_request_text(initial_input)} " + f"metadata_keys=[{TASK_T_EXTENSION_URI}, {NEGOTIATION_T_EXTENSION_URI}, " + f"{TEMPLATE_URI_METADATA_KEY}, {NEGOTIATION_CONTEXT_METADATA_KEY}] " + f"header[A2A-Extensions]={extension_header} " + f"return_immediately=true push_registration=inline" ), ) ) - return artifact_count, end_signal - metadata = input_required_event.get("metadata") - session = _parse_negotiation_context(metadata) if isinstance(metadata, dict) else None - plan = _plan_negotiation_accept( - negotiation_response_provider=negotiation_response_provider, - metadata=metadata if isinstance(metadata, dict) else None, - session=session, - negotiation_kind=negotiation_kind, - log_sink=log_sink, - ) - if plan is None or session is None: - # plan None covers the unhandled/decision ends; the session check only narrows the type - # (a resolved plan always parsed its session). - return artifact_count, end_signal - - task_id = _first_task_id(normalized_events) - context_id = str(input_required_event.get("context_id", "")) - accept_request, accept_call_context = _build_negotiation_accept_request( - prompt_client=prompt_client, - initial_input=initial_input, - session=session, - plan=plan, - negotiation_params=negotiation_params, - log_sink=log_sink, - ) - accept_request.message.task_id = task_id - accept_request.message.context_id = context_id - - # R4: re-register the SAME url/token via the REST endpoint before re-sending (the task now - # exists, so the endpoint path is available; the initial send could only register inline). - registration = TaskPushNotificationConfig(task_id=task_id, url=push_url, token=push_token) - if log_sink is not None: - log_sink( - format_stage_log( - role="client", - stage="webhook-push-reregister", - detail=( - f"task_id={task_id} url={push_url} method=create_task_push_notification_config " - f"token={'***' if push_token else ''}" - ), - ) + await _send_webhook_message( + a2a_client=a2a_client, request=accept_request, call_context=accept_call_context, log_sink=log_sink ) - await a2a_client.create_task_push_notification_config(registration) - accept_request.configuration.return_immediately = True # NO inline config — registered above - if log_sink is not None: - log_sink( - format_stage_log( - role="client", - stage="a2a-send-message", - detail=( - f"phase=continuation webhook text_part={_build_request_text(initial_input)} " - f"metadata_keys=[{TASK_T_EXTENSION_URI}, {NEGOTIATION_T_EXTENSION_URI}, " - f"{TEMPLATE_URI_METADATA_KEY}, {NEGOTIATION_CONTEXT_METADATA_KEY}] " - f"header[A2A-Extensions]={','.join(sorted({TASK_T_EXTENSION_URI, NEGOTIATION_T_EXTENSION_URI}))} " - f"return_immediately=true push_registration=endpoint" - ), - ) + # The continuation ends on the report artifact (the task stays WORKING) or a terminal state + # (REJECTED/FAILED); the idle timeout backstops. + end_signal, artifact_count = await _consume_webhook_events( + webhook_queue=webhook_queue, + webhook_idle_timeout=webhook_idle_timeout, + normalized_events=normalized_events, + artifact_count=artifact_count, + log_sink=log_sink, + end_signals=_CONTINUATION_PHASE_END_SIGNALS, ) - await _send_webhook_message( - a2a_client=a2a_client, request=accept_request, call_context=accept_call_context, log_sink=log_sink - ) + return artifact_count, end_signal - # Continuation-phase consume: the negotiation closes on a terminal state — COMPLETED normally, - # REJECTED (accept validation failure) / FAILED (stream exception) instead of hanging (final - # fix); the idle timeout backstops (R5). + # Default flow: consume the pushed events until the report artifact or a terminal state. end_signal, artifact_count = await _consume_webhook_events( webhook_queue=webhook_queue, webhook_idle_timeout=webhook_idle_timeout, normalized_events=normalized_events, artifact_count=artifact_count, log_sink=log_sink, - end_signals=_CONTINUATION_PHASE_END_SIGNALS, + end_signals=_INITIAL_PHASE_END_SIGNALS, ) return artifact_count, end_signal @@ -722,7 +709,7 @@ def _plan_negotiation_accept( role="client", stage="negotiation-unhandled", detail=( - "Negotiation-T INPUT_REQUIRED received; the negotiation request could not be used " + "Negotiation-T propose received; the negotiation request could not be used " "(malformed/missing session context), the propose stays unanswered" ), ) @@ -736,7 +723,7 @@ def _plan_negotiation_accept( role="client", stage="negotiation-unhandled", detail=( - "Negotiation-T INPUT_REQUIRED received; the negotiation request could not be used " + "Negotiation-T propose received; the negotiation request could not be used " "(unrecognized propose templateUri), the propose stays unanswered" ), ) @@ -821,7 +808,6 @@ async def _run_negotiation_accept( a2a_client: object, initial_input: dict[str, object], session: NegotiationContext, - task_id: str, context_id: str, plan: _NegotiationAcceptPlan, negotiation_params: dict[str, object], @@ -830,11 +816,12 @@ async def _run_negotiation_accept( max_artifacts: int | None, log_sink: object | None, ) -> int: - """Answer the server's INPUT_REQUIRED propose with the filled Task-T prompt and the accept. + """Answer the server's pre-task propose with the filled Task-T prompt and the accept. SSE-path accept: builds the accept request (shared builder, from the provider's decision plan) and consumes the continuation stream into ``normalized_events`` (stream cut once - ``max_artifacts`` is reached). + ``max_artifacts`` is reached). The accept is a new message in the propose's context — no task + exists yet, so it carries no ``taskId``; the server creates the task when the accept validates. """ accept_request, accept_call_context = _build_negotiation_accept_request( prompt_client=prompt_client, @@ -844,7 +831,6 @@ async def _run_negotiation_accept( negotiation_params=negotiation_params, log_sink=log_sink, ) - accept_request.message.task_id = task_id accept_request.message.context_id = context_id # The A2A-Extensions header carries both extension URIs as the comma-joined string the @@ -899,20 +885,21 @@ async def run_client_flow( artifact end signal itself closes the default flow there, so the stream-cut cap does not apply. - Negotiation response integration point (plan R3/R4): when the server answers INPUT_REQUIRED with a - Negotiation-T propose, HOW to answer is decided by ``negotiation_response_provider`` - (``NegotiationResponseProvider.respond`` — the client-side INTEGRATION POINT). Supplying a - provider replaces the scripted answer derivation entirely: ``negotiation_answers`` is not - consulted and the accept content comes from the provider's decision (a reject/abort decision - ends the flow with the dedicated ``negotiation-decision`` log, no accept sent). The flow keeps - the message handling: ``negotiation_params`` remains the Task-T prompt refill input of the - accept message and ``negotiation_kind`` keeps hinting the propose view's kind resolution (the - flag wins, else the propose's ``templateUri`` dispatches on the received message). Without a provider, the - legacy scripted arguments keep the pre-integration-point behavior unchanged: when both - ``negotiation_params`` and ``negotiation_answers`` are supplied, the flow internally builds the - demo ``ScriptedNegotiationResponseProvider`` around them (back-compat shim); without them an - INPUT_REQUIRED propose is logged as unhandled and the propose stays unanswered. The default - flow (no INPUT_REQUIRED) is unchanged. + Negotiation response integration point (plan R3/R4): when the server answers the initial request + with a pre-task Negotiation-T propose Message, HOW to answer is decided by + ``negotiation_response_provider`` (``NegotiationResponseProvider.respond`` — the client-side + INTEGRATION POINT). Supplying a provider replaces the scripted answer derivation entirely: + ``negotiation_answers`` is not consulted and the accept content comes from the provider's + decision (a reject/abort decision ends the flow with the dedicated ``negotiation-decision`` + log, no accept sent). The flow keeps the message handling: ``negotiation_params`` remains the + Task-T prompt refill input of the accept message and ``negotiation_kind`` keeps hinting the + propose view's kind resolution (the flag wins, else the propose's ``templateUri`` dispatches on + the received message). Without a provider, the legacy scripted arguments keep the + pre-integration-point behavior unchanged: when both ``negotiation_params`` and + ``negotiation_answers`` are supplied, the flow internally builds the demo + ``ScriptedNegotiationResponseProvider`` around them (back-compat shim); without them a propose + is logged as unhandled and the propose stays unanswered. The plain flow (no propose) is + unchanged. Pipeline and its logged boundaries: 1. raw natural-language input -> ``[client] input-raw`` @@ -921,20 +908,19 @@ async def run_client_flow( 3. A2A SDK ``send_message`` to the server -> ``[client] a2a-send-message`` 4. each received A2A event, as raw JSON -> ``[client] a2a-event`` (SSE) / ``[client] webhook-event`` (webhook) - 5. Negotiation round trip: when the server answers INPUT_REQUIRED with a Negotiation-T - propose and a negotiation response provider is in place, resolve the negotiation kind (the - explicit flag, else the propose's ``templateUri`` — dispatch on the received message), ask the - provider for the decision on the propose view, regenerate the Task-T prompt from the - filled parameters, render the Negotiation-T accept of that type for the parsed session - from the decision (from typed data, or from a natural-language rendering plus one - extraction LLM call), and re-send over a second request + 5. Negotiation round trip: when the server answers with a pre-task Negotiation-T propose and a + negotiation response provider is in place, resolve the negotiation kind (the explicit flag, + else the propose's ``templateUri`` — dispatch on the received message), ask the provider for + the decision on the propose view, regenerate the Task-T prompt from the filled parameters, + render the Negotiation-T accept of that type for the parsed session from the decision + (from a natural-language rendering plus one extraction LLM call), and re-send over a + second request; the server creates the task when that accept validates -> ``[client] negotiation-accept`` / ``[client] a2a-send-message`` (phase=continuation) Without a provider (or with a propose carrying no parseable session context, or resolving to no - negotiation kind) an INPUT_REQUIRED propose is logged as unhandled and the propose stays - unanswered. + negotiation kind) a propose is logged as unhandled and the propose stays unanswered. Message-body convention: * text part -> task name (request metadata) @@ -1043,25 +1029,24 @@ async def run_client_flow( log_sink=log_sink, ) - # (5) negotiation continuation: answer the server's INPUT_REQUIRED propose with the accept. - # SSE-only: the webhook driver answers the propose inline inside its event loop (the accept send - # with the endpoint re-registration happens there). + # (5) negotiation continuation: answer the server's pre-task propose Message with the accept. + # SSE-only: the webhook driver answers the propose from the initial RPC reply inside its flow. if transport != TRANSPORT_WEBHOOK: - input_required_event = _find_negotiation_input_required(normalized_events) - if input_required_event is not None and (negotiation_provider is None or negotiation_params is None): + propose_event = _find_negotiation_propose(normalized_events) + if propose_event is not None and (negotiation_provider is None or negotiation_params is None): if log_sink is not None: log_sink( format_stage_log( role="client", stage="negotiation-unhandled", detail=( - "Negotiation-T INPUT_REQUIRED received; no negotiation answers supplied, " + "Negotiation-T propose received; no negotiation answers supplied, " "the propose stays unanswered" ), ) ) - elif input_required_event is not None and negotiation_provider is not None and negotiation_params is not None: - metadata = input_required_event.get("metadata") + elif propose_event is not None and negotiation_provider is not None and negotiation_params is not None: + metadata = propose_event.get("metadata") session = _parse_negotiation_context(metadata) if isinstance(metadata, dict) else None plan = _plan_negotiation_accept( negotiation_response_provider=negotiation_provider, @@ -1077,10 +1062,9 @@ async def run_client_flow( a2a_client=a2a_client, initial_input=initial_input, session=session, - task_id=_first_task_id(normalized_events), - context_id=str(input_required_event.get("context_id", "")), + context_id=str(propose_event.get("context_id", "")), plan=plan, - negotiation_params=negotiation_params, + negotiation_params=negotiation_params, normalized_events=normalized_events, artifact_count=artifact_count, max_artifacts=max_artifacts, diff --git a/a2a-t-sample/ran-energy-saving/src/client/client_main.py b/a2a-t-sample/ran-energy-saving/src/client/client_main.py index f7346b24..79fd570a 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/client_main.py +++ b/a2a-t-sample/ran-energy-saving/src/client/client_main.py @@ -171,7 +171,7 @@ async def run_main( """Run the full client flow: bootstrap runtime, discover agent, generate prompt, consume events. The negotiation arguments are supplied by the caller for the negotiation kinds: when the server - answers the initial request with an INPUT_REQUIRED propose, the flow answers it with the filled + answers the initial request with a pre-task propose Message, the flow answers it with the filled Task-T prompt and the Negotiation-T accept of the resolved type. The ``negotiation_kind`` flag selects the accept type directly; ``None`` dispatches on the propose's ``templateUri``. ``negotiation_response_provider`` is the client-side INTEGRATION POINT: when supplied, the flow diff --git a/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py b/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py index 7574425d..cf1174ca 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py +++ b/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py @@ -26,7 +26,7 @@ @dataclass(frozen=True, slots=True) class NegotiationProposeView: - """What the flow observed from one INPUT_REQUIRED propose, handed to the provider. + """What the flow observed from one pre-task propose Message, handed to the provider. The A2A-T message carries no structured per-type propose content — only the propose's ``templateUri`` and its rendered text in the status metadata (plus the session context the @@ -72,8 +72,8 @@ class NegotiationDecision: class NegotiationResponseProvider(Protocol): """INTEGRATION POINT (client): decide how to answer a negotiation propose. - The client flow calls this integration point once the server answers the initial task request with an - INPUT_REQUIRED Negotiation-T propose: ``propose`` is the lightweight + The client flow calls this integration point once the server answers the initial task request with a + pre-task Negotiation-T propose Message: ``propose`` is the lightweight :class:`NegotiationProposeView` observed from the message (negotiation kind, template URI, rendered text) and ``session`` the parsed :class:`NegotiationContext` of the server's session. The implementation returns the :class:`NegotiationDecision` — accept (with the per-type answer diff --git a/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py b/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py index 850d6f7f..7ae39629 100644 --- a/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py +++ b/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py @@ -435,6 +435,20 @@ def build_prompt_input( }, } +#: Site-level context of the feasibility refill: the station list and the power outage period the +#: accepted lower rate applies to. The base refill is region-level, so these two optional schema +#: fields are added only for the feasibility kind. +_FEASIBILITY_REFILL_CONTEXT: dict[str, dict[str, str]] = { + "en-US": { + "siteList": "Station 1,Station 2", + "backgroundEvent": "power outage from 00:00 to 18:00", + }, + "zh-CN": { + "siteList": "站点名称1,站点名称2", + "backgroundEvent": "00:00~18:00 停电", + }, +} + def negotiation_filled_params( negotiation_kind: str = NEGOTIATION_KIND_INFORMATION, @@ -452,6 +466,8 @@ def negotiation_filled_params( kind_targets = _NEGOTIATION_FILLED_TARGETS.get(negotiation_kind) if kind_targets is not None: filled["guaranteedThroughputTarget"] = kind_targets.get(language, kind_targets["zh-CN"]) + if negotiation_kind == NEGOTIATION_KIND_FEASIBILITY: + filled.update(_FEASIBILITY_REFILL_CONTEXT.get(language, _FEASIBILITY_REFILL_CONTEXT["zh-CN"])) return filled diff --git a/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py b/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py index 129a7644..95d4287f 100644 --- a/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py +++ b/a2a-t-sample/ran-energy-saving/src/common/mock_llm.py @@ -13,19 +13,23 @@ ``slots`` + ``slot_errors`` Task-T slot extraction — the ``slot_extraction`` complete variant (default scenario input or from-data regeneration), the ``slot_extraction_missing`` variant for - the missing-region scenario input, and the + the missing-region scenario input, the ``slot_extraction_target`` / ``slot_extraction_feasibility`` variants for the target/feasibility negotiation inputs (complete slot sets that preserve the distinguishing - overlapping-segment / outage values in the rendered prompt), told - apart by the scenario natural-language constants of - ``client.scenario_data`` -``semantic_verdict`` property content semantic validation — 4-way by the scenario - natural-language input, or the distinguishing - target/feasibility values of the rendered prompt, carried - in the message head cut at the template-content labels - (``content_validation_target`` / + overlapping-segment / outage values in the rendered prompt), + and the ``*_refill`` variants for the from-data refill carrying + the accepted target / site-level context; told apart by the + scenario natural-language constants of + ``client.scenario_data`` and the refill values +``semantic_verdict`` property content semantic validation — by the scenario + natural-language input, the distinguishing + target/feasibility values of the rendered prompt (carried in + the message head cut at the template-content labels), or the + refill values (``content_validation_target`` / ``content_validation_feasibility`` / + ``content_validation_target_refill`` / + ``content_validation_feasibility_refill`` / ``content_validation`` / ``content_validation_missing``), falling back to the v1 region display-string heuristic for heads without either @@ -79,10 +83,14 @@ STAGE_SLOT_EXTRACTION_MISSING = "slot_extraction_missing" STAGE_SLOT_EXTRACTION_TARGET = "slot_extraction_target" STAGE_SLOT_EXTRACTION_FEASIBILITY = "slot_extraction_feasibility" +STAGE_SLOT_EXTRACTION_TARGET_REFILL = "slot_extraction_target_refill" +STAGE_SLOT_EXTRACTION_FEASIBILITY_REFILL = "slot_extraction_feasibility_refill" STAGE_CONTENT_VALIDATION = "content_validation" STAGE_CONTENT_VALIDATION_MISSING = "content_validation_missing" STAGE_CONTENT_VALIDATION_TARGET = "content_validation_target" STAGE_CONTENT_VALIDATION_FEASIBILITY = "content_validation_feasibility" +STAGE_CONTENT_VALIDATION_TARGET_REFILL = "content_validation_target_refill" +STAGE_CONTENT_VALIDATION_FEASIBILITY_REFILL = "content_validation_feasibility_refill" STAGE_NEGOTIATION_PROPOSE_EXTRACTION = "negotiation_propose_extraction" STAGE_NEGOTIATION_ACCEPT_EXTRACTION = "negotiation_accept_extraction" STAGE_NEGOTIATION_TARGET_PROPOSE_EXTRACTION = "negotiation_target_propose_extraction" @@ -96,10 +104,14 @@ STAGE_SLOT_EXTRACTION_MISSING: "slot_extraction_missing.json", STAGE_SLOT_EXTRACTION_TARGET: "slot_extraction_target.json", STAGE_SLOT_EXTRACTION_FEASIBILITY: "slot_extraction_feasibility.json", + STAGE_SLOT_EXTRACTION_TARGET_REFILL: "slot_extraction_target_refill.json", + STAGE_SLOT_EXTRACTION_FEASIBILITY_REFILL: "slot_extraction_feasibility_refill.json", STAGE_CONTENT_VALIDATION: "content_validation.json", STAGE_CONTENT_VALIDATION_MISSING: "content_validation_missing.json", STAGE_CONTENT_VALIDATION_TARGET: "content_validation_target.json", STAGE_CONTENT_VALIDATION_FEASIBILITY: "content_validation_feasibility.json", + STAGE_CONTENT_VALIDATION_TARGET_REFILL: "content_validation_target_refill.json", + STAGE_CONTENT_VALIDATION_FEASIBILITY_REFILL: "content_validation_feasibility_refill.json", STAGE_NEGOTIATION_PROPOSE_EXTRACTION: "negotiation_propose_extraction.json", STAGE_NEGOTIATION_ACCEPT_EXTRACTION: "negotiation_accept_extraction.json", STAGE_NEGOTIATION_TARGET_PROPOSE_EXTRACTION: "negotiation_target_propose_extraction.json", @@ -135,6 +147,13 @@ _TARGET_VALIDATION_MARKER = "20:00~00:00,10Mbps" _FEASIBILITY_VALIDATION_MARKERS = ("06:00~18:00,10Mbps",) +#: From-data Task-T refill markers. The refill data is a parameter map, so the accepted values are +#: the only distinguishing text: the corrected non-overlapping target for the target kind, and the +#: site-level outage for the feasibility kind. Checked after the natural-language scenario inputs +#: (the outage value also appears in the initial feasibility input) and before the default variant. +_TARGET_REFILL_MARKERS = ("23:00~00:00,10Mbps",) +_FEASIBILITY_REFILL_MARKERS = ("power outage from 00:00 to 18:00", "00:00~18:00 停电") + #: Negotiation-T extraction signatures routed most-specific property set first, pinned to the #: property sets of ``a2a_t.negotiation.generation.json_schema_builder.build_extraction_schema``. #: A bare ``{conclusion}`` signature is impossible and never routed. @@ -260,6 +279,10 @@ def route_structured_call( return STAGE_SLOT_EXTRACTION_TARGET if any(scenario_input in user_content for scenario_input in _FEASIBILITY_SCENARIO_INPUTS): return STAGE_SLOT_EXTRACTION_FEASIBILITY + if any(marker in user_content for marker in _FEASIBILITY_REFILL_MARKERS): + return STAGE_SLOT_EXTRACTION_FEASIBILITY_REFILL + if any(marker in user_content for marker in _TARGET_REFILL_MARKERS): + return STAGE_SLOT_EXTRACTION_TARGET_REFILL return STAGE_SLOT_EXTRACTION if "semantic_verdict" in property_names: head = _head_before_markers(user_content, _TEMPLATE_CONTENT_LABELS) @@ -267,6 +290,10 @@ def route_structured_call( return STAGE_CONTENT_VALIDATION_TARGET if _is_feasibility_validation_head(head): return STAGE_CONTENT_VALIDATION_FEASIBILITY + if any(marker in head for marker in _FEASIBILITY_REFILL_MARKERS): + return STAGE_CONTENT_VALIDATION_FEASIBILITY_REFILL + if any(marker in head for marker in _TARGET_REFILL_MARKERS): + return STAGE_CONTENT_VALIDATION_TARGET_REFILL if any(scenario_input in head for scenario_input in _COMPLETE_SCENARIO_INPUTS): return STAGE_CONTENT_VALIDATION if any(scenario_input in head for scenario_input in _MISSING_SCENARIO_INPUTS): diff --git a/a2a-t-sample/ran-energy-saving/src/common/sse_event_consumer.py b/a2a-t-sample/ran-energy-saving/src/common/sse_event_consumer.py index ef51368f..23106ef1 100644 --- a/a2a-t-sample/ran-energy-saving/src/common/sse_event_consumer.py +++ b/a2a-t-sample/ran-energy-saving/src/common/sse_event_consumer.py @@ -35,13 +35,20 @@ def normalize_event(stream_response: StreamResponse) -> dict[str, object]: return event if stream_response.HasField("message"): + message = stream_response.message text = "" - if stream_response.message.parts: - text = stream_response.message.parts[0].text - return { + if message.parts: + text = message.parts[0].text + event: dict[str, object] = { "kind": "message", "text": text, + "context_id": message.context_id, + "message_id": message.message_id, } + metadata = MessageToDict(message.metadata) + if metadata: + event["metadata"] = metadata + return event if stream_response.HasField("artifact_update"): artifact = stream_response.artifact_update.artifact diff --git a/a2a-t-sample/ran-energy-saving/src/common/task_schema.py b/a2a-t-sample/ran-energy-saving/src/common/task_schema.py index 2054d700..82eda6b4 100644 --- a/a2a-t-sample/ran-energy-saving/src/common/task_schema.py +++ b/a2a-t-sample/ran-energy-saving/src/common/task_schema.py @@ -1,9 +1,11 @@ """Caller-provided parameter schema for the ran-energy-saving Task-T content validation. The Task-T content validation pipeline extracts these parameters from the submitted prompt, aligned -with the UC1 RAN energy-saving intent (region-level task with a time-segment-based guaranteed -throughput target). Both sides of the sample share the schema: the server validates the submitted -prompt against it, and the client's negotiation accept regenerates the filled Task-T prompt from it. +with the UC1 RAN energy-saving intent: a region-level or site-level task with a time-segment-based +guaranteed throughput target; the site-level context also carries the site list and an optional +background event (the feasibility scenario uses a power outage). Both sides of the sample share the +schema: the server validates the submitted prompt against it, and the client's negotiation accept +regenerates the filled Task-T prompt from it. """ from __future__ import annotations @@ -34,6 +36,14 @@ "{00:00~07:00,2Mbps}, {07:00~17:30,10Mbps}." ), }, + "siteList": { + "type": "string", + "description": "Site list of a site-level energy-saving task (comma-separated).", + }, + "backgroundEvent": { + "type": "string", + "description": "Optional background event and its period of a site-level task.", + }, }, "required": ["operationType", "region"], } diff --git a/a2a-t-sample/ran-energy-saving/src/common/webhook_receiver.py b/a2a-t-sample/ran-energy-saving/src/common/webhook_receiver.py index 67f76190..41345797 100644 --- a/a2a-t-sample/ran-energy-saving/src/common/webhook_receiver.py +++ b/a2a-t-sample/ran-energy-saving/src/common/webhook_receiver.py @@ -59,13 +59,15 @@ class _HostSignalServer(uvicorn.Server): - """不接管进程信号的 uvicorn Server(旧版 ``Config.install_signal_handlers=False`` 的等价物)。 - - 本机 uvicorn 0.52 已移除 ``install_signal_handlers`` 配置项,``Server.serve()`` 在主线程 - 无条件 ``capture_signals()``——不处理的话,第一次 Ctrl+C 会被 uvicorn 换成"优雅关闭接收器" - (流程继续跑),与 SSE 模式的 KeyboardInterrupt 语义不一致。重写为空操作后,Ctrl+C 照常 - 在宿主 client_main 里抛 KeyboardInterrupt,由 ``run_main`` 的 ``finally`` 调 ``stop()`` 收尾 - 接收器(webhook 模式与 SSE 模式的 Ctrl+C 行为完全一致)。 + """uvicorn Server that leaves process-signal handling to the host. + + Equivalent to the retired ``Config.install_signal_handlers=False``: the bundled uvicorn 0.52 + removed that option, and ``Server.serve()`` on the main thread calls ``capture_signals()`` + unconditionally. Left as is, the first Ctrl+C would make uvicorn gracefully shut the receiver + down while the flow keeps running, which differs from the KeyboardInterrupt semantics of SSE + mode. Overriding it as a no-op lets Ctrl+C raise KeyboardInterrupt in the host ``client_main``, + whose ``run_main`` ``finally`` stops the receiver — so webhook and SSE mode behave identically + on Ctrl+C. """ @contextlib.contextmanager diff --git a/a2a-t-sample/ran-energy-saving/src/server/constants_data.py b/a2a-t-sample/ran-energy-saving/src/server/constants_data.py index 39b0e872..24e6ff68 100644 --- a/a2a-t-sample/ran-energy-saving/src/server/constants_data.py +++ b/a2a-t-sample/ran-energy-saving/src/server/constants_data.py @@ -14,11 +14,8 @@ SUBMITTED_MESSAGE = "Energy saving task accepted, starting RAN energy saving task" ARTIFACT_SEND_INTERVAL_SECONDS = 1.0 -#: Text part of the INPUT_REQUIRED status message carrying the Negotiation-T propose metadata. -INPUT_REQUIRED_MESSAGE = "Additional information is required to start the energy saving task" - -#: Text part of the COMPLETED status message closing the negotiation continuation flow. -COMPLETED_MESSAGE = "Energy saving task completed" +#: Text part of the standalone Negotiation-T propose message sent before the task is created. +PROPOSE_MESSAGE = "Additional information is required before the energy saving task can start" #: Hint rendered into each missing-item entry of a Negotiation-T information propose; the #: ``{slot}`` / ``{description}`` placeholders are filled from the validation error's slot name @@ -45,8 +42,8 @@ #: Negotiation-T per-type propose templates addressed by the server's propose #: (re-exported from ``a2a_t.core.standard_templates``). The proposal type is selected at runtime -#: by the first content-validation error's code (plan R1); the templateUri inside the INPUT_REQUIRED -#: metadata carries the per-type URI, which is what the client dispatches its accept on. +#: by the first content-validation error's code (plan R1); the templateUri inside the propose +#: message metadata carries the per-type URI, which is what the client dispatches its accept on. TARGET_NEGOTIATION_PROPOSE_TEMPLATE_URI = TARGET_NEGOTIATION_PROPOSE_URI FEASIBILITY_NEGOTIATION_PROPOSE_TEMPLATE_URI = FEASIBILITY_NEGOTIATION_PROPOSE_URI diff --git a/a2a-t-sample/ran-energy-saving/src/server/server_flow.py b/a2a-t-sample/ran-energy-saving/src/server/server_flow.py index 6206d5f3..f92d70ad 100644 --- a/a2a-t-sample/ran-energy-saving/src/server/server_flow.py +++ b/a2a-t-sample/ran-energy-saving/src/server/server_flow.py @@ -8,7 +8,7 @@ from a2a.server.agent_execution.context import RequestContext from a2a.server.events.event_queue import EventQueue -from a2a.types import Task, TaskArtifactUpdateEvent, TaskState +from a2a.types import Message, Role, Task, TaskArtifactUpdateEvent, TaskState from a2a_t.core.errors.exceptions import ContentValidationError, SlotValidationError from a2a_t.core.metadata import NegotiationContext, NegotiationPerformative from a2a_t.negotiation.content.models import NegotiationItem @@ -22,17 +22,16 @@ from server.constants_data import ( ARTIFACT_SEND_INTERVAL_SECONDS, - COMPLETED_MESSAGE, CONTENT_ERROR_CODE_FEASIBILITY, CONTENT_ERROR_CODE_INFORMATION, CONTENT_ERROR_CODE_TARGET, ENERGY_SAVING_TEMPLATE_URI, FEASIBILITY_NEGOTIATION_PROPOSE_TEMPLATE_URI, - INPUT_REQUIRED_MESSAGE, NEGOTIATION_MISSING_ITEM_HINT, NEGOTIATION_PROPOSE_TEMPLATE_URI, NEGOTIATION_RELATIONSHIP, NEGOTIATION_T_EXTENSION_URI, + PROPOSE_MESSAGE, SUBMITTED_MESSAGE, TARGET_NEGOTIATION_PROPOSE_TEMPLATE_URI, TASK_T_EXTENSION_URI, @@ -204,19 +203,19 @@ async def _emit_negotiation_propose( missing_items: list[NegotiationItem], negotiation_type: str = NEGOTIATION_TYPE_INFORMATION, env_path: Path | None = None, - task_id: str, context_id: str, log_sink: object | None, ) -> None: - """Generate the Negotiation-T propose for the negotiation type and emit INPUT_REQUIRED. - - The type selects the propose template URI and the content (plan R1/R3). The propose is written - as a business message and generated through the from-text API: the SDK first extracts the - canonical typed content (one LLM call), then renders the addressed template. The accept is the - client's decision; how it is generated is the client's choice. The propose travels on the - INPUT_REQUIRED status event's message metadata (``propose.build_metadata_content()``); the - templateUri inside carries the per-type propose template URI, which is what the client - dispatches its accept on. + """Generate the Negotiation-T propose for the negotiation type and emit it as a Message. + + The negotiation runs before the task is created: the propose is a standalone A2A ``Message`` + (role ``ROLE_AGENT``, same ``contextId`` as the request, no ``taskId``), not a Task status + event. The type selects the propose template URI and the content (plan R1/R3). The propose is + written as a business message and generated through the from-text API: the SDK first extracts + the canonical typed content (one LLM call), then renders the addressed template. The accept is + the client's decision; how it is generated is the client's choice. The propose content travels + in the message metadata (``propose.build_metadata_content()``); the templateUri inside carries + the per-type propose template URI, which is what the client dispatches its accept on. """ propose_context = NegotiationContext( id=str(uuid.uuid4()), @@ -240,17 +239,14 @@ async def _emit_negotiation_propose( ), ) ) - await emit_status_update( - request_context=request_context, - event_queue=event_queue, - context_id=context_id, - task_id=task_id, - state=TaskState.TASK_STATE_INPUT_REQUIRED, - text=INPUT_REQUIRED_MESSAGE, - metadata=propose.build_metadata_content(), - ) + propose_message = Message(context_id=context_id, role=Role.ROLE_AGENT) + propose_message.message_id = str(uuid.uuid4()) + propose_message.parts.add(text=PROPOSE_MESSAGE) + for key, value in propose.build_metadata_content().items(): + propose_message.metadata[key] = value + await event_queue.enqueue_event(propose_message) if log_sink is not None: - log_sink(format_stage_log(role="server", stage="task-status", detail="TASK_STATE_INPUT_REQUIRED")) + log_sink(format_stage_log(role="server", stage="negotiation-propose-sent", detail="message emitted")) async def _execute_and_emit_result( @@ -357,21 +353,21 @@ async def execute_server_flow( 1. Validate the A2A-Extensions header (Task-T) and extract the prompt text 2. ``A2ATServer.validate_task_prompt_and_data_filling`` (A2A-T server SDK) validates the prompt and extracts the filled parameters - 3. Negotiation continuation (Negotiation-T URI in the message metadata): no SUBMITTED — - validate, execute the task through the integration point (progress messages and report artifact), - then COMPLETED - 4. Default flow: emit SUBMITTED, then on failure REJECTED — or, when the first validation - error's code selects a negotiation type (plan R1: ``content.param_missing`` information - with slot-named errors, ``content.value_not_allowed`` target, ``content.rule_violation`` - feasibility), a Negotiation-T propose of that type on an INPUT_REQUIRED status update, - generated from a natural-language business message through the from-text API + 3. Pre-task negotiation: when the first validation error's code selects a negotiation type + (plan R1: ``content.param_missing`` information with slot-named errors, + ``content.value_not_allowed`` target, ``content.rule_violation`` feasibility), emit a + standalone Negotiation-T propose ``Message`` and return — the task is not created. The + propose is generated from a natural-language business message through the from-text API (data-driven trigger, the server reads no scenario flag) - 5. Default success: execute the negotiated task through the integration point — each progress line - becomes a ``TASK_STATE_WORKING`` status update and the ``TaskExecutionResult`` becomes - the report artifact as one ``TaskArtifactUpdateEvent`` (the report body travels in + 4. The client's accept arrives as a new message (Negotiation-T URI in the metadata); its + Task-T prompt is validated, then the task is created (SUBMITTED) and executed + 5. On success: execute the task through the integration point — each progress line becomes a + ``TASK_STATE_WORKING`` status update and the ``TaskExecutionResult`` becomes the report + artifact as one ``TaskArtifactUpdateEvent`` (the report body travels in ``artifact.metadata[Task-T/v1]``); the task then stays long-running — no terminal state is emitted - 6. Exception during execution or artifact pushing -> emit FAILED (generic text) and re-raise + 6. A failure that does not lead to a propose (or a failed accept) -> SUBMITTED then REJECTED + 7. Exception during execution or artifact pushing -> emit FAILED (generic text) and re-raise """ resolved_execution_service = execution_service or Uc1DemoExecutionService( progress_steps=progress_steps, @@ -448,43 +444,25 @@ async def execute_server_flow( if log_sink is not None: log_sink(format_stage_log(role="server", stage="sdk-result", detail=f"invalid input {validation_failure}")) - # Negotiation continuation: no SUBMITTED — the completed Task-T prompt is validated, - # the task executes through the integration point, and the task closes with COMPLETED. - if negotiation_continuation: - if validation_failure is not None: - await emit_status_update( + # Pre-task negotiation: a failed validation that maps to a negotiation type is answered with a + # standalone propose Message — no Task is created. The client's accept comes back through this + # same flow (negotiation_continuation) and creates the task once it validates. + if validation_failure is not None and not negotiation_continuation: + negotiation_type = _resolve_negotiation_type(content_errors, missing_items) + if negotiation_type is not None: + await _emit_negotiation_propose( request_context=request_context, event_queue=event_queue, + prompt_server=prompt_server, + missing_items=missing_items, + negotiation_type=negotiation_type, + env_path=env_path, context_id=resolved_context_id, - task_id=resolved_task_id, - state=TaskState.TASK_STATE_REJECTED, - text=f"Prompt validation failed: {validation_failure}", + log_sink=log_sink, ) - if log_sink is not None: - log_sink(format_stage_log(role="server", stage="task-status", detail="TASK_STATE_REJECTED")) return - await _execute_and_emit_result( - request_context=request_context, - event_queue=event_queue, - execution_service=resolved_execution_service, - params=filled_params_data, - task_id=resolved_task_id, - context_id=resolved_context_id, - log_sink=log_sink, - ) - await emit_status_update( - request_context=request_context, - event_queue=event_queue, - context_id=resolved_context_id, - task_id=resolved_task_id, - state=TaskState.TASK_STATE_COMPLETED, - text=COMPLETED_MESSAGE, - ) - if log_sink is not None: - log_sink(format_stage_log(role="server", stage="task-status", detail="TASK_STATE_COMPLETED")) - return - # 1. SUBMITTED — always emitted before the validation result (default flow) + # The task is created only after the request (or the negotiation accept) validates. task = Task( id=resolved_task_id, context_id=resolved_context_id, @@ -501,23 +479,8 @@ async def execute_server_flow( if log_sink is not None: log_sink(format_stage_log(role="server", stage="task-status", detail="TASK_STATE_SUBMITTED")) - # 2. On failure → Negotiation-T propose selected by the first error's code (plan R1), - # otherwise REJECTED + # A failure that does not lead to a propose (or a failed accept) → REJECTED. if validation_failure is not None: - negotiation_type = _resolve_negotiation_type(content_errors, missing_items) - if negotiation_type is not None: - await _emit_negotiation_propose( - request_context=request_context, - event_queue=event_queue, - prompt_server=prompt_server, - missing_items=missing_items, - negotiation_type=negotiation_type, - env_path=env_path, - task_id=resolved_task_id, - context_id=resolved_context_id, - log_sink=log_sink, - ) - return await emit_status_update( request_context=request_context, event_queue=event_queue, @@ -530,7 +493,8 @@ async def execute_server_flow( log_sink(format_stage_log(role="server", stage="task-status", detail="TASK_STATE_REJECTED")) return - # 3. On success → execute the negotiated task through the integration point (default flow stays long-running) + # Success → execute through the integration point; the task stays long-running (WORKING) after + # the report artifact — no terminal state is emitted. await _execute_and_emit_result( request_context=request_context, event_queue=event_queue, diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_client_flow.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_client_flow.py index 204c79f2..d5e7c342 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_client_flow.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_client_flow.py @@ -2,7 +2,7 @@ Drives ``run_client_flow`` against a scripted fake a2a client (real protobuf StreamResponse events) and a fake prompt client: initial phase sends the Task-T prompt and receives the Negotiation-T propose on -INPUT_REQUIRED, the continuation re-sends the filled Task-T prompt together with the Negotiation-T accept and +a pre-task propose Message, the continuation re-sends the filled Task-T prompt together with the Negotiation-T accept and consumes the UC1 result; the default path stays single-round and its stage logs are pinned. The accept generation covers all three negotiation types dispatched on the propose's ``templateUri`` (the explicit kind flag wins when supplied) over both channels: from-data (typed content, zero LLM @@ -222,25 +222,28 @@ def _artifact_event() -> StreamResponse: ) +def _propose_event(metadata: dict[str, object] | None = None) -> StreamResponse: + """Build the standalone pre-task propose Message (no task id, same context id).""" + message = Message(context_id=_CONTEXT_ID, role=Role.ROLE_AGENT) + message.message_id = str(uuid.uuid4()) + message.parts.add(text="Additional information is required before the energy saving task can start") + for key, value in (metadata or {}).items(): + message.metadata[key] = value + return StreamResponse(message=message) + + def _initial_events(*, metadata: dict[str, object] | None = None) -> list[StreamResponse]: - """Round-1 script: the SUBMITTED Task event, then INPUT_REQUIRED carrying the propose.""" - return [ - _task_event(TaskState.TASK_STATE_SUBMITTED), - _status_update( - state=TaskState.TASK_STATE_INPUT_REQUIRED, - text="Additional information is required to start the energy saving task", - metadata=metadata, - ), - ] + """Initial script: the pre-task propose Message only — no task exists yet.""" + return [_propose_event(metadata)] def _continuation_events() -> list[StreamResponse]: - """Round-2 script: WORKING steps, the intent report artifact, then COMPLETED.""" + """Continuation script: the task is created, WORKING steps, then the intent report artifact.""" return [ + _task_event(TaskState.TASK_STATE_SUBMITTED), _status_update(state=TaskState.TASK_STATE_WORKING, text="progress step 1"), _status_update(state=TaskState.TASK_STATE_WORKING, text="progress step 2"), _artifact_event(), - _status_update(state=TaskState.TASK_STATE_COMPLETED, text="Energy saving task completed"), ] @@ -295,10 +298,10 @@ def _stage_names(logs: list[str]) -> list[str]: return [line[len(prefix) :].split(":", 1)[0] for line in logs if line.startswith(prefix)] -# --- negotiation round trip: propose -> filled prompt + accept -> COMPLETED ------------------ +# --- negotiation round trip: propose -> filled prompt + accept -> task runs -------------------- -def test_negotiation_round_trip_answers_the_propose_and_completes() -> None: +def test_negotiation_round_trip_answers_the_propose_and_the_task_stays_working() -> None: propose_metadata = _propose_metadata() session_id = propose_metadata[NEGOTIATION_CONTEXT_METADATA_KEY]["id"] prompt_client = _ScriptedPromptClient() @@ -321,7 +324,8 @@ def test_negotiation_round_trip_answers_the_propose_and_completes() -> None: assert set(_request_metadata(initial_request)) == {TASK_T_EXTENSION_URI, TEMPLATE_URI_METADATA_KEY} accept_request, accept_context = a2a_client.calls[1] - assert accept_request.message.task_id == _TASK_ID + # The accept is a pre-task message: same context, no task id. + assert accept_request.message.task_id == "" assert accept_request.message.context_id == _CONTEXT_ID assert accept_request.message.role == Role.ROLE_USER assert accept_request.message.parts[0].text == "create ran energy saving task" @@ -358,18 +362,18 @@ def test_negotiation_round_trip_answers_the_propose_and_completes() -> None: assert accept_session.round == 1 assert accept_session.performative == NegotiationPerformative.ACCEPT - # The consumed stream spans both rounds and closes with the COMPLETED state. + # The propose is a Message; the continuation creates the task and ends on the report artifact + # without a terminal state (the task stays WORKING). + assert any(event["kind"] == "message" for event in events) assert _event_states(events) == [ "TASK_STATE_SUBMITTED", - "TASK_STATE_INPUT_REQUIRED", "TASK_STATE_WORKING", "TASK_STATE_WORKING", - "TASK_STATE_COMPLETED", ] assert any(event["kind"] == "artifact" for event in events) -def test_accept_reuses_the_task_and_context_ids_of_the_initial_request() -> None: +def test_accept_reuses_the_context_id_of_the_propose_message() -> None: prompt_client = _ScriptedPromptClient() a2a_client = _ScriptedA2AClient([_initial_events(metadata=_propose_metadata()), _continuation_events()]) @@ -382,20 +386,19 @@ def test_accept_reuses_the_task_and_context_ids_of_the_initial_request() -> None ) ) - input_required = [event for event in events if event.get("state") == "TASK_STATE_INPUT_REQUIRED"][0] - assert input_required["context_id"] == _CONTEXT_ID - assert input_required["metadata"][NEGOTIATION_T_EXTENSION_URI] - # The accept request carries exactly the initial identity (task id from any initial-phase event, - # context id from the INPUT_REQUIRED status event). + propose = [event for event in events if event.get("kind") == "message"][0] + assert propose["context_id"] == _CONTEXT_ID + assert propose["metadata"][NEGOTIATION_T_EXTENSION_URI] + # The accept starts a new task in the propose's context: context id only, no task id. accept_request = a2a_client.calls[1][0] - assert accept_request.message.task_id == input_required["task_id"] - assert accept_request.message.context_id == input_required["context_id"] + assert accept_request.message.task_id == "" + assert accept_request.message.context_id == propose["context_id"] -# --- INPUT_REQUIRED without negotiation answers / default path ------------------------------- +# --- propose without negotiation answers / default path --------------------------------------- -def test_input_required_without_answers_is_logged_unhandled_and_stays_on_round_1() -> None: +def test_propose_without_answers_is_logged_unhandled_and_stays_on_round_1() -> None: prompt_client = _ScriptedPromptClient() a2a_client = _ScriptedA2AClient([_initial_events(metadata=_propose_metadata())]) @@ -405,7 +408,7 @@ def test_input_required_without_answers_is_logged_unhandled_and_stays_on_round_1 assert len(a2a_client.calls) == 1 assert prompt_client.accept_from_text_calls == [] assert prompt_client.from_data_calls == [] - assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED"] + assert [event["kind"] for event in events] == ["message"] assert any("negotiation-unhandled" in line for line in logs) @@ -428,7 +431,7 @@ def test_malformed_negotiation_context_is_logged_unhandled_and_stays_on_round_1( assert len(a2a_client.calls) == 1 assert prompt_client.accept_from_text_calls == [] assert prompt_client.from_data_calls == [] - assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED"] + assert [event["kind"] for event in events] == ["message"] unhandled_logs = [line for line in logs if line.startswith("[client] negotiation-unhandled:")] assert len(unhandled_logs) == 1 assert "malformed/missing session context" in unhandled_logs[0] @@ -527,7 +530,8 @@ def test_target_propose_answers_with_the_typed_target_accept(_offline_env: Path) assert accept_session.id == session_id assert accept_session.round == 1 assert accept_session.performative == NegotiationPerformative.ACCEPT - assert _event_states(events)[-1] == "TASK_STATE_COMPLETED" + assert any(event["kind"] == "artifact" for event in events) + assert "TASK_STATE_COMPLETED" not in _event_states(events) accept_logs = [line for line in logs if line.startswith("[client] negotiation-accept:")] assert len(accept_logs) == 1 assert "Negotiation-T/target-negotiation/accept-reject/v1" in accept_logs[0] @@ -563,7 +567,8 @@ def test_feasibility_propose_answers_with_the_typed_feasibility_accept(_offline_ assert accept_uri == FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI assert accept_session.round == 1 assert accept_session.performative == NegotiationPerformative.ACCEPT - assert _event_states(events)[-1] == "TASK_STATE_COMPLETED" + assert any(event["kind"] == "artifact" for event in events) + assert "TASK_STATE_COMPLETED" not in _event_states(events) @pytest.mark.parametrize( @@ -750,17 +755,26 @@ def test_malformed_context_degrades_to_unhandled_with_a_kind_flag_too(_offline_e def test_status_event_surfaces_the_message_metadata_and_context_id() -> None: event = normalize_event( - _status_update(state=TaskState.TASK_STATE_INPUT_REQUIRED, text="need more", metadata={"key": "value"}) + _status_update(state=TaskState.TASK_STATE_WORKING, text="need more", metadata={"key": "value"}) ) assert event["kind"] == "status" assert event["task_id"] == _TASK_ID assert event["context_id"] == _CONTEXT_ID - assert event["state"] == "TASK_STATE_INPUT_REQUIRED" + assert event["state"] == "TASK_STATE_WORKING" assert event["text"] == "need more" assert event["metadata"] == {"key": "value"} +def test_message_event_surfaces_the_metadata_and_context_id() -> None: + event = normalize_event(_propose_event({"key": "value"})) + + assert event["kind"] == "message" + assert event["context_id"] == _CONTEXT_ID + assert event["text"].startswith("Additional information is required") + assert event["metadata"] == {"key": "value"} + + def test_status_event_without_metadata_keeps_the_old_shape() -> None: event = normalize_event(_status_update(state=TaskState.TASK_STATE_WORKING, text="working")) @@ -964,7 +978,8 @@ def test_injected_provider_replaces_the_scripted_accept_end_to_end(_offline_env: assert prompt_client.accept_from_text_calls[0][0] == "Accept. 1. region:Custom filled region;" # The Task-T prompt refill remains the flow's message construction fed by the caller. assert prompt_client.from_data_calls == [(_NEGOTIATION_PARAMS, ENERGY_SAVING_TEMPLATE_URI)] - assert _event_states(events)[-1] == "TASK_STATE_COMPLETED" + assert any(event["kind"] == "artifact" for event in events) + assert "TASK_STATE_COMPLETED" not in _event_states(events) def test_reject_decision_logs_negotiation_decision_and_stays_on_round_1(_offline_env: Path) -> None: @@ -992,9 +1007,9 @@ def test_reject_decision_logs_negotiation_decision_and_stays_on_round_1(_offline assert decision_logs == [ "[client] negotiation-decision: reject (not implemented in this demo; see the sample README)" ] - assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED"] + assert [event["kind"] for event in events] == ["message"] summary_lines = [line for line in logs if line.startswith("[client] stream-completed:")] - assert summary_lines == ["[client] stream-completed: events=2 artifacts=0"] + assert summary_lines == ["[client] stream-completed: events=1 artifacts=0"] def test_abort_decision_logs_negotiation_decision_and_stays_on_round_1(_offline_env: Path) -> None: @@ -1019,9 +1034,9 @@ def test_abort_decision_logs_negotiation_decision_and_stays_on_round_1(_offline_ assert prompt_client.accept_from_text_calls == [] decision_logs = [line for line in logs if line.startswith("[client] negotiation-decision:")] assert decision_logs == ["[client] negotiation-decision: abort (not implemented in this demo; see the sample README)"] - assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED"] + assert [event["kind"] for event in events] == ["message"] summary_lines = [line for line in logs if line.startswith("[client] stream-completed:")] - assert summary_lines == ["[client] stream-completed: events=2 artifacts=0"] + assert summary_lines == ["[client] stream-completed: events=1 artifacts=0"] def test_target_accept_missing_confirmed_intent_is_logged_and_stays_unhandled(_offline_env: Path) -> None: @@ -1046,8 +1061,7 @@ def test_target_accept_missing_confirmed_intent_is_logged_and_stays_unhandled(_o assert len(a2a_client.calls) == 1 assert prompt_client.accept_from_text_calls == [] - assert prompt_client.accept_from_text_calls == [] decision_logs = [line for line in logs if line.startswith("[client] negotiation-decision:")] assert len(decision_logs) == 1 assert "missing confirmed_intent" in decision_logs[0] - assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED"] + assert [event["kind"] for event in events] == ["message"] diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py index f29a2619..19d27a04 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_scenario.py @@ -135,7 +135,7 @@ def _content_validation_messages( """User-message layout of a real content validation call (head + template-content tail). ``template_content_label`` is the localized label starting the template-content block; the - real zh-CN SDK template uses the full-width ``模板正文:`` with no trailing space. + real zh-CN SDK template uses its full-width localized label with no trailing space. """ return [ {"role": "system", "content": "content validation system prompt"}, @@ -511,7 +511,7 @@ def test_negotiation_slot_resources_preserve_the_distinguishing_values(language: #: Localized section label per language of the sections the new content-validation variants -#: report (the zh labels follow the v1 ``任务对象`` section-label convention). +#: report (the zh labels follow the v1 section-label convention). _VARIANT_SECTION_LABELS = { "en-US": {"task_context": "task_context", "task_target": "task_target"}, "zh-CN": {"task_context": "任务上下文", "task_target": "任务目标"}, @@ -598,7 +598,15 @@ def test_content_validation_variant_resources_carry_the_trigger_codes(language: "facts": {"section_label": _VARIANT_SECTION_LABELS[language]["task_context"]}, } ] - assert feasibility["params"] == {**complete["params"], "guaranteedThroughputTarget": None} + assert feasibility["params"] == { + **complete["params"], + "guaranteedThroughputTarget": None, + "siteList": {"en-US": "Station 1,Station 2", "zh-CN": "站点名称1,站点名称2"}[language], + "backgroundEvent": { + "en-US": "power outage from 00:00 to 18:00", + "zh-CN": "00:00~18:00 停电", + }[language], + } @pytest.mark.parametrize("language", ["en-US", "zh-CN"]) diff --git a/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py b/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py index 8b7a2f67..5053667c 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py +++ b/a2a-t-sample/ran-energy-saving/test/test_negotiation_server_flow.py @@ -1,11 +1,11 @@ """Offline tests of the server-side negotiation flow of the ran-energy-saving demo. Drives ``execute_server_flow`` with the real A2A-T facades, the scripted mock LLM and a real -a2a-sdk ``EventQueue``: initial phase (missing region) answers SUBMITTED + INPUT_REQUIRED carrying the -Negotiation-T information propose metadata, the continuation streams the UC1 result and -closes with COMPLETED, a failure without slot-named errors keeps the plain REJECTED branch, and -the target/feasibility content-validation failures propose their per-type Negotiation-T content -over both generation channels (from-data without and from-text with one extraction LLM call). +a2a-sdk ``EventQueue``: initial phase (missing region) answers a standalone Negotiation-T propose +Message carrying the information propose metadata (no task is created), the continuation creates the +task, streams the UC1 result and stays WORKING (no terminal state), a failure without slot-named +errors keeps the plain REJECTED branch, and the target/feasibility content-validation failures +propose their per-type Negotiation-T content with one extraction LLM call. The task-execution seam is covered too: the demo service satisfies the ``TaskExecutionService`` Protocol, an injected fake replaces the demo behavior end-to-end, and an execution failure emits FAILED with the generic text. @@ -24,7 +24,15 @@ from a2a.server.agent_execution.context import RequestContext from a2a.server.context import ServerCallContext from a2a.server.events.event_queue import EventQueueLegacy, QueueShutDown -from a2a.types import Role, SendMessageRequest, Task, TaskArtifactUpdateEvent, TaskState, TaskStatusUpdateEvent +from a2a.types import ( + Message, + Role, + SendMessageRequest, + Task, + TaskArtifactUpdateEvent, + TaskState, + TaskStatusUpdateEvent, +) from a2a_t.client.a2at_client import A2ATClient from a2a_t.core.errors.exceptions import ContentValidationError, SlotValidationError from a2a_t.core.metadata import ( @@ -69,11 +77,10 @@ from common import mock_llm from common.llm_logger import install_llm_logger from server.constants_data import ( - COMPLETED_MESSAGE, ENERGY_SAVING_REGION, - INPUT_REQUIRED_MESSAGE, NEGOTIATION_RELATIONSHIP, NEGOTIATION_T_EXTENSION_URI, + PROPOSE_MESSAGE, TASK_T_EXTENSION_URI, get_energy_saving_intent_report, get_energy_saving_progress_steps, @@ -206,6 +213,13 @@ def _message_metadata(event: object) -> dict[str, object]: return MessageToDict(status.message.metadata) +def _event_metadata(event: object) -> dict[str, object]: + """Return the metadata map of a standalone Message or of an event's status message.""" + if isinstance(event, Message): + return MessageToDict(event.metadata) + return _message_metadata(event) + + def _missing_region_prompt(client: A2ATClient, env_path: Path) -> MetadataContent: """Generate the initial Task-T prompt (missing region) through the real client facade.""" return client.generate_task_prompt_from_text( @@ -233,7 +247,7 @@ def _run_two_phase_flow(env_path: Path) -> tuple[list[object], list[object], dic prompt_server=server, ) ) - propose_context = dict(_message_metadata(initial_events[-1])[NEGOTIATION_CONTEXT_METADATA_KEY]) + propose_context = dict(_event_metadata(initial_events[-1])[NEGOTIATION_CONTEXT_METADATA_KEY]) # Continuation: the client answers with the accept prompt and the filled Task-T prompt. accept_context = NegotiationContext( @@ -291,7 +305,7 @@ def validate_task_prompt_and_data_filling(self, *, prompt: str, schema: object, # --- initial phase: missing params -> Negotiation-T propose on INPUT_REQUIRED --------------------- -def test_round_1_missing_region_answers_input_required_carrying_the_propose(_offline_env: Path) -> None: +def test_round_1_missing_region_answers_with_a_propose_message(_offline_env: Path) -> None: mock_llm.reset_call_log() client = A2ATClient(env_path=_offline_env) prompt = _missing_region_prompt(client, _offline_env) @@ -306,12 +320,13 @@ def test_round_1_missing_region_answers_input_required_carrying_the_propose(_off ) ) - assert _event_states(events) == [TaskState.TASK_STATE_SUBMITTED, TaskState.TASK_STATE_INPUT_REQUIRED] - input_required = events[-1] - assert isinstance(input_required, TaskStatusUpdateEvent) - # The status text part stays a short human line; the propose travels in the metadata. - assert input_required.status.message.parts[0].text == INPUT_REQUIRED_MESSAGE - metadata = _message_metadata(input_required) + # The negotiation is pre-task: one standalone propose Message, no SUBMITTED / Task event. + assert len(events) == 1 + propose = events[0] + assert isinstance(propose, Message) + # The text part stays a short human line; the propose travels in the metadata. + assert propose.parts[0].text == PROPOSE_MESSAGE + metadata = _event_metadata(propose) assert NEGOTIATION_T_EXTENSION_URI in metadata assert metadata[TEMPLATE_URI_METADATA_KEY] == "Negotiation-T/information-negotiation/propose/v1" context = metadata[NEGOTIATION_CONTEXT_METADATA_KEY] @@ -339,7 +354,7 @@ def _kind_initial_metadata(client: A2ATClient, env_path: Path, kind: str) -> dic # --- initial phase: per-type proposes (target / feasibility) ------------------------------------ -def test_round_1_target_failure_answers_input_required_carrying_the_target_propose(_offline_env: Path) -> None: +def test_round_1_target_failure_answers_with_a_target_propose_message(_offline_env: Path) -> None: mock_llm.reset_call_log() metadata = _kind_initial_metadata( A2ATClient(env_path=_offline_env), _offline_env, scenario_data.NEGOTIATION_KIND_TARGET @@ -349,11 +364,11 @@ def test_round_1_target_failure_answers_input_required_carrying_the_target_propo _run_flow(metadata=metadata, prompt_server=A2ATServer(env_path=_offline_env), env_path=_offline_env) ) - assert _event_states(events) == [TaskState.TASK_STATE_SUBMITTED, TaskState.TASK_STATE_INPUT_REQUIRED] - input_required = events[-1] - assert isinstance(input_required, TaskStatusUpdateEvent) - assert input_required.status.message.parts[0].text == INPUT_REQUIRED_MESSAGE - metadata = _message_metadata(input_required) + assert len(events) == 1 + propose = events[0] + assert isinstance(propose, Message) + assert propose.parts[0].text == PROPOSE_MESSAGE + metadata = _event_metadata(propose) assert NEGOTIATION_T_EXTENSION_URI in metadata assert metadata[TEMPLATE_URI_METADATA_KEY] == TARGET_NEGOTIATION_PROPOSE_URI context = metadata[NEGOTIATION_CONTEXT_METADATA_KEY] @@ -374,7 +389,7 @@ def test_round_1_target_failure_answers_input_required_carrying_the_target_propo ] -def test_round_1_feasibility_failure_answers_input_required_carrying_the_feasibility_propose( +def test_round_1_feasibility_failure_answers_with_a_feasibility_propose_message( _offline_env: Path, ) -> None: mock_llm.reset_call_log() @@ -386,8 +401,10 @@ def test_round_1_feasibility_failure_answers_input_required_carrying_the_feasibi _run_flow(metadata=metadata, prompt_server=A2ATServer(env_path=_offline_env), env_path=_offline_env) ) - assert _event_states(events) == [TaskState.TASK_STATE_SUBMITTED, TaskState.TASK_STATE_INPUT_REQUIRED] - metadata = _message_metadata(events[-1]) + assert len(events) == 1 + propose = events[0] + assert isinstance(propose, Message) + metadata = _event_metadata(propose) assert NEGOTIATION_T_EXTENSION_URI in metadata assert metadata[TEMPLATE_URI_METADATA_KEY] == FEASIBILITY_NEGOTIATION_PROPOSE_URI context = metadata[NEGOTIATION_CONTEXT_METADATA_KEY] @@ -446,29 +463,26 @@ def test_feasibility_propose_text_renders_the_infeasibility_details(_offline_env assert f"{name}:{value}" in text -# --- continuation: validate -> progress -> artifact -> COMPLETED ----------------- +# --- continuation: create task -> validate -> progress -> artifact (stays WORKING) ------------ -def test_round_2_continuation_streams_the_uc1_result_and_completes(_offline_env: Path) -> None: +def test_round_2_continuation_creates_the_task_and_keeps_it_working(_offline_env: Path) -> None: mock_llm.reset_call_log() _, continuation_events, _ = _run_two_phase_flow(_offline_env) expected_steps = get_energy_saving_progress_steps() - assert _event_states(continuation_events) == [TaskState.TASK_STATE_WORKING] * len(expected_steps) + [ - TaskState.TASK_STATE_COMPLETED - ] - # No SUBMITTED event on the continuation round. - assert not any(isinstance(event, Task) for event in continuation_events) - # One artifact event, arriving between the last WORKING step and COMPLETED. + assert _event_states(continuation_events) == [TaskState.TASK_STATE_SUBMITTED] + [ + TaskState.TASK_STATE_WORKING + ] * len(expected_steps) + # The task is created when the accept validates (the negotiation itself was pre-task). + assert any(isinstance(event, Task) for event in continuation_events) + # One artifact event, the last event: no terminal state follows it. artifacts = [event for event in continuation_events if isinstance(event, TaskArtifactUpdateEvent)] assert len(artifacts) == 1 artifact_index = continuation_events.index(artifacts[0]) + assert artifact_index == len(continuation_events) - 1 assert _event_state(continuation_events[artifact_index - 1]) == TaskState.TASK_STATE_WORKING - assert _event_state(continuation_events[artifact_index + 1]) == TaskState.TASK_STATE_COMPLETED assert artifacts[0].artifact.name == "energy saving intent report" - completed = continuation_events[-1] - assert isinstance(completed, TaskStatusUpdateEvent) - assert completed.status.message.parts[0].text == COMPLETED_MESSAGE def test_two_round_sequence_pins_the_mock_call_log(_offline_env: Path) -> None: @@ -770,7 +784,6 @@ async def _emit(items: list[NegotiationItem]) -> list[object]: event_queue=queue, prompt_server=A2ATServer(env_path=_offline_env), missing_items=items, - task_id=request_context.task_id or "", context_id=request_context.context_id or "", log_sink=None, ) @@ -779,10 +792,10 @@ async def _emit(items: list[NegotiationItem]) -> list[object]: single = asyncio.run(_emit([_missing_item("region")])) multiple = asyncio.run(_emit([_missing_item("region"), _missing_item("energySavingGoal")])) - assert _event_states(single) == [TaskState.TASK_STATE_INPUT_REQUIRED] - assert _event_states(multiple) == [TaskState.TASK_STATE_INPUT_REQUIRED] - single_text = str(_message_metadata(single[-1])[NEGOTIATION_T_EXTENSION_URI]) - multiple_text = str(_message_metadata(multiple[-1])[NEGOTIATION_T_EXTENSION_URI]) + assert len(single) == 1 and isinstance(single[0], Message) + assert len(multiple) == 1 and isinstance(multiple[0], Message) + single_text = str(_event_metadata(single[-1])[NEGOTIATION_T_EXTENSION_URI]) + multiple_text = str(_event_metadata(multiple[-1])[NEGOTIATION_T_EXTENSION_URI]) assert NEGOTIATION_RELATIONSHIP not in single_text assert NEGOTIATION_RELATIONSHIP in multiple_text diff --git a/a2a-t-sample/ran-energy-saving/test/test_webhook_client_flow.py b/a2a-t-sample/ran-energy-saving/test/test_webhook_client_flow.py index 0dace20e..d1a04417 100644 --- a/a2a-t-sample/ran-energy-saving/test/test_webhook_client_flow.py +++ b/a2a-t-sample/ran-energy-saving/test/test_webhook_client_flow.py @@ -1,21 +1,19 @@ """Offline tests of the webhook transport flow (v3) of the ran-energy-saving demo. The powerful pattern: a REAL embedded ``WebhookReceiver`` (ephemeral port, in-process HTTP only) -plus a fake a2a client whose ``send_message`` records every request (the initial send must carry -``return_immediately=True`` and the inline push config; the accept must carry NO inline config) and -whose side effect POSTs the scripted StreamResponse-shaped bodies to the receiver URL with the -registered token header — simulating the server's push sender, so the ENTIRE webhook pipeline -(normalize → queue → flow) runs without the server stack. The fake -``create_task_push_notification_config`` records the accept endpoint re-registration -(R4) with the same url/token, and the call order send→register→send is pinned. +plus a fake a2a client whose ``send_message`` records every request (both the initial send and the +pre-task accept carry ``return_immediately=True`` and the inline push config) and whose side effect +POSTs the scripted StreamResponse-shaped task bodies to the receiver URL with the registered token +header — simulating the server's push sender, so the ENTIRE webhook pipeline (normalize → queue → +flow) runs without the server stack. A pre-task propose Message is returned as the RPC reply and is +never pushed (the push sender only carries task events). Covered: the default scenario (artifact end, no accept sent, summary counts WEBHOOK-received events -only — the RPC reply is logged separately per R6), the 3-type negotiation matrix (per-type accept -content + re-registration + COMPLETED end), the target accept spot check, the unhandled/malformed -INPUT_REQUIRED degradations (identical to SSE), the terminal-state -end signals (a REJECTED in either phase ends promptly with ``end=`` on the summary — no idle -hang, final fix), the idle-timeout backstop (R5), the -``normalize_webhook_payload``/``normalize_event`` parity, and the server +only — the RPC reply is logged separately per R6), the 3-type negotiation matrix (propose reply → +inline-config accept → task stays WORKING), the target accept spot check, the unhandled/malformed +propose degradations (identical to SSE), the terminal-state end signals (a REJECTED in either phase +ends promptly with ``end=`` on the summary — no idle hang, final fix), the idle-timeout +backstop (R5), the ``normalize_webhook_payload``/``normalize_event`` parity, and the server assembly (push store/sender assembled unconditionally into ``build_rest_app``). """ @@ -163,12 +161,10 @@ def generate_negotiation_accept_prompt_from_text( class _WebhookFakeA2AClient: """Fake a2a client simulating the server side over the REAL receiver. - ``send_message`` records the request (and its call context), POSTs the round's scripted - StreamResponse-shaped bodies to the receiver URL with the registered token header (the push - sender's behavior), and yields the single non-streaming RPC reply — the first Task of the - round, per ``return_immediately`` semantics (R6: the reply also gets pushed, so it overlaps - with the webhook events). ``create_task_push_notification_config`` records the accept - re-registration call. ``call_log`` pins the send→register→send order. + ``send_message`` records the request (and its call context), POSTs the round's scripted task + bodies to the receiver URL with the registered token header (the push sender's behavior), and + yields the single non-streaming RPC reply — the first Task of the round, or a pre-task propose + Message (which is never pushed). ``call_log`` pins the send order. """ def __init__( @@ -196,6 +192,11 @@ async def send_message( script = self._rounds[len(self.send_calls) - 1] reply: StreamResponse | None = None for event in script: + if event.HasField("message"): + # A pre-task propose Message is the RPC reply; the push sender never pushes Messages. + if reply is None: + reply = StreamResponse(message=event.message) + continue # The script events are StreamResponse-shaped; MessageToDict(event) is exactly the # body the server's push sender POSTs (MessageToDict(to_stream_response(event))). body = MessageToDict(event) @@ -294,25 +295,28 @@ def _default_initial_script() -> list[StreamResponse]: ] +def _propose_event(metadata: dict[str, object]) -> StreamResponse: + """Build the standalone pre-task propose Message (the RPC reply, never pushed).""" + message = Message(context_id=_CONTEXT_ID, role=Role.ROLE_AGENT) + message.message_id = str(uuid.uuid4()) + message.parts.add(text="Additional information is required before the energy saving task can start") + for key, value in metadata.items(): + message.metadata[key] = value + return StreamResponse(message=message) + + def _negotiation_initial_script(metadata: dict[str, object]) -> list[StreamResponse]: - """Negotiation initial-phase script pushed via webhook: SUBMITTED Task, then INPUT_REQUIRED propose.""" - return [ - _task_event(TaskState.TASK_STATE_SUBMITTED), - _status_update( - state=TaskState.TASK_STATE_INPUT_REQUIRED, - text="Additional information is required to start the energy saving task", - metadata=metadata, - ), - ] + """Negotiation initial-phase script: the standalone propose Message only (no task yet).""" + return [_propose_event(metadata)] def _negotiation_continuation_script() -> list[StreamResponse]: - """Negotiation continuation script pushed via webhook: WORKING steps, artifact, then COMPLETED.""" + """Negotiation continuation script: the created task, WORKING steps, then the report artifact.""" return [ + _task_event(TaskState.TASK_STATE_SUBMITTED), _status_update(state=TaskState.TASK_STATE_WORKING, text="progress step 1"), _status_update(state=TaskState.TASK_STATE_WORKING, text="progress step 2"), _artifact_event(), - _status_update(state=TaskState.TASK_STATE_COMPLETED, text="Energy saving task completed"), ] @@ -441,10 +445,10 @@ def test_default_scenario_webhook_ends_on_artifact_and_counts_webhook_events(_of (FEASIBILITY_NEGOTIATION_PROPOSE_URI, FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI), ], ) -def test_negotiation_webhook_round_trip_re_registers_and_completes( +def test_negotiation_webhook_round_trip_answers_the_propose( _offline_env: Path, propose_uri: str, accept_uri: str ) -> None: - """3-type matrix: INPUT_REQUIRED -> endpoint re-registration (R4) -> accept -> COMPLETED (R5).""" + """3-type matrix: propose Message reply -> inline config on the accept -> task runs (no terminal).""" propose_metadata = _propose_metadata(template_uri=propose_uri) session_id = propose_metadata[NEGOTIATION_CONTEXT_METADATA_KEY]["id"] @@ -457,26 +461,25 @@ def test_negotiation_webhook_round_trip_re_registers_and_completes( ) ) - # Pushes all delivered; the flow ends on the COMPLETED webhook event. - assert a2a_client.push_status_codes == [200] * 6 + # The propose is the RPC reply (not pushed); the continuation's 4 events are pushed. + assert a2a_client.push_status_codes == [200] * 4 assert _event_states(events) == [ "TASK_STATE_SUBMITTED", - "TASK_STATE_INPUT_REQUIRED", "TASK_STATE_WORKING", "TASK_STATE_WORKING", - "TASK_STATE_COMPLETED", ] + assert any(event["kind"] == "message" for event in events) assert any(event["kind"] == "artifact" for event in events) - # Order evidence: initial send -> endpoint re-registration -> accept send. - assert a2a_client.call_log == ["send:1", "register:1", "send:2"] + # Order evidence: initial send -> accept send (no endpoint re-registration on a new task). + assert a2a_client.call_log == ["send:1", "send:2"] - # Round-2 request: non-blocking, NO inline config (already registered via the endpoint), - # initial identity, per-type accept templateUri, same-round ACCEPT session. + # The accept is a new message: inline push config, same context, no task id, per-type + # accept templateUri, same-round ACCEPT session. accept_request, accept_context = a2a_client.send_calls[1] assert accept_request.configuration.return_immediately is True - assert not accept_request.configuration.HasField("task_push_notification_config") - assert accept_request.message.task_id == _TASK_ID + assert accept_request.configuration.HasField("task_push_notification_config") + assert accept_request.message.task_id == "" assert accept_request.message.context_id == _CONTEXT_ID metadata = _request_metadata(accept_request) assert metadata[TEMPLATE_URI_METADATA_KEY] == accept_uri @@ -491,24 +494,17 @@ def test_negotiation_webhook_round_trip_re_registers_and_completes( TASK_T_EXTENSION_URI, NEGOTIATION_T_EXTENSION_URI, } - - # Re-registration (R4): same task id, SAME url/token as the initial inline config. - registered_task_id, registered_config = a2a_client.register_calls[0] - assert registered_task_id == _TASK_ID - assert registered_config.url == receiver.url - assert registered_config.token == _DEMO_TOKEN - reregister_logs = [line for line in logs if line.startswith("[client] webhook-push-reregister:")] - assert len(reregister_logs) == 1 + assert a2a_client.register_calls == [] # The accept goes through the extraction API for the proposed type. assert prompt_client.from_data_calls == [(_NEGOTIATION_PARAMS, ENERGY_SAVING_TEMPLATE_URI)] assert len(prompt_client.accept_from_text_calls) == 1 assert prompt_client.accept_from_text_calls[0][1].round == 1 - # Summary: 2 webhook events (initial) + 4 (continuation), 1 artifact, terminal COMPLETED end. + # Summary: 1 propose reply + 4 continuation events, 1 artifact; the task stays WORKING. summary_lines = [line for line in logs if line.startswith("[client] stream-completed:")] assert summary_lines == [ - "[client] stream-completed: events=6 artifacts=1 transport=webhook end=TASK_STATE_COMPLETED" + "[client] stream-completed: events=5 artifacts=1 transport=webhook" ] @@ -585,8 +581,8 @@ def test_target_accept_webhook_spot_check(_offline_env: Path) -> None: assert context.performative == NegotiationPerformative.ACCEPT assert template_uri == TARGET_NEGOTIATION_ACCEPT_REJECT_URI - # Round-2 message shape: send -> register -> send, COMPLETED end. - assert a2a_client.call_log == ["send:1", "register:1", "send:2"] + # Message shape: send (propose reply) -> send (accept, inline config). + assert a2a_client.call_log == ["send:1", "send:2"] accept_request = a2a_client.send_calls[1][0] metadata = _request_metadata(accept_request) assert metadata[TEMPLATE_URI_METADATA_KEY] == TARGET_NEGOTIATION_ACCEPT_REJECT_URI @@ -611,7 +607,7 @@ def test_input_required_without_answers_is_unhandled_and_ends_after_the_initial_ assert len(unhandled_logs) == 1 assert "no negotiation answers supplied" in unhandled_logs[0] summary_lines = [line for line in logs if line.startswith("[client] stream-completed:")] - assert summary_lines == ["[client] stream-completed: events=2 artifacts=0 transport=webhook"] + assert summary_lines == ["[client] stream-completed: events=1 artifacts=0 transport=webhook"] def test_malformed_context_is_unhandled_and_ends_after_the_initial_phase_webhook(_offline_env: Path) -> None: @@ -649,7 +645,7 @@ def respond(self, propose: NegotiationProposeView, session: NegotiationContext) def test_injected_provider_webhook_replaces_the_scripted_accept_end_to_end(_offline_env: Path) -> None: - """A custom accept decision drives the webhook accept: re-register, send, COMPLETED.""" + """A custom accept decision drives the webhook accept; the task then stays WORKING.""" propose_metadata = _propose_metadata(template_uri=TARGET_NEGOTIATION_PROPOSE_URI) session_id = propose_metadata[NEGOTIATION_CONTEXT_METADATA_KEY]["id"] provider = _RecordingResponseProvider( @@ -675,20 +671,21 @@ def test_injected_provider_webhook_replaces_the_scripted_accept_end_to_end(_offl ) assert provider.sessions[0].id == session_id - # Order evidence: initial send -> endpoint re-registration -> accept send. - assert a2a_client.call_log == ["send:1", "register:1", "send:2"] + # Order evidence: initial send (propose reply) -> accept send (inline config). + assert a2a_client.call_log == ["send:1", "send:2"] # The accept text carries the decision's confirmed intent (not the scenario text). assert len(prompt_client.accept_from_text_calls) == 1 assert prompt_client.accept_from_text_calls[0][0] == "Accept. Custom confirmed target" - assert _event_states(events)[-1] == "TASK_STATE_COMPLETED" + assert any(event["kind"] == "artifact" for event in events) + assert "TASK_STATE_COMPLETED" not in _event_states(events) summary_lines = [line for line in logs if line.startswith("[client] stream-completed:")] assert summary_lines == [ - "[client] stream-completed: events=6 artifacts=1 transport=webhook end=TASK_STATE_COMPLETED" + "[client] stream-completed: events=5 artifacts=1 transport=webhook" ] def test_reject_decision_webhook_logs_and_ends_after_the_initial_phase(_offline_env: Path) -> None: - """A reject decision over webhook: dedicated log, no re-registration, no accept sent (R5).""" + """A reject decision over webhook: dedicated log, no accept sent (R5).""" provider = _RecordingResponseProvider(NegotiationDecision(conclusion="reject", reason="policy")) events, a2a_client, prompt_client, logs, _receiver = asyncio.run( @@ -707,9 +704,9 @@ def test_reject_decision_webhook_logs_and_ends_after_the_initial_phase(_offline_ assert decision_logs == [ "[client] negotiation-decision: reject (not implemented in this demo; see the sample README)" ] - assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED"] + assert [event["kind"] for event in events] == ["message"] summary_lines = [line for line in logs if line.startswith("[client] stream-completed:")] - assert summary_lines == ["[client] stream-completed: events=2 artifacts=0 transport=webhook"] + assert summary_lines == ["[client] stream-completed: events=1 artifacts=0 transport=webhook"] # --- idle timeout backstop (R5) ------------------------------------------------------------------- @@ -769,7 +766,7 @@ def test_accept_rejected_ends_the_flow_with_the_state_noted(_offline_env: Path) _run_webhook_scenario( rounds=[ _negotiation_initial_script(_propose_metadata()), - # The server pushes REJECTED instead of the normal progress/artifact/COMPLETED. + # The server pushes REJECTED instead of the normal progress/artifact flow. [_status_update(state=TaskState.TASK_STATE_REJECTED, text="Prompt validation failed: region")], ], env_path=_offline_env, @@ -779,13 +776,13 @@ def test_accept_rejected_ends_the_flow_with_the_state_noted(_offline_env: Path) ) # The accept WAS sent (a genuine accept failure), then REJECTED ended the flow — no hang. - assert a2a_client.call_log == ["send:1", "register:1", "send:2"] + assert a2a_client.call_log == ["send:1", "send:2"] timeout_logs = [line for line in logs if line.startswith("[client] webhook-idle-timeout:")] assert timeout_logs == [] - assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED", "TASK_STATE_REJECTED"] + assert _event_states(events) == ["TASK_STATE_REJECTED"] summary_lines = [line for line in logs if line.startswith("[client] stream-completed:")] assert summary_lines == [ - "[client] stream-completed: events=3 artifacts=0 transport=webhook end=TASK_STATE_REJECTED" + "[client] stream-completed: events=2 artifacts=0 transport=webhook end=TASK_STATE_REJECTED" ] @@ -797,7 +794,7 @@ def test_normalize_webhook_payload_matches_normalize_event_for_every_event_kind( propose_metadata = _propose_metadata() events = [ _task_event(TaskState.TASK_STATE_SUBMITTED), - _status_update(state=TaskState.TASK_STATE_INPUT_REQUIRED, text="need more", metadata=propose_metadata), + _propose_event(propose_metadata), _status_update(state=TaskState.TASK_STATE_WORKING, text="working"), _artifact_event(), ] @@ -826,10 +823,16 @@ def test_normalize_webhook_payload_artifact_data_and_text_parts() -> None: def test_normalize_webhook_payload_message_body() -> None: - message = Message(role=Role.ROLE_AGENT) + message = Message(context_id=_CONTEXT_ID, role=Role.ROLE_AGENT) + message.message_id = "m-1" message.parts.add(text="plain agent message") event = normalize_webhook_payload(MessageToDict(StreamResponse(message=message))) - assert event == {"kind": "message", "text": "plain agent message"} + assert event == { + "kind": "message", + "text": "plain agent message", + "context_id": _CONTEXT_ID, + "message_id": "m-1", + } def test_normalize_webhook_payload_rejects_unrecognized_body() -> None: @@ -841,8 +844,8 @@ def test_normalize_webhook_payload_keeps_metadata_so_int_coercion_absorbs_the_fl """``negotiationContext.round`` arrives as the JSON 1.0 float; the session parser absorbs it.""" metadata = _propose_metadata() metadata[NEGOTIATION_CONTEXT_METADATA_KEY]["round"] = 1.0 # the Struct/JSON float round-trip - status = _status_update(state=TaskState.TASK_STATE_INPUT_REQUIRED, text="propose", metadata=metadata) - payload = MessageToDict(status) + propose = _propose_event(metadata) + payload = MessageToDict(propose) event = normalize_webhook_payload(payload) session = _parse_negotiation_context(event["metadata"]) assert session is not None From db3975eac964f306e09f866440f7051264eba7bf Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Tue, 20 Oct 2026 12:06:50 +0800 Subject: [PATCH 30/30] docs(sample): describe pre-task negotiation and the long-running task Signed-off-by: codetalks-hw --- a2a-t-sample/ran-energy-saving/README-zh.md | 30 ++++++++++++--------- a2a-t-sample/ran-energy-saving/README.md | 29 ++++++++++++-------- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/a2a-t-sample/ran-energy-saving/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index 1ae74cac..d6ea0c65 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -103,8 +103,9 @@ python run_demo.py # 默认英文;加 --language zh-CN 运行中文 2. **发送** —— 提示词放在消息 `metadata` 中发给服务端。 3. **校验** —— 服务端从 metadata 中取出提示词,调用 A2A-T 服务端 SDK 的 `validate_task_prompt_and_data_filling` 校验并抽取参数(`operationType`、`region`、 - `energySavingGoal`、`guaranteedThroughputTarget`)。双端共享的调用方 schema `TASK_PARAM_SCHEMA` - 位于 [`src/common/task_schema.py`](src/common/task_schema.py)。 + `energySavingGoal`、`guaranteedThroughputTarget`,以及可选的站点级 `siteList` 与 + `backgroundEvent`)。双端共享的调用方 schema `TASK_PARAM_SCHEMA` 位于 + [`src/common/task_schema.py`](src/common/task_schema.py)。 4. **执行并报告** —— 抽取出的参数交给业务执行;进度以状态更新返回、结果以报告 artifact 返回, 由客户端呈现。 @@ -202,6 +203,11 @@ Prompt 模板渲染而来,例如 `Negotiation-T/information-negotiation/propos 会话(`id` / `round` / `maxRounds` / performative)随每条协商消息放在 `negotiationContext` metadata 中,因此 A2A-T SDK 本身无状态。 +**协商发生在任务创建之前。** 第 2 条报文中的 propose 是一条独立的 A2A `Message`(角色为 +`ROLE_AGENT`,context id 与请求相同),不是 Task 状态事件。只有 accept 携带的 Task-T 提示词校验 +通过后,服务端才创建任务(先 `SUBMITTED`,再 `WORKING`),此后按普通 Task-T 交互继续。节能任务是 +值守类长时任务:报告发出后仍保持 `WORKING`,没有 `COMPLETED` 状态 —— 只有任务被取消才会结束。 + **client 侧要接的地方。** 收到第 2 条报文时,客户端 flow 构建一个轻量的 `NegotiationProposeView`(kind、 `templateUri`、渲染文本)并调用([`negotiation_response.py`](src/client/negotiation_response.py)): @@ -394,7 +400,8 @@ webhook 模式下的变化:客户端非阻塞发送(`return_immediately`) 送达客户端进程内嵌的接收器([`src/common/webhook_receiver.py`](src/common/webhook_receiver.py) 的 `WebhookReceiver`),它额外绑定一个监听端口(`--webhook-port`,默认 9000)。服务端推送在 `X-A2A-Notification-Token` 头中携带 `A2AT_SAMPLE_WEBHOOK_TOKEN`(默认 `demo-webhook-token`)。 -流程在任一 SDK 终态结束,另有 30 秒空闲兜底。协商往返在两种传输上的行为一致。 +流程以报告 artifact 结束,另有 30 秒空闲兜底。协商往返在两种传输上的行为一致;任务创建前的 +propose 是 Message 而非任务事件,因此客户端从非阻塞 RPC 应答中读取它。 - **两条注册路径。** 初始请求在请求上内联注册推送配置 (`SendMessageConfiguration.task_push_notification_config`,即扁平的 @@ -411,15 +418,14 @@ webhook 模式下的变化:客户端非阻塞发送(`return_immediately`) - **Token 鉴权。** 接收器对每个 POST 校验 `X-A2A-Notification-Token`:不匹配回 `401`(事件被丢弃), 畸形或无法识别的报文回 `400`。demo 的静态共享 token 是 demo 级的 —— 生产请使用 TLS 之上可轮换的 逐客户端密钥。 -- **结束信号。** 任一 SDK 终态(`TASK_STATE_COMPLETED` / `FAILED` / `CANCELED` / `REJECTED`)都会结束 - 当前正在消费事件的阶段:协商场景正常以 `COMPLETED` 结束;校验失败或流异常以 `REJECTED` / - `FAILED` 结束,summary 行以 `end=` 记录。默认场景(无协商)以报告 artifact 结束。 - `INPUT_REQUIRED`(协商 propose)是初始阶段的中断点,不是流程结束:协商已接入时,flow 会进入续传 - 阶段。空闲兜底 - (`A2AT_SAMPLE_WEBHOOK_IDLE_TIMEOUT`,默认 30 秒,每收到一个事件重置)打印 `webhook-idle-timeout` - 行后带着已收集的事件正常结束。`A2AT_SAMPLE_MAX_ARTIFACTS` 在 webhook 模式下不生效。每个阶段的唯一 - RPC 应答以 `webhook-rpc-reply` 记录一次,不重复计数。`stream-completed` 汇总统计经 webhook 收到 - 的事件并追加 `transport=webhook`。 +- **结束信号。** 所有场景都以报告 artifact 结束:节能任务是值守类长时任务,报告发出后保持 + `WORKING`,服务端不会再发终态。校验失败(`REJECTED` 任务)或流异常(`FAILED` / `CANCELED`)仍会 + 立即结束流程,summary 行以 `end=` 记录。任务创建前的 propose 是 Message,不是推送的任务 + 事件:webhook 模式下客户端从非阻塞 RPC 应答中读取它,并在 accept 上注册自己的内联推送配置。空闲 + 兜底(`A2AT_SAMPLE_WEBHOOK_IDLE_TIMEOUT`,默认 30 秒,每收到一个事件重置)打印 + `webhook-idle-timeout` 行后带着已收集的事件正常结束。`A2AT_SAMPLE_MAX_ARTIFACTS` 在 webhook + 模式下不生效。每个阶段的唯一 RPC 应答以 `webhook-rpc-reply` 记录一次,不重复计数。 + `stream-completed` 汇总统计经 webhook 收到的事件并追加 `transport=webhook`。 ### 6.2 配置(a2a-t-sample/.env) diff --git a/a2a-t-sample/ran-energy-saving/README.md b/a2a-t-sample/ran-energy-saving/README.md index 596fd8ba..2f15c6a2 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -106,9 +106,9 @@ cannot keep a stream open, add `--transport webhook` — see [§6.1](#61-webhook 2. **Send** — the prompt travels to the server in the message `metadata`. 3. **Validate** — the server reads the prompt from the metadata and calls the A2A-T server SDK's `validate_task_prompt_and_data_filling`, which validates it and extracts the parameters - (`operationType`, `region`, `energySavingGoal`, `guaranteedThroughputTarget`). The caller schema - `TASK_PARAM_SCHEMA`, shared by both sides, lives in - [`src/common/task_schema.py`](src/common/task_schema.py). + (`operationType`, `region`, `energySavingGoal`, `guaranteedThroughputTarget`, plus the optional + site-level `siteList` and `backgroundEvent`). The caller schema `TASK_PARAM_SCHEMA`, shared by + both sides, lives in [`src/common/task_schema.py`](src/common/task_schema.py). 4. **Execute and report** — the extracted parameters feed the business execution; progress comes back as status updates and the result as a report artifact, which the client presents. @@ -225,6 +225,12 @@ need the equivalent decision on the server side. The session (`id` / `round` / `maxRounds` / performative) travels with every negotiation message in the `negotiationContext` metadata map, so the A2A-T SDK itself stays stateless. +**The negotiation runs before the task is created.** The propose in message 2 is a standalone A2A +`Message` (role `ROLE_AGENT`, same context id), not a Task status event. The server creates the task +(`SUBMITTED`, then `WORKING`) only when the accept's Task-T prompt validates; the interaction then +continues as ordinary Task-T. The energy-saving task is a long-running duty task: it stays `WORKING` +after the report and has no `COMPLETED` state — it ends only if the task is cancelled. + **Where the client's decision plugs in.** When message 2 arrives, the client flow builds a lightweight `NegotiationProposeView` (kind, `templateUri`, rendered text) and calls the provider ([`negotiation_response.py`](src/client/negotiation_response.py)): @@ -438,8 +444,9 @@ event arrives as a webhook POST on a receiver embedded in the client process (`W [`src/common/webhook_receiver.py`](src/common/webhook_receiver.py)), which binds one extra listening port (`--webhook-port`, default 9000). The server's pushes carry `A2AT_SAMPLE_WEBHOOK_TOKEN` (default `demo-webhook-token`) in the `X-A2A-Notification-Token` -header. The flow ends on any SDK terminal state, with a 30 s idle backstop. The negotiation round -trip behaves identically on both transports. +header. The flow ends on the report artifact, with a 30 s idle backstop. The negotiation round +trip behaves identically on both transports; a pre-task propose Message is not a task event, so the +client reads it from the non-blocking RPC reply. - **Two registration paths.** The initial request registers the push config inline (`SendMessageConfiguration.task_push_notification_config`, a flat @@ -458,12 +465,12 @@ trip behaves identically on both transports. - **Token auth.** The receiver validates `X-A2A-Notification-Token` on every POST: a mismatch answers `401` (the event is dropped), a malformed or unrecognized body answers `400`. The demo's static shared token is demo-grade — for production use per-client secrets with rotation over TLS. -- **End signals.** Any SDK terminal state (`TASK_STATE_COMPLETED` / `FAILED` / `CANCELED` / - `REJECTED`) ends the phase that is consuming events: a negotiation scenario normally ends on - `COMPLETED`; a failed validation or a stream error ends on `REJECTED` / `FAILED`, and the summary - records it as `end=`. The default scenario (no negotiation) ends on the report artifact. - `INPUT_REQUIRED` (a negotiation propose) is an initial-phase interruption, not a flow end: when - the negotiation is engaged, the flow proceeds to the continuation phase. The idle backstop +- **End signals.** Every scenario ends on the report artifact: the energy-saving task is a + long-running duty task and stays `WORKING`, so the server emits no terminal state after the + report. A failed validation (a `REJECTED` task) or a stream error (`FAILED` / `CANCELED`) still + ends the flow immediately, and the summary records it as `end=`. A pre-task propose is a + Message, never a pushed task event: in webhook mode the client answers it from the non-blocking + RPC reply and registers its own inline push config on the accept. The idle backstop (`A2AT_SAMPLE_WEBHOOK_IDLE_TIMEOUT`, default 30 s, reset per received event) logs `webhook-idle-timeout` and ends the flow with what was collected. `A2AT_SAMPLE_MAX_ARTIFACTS` does not apply in webhook mode. Each phase's single RPC reply is logged once as