nebula-ai-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 +69 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +43 -0
- package/src/brain/openai-brain.ts +533 -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 +75 -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.ts +255 -0
- package/src/config.ts +213 -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.ts +63 -0
- package/src/events/router.ts +42 -0
- package/src/events/types.ts +28 -0
- package/src/format.ts +12 -0
- package/src/identity/agent-card.ts +110 -0
- package/src/identity/deployments.ts +20 -0
- package/src/identity/erc8004.ts +161 -0
- package/src/identity/index.ts +29 -0
- package/src/identity/keystore-blob.ts +60 -0
- package/src/identity/receipt.ts +27 -0
- package/src/identity/stub.ts +29 -0
- package/src/identity/types.ts +20 -0
- package/src/index.ts +372 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -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.ts +53 -0
- package/src/memory/encryption.ts +88 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -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.ts +120 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.ts +189 -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.ts +127 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/keychain.ts +53 -0
- package/src/operator/keystore-file.ts +33 -0
- package/src/operator/privkey-base.ts +60 -0
- package/src/operator/raw-privkey.ts +39 -0
- package/src/operator/signer.ts +46 -0
- package/src/operator/walletconnect.ts +454 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +108 -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/service.ts +191 -0
- package/src/plugins/context.ts +225 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/runtime.ts +113 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +396 -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/seatbelt-profile.ts +139 -0
- package/src/sandbox/types.ts +129 -0
- package/src/skills/index.ts +8 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -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.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.ts +79 -0
- package/src/wallet/eoa.ts +51 -0
- package/src/wallet/index.ts +47 -0
- package/src/wallet/keystore.ts +50 -0
- package/src/wallet/operator-keystore-crypto.ts +530 -0
- package/src/wallet/operator-session.ts +344 -0
|
@@ -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,152 @@
|
|
|
1
|
+
import type { ToolCall, ToolDef, ToolResult, ToolSchema } from './types'
|
|
2
|
+
import { zodToJsonSchema } from './zod-schema'
|
|
3
|
+
|
|
4
|
+
interface EnablementRule {
|
|
5
|
+
pattern: string
|
|
6
|
+
regex: RegExp | null
|
|
7
|
+
enabled: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Symbol-based tool registry. Tools self-register at import time (plugins
|
|
12
|
+
* contribute by importing their entry module, which triggers the registry
|
|
13
|
+
* call). Glob-style enable/disable via `config.tools` is applied at `list()`.
|
|
14
|
+
*
|
|
15
|
+
* Deferred-tool model (Claude Code-compatible): tools default to alwaysLoad
|
|
16
|
+
* (eager). A tool with `shouldDefer: true` (and not `alwaysLoad: true`) is
|
|
17
|
+
* hidden from `schemas()` until `unlock(name)` is called. The brain hydrates
|
|
18
|
+
* deferred schemas via the `tool.search` meta-tool.
|
|
19
|
+
*/
|
|
20
|
+
export class ToolRegistry {
|
|
21
|
+
private readonly tools = new Map<string, ToolDef>()
|
|
22
|
+
private readonly rules: EnablementRule[]
|
|
23
|
+
private readonly unlocked = new Set<string>()
|
|
24
|
+
|
|
25
|
+
constructor(enabled: Record<string, boolean> = {}) {
|
|
26
|
+
this.rules = Object.entries(enabled).map(([pattern, on]) => ({
|
|
27
|
+
pattern,
|
|
28
|
+
regex: pattern.includes('*') ? new RegExp(`^${pattern.replace(/\*/g, '.*')}$`) : null,
|
|
29
|
+
enabled: on,
|
|
30
|
+
}))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
register(def: ToolDef): void {
|
|
34
|
+
if (this.tools.has(def.name)) {
|
|
35
|
+
throw new Error(`Tool already registered: ${def.name}`)
|
|
36
|
+
}
|
|
37
|
+
this.tools.set(def.name, def as ToolDef<unknown>)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
find(name: string): ToolDef | undefined {
|
|
41
|
+
const tool = this.tools.get(name)
|
|
42
|
+
if (!tool) return undefined
|
|
43
|
+
if (!this.isEnabled(name)) return undefined
|
|
44
|
+
return tool
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** All registered + enabled tools, regardless of defer state. */
|
|
48
|
+
list(): ToolDef[] {
|
|
49
|
+
return [...this.tools.values()].filter(t => this.isEnabled(t.name))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Tools whose schemas the brain should see this turn. */
|
|
53
|
+
loadedList(): ToolDef[] {
|
|
54
|
+
return this.list().filter(t => this.isLoaded(t))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** OpenAI-format schemas for the eager (loaded) set; sent to Mantle Compute. */
|
|
58
|
+
schemas(): ToolSchema[] {
|
|
59
|
+
return this.loadedList().map(t => ({
|
|
60
|
+
type: 'function',
|
|
61
|
+
function: {
|
|
62
|
+
name: t.name,
|
|
63
|
+
description: t.description,
|
|
64
|
+
parameters: t.parametersOverride ?? zodToJsonSchema(t.schema),
|
|
65
|
+
},
|
|
66
|
+
}))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mark a deferred tool as loaded so its schema appears in the next
|
|
71
|
+
* `schemas()` call. Idempotent.
|
|
72
|
+
*/
|
|
73
|
+
unlock(name: string): boolean {
|
|
74
|
+
const tool = this.tools.get(name)
|
|
75
|
+
if (!tool) return false
|
|
76
|
+
this.unlocked.add(name)
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether the brain currently sees the tool's schema. */
|
|
81
|
+
isLoaded(tool: ToolDef): boolean {
|
|
82
|
+
if (tool.shouldDefer && tool.alwaysLoad !== true) {
|
|
83
|
+
return this.unlocked.has(tool.name)
|
|
84
|
+
}
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Search the registry for tools matching either an exact-name select query
|
|
90
|
+
* (`select:fs.read,fs.write`) or a free-text keyword query that matches
|
|
91
|
+
* names, descriptions, and searchHints.
|
|
92
|
+
*/
|
|
93
|
+
search(query: string, maxResults = 5): ToolDef[] {
|
|
94
|
+
const trimmed = query.trim()
|
|
95
|
+
if (trimmed.startsWith('select:')) {
|
|
96
|
+
const names = trimmed
|
|
97
|
+
.slice('select:'.length)
|
|
98
|
+
.split(',')
|
|
99
|
+
.map(s => s.trim())
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
return names
|
|
102
|
+
.map(n => this.tools.get(n))
|
|
103
|
+
.filter((t): t is ToolDef => !!t && this.isEnabled(t.name))
|
|
104
|
+
.slice(0, maxResults)
|
|
105
|
+
}
|
|
106
|
+
const required: string[] = []
|
|
107
|
+
const keywords: string[] = []
|
|
108
|
+
for (const part of trimmed.toLowerCase().split(/\s+/).filter(Boolean)) {
|
|
109
|
+
if (part.startsWith('+')) required.push(part.slice(1))
|
|
110
|
+
else keywords.push(part)
|
|
111
|
+
}
|
|
112
|
+
const scored: { tool: ToolDef; score: number }[] = []
|
|
113
|
+
for (const tool of this.list()) {
|
|
114
|
+
const haystack = [tool.name, tool.description, tool.searchHint ?? ''].join(' ').toLowerCase()
|
|
115
|
+
if (!required.every(r => haystack.includes(r))) continue
|
|
116
|
+
let score = required.length > 0 ? 1 : 0
|
|
117
|
+
for (const kw of keywords) {
|
|
118
|
+
if (haystack.includes(kw)) score++
|
|
119
|
+
}
|
|
120
|
+
if (score > 0) scored.push({ tool, score })
|
|
121
|
+
}
|
|
122
|
+
return scored
|
|
123
|
+
.sort((a, b) => b.score - a.score)
|
|
124
|
+
.slice(0, maxResults)
|
|
125
|
+
.map(c => c.tool)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async dispatch(call: ToolCall): Promise<ToolResult> {
|
|
129
|
+
const tool = this.find(call.name)
|
|
130
|
+
if (!tool) return { ok: false, error: `Unknown tool: ${call.name}` }
|
|
131
|
+
const parsed = tool.schema.safeParse(call.args)
|
|
132
|
+
if (!parsed.success) {
|
|
133
|
+
return { ok: false, error: `Invalid args: ${parsed.error.message}` }
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
return await tool.handler(parsed.data)
|
|
137
|
+
} catch (e) {
|
|
138
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
139
|
+
return { ok: false, error: msg }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private isEnabled(name: string): boolean {
|
|
144
|
+
// Right-most matching rule wins. No explicit rule = enabled by default.
|
|
145
|
+
let decision: boolean | null = null
|
|
146
|
+
for (const rule of this.rules) {
|
|
147
|
+
const matches = rule.regex ? rule.regex.test(name) : rule.pattern === name
|
|
148
|
+
if (matches) decision = rule.enabled
|
|
149
|
+
}
|
|
150
|
+
return decision ?? true
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/** OpenAI-compatible JSON Schema for a function parameter spec. */
|
|
4
|
+
export interface JSONSchema {
|
|
5
|
+
type: 'object'
|
|
6
|
+
properties: Record<string, unknown>
|
|
7
|
+
required?: string[]
|
|
8
|
+
additionalProperties?: boolean
|
|
9
|
+
description?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Shape we hand to the brain when asking it to plan with tools. */
|
|
13
|
+
export interface ToolSchema {
|
|
14
|
+
type: 'function'
|
|
15
|
+
function: {
|
|
16
|
+
name: string
|
|
17
|
+
description: string
|
|
18
|
+
parameters: JSONSchema
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ToolCall {
|
|
23
|
+
id: string
|
|
24
|
+
name: string
|
|
25
|
+
args: unknown
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ToolDef<TArgs = unknown> {
|
|
29
|
+
name: string
|
|
30
|
+
description: string
|
|
31
|
+
/** zod schema the runtime uses to both validate AND build JSONSchema for the brain. */
|
|
32
|
+
schema: z.ZodType<TArgs>
|
|
33
|
+
handler: (args: TArgs) => Promise<ToolResult> | ToolResult
|
|
34
|
+
/**
|
|
35
|
+
* When set with `shouldDefer`, overrides the deferral so the tool's schema
|
|
36
|
+
* still ships every turn. Has no effect when `shouldDefer` is false/unset.
|
|
37
|
+
*/
|
|
38
|
+
alwaysLoad?: boolean
|
|
39
|
+
/**
|
|
40
|
+
* Hide this tool's schema by default; the brain only sees it after
|
|
41
|
+
* `tool.search` matches it (mirrors Claude Code's shouldDefer). Combine with
|
|
42
|
+
* `alwaysLoad: true` to force eager loading even though the tool is meant
|
|
43
|
+
* to be searchable.
|
|
44
|
+
*/
|
|
45
|
+
shouldDefer?: boolean
|
|
46
|
+
/**
|
|
47
|
+
* 3-10 word hint used by `tool.search` keyword matching when this tool is
|
|
48
|
+
* deferred. Should describe domain ("filesystem read text"), not phrasing.
|
|
49
|
+
*/
|
|
50
|
+
searchHint?: string
|
|
51
|
+
/**
|
|
52
|
+
* Optional JSON Schema override for tools whose param shape isn't expressed
|
|
53
|
+
* as a top-level `z.object({})` (MCP tools, dynamically-discovered remote
|
|
54
|
+
* tools). When set, `registry.schemas()` and `tool.search` use this verbatim
|
|
55
|
+
* instead of running `zodToJsonSchema(schema)`. The `schema.safeParse()`
|
|
56
|
+
* still gates dispatch.
|
|
57
|
+
*/
|
|
58
|
+
parametersOverride?: JSONSchema
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ToolResult {
|
|
62
|
+
ok: boolean
|
|
63
|
+
data?: unknown
|
|
64
|
+
error?: string
|
|
65
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Some Mantle Compute providers (qwen3.6-plus among them) serialize tool-call
|
|
5
|
+
* boolean args as the strings "true"/"false" instead of JSON booleans. This
|
|
6
|
+
* accepts either form and falls back to actual booleans + 0/1 numbers.
|
|
7
|
+
*/
|
|
8
|
+
export const coerceBool: z.ZodType<boolean> = z.preprocess(v => {
|
|
9
|
+
if (typeof v === 'boolean') return v
|
|
10
|
+
if (typeof v === 'number') return v !== 0
|
|
11
|
+
if (typeof v === 'string') {
|
|
12
|
+
const lower = v.trim().toLowerCase()
|
|
13
|
+
if (lower === 'true' || lower === '1' || lower === 'yes') return true
|
|
14
|
+
if (lower === 'false' || lower === '0' || lower === 'no') return false
|
|
15
|
+
}
|
|
16
|
+
return v
|
|
17
|
+
}, z.boolean()) as unknown as z.ZodType<boolean>
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Same shape as coerceBool but for integers. qwen3.6-plus and other Mantle
|
|
21
|
+
* Compute providers sometimes serialize numeric tool-call args as strings
|
|
22
|
+
* ("400" instead of 400). zod's z.number() rejects them with
|
|
23
|
+
* "Expected number, received string". Wrap any numeric tool arg with
|
|
24
|
+
* `coerceInt` (or `coerceInt.refine(n => n > 0, 'must be positive')`) so the
|
|
25
|
+
* validation passes regardless of how the brain stringifies it.
|
|
26
|
+
*/
|
|
27
|
+
export const coerceInt: z.ZodType<number> = z.preprocess(v => {
|
|
28
|
+
if (typeof v === 'number') return v
|
|
29
|
+
if (typeof v === 'string') {
|
|
30
|
+
const trimmed = v.trim()
|
|
31
|
+
if (trimmed === '') return v
|
|
32
|
+
const n = Number(trimmed)
|
|
33
|
+
if (Number.isFinite(n) && Math.trunc(n) === n) return n
|
|
34
|
+
}
|
|
35
|
+
return v
|
|
36
|
+
}, z.number().int()) as unknown as z.ZodType<number>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { z } from 'zod'
|
|
2
|
+
import type { JSONSchema } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert a zod object schema to the minimal JSON Schema dialect Mantle Compute
|
|
6
|
+
* (and the OpenAI tool-calling format) expects. Handles: string, number,
|
|
7
|
+
* boolean, enum, optional, array, nested object. Good enough for phase 1-3
|
|
8
|
+
* MVP tools; revisit when we need deeper schema features.
|
|
9
|
+
*/
|
|
10
|
+
export function zodToJsonSchema(schema: z.ZodType, description?: string): JSONSchema {
|
|
11
|
+
const shape = unwrapObjectShape(schema)
|
|
12
|
+
if (!shape) throw new Error('Top-level tool schema must be a z.object({ ... })')
|
|
13
|
+
return objectShapeToJson(shape, description) as unknown as JSONSchema
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type ZodObjectLike = { _def: { typeName: string; shape: () => Record<string, z.ZodType> } }
|
|
17
|
+
|
|
18
|
+
function unwrapObjectShape(schema: z.ZodType): Record<string, z.ZodType> | null {
|
|
19
|
+
const s = schema as unknown as ZodObjectLike
|
|
20
|
+
if (s._def?.typeName === 'ZodObject') return s._def.shape()
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function unwrapOptional(schema: z.ZodType): { schema: z.ZodType; optional: boolean } {
|
|
25
|
+
const s = schema as unknown as {
|
|
26
|
+
_def: { typeName: string; innerType?: z.ZodType; schema?: z.ZodType }
|
|
27
|
+
}
|
|
28
|
+
if (s._def?.typeName === 'ZodOptional' || s._def?.typeName === 'ZodDefault') {
|
|
29
|
+
return { schema: s._def.innerType!, optional: true }
|
|
30
|
+
}
|
|
31
|
+
if (s._def?.typeName === 'ZodEffects') {
|
|
32
|
+
const inner = unwrapOptional(s._def.schema!)
|
|
33
|
+
return inner
|
|
34
|
+
}
|
|
35
|
+
return { schema, optional: false }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function objectShapeToJson(
|
|
39
|
+
shape: Record<string, z.ZodType>,
|
|
40
|
+
description?: string,
|
|
41
|
+
): Record<string, unknown> {
|
|
42
|
+
const properties: Record<string, unknown> = {}
|
|
43
|
+
const required: string[] = []
|
|
44
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
45
|
+
const { schema: prop, optional } = unwrapOptional(value)
|
|
46
|
+
properties[key] = zodTypeToJson(prop)
|
|
47
|
+
if (!optional) required.push(key)
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties,
|
|
52
|
+
...(required.length ? { required } : {}),
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
...(description ? { description } : {}),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function zodTypeToJson(schema: z.ZodType): unknown {
|
|
59
|
+
const s = schema as unknown as {
|
|
60
|
+
_def: {
|
|
61
|
+
typeName: string
|
|
62
|
+
description?: string
|
|
63
|
+
values?: string[]
|
|
64
|
+
type?: z.ZodType
|
|
65
|
+
shape?: () => Record<string, z.ZodType>
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const t = s._def.typeName
|
|
69
|
+
const description = s._def.description
|
|
70
|
+
|
|
71
|
+
switch (t) {
|
|
72
|
+
case 'ZodString':
|
|
73
|
+
return { type: 'string', ...(description ? { description } : {}) }
|
|
74
|
+
case 'ZodNumber':
|
|
75
|
+
return { type: 'number', ...(description ? { description } : {}) }
|
|
76
|
+
case 'ZodBoolean':
|
|
77
|
+
return { type: 'boolean', ...(description ? { description } : {}) }
|
|
78
|
+
case 'ZodEnum':
|
|
79
|
+
return {
|
|
80
|
+
type: 'string',
|
|
81
|
+
enum: s._def.values,
|
|
82
|
+
...(description ? { description } : {}),
|
|
83
|
+
}
|
|
84
|
+
case 'ZodArray':
|
|
85
|
+
return {
|
|
86
|
+
type: 'array',
|
|
87
|
+
items: zodTypeToJson(s._def.type!),
|
|
88
|
+
...(description ? { description } : {}),
|
|
89
|
+
}
|
|
90
|
+
case 'ZodObject':
|
|
91
|
+
return objectShapeToJson(s._def.shape?.() ?? {}, description)
|
|
92
|
+
case 'ZodEffects': {
|
|
93
|
+
const inner = (s._def as unknown as { schema: z.ZodType }).schema
|
|
94
|
+
return zodTypeToJson(inner)
|
|
95
|
+
}
|
|
96
|
+
default:
|
|
97
|
+
return { description: description ?? 'unspecified' }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type Address, type Hex, formatEther } from 'viem'
|
|
2
|
+
import { getGasPriceWithFloor, makeViemClients } from '../chain'
|
|
3
|
+
import type { NebulaNetwork } from '../config'
|
|
4
|
+
import { waitForReceiptResilient } from '../identity/receipt'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sweep an agent EOA's native balance to a target address. Reserves enough
|
|
8
|
+
* for the sweep tx itself (21000 gas at the live max-fee), so the resulting
|
|
9
|
+
* balance is "as close to 0 as the gas reserve allows" without underpaying.
|
|
10
|
+
*
|
|
11
|
+
* Used by `nebula drain` for fund recovery on a retiring agent. Does not
|
|
12
|
+
* touch the compute ledger; that's `nebula ledger refund`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface DrainAgentResult {
|
|
16
|
+
txHash: Hex
|
|
17
|
+
amountSent: bigint
|
|
18
|
+
gasReserved: bigint
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const SWEEP_GAS_LIMIT = 21_000n
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Pure helper: given balance + gasPrice + optional override, return the value
|
|
25
|
+
* to send, the gas reserve, and an error message if the balance can't cover
|
|
26
|
+
* the sweep. Lifted out of drainAgentEOA so it can be unit-tested without a
|
|
27
|
+
* live RPC.
|
|
28
|
+
*/
|
|
29
|
+
export function computeSweepAmount(opts: {
|
|
30
|
+
balance: bigint
|
|
31
|
+
gasPrice: bigint
|
|
32
|
+
agentAddress: Address
|
|
33
|
+
gasReserveOverride?: bigint
|
|
34
|
+
}): { value: bigint; gasReserve: bigint; error?: string } {
|
|
35
|
+
const gasReserve = opts.gasReserveOverride ?? SWEEP_GAS_LIMIT * opts.gasPrice
|
|
36
|
+
if (opts.balance <= gasReserve) {
|
|
37
|
+
return {
|
|
38
|
+
value: 0n,
|
|
39
|
+
gasReserve,
|
|
40
|
+
error: `agent EOA ${opts.agentAddress} has ${formatEther(opts.balance)} Mantle; below gas reserve ${formatEther(gasReserve)} Mantle`,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { value: opts.balance - gasReserve, gasReserve }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function drainAgentEOA(opts: {
|
|
47
|
+
network: NebulaNetwork
|
|
48
|
+
privkeyHex: Hex
|
|
49
|
+
to: Address
|
|
50
|
+
/** Override the gas reserve (in wei). Default = 21000 * live max-fee. */
|
|
51
|
+
gasReserveWei?: bigint
|
|
52
|
+
}): Promise<DrainAgentResult> {
|
|
53
|
+
const { account, publicClient, walletClient, chain } = makeViemClients({
|
|
54
|
+
network: opts.network,
|
|
55
|
+
privkeyHex: opts.privkeyHex,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const balance = await publicClient.getBalance({ address: account.address })
|
|
59
|
+
const gasPrice = await getGasPriceWithFloor(publicClient)
|
|
60
|
+
const sweep = computeSweepAmount({
|
|
61
|
+
balance,
|
|
62
|
+
gasPrice,
|
|
63
|
+
agentAddress: account.address,
|
|
64
|
+
gasReserveOverride: opts.gasReserveWei,
|
|
65
|
+
})
|
|
66
|
+
if (sweep.error) throw new Error(sweep.error)
|
|
67
|
+
|
|
68
|
+
const txHash = await walletClient.sendTransaction({
|
|
69
|
+
account,
|
|
70
|
+
chain,
|
|
71
|
+
to: opts.to,
|
|
72
|
+
value: sweep.value,
|
|
73
|
+
gas: SWEEP_GAS_LIMIT,
|
|
74
|
+
maxFeePerGas: gasPrice,
|
|
75
|
+
maxPriorityFeePerGas: gasPrice,
|
|
76
|
+
})
|
|
77
|
+
await waitForReceiptResilient(publicClient, txHash)
|
|
78
|
+
return { txHash, amountSent: sweep.value, gasReserved: sweep.gasReserve }
|
|
79
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { bytesToHex, hexToBytes } from 'viem'
|
|
4
|
+
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
|
|
5
|
+
import { type EncryptedKeystore, decryptKey, encryptKey } from './keystore'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Library-agnostic agent wallet material. Callers instantiate the right
|
|
9
|
+
* chain library (viem `privateKeyToAccount`, ethers `new Wallet(hex)`, etc.)
|
|
10
|
+
* at point of use.
|
|
11
|
+
*/
|
|
12
|
+
export interface AgentWalletMaterial {
|
|
13
|
+
/** 0x-prefixed hex private key. */
|
|
14
|
+
privkeyHex: `0x${string}`
|
|
15
|
+
/** EIP-55 address derived from the key. */
|
|
16
|
+
address: `0x${string}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function generateAgentWallet(): AgentWalletMaterial {
|
|
20
|
+
const privkeyHex = generatePrivateKey()
|
|
21
|
+
const account = privateKeyToAccount(privkeyHex)
|
|
22
|
+
return { privkeyHex, address: account.address }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function saveKeystore(
|
|
26
|
+
path: string,
|
|
27
|
+
privkeyHex: string,
|
|
28
|
+
passphrase: string,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const privkey = hexToBytes(privkeyHex as `0x${string}`)
|
|
31
|
+
const encrypted = encryptKey(privkey, passphrase)
|
|
32
|
+
await mkdir(dirname(path), { recursive: true })
|
|
33
|
+
await writeFile(path, JSON.stringify(encrypted, null, 2), 'utf8')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function loadKeystore(path: string, passphrase: string): Promise<AgentWalletMaterial> {
|
|
37
|
+
let raw: string
|
|
38
|
+
try {
|
|
39
|
+
raw = await readFile(path, 'utf8')
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
42
|
+
throw new Error(`Keystore not found at ${path}`)
|
|
43
|
+
}
|
|
44
|
+
throw e
|
|
45
|
+
}
|
|
46
|
+
const encrypted = JSON.parse(raw) as EncryptedKeystore
|
|
47
|
+
const privkey = decryptKey(encrypted, passphrase)
|
|
48
|
+
const privkeyHex = bytesToHex(privkey)
|
|
49
|
+
const account = privateKeyToAccount(privkeyHex)
|
|
50
|
+
return { privkeyHex, address: account.address }
|
|
51
|
+
}
|