@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.
- package/USAGE.md +29 -0
- package/dist/boot/config-center.js +5 -1
- package/dist/boot/resolve-spec.js +13 -1
- package/dist/boot/runner-deps.js +10 -0
- package/dist/config-center/facade.d.ts +2 -2
- package/dist/config-center/facade.js +1 -1
- package/dist/config-center/restart-signal.d.ts +1 -1
- package/dist/config-center/restart-signal.js +6 -1
- package/dist/config-center/skills-mcp.d.ts +28 -3
- package/dist/config-center/skills-mcp.js +64 -6
- package/dist/config-center/types.d.ts +28 -0
- package/dist/config-types.d.ts +72 -3
- package/dist/config.d.ts +2 -1
- package/dist/config.js +109 -3
- package/dist/http/route-ctx.d.ts +12 -2
- package/dist/http/routes/a2a-serve.d.ts +75 -0
- package/dist/http/routes/a2a-serve.js +710 -0
- package/dist/http/routes/capabilities.js +20 -0
- package/dist/http/routes/fleet.js +6 -1
- package/dist/http/routes/runs.d.ts +27 -1
- package/dist/http/routes/runs.js +163 -120
- package/dist/http/routes/sessions.d.ts +11 -0
- package/dist/http/routes/sessions.js +100 -3
- package/dist/http/server.d.ts +17 -0
- package/dist/http/server.js +65 -6
- package/dist/http/wire-types.d.ts +12 -1
- package/dist/main.js +17 -0
- package/dist/observability/metrics.js +11 -0
- package/dist/run-local.js +14 -1
- package/dist/task-a2a.d.ts +114 -0
- package/dist/task-a2a.js +245 -0
- package/package.json +1 -1
package/dist/http/server.js
CHANGED
|
@@ -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
|
-
|
|
713
|
-
|
|
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
|