@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/USAGE.md
CHANGED
|
@@ -383,6 +383,35 @@ MCP_ELICITATION_TTL_MS=300000 # 一张没人填的表单挂多久后释放(
|
|
|
383
383
|
- 总开关关着时四个节流钮解析照跑但无消费者——不设=行为不变。帧格式见
|
|
384
384
|
[`docs/ASSISTANT-WIRE-CONTRACT.md` §4a-quater](docs/ASSISTANT-WIRE-CONTRACT.md)(`elicitation` / `elicitation_complete`)。
|
|
385
385
|
|
|
386
|
+
**可选 — 把这台 worker 挂进别人的 A2A 拓扑(server-as-peer,DESIGN-269 车2):默认关**
|
|
387
|
+
|
|
388
|
+
别家 orchestrator 把这台 sema-server 当成它拓扑里的一个 **A2A agent** 来调。开了才有两条对外路由;
|
|
389
|
+
关着(默认)它们**都不存在**(404)——不是「发卡但拒调」。
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
A2A_SERVE_ENABLED=true # 总开关,默认 **false**(operator-knob 律;坏词拒启)
|
|
393
|
+
A2A_SERVE_URL=https://agent.example.com/v1/a2a # 【开了就必填】卡上公告的对外 JSON-RPC 端点
|
|
394
|
+
A2A_SERVE_NAME=sema-edge # 卡上的 name(默认 "sema-server")
|
|
395
|
+
A2A_SERVE_DESCRIPTION="…" # 可选
|
|
396
|
+
A2A_SERVE_SKILLS='[{"id":"review","name":"Code review","description":"…","scenario":"code-review"}]'
|
|
397
|
+
A2A_SERVE_BLOCKING_WAIT_MS=45000 # blocking:true 的服务端等待窗(默认 45000,有界 [0,300000])
|
|
398
|
+
```
|
|
399
|
+
- **`A2A_SERVE_URL` 开了就必填,且必须是可解析的 http(s) URL** —— 缺席/坏形**启动期拒**。可达地址是
|
|
400
|
+
运维声明的,服务端**不从请求 `Host` 头推导**:那等于让一个匿名的发现请求决定我们公网卡上公告的地址。
|
|
401
|
+
(同族先例:`FLEET_ADVERTISE_ADDRESS`。)🔒 URL **不许带 userinfo**(`https://user:pass@host/…`)——
|
|
402
|
+
它逐字上匿名可拉的卡,等于把凭据发给每个发现请求;带了**拒启**。入站鉴权走**头形**(服务凭据)。
|
|
403
|
+
- **`A2A_SERVE_SKILLS` = JSON 数组,坏形拒启**(非 JSON / 非数组 / 条目缺 `id` 或 `scenario` / `id` 重复
|
|
404
|
+
/ 超长 / 超过 64 条)。声明面**没有**「丢掉坏的那条继续跑」的合法语义:那会让你以为公告了一个技能而
|
|
405
|
+
对端根本看不到。每条 skill 的 `scenario` 是**内部**路由键 —— 它**不上卡**(自动枚举场景名给公网是
|
|
406
|
+
本设计唯一点名禁止的事),只决定点名这条 skill 的请求跑哪个场景。
|
|
407
|
+
- 两条路由的**鉴权位置刻意不同**:`GET /.well-known/agent-card.json` 在服务凭据门**外**(公网匿名可
|
|
408
|
+
发现,零存量披露);`POST /v1/a2a` 在门**内**(与本仓其余 API 同一套凭据)。
|
|
409
|
+
- `message/send` / `tasks/get` 还需要 **durable run store**(`DB_BACKEND=mysql|pg`)——缺它时两个方法
|
|
410
|
+
回具名 JSON-RPC `-32004`(卡照发)。开关打开时 boot 日志有一行 `a2a_serve_enabled`,里面的
|
|
411
|
+
`tasksUsable` 就是这件事。
|
|
412
|
+
- 完整 wire 契约(方法表、错误码全表、Task 形与任务态投影、反枚举语义、版本槽)见
|
|
413
|
+
[`docs/ASSISTANT-WIRE-CONTRACT.md` 附录 C](docs/ASSISTANT-WIRE-CONTRACT.md)。
|
|
414
|
+
|
|
386
415
|
**可选 — 父进程存活监视(`SEMA_PARENT_PID`,默认不装配)**
|
|
387
416
|
|
|
388
417
|
壳(cli/TUI/桌面)自己 spawn 引擎的**同机形**里,壳被 `SIGKILL`(崩溃、`kill -9`、OOM killer)之后没有
|
|
@@ -46,7 +46,7 @@ import { defaultLkgPath, defaultSkillCacheDir, saveLkg, loadLkg } from "../confi
|
|
|
46
46
|
import { createConfigProvider, raceBootFetch, BOOT_FETCH_DEFERRED } from "../config-provider.js";
|
|
47
47
|
import { createKeyResolver } from "../key-resolver.js";
|
|
48
48
|
import { ensureSealedKeyStore, reportExecutionPublicKey } from "../sealed-key.js";
|
|
49
|
-
import { applyEffective, mutateInPlace, logEffectiveDiff, applyCenterSkills, resolveMcpServers, restartReasons, modelPlaneChanged, planeHasActiveTiers, fetchPromptArtifact } from "../config-center/facade.js";
|
|
49
|
+
import { applyEffective, mutateInPlace, logEffectiveDiff, applyCenterSkills, resolveMcpServers, resolveA2aPeers, restartReasons, modelPlaneChanged, planeHasActiveTiers, fetchPromptArtifact } from "../config-center/facade.js";
|
|
50
50
|
import { SHA256_HEX_RE } from "../digest-form.js";
|
|
51
51
|
export async function createConfigCenterRuntime(ctx) {
|
|
52
52
|
const { config, logger, metrics, localRoot } = ctx;
|
|
@@ -633,6 +633,10 @@ export async function createConfigCenterRuntime(ctx) {
|
|
|
633
633
|
}
|
|
634
634
|
if (effective?.mcp)
|
|
635
635
|
config.mcpServers = resolveMcpServers(effective.mcp, logger);
|
|
636
|
+
// DESIGN-269 车1 件1:A2A peers ride the SAME restart-to-apply line as MCP (one center pull, one
|
|
637
|
+
// resolution, no hot swap). Absent `a2a` domain ⇒ the key is never written ⇒ the leg does not exist.
|
|
638
|
+
if (effective?.a2a)
|
|
639
|
+
config.a2aPeers = resolveA2aPeers(effective.a2a, logger);
|
|
636
640
|
return skills;
|
|
637
641
|
},
|
|
638
642
|
applyCenterScenarios(t) {
|
|
@@ -43,9 +43,10 @@ import { gateExecutionLane } from "../runtime-caps-resolver.js";
|
|
|
43
43
|
import { applyRuntimeGovernance, stripDelegationTools } from "../runtime-governance.js";
|
|
44
44
|
import { HttpError, encodeCheckpointScope } from "../security.js";
|
|
45
45
|
import { memorySpecForRequest } from "../memory-scope.js";
|
|
46
|
-
import { mcpForScenario } from "../config-center/facade.js";
|
|
46
|
+
import { a2aForScenario, mcpForScenario } from "../config-center/facade.js";
|
|
47
47
|
import { normalizeAttachments, normalizeResilience, normalizeResumeAtMode, normalizeSuggestNextPrompts, promptProfileFromBody, resolveTaskLimits, retainBackgroundProcessesFromBody, taskAgentsSpecFragment, toolMaterializeStrategyFromBody, toolNameListFromBody } from "../spec-fields.js";
|
|
48
48
|
import { cwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
|
|
49
|
+
import { assertRequestA2aUnlocked, resolveRequestA2a } from "../task-a2a.js";
|
|
49
50
|
import { assertRequestMcpUnlocked, resolveRequestMcp } from "../task-mcp.js";
|
|
50
51
|
import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, acceptAppendSystemPrompt, applyTaskSettings, effectivePermissionMode, effectiveThinking, hasConstitutionAnchors, parseTaskSettings, providerDropsAppend, shellGateForMode, withPermissionMode } from "../task-settings.js";
|
|
51
52
|
import { enableForkFromBody, normalizeRetainSubagentSessions, selfOrchestrationFromBody } from "../task-workflow.js";
|
|
@@ -82,6 +83,10 @@ export function createResolveSpec(ctx) {
|
|
|
82
83
|
// —— 一个最终 400 的请求若已经改掉了这两样,它就改变了**后续被接受的**任务会物化什么、在哪里执行。
|
|
83
84
|
// core 对同一把锁的裁定原话是「Runs BEFORE any side effect of task preparation」,server 侧同姿势。
|
|
84
85
|
assertRequestMcpUnlocked(body.mcpServers, lockedKeys);
|
|
86
|
+
// DESIGN-269 车1 件3:A2A 的同一条判据、同一个位置(同一批副作用要防)。锁键 `a2a` 在 core 5.36 的
|
|
87
|
+
// `LockedKey` 里尚不存在(分单件 C-1),所以这道门今天恒不触发 —— 它先在场是纵深:词表到货那一拍
|
|
88
|
+
// 它自己就活,而不是等着谁记起来补(见 task-a2a.ts 模块头的成文理由)。
|
|
89
|
+
assertRequestA2aUnlocked(body.a2aPeers, lockedKeys);
|
|
85
90
|
// A10 留档件#2:六阶段流水线。顺序=原文顺序(每一阶段的体逐字搬运),阶段之间只传显式产出;
|
|
86
91
|
// 六个阶段函数定义在本 `return` 之后(自上而下先读骨架、再读各阶段)。
|
|
87
92
|
const gated = await gateScenarioAndAppend(body, auth, opts);
|
|
@@ -817,6 +822,13 @@ export function createResolveSpec(ctx) {
|
|
|
817
822
|
// design/170 件C:锁形下本行不可达 —— `resolveSpec` 腿首已整拒(见那里的 F3 注)。`resolveRequestMcp`
|
|
818
823
|
// 收下的 config 带着 `lockedConfigKeys`,所以即使将来有人绕开腿首那道门,honored 谓词也会翻假。
|
|
819
824
|
mcp: resolveRequestMcp(mcpForScenario(config.mcpServers, scenarioName), body.mcpServers, config, logger),
|
|
825
|
+
// DESIGN-269 车1 件3 —— A2A peers,与上一行逐点同构:center 基线按场景过滤 + 调用方自带的 peer
|
|
826
|
+
// (单用户闸 ∧ 未锁 ∧ 合规未禁,`resolveRequestA2a` 里那只三票谓词是唯一属主),同名 baseline 赢。
|
|
827
|
+
// 🔴 与 MCP 的语义差:一个 peer 是**远端 agent**,core 给它的每条 skill 挂 `egress:true`+
|
|
828
|
+
// `effect:"write"`(2026-08-03 裁定)—— 所以这一行接出去的是对外写通道,不是数据源。两侧都缺席时
|
|
829
|
+
// 键整个不写(`a2aForScenario` 与 `resolveRequestA2a` 都恒返回 undefined 而非 []):缺席 = 本部署
|
|
830
|
+
// 没有这条腿,空表 = 「声明了一个空的 peer 集合」,对 core 与对读面都是两回事。
|
|
831
|
+
a2a: resolveRequestA2a(a2aForScenario(config.a2aPeers, scenarioName), body.a2aPeers, config, logger),
|
|
820
832
|
// center prompts 投影([1057]①f):center 下发的场景终形(overrides[s] ?? 基线)赢过内置 provider
|
|
821
833
|
// (center-wins,scenario overlay 同先例);无 pack/该场景空声明 ⇒ 内置照旧。声明数组逐字喂 typed
|
|
822
834
|
// 钩子(contentHash 透传,core 1.315 校验入 manifest);packId 归因在采用日志(center_prompts_adopted)。
|
package/dist/boot/runner-deps.js
CHANGED
|
@@ -417,6 +417,16 @@ export function createRunnerDeps(ctx) {
|
|
|
417
417
|
metrics.inc("mcp_server_unavailable_total");
|
|
418
418
|
return;
|
|
419
419
|
}
|
|
420
|
+
// "a2a" (DESIGN-269 车1 件4):an unreachable A2A peer was SKIPPED (core's materialize is fail-open per
|
|
421
|
+
// peer, `MaterializedA2a.warnings`) — the task still ran with the healthy peers' tools. Same posture as
|
|
422
|
+
// the MCP arm one line up, and for the same reason: a配置错/临时不可达的 peer 必须**可观测**,但它
|
|
423
|
+
// 不是任务失败,落 error 级会把它混进真故障的告警面。⚠️ 这不是「静默兜底」:fail-open 的裁决属 core
|
|
424
|
+
// (它已经决定跳过并把原因交出来),server 这一格只负责让它说出来 + 计数。
|
|
425
|
+
if (ctx.phase === "a2a") {
|
|
426
|
+
logger.warn("a2a_peer_unavailable", { sessionId: ctx.sessionId, info: String(err) });
|
|
427
|
+
metrics.inc("a2a_peer_unavailable_total");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
420
430
|
// "prompt-cache" (1.22 low-hit + design/31 break detector): since core 1.89 the detector passes its
|
|
421
431
|
// root cause as ctx.classification — "server-or-ttl" is usually benign in agentic tasks (slow tools ⇒
|
|
422
432
|
// 5min+ request gaps expire provider caches) → warn; the prefix-bug causes
|
|
@@ -42,6 +42,6 @@
|
|
|
42
42
|
export { fetchEffective, fetchPrincipalCaps, fetchPrincipalOrgMemory, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./http-client.js";
|
|
43
43
|
export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./apply-effective.js";
|
|
44
44
|
export { restartReasons, planeHasActiveTiers, modelPlaneChanged, type RestartSlice, type RestartSliceCtx, type RestartSignal, } from "./restart-signal.js";
|
|
45
|
-
export { applyCenterSkills, resolveMcpServers, mcpForScenario } from "./skills-mcp.js";
|
|
46
|
-
export type { CenterSkillManifest, CenterMcpServer, EffectiveConfig, ExecutionRuling, SessionMirrorRuling, } from "./types.js";
|
|
45
|
+
export { applyCenterSkills, resolveMcpServers, mcpForScenario, resolveA2aPeers, a2aForScenario } from "./skills-mcp.js";
|
|
46
|
+
export type { CenterSkillManifest, CenterMcpServer, CenterA2aPeer, EffectiveConfig, ExecutionRuling, SessionMirrorRuling, } from "./types.js";
|
|
47
47
|
//# sourceMappingURL=facade.d.ts.map
|
|
@@ -42,5 +42,5 @@
|
|
|
42
42
|
export { fetchEffective, fetchPrincipalCaps, fetchPrincipalOrgMemory, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./http-client.js";
|
|
43
43
|
export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./apply-effective.js";
|
|
44
44
|
export { restartReasons, planeHasActiveTiers, modelPlaneChanged, } from "./restart-signal.js";
|
|
45
|
-
export { applyCenterSkills, resolveMcpServers, mcpForScenario } from "./skills-mcp.js";
|
|
45
|
+
export { applyCenterSkills, resolveMcpServers, mcpForScenario, resolveA2aPeers, a2aForScenario } from "./skills-mcp.js";
|
|
46
46
|
//# sourceMappingURL=facade.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EffectiveConfig } from "./types.js";
|
|
2
|
-
export type RestartSlice = "skills" | "mcp" | "scenarios" | "runtime-gates" | "models-tiers" | "degrade-route";
|
|
2
|
+
export type RestartSlice = "skills" | "mcp" | "a2a" | "scenarios" | "runtime-gates" | "models-tiers" | "degrade-route";
|
|
3
3
|
/** What the CALLER must contribute about boot-baked catalog consumers that live OUTSIDE the Runner — the
|
|
4
4
|
* slices cannot see them, because they are configured from env (`ServiceConfig`), not from the center's
|
|
5
5
|
* EffectiveConfig. Absent/empty = that lane is off ⇒ its slice is inert (null on both sides). */
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { resolveActiveTiers } from "@sema-agent/registry-core";
|
|
9
9
|
import { RUNTIME_GATE_KEYS, runtimeGatePresent, resolveDefaultModelName } from "./apply-effective.js";
|
|
10
|
-
const RESTART_SLICES = ["skills", "mcp", "scenarios", "runtime-gates", "models-tiers", "degrade-route"];
|
|
10
|
+
const RESTART_SLICES = ["skills", "mcp", "a2a", "scenarios", "runtime-gates", "models-tiers", "degrade-route"];
|
|
11
11
|
/** Canonical, key-sorted JSON (array order preserved) so two semantically-equal effective slices fingerprint
|
|
12
12
|
* identically regardless of object key order from the center serializer. */
|
|
13
13
|
function stableStringify(v) {
|
|
@@ -70,6 +70,11 @@ function restartSliceValue(eff, slice, ctx) {
|
|
|
70
70
|
return enabledOnly(eff.skills?.skills);
|
|
71
71
|
case "mcp":
|
|
72
72
|
return enabledOnly(eff.mcp?.servers);
|
|
73
|
+
case "a2a":
|
|
74
|
+
// 与 mcp 逐字同形:只指纹 ENABLED 条目(`resolveA2aPeers` 跳过 `enabled:false`,所以被禁条目的
|
|
75
|
+
// 内容零运行期效果),整条记录进指纹(url/cardUrl/headerRefs/principalHeader/allowSkills/scenarios
|
|
76
|
+
// 全是运行期相关字段;DENY-list 都不需要——本域没有 cost/quotaWeight 那种热字段)。
|
|
77
|
+
return enabledOnly(eff.a2a?.peers);
|
|
73
78
|
case "scenarios":
|
|
74
79
|
return enabledOnly(eff.scenarios?.scenarios);
|
|
75
80
|
case "runtime-gates": {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { McpServerSpec } from "@sema-agent/core";
|
|
2
|
-
import type { ScopedMcpServer } from "../config-types.js";
|
|
1
|
+
import type { A2aServerSpec, McpServerSpec } from "@sema-agent/core";
|
|
2
|
+
import type { ScopedA2aPeer, ScopedMcpServer } from "../config-types.js";
|
|
3
3
|
import type { Logger } from "../observability/logger.js";
|
|
4
4
|
import type { LoadedSkill } from "../capabilities/skills.js";
|
|
5
|
-
import type { CenterMcpServer, CenterSkillManifest } from "./types.js";
|
|
5
|
+
import type { CenterA2aPeer, CenterMcpServer, CenterSkillManifest } from "./types.js";
|
|
6
6
|
/**
|
|
7
7
|
* Merge center skills OVER the image baseline (design/41-sibling B1; `sema-registry docs/MCP-SKILLS.md`). Load order =
|
|
8
8
|
* `loadSkills(SKILLS_DIR)` baseline → center skills lazily fetched by hash and overlaid BY NAME (center
|
|
@@ -29,4 +29,29 @@ export declare function resolveMcpServers(mcp: {
|
|
|
29
29
|
}, logger?: Logger): ScopedMcpServer[];
|
|
30
30
|
/** The MCP servers applicable to a scenario: tagged with it, or untagged (global). undefined when none. */
|
|
31
31
|
export declare function mcpForScenario(servers: ScopedMcpServer[] | undefined, scenario: string): McpServerSpec[] | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* DESIGN-269 §2.1 —— resolve center A2A peers (`/effective.a2a`) into core `A2aServerSpec`s + the scenarios
|
|
34
|
+
* each applies to. Exact sibling of {@link resolveMcpServers}, same three rules:
|
|
35
|
+
* · header-NAME refs → real values from THIS service's env (the secret boundary is unchanged — the center
|
|
36
|
+
* never holds the bearer). A peer with ANY missing ref is SKIPPED wholesale (`resolveRefs` → null):
|
|
37
|
+
* dialing a remote AGENT with half the credentials is the wrong failure direction.
|
|
38
|
+
* · `enabled:false` skipped.
|
|
39
|
+
* · restart-to-apply (filtered per scenario in `resolveSpec` via {@link a2aForScenario}).
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ **No `toolAxes` on the center face, deliberately** (this is where the A2A lane STOPS mirroring MCP):
|
|
42
|
+
* core's ruling is that a caller override is the ONLY trusted way down from the fail-closed
|
|
43
|
+
* `egress:true`+`effect:"write"` default, and it names the CALLER as that trust root. An operator lowering
|
|
44
|
+
* an axis in center config would be vouching, on behalf of every future task, that a REMOTE agent's skill
|
|
45
|
+
* only reads — a claim the operator cannot actually verify and which core's own doc-comment reserves for
|
|
46
|
+
* the caller. Absent face > a face that quietly launders trust. (`allowSkills` is the operator's real
|
|
47
|
+
* knob here: narrow WHAT mounts, never re-label what it does.)
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveA2aPeers(a2a: {
|
|
50
|
+
peers: CenterA2aPeer[];
|
|
51
|
+
}, logger?: Logger): ScopedA2aPeer[];
|
|
52
|
+
/** The A2A peers applicable to a scenario: tagged with it, or untagged (global). undefined when none.
|
|
53
|
+
* Twin of {@link mcpForScenario} — `undefined` (not `[]`) is load-bearing: an empty array would put an
|
|
54
|
+
* `a2a: []` key on the spec, i.e. "this deployment declares an empty peer set", where absence means
|
|
55
|
+
* "this leg does not exist here". */
|
|
56
|
+
export declare function a2aForScenario(peers: ScopedA2aPeer[] | undefined, scenario: string): A2aServerSpec[] | undefined;
|
|
32
57
|
//# sourceMappingURL=skills-mcp.d.ts.map
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Small consumption lanes that all project a center manifest domain onto a per-request runtime shape:
|
|
3
3
|
* skills application (`applyCenterSkills` — merge center skills OVER the image baseline, content-addressed
|
|
4
|
-
* fetch+verify+cache)
|
|
5
|
-
* resolution + per-scenario filtering)
|
|
4
|
+
* fetch+verify+cache), MCP server resolution (`resolveMcpServers`/`mcpForScenario` — env-NAME ref
|
|
5
|
+
* resolution + per-scenario filtering) and — since DESIGN-269 车1 — A2A peer resolution
|
|
6
|
+
* (`resolveA2aPeers`/`a2aForScenario`, the same two rules on the agent-to-agent lane; it lives HERE rather
|
|
7
|
+
* than in a new file because it shares `resolveRefs`, and a secret-boundary rule with two implementations
|
|
8
|
+
* is a rule with one that will drift). Split out of `facade.ts` (design/158 A13, internal-lossless —
|
|
6
9
|
* the facade re-exports every symbol below unchanged).
|
|
7
10
|
*/
|
|
8
11
|
import { skillContentHash } from "@sema-agent/registry-core";
|
|
@@ -86,15 +89,19 @@ diskCacheDir) {
|
|
|
86
89
|
}
|
|
87
90
|
/** Resolve env-NAME refs (target-key → ENV_NAME) to real values from THIS service's env. Returns null when
|
|
88
91
|
* ANY referenced env var is unset → the caller skips the whole server (fail-safe: never wire a half-credentialed
|
|
89
|
-
* endpoint). Mirrors the missing-apiKeyEnv handling for models (the secret boundary is unchanged).
|
|
90
|
-
|
|
92
|
+
* endpoint). Mirrors the missing-apiKeyEnv handling for models (the secret boundary is unchanged).
|
|
93
|
+
*
|
|
94
|
+
* `event`/`skipNote` are per-lane so an operator reading logs sees WHICH lane dropped an entry (MCP server vs
|
|
95
|
+
* A2A peer) — the resolution rule itself is one function on purpose (two copies of a secret-boundary rule is
|
|
96
|
+
* how one of them drifts open). */
|
|
97
|
+
function resolveRefs(refs, server, kind, logger, event = "sema_registry_mcp_env_missing", skipNote = "skipping this MCP server — referenced env var is unset in this service") {
|
|
91
98
|
if (!refs)
|
|
92
99
|
return {};
|
|
93
100
|
const out = {};
|
|
94
101
|
for (const [key, envName] of Object.entries(refs)) {
|
|
95
102
|
const v = process.env[envName];
|
|
96
103
|
if (v === undefined) {
|
|
97
|
-
logger?.warn(
|
|
104
|
+
logger?.warn(event, { server, kind, key, envName, note: skipNote });
|
|
98
105
|
return null;
|
|
99
106
|
}
|
|
100
107
|
out[key] = v;
|
|
@@ -146,4 +153,55 @@ export function mcpForScenario(servers, scenario) {
|
|
|
146
153
|
const hit = servers.filter((s) => s.scenarios.length === 0 || s.scenarios.includes(scenario)).map((s) => s.spec);
|
|
147
154
|
return hit.length > 0 ? hit : undefined;
|
|
148
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* DESIGN-269 §2.1 —— resolve center A2A peers (`/effective.a2a`) into core `A2aServerSpec`s + the scenarios
|
|
158
|
+
* each applies to. Exact sibling of {@link resolveMcpServers}, same three rules:
|
|
159
|
+
* · header-NAME refs → real values from THIS service's env (the secret boundary is unchanged — the center
|
|
160
|
+
* never holds the bearer). A peer with ANY missing ref is SKIPPED wholesale (`resolveRefs` → null):
|
|
161
|
+
* dialing a remote AGENT with half the credentials is the wrong failure direction.
|
|
162
|
+
* · `enabled:false` skipped.
|
|
163
|
+
* · restart-to-apply (filtered per scenario in `resolveSpec` via {@link a2aForScenario}).
|
|
164
|
+
*
|
|
165
|
+
* ⚠️ **No `toolAxes` on the center face, deliberately** (this is where the A2A lane STOPS mirroring MCP):
|
|
166
|
+
* core's ruling is that a caller override is the ONLY trusted way down from the fail-closed
|
|
167
|
+
* `egress:true`+`effect:"write"` default, and it names the CALLER as that trust root. An operator lowering
|
|
168
|
+
* an axis in center config would be vouching, on behalf of every future task, that a REMOTE agent's skill
|
|
169
|
+
* only reads — a claim the operator cannot actually verify and which core's own doc-comment reserves for
|
|
170
|
+
* the caller. Absent face > a face that quietly launders trust. (`allowSkills` is the operator's real
|
|
171
|
+
* knob here: narrow WHAT mounts, never re-label what it does.)
|
|
172
|
+
*/
|
|
173
|
+
export function resolveA2aPeers(a2a, logger) {
|
|
174
|
+
const out = [];
|
|
175
|
+
for (const p of a2a.peers ?? []) {
|
|
176
|
+
if (p.enabled === false)
|
|
177
|
+
continue;
|
|
178
|
+
const headers = resolveRefs(p.headerRefs, p.name, "header", logger, "sema_registry_a2a_env_missing", "skipping this A2A peer — referenced env var is unset in this service");
|
|
179
|
+
if (headers === null)
|
|
180
|
+
continue;
|
|
181
|
+
out.push({
|
|
182
|
+
scenarios: p.scenarios ?? [],
|
|
183
|
+
spec: {
|
|
184
|
+
name: p.name,
|
|
185
|
+
url: p.url,
|
|
186
|
+
...(p.cardUrl ? { cardUrl: p.cardUrl } : {}),
|
|
187
|
+
...(Object.keys(headers).length ? { headers } : {}),
|
|
188
|
+
...(p.principalHeader ? { principalHeader: p.principalHeader } : {}),
|
|
189
|
+
...(p.allowSkills && p.allowSkills.length > 0 ? { allowSkills: p.allowSkills } : {}),
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (out.length > 0)
|
|
194
|
+
logger?.info("sema_registry_a2a", { peers: out.map((p) => p.spec.name) });
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
/** The A2A peers applicable to a scenario: tagged with it, or untagged (global). undefined when none.
|
|
198
|
+
* Twin of {@link mcpForScenario} — `undefined` (not `[]`) is load-bearing: an empty array would put an
|
|
199
|
+
* `a2a: []` key on the spec, i.e. "this deployment declares an empty peer set", where absence means
|
|
200
|
+
* "this leg does not exist here". */
|
|
201
|
+
export function a2aForScenario(peers, scenario) {
|
|
202
|
+
if (!peers || peers.length === 0)
|
|
203
|
+
return undefined;
|
|
204
|
+
const hit = peers.filter((p) => p.scenarios.length === 0 || p.scenarios.includes(scenario)).map((p) => p.spec);
|
|
205
|
+
return hit.length > 0 ? hit : undefined;
|
|
206
|
+
}
|
|
149
207
|
//# sourceMappingURL=skills-mcp.js.map
|
|
@@ -123,6 +123,29 @@ export interface CenterMcpServer {
|
|
|
123
123
|
principalHeader?: string;
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* DESIGN-269 §2.1 —— A2A peer registry entry (the `CenterMcpServer` sibling). `headerRefs` are env-NAME
|
|
128
|
+
* refs (header-name → ENV_NAME), **never** literal secrets: the bearer for a peer lives in THIS service's
|
|
129
|
+
* env, the center only ever stores the NAME. (A2A has a single transport — every peer is an https(s)
|
|
130
|
+
* endpoint — so unlike MCP there is no transport discriminant; the two ref-carrying shapes collapse to one.)
|
|
131
|
+
*/
|
|
132
|
+
export interface CenterA2aPeer {
|
|
133
|
+
name: string;
|
|
134
|
+
scenarios: string[];
|
|
135
|
+
enabled?: boolean;
|
|
136
|
+
/** The peer's service URL (also the origin the well-known agent-card path resolves against). */
|
|
137
|
+
url: string;
|
|
138
|
+
/** Explicit agent-card location when the peer does not publish at a well-known path. Set ⇒ used ALONE. */
|
|
139
|
+
cardUrl?: string;
|
|
140
|
+
/** env-NAME refs (header-name → ENV_NAME) resolved from THIS service's env at boot. A peer with ANY
|
|
141
|
+
* unset ref is SKIPPED wholesale (fail-safe — never dial a peer half-credentialed). */
|
|
142
|
+
headerRefs?: Record<string, string>;
|
|
143
|
+
/** design/62, same contract as the MCP side: the header name the runner injects the task's authenticated
|
|
144
|
+
* end-user principal into. Not a ref — a literal header NAME (the runner fills the value, never the model). */
|
|
145
|
+
principalHeader?: string;
|
|
146
|
+
/** Optional allowlist of the peer's skill ids to mount (others are not mounted). */
|
|
147
|
+
allowSkills?: string[];
|
|
148
|
+
}
|
|
126
149
|
export interface EffectiveConfig {
|
|
127
150
|
version: number;
|
|
128
151
|
updatedAt: string;
|
|
@@ -156,6 +179,11 @@ export interface EffectiveConfig {
|
|
|
156
179
|
mcp?: {
|
|
157
180
|
servers: CenterMcpServer[];
|
|
158
181
|
};
|
|
182
|
+
/** DESIGN-269 §2.1: center-declared A2A peers. Absent (the shape every center ships today) ⇒ the leg
|
|
183
|
+
* does not exist for this deployment — `config.a2aPeers` stays undefined, no peer is ever dialed. */
|
|
184
|
+
a2a?: {
|
|
185
|
+
peers: CenterA2aPeer[];
|
|
186
|
+
};
|
|
159
187
|
/** Center-declared scenarios (composition-as-config; capability stays code-pinned): each entry names a
|
|
160
188
|
* deploy-registered TOOLSET (`none` / `repo-readonly`) + an optional static prompt (≤4KB, audited at
|
|
161
189
|
* publish). Restart-to-apply like skills/mcp (baked into the scenario table at boot). Center entries
|
package/dist/config-types.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* compiling unchanged. All imports below are top-level `import type` (erased — never a load edge,
|
|
7
7
|
* see test/module-cycle-gate.test.ts header on verbatimModuleSyntax). */
|
|
8
8
|
import type { SealedKeyPoison } from "./sealed-key.js";
|
|
9
|
-
import type { CompliancePosture, LockedKey, McpServerSpec, Model, ModelRoles, RetentionPolicy } from "@sema-agent/core";
|
|
9
|
+
import type { A2aServerSpec, CompliancePosture, LockedKey, McpServerSpec, Model, ModelRoles, RetentionPolicy } from "@sema-agent/core";
|
|
10
10
|
import type { ApprovalHmacKey, PrincipalJwtKey } from "./auth-keys.js";
|
|
11
11
|
import type { ElicitationThrottle } from "./elicitation.js";
|
|
12
12
|
import type { QuestionThrottle } from "./question.js";
|
|
@@ -35,6 +35,63 @@ export interface ScopedMcpServer {
|
|
|
35
35
|
scenarios: string[];
|
|
36
36
|
spec: McpServerSpec;
|
|
37
37
|
}
|
|
38
|
+
/** DESIGN-269 §2.1 —— an A2A PEER resolved to a core spec (header-NAME refs already → real values) plus
|
|
39
|
+
* the scenarios it applies to (empty = all). Exact sibling of {@link ScopedMcpServer}: `resolveSpec`
|
|
40
|
+
* filters by scenario (`a2aForScenario`) and passes `spec` to core as `TaskSpec.a2a`.
|
|
41
|
+
*
|
|
42
|
+
* 🔒 A peer is a remote AGENT, not a data source — core mounts every one of its skills with
|
|
43
|
+
* `egress:true` + `effect:"write"` (ruled 2026-08-03), so a configured peer is an outbound WRITE
|
|
44
|
+
* channel on every scenario it is scoped to. Scope deliberately, exactly as with an MCP server. */
|
|
45
|
+
export interface ScopedA2aPeer {
|
|
46
|
+
scenarios: string[];
|
|
47
|
+
spec: A2aServerSpec;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* DESIGN-269 §3.1 车2 —— ONE declared skill on the agent card this deployment publishes when it acts as
|
|
51
|
+
* an A2A **peer** (server-as-peer half). v1 is a **static declaration**: the operator writes the list,
|
|
52
|
+
* the server never enumerates its own scenarios into it.
|
|
53
|
+
*
|
|
54
|
+
* 🔒 Why static (设计稿 §3.1 逐字):auto-enumeration would publish this deployment's internal scenario
|
|
55
|
+
* names to an anonymous public discovery document. `scenario` is the ONE field that names an internal
|
|
56
|
+
* key, and it is deliberately **not** projected onto the wire card (see `buildAgentCard` — the card
|
|
57
|
+
* carries `id`/`name`/`description` only). It exists so a declared skill actually routes somewhere:
|
|
58
|
+
* a `message/send` naming this skill runs under that scenario.
|
|
59
|
+
*/
|
|
60
|
+
export interface A2aServeSkill {
|
|
61
|
+
/** Public, caller-facing skill id (what an A2A peer names to select it). Never an internal key. */
|
|
62
|
+
id: string;
|
|
63
|
+
/** Human label; defaults to `id` on the card when absent. */
|
|
64
|
+
name?: string;
|
|
65
|
+
description?: string;
|
|
66
|
+
/** INTERNAL scenario key this skill runs under. NEVER projected onto the card. */
|
|
67
|
+
scenario: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* DESIGN-269 §3.3 车2 —— the server-as-peer block. **Absence IS the OFF state** (`A2A_SERVE_ENABLED`
|
|
71
|
+
* default OFF): with this field undefined both `/.well-known/agent-card.json` and `POST /v1/a2a` 404,
|
|
72
|
+
* i.e. "卡都不发=对外不存在" — not "publish a card but refuse the calls". Modelling OFF as absence
|
|
73
|
+
* (rather than an `enabled:false` member) makes the half-state structurally unrepresentable.
|
|
74
|
+
*
|
|
75
|
+
* `url` is REQUIRED here because it is required when the knob is on: a public discovery document that
|
|
76
|
+
* cannot say where to call is a broken card, and the reachable address is the operator's to declare —
|
|
77
|
+
* #123 `FLEET_ADVERTISE_ADDRESS` precedent, 不自猜可达地址(deriving it from the request `Host` header
|
|
78
|
+
* would let an anonymous caller decide what our public card advertises).
|
|
79
|
+
*/
|
|
80
|
+
export interface A2aServeConfig {
|
|
81
|
+
/** `A2A_SERVE_NAME` — the card's `name`. */
|
|
82
|
+
name: string;
|
|
83
|
+
/** `A2A_SERVE_DESCRIPTION` — the card's `description`. */
|
|
84
|
+
description?: string;
|
|
85
|
+
/** `A2A_SERVE_URL` — the PUBLIC JSON-RPC endpoint URL announced on the card (http/https, validated). */
|
|
86
|
+
url: string;
|
|
87
|
+
/** `A2A_SERVE_SKILLS` — declared skills (may be empty: an honest "I take work, I advertise no skill"). */
|
|
88
|
+
skills: A2aServeSkill[];
|
|
89
|
+
/** `A2A_SERVE_BLOCKING_WAIT_MS` — how long a `blocking:true` `message/send` waits for a terminal state
|
|
90
|
+
* before answering `working` and letting the caller poll `tasks/get`. Server-side budget only — it is
|
|
91
|
+
* NOT core's client-leg `A2A_CALL_TOTAL_TIMEOUT_MS` (that is the CALLER's 10-minute budget, and holding
|
|
92
|
+
* an inbound HTTP request open for anything like that long is how you exhaust a connection pool). */
|
|
93
|
+
blockingWaitMs: number;
|
|
94
|
+
}
|
|
38
95
|
/** Sandbox-image-pool BAKE config (IMAGE-API-DESIGN.md §P2). `enabled=false` ⇒ the /v1/images/bakes* block is
|
|
39
96
|
* off (the control plane never mounts). The runner-facing facts (the runner principal, the build-host CACHE_BASE,
|
|
40
97
|
* the default base ref) live here so image-api injects only VETTED, server-fixed values into the build.sh argv —
|
|
@@ -435,6 +492,18 @@ export interface ServiceConfigFlat {
|
|
|
435
492
|
* filtered per scenario in `resolveSpec`. Not env-derived — populated by the sema-registry adapter when
|
|
436
493
|
* present (restart-to-apply, like models/roles); undefined = no center MCP (scenarios use no MCP). */
|
|
437
494
|
mcpServers?: ScopedMcpServer[];
|
|
495
|
+
/** DESIGN-269 §2.1 —— A2A peers from the config center (`/effective.a2a`), resolved at startup
|
|
496
|
+
* (header-NAME refs → values) and filtered per scenario in `resolveSpec` (`a2aForScenario`). Same
|
|
497
|
+
* posture as {@link mcpServers}: not env-derived (no A2A_* env leg — a peer is a structured record,
|
|
498
|
+
* not a scalar), populated by the config-center adapter when present, restart-to-apply.
|
|
499
|
+
* undefined = no center peers ⇒ **the whole leg does not exist** for a task (`TaskSpec.a2a` stays
|
|
500
|
+
* absent unless a single-user caller injects `body.a2aPeers`). */
|
|
501
|
+
a2aPeers?: ScopedA2aPeer[];
|
|
502
|
+
/** DESIGN-269 §3.3 车2 —— server-as-peer. **undefined = OFF** (the default), and OFF means both the
|
|
503
|
+
* card and the JSON-RPC endpoint 404. See {@link A2aServeConfig}. Env-derived (`A2A_SERVE_*`), unlike
|
|
504
|
+
* {@link a2aPeers}: the serve block is a handful of scalars an operator sets on the worker, not a
|
|
505
|
+
* center-distributed record. */
|
|
506
|
+
a2aServe?: A2aServeConfig;
|
|
438
507
|
tidb?: {
|
|
439
508
|
host: string;
|
|
440
509
|
port: number;
|
|
@@ -1222,8 +1291,8 @@ export type ServiceOrchestrationConfig = Pick<ServiceConfigFlat, "remoteExec" |
|
|
|
1222
1291
|
export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachmentOrphanGraceMs" | "workspaceFileMaxBytes" | "attachmentMaxBytes" | "attachmentMimeAllowlist" | "attachmentUnboundTtlMs" | "infraCostRates" | "drainGraceMs" | "sighupIdleGraceMs" | "parentPid" | "rateLimitPerMin" | "maxTaskCostUsd" | "maxTaskTokens" | "maxPrincipalCostUsd" | "costQuotaWindowSec" | "reapIntervalSec" | "runStaleSec" | "toolResultTtlSec">;
|
|
1223
1292
|
/** 组:observability(可观测)。 */
|
|
1224
1293
|
export type ServiceObservabilityConfig = Pick<ServiceConfigFlat, "metricsToken" | "traceToken" | "toolTrace" | "traceThinking" | "logLevel" | "otel">;
|
|
1225
|
-
/** 组:integrations(外部集成)。`mcpServers` 由 sema-registry 适配器填(无 env 腿),归本组。 */
|
|
1226
|
-
export type ServiceIntegrationsConfig = Pick<ServiceConfigFlat, "pluginsAllowHosts" | "mcpServers" | "configBootFetchBudgetMs" | "gitApiBaseUrl" | "gitApiToken" | "gitApiKind" | "defaultScenario" | "skillsDir" | "configCenter" | "configProvider" | "configLocalDir">;
|
|
1294
|
+
/** 组:integrations(外部集成)。`mcpServers` / `a2aPeers` 由 sema-registry 适配器填(无 env 腿),归本组。 */
|
|
1295
|
+
export type ServiceIntegrationsConfig = Pick<ServiceConfigFlat, "pluginsAllowHosts" | "mcpServers" | "a2aPeers" | "a2aServe" | "configBootFetchBudgetMs" | "gitApiBaseUrl" | "gitApiToken" | "gitApiKind" | "defaultScenario" | "skillsDir" | "configCenter" | "configProvider" | "configLocalDir">;
|
|
1227
1296
|
/** 九个组槽。每组恒在场(`loadConfig` / `attachConfigGroups` 装好才交出配置),故不可选——新代码写
|
|
1228
1297
|
* `config.modelPlane.model` 不需要 `?.`(可选组会把 `Model` 污染成 `Model | undefined`)。 */
|
|
1229
1298
|
export interface ServiceConfigGroups {
|
package/dist/config.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ import type { ServiceConfig, ServiceConfigFlat, ServiceConfigGroups } from "./co
|
|
|
4
4
|
/** design/158 A1: the config TYPE face lives in the leaf module config-types.ts; re-exported here so
|
|
5
5
|
* every existing `from "./config.js"` importer compiles unchanged (pure-type consumers should prefer
|
|
6
6
|
* importing config-types.js directly — a type-only leaf, no loader baggage). */
|
|
7
|
-
export type { ServiceConfig, ScopedMcpServer, ImageBakeConfig, ServiceConfigFlat, ServiceConfigGroups, ServiceStoreConfig, ServiceModelPlaneConfig, ServiceApprovalConfig, ServiceMemoryConfig, ServiceAuthConfig, ServiceOrchestrationConfig, ServiceLimitsHttpConfig, ServiceObservabilityConfig, ServiceIntegrationsConfig, MemoryEmbedderConfig,
|
|
7
|
+
export type { ServiceConfig, ScopedMcpServer, ImageBakeConfig, ServiceConfigFlat, ServiceConfigGroups, ServiceStoreConfig, ServiceModelPlaneConfig, ServiceApprovalConfig, ServiceMemoryConfig, ServiceAuthConfig, ServiceOrchestrationConfig, ServiceLimitsHttpConfig, ServiceObservabilityConfig, ServiceIntegrationsConfig, MemoryEmbedderConfig, // #228:embedder 坐标(plugins/memory-embedder.ts 消费同一属主类型)
|
|
8
|
+
A2aServeConfig, A2aServeSkill, } from "./config-types.js";
|
|
8
9
|
/** Parse the AUTONOMY env into a validated autonomy mode. Unset/empty → undefined (unmanaged → no extra
|
|
9
10
|
* tightening). An UNKNOWN value FAILS at startup rather than silently becoming a no-op (a typo'd `AUTONOMY=readonly`
|
|
10
11
|
* must not silently leave a deployment ungoverned — fail-loud, same discipline as numEnv). Exported so the HOT
|
package/dist/config.js
CHANGED
|
@@ -1090,8 +1090,9 @@ function parseDegradeOn(words) {
|
|
|
1090
1090
|
* "only a tool literally named `Bash(ps:*)` may run" ⇒ Bash unavailable, with no warning anywhere. That is a
|
|
1091
1091
|
* spelling-FAMILY error, not a typo, so it is refused on every list — with the two knobs that really do the job.
|
|
1092
1092
|
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1093
|
+
* 🔴 CC 形语义(参数级规则)**已裁不支持,此为终态**(clay 终裁 2026-08-16,sema-comms
|
|
1094
|
+
* audits/CLAY-RULINGS.md R-9;历史上已裁多遍,禁再上问)——响亮拒+指路即本面的完整承诺。
|
|
1095
|
+
* 判据只认括号的在场,残形(`Bash(ps`)同样拒——半个括号一样永不匹配活名。
|
|
1095
1096
|
*/
|
|
1096
1097
|
function ccRuleFormGuidance(name) {
|
|
1097
1098
|
if (!name.includes("(") && !name.includes(")"))
|
|
@@ -2154,8 +2155,113 @@ function parseIntegrationsDomain() {
|
|
|
2154
2155
|
: undefined,
|
|
2155
2156
|
configProvider: process.env.CONFIG_PROVIDER || undefined,
|
|
2156
2157
|
configLocalDir: process.env.CONFIG_LOCAL_DIR || undefined,
|
|
2158
|
+
a2aServe: parseA2aServe(),
|
|
2157
2159
|
};
|
|
2158
2160
|
}
|
|
2161
|
+
/** 一条已声明 skill 的字段上限(卡是**公网文档**:无界的运维自由文本会让一张卡长到任何大小,而卡由
|
|
2162
|
+
* 匿名调用方拉取)。id 短、文案宽,数量有帽。 */
|
|
2163
|
+
const A2A_SERVE_MAX_SKILLS = 64;
|
|
2164
|
+
const A2A_SERVE_MAX_ID_CHARS = 128;
|
|
2165
|
+
const A2A_SERVE_MAX_TEXT_CHARS = 2048;
|
|
2166
|
+
/**
|
|
2167
|
+
* DESIGN-269 §3.3 车2 —— `A2A_SERVE_*` 域(server-as-peer 总旋钮 + 卡内容声明面)。
|
|
2168
|
+
*
|
|
2169
|
+
* 姿势(全是既有先例的照抄,不是新发明):
|
|
2170
|
+
* · **默认 OFF**(operator-knob 律:无客户端表态派生腿)。`A2A_SERVE_ENABLED` 走 {@link boolEnv} ⇒
|
|
2171
|
+
* 坏词当场拒启(#123/#241-rc2 响亮拒族),不静默回默认。
|
|
2172
|
+
* · OFF ⇒ 返回 `undefined`,而不是一个 `enabled:false` 的块。缺席即 OFF 让「发了卡但拒调」这个半态
|
|
2173
|
+
* 在类型上不可表达(设计稿 §3.3 的判词是「卡都不发=对外不存在」)。
|
|
2174
|
+
* · ON 而 `A2A_SERVE_URL` 缺席/坏形 ⇒ **拒启**。理由与 #123 `FLEET_ADVERTISE_ADDRESS` 逐字同族:
|
|
2175
|
+
* ①半配置的部署带病运行比起不来危险(卡上没有 `url`,对端拿到一张打不通的卡,失败点远离原因);
|
|
2176
|
+
* ②可达地址是**运维声明**的,不许自猜——从请求 `Host` 头推导等于让匿名调用方决定我们公网卡上
|
|
2177
|
+
* 公告的地址(反射/缓存投毒面),本仓在 fleet 那条腿上已经拒绝过一次同样的诱惑。
|
|
2178
|
+
* · `A2A_SERVE_SKILLS` = JSON 数组,**坏形拒启**(声明面静默丢=部署方以为发出去了、对端看不到,
|
|
2179
|
+
* 正是「无声 fail-open」;这里没有可用的降级语义)。
|
|
2180
|
+
*/
|
|
2181
|
+
function parseA2aServe() {
|
|
2182
|
+
if (!boolEnv("A2A_SERVE_ENABLED", false))
|
|
2183
|
+
return undefined;
|
|
2184
|
+
const rawUrl = process.env.A2A_SERVE_URL ?? "";
|
|
2185
|
+
if (rawUrl === "") {
|
|
2186
|
+
throw new Error(`A2A_SERVE_ENABLED is on but A2A_SERVE_URL is not set — the agent card MUST announce where its JSON-RPC endpoint is reachable, ` +
|
|
2187
|
+
`and that address is the operator's to declare (it is NOT derived from the request Host header: an anonymous discovery caller must not decide what our public card advertises). ` +
|
|
2188
|
+
`Set A2A_SERVE_URL to this deployment's externally reachable /v1/a2a URL (e.g. "https://agent.example.com/v1/a2a").`);
|
|
2189
|
+
}
|
|
2190
|
+
let parsed;
|
|
2191
|
+
try {
|
|
2192
|
+
parsed = new URL(rawUrl);
|
|
2193
|
+
}
|
|
2194
|
+
catch {
|
|
2195
|
+
parsed = undefined;
|
|
2196
|
+
}
|
|
2197
|
+
if (parsed === undefined || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
|
|
2198
|
+
throw new Error(`A2A_SERVE_URL must be a parseable http(s) URL (e.g. "https://agent.example.com/v1/a2a"); got ${JSON.stringify(rawUrl)}`);
|
|
2199
|
+
}
|
|
2200
|
+
// 🔴 codex 轮2 R2-1(HIGH,验真后修):URL **userinfo** 段拒启。这个值被**逐字**放进 agent card,而卡是
|
|
2201
|
+
// 匿名可拉的公开文档 —— 一个看起来很合理的 Basic-auth 配置手滑(`https://user:pass@host/v1/a2a`)会把
|
|
2202
|
+
// 凭据发给每一个发现请求。方向取**拒启**而不是脱敏:脱敏会让部署方以为自己配的鉴权生效了(实际
|
|
2203
|
+
// 被抹掉),而这条路的鉴权本来就该走**头形**(R-11:RPC 持服务凭据)。同族先例=车1 自审第三镜给
|
|
2204
|
+
// client 侧读面做的 endpoint 脱敏,只是那边是**读别人的**卡、这边是**发我们自己的**卡,故更严。
|
|
2205
|
+
if (parsed.username !== "" || parsed.password !== "") {
|
|
2206
|
+
throw new Error(`A2A_SERVE_URL must not carry userinfo (credentials before "@") — it is published verbatim on the ANONYMOUSLY readable agent card, so those credentials would be handed to every discovery caller. ` +
|
|
2207
|
+
`Inbound authentication on this endpoint is header-shaped (a service credential), not URL-embedded; strip the userinfo and keep the secret in the caller's headers.`);
|
|
2208
|
+
}
|
|
2209
|
+
return {
|
|
2210
|
+
name: env("A2A_SERVE_NAME", "sema-server"),
|
|
2211
|
+
...(process.env.A2A_SERVE_DESCRIPTION ? { description: process.env.A2A_SERVE_DESCRIPTION } : {}),
|
|
2212
|
+
url: rawUrl,
|
|
2213
|
+
skills: parseA2aServeSkills(process.env.A2A_SERVE_SKILLS),
|
|
2214
|
+
// 服务端等待窗。**远小于** core 客户端腿的 10min 总预算(那是调用方的钱包,不是我们的连接池):
|
|
2215
|
+
// 一条 inbound HTTP 请求被按住的每一秒都占着一条连接与一个 socket,而协议自带 `tasks/get` 轮询
|
|
2216
|
+
// 出路,所以窗到期回 `working` 是**正常路径**、不是降级。上限 5min 是硬帽(比它更长请改用轮询)。
|
|
2217
|
+
blockingWaitMs: numEnvBounded("A2A_SERVE_BLOCKING_WAIT_MS", "45000", 0, 300_000),
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
/** `A2A_SERVE_SKILLS` 的逐条校验。**在场即须合法,否则拒启**——声明面没有「丢掉坏的那条继续跑」的
|
|
2221
|
+
* 合法语义:那会让部署方以为自己公告了一个技能而对端根本看不到(静默 fail-open 的教科书形)。 */
|
|
2222
|
+
function parseA2aServeSkills(raw) {
|
|
2223
|
+
if (raw === undefined || raw.trim() === "")
|
|
2224
|
+
return [];
|
|
2225
|
+
let parsed;
|
|
2226
|
+
try {
|
|
2227
|
+
parsed = JSON.parse(raw);
|
|
2228
|
+
}
|
|
2229
|
+
catch (e) {
|
|
2230
|
+
throw new Error(`A2A_SERVE_SKILLS must be a JSON array of {id, name?, description?, scenario} objects — it did not parse as JSON (${e.message})`);
|
|
2231
|
+
}
|
|
2232
|
+
if (!Array.isArray(parsed))
|
|
2233
|
+
throw new Error(`A2A_SERVE_SKILLS must be a JSON ARRAY of {id, name?, description?, scenario} objects; got ${typeof parsed}`);
|
|
2234
|
+
if (parsed.length > A2A_SERVE_MAX_SKILLS)
|
|
2235
|
+
throw new Error(`A2A_SERVE_SKILLS declares ${parsed.length} skills — at most ${A2A_SERVE_MAX_SKILLS} are accepted (the card is a public document fetched by anonymous callers)`);
|
|
2236
|
+
const seen = new Set();
|
|
2237
|
+
return parsed.map((entry, i) => {
|
|
2238
|
+
const at = `A2A_SERVE_SKILLS[${i}]`;
|
|
2239
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
|
|
2240
|
+
throw new Error(`${at} must be an object {id, name?, description?, scenario}`);
|
|
2241
|
+
const e = entry;
|
|
2242
|
+
const str = (key, required, max) => {
|
|
2243
|
+
const v = e[key];
|
|
2244
|
+
if (v === undefined) {
|
|
2245
|
+
if (required)
|
|
2246
|
+
throw new Error(`${at}.${key} is required (a skill without it cannot be ${key === "id" ? "selected by a caller" : "routed to a scenario"})`);
|
|
2247
|
+
return undefined;
|
|
2248
|
+
}
|
|
2249
|
+
if (typeof v !== "string" || v.length === 0)
|
|
2250
|
+
throw new Error(`${at}.${key} must be a non-empty string`);
|
|
2251
|
+
if (v.length > max)
|
|
2252
|
+
throw new Error(`${at}.${key} must be at most ${max} characters`);
|
|
2253
|
+
return v;
|
|
2254
|
+
};
|
|
2255
|
+
const id = str("id", true, A2A_SERVE_MAX_ID_CHARS);
|
|
2256
|
+
if (seen.has(id))
|
|
2257
|
+
throw new Error(`${at}.id ${JSON.stringify(id)} is declared twice — skill ids address a scenario, so a duplicate makes the routing ambiguous`);
|
|
2258
|
+
seen.add(id);
|
|
2259
|
+
const name = str("name", false, A2A_SERVE_MAX_TEXT_CHARS);
|
|
2260
|
+
const description = str("description", false, A2A_SERVE_MAX_TEXT_CHARS);
|
|
2261
|
+
const scenario = str("scenario", true, A2A_SERVE_MAX_ID_CHARS);
|
|
2262
|
+
return { id, ...(name !== undefined ? { name } : {}), ...(description !== undefined ? { description } : {}), scenario };
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2159
2265
|
const STORE_GROUP_KEYS = [
|
|
2160
2266
|
"sessionBackend", "sessionCacheTtlSec", "storeProbeIntervalMs", "rewindSnapshotMaxMb", "dbBackend", "dbBackendExplicit", "localDataRoot",
|
|
2161
2267
|
"tidb", "pg", "dbQueryTimeoutMs", "snapshotBlobStore", "snapshotBlobSqlMaxBytes", "snapshotBlobAllowSql", "sendUserFile",
|
|
@@ -2198,7 +2304,7 @@ const LIMITS_HTTP_GROUP_KEYS = [
|
|
|
2198
2304
|
];
|
|
2199
2305
|
const OBSERVABILITY_GROUP_KEYS = ["metricsToken", "traceToken", "toolTrace", "traceThinking", "logLevel", "otel"];
|
|
2200
2306
|
const INTEGRATIONS_GROUP_KEYS = [
|
|
2201
|
-
"pluginsAllowHosts", "mcpServers", "configBootFetchBudgetMs", "gitApiBaseUrl", "gitApiToken", "gitApiKind", "defaultScenario",
|
|
2307
|
+
"pluginsAllowHosts", "mcpServers", "a2aPeers", "a2aServe", "configBootFetchBudgetMs", "gitApiBaseUrl", "gitApiToken", "gitApiKind", "defaultScenario",
|
|
2202
2308
|
"skillsDir", "configCenter", "configProvider", "configLocalDir",
|
|
2203
2309
|
];
|
|
2204
2310
|
/** 组名 → 该组取景的平铺键(introspection 面:测试用它钉「每个平铺键恰好被一组取景」)。 */
|
package/dist/http/route-ctx.d.ts
CHANGED
|
@@ -125,8 +125,18 @@ export interface DriveResumeArgs {
|
|
|
125
125
|
* routes/* 互相值 import 的第二条环。做法:留在 `createHttpServer` 闭包里(实现逐字不动),只把**函数引用**
|
|
126
126
|
* 装进这一格,域模块经 `ctx.legs.*` 调用。类型化 ⇒ 腿的形漂了是编译红,不是运行时静默 404。 */
|
|
127
127
|
export interface RouteLegs {
|
|
128
|
-
/** POST /v1/tasks · /v1/tasks/stream · /v1/runs 的共同前段:读体 → 校验 → resolveSpec。null = 已应答(400/…)。
|
|
129
|
-
|
|
128
|
+
/** POST /v1/tasks · /v1/tasks/stream · /v1/runs 的共同前段:读体 → 校验 → resolveSpec。null = 已应答(400/…)。
|
|
129
|
+
*
|
|
130
|
+
* `body` = **已解析好的**请求体,给那些体不是「这条 HTTP 请求的 JSON 正文」的调用方
|
|
131
|
+
* (DESIGN-269 车2:A2A JSON-RPC 腿已经把正文读成了一条 `message/send` 请求,再 `readJson(req)`
|
|
132
|
+
* 一次只会读到空流)。缺席 ⇒ 照旧自己读体,既有三个调用方一字不改。
|
|
133
|
+
* ⚠️ 这是**入口形**的分歧,不是校验面的分歧:传进来的体与自己读的体走**同一段**校验/授权/
|
|
134
|
+
* resolveSpec —— 分叉出第二条校验路径正是本参数存在的理由的反面。
|
|
135
|
+
*
|
|
136
|
+
* `onTypedFailure` = 「typed 拒绝**别写响应**,交回给我」。默认(缺席)行为不变:`HttpError` 走
|
|
137
|
+
* `sendError` 连 `extra` 一起回显。DESIGN-269 车2 的 A2A 腿必须传它 —— 那些 `extra`(尤其
|
|
138
|
+
* `scenario_unknown` 的**全部场景名**)对壳是指路材料,对一个外部 peer 是内部词表泄漏。 */
|
|
139
|
+
prepareSpec(req: IncomingMessage, res: ServerResponse, body?: TaskRequestBody, onTypedFailure?: (err: import("../security.js").HttpError) => void): Promise<PreparedTaskSubmission | null>;
|
|
130
140
|
/** 同步腿的终局记账(计费/配额/指标),tasks 域用。 */
|
|
131
141
|
finalizeTaskResult(result: TaskResult, principal: string | undefined, objective: string, sessionId: string | undefined): void;
|
|
132
142
|
/** approvals 决策腿:session → pending checkpoint → markResuming CAS → 驱动续跑。
|