@sema-agent/server 7.26.0 → 7.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,6 +46,7 @@ import { handleSessions, createSessionsLocal } from "./routes/sessions.js";
46
46
  import { handleSessionSync, createSessionSyncLocal } from "./routes/session-sync.js";
47
47
  import { handleAttachments } from "./routes/attachments.js";
48
48
  import { handleFleet } from "./routes/fleet.js";
49
+ import { handleA2aServe, handleA2a } from "./routes/a2a-serve.js";
49
50
  import { handleTraceUsage } from "./routes/trace-usage.js";
50
51
  import { handleWorkflows, handleWorkflowAgentSteer } from "./routes/workflows.js";
51
52
  import { handleNotifyWake, SESSION_WAKE_RE } from "./routes/notify-wake.js";
@@ -193,10 +194,12 @@ const ROUTE_DOMAINS = [
193
194
  handleTraceUsage,
194
195
  handleWorkflows,
195
196
  handleFleet,
197
+ handleA2aServe,
196
198
  handleCapabilities,
197
199
  handleSideQuery,
198
200
  handleTasks,
199
201
  handleRuns,
202
+ handleA2a,
200
203
  handleWorkflowAgentSteer,
201
204
  handleRunVerbs,
202
205
  handleLeader,
@@ -559,6 +562,12 @@ export function createHttpServer(rawDeps) {
559
562
  // design/158 A9 域模块:MF-Fleet 实时舰队流(routes/fleet.ts)。服务凭据门之前,按 principal 自门。
560
563
  if (await handleFleet(req, res, url, ctx))
561
564
  return;
565
+ // DESIGN-269 车2 件1 域模块:A2A agent card(routes/a2a-serve.ts)。
566
+ // ⚠️ 位置即契约:必须留在下面的全局 service-credential 门**之前** —— 卡是 R-11 裁定的「公网匿名
567
+ // 可发现」面(挂在门后=要凭据才能发现,A2A 协商第一步就断)。它**零存量披露**(纯 config 投影,
568
+ // 不读任何 task/run/session),所以匿名可读不构成披露;同域的 JSON-RPC 调用面在门**内**(下方)。
569
+ if (await handleA2aServe(req, res, url, ctx))
570
+ return;
562
571
  // Service-credential gate + SOURCE derivation: which system's token authenticated this request.
563
572
  // `source` is set ONLY here, from the credential — a caller cannot claim another system's identity.
564
573
  let source = null;
@@ -608,7 +617,7 @@ export function createHttpServer(rawDeps) {
608
617
  // resume-flavored assistant verbs + approvals responds — each starts a model turn) with 503 + Retry-After so
609
618
  // the shell/orchestrator retries against the replacement. Everything else stays open: reads (events/health/
610
619
  // trace), cancel, steer/respond on in-flight runs — those help the drained turns finish, they don't start new ones.
611
- if (deps.drainState?.draining && req.method === "POST" && isBillableSubmitPath(url)) {
620
+ if (deps.drainState?.draining && req.method === "POST" && isBillableSubmitPath(url) && !isMethodDispatchedSubmitPath(url)) {
612
621
  res.setHeader("retry-after", "15");
613
622
  // errorCode 补齐(2026-07-28 核查:cli 的 pre-stream drain 重试只能靠 `error` 文案字面判型——
614
623
  // SDK 的 code 臂恒 undefined 是死码;server 改文案=对端重试静默失效)。`error:"draining"` 字面
@@ -620,7 +629,7 @@ export function createHttpServer(rawDeps) {
620
629
  // 首次 effective pull 落 roster 之前 worker 只有占位模型——计费提交打占位=垃圾任务,503 让编排器/壳重试
621
630
  // 到 roster 落地(通常 <秒)。镜像 draining 姿势(同 billable 集合;读面/cancel/steer 全开);/health 带
622
631
  // ready:false 加性旗。env 模型在(操作员显式声明)或非 registry 部署=恒 ready,现有部署零影响。
623
- if (deps.modelReady && !deps.modelReady() && req.method === "POST" && isBillableSubmitPath(url)) {
632
+ if (deps.modelReady && !deps.modelReady() && req.method === "POST" && isBillableSubmitPath(url) && !isMethodDispatchedSubmitPath(url)) {
624
633
  res.setHeader("retry-after", "5");
625
634
  sendError(res, 503, "state.model_roster_pending", "model_roster_pending", { message: "this worker has no model yet (waiting for the first effective-config pull to land the roster) — retry shortly" });
626
635
  return;
@@ -650,6 +659,13 @@ export function createHttpServer(rawDeps) {
650
659
  // design/158 A9 域模块:异步 run 面 + run 级动词(routes/runs.ts)。与 tasks 域共用 ctx.legs.prepareSpec。
651
660
  if (await handleRuns(req, res, url, ctx))
652
661
  return;
662
+ // DESIGN-269 车2 件2 域模块:A2A JSON-RPC 端点 POST /v1/a2a(routes/a2a-serve.ts)。
663
+ // ⚠️ 位置即契约:服务凭据门**之后**(R-11「持凭据才可调」),且在 drain / model-roster-pending 两道
664
+ // 503 之后 —— `/v1/a2a` 在 isBillableSubmitPath 里(message/send 会烧模型)。排在 handleRuns 之后是
665
+ // 因为它**复用** runs 域导出的提交腿(createDurableRun):次序上先让原生 run 面各就各位,再挂它的
666
+ // 协议门面,评审读这张链时的因果方向才与实现一致。
667
+ if (await handleA2a(req, res, url, ctx))
668
+ return;
653
669
  // design/158 A9 域模块:SVC-5 workflow agent steer(routes/workflows.ts,与上面的只读三路同域)。
654
670
  if (await handleWorkflowAgentSteer(req, res, url, ctx))
655
671
  return;
@@ -708,9 +724,14 @@ export function createHttpServer(rawDeps) {
708
724
  function runnerFor(spec) {
709
725
  return deps.runnerFor?.(spec) ?? deps.runner;
710
726
  }
711
- /** Read + validate the body, authorize, and build the spec. Returns null if it already responded. */
712
- async function prepareSpec(req, res) {
713
- const body = (await readJson(req));
727
+ /** Read + validate the body, authorize, and build the spec. Returns null if it already responded.
728
+ *
729
+ * `bodyIn` (DESIGN-269 车2):调用方**已经**把体解析出来了(A2A 的 JSON-RPC 腿 —— 那条请求的正文是
730
+ * 一次 `message/send`,腿把它翻成 `TaskRequestBody` 再进这条链)。缺席 ⇒ 照旧 `readJson(req)`。
731
+ * 分歧只在**入口**:往下的每一条校验/授权/resolveSpec 都是同一段代码,这正是本参数的全部意义
732
+ * (给 A2A 抄一份校验 = 抄一份会漂的 400 语义)。 */
733
+ async function prepareSpec(req, res, bodyIn, onTypedFailure) {
734
+ const body = bodyIn ?? (await readJson(req));
714
735
  if (!body || typeof body.objective !== "string") {
715
736
  sendError(res, 400, "request.field_invalid", "missing 'objective' string");
716
737
  return null;
@@ -1312,6 +1333,15 @@ export function createHttpServer(rawDeps) {
1312
1333
  }
1313
1334
  catch (err) {
1314
1335
  if (err instanceof HttpError) {
1336
+ // 🔴 codex 轮2 R2-4(验真后修):`onTypedFailure` 在场 ⇒ **不写响应**,把 typed 失败交回调用方。
1337
+ // 为什么必须有这个口:本仓的 typed 拒绝会把结构化 `extra` 一起回显(`scenario_unknown` 的
1338
+ // extra 是**本部署的全部场景名**)。那对壳/SDK 是指路材料,对一个 **A2A 外部 peer** 却是内部
1339
+ // 词表泄漏 —— 而 DESIGN-269 车2 的整条 skill 声明制,存在的理由就是不把场景名给公网。
1340
+ // 加钩子而不是重构响应形:三个既有调用方一字不传 ⇒ 行为逐字节不变(仍是这一行 sendError)。
1341
+ if (onTypedFailure) {
1342
+ onTypedFailure(err);
1343
+ return null;
1344
+ }
1315
1345
  // Typed rejection: echo the stable `code` + any structured extras (e.g. the scenario
1316
1346
  // allowlist) beside the human message — machine code on the wire, prose stays with the shell/web.
1317
1347
  sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.extra ?? {}) });
@@ -3115,12 +3145,37 @@ function checkpointRowRedeemableElsewhere(reason) {
3115
3145
  * via resumeCheckpoint/store.decide → paid tokens). Keep this in sync when adding a billable POST route. */
3116
3146
  // [#104/#87-A3] exported for the declaration gate (test/billable-route-declaration.test.ts):每条路由
3117
3147
  // 标签必须显式申明 billable,与本谓词逐样本对账——「Keep this in sync」从注释请求变成机器断言。
3148
+ /**
3149
+ * 「这条 url **是**计费提交面,但它到底提不提交由**请求体里的方法**决定」—— 目前只有 A2A 的
3150
+ * JSON-RPC 端点(单 URL 承载 `message/send` 写 + `tasks/get` 读,因为 agent card 只能公告一个 `url`)。
3151
+ *
3152
+ * 用途:两道**可用性** 503(drain / model-roster-pending)按 url 一刀切会把 `tasks/get` 这条纯读一起
3153
+ * 关掉,而单副本部署恰好在重启/依赖降级窗口里最需要取回已完成的结果(codex 轮2 R2-3,验真后修)。
3154
+ * 本谓词让 `handle()` 对这条 url **跳过**那两道,改由域模块在 `message/send` 分支里施加同样的两道
3155
+ * (`routes/a2a-serve.ts` 的 `submitUnavailable`,文案逐字同源)。
3156
+ *
3157
+ * 🔴 **只豁免那两道,不豁免安全轴**:`isBillableSubmitPath` 本身**不变**(它仍答 true),所以
3158
+ * ① 无 service token 的部署上那道 fail-closed 503 照旧罩着这条 url —— 它判的是「这台机能不能收写」,
3159
+ * 与方法无关,而且门必须在解析请求体**之前**;
3160
+ * ② billable 申明门钉的仍是那只纯谓词(表里 `/v1/a2a` 依旧 billable=true)。
3161
+ * 新成员进本表 = 承诺「该域在自己的方法分支里补齐那两道」,漏补就是一条静默旁路;所以本表只该有
3162
+ * 「单 URL 多方法」这一种成员,加一条要连同它的域内补门一起过评审。
3163
+ */
3164
+ export function isMethodDispatchedSubmitPath(url) {
3165
+ return url === "/v1/a2a";
3166
+ }
3118
3167
  export function isBillableSubmitPath(url) {
3119
3168
  return (url === "/v1/side-query" || // [1469] one-shot brain call — runs the model, so it rides every billable-submit gate
3120
3169
  url === "/v1/tasks" ||
3121
3170
  url === "/v1/tasks/stream" ||
3122
3171
  url === "/v1/runs" ||
3123
3172
  url === "/v1/leader" ||
3173
+ // DESIGN-269 车2:A2A JSON-RPC 端点。`message/send` 走的是与 /v1/runs **同一条**提交腿 ⇒ 它烧模型
3174
+ // ⇒ 三道 503 门(drain / model-roster-pending / 无 service-token)必须对它生效,否则一台正在下线的
3175
+ // 副本会照收外部 peer 的新任务(wake 当年漏在名单外犯的就是这一条)。
3176
+ // ⚠️ 端点是**单 URL** 的(A2A 卡只公告一个 url),所以同一道门也会在排空期挡下 `tasks/get` 的读。
3177
+ // 方向取「宁紧」:开新模型工作不可回滚,而读面有现成补偿(行是 durable 的,换一台副本读同一条)。
3178
+ url === "/v1/a2a" ||
3124
3179
  ASSISTANT_RESUME_RE.test(url) || // resource_limit/preempt resume runs the model → BILLABLE (preempt is not)
3125
3180
  ASSISTANT_PLAN_REVIEW_RE.test(url) || // plan_review approve/edit resumes + runs the model → BILLABLE
3126
3181
  RUN_SUBAGENT_RESUME_RE.test(url) || // 对抗复查 B-2(HIGH):subagent revive (handle.resume) STARTS model work → BILLABLE
@@ -3189,7 +3244,7 @@ const ROUTE_LABEL_PATTERNS = [
3189
3244
  [/^\/v1\/images\/[^/]+$/, "/v1/images/:profile"],
3190
3245
  [/^\/v1\/approvals\/[^/]+\/decide$/, "/v1/approvals/:id/decide"],
3191
3246
  [/^\/v1\/approvals\/[^/]+\/exemptions(\/[^/]+)?$/, "/v1/approvals/:id/exemptions"],
3192
- [/^\/v1\/sessions\/[^/]+\/(wake|fork|init|notify|events|head|mcp|policy|settings)$/, "/v1/sessions/:id/$verb"],
3247
+ [/^\/v1\/sessions\/[^/]+\/(wake|fork|init|notify|events|head|mcp|a2a|policy|settings)$/, "/v1/sessions/:id/$verb"],
3193
3248
  [/^\/v1\/sessions\/[^/]+\/sync\/(manifest|entries|plan)$/, "/v1/sessions/:id/sync/$verb"],
3194
3249
  [/^\/v1\/sessions\/[^/]+\/sync\/import(\/[^/]+\/entries)?$/, "/v1/sessions/:id/sync/import"],
3195
3250
  [/^\/v1\/sessions\/[^/]+\/sync\/blobs(\/[^/]+)?$/, "/v1/sessions/:id/sync/blobs"],
@@ -3248,6 +3303,10 @@ const ROUTE_LABEL_LITERALS = new Set([
3248
3303
  // #203 §2:撤销面两口(GET 列举 / DELETE 撤销)共用一个字面路径。同样写字面量而不是引 `RULES_PATH`
3249
3304
  // 常量,理由与上一条逐字相同(名册门与 billable 申明门都扫源码文本)。
3250
3305
  "/v1/rules",
3306
+ // DESIGN-269 车2:server-as-peer 两口。卡是**公网匿名**面(唯一一条不吃服务凭据的 2xx 路由),
3307
+ // 它的 QPS 与 duration 是判断「有没有人在扫我们」的第一手材料;RPC 口是外部 peer 的提交面。
3308
+ // 两条都落进 `other` 桶 = 对外暴露面在 metrics 上不可分。同上,写**字面量**不引常量(两道门扫源码文本)。
3309
+ "/.well-known/agent-card.json", "/v1/a2a",
3251
3310
  ]);
3252
3311
  /** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`).
3253
3312
  * [#104] 字面量**先于**模式:此前模式先查,五条精确路由被形状桶吞掉(`/v1/approvals/stream`
@@ -6,7 +6,7 @@
6
6
  * cut: ONLY TaskRequestBody (its field types are inline or core imports — no other server.ts-local satellites);
7
7
  * sibling request/response shapes can migrate here later on the same pattern.
8
8
  */
9
- import type { AgentDefinition, McpServerSpec } from "@sema-agent/core";
9
+ import type { A2aServerSpec, AgentDefinition, McpServerSpec } from "@sema-agent/core";
10
10
  export interface TaskRequestBody {
11
11
  objective: string;
12
12
  sessionId?: string;
@@ -336,6 +336,17 @@ export interface TaskRequestBody {
336
336
  * worker, not the exec env); IGNORED on multi-tenant deployments (can't make the shared worker run an arbitrary
337
337
  * command). Validated + gated downstream; the center/config baseline wins on a name clash. */
338
338
  mcpServers?: McpServerSpec[];
339
+ /** [DESIGN-269 车1] Caller-supplied per-request A2A peers (remote agents this task may call), aligned to
340
+ * core `A2aServerSpec`. 🔒 honored on a SINGLE-USER deployment that has neither locked the `a2a` key nor
341
+ * had `a2a_peers` denied by its compliance posture (task-a2a.ts `a2aInjectionHonored`, the same three-veto
342
+ * predicate `capabilities.a2aInjection` advertises) — the requester is the super-admin of their OWN worker,
343
+ * on ANY execution lane (an A2A call is a plain outbound HTTPS request from the worker, so the lane where
344
+ * tool calls run is irrelevant); IGNORED on multi-tenant (a tenant can't point the shared worker at an
345
+ * agent of their choosing). A locked deployment refuses the whole request instead: 400 `config.locked_key`.
346
+ * 🔴 Every skill a peer advertises mounts `egress:true` + `effect:"write"` (core's ruling) — declaring a
347
+ * peer is declaring an outbound WRITE channel, not a data source. Validated + gated downstream; the
348
+ * center/config baseline wins on a name clash (the peer name IS the tool namespace). */
349
+ a2aPeers?: A2aServerSpec[];
339
350
  /** CC parity (Workflow super-set): per-task activation of the LLM-authored workflow engine (core `run_workflow`).
340
351
  * `true` → `spec.selfOrchestration` (the engine mounts run_workflow), gated on the deployment's `selfOrchestrationEnabled`
341
352
  * (else a harmless no-op) + core's `allowWorkflows` per-principal entitlement on multi-tenant (single-user honors
package/dist/main.js CHANGED
@@ -614,6 +614,23 @@ async function main() {
614
614
  // 同源,恒有 durableEnabled === !!checkpointStore。
615
615
  if (durableEnabled)
616
616
  logger.info("durable_approval_enabled", { toolApprovalGate: config.approvalRequire.length });
617
+ // DESIGN-269 车2 件3 —— server-as-peer 上场的**感知链路**(#266 memory_persistence_capable_auto_declared
618
+ // 同族纪律:一条对外暴露面上了线,必须在 boot 日志里一眼可核)。这行是 `A2A_SERVE_ENABLED=true` **唯一**
619
+ // 的启动期回声:运维据此确认「这台机现在对外是一个 A2A agent、卡在这个路径上、公告的是这个 URL、
620
+ // 声明了几条 skill」。OFF(默认)⇒ 一个字都不发,零噪声。
621
+ // ⚠️ 半配置的诚实:开了旋钮却没有 durable run store 时,两个 RPC 方法会回具名 -32004 —— 那不是静默
622
+ // 降级,但它发生在**调用时**,而运维想在启动时就知道。所以这一行连带把 runStore 在不在场说出来。
623
+ if (config.a2aServe) {
624
+ logger.info("a2a_serve_enabled", {
625
+ card: "/.well-known/agent-card.json",
626
+ rpc: "/v1/a2a",
627
+ advertisedUrl: config.a2aServe.url,
628
+ skills: config.a2aServe.skills.length,
629
+ blockingWaitMs: config.a2aServe.blockingWaitMs,
630
+ // false = 卡照发(发现面不依赖 store),但 message/send 与 tasks/get 会回 -32004 UnsupportedOperation。
631
+ tasksUsable: runStore !== undefined,
632
+ });
633
+ }
617
634
  // 修4(三路复审 W3):the fs-write ask gate (resolveSpec, applyTaskSettings wiring below) mounts purely off the
618
635
  // host-semantics lane — DELIBERATELY decoupled from any approval face, so no assembly path is ever gateless:
619
636
  // with no live coordinator and no durable park, a permissionMode default/auto Write/Edit ask fail-closed
@@ -274,6 +274,17 @@ export function createMetrics() {
274
274
  // NOT need the fire-and-forget consolidation() to be awaited.
275
275
  m.counter("memory_consolidation_ops_total", "Memory consolidation store ops (1.62 B-full), by op (update/delete)");
276
276
  m.counter("mcp_server_unavailable_total", "MCP servers skipped fail-open at task start (1.68), unreachable/misconfigured");
277
+ // DESIGN-269 车1:A2A peer 在任务起手不可用(卡取不到 / skill 铸不出)—— 与 mcp 同族的 fail-open 计数。
278
+ m.counter("a2a_peer_unavailable_total", "A2A peers skipped at task start (DESIGN-269 client leg): card unreachable / spec unusable");
279
+ // DESIGN-269 车2:server-as-peer 的**对外**面。两只计数器刻意分开,它们回答两个不同的问题:
280
+ // · `a2a_serve_rpc_total{method,outcome}` = 每个方法各被调了多少次、结果是什么(ok / refused /
281
+ // error_<code> / method_not_found / internal_error)。`method_not_found` 那一格是**路线图信号**:
282
+ // 外部 peer 在真实拓扑里到底在要哪个还没做的方法(message/stream?tasks/cancel?),比我们猜准。
283
+ // · `a2a_serve_rejected_total{reason}` = **没能进到方法层**的那些(凭据/形/版本以外的门),
284
+ // 它是「有人在扫我们 / 有人接错了」的第一手材料。混进上面那只会让「协议用错」与「跑失败了」
285
+ // 在同一条曲线上不可分。
286
+ m.counter("a2a_serve_rpc_total", "A2A server-as-peer JSON-RPC calls by method and outcome (DESIGN-269 车2)");
287
+ m.counter("a2a_serve_rejected_total", "A2A server-as-peer requests refused before reaching a method handler, by reason (DESIGN-269 车2)");
277
288
  // Remote execution env (design/48 E2B adapter): lifecycle outcomes by provider/result.
278
289
  m.counter("remote_env_connect_total", "E2B remote-env connect outcomes (provider/result)");
279
290
  m.counter("remote_env_destroy_total", "E2B remote-env destroy outcomes (provider/result)");
package/dist/run-local.js CHANGED
@@ -59,7 +59,7 @@ import { applyCatalogToSource } from "./capabilities/center-prompts.js";
59
59
  import { validatePromptsDomain, CORE_ENGINE_VERSION } from "./prompts-domain-validate.js";
60
60
  import { loadConfig, logConfigDiagnostics, numEnv } from "./config.js";
61
61
  import { createConfigProvider } from "./config-provider.js";
62
- import { applyEffective, resolveMcpServers, mcpForScenario } from "./config-center/facade.js";
62
+ import { applyEffective, resolveMcpServers, mcpForScenario, resolveA2aPeers, a2aForScenario } from "./config-center/facade.js";
63
63
  import { hostExecutionEnvFactory } from "./plugins/remote-env-host.js";
64
64
  import { makeLoadProjectMemory, makeProbeInstructionSources } from "./project-memory.js";
65
65
  import { loadSkills } from "./capabilities/skills.js";
@@ -339,6 +339,14 @@ export async function runLocal(argv, deps = {}) {
339
339
  // covers models/roles/gates/teams) so run-local matches the server path (main.ts:446). Restart-to-apply.
340
340
  if (effective?.mcp)
341
341
  config.mcpServers = resolveMcpServers(effective.mcp, logger);
342
+ // DESIGN-269 车1 件1:A2A peers, same line as MCP — applied HERE so run-local matches the server path
343
+ // (boot/config-center.ts). Restart-to-apply.
344
+ // ⚠️ 如实记(codex 对抗复审 finding 4,验真后采纳为**文档更正**):今天只有**远端 center** 的
345
+ // `/effective` 里带 `a2a` 域时这一行才写得进值。**本地 config.d 车道还接不上** —— registry-core
346
+ // 锁定版的可移植域表里没有 a2a,`config-provider.ts` 的 `mapToServiceEffective` 闭式投影也就没有这
347
+ // 一域可投。补齐属于 registry-core(上游件),不在本车射程;在那之前别在文档里承诺 config.d/a2a.json。
348
+ if (effective?.a2a)
349
+ config.a2aPeers = resolveA2aPeers(effective.a2a, logger);
342
350
  logger.info("config_loaded", { source: configProvider.kind, version: r.effective.version });
343
351
  }
344
352
  }
@@ -724,6 +732,10 @@ export async function runLocal(argv, deps = {}) {
724
732
  const taskTimeoutSec = Math.max(0, Math.floor(numEnv("TASK_TIMEOUT_SEC", "0")));
725
733
  const timeoutSec = taskWallClockSec(taskTimeoutSec, false, scenarioName === "team");
726
734
  const mcp = mcpForScenario(config.mcpServers, scenarioName);
735
+ // DESIGN-269 车1 件3(本腿半场):A2A peers 同样按场景过滤进 spec。**只有 center/config 基线这一半**
736
+ // —— run-local 无 HTTP body,所以没有「调用方自带 peer」那条腿(与 [854]④ 的 body.limits 同姿势:
737
+ // 要 CLI flag 时另开件,别在这里半做)。缺席仍是缺席:`a2aForScenario` 恒返回 undefined 而非 []。
738
+ const a2a = a2aForScenario(config.a2aPeers, scenarioName);
727
739
  // A-002.9(#180 governance 缺口的同文件兄弟残余):运营方预算天花板两枚 —— 与 server 主路径
728
740
  // (boot/resolve-spec.ts)**同键同算法**,共用 budget.ts 的 `cappedCeiling`。此腿无 body(见上一段
729
741
  // 的 [854]④ 记账),所以 requested 恒缺席 ⇒ env 天花板在场时**直接成为** spec 值,天花板为 0/未设时
@@ -765,6 +777,7 @@ export async function runLocal(argv, deps = {}) {
765
777
  // [849]→[2400] 场景层定死终验已无生产者(autonomous 退役);OR 折入形保留,与 resolveSpec 同语义。
766
778
  ...(cap.finalVerification === true ? { finalVerification: true } : {}),
767
779
  ...(mcp ? { mcp } : {}),
780
+ ...(a2a ? { a2a } : {}),
768
781
  // core 5.8.0:timeoutSec 键退役 → maxWalltimeMs(毫秒);taskWallClockSec 仍产秒,换算在上方 limits 合成处。
769
782
  ...(Object.keys(limits).length > 0 ? { limits } : {}),
770
783
  }, config, workspaceDir);
@@ -0,0 +1,114 @@
1
+ /**
2
+ * [DESIGN-269 车1 §2.2/§2.3 — A2A client seam] — project a caller's per-request A2A peers
3
+ * (`TaskRequest.a2aPeers`) into the engine, so a `sema` run can talk to the agents the USER declares,
4
+ * not only the ones the deployment's config center declares. Point-for-point mirror of `task-mcp.ts`
5
+ * (the MCP seam), because the trust question is the same question — and deliberately NOT a copy where
6
+ * the two protocols differ (see 「A2A 比 MCP 窄」 below).
7
+ *
8
+ * 🔒 SECURITY — an A2A peer is a remote **AGENT**, not a data source: it acts on its own side. core ruled
9
+ * (2026-08-03, `A2aServerSpec` doc) that every skill such a peer advertises mounts with `egress: true` +
10
+ * `effect: "write"`, i.e.接入即引入对外写通道 — it registers the approval gate, is never auto-allowed on a
11
+ * no-policy deployment, and its replies reach the model inside an untrusted-data fence. The trust boundary
12
+ * is therefore the SAME one the MCP seam draws, for the same reason:
13
+ * - SINGLE-USER deployment (`requirePrincipal !== false`… i.e. `!== true`): the requester IS the
14
+ * super-admin of their own worker, so the peers they name are their own choice (CC-parity), on EVERY
15
+ * execution lane — an A2A call is a plain outbound HTTPS request made by the worker, so the lane where
16
+ * the agent's TOOL CALLS run is irrelevant (exactly the fact that decoupled the MCP gate from the lane).
17
+ * - MULTI-TENANT deployment (`requirePrincipal === true`): a tenant must NOT make the SHARED worker POST
18
+ * to a body-chosen URL (SSRF) carrying body-chosen headers, nor mount write-capable tools from an agent
19
+ * the operator never vetted. Gate CLOSED → body peers ignored; the fleet gets peers only through center
20
+ * config (`config.a2aPeers`).
21
+ * When the gate is closed a sent `a2aPeers` is IGNORED — and SAID (`a2a_injection_ignored` + the
22
+ * `capabilities.a2aInjection` advertisement), never silently swallowed.
23
+ *
24
+ * ⚠️ **A2A 的轴比 MCP 窄,这不是简化而是协议事实**:A2A has no per-skill annotation vocabulary, so there is
25
+ * no server-hint leg to fold — `toolAxes` (the CALLER's own judgement) is the ONLY thing that can move an axis
26
+ * off the fail-closed default. core says as much on `A2aToolAxis`: `irreversibility:"never"` / `egress:false`
27
+ * 「appears ONLY via an explicit caller override」, enforcement ignores the loosening (tighten-only) and the
28
+ * ask's risk-axes REPORT face consumes it. So this module carries the caller's overrides through verbatim —
29
+ * they are a judgement, not silence — while knowing that core will not let them widen enforcement.
30
+ *
31
+ * ⚠️ **两票判据的词表在 core 5.36 尚未到货**(分单件 C-1/C-2,core 已认领排 5.37 后首个 A2A 窗):
32
+ * `LockedKey` 是 `"mcp"|"toolPolicy"|"compliancePosture"|"retentionPolicy"`,`ComplianceCapability` 是
33
+ * `"mcp_servers"|"workflows"|"web_fetch"|"org_memory_mount"` —— 两张表里都还没有 A2A 的位。本模块因此按
34
+ * **字符串**判(server config 的 `lockedConfigKeys` 本就是 server 自己的声明面,不经 core 闭集型),
35
+ * 判据一到货就换 core 真源、钉子逐字不用改。
36
+ * 🔴 **今天这两票在 env 通道上还打不响**,而这是刻意如实记下的、不是被忽略的缺口:`LOCKED_CONFIG_KEYS`
37
+ * 经 core 的 `resolveLockedKeys` 校验(未知键拒启)、`COMPLIANCE_ADDITIONAL_DENIES` 经 `COMPLIANCE_CAPABILITIES`
38
+ * 校验(同拒),所以运维今天**写不进** `a2a` / `a2a_peers`。而且合规那一票还有第二层:即便有人绕过 boot 期
39
+ * 校验把 `a2a_peers` 塞进档位,`resolveComplianceDenies` 对闭集外的词是**抛**(亲测 core 5.36),于是这条
40
+ * 请求腿的失败方向是**响亮**(500)而不是安静放行 —— 方向正确,故本模块不加任何兜底。两票的代码先在场是
41
+ * 纵深:core 词表到货那一拍这条腿自己就活了,不需要有人记得回来补;反过来(等词表到了再写判据)才是
42
+ * 「广告了却拦不住」的那一类缺口。钉在 `test/task-a2a.test.ts`(含到货后该怎么改那一格的原地说明)。
43
+ */
44
+ import { type A2aServerSpec, type CompliancePosture } from "@sema-agent/core";
45
+ /** Bound on caller-supplied peers (anti-DoS; a real declaration names a handful of agents). */
46
+ export declare const MAX_REQUEST_A2A_PEERS = 32;
47
+ /** The deployment-facing shape this module reads. `lockedConfigKeys` is deliberately `readonly string[]`
48
+ * and not core's `LockedKey[]`: the `a2a` member does not exist in core's union yet (see module header),
49
+ * and a predicate that cannot even NAME the key it guards is a predicate that silently guards nothing. */
50
+ export interface A2aGateConfig {
51
+ requirePrincipal?: boolean;
52
+ lockedConfigKeys?: readonly string[];
53
+ compliancePosture?: CompliancePosture;
54
+ }
55
+ /**
56
+ * Does THIS deployment honor caller-supplied per-request A2A peers? Three vetoes, ALL of them here (this
57
+ * predicate is the single owner — the capability bit and the request leg both read it, so there is exactly
58
+ * one place where the answer can be wrong):
59
+ * ① the deployment locks the `a2a` key — a locked deployment that advertised `a2aInjection:true` would
60
+ * hand every consumer an affordance whose every use 400s (`config.locked_key`); "says yes ⟺ route
61
+ * works" breaks on the spot. Same conjunction, same reason, as `mcpInjectionHonored`'s lock veto;
62
+ * ② the compliance posture denies `a2a_peers` — advertised-but-refused again;
63
+ * ③ multi-tenant (`requirePrincipal === true`) — a tenant cannot point the shared worker at an agent.
64
+ *
65
+ * ⚠️ **`resolveComplianceDenies` is deliberately NOT wrapped in try/catch** (same ruling as the MCP twin):
66
+ * it is fail-loud on a posture outside the closed set, and the posture has already been validated twice
67
+ * before reaching here (config parse + boot assembly). If it ever throws here, someone bypassed both doors
68
+ * and the LOUD direction (route 500) is the correct one — swallowing it into a boolean would let a
69
+ * deployment with a broken posture keep advertising the capability (仓规:安全轴禁静默兜底).
70
+ */
71
+ export declare function a2aInjectionHonored(config: A2aGateConfig): boolean;
72
+ /**
73
+ * Shape-check the raw `body.a2aPeers`. `null` → a non-array (caller error; the route may 400). Otherwise the
74
+ * valid subset + the names of dropped (malformed/over-cap) entries.
75
+ */
76
+ export declare function validateRequestA2a(raw: unknown): {
77
+ ok: A2aServerSpec[];
78
+ dropped: string[];
79
+ } | null;
80
+ /**
81
+ * DESIGN-269 §2.4 —— **同步**拒面:部署锁了 `a2a` 而请求仍带 `a2aPeers` ⇒ 400 `config.locked_key`
82
+ * (逐字沿用 `assertRequestMcpUnlocked` 的裁定,连错误码都同一个 —— 消费端判的是「某个键被行政锁住了」,
83
+ * 不是「哪个键」;码分裂只会让 SDK 多写一条等价分支)。
84
+ *
85
+ * 判据是「请求**占位**」而不是「请求的值合不合法」:锁是两态的,占了就整拒,不静默丢(静默丢正是把
86
+ * 判决变成探针的那一形)。键缺席 / 显式 `null` 都不是占位;**空数组是占位**(调用方确实写了这个键)。
87
+ *
88
+ * ⚠️ **不看车道**:多租户腿本来就会忽略 body peers(`a2aInjectionHonored` 关),但锁在场时「忽略」是
89
+ * 错的失败方向 —— operator 声明了「本部署不收任务自带 A2A peer」,那就该说出来。
90
+ *
91
+ * ⚠️ 参数类型是 `ReadonlySet<string>`:core 的 `LockedKey` 尚无 `a2a` 成员(见模块头),而
92
+ * `ReadonlySet<LockedKey>` 结构上可当 `ReadonlySet<string>` 传入,所以调用点无需任何转换。
93
+ */
94
+ export declare function assertRequestA2aUnlocked(bodyA2a: unknown, lockedKeys: ReadonlySet<string>): void;
95
+ /**
96
+ * Merge GATED per-request peers OVER the deployment baseline (center/config). The baseline WINS on a name
97
+ * clash — a caller can ADD a peer but can never SHADOW a configured one. This is load-bearing on THIS lane
98
+ * specifically: the peer name is the tool-namespace segment (`a2a__<peer>__<skill>`), so shadowing would let
99
+ * a caller keep the tool NAMES the model was told about while swapping the agent behind them.
100
+ * Returns the baseline unchanged when there is nothing to add.
101
+ */
102
+ export declare function mergeRequestA2a(baseline: A2aServerSpec[] | undefined, gated: A2aServerSpec[]): A2aServerSpec[] | undefined;
103
+ /**
104
+ * The single entry point the spec-builder calls: compute the effective `TaskSpec.a2a` = deployment baseline
105
+ * (`a2aForScenario(config.a2aPeers, scenario)`) + the caller's gated per-request peers. Off the honored lane
106
+ * (or with no caller peers) returns the baseline unchanged. Logs `a2a_injection_ignored` when a caller SENT
107
+ * peers a closed gate dropped, so a shell never silently believes its declaration took effect (it also reads
108
+ * `capabilities.a2aInjection`).
109
+ */
110
+ export declare function resolveRequestA2a(baseline: A2aServerSpec[] | undefined, bodyA2a: unknown, config: A2aGateConfig, logger?: {
111
+ info?(m: string, meta?: unknown): void;
112
+ warn(m: string, meta?: unknown): void;
113
+ }): A2aServerSpec[] | undefined;
114
+ //# sourceMappingURL=task-a2a.d.ts.map