Skip to content

fix(discovery): bound unattributed tools/change to one representative probe - #505

Open
ikliml666 wants to merge 1 commit into
T-Auto:mainfrom
ikliml666:fix/490-upstream
Open

ikliml666 wants to merge 1 commit into
T-Auto:mainfrom
ikliml666:fix/490-upstream

Conversation

@ikliml666

@ikliml666 ikliml666 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Closes #490

What was wrong

新建会话时,宿主在建立会话作用域期间会发出未归属(无 scope 载荷)的 tools/change。官方契约就是如此 —— packages/core/tools/src/index.ts:201-209 的签名是 'tools/change'(): void,注释写明 "deliberately not scope-filtered dispatch"。

dsh-ops 把这个信号当作「所有会话表面都可能变了」:

if (!scopes?.length) { refresh(); return }

而 refresh() 会对每个存活会话重新装配一次完整提示词。装配成本几乎全在 tools:sdk 段(渲染全部工具的 TypeScript 声明),实测约 0.23 ms/KB,而 SDK 字节由「工具数 × schema 复杂度」决定。

在官方 dsh-v0.2.1-rc.4、150 个近生产 schema 下实测:

存活会话 耗时 装配次数
0 109 ms 3
16 347 ms 19
64 1,171 ms 67
128 2,360 ms 131
198 3,343 ms 201

装配次数 = 存活会话数 + 3,严格线性 —— 与 issue 报告的 2.7 s 中位数 / 3.9 s 最差吻合。

The change

两处,都在 tools/change 的归属处理上。

src/tool-changes.ts — 新增 hostScope(args),读取较新宿主在载荷里附带的 { scope }:

const scope = hostScope(args)
const scopes = scope !== undefined ? [scope] : toolChangeScopes()

载荷为空对象或缺失时回退到插件自己的 withToolChange 窗口,而不是覆盖成 [](全局)。这一点是必须的:dsh-ops 自己的发布也会被宿主包成空载荷,若判成全局就丢了自己的归属,test/discovery-probe-memo.test.mjs 会立刻变红。

src/discovery/index.ts — 未归属变更只探测一个代表会话,其余惰性失效:

 if (!scopes?.length) {
-  refresh()
+  const representative = [...entries].find(([agent, entry]) =>
+    entry.ctx && ownership?.owns(agent) && permitted(agent))
+  if (representative) {
+    invalidate(representative[1])
+    void reconcile([representative[0]])
+  }
+  for (const [agent, entry] of entries)
+    if (agent !== representative?.[0]) invalidate(entry)
   return
 }

代表会话必须从本组件真正拥有的会话里选(owns + permitted + has ctx 三者都满足)。取 entries 插入序首位的话,若那个会话不属本组件,reconcile 会把它过滤掉,「至少探测一次」的约定就静默退化成零次。

刻意没做的

  • 没有改成纯惰性失效。test/ptc-sdk.test.mjs:199-205 有第三方 system-prompt/assemble hook 能在装配阶段把某个 scope 翻成 PTC-only,且不产生任何 registry 事件。只失效不探测会漏掉它 —— 代表会话这一次探测正是保守性的下限。
  • 没有动 src/ptc/index.ts 的未归属分支。它只标记 queued、不自己装配,实测探针数为 0,不是放大源。
  • 没有动 src/official-tool-hiding.ts:189 的 ctx.on('tools/change', reconcile)。默认禁用,且成本约 4 ms/会话,远小于装配成本。

Validation

$ node scripts/verify-gate.mjs --skip-install
162 suites passed; skipped: linux-attributes, linux-shell, tui-host, tui-native (allowed: file-metadata, linux-attributes, linux-shell, tui-host, tui-native)
manifest: 83/59 checks
win32-x64: gate green

Every new assertion was checked in both directions — reverting the implementation turns it red:

Reverted Failure
staleAll back to refresh on system-prompt/change AssertionError: a system-prompt/change must probe exactly one representative session / 8 !== 1
hostScope payload read AssertionError: a scope-attributed change must not re-probe sibling sessions
Array.isArray(scope) guard AssertionError: a list payload is not a scope identity and must stay conservative / 0 !== 1
coordinable external branch discovery-external-lazy / 0 !== 1

Measurement on official v0.2.1-rc.4

150 near-production schemas, each round creating one session plus one third-party registration and one unattributed tools/change:

Live sessions Before After (discovery path bounded)
0 196.8 / 310.8 ms, 7 / 14 assemblies 103.0 / 129.0 ms, 3 / 6
16 2,130.9 / 2,568.4 ms, 119 / 126 660.0 / 658.0 ms, 36 / 38
64 8,531.1 / 8,963.3 ms, 455 / 462 2,344.0 / 2,329.0 ms, 132 / 134
198 23,143.4 / 22,773.5 ms, 1,393 / 1,400 7,097.0 / 7,070.0 ms, 400 / 402

Zero live sessions are unchanged, because no sibling assembly exists to save. The remaining linear growth comes from the PTC path, which still probes every entry on an unattributed change: bounding it requires per-session presentation verdicts without a full assembly, and the three approaches tried (single representative, surfaceOf comparison, run_code visibility) each either broke test/discovery.test.mjs or failed to actually short-circuit. It is recorded on the issue rather than guessed at here.

@ikliml666
ikliml666 force-pushed the fix/490-upstream branch 2 times, most recently from 947ab3d to 847be0d Compare October 30, 2026 16:01
… probe

A host that emits `tools/change` without a scope payload (the official
0.2.1-rc.4 contract is deliberately unfiltered) made every live session
re-probe, each probe costing a full prompt assembly. At 198 live sessions
this measured 23,143ms and 1,393 assemblies per session creation.

Two changes:
- publication/changes.ts: read the scope payload newer hosts attach,
  falling back to our own `withToolChange` window when the payload is
  empty or absent. An empty payload must not override our attribution
  with "global", or our own publications would be misread as global; a
  list payload is not a scope identity and must stay conservative
  rather than silently dropping the change.
- discovery/controller.ts: on an unattributed change, probe one
  representative session (satisfying the probe-memo "at least once"
  contract) and lazily invalidate the rest. `system-prompt/change` takes
  the same path, since a prompt change moves presentation only.
  Publication-set changes (modes, runtime, configuration) keep using
  `refresh`, which must visit every entry to withdraw stale
  registrations. The representative is chosen through the same
  `coordinable` predicate the reconcile loop uses, so the "at least one
  probe" contract cannot silently degrade to zero probes — including on
  an external-only mount, where no sandbox policy is injected.

Refs T-Auto#490
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] perf: 新建会话卡顿 2–4s,且随已打开会话数增长

1 participant