Follow-up from the review of #349. Not a blocker there; recording it so it isn't lost.
CompositeVoiceToolExecutor.kt:248:
val approve = withContext(Dispatchers.IO) { runCatching { source.policy.approve }.getOrNull() }
No deadline. enumerate() deliberately puts every embedder getter — namePrefix, policy, excludeExtra, irreversibleExtra — behind the 2s enumeration deadline, pinned by VoiceToolSourceIsolationTest."a hanging classifier does not hang the call". This read sits outside it, and runCatching answers a getter that throws while doing nothing for one that hangs — which is the enumeration KDoc's own argument.
Reachability is narrow: a getter that hangs consistently already fails enumeration, so the tool is never advertised and this line is never reached. It needs one that blocks only sometimes — e.g. taking a plugin-registry lock held by a concurrent reload. Blast radius is an IO thread pinned until the 120s watchdog.
It can also disagree with what was advertised. hostApproves is captured at merge time (VoiceToolMerge.kt:228) and decides whether voice_confirm appears in the advertised schema; approve is re-read live.
- Approver installed mid-call: the model was told about a token protocol that now never runs — harmless.
- Approver removed mid-call: the in-band gate takes over and hands back a
voice_confirm token for a parameter the advertised schema never declared, so the model has no sanctioned way to redeem it and the gated tool becomes a confirm loop.
Both fall out of one change: have AdvertisedExternalTool carry the resolved approve (or the whole resolved VoiceToolPolicy) from the merge, which already ran inside the deadline, and drop the live read. gated is already snapshotted that way — this makes approve consistent with it.
Follow-up from the review of #349. Not a blocker there; recording it so it isn't lost.
CompositeVoiceToolExecutor.kt:248:No deadline.
enumerate()deliberately puts every embedder getter —namePrefix,policy,excludeExtra,irreversibleExtra— behind the 2s enumeration deadline, pinned byVoiceToolSourceIsolationTest."a hanging classifier does not hang the call". This read sits outside it, andrunCatchinganswers a getter that throws while doing nothing for one that hangs — which is the enumeration KDoc's own argument.Reachability is narrow: a getter that hangs consistently already fails enumeration, so the tool is never advertised and this line is never reached. It needs one that blocks only sometimes — e.g. taking a plugin-registry lock held by a concurrent reload. Blast radius is an IO thread pinned until the 120s watchdog.
It can also disagree with what was advertised.
hostApprovesis captured at merge time (VoiceToolMerge.kt:228) and decides whethervoice_confirmappears in the advertised schema;approveis re-read live.voice_confirmtoken for a parameter the advertised schema never declared, so the model has no sanctioned way to redeem it and the gated tool becomes a confirm loop.Both fall out of one change: have
AdvertisedExternalToolcarry the resolvedapprove(or the whole resolvedVoiceToolPolicy) from the merge, which already ran inside the deadline, and drop the live read.gatedis already snapshotted that way — this makesapproveconsistent with it.