lyra-core 0.1.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.
Files changed (131) hide show
  1. package/README.md +24 -0
  2. package/package.json +65 -0
  3. package/src/brain/compaction.test.ts +156 -0
  4. package/src/brain/compaction.ts +131 -0
  5. package/src/brain/demo-endpoint.ts +13 -0
  6. package/src/brain/frozen-prefix.test.ts +235 -0
  7. package/src/brain/frozen-prefix.ts +320 -0
  8. package/src/brain/history-persist.test.ts +129 -0
  9. package/src/brain/history-persist.ts +154 -0
  10. package/src/brain/index.ts +44 -0
  11. package/src/brain/openai-brain.test.ts +61 -0
  12. package/src/brain/openai-brain.ts +544 -0
  13. package/src/brain/sanitize.test.ts +27 -0
  14. package/src/brain/sanitize.ts +23 -0
  15. package/src/brain/stub.ts +20 -0
  16. package/src/brain/types.ts +129 -0
  17. package/src/chain.ts +35 -0
  18. package/src/claude-plugins/discovery.test.ts +71 -0
  19. package/src/claude-plugins/discovery.ts +152 -0
  20. package/src/claude-plugins/index.ts +6 -0
  21. package/src/claude-plugins/types.ts +38 -0
  22. package/src/commands/index.ts +16 -0
  23. package/src/commands/registry.test.ts +186 -0
  24. package/src/commands/registry.ts +255 -0
  25. package/src/config.ts +201 -0
  26. package/src/economy/index.ts +6 -0
  27. package/src/events/index.ts +4 -0
  28. package/src/events/listeners.ts +37 -0
  29. package/src/events/queue.test.ts +43 -0
  30. package/src/events/queue.ts +63 -0
  31. package/src/events/router.ts +42 -0
  32. package/src/events/types.ts +28 -0
  33. package/src/format.ts +13 -0
  34. package/src/index.test.ts +6 -0
  35. package/src/index.ts +314 -0
  36. package/src/locks.test.ts +259 -0
  37. package/src/locks.ts +233 -0
  38. package/src/mcp/discovery.test.ts +97 -0
  39. package/src/mcp/discovery.ts +150 -0
  40. package/src/mcp/index.ts +10 -0
  41. package/src/mcp/manager.test.ts +97 -0
  42. package/src/mcp/manager.ts +110 -0
  43. package/src/mcp/stdio-client.ts +154 -0
  44. package/src/mcp/types.ts +44 -0
  45. package/src/memory/edit.test.ts +41 -0
  46. package/src/memory/edit.ts +53 -0
  47. package/src/memory/encryption.test.ts +68 -0
  48. package/src/memory/encryption.ts +94 -0
  49. package/src/memory/fs-util.ts +15 -0
  50. package/src/memory/index-file.ts +74 -0
  51. package/src/memory/index-sync.test.ts +81 -0
  52. package/src/memory/index-sync.ts +99 -0
  53. package/src/memory/index.ts +58 -0
  54. package/src/memory/list-tool.ts +105 -0
  55. package/src/memory/pack-blob.test.ts +90 -0
  56. package/src/memory/pack-blob.ts +120 -0
  57. package/src/memory/pack-gather.test.ts +110 -0
  58. package/src/memory/pack-gather.ts +112 -0
  59. package/src/memory/parser.test.ts +29 -0
  60. package/src/memory/parser.ts +20 -0
  61. package/src/memory/path-drift.test.ts +71 -0
  62. package/src/memory/read-tool-fallback.test.ts +54 -0
  63. package/src/memory/read-tool.ts +198 -0
  64. package/src/memory/save-tool.test.ts +193 -0
  65. package/src/memory/save-tool.ts +189 -0
  66. package/src/memory/scan.test.ts +24 -0
  67. package/src/memory/scan.ts +63 -0
  68. package/src/memory/topic.ts +32 -0
  69. package/src/memory/types.ts +49 -0
  70. package/src/migration/index.ts +6 -0
  71. package/src/migration/option3-crypto.test.ts +106 -0
  72. package/src/migration/option3-crypto.ts +143 -0
  73. package/src/pairing.test.ts +212 -0
  74. package/src/pairing.ts +285 -0
  75. package/src/paths.ts +70 -0
  76. package/src/permission/dangerous.ts +105 -0
  77. package/src/permission/env-redact.ts +54 -0
  78. package/src/permission/index.ts +16 -0
  79. package/src/permission/path-guard.ts +114 -0
  80. package/src/permission/permission.test.ts +299 -0
  81. package/src/permission/service.ts +191 -0
  82. package/src/plugins/context.ts +226 -0
  83. package/src/plugins/hooks.ts +81 -0
  84. package/src/plugins/index.ts +24 -0
  85. package/src/plugins/plugins.test.ts +196 -0
  86. package/src/plugins/tool-search.ts +49 -0
  87. package/src/public/card.test.ts +70 -0
  88. package/src/public/card.ts +67 -0
  89. package/src/runtime/activity.ts +29 -0
  90. package/src/runtime/index.ts +7 -0
  91. package/src/runtime/runtime.test.ts +55 -0
  92. package/src/runtime/runtime.ts +126 -0
  93. package/src/sandbox/credentials.ts +25 -0
  94. package/src/sandbox/docker.ts +389 -0
  95. package/src/sandbox/factory.ts +99 -0
  96. package/src/sandbox/index.ts +15 -0
  97. package/src/sandbox/linux.ts +141 -0
  98. package/src/sandbox/local.ts +19 -0
  99. package/src/sandbox/macos.ts +71 -0
  100. package/src/sandbox/sandbox.test.ts +386 -0
  101. package/src/sandbox/seatbelt-profile.ts +139 -0
  102. package/src/sandbox/types.ts +129 -0
  103. package/src/skills/index.ts +8 -0
  104. package/src/skills/scanner.test.ts +137 -0
  105. package/src/skills/scanner.ts +257 -0
  106. package/src/skills/triggers.test.ts +77 -0
  107. package/src/skills/triggers.ts +78 -0
  108. package/src/skills/types.ts +37 -0
  109. package/src/storage/encryption.test.ts +30 -0
  110. package/src/storage/encryption.ts +87 -0
  111. package/src/storage/factory.ts +31 -0
  112. package/src/storage/index.ts +11 -0
  113. package/src/storage/local-stub.ts +70 -0
  114. package/src/storage/sqlite.test.ts +29 -0
  115. package/src/storage/sqlite.ts +95 -0
  116. package/src/storage/types.ts +21 -0
  117. package/src/tools/escalation.test.ts +348 -0
  118. package/src/tools/escalation.ts +200 -0
  119. package/src/tools/index.ts +11 -0
  120. package/src/tools/registry.test.ts +70 -0
  121. package/src/tools/registry.ts +152 -0
  122. package/src/tools/types.ts +65 -0
  123. package/src/tools/zod-helpers.test.ts +63 -0
  124. package/src/tools/zod-helpers.ts +36 -0
  125. package/src/tools/zod-schema.ts +99 -0
  126. package/src/wallet/drain.test.ts +41 -0
  127. package/src/wallet/drain.ts +76 -0
  128. package/src/wallet/eoa.ts +61 -0
  129. package/src/wallet/index.ts +14 -0
  130. package/src/wallet/keystore.test.ts +17 -0
  131. package/src/wallet/keystore.ts +50 -0
@@ -0,0 +1,348 @@
1
+ import { expect, test } from 'bun:test'
2
+ import {
3
+ type EscalationDeps,
4
+ type FetchEscalation,
5
+ detectFetchEscalation,
6
+ mergeEscalationResult,
7
+ runEscalation,
8
+ } from './escalation'
9
+ import type { ToolCall, ToolResult } from './types'
10
+
11
+ const fetchCall = (id: string, url: string): ToolCall => ({
12
+ id,
13
+ name: 'web.fetch',
14
+ args: { url },
15
+ })
16
+
17
+ const blockedResult = (final_url: string, reason: string): ToolResult => ({
18
+ ok: true,
19
+ data: {
20
+ status: 200,
21
+ content_type: 'text/html',
22
+ body: '# unusual traffic from your computer network\n\nplease show you are not a robot',
23
+ truncated: false,
24
+ final_url,
25
+ blocked: true,
26
+ block_reason: reason,
27
+ },
28
+ })
29
+
30
+ const escalationFor = (call: ToolCall, reason: string, url: string): FetchEscalation => ({
31
+ needed: true,
32
+ escalatedCall: { id: `auto-escalate-${call.id}`, name: 'browser.navigate', args: { url } },
33
+ reason,
34
+ url,
35
+ })
36
+
37
+ test('detect: web.fetch + blocked + final_url returns needed with escalated call', () => {
38
+ const call = fetchCall('c1', 'https://www.google.com/search?q=test')
39
+ const result = blockedResult('https://www.google.com/search?q=test', 'google-bot-block')
40
+ const out = detectFetchEscalation(call, result)
41
+ expect(out.needed).toBe(true)
42
+ expect(out.escalatedCall?.id).toBe('auto-escalate-c1')
43
+ expect(out.escalatedCall?.name).toBe('browser.navigate')
44
+ expect(out.escalatedCall?.args).toEqual({ url: 'https://www.google.com/search?q=test' })
45
+ expect(out.reason).toBe('google-bot-block')
46
+ expect(out.url).toBe('https://www.google.com/search?q=test')
47
+ })
48
+
49
+ test('detect: blocked + no final_url falls back to call.args.url', () => {
50
+ const call = fetchCall('c2', 'https://example.com/blocked')
51
+ const result: ToolResult = {
52
+ ok: true,
53
+ data: { blocked: true, block_reason: 'cloudflare', body: 'Just a moment...' },
54
+ }
55
+ const out = detectFetchEscalation(call, result)
56
+ expect(out.needed).toBe(true)
57
+ expect(out.escalatedCall?.args).toEqual({ url: 'https://example.com/blocked' })
58
+ })
59
+
60
+ test('detect: blocked + neither final_url nor args.url returns not needed', () => {
61
+ const call: ToolCall = { id: 'c3', name: 'web.fetch', args: {} }
62
+ const result: ToolResult = { ok: true, data: { blocked: true, block_reason: 'captcha' } }
63
+ expect(detectFetchEscalation(call, result).needed).toBe(false)
64
+ })
65
+
66
+ test('detect: v0.21.3 — timeout failure DOES escalate (transient)', () => {
67
+ const call = fetchCall('c4', 'https://arxiv.org/search/?query=test')
68
+ const result: ToolResult = { ok: false, error: 'timeout after 15000ms' }
69
+ const out = detectFetchEscalation(call, result)
70
+ expect(out.needed).toBe(true)
71
+ expect(out.reason).toBe('timeout')
72
+ expect(out.escalatedCall?.args).toEqual({ url: 'https://arxiv.org/search/?query=test' })
73
+ })
74
+
75
+ test('detect: v0.21.3 — http 503 failure DOES escalate', () => {
76
+ const call = fetchCall('c4b', 'https://flaky.example.com')
77
+ const result: ToolResult = { ok: false, error: 'http 503' }
78
+ const out = detectFetchEscalation(call, result)
79
+ expect(out.needed).toBe(true)
80
+ expect(out.reason).toBe('http-503')
81
+ })
82
+
83
+ test('detect: v0.21.3 — http 429 failure DOES escalate', () => {
84
+ const call = fetchCall('c4c', 'https://api.example.com')
85
+ const result: ToolResult = { ok: false, error: 'http 429' }
86
+ const out = detectFetchEscalation(call, result)
87
+ expect(out.needed).toBe(true)
88
+ expect(out.reason).toBe('http-429')
89
+ })
90
+
91
+ test('detect: v0.21.3 — DNS failure DOES escalate', () => {
92
+ const call = fetchCall('c4d', 'https://dns-broken.example')
93
+ const result: ToolResult = {
94
+ ok: false,
95
+ error: 'getaddrinfo ENOTFOUND dns-broken.example',
96
+ }
97
+ const out = detectFetchEscalation(call, result)
98
+ expect(out.needed).toBe(true)
99
+ expect(out.reason).toBe('dns')
100
+ })
101
+
102
+ test('detect: v0.21.3 — connection refused DOES escalate', () => {
103
+ const call = fetchCall('c4e', 'https://refused.example.com')
104
+ const result: ToolResult = { ok: false, error: 'connect ECONNREFUSED' }
105
+ const out = detectFetchEscalation(call, result)
106
+ expect(out.needed).toBe(true)
107
+ expect(out.reason).toBe('connection')
108
+ })
109
+
110
+ test('detect: v0.21.3 — generic fetch failure DOES escalate', () => {
111
+ const call = fetchCall('c4f', 'https://example.com')
112
+ const result: ToolResult = { ok: false, error: 'fetch failed' }
113
+ const out = detectFetchEscalation(call, result)
114
+ expect(out.needed).toBe(true)
115
+ expect(out.reason).toBe('fetch-error')
116
+ })
117
+
118
+ test('detect: v0.21.3 — invalid URL is permanent, does NOT escalate', () => {
119
+ const call: ToolCall = { id: 'c4g', name: 'web.fetch', args: { url: 'not a url' } }
120
+ const result: ToolResult = { ok: false, error: 'invalid URL' }
121
+ expect(detectFetchEscalation(call, result).needed).toBe(false)
122
+ })
123
+
124
+ test('detect: v0.21.3 — unsupported protocol is permanent, does NOT escalate', () => {
125
+ const call = fetchCall('c4h', 'file:///etc/passwd')
126
+ const result: ToolResult = { ok: false, error: 'unsupported protocol: file:' }
127
+ expect(detectFetchEscalation(call, result).needed).toBe(false)
128
+ })
129
+
130
+ test('detect: v0.21.3 — host blocked (private IP) is permanent, does NOT escalate', () => {
131
+ const call = fetchCall('c4i', 'http://169.254.169.254/latest/meta-data')
132
+ const result: ToolResult = {
133
+ ok: false,
134
+ error: 'host blocked (private/loopback/metadata): 169.254.169.254',
135
+ }
136
+ expect(detectFetchEscalation(call, result).needed).toBe(false)
137
+ })
138
+
139
+ test('detect: web.fetch ok with no blocked field returns not needed', () => {
140
+ const call = fetchCall('c5', 'https://news.ycombinator.com')
141
+ const result: ToolResult = {
142
+ ok: true,
143
+ data: { status: 200, body: '# Hacker News', final_url: 'https://news.ycombinator.com' },
144
+ }
145
+ expect(detectFetchEscalation(call, result).needed).toBe(false)
146
+ })
147
+
148
+ test('detect: non-web.fetch tool with blocked:true returns not needed', () => {
149
+ const call: ToolCall = { id: 'c6', name: 'shell.run', args: { command: 'ls' } }
150
+ const result: ToolResult = { ok: true, data: { blocked: true, block_reason: 'spurious' } }
151
+ expect(detectFetchEscalation(call, result).needed).toBe(false)
152
+ })
153
+
154
+ test('detect: web.fetch with missing data field + ok=true returns not needed', () => {
155
+ const call = fetchCall('c7', 'https://x.com')
156
+ const result: ToolResult = { ok: true }
157
+ expect(detectFetchEscalation(call, result).needed).toBe(false)
158
+ })
159
+
160
+ test('detect: web.fetch missing block_reason still escalates with reason "unknown"', () => {
161
+ const call = fetchCall('c8', 'https://x.com')
162
+ const result: ToolResult = {
163
+ ok: true,
164
+ data: { blocked: true, final_url: 'https://x.com' },
165
+ }
166
+ const out = detectFetchEscalation(call, result)
167
+ expect(out.needed).toBe(true)
168
+ expect(out.reason).toBe('unknown')
169
+ })
170
+
171
+ test('detect: tail-recursion guard refuses to escalate already-escalated calls', () => {
172
+ const call: ToolCall = {
173
+ id: 'auto-escalate-original',
174
+ name: 'web.fetch',
175
+ args: { url: 'https://x.com' },
176
+ }
177
+ const result = blockedResult('https://x.com', 'cloudflare')
178
+ expect(detectFetchEscalation(call, result).needed).toBe(false)
179
+ })
180
+
181
+ test('detect: v0.21.3 — failure with no URL anywhere does NOT escalate', () => {
182
+ const call: ToolCall = { id: 'c9', name: 'web.fetch', args: {} }
183
+ const result: ToolResult = { ok: false, error: 'timeout after 15000ms' }
184
+ expect(detectFetchEscalation(call, result).needed).toBe(false)
185
+ })
186
+
187
+ test('merge: ok escalation produces ok=true result with both blobs', () => {
188
+ const original = blockedResult('https://www.google.com', 'google-bot-block')
189
+ const escalated: ToolResult = {
190
+ ok: true,
191
+ data: { currentUrl: 'https://www.google.com', title: 'Google' },
192
+ }
193
+ const escalation = escalationFor(
194
+ fetchCall('c9', 'https://www.google.com'),
195
+ 'google-bot-block',
196
+ 'https://www.google.com',
197
+ )
198
+ const merged = mergeEscalationResult(original, escalated, escalation)
199
+ expect(merged.ok).toBe(true)
200
+ const data = merged.data as Record<string, unknown>
201
+ expect(data.blocked).toBe(true)
202
+ expect(data.body).toContain('unusual traffic')
203
+ const ae = data.auto_escalation as Record<string, unknown>
204
+ expect(ae.triggered).toBe(true)
205
+ expect(ae.from).toBe('web.fetch')
206
+ expect(ae.to).toBe('browser.navigate')
207
+ expect(ae.reason).toBe('google-bot-block')
208
+ expect(ae.url).toBe('https://www.google.com')
209
+ const aeResult = ae.result as ToolResult
210
+ expect(aeResult.ok).toBe(true)
211
+ expect((aeResult.data as Record<string, unknown>).title).toBe('Google')
212
+ })
213
+
214
+ test('merge: v0.21.3 — timeout escalation preserves original error', () => {
215
+ const original: ToolResult = { ok: false, error: 'timeout after 15000ms' }
216
+ const escalated: ToolResult = { ok: true, data: { currentUrl: 'https://arxiv.org/search' } }
217
+ const escalation = escalationFor(
218
+ fetchCall('cT', 'https://arxiv.org/search'),
219
+ 'timeout',
220
+ 'https://arxiv.org/search',
221
+ )
222
+ const merged = mergeEscalationResult(original, escalated, escalation)
223
+ expect(merged.ok).toBe(true)
224
+ const data = merged.data as Record<string, unknown>
225
+ const ae = data.auto_escalation as Record<string, unknown>
226
+ expect(ae.reason).toBe('timeout')
227
+ expect(ae.original_error).toBe('timeout after 15000ms')
228
+ })
229
+
230
+ test('merge: failed escalation sets ok=false with descriptive error, preserves original data', () => {
231
+ const original = blockedResult('https://www.google.com', 'google-bot-block')
232
+ const escalated: ToolResult = {
233
+ ok: false,
234
+ error: 'Unknown tool: browser.navigate',
235
+ }
236
+ const escalation = escalationFor(
237
+ fetchCall('c10', 'https://www.google.com'),
238
+ 'google-bot-block',
239
+ 'https://www.google.com',
240
+ )
241
+ const merged = mergeEscalationResult(original, escalated, escalation)
242
+ expect(merged.ok).toBe(false)
243
+ expect(merged.error).toContain('auto-escalation failed')
244
+ expect(merged.error).toContain('Unknown tool: browser.navigate')
245
+ const data = merged.data as Record<string, unknown>
246
+ expect(data.blocked).toBe(true)
247
+ expect(data.body).toContain('unusual traffic')
248
+ const ae = data.auto_escalation as Record<string, unknown>
249
+ expect((ae.result as ToolResult).ok).toBe(false)
250
+ })
251
+
252
+ test('merge: failed escalation with no error field still produces fallback message', () => {
253
+ const original = blockedResult('https://x.com', 'cloudflare')
254
+ const escalated: ToolResult = { ok: false }
255
+ const escalation = escalationFor(fetchCall('c11', 'https://x.com'), 'cloudflare', 'https://x.com')
256
+ const merged = mergeEscalationResult(original, escalated, escalation)
257
+ expect(merged.error).toBe('auto-escalation failed: unknown')
258
+ })
259
+
260
+ test('merge: missing original.data handled defensively (no throw, escalation block still set)', () => {
261
+ const original: ToolResult = { ok: true }
262
+ const escalated: ToolResult = { ok: true, data: { currentUrl: 'https://x.com' } }
263
+ const escalation = escalationFor(fetchCall('c12', 'https://x.com'), 'rate-limit', 'https://x.com')
264
+ const merged = mergeEscalationResult(original, escalated, escalation)
265
+ expect(merged.ok).toBe(true)
266
+ const data = merged.data as Record<string, unknown>
267
+ expect(data.auto_escalation).toBeDefined()
268
+ const ae = data.auto_escalation as Record<string, unknown>
269
+ expect(ae.reason).toBe('rate-limit')
270
+ })
271
+
272
+ interface CallLog {
273
+ pre: ToolCall[]
274
+ post: Array<{ call: ToolCall; result: ToolResult }>
275
+ dispatch: ToolCall[]
276
+ activity: Array<{ call: ToolCall; result: ToolResult }>
277
+ starts: ToolCall[]
278
+ ends: Array<{ call: ToolCall; result: ToolResult; durationMs: number }>
279
+ }
280
+
281
+ const captureDeps = (
282
+ log: CallLog,
283
+ dispatchResult: ToolResult,
284
+ preShort?: ToolResult,
285
+ ): EscalationDeps => ({
286
+ runPreCall: async call => {
287
+ log.pre.push(call)
288
+ return preShort ? { short: preShort } : {}
289
+ },
290
+ runPostCall: async (call, result) => {
291
+ log.post.push({ call, result })
292
+ },
293
+ dispatch: async call => {
294
+ log.dispatch.push(call)
295
+ return dispatchResult
296
+ },
297
+ appendActivity: async (call, result) => {
298
+ log.activity.push({ call, result })
299
+ },
300
+ onStart: call => {
301
+ log.starts.push(call)
302
+ },
303
+ onEnd: (call, result, durationMs) => {
304
+ log.ends.push({ call, result, durationMs })
305
+ },
306
+ })
307
+
308
+ test('runEscalation: passes through original when escalation not needed', async () => {
309
+ const log: CallLog = { pre: [], post: [], dispatch: [], activity: [], starts: [], ends: [] }
310
+ const original: ToolResult = { ok: true, data: { body: 'fine' } }
311
+ const out = await runEscalation({ needed: false }, original, captureDeps(log, { ok: true }))
312
+ expect(out).toBe(original)
313
+ expect(log.starts.length).toBe(0)
314
+ expect(log.dispatch.length).toBe(0)
315
+ })
316
+
317
+ test('runEscalation: ok escalation runs full pipeline + emits start/end + merges', async () => {
318
+ const log: CallLog = { pre: [], post: [], dispatch: [], activity: [], starts: [], ends: [] }
319
+ const original = blockedResult('https://x.com', 'cloudflare')
320
+ const escalation = escalationFor(fetchCall('c13', 'https://x.com'), 'cloudflare', 'https://x.com')
321
+ const escalated: ToolResult = { ok: true, data: { currentUrl: 'https://x.com' } }
322
+ const merged = await runEscalation(escalation, original, captureDeps(log, escalated))
323
+ expect(log.starts.length).toBe(1)
324
+ expect(log.starts[0]?.name).toBe('browser.navigate')
325
+ expect(log.pre.length).toBe(1)
326
+ expect(log.dispatch.length).toBe(1)
327
+ expect(log.post.length).toBe(1)
328
+ expect(log.activity.length).toBe(1)
329
+ expect(log.ends.length).toBe(1)
330
+ expect(typeof log.ends[0]?.durationMs).toBe('number')
331
+ expect(merged.ok).toBe(true)
332
+ const ae = (merged.data as Record<string, unknown>).auto_escalation as Record<string, unknown>
333
+ expect(ae.triggered).toBe(true)
334
+ })
335
+
336
+ test('runEscalation: pre-hook short-circuit skips dispatch + post-hook', async () => {
337
+ const log: CallLog = { pre: [], post: [], dispatch: [], activity: [], starts: [], ends: [] }
338
+ const original = blockedResult('https://x.com', 'cloudflare')
339
+ const escalation = escalationFor(fetchCall('c14', 'https://x.com'), 'cloudflare', 'https://x.com')
340
+ const denied: ToolResult = { ok: false, error: 'denied by approval' }
341
+ const merged = await runEscalation(escalation, original, captureDeps(log, { ok: true }, denied))
342
+ expect(log.dispatch.length).toBe(0)
343
+ expect(log.post.length).toBe(0)
344
+ expect(log.activity.length).toBe(1)
345
+ expect(log.activity[0]?.result.ok).toBe(false)
346
+ expect(merged.ok).toBe(false)
347
+ expect(merged.error).toContain('auto-escalation failed')
348
+ })
@@ -0,0 +1,200 @@
1
+ import type { ToolCall, ToolResult } from './types'
2
+
3
+ /**
4
+ * v0.21.2/v0.21.3: web.fetch can fail in many ways. Some failures are transient
5
+ * (Cloudflare interstitial, Google bot block, captcha, rate-limit, HTTP 5xx,
6
+ * timeout, network error) and the browser primitive often recovers because it
7
+ * runs a real headless Chromium that handles cookies, JS challenges, and uses
8
+ * different DNS / network stack. Other failures are permanent (invalid URL,
9
+ * unsupported protocol, private/loopback host) and re-trying via browser would
10
+ * not help.
11
+ *
12
+ * The dispatcher (chat.tsx onToolCall + build-runtime.ts onToolCall) calls
13
+ * `runEscalation`. Both wrappers share orchestration (pre/post hooks, dispatch,
14
+ * activity append, merge) here; only the UX side effects (state.pushRow vs
15
+ * events.publish) are passed as callbacks. Brain sees one merged tool message
16
+ * regardless of how many wrapper-level retries happened.
17
+ *
18
+ * Live drives showed Qwen3.6 ignores conditional escalation rules in long
19
+ * contexts: pre-fix, the brain ended turn with toolCalls=0 on a blocked fetch
20
+ * and replied "(no reply)" / memory disclaimer. v0.21.2 escalated on the
21
+ * structured `blocked:true` signal. v0.21.3 extends to ANY transient web.fetch
22
+ * failure ("ensure browser routing is active so agent proactively go with
23
+ * browser every time it gets any hiccup or issues").
24
+ */
25
+
26
+ const ESCALATED_ID_PREFIX = 'auto-escalate-'
27
+
28
+ /**
29
+ * Errors web.fetch returns for input it must refuse outright. Browser would
30
+ * not fix them and we don't want to drive it to file:// / private IPs / etc.
31
+ * Match-prefix on the error string.
32
+ */
33
+ const PERMANENT_FAILURE_PATTERNS: readonly RegExp[] = [
34
+ /^invalid URL$/i,
35
+ /^unsupported protocol/i,
36
+ /^host blocked/i,
37
+ ]
38
+
39
+ export interface FetchEscalation {
40
+ needed: boolean
41
+ escalatedCall?: ToolCall
42
+ reason?: string
43
+ url?: string
44
+ }
45
+
46
+ /**
47
+ * Decide whether a web.fetch result warrants an automatic browser.navigate
48
+ * retry. Fires on:
49
+ * - bot-block / captcha / rate-limit interstitial (data.blocked === true)
50
+ * - any web.fetch failure (result.ok === false) UNLESS the error is one of
51
+ * the permanent-failure patterns above (invalid URL, unsupported
52
+ * protocol, host blocked for security)
53
+ *
54
+ * URL preference order: `result.data.final_url` (post-redirect canonical URL),
55
+ * then `call.args.url` (original request). If neither is present we cannot
56
+ * synthesize a browser.navigate call, so escalation is skipped. Recursion
57
+ * guard refuses to re-escalate calls that are themselves synthetic
58
+ * escalations.
59
+ */
60
+ export function detectFetchEscalation(call: ToolCall, result: ToolResult): FetchEscalation {
61
+ if (call.name !== 'web.fetch') return { needed: false }
62
+ if (typeof call.id === 'string' && call.id.startsWith(ESCALATED_ID_PREFIX))
63
+ return { needed: false }
64
+
65
+ const data = result.data as Record<string, unknown> | undefined
66
+ const finalUrl = data && typeof data.final_url === 'string' ? data.final_url : null
67
+ const argUrl = extractUrlFromCallArgs(call)
68
+ const url = finalUrl && finalUrl.length > 0 ? finalUrl : argUrl
69
+ if (!url) return { needed: false }
70
+
71
+ if (result.ok && data?.blocked === true) {
72
+ const reason = typeof data.block_reason === 'string' ? data.block_reason : 'unknown'
73
+ return synthesize(call, url, reason)
74
+ }
75
+ if (!result.ok) {
76
+ const error = typeof result.error === 'string' ? result.error : 'unknown'
77
+ if (PERMANENT_FAILURE_PATTERNS.some(re => re.test(error))) return { needed: false }
78
+ return synthesize(call, url, classifyError(error))
79
+ }
80
+ return { needed: false }
81
+ }
82
+
83
+ function synthesize(call: ToolCall, url: string, reason: string): FetchEscalation {
84
+ return {
85
+ needed: true,
86
+ escalatedCall: {
87
+ id: `${ESCALATED_ID_PREFIX}${call.id}`,
88
+ name: 'browser.navigate',
89
+ args: { url },
90
+ },
91
+ reason,
92
+ url,
93
+ }
94
+ }
95
+
96
+ function extractUrlFromCallArgs(call: ToolCall): string | null {
97
+ if (!call.args || typeof call.args !== 'object') return null
98
+ const args = call.args as Record<string, unknown>
99
+ return typeof args.url === 'string' && args.url.length > 0 ? args.url : null
100
+ }
101
+
102
+ /**
103
+ * Classify a non-block web.fetch error into a short symbolic reason. Keeps
104
+ * the merged auto_escalation.reason field readable for the brain instead of
105
+ * pasting raw stack traces.
106
+ */
107
+ function classifyError(error: string): string {
108
+ if (/^timeout/i.test(error)) return 'timeout'
109
+ const httpMatch = error.match(/^http\s+(\d{3})/i)
110
+ if (httpMatch) return `http-${httpMatch[1]}`
111
+ if (/dns|enotfound|getaddrinfo/i.test(error)) return 'dns'
112
+ if (/connection|econnrefused|econnreset|socket/i.test(error)) return 'connection'
113
+ if (/aborted|abortError/i.test(error)) return 'aborted'
114
+ return 'fetch-error'
115
+ }
116
+
117
+ /**
118
+ * Merge a failed/blocked web.fetch result with an escalated browser.navigate
119
+ * result into the single tool message the brain receives. Original `data.body`
120
+ * (the bot-block markdown, if any) is preserved alongside `data.auto_escalation`
121
+ * so the brain has full context to call `browser.snapshot` next.
122
+ *
123
+ * `mergedResult.ok` reflects the ESCALATED call's success: if browser.navigate
124
+ * also failed (no agent-browser binary, headless Chrome flake, etc.), the
125
+ * brain sees ok:false and can degrade gracefully.
126
+ */
127
+ export function mergeEscalationResult(
128
+ original: ToolResult,
129
+ escalated: ToolResult,
130
+ escalation: FetchEscalation,
131
+ ): ToolResult {
132
+ const originalData =
133
+ original.data && typeof original.data === 'object'
134
+ ? (original.data as Record<string, unknown>)
135
+ : {}
136
+ const merged: ToolResult = {
137
+ ok: escalated.ok,
138
+ data: {
139
+ ...originalData,
140
+ auto_escalation: {
141
+ triggered: true,
142
+ from: 'web.fetch',
143
+ to: 'browser.navigate',
144
+ reason: escalation.reason ?? 'unknown',
145
+ url: escalation.url ?? '',
146
+ original_error: original.error,
147
+ result: escalated,
148
+ },
149
+ },
150
+ }
151
+ if (!escalated.ok) {
152
+ merged.error = `auto-escalation failed: ${escalated.error ?? 'unknown'}`
153
+ }
154
+ return merged
155
+ }
156
+
157
+ export interface EscalationDeps {
158
+ /** Run pre-tool hooks (permission, sandbox bridge). Mirrors HookBus shape. */
159
+ runPreCall: (call: ToolCall) => Promise<{ short?: ToolResult; call?: ToolCall }>
160
+ /** Run post-tool hooks (audit, telemetry). */
161
+ runPostCall: (call: ToolCall, result: ToolResult) => Promise<void>
162
+ /** Dispatch the (possibly hook-replaced) call to the tool registry. */
163
+ dispatch: (call: ToolCall) => Promise<ToolResult>
164
+ /** Append a `kind:'tool-call'` activity entry tagged `autoEscalated:true`. */
165
+ appendActivity: (call: ToolCall, result: ToolResult) => Promise<void>
166
+ /** UX sink: notify the operator a follow-up call is starting. */
167
+ onStart: (call: ToolCall) => void
168
+ /** UX sink: notify the operator the follow-up call finished. */
169
+ onEnd: (call: ToolCall, result: ToolResult, durationMs: number) => void
170
+ }
171
+
172
+ /**
173
+ * Orchestrate the escalated browser.navigate dispatch on behalf of the brain
174
+ * wrapper. Owns: UX start → pre-hook → dispatch (or short-circuit) → post-hook →
175
+ * activity append → UX end → merge. Both `chat.tsx` and `build-runtime.ts` call
176
+ * this so any future change (extra hook, retry policy, telemetry) lands in one
177
+ * place instead of drifting between TUI and gateway paths.
178
+ */
179
+ export async function runEscalation(
180
+ escalation: FetchEscalation,
181
+ originalResult: ToolResult,
182
+ deps: EscalationDeps,
183
+ ): Promise<ToolResult> {
184
+ if (!escalation.needed || !escalation.escalatedCall) return originalResult
185
+ const synthCall = escalation.escalatedCall
186
+ const startedAt = Date.now()
187
+ deps.onStart(synthCall)
188
+ const pre = await deps.runPreCall(synthCall)
189
+ const effective = pre.call ?? synthCall
190
+ let result: ToolResult
191
+ if (pre.short) {
192
+ result = pre.short
193
+ } else {
194
+ result = await deps.dispatch(effective)
195
+ await deps.runPostCall(effective, result)
196
+ }
197
+ await deps.appendActivity(effective, result)
198
+ deps.onEnd(effective, result, Date.now() - startedAt)
199
+ return mergeEscalationResult(originalResult, result, escalation)
200
+ }
@@ -0,0 +1,11 @@
1
+ export type { ToolCall, ToolDef, ToolResult, ToolSchema, JSONSchema } from './types'
2
+ export { ToolRegistry } from './registry'
3
+ export { zodToJsonSchema } from './zod-schema'
4
+ export { coerceBool, coerceInt } from './zod-helpers'
5
+ export {
6
+ detectFetchEscalation,
7
+ mergeEscalationResult,
8
+ runEscalation,
9
+ type EscalationDeps,
10
+ type FetchEscalation,
11
+ } from './escalation'
@@ -0,0 +1,70 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { z } from 'zod'
3
+ import { ToolRegistry } from './registry'
4
+
5
+ test('register + find + dispatch a tool', async () => {
6
+ const reg = new ToolRegistry()
7
+ let captured: unknown = null
8
+ reg.register({
9
+ name: 'echo',
10
+ description: 'Echo back a message',
11
+ schema: z.object({ message: z.string() }),
12
+ handler: async args => {
13
+ captured = args
14
+ return { ok: true, data: args }
15
+ },
16
+ })
17
+
18
+ const r = await reg.dispatch({ id: 'c1', name: 'echo', args: { message: 'hi' } })
19
+ expect(r.ok).toBe(true)
20
+ expect(captured).toEqual({ message: 'hi' })
21
+ })
22
+
23
+ test('dispatch fails on unknown tool', async () => {
24
+ const reg = new ToolRegistry()
25
+ const r = await reg.dispatch({ id: 'c1', name: 'nope', args: {} })
26
+ expect(r.ok).toBe(false)
27
+ })
28
+
29
+ test('dispatch fails on schema-invalid args', async () => {
30
+ const reg = new ToolRegistry()
31
+ reg.register({
32
+ name: 'echo',
33
+ description: 'Echo',
34
+ schema: z.object({ message: z.string() }),
35
+ handler: async () => ({ ok: true }),
36
+ })
37
+ const r = await reg.dispatch({ id: 'c1', name: 'echo', args: { message: 123 } })
38
+ expect(r.ok).toBe(false)
39
+ expect(r.error).toContain('Invalid args')
40
+ })
41
+
42
+ test('glob disable prevents dispatch', async () => {
43
+ const reg = new ToolRegistry({ 'defi.*': false })
44
+ reg.register({
45
+ name: 'defi.swap',
46
+ description: 'Swap',
47
+ schema: z.object({}),
48
+ handler: async () => ({ ok: true }),
49
+ })
50
+ expect(reg.find('defi.swap')).toBeUndefined()
51
+ const r = await reg.dispatch({ id: 'c1', name: 'defi.swap', args: {} })
52
+ expect(r.ok).toBe(false)
53
+ expect(r.error).toContain('Unknown tool')
54
+ })
55
+
56
+ test('schemas() emits OpenAI-compat shape', () => {
57
+ const reg = new ToolRegistry()
58
+ reg.register({
59
+ name: 'memory.save',
60
+ description: 'Save memory',
61
+ schema: z.object({ name: z.string(), type: z.enum(['feedback', 'user']) }),
62
+ handler: async () => ({ ok: true }),
63
+ })
64
+ const schemas = reg.schemas()
65
+ expect(schemas[0]!.type).toBe('function')
66
+ expect(schemas[0]!.function.name).toBe('memory.save')
67
+ const props = schemas[0]!.function.parameters.properties as Record<string, { type: string }>
68
+ expect(props.name).toEqual({ type: 'string' })
69
+ expect(schemas[0]!.function.parameters.required).toEqual(['name', 'type'])
70
+ })