diff --git a/a2a-t-sample/README-zh.md b/a2a-t-sample/README-zh.md index 56470ab1..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 协商(信息 / 目标 / 可行性),覆盖 fromData / fromText 两种生成通道,另支持可选的 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 a613b219..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) 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) | 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/README-zh.md b/a2a-t-sample/ran-energy-saving/README-zh.md index bdcf7c40..d6ea0c65 100644 --- a/a2a-t-sample/ran-energy-saving/README-zh.md +++ b/a2a-t-sample/ran-energy-saving/README-zh.md @@ -3,36 +3,65 @@ 一个 A2A-T 样例:包含一个客户端 Agent、一个 RAN 域服务端 Agent,以及用于发现 AgentCard 的 mock 注册中心。 +**读者定位。** 本文档面向实现 **client agent** 的团队;**server agent** 是可运行的参考实现。 +生产中两端通常归属不同厂商,因此下文统一按角色说明各自负责什么,不使用含混的第二人称。 + +## 目录 + +- [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-服务端侧) +- [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) +- [5. 自定义 Agent 开发说明](#5-自定义-agent-开发说明) +- [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 执行节能任务。在这一个场景上,样例按顺序覆盖客户 -需要集成的两项 A2A-T 能力: +本样例演示 A2A-T Agent 协作:客户端 Agent 与 RAN(Radio Access Network,无线接入网)节能 Agent +交互,执行节能任务。样例同时使用 Task-T 与 Negotiation-T。 -```text -Task-T 自然语言 ──> 任务提示词 ──> 校验 ──> 执行 ──> 报告 +关键工作流: -Negotiation-T 请求不能直接执行时(信息缺失 / 目标不清晰 / 可行性不确定) - ──> 双方 Agent 协商 ──> 达成一致 ──> 任务随后按 Task-T 执行 -``` +- **Task-T(不含协商):** -按三种阅读路径之一阅读: + ```text + 自然语言 ──> 任务提示词 ──> 校验 ──> 任务执行 + ``` -- **"先跑起来"** —— [§2 快速开始](#2-快速开始)。 -- **"我要集成一次性任务"** —— [§3 Task-T 交互](#3-使用-a2a-t-sdk-实现-task-t-交互)加上 - [替换清单](#5-替换清单demo-到生产)。 -- **"我要集成协商"** —— [§4 Negotiation-T 交互](#4-使用-a2a-t-sdk-实现-negotiation-t-交互)。 +- **Task-T + Negotiation-T(含协商):** + + ```text + 自然语言(不完整 / 存在歧义 / 不可行) + ──> 任务提示词 ──> 校验 ──> 协商 ──> 达成一致 ──> 任务执行 + ``` ### 1.1 目录结构 | 路径 | 核心职责 | | --- | --- | | [`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/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](#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 服务。 | +| [`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) | 脚本化的槽位抽取与内容校验应答。 | @@ -40,7 +69,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 @@ -56,11 +86,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 交互 @@ -71,21 +103,22 @@ 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 返回, 由客户端呈现。 ### 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}) @@ -95,59 +128,87 @@ 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))。 -被拒绝的提示词映射为 `TASK_STATE_REJECTED`,而不是让服务崩溃;校验通过的参数进入执行接缝。 - -### 3.4 你要替换什么 +`_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`)。 -模板(`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 与自然语言输入都是样例特有的;各端需要替换的内容见 +[§5](#5-自定义-agent-开发说明)。 ## 4. 使用 A2A-T SDK 实现 Negotiation-T 交互 -一句话分界:SDK 拥有报文 —— 渲染、校验、协商会话映射、传输机制;你的系统拥有业务 —— 校验失败 -何时转为协商、以及你这一侧如何应答 propose。 +两个 SDK 负责协议细节,业务决策由两端 Agent 各自负责。 -### 4.1 服务端何时开启协商 +- **A2A-T SDK** 负责生成与校验协商消息(propose、accept、reject、abort):渲染提示词文本,并把 + 调用方传入的 `negotiationContext` 带入生成的消息。它不保存会话状态,也不含传输层。 +- **A2A SDK** 负责传输这些消息,因此部署时总是两者配合使用。 +- 在本样例中,server agent 决定请求何时无法按收到的内容执行、且值得通过协商解决;client agent + 决定如何应答收到的 propose。协议本身允许任何一方发起 propose(见 + [§4.3](#43-通用机制))。 -当 Task-T 内容校验以某条内容错误码失败、且服务端倾向于通过对话而非直接拒绝来解决时。哪种失败对应 -哪种协商类型是**样例层的约定,不是 SDK 契约** —— 报文是自描述的:每个 propose 携带各自的 -`templateUri`(`Negotiation-T/{information,target,feasibility}-negotiation/propose/v1`),客户端 -基于该 URI 分派 accept。 +### 4.1 协商场景 -| 内容校验错误码 | 协商类型 | 业务语义的触发条件 | 样例输入 | -| --- | --- | --- | --- | -| `content.param_missing` | **信息协商(information)** | 请求缺少服务端必需的参数 | `--scenario negotiation` | -| `content.value_not_allowed` | **目标协商(target)** | 目标本身合法但超出或偏离服务端能力口径 | `--negotiation target` | -| `content.semantic_conflict` | **可行性协商(feasibility)** | 目标在当前网络状态下可能不可达 | `--negotiation feasibility --channel fromText` | +在本样例中,服务端无法按收到的请求执行、且该问题可以通过协商解决而不必直接拒绝时,由服务端开启 +协商。样例覆盖三种情况: + +**信息不完整。** 客户端要求执行节能,给了目标、时段和吞吐目标,却没说在哪个区域。服务端缺这一项 +无法开工,于是告诉客户端缺什么;客户端补上,任务继续。 + +**目标存在歧义。** 客户端给出的分时段保障速率目标里,两个时段重叠: +`{17:30~23:00,20Mbps}` 与 `{20:00~00:00,10Mbps}`。请求本身格式没问题,但重叠时段的保障速率无法 +唯一确定。服务端复述它理解的意图,请客户端调整时间;客户端带着修正后的时段回应。 -映射常量是 `_NEGOTIATION_TYPE_BY_ERROR_CODE` -([`server_flow.py:64`](src/server/server_flow.py#L64)) —— 你可以随意编辑自己的副本(重映射错误码、 -新增自有错误码、或让某些码不映射;不映射的码保持普通 `TASK_STATE_REJECTED`)。 +**目标在当前条件下无法满足。** 客户端要求两个站点在 00:00~18:00 停电期间保障 +`{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}`,而站点既有的备电要求是至少支撑 18 小时。按这个 +速率执行,电池撑不到停电结束。服务端给出不可行的结论和提案(把 06:00~18:00 的保障速率从 10Mbps 下调 +到 2Mbps);客户端接受调整后的目标。 -### 4.2 共享机制 +### 4.2 样例如何选择协商类型 -以下对三类协商通用,各类型小节不再重复。 +是否需要协商、以及属于哪种类型,是**server agent 的业务决策,两个 SDK 都不作规定** —— client +只对收到的 propose 作出反应。本样例是数据驱动的:首个内容校验错误的错误码通过 +`_NEGOTIATION_TYPE_BY_ERROR_CODE` 表([`server_flow.py`](src/server/server_flow.py))选出类型。 +这张表是**样例约定,不是 SDK 契约**。 -**一轮 = 一次 propose 加上对它的应答。** 会话上下文携带 1 起始的 `round`;应答 propose 的 accept -沿用该 propose 的轮次,只有**新的** propose 才会推进轮次。初始的 Task-T 请求不是协商轮 —— 它是一次 -普通任务请求,协商从服务端的 propose 才开始。`maxRounds`(默认 5)限制会话轮次。 +报文并不依赖这张表。每个 propose 都记录自己的 `templateUri` —— 即 metadata 中这段报文由哪个 +Prompt 模板渲染而来,例如 `Negotiation-T/information-negotiation/propose/v1`。这个 URI 同时告诉 +客户端收到的是哪一类协商,因此客户端按收到的消息决定应答 —— accept、reject 或 abort —— 而不是按 +错误码。 + +### 4.3 通用机制 + +以下机制对三类协商均适用,各类型小节不再重复。 + +**轮次。** 一轮由一次 propose 和针对该 propose 的应答(accept、reject 或 abort)构成。会话上下文 +携带从 1 开始的 `round`:应答沿用其所回应的 propose 的轮次,只有**新的** propose 才推进轮次。 +客户端最初的请求不计入协商轮次 —— 第一轮从服务端的 propose 开始。`maxRounds`(默认 5)限制会话 +轮数。 + +**双方都可以发起 propose。** 协商是对称的:A2A-T 客户端 SDK 与 A2A-T 服务端 SDK 都提供同样的四个 +生成方法(propose / accept / reject / abort)和同样的校验方法,任何一方都能发起协商,任何一方也都 +能应答。区别只在于谁发送 propose;报文形态、会话处理与传输完全一致。本样例由**服务端**发起,因为 +发现"请求无法按收到的内容执行"的是服务端。客户端同样可以发起协商,例如它无法满足服务端公布的某项 +约束时,由服务端来 accept / reject / abort。本样例只在客户端侧实现了决策(provider 接入点);如果 +由客户端发起协商,对等的决策逻辑需要放在服务端侧。 **四条报文的闭环:** -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` 收尾协商运行。 - -**决策接缝。** 收到第 2 条报文时,客户端 flow 构建一个轻量的 `NegotiationProposeView`(kind、 +1. 客户端 → 服务端:节能请求。样例中它故意在以下三处之一存在缺陷:缺少区域、目标中时段重叠、或 + 保障速率无法支撑停电时长。 +2. 服务端 → 客户端:propose —— 缺什么、需要澄清什么、或哪个目标不可行以及提供了什么替代方案。 +3. 客户端 → 服务端:一条消息,同时承载 accept 和补全/修正后重新发送的请求。 +4. 服务端 → 客户端:结果 —— 先进度更新,后报告。 + +会话(`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)): ```python @@ -158,164 +219,213 @@ class NegotiationResponseProvider(Protocol): ) -> NegotiationDecision: ... ``` -`NegotiationDecision` 携带 `conclusion`(`accept` / `reject` / `abort`)加上对应类型的 accept -字段:`filled_params`(信息协商,name → value)、`confirmed_intent`(目标协商)、 -`feasibility_summary`(可行性协商),以及 `reason`(reject/abort)。**`accept` 已端到端接线**: -flow 根据你的决策重新生成 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.3 信息协商(information) +### 4.4 信息协商(information) -**① 服务端何时开启。** Task-T 校验以 `content.param_missing` 失败:请求缺少服务端必需的参数 -(样例中为 `region`)。 +**① 触发条件。** 请求不完整 —— 缺少服务端必需的参数(样例中为区域)。样例的触发条件是 +`content.param_missing` 校验错误,但这个错误码是样例的判断方式,不属于协议本身。 -**② 参考代码做了什么。** +**② 参考实现。** | 步骤 | 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_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 渲染 +propose = server.generate_negotiation_propose_prompt_from_text( + propose_text, # 缺失的参数,写成编号列表 + context, + INFORMATION_NEGOTIATION_PROPOSE_URI, +) + +# 客户端决策:询问 provider 如何应答 propose +decision = provider.respond(propose_view, session) # filled_params = 要补的取值 + +# 客户端 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 文本列出补全后的取值。 -**③ 哪些可以原样复用。** 提示词渲染、`templateUri`/`negotiationContext` 的 metadata 组装、 +**③ 可复用的客户端代码。** 提示词渲染、`templateUri`/`negotiationContext` 的 metadata 组装、 会话处理与传输 —— `client_flow.py` 中除决策本身以外的全部内容。 -**④ 哪些必须你自己写。** propose 的业务措辞(要问哪些参数、如何命名 —— -`server_flow._missing_items_from_errors` 加上你的槽位 schema),以及 provider 接缝背后的应答来源: +**④ 客户端自行实现的代码。** provider 接入点背后的应答来源。 ```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) ``` -### 4.4 目标协商(target) +### 4.5 目标协商(target) -**① 服务端何时开启。** Task-T 校验以 `content.value_not_allowed` 失败:目标本身合法但超出或偏离 -服务端能力口径(样例中为 100Mbps 吞吐目标,超出小区能力)。 +**① 触发条件。** 目标存在歧义:分时段保障速率目标里有两个时段重叠,重叠时段的保障速率无法 +唯一确定。样例的触发条件是 `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) | -| 客户端 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_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`) | -样例提出的是**确认请求**形态(`target_confirm_request` 非空,三个条件列表为空 —— SDK 强制的互斥 -规则),accept 确认澄清后的意图。 +```python +# 服务端 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:把应答写成业务报文,再经 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, +) +``` -**③ 哪些可以原样复用。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 +样例的 propose 请求澄清:服务端复述它理解的意图(`intent_understanding`),并询问需要调整哪个 +时段(`request_for_clarification`)。accept 把修正后的目标放进 `confirmed_intent`。 -**④ 哪些必须你自己写。** propose 中澄清后目标的措辞(`_negotiation_propose_data` 的 target 分支), -以及你这一侧如何得出 `confirmed_intent` —— 样例从 `scenario_data` 读取;你的来自确认目标的那一方。 +**③ 可复用的客户端代码。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 -> `TargetProposeContent` 还支持多轮澄清(首轮的 `intent_understanding`、后续轮的 -> `alignment_and_clarification`、开放式问题的 `request_for_clarification`)。样例只用了确认请求 -> 形态;SDK 四种都会渲染。 +**④ 客户端自行实现的代码。** 如何得出 `confirmed_intent`:样例从 `scenario_data` 读取,客户端 +实现则从消解歧义的组件获取。 -### 4.5 可行性协商(feasibility) +### 4.6 可行性协商(feasibility) -**① 服务端何时开启。** Task-T 校验以 `content.semantic_conflict` 失败:目标在当前网络状态下可能 -不可达(样例中为 30% 节能目标与 50Mbps 吞吐保障冲突)。 +**① 触发条件。** 请求在当前条件下无法满足:保障速率无法支撑请求中所列站点的停电时长。样例的 +触发条件是 `content.rule_violation` 校验错误。 -**② 参考代码做了什么。** +**② 参考实现。** | 步骤 | 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) | -| 客户端 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_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`) | -propose 请求评估(`action=REQUEST_FEASIBILITY_EVALUATION` 加 `contents_to_evaluate`);accept 返回 -评估摘要。 - -**③ 哪些可以原样复用。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 +```python +# 服务端 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:把应答写成业务报文,再经 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, +) +``` -**④ 哪些必须你自己写。** 评估请求的措辞,更重要的是评估本身:`feasibility_summary` 必须来自你的 -规划或仿真系统。 +样例的 propose 说明评估结论为该目标不可行,并附上替代方案 +(`action=PROPOSE_ALTERNATIVE_ON_FAILURE` 加 `infeasibility_details_and_proposal`);accept 说明 +客户端同意的调整。 -> `FeasibilityProposeContent` 还支持 `PROPOSE_ALTERNATIVE_ON_FAILURE` -> (`infeasibility_details_and_proposal`)与 `feasibility_confirm_request` 形态。样例只用了评估 -> 请求形态。 +**③ 可复用的客户端代码。** 与信息协商相同:渲染、metadata 组装、会话处理、传输。 -### 4.6 reject 与 abort —— 扩展点 +**④ 客户端自行实现的代码。** `feasibility_summary` 中的应答内容,即客户端对服务端提案的答复。 -`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)) -—— 以 `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` 模板。 +### 4.7 reject 与 abort -### 4.7 fromData 还是 fromText +客户端可以用 `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`)。 -默认的 `fromData` 通道从类型化内容确定性渲染 —— 不调用 LLM。`fromText`(`--channel fromText`)先把 -每条消息渲染为自然语言文本,再由 SDK 抽取回结构化内容 —— 每条生成的消息多一次抽取 LLM 调用; -报文格式与 `fromData` 完全一致。 +## 5. 自定义 Agent 开发说明 -## 5. 替换清单(demo 到生产) +**归属**列标明各组件由哪一端负责。client 团队负责标注为 client 与 both 的行;server agent 负责 +标注为 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) | 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` -> ([`server_flow.py:207`](src/server/server_flow.py#L207)) 用其访问器填充 target/feasibility 的 -> propose 文案。只替换 provider 与执行服务,服务端仍会用 demo 的预制文案发起 propose —— -> 该模块不可删除。 +| 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) | 两端(见下) | 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` 同时喂两侧** —— 客户端的 Task-T 输入与脚本化协商应答(补全参数、确认意图、 +> 可行性摘要)经 provider 接入点使用;服务端的 propose 文本也由同一模块构建:`_negotiation_propose_text` +> ([`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`)。 +流程以报告 artifact 结束,另有 30 秒空闲兜底。协商往返在两种传输上的行为一致;任务创建前的 +propose 是 Message 而非任务事件,因此客户端从非阻塞 RPC 应答中读取它。 - **两条注册路径。** 初始请求在请求上内联注册推送配置 (`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)只是初始阶段的中断点,不是流程结束。空闲兜底 - (`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) @@ -324,7 +434,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`);协商 propose/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` | @@ -338,16 +447,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 @@ -382,9 +494,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 调用) | +| `--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 59a996e6..2f15c6a2 100644 --- a/a2a-t-sample/ran-energy-saving/README.md +++ b/a2a-t-sample/ran-energy-saving/README.md @@ -3,37 +3,68 @@ 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 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 + +- [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 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) +- [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) + - [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. 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) + - [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 -scenario the sample covers the two A2A-T capabilities a customer integrates, in 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 ──> validated ──> executed ──> reported +Key workflows: -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 -``` +- **Task-T (without negotiation):** + + ```text + natural language ──> task prompt ──> validation ──> task execution + ``` -Read it one of three ways: +- **Task-T + Negotiation-T (with negotiation):** -- **"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). + ```text + natural language (incomplete / ambiguous / infeasible) + ──> task prompt ──> validation ──> negotiation ──> agreement ──> task execution + ``` ### 1.1 Project layout | 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/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/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 [§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 seam 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. | @@ -41,7 +72,8 @@ Read it one of three ways: ## 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 @@ -57,15 +89,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 your 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 @@ -74,21 +106,23 @@ negotiation instead. If your client cannot hold a stream open, add `--transport 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`, 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. ### 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}) @@ -98,66 +132,107 @@ 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)). -A rejected prompt is mapped to `TASK_STATE_REJECTED` instead of crashing the server; the validated -parameters go into the execution seam. +`_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 you replace +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. -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 -[replace checklist](#5-replace-checklist-demo--production)). +## 4. Using the A2A-T SDK to implement the Negotiation-T interaction -## 4. Using the A2A-T SDK to implement Negotiation-T interaction +The SDKs handle the protocol details; the two agents own the business decisions. -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. +- 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 When the server opens a negotiation +### 4.1 Negotiation scenarios -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. +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: -| 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. + +**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 +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. -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`). +### 4.2 How the demo selects the negotiation kind -### 4.2 Shared mechanics +Whether to negotiate at all, and which kind applies, is **the server agent's business decision; 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 +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 +`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.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 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. +**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 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. - -**The decision seam.** When message 2 arrives, the client flow builds a lightweight -`NegotiationProposeView` (kind, `templateUri`, rendered text) and calls +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. + +**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)): ```python @@ -168,186 +243,238 @@ 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 wired -end-to-end**: from your 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.3 Information negotiation +### 4.4 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`). +**① 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 | | --- | --- | --- | -| 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_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 +propose = server.generate_negotiation_propose_prompt_from_text( + propose_text, # the missing parameters, written as a numbered list + context, + INFORMATION_NEGOTIATION_PROPOSE_URI, +) + +# Client decision: ask the provider how to answer the propose +decision = provider.respond(propose_view, session) # filled_params = the values to deliver + +# 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 you 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 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: +**④ Client-specific code.** The answer source behind the provider +integration point. ```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) ``` -### 4.4 Target negotiation +### 4.5 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). +**① 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. -**② What the reference code does.** +**② Reference implementation.** | 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) | -| 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_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`) | -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. +```python +# 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 decision: ask the provider how to answer the propose +decision = provider.respond(propose_view, session) # confirmed_intent = the corrected target + +# 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, +) +``` -**③ What you can copy as-is.** The same as information: rendering, metadata assembly, session -handling, transport. +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`. -**④ What you must write yourself.** The clarified-target 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. +**③ Reusable client code.** The same as information: rendering, metadata assembly, session +handling, transport. -> `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. +**④ 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.5 Feasibility negotiation +### 4.6 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). +**① 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. -**② What the reference code does.** +**② Reference implementation.** | 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) | -| 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_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`) | -The propose requests an evaluation (`action=REQUEST_FEASIBILITY_EVALUATION` with -`contents_to_evaluate`); the accept returns the evaluation summary. - -**③ What you can copy as-is.** The same as information: rendering, metadata assembly, session -handling, transport. +```python +# 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 decision: ask the provider how to answer the propose +decision = provider.respond(propose_view, session) # feasibility_summary = the answer + +# 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, +) +``` -**④ 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. +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. -> `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. +**③ Reusable client code.** The same as information: rendering, metadata assembly, session +handling, transport. -### 4.6 Reject and abort — the extension point +**④ Client-specific code.** The answer carried in `feasibility_summary`, i.e. the client's response +to the server's proposal. -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 -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`. +### 4.7 Reject and abort -### 4.7 fromData or fromText +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`). -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. +## 5. Notes for Custom Agent Development -## 5. Replace checklist (demo → production) +The **Side** column identifies the agent responsible for each component. The client team is +responsible for the rows marked `client` and `both`; the server agent is responsible for the rows +marked `server`. -| 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 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 -> 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 -> the server proposing the demo's canned texts — the module is not removable. +| 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 | +| 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 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 +> 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 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 - `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 +- **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 - `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) @@ -357,7 +484,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`); generation channel of the negotiation propose/accept messages, 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` | @@ -372,18 +498,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 @@ -418,9 +547,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` = 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) | +| `--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` | 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..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 @@ -2,17 +2,19 @@ "semantic_verdict": false, "errors": [ { - "slot_name": "energySavingGoal", - "code": "content.semantic_conflict", + "slot_name": "guaranteedThroughputTarget", + "code": "content.rule_violation", "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, + "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/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..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 @@ -1,16 +1,16 @@ { - "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": "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_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/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_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.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/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 6f2b1a93..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 @@ -2,17 +2,19 @@ "semantic_verdict": false, "errors": [ { - "slot_name": "energySavingGoal", - "code": "content.semantic_conflict", + "slot_name": "guaranteedThroughputTarget", + "code": "content.rule_violation", "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, + "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/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..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 @@ -1,16 +1,16 @@ { - "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": "建议将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_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/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_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.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/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/run_demo.py b/a2a-t-sample/ran-energy-saving/run_demo.py index cd0ddca8..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``). @@ -27,8 +27,8 @@ 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 feasibility --channel fromText + python run_demo.py --negotiation target # overlapping time segments; target negotiation + 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), } @@ -281,24 +275,14 @@ 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 " "(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 91683d96..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 @@ -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, @@ -68,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", "")) @@ -97,8 +87,8 @@ 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. +#: 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, TARGET_NEGOTIATION_PROPOSE_URI: NEGOTIATION_KIND_TARGET, @@ -115,9 +105,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 - 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`` + 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). """ if negotiation_kind is not None: @@ -128,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: @@ -146,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, @@ -197,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) @@ -218,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]: @@ -228,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. @@ -267,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): @@ -308,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 @@ -378,24 +367,27 @@ 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, 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", @@ -403,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( @@ -419,7 +412,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, @@ -428,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 @@ -450,121 +441,105 @@ 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" ), ) ) - 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 seam/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, - channel=channel, - 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_metadata(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 @@ -604,40 +579,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 +609,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]: @@ -671,14 +617,12 @@ 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), - ``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 +635,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) @@ -726,8 +661,8 @@ 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. - accept_request.message.parts.add().text = _build_request_metadata(initial_input) + # no events); the message shape is identical for both drivers once set. + 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 @@ -759,7 +694,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, @@ -774,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" ), ) @@ -788,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" ), ) @@ -806,7 +741,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 @@ -873,32 +808,29 @@ async def _run_negotiation_accept( a2a_client: object, initial_input: dict[str, object], session: NegotiationContext, - task_id: str, context_id: str, plan: _NegotiationAcceptPlan, - channel: str, negotiation_params: dict[str, object], normalized_events: list[dict[str, object]], artifact_count: int, 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, initial_input=initial_input, session=session, plan=plan, - channel=channel, 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 @@ -910,7 +842,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)}" @@ -934,7 +866,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, @@ -954,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 seam (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 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 - ``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`` @@ -976,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 wire), 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 per the configured channel), 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) @@ -1000,7 +931,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 @@ -1045,7 +976,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 @@ -1078,8 +1009,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, ) @@ -1089,7 +1019,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}" ), @@ -1099,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, @@ -1133,10 +1062,8 @@ 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, - channel=channel, negotiation_params=negotiation_params, normalized_events=normalized_events, artifact_count=artifact_count, 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..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 @@ -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, @@ -174,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 @@ -184,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. """ @@ -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 @@ -319,10 +314,12 @@ 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...) + # 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, @@ -345,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/negotiation_response.py b/a2a-t-sample/ran-energy-saving/src/client/negotiation_response.py index 58697484..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 @@ -1,10 +1,10 @@ -"""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`` 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. """ @@ -26,9 +26,9 @@ @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 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,12 +52,12 @@ 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. - * ``"reject"`` / ``"abort"`` — not wired in this demo: the flow logs the decision and ends the + through the existing accept builders, so the message behavior is the demo's accept. + * ``"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 you wire the reject/abort send yourself (see the sample README, + ``failure_reason`` once the client implementation adds the reject/abort send (see the sample README, Negotiation-T chapter). """ @@ -72,16 +72,16 @@ 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 - INPUT_REQUIRED Negotiation-T propose: ``propose`` is the lightweight - :class:`NegotiationProposeView` observed from the wire (negotiation kind, template URI, rendered + 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 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 - Task-T prompt, builds the typed accept from your decision with the same round stamped as + 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. ``respond`` is synchronous — the flow calls it at the decision point. A customer implementation @@ -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/client/scenario_data.py b/a2a-t-sample/ran-energy-saving/src/client/scenario_data.py index ab80563b..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 @@ -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 @@ -67,7 +56,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" @@ -106,31 +95,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 +#: 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 = ( - "在松山湖管委会创建无线网络节能任务:节能目标为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 +188,110 @@ 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": "无线网络节能任务的目标协商:用户提交的保障速率目标100Mbps超出小区能力,需对澄清后的目标进行确认。", + "zh-CN": ( + "无线网络节能任务的目标协商:分时段保障速率目标中 {17:30~23:00,20Mbps} 与 " + "{20:00~00:00,10Mbps} 两个时段存在时间重叠,无法唯一确定重叠时段的速率保障目标。" + ), +} + +#: 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}", + ), + ], } -#: 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,请确认。", +#: 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 confirmed-target sentence) per language. +#: 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.", - ), - ( - "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制式,低话务时段具备关断载频条件。"), + ("评估结论", "不可行。"), + ( + "提案", + "建议将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,本次协商确认结束。", } @@ -263,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``). @@ -376,11 +415,60 @@ 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}", + }, +} + +#: 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, + *, + 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"]) + if negotiation_kind == NEGOTIATION_KIND_FEASIBILITY: + filled.update(_FEASIBILITY_REFILL_CONTEXT.get(language, _FEASIBILITY_REFILL_CONTEXT["zh-CN"])) + return filled def negotiation_answers(*, env_path: Path | None = None) -> dict[str, str]: @@ -394,9 +482,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 +508,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..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 - 100Mbps / 30%+50Mbps 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", @@ -128,9 +140,19 @@ #: 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",) + +#: 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``. @@ -156,7 +178,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 +211,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() @@ -257,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) @@ -264,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): @@ -292,6 +322,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, @@ -299,8 +335,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/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 3e70ab81..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 @@ -86,7 +88,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/constants_data.py b/a2a-t-sample/ran-energy-saving/src/server/constants_data.py index 80fed7f9..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 @@ -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,16 +10,12 @@ 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 -#: 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 @@ -48,29 +42,18 @@ #: 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 #: 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" - -#: 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" +CONTENT_ERROR_CODE_FEASIBILITY = "content.rule_violation" #: Area the sample energy-saving intent is scoped to (from the RAN energy-saving interface spec). ENERGY_SAVING_REGION = "Songshanhu Administration Committee" @@ -198,15 +181,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/src/server/server_flow.py b/a2a-t-sample/ran-energy-saving/src/server/server_flow.py index b33f08f3..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,17 +8,10 @@ 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.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,19 +22,16 @@ from server.constants_data import ( ARTIFACT_SEND_INTERVAL_SECONDS, - CHANNEL_DATA, - CHANNEL_TEXT, - 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, @@ -58,7 +48,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 +130,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). """ @@ -182,12 +172,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: @@ -204,53 +195,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 (plan R3 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. - """ - 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, - alignment_and_clarification=None, - request_for_clarification=None, - target_confirm_request=scenario_data.negotiation_target_confirm_request(env_path=env_path), - ), - ) - 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.REQUEST_FEASIBILITY_EVALUATION, - contents_to_evaluate=[ - NegotiationItem(name=name, value=value) - for name, value in scenario_data.negotiation_feasibility_contents(env_path=env_path) - ], - infeasibility_details_and_proposal=None, - 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, @@ -258,22 +202,20 @@ 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, 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 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. + """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()), @@ -282,35 +224,29 @@ 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]}" ), ) ) - 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( @@ -323,7 +259,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 @@ -401,7 +337,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: @@ -412,27 +347,27 @@ 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), - 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`` - 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 + 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 seam — 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, @@ -478,7 +413,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] = {} @@ -509,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 seam, 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, @@ -562,24 +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, - channel=channel, - 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, @@ -592,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 seam (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/src/server/server_main.py b/a2a-t-sample/ran-energy-saving/src/server/server_main.py index 8204bb6e..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,24 +124,22 @@ 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 seam 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: 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( 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/src/server/task_execution.py b/a2a-t-sample/ran-energy-saving/src/server/task_execution.py index 699928d1..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 @@ -1,10 +1,10 @@ -"""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 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,16 +39,16 @@ 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 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). + 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. 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..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 @@ -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 @@ -209,7 +195,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, @@ -236,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"), ] @@ -275,7 +264,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 +277,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, ) @@ -311,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() @@ -337,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" @@ -365,27 +353,27 @@ 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 consumed stream spans both rounds and closes with the COMPLETED state. + # 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 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()]) @@ -398,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())]) @@ -419,9 +406,9 @@ 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 [event["kind"] for event in events] == ["message"] assert any("negotiation-unhandled" in line for line in logs) @@ -442,9 +429,9 @@ 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"] + 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] @@ -458,7 +445,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,19 +522,16 @@ 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 == [] - assert _event_states(events)[-1] == "TASK_STATE_COMPLETED" + # 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 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] @@ -576,14 +560,15 @@ 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 == [] - assert _event_states(events)[-1] == "TASK_STATE_COMPLETED" + # 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 any(event["kind"] == "artifact" for event in events) + assert "TASK_STATE_COMPLETED" not in _event_states(events) @pytest.mark.parametrize( @@ -597,7 +582,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()] @@ -619,14 +604,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 +623,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 +647,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 +662,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 +678,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 +686,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 wire 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 +717,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 +743,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 @@ -799,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")) @@ -952,18 +917,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,12 +974,12 @@ 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")] - # The Task-T prompt refill remains the flow's wire mechanic fed by the caller. + 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" + 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: @@ -1040,15 +1001,15 @@ 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 == [ - "[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"] + 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: @@ -1070,12 +1031,12 @@ 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 wired in this demo; see the sample README)"] - assert _event_states(events) == ["TASK_STATE_SUBMITTED", "TASK_STATE_INPUT_REQUIRED"] + assert decision_logs == ["[client] negotiation-decision: abort (not implemented in this demo; see the sample README)"] + 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: @@ -1099,9 +1060,8 @@ 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 == [] 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 7ebc8ccc..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 @@ -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,19 +39,18 @@ SCENARIO_DEFAULT, SCENARIO_ENV_KEY, SCENARIO_NEGOTIATION, - SUPPORTED_CHANNELS, SUPPORTED_NEGOTIATION_KINDS, 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, - resolve_channel, + negotiation_target_intent_understanding, resolve_negotiation_kind, resolve_scenario, ) @@ -87,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) @@ -140,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"}, @@ -390,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( [ @@ -439,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( [ @@ -524,7 +485,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 +498,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: @@ -551,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": "任务目标"}, @@ -601,8 +561,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,7 +579,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 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"] == [ { @@ -631,12 +593,20 @@ def test_content_validation_variant_resources_carry_the_trigger_codes(language: assert feasibility["semantic_verdict"] is False assert feasibility["errors"] == [ { - "slot_name": "energySavingGoal", - "code": "content.semantic_conflict", - "facts": {"section_label": _VARIANT_SECTION_LABELS[language]["task_target"]}, + "slot_name": "guaranteedThroughputTarget", + "code": "content.rule_violation", + "facts": {"section_label": _VARIANT_SECTION_LABELS[language]["task_context"]}, } ] - assert feasibility["params"] == {**complete["params"], "energySavingGoal": 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"]) @@ -647,30 +617,35 @@ 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"] 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 @@ -743,9 +718,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) @@ -755,30 +729,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 @@ -847,11 +797,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 +817,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)) == 2 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..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 ( @@ -60,27 +68,22 @@ 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 from server.constants_data import ( - CHANNEL_DATA, - CHANNEL_ENV_KEY, - CHANNEL_TEXT, - 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, - resolve_negotiation_channel, ) from server.server_flow import ( _NEGOTIATION_TYPE_BY_ERROR_CODE, @@ -113,7 +116,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) @@ -153,7 +155,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]: @@ -168,7 +169,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, ) @@ -213,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( @@ -240,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( @@ -298,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) @@ -313,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] @@ -343,10 +351,10 @@ 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: +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 @@ -356,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] @@ -368,17 +376,20 @@ 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 - # from-data propose generation is deterministic: no negotiation extraction call is logged. + for name, _value in negotiation_target_clarifications(env_path=_offline_env): + assert name in propose_text + # 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, ] -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() @@ -390,79 +401,19 @@ 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] 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, - 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_confirm_request(env_path=_offline_env) 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_contents(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, @@ -470,59 +421,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: @@ -539,48 +443,46 @@ 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 -# --- 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: @@ -588,11 +490,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, ] @@ -836,7 +740,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 @@ -880,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, ) @@ -889,82 +792,14 @@ 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 -# --- channel resolution and 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 - - -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"] +# --- executor plumbing ------------------------------------------------------------------------ - # 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 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..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,22 +1,20 @@ """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 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 -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``). +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``). """ from __future__ import annotations @@ -61,13 +59,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 +74,6 @@ NegotiationResponseProvider, ) from client.scenario_data import ( - CHANNEL_DATA, - CHANNEL_TEXT, ENERGY_SAVING_TEMPLATE_URI, NEGOTIATION_KIND_TARGET, negotiation_feasibility_summary, @@ -128,7 +117,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 +150,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 @@ -178,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__( @@ -211,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) @@ -273,7 +259,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, @@ -309,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"), ] @@ -339,7 +328,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 +354,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 +399,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 +434,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( @@ -458,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"] @@ -474,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 @@ -508,26 +494,17 @@ def test_negotiation_webhook_round_trip_re_registers_and_completes( TASK_T_EXTENSION_URI, NEGOTIATION_T_EXTENSION_URI, } + assert a2a_client.register_calls == [] - # 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 - - # 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: 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" ] @@ -541,9 +518,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 +534,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 +552,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 +571,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,8 +581,8 @@ 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. - 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 @@ -630,12 +602,12 @@ 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] 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 +621,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] @@ -673,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( @@ -699,22 +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"] - # 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" - assert _event_states(events)[-1] == "TASK_STATE_COMPLETED" + # 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 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( @@ -728,14 +699,14 @@ 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 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"] + 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) ------------------------------------------------------------------- @@ -782,7 +753,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" @@ -795,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, @@ -805,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" ] @@ -823,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(), ] @@ -852,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: @@ -867,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 @@ -876,7 +853,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 +869,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).