Skip to content

Commit e6390d4

Browse files
committed
Attach: set ENABLE_TOOL_SEARCH=true so the gateway base URL doesn't force eager tool loading
Claude Code disables deferred tool loading whenever ANTHROPIC_BASE_URL is a non-first-party host, sending every tool schema up front (~50k tokens of per-session context bloat). The gateway is a pure pass-through that forwards tool_reference blocks, so attach now sets ENABLE_TOOL_SEARCH=true to keep deferred loading on. Only manage the key when it is ours: a value the user set themselves is left untouched and unrecorded, so detach never clobbers it. The core detach scopes the prev_base_url restore to ANTHROPIC_BASE_URL only and removes any other managed key, so it can't stamp the base URL onto ENABLE_TOOL_SEARCH. Documents the rationale in LLP 0045.
1 parent 935e397 commit e6390d4

5 files changed

Lines changed: 163 additions & 15 deletions

File tree

hypaware-core/plugins-workspace/claude/src/settings.js

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,20 +96,41 @@ export async function attach(opts) {
9696
const baseUrl = `http://127.0.0.1:${port}`
9797
const command = managedHookCommand(binPath, stateFile)
9898

99+
// Keep deferred tool loading on through the gateway. Claude Code turns it off
100+
// whenever ANTHROPIC_BASE_URL is a non-first-party host - it assumes the proxy
101+
// cannot forward `tool_reference` blocks and so sends every tool schema up
102+
// front, tens of thousands of tokens of per-session context bloat. Our gateway
103+
// is a pure pass-through that does forward them, so re-enable deferred loading
104+
// with ENABLE_TOOL_SEARCH=true. Only manage the key when it is ours to manage:
105+
// a value the user set themselves (and that a prior marker did not record as
106+
// ours) is left untouched so detach never clobbers it, mirroring the
107+
// back-up/restore rule for the base URL.
108+
// @ref LLP 0045#enable_tool_search-keep-deferred-tool-loading-on-through-the-gateway [implements]: attach sets ENABLE_TOOL_SEARCH=true so the non-first-party base URL doesn't force eager tool-schema loading
109+
const priorManagedEnv = priorMarker && isPlainObject(priorMarker.managed) && isPlainObject(priorMarker.managed.env)
110+
? /** @type {Record<string, unknown>} */ (priorMarker.managed.env)
111+
: undefined
112+
const weOwnToolSearch = priorManagedEnv ? 'ENABLE_TOOL_SEARCH' in priorManagedEnv : false
113+
const manageToolSearch = weOwnToolSearch || typeof env.ENABLE_TOOL_SEARCH !== 'string'
114+
99115
env.ANTHROPIC_BASE_URL = baseUrl
116+
if (manageToolSearch) env.ENABLE_TOOL_SEARCH = 'true'
100117
installSessionContextHooks(value, command)
101118
// Self-describing undo record: enough for the format-aware core undo
102-
// to restore-or-remove `env.ANTHROPIC_BASE_URL`, strip the managed
103-
// hook entries, and delete the marker without loading this plugin —
104-
// leaving no orphaned `hyp claude-hook` entries.
119+
// to restore-or-remove `env.ANTHROPIC_BASE_URL`, remove the managed
120+
// ENABLE_TOOL_SEARCH we added, strip the managed hook entries, and delete
121+
// the marker without loading this plugin — leaving no orphaned
122+
// `hyp claude-hook` entries.
105123
// @ref LLP 0045#part-3--reverse-runs-from-disk-the-marker-is-a-self-describing-undo-record [implements] — claude marker records prev_base_url + managed env/hook entries
106124
value[MARKER_KEY] = {
107125
attached_at: new Date().toISOString(),
108126
version,
109127
port,
110128
state_file: stateFile,
111129
managed: {
112-
env: { ANTHROPIC_BASE_URL: baseUrl },
130+
env: {
131+
ANTHROPIC_BASE_URL: baseUrl,
132+
...(manageToolSearch ? { ENABLE_TOOL_SEARCH: 'true' } : {}),
133+
},
113134
hooks: managedHookEntries(command),
114135
},
115136
...(prevBaseUrl !== undefined ? { prev_base_url: prevBaseUrl } : {}),

llp/0045-client-attach.design.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,10 @@ a self-describing undo record into its marker** — enough for a format-aware bu
222222
plugin-agnostic core routine to fully reverse:
223223

224224
- **Claude (`json`):** the `_hypaware` marker records `prev_base_url` (the restore
225-
target) plus the managed keys/hooks it added, so core can restore-or-remove
226-
`env.ANTHROPIC_BASE_URL`, strip the managed `SessionStart`/… hook entries, and
227-
delete the marker — leaving **no orphaned hooks** still pointing at
225+
target) plus the managed env keys and hooks it added, so core can
226+
restore-or-remove `env.ANTHROPIC_BASE_URL`, remove the managed
227+
`ENABLE_TOOL_SEARCH` (see below), strip the managed `SessionStart`/… hook
228+
entries, and delete the marker — leaving **no orphaned hooks** still pointing at
228229
`hyp claude-hook`. The backup is preserved idempotently across a re-attach:
229230
once we own the URL the current value is *our* gateway URL, so a re-attach keeps
230231
the marker's recorded original rather than overwriting it.
@@ -237,6 +238,32 @@ and how to replay an undo record, not "Claude" vs "Codex". The split is clean: a
237238
rich *write* (attach) needs the adapter (`ctx.clients`, Part 2); the *undo* is a
238239
marker-guided removal core does from disk.
239240

241+
#### ENABLE_TOOL_SEARCH: keep deferred tool loading on through the gateway
242+
243+
Pointing Claude Code at the gateway has a hidden cost. When `ANTHROPIC_BASE_URL`
244+
is a **non-first-party host**, Claude Code disables deferred (on-demand) tool
245+
loading by default — it assumes an arbitrary proxy cannot forward the
246+
`tool_reference` blocks that on-demand loading depends on, and instead sends
247+
**every tool schema up front**. With the full tool set that is tens of thousands
248+
of tokens of per-session context bloat, present on every request. The gateway
249+
itself is innocent: it is a pure pass-through that forwards `tool_reference`
250+
untouched; the switch is Claude Code's, keyed only on the base URL not being
251+
Anthropic's.
252+
253+
So attach also writes `env.ENABLE_TOOL_SEARCH = "true"`, the documented override
254+
that re-enables deferred loading for proxies that do forward everything (ours
255+
does). Two rules keep this honest against the undo contract above:
256+
257+
- **Only manage the key when it is ours.** If the user already set
258+
`ENABLE_TOOL_SEARCH` themselves and no prior marker recorded it as ours, attach
259+
leaves their value untouched and does **not** record it in `managed.env`. This
260+
is the same never-clobber-a-user-value stance the base URL takes, minus a
261+
backup: we only ever *add* the key when it was absent.
262+
- **`prev_base_url` restores the base URL only.** It is the sole managed key with
263+
a backed-up prior. The undo therefore restores `ANTHROPIC_BASE_URL` to
264+
`prev_base_url` but *removes* any other managed key (like `ENABLE_TOOL_SEARCH`)
265+
rather than stamping the base URL onto it.
266+
240267
**There is exactly one undo implementation, and it lives in core.** Both call
241268
sites use it — the reconciler's `reverse()` *and* the manual `hyp detach` command
242269
(`runClientLifecycle`'s detach branch routes through the core undo via the

src/core/config/client_detach_disk.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,13 +154,17 @@ async function detachJsonMarker({ settingsPath, markerKey, fs }) {
154154
for (const [key, ourVal] of Object.entries(managedEnv)) {
155155
const current = envObj[key]
156156
if (current === ourVal) {
157-
// The value we wrote is still live: restore the recorded prior, or
158-
// remove the key when the attach had no pre-existing value to back up.
159-
if (prevBaseUrl !== undefined) {
157+
// The value we wrote is still live. `prev_base_url` is the restore
158+
// target for ANTHROPIC_BASE_URL only - it is the one managed key that
159+
// backs up a pre-existing value. Every other managed key (e.g.
160+
// ENABLE_TOOL_SEARCH) is one attach only ever added when it was absent,
161+
// so it is removed, never restored. Applying prev_base_url to every key
162+
// would wrongly stamp the base URL onto them.
163+
if (key === 'ANTHROPIC_BASE_URL' && prevBaseUrl !== undefined) {
160164
envObj[key] = prevBaseUrl
161165
restoredValue = prevBaseUrl
162166
} else {
163-
removed = typeof current === 'string' ? current : String(current)
167+
if (key === 'ANTHROPIC_BASE_URL') removed = typeof current === 'string' ? current : String(current)
164168
delete envObj[key]
165169
}
166170
} else if (typeof current === 'string') {

test/core/client-detach-disk.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,48 @@ test('claude undo of a no-pre-existing-URL attach round-trips to empty', async (
112112
}
113113
})
114114

115+
test('claude undo removes managed ENABLE_TOOL_SEARCH without stamping the restored base URL onto it', async () => {
116+
const home = await stageHome()
117+
try {
118+
// A pre-existing foreign base URL means prev_base_url is set. Detach must
119+
// restore ANTHROPIC_BASE_URL to it *and* drop ENABLE_TOOL_SEARCH - never
120+
// apply prev_base_url to the tool-search key.
121+
const original = { env: { ANTHROPIC_BASE_URL: 'https://foreign.example/api' } }
122+
const settingsPath = await writeClaudeSettings(home, JSON.stringify(original, null, 2) + '\n')
123+
await claudeAttach({ ...ATTACH, settingsPath })
124+
125+
// Sanity: the fixture the adapter wrote actually set the tool-search key.
126+
const attached = JSON.parse(await fs.readFile(settingsPath, 'utf8'))
127+
assert.equal(attached.env.ENABLE_TOOL_SEARCH, 'true')
128+
129+
await detachClientFromDisk({ descriptor: CLAUDE_DESCRIPTOR, homeDir: home })
130+
131+
const parsed = JSON.parse(await fs.readFile(settingsPath, 'utf8'))
132+
assert.equal(parsed.env.ANTHROPIC_BASE_URL, 'https://foreign.example/api')
133+
assert.equal('ENABLE_TOOL_SEARCH' in parsed.env, false)
134+
} finally {
135+
await fs.rm(home, { recursive: true, force: true })
136+
}
137+
})
138+
139+
test('claude undo leaves a user-owned ENABLE_TOOL_SEARCH in place', async () => {
140+
const home = await stageHome()
141+
try {
142+
// The user set ENABLE_TOOL_SEARCH themselves, so attach never managed it;
143+
// detach must not remove it.
144+
const original = { env: { ENABLE_TOOL_SEARCH: 'false' } }
145+
const originalText = JSON.stringify(original, null, 2) + '\n'
146+
const settingsPath = await writeClaudeSettings(home, originalText)
147+
await claudeAttach({ ...ATTACH, settingsPath })
148+
149+
await detachClientFromDisk({ descriptor: CLAUDE_DESCRIPTOR, homeDir: home })
150+
151+
assert.equal(await fs.readFile(settingsPath, 'utf8'), originalText)
152+
} finally {
153+
await fs.rm(home, { recursive: true, force: true })
154+
}
155+
})
156+
115157
test('claude undo strips marker + managed keys/hooks from a hand-written fixture (no plugin loaded)', async () => {
116158
const home = await stageHome()
117159
try {

test/plugins/claude-settings-attach.test.js

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,20 @@ test('attach records the managed env + hook entries into the marker undo record'
4242
const result = await attach({ ...ATTACH, settingsPath })
4343
assert.equal(result.changed, true)
4444

45+
// The gateway base URL is written live, and ENABLE_TOOL_SEARCH=true is set
46+
// so the non-first-party base URL doesn't make Claude Code eager-load every
47+
// tool schema.
48+
const attached = JSON.parse(await fs.readFile(settingsPath, 'utf8'))
49+
assert.equal(attached.env.ANTHROPIC_BASE_URL, 'http://127.0.0.1:4123')
50+
assert.equal(attached.env.ENABLE_TOOL_SEARCH, 'true')
51+
4552
const marker = await readMarker(settingsPath)
46-
// Managed env value is the gateway URL we wrote — the core undo
47-
// matches the live value against it before removing.
48-
assert.deepEqual(marker.managed.env, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:4123' })
53+
// Managed env values are what we wrote — the core undo matches the live
54+
// value against them before removing.
55+
assert.deepEqual(marker.managed.env, {
56+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:4123',
57+
ENABLE_TOOL_SEARCH: 'true',
58+
})
4959

5060
// Every managed hook spec is recorded with its command, and the
5161
// PostToolUse entry carries its matcher so the undo strips exactly
@@ -92,13 +102,57 @@ test('attach omits prev_base_url when there was no pre-existing base URL', async
92102
const marker = await readMarker(settingsPath)
93103
assert.equal('prev_base_url' in marker, false)
94104
// The managed undo record is still present so the core undo can
95-
// remove (not restore) the gateway URL.
105+
// remove (not restore) the gateway URL and ENABLE_TOOL_SEARCH.
106+
assert.deepEqual(marker.managed.env, {
107+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:4123',
108+
ENABLE_TOOL_SEARCH: 'true',
109+
})
110+
} finally {
111+
await fs.rm(dir, { recursive: true, force: true })
112+
}
113+
})
114+
115+
test('attach leaves a user-owned ENABLE_TOOL_SEARCH untouched and unmanaged', async () => {
116+
const { dir, settingsPath } = await stage()
117+
try {
118+
await fs.writeFile(
119+
settingsPath,
120+
JSON.stringify({ env: { ENABLE_TOOL_SEARCH: 'false' } }, null, 2)
121+
)
122+
123+
await attach({ ...ATTACH, settingsPath })
124+
125+
// The user's own value is respected, not overwritten, and it is not
126+
// recorded as ours — so detach will never remove it.
127+
const attached = JSON.parse(await fs.readFile(settingsPath, 'utf8'))
128+
assert.equal(attached.env.ENABLE_TOOL_SEARCH, 'false')
129+
130+
const marker = await readMarker(settingsPath)
96131
assert.deepEqual(marker.managed.env, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:4123' })
97132
} finally {
98133
await fs.rm(dir, { recursive: true, force: true })
99134
}
100135
})
101136

137+
test('re-attach keeps managing an ENABLE_TOOL_SEARCH it owns', async () => {
138+
const { dir, settingsPath } = await stage()
139+
try {
140+
await attach({ ...ATTACH, settingsPath })
141+
// Our own 'true' is now live; the second attach must recognize it as ours
142+
// (recorded in the prior marker) and keep managing it, not mistake it for a
143+
// user value to leave alone.
144+
await attach({ ...ATTACH, settingsPath })
145+
146+
const marker = await readMarker(settingsPath)
147+
assert.deepEqual(marker.managed.env, {
148+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:4123',
149+
ENABLE_TOOL_SEARCH: 'true',
150+
})
151+
} finally {
152+
await fs.rm(dir, { recursive: true, force: true })
153+
}
154+
})
155+
102156
test('idempotent re-attach keeps the original prev_base_url, not the gateway URL', async () => {
103157
const { dir, settingsPath } = await stage()
104158
try {

0 commit comments

Comments
 (0)