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.
- package/README.md +24 -0
- package/package.json +65 -0
- package/src/brain/compaction.test.ts +156 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/demo-endpoint.ts +13 -0
- package/src/brain/frozen-prefix.test.ts +235 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.test.ts +129 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +44 -0
- package/src/brain/openai-brain.test.ts +61 -0
- package/src/brain/openai-brain.ts +544 -0
- package/src/brain/sanitize.test.ts +27 -0
- package/src/brain/sanitize.ts +23 -0
- package/src/brain/stub.ts +20 -0
- package/src/brain/types.ts +129 -0
- package/src/chain.ts +35 -0
- package/src/claude-plugins/discovery.test.ts +71 -0
- package/src/claude-plugins/discovery.ts +152 -0
- package/src/claude-plugins/index.ts +6 -0
- package/src/claude-plugins/types.ts +38 -0
- package/src/commands/index.ts +16 -0
- package/src/commands/registry.test.ts +186 -0
- package/src/commands/registry.ts +255 -0
- package/src/config.ts +201 -0
- package/src/economy/index.ts +6 -0
- package/src/events/index.ts +4 -0
- package/src/events/listeners.ts +37 -0
- package/src/events/queue.test.ts +43 -0
- package/src/events/queue.ts +63 -0
- package/src/events/router.ts +42 -0
- package/src/events/types.ts +28 -0
- package/src/format.ts +13 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +314 -0
- package/src/locks.test.ts +259 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.test.ts +97 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -0
- package/src/mcp/manager.test.ts +97 -0
- package/src/mcp/manager.ts +110 -0
- package/src/mcp/stdio-client.ts +154 -0
- package/src/mcp/types.ts +44 -0
- package/src/memory/edit.test.ts +41 -0
- package/src/memory/edit.ts +53 -0
- package/src/memory/encryption.test.ts +68 -0
- package/src/memory/encryption.ts +94 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -0
- package/src/memory/index-sync.test.ts +81 -0
- package/src/memory/index-sync.ts +99 -0
- package/src/memory/index.ts +58 -0
- package/src/memory/list-tool.ts +105 -0
- package/src/memory/pack-blob.test.ts +90 -0
- package/src/memory/pack-blob.ts +120 -0
- package/src/memory/pack-gather.test.ts +110 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.test.ts +29 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/path-drift.test.ts +71 -0
- package/src/memory/read-tool-fallback.test.ts +54 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.test.ts +193 -0
- package/src/memory/save-tool.ts +189 -0
- package/src/memory/scan.test.ts +24 -0
- package/src/memory/scan.ts +63 -0
- package/src/memory/topic.ts +32 -0
- package/src/memory/types.ts +49 -0
- package/src/migration/index.ts +6 -0
- package/src/migration/option3-crypto.test.ts +106 -0
- package/src/migration/option3-crypto.ts +143 -0
- package/src/pairing.test.ts +212 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +105 -0
- package/src/permission/env-redact.ts +54 -0
- package/src/permission/index.ts +16 -0
- package/src/permission/path-guard.ts +114 -0
- package/src/permission/permission.test.ts +299 -0
- package/src/permission/service.ts +191 -0
- package/src/plugins/context.ts +226 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/plugins.test.ts +196 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.test.ts +70 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +7 -0
- package/src/runtime/runtime.test.ts +55 -0
- package/src/runtime/runtime.ts +126 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +389 -0
- package/src/sandbox/factory.ts +99 -0
- package/src/sandbox/index.ts +15 -0
- package/src/sandbox/linux.ts +141 -0
- package/src/sandbox/local.ts +19 -0
- package/src/sandbox/macos.ts +71 -0
- package/src/sandbox/sandbox.test.ts +386 -0
- package/src/sandbox/seatbelt-profile.ts +139 -0
- package/src/sandbox/types.ts +129 -0
- package/src/skills/index.ts +8 -0
- package/src/skills/scanner.test.ts +137 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.test.ts +77 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -0
- package/src/storage/encryption.test.ts +30 -0
- package/src/storage/encryption.ts +87 -0
- package/src/storage/factory.ts +31 -0
- package/src/storage/index.ts +11 -0
- package/src/storage/local-stub.ts +70 -0
- package/src/storage/sqlite.test.ts +29 -0
- package/src/storage/sqlite.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.test.ts +348 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.test.ts +70 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.test.ts +63 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.test.ts +41 -0
- package/src/wallet/drain.ts +76 -0
- package/src/wallet/eoa.ts +61 -0
- package/src/wallet/index.ts +14 -0
- package/src/wallet/keystore.test.ts +17 -0
- 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
|
+
})
|