@elizaos/interop 2.0.0-alpha

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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +436 -0
  3. package/dist/packages/interop/tsconfig.tsbuildinfo +1 -0
  4. package/dist/tsconfig.tsbuildinfo +1 -0
  5. package/dist/typescript/index.d.ts +33 -0
  6. package/dist/typescript/index.d.ts.map +1 -0
  7. package/dist/typescript/index.js +121 -0
  8. package/dist/typescript/python-bridge.d.ts +80 -0
  9. package/dist/typescript/python-bridge.d.ts.map +1 -0
  10. package/dist/typescript/python-bridge.js +334 -0
  11. package/dist/typescript/types.d.ts +301 -0
  12. package/dist/typescript/types.d.ts.map +1 -0
  13. package/dist/typescript/types.js +10 -0
  14. package/dist/typescript/wasm-loader.d.ts +32 -0
  15. package/dist/typescript/wasm-loader.d.ts.map +1 -0
  16. package/dist/typescript/wasm-loader.js +269 -0
  17. package/package.json +43 -0
  18. package/python/__init__.py +50 -0
  19. package/python/__pycache__/__init__.cpython-313.pyc +0 -0
  20. package/python/__pycache__/rust_ffi.cpython-313.pyc +0 -0
  21. package/python/__pycache__/ts_bridge.cpython-313.pyc +0 -0
  22. package/python/__pycache__/wasm_loader.cpython-313.pyc +0 -0
  23. package/python/bridge_server.py +505 -0
  24. package/python/rust_ffi.py +418 -0
  25. package/python/tests/__init__.py +1 -0
  26. package/python/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/python/tests/__pycache__/test_bridge_server.cpython-313-pytest-9.0.2.pyc +0 -0
  28. package/python/tests/__pycache__/test_interop.cpython-313-pytest-9.0.2.pyc +0 -0
  29. package/python/tests/__pycache__/test_rust_ffi.cpython-313-pytest-9.0.2.pyc +0 -0
  30. package/python/tests/__pycache__/test_rust_ffi_loader.cpython-313-pytest-9.0.2.pyc +0 -0
  31. package/python/tests/test_bridge_server.py +525 -0
  32. package/python/tests/test_interop.py +319 -0
  33. package/python/tests/test_rust_ffi.py +352 -0
  34. package/python/tests/test_rust_ffi_loader.py +71 -0
  35. package/python/ts_bridge.py +452 -0
  36. package/python/ts_bridge_runner.mjs +284 -0
  37. package/python/wasm_loader.py +517 -0
  38. package/rust/ffi_exports.rs +362 -0
  39. package/rust/mod.rs +21 -0
  40. package/rust/py_loader.rs +412 -0
  41. package/rust/ts_loader.rs +467 -0
  42. package/rust/wasm_plugin.rs +320 -0
@@ -0,0 +1,517 @@
1
+ """
2
+ WASM Plugin Loader for elizaOS
3
+
4
+ This module provides utilities for loading WASM plugins into the Python runtime
5
+ via wasmtime or similar WASM runtimes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Awaitable
13
+
14
+ # Try to import wasmtime, provide fallback if not available
15
+ try:
16
+ import wasmtime
17
+ HAS_WASMTIME = True
18
+ except ImportError:
19
+ HAS_WASMTIME = False
20
+ wasmtime = None # type: ignore
21
+
22
+ from elizaos.types.plugin import Plugin
23
+ from elizaos.types.memory import Memory
24
+ from elizaos.types.state import State
25
+ from elizaos.types.components import (
26
+ Action,
27
+ ActionResult,
28
+ Provider,
29
+ ProviderResult,
30
+ Evaluator,
31
+ HandlerOptions,
32
+ )
33
+
34
+
35
+ class WasmPluginLoader:
36
+ """
37
+ WASM plugin loader for elizaOS.
38
+
39
+ Loads Rust or TypeScript plugins compiled to WASM and adapts them
40
+ to the Python Plugin interface.
41
+
42
+ Requires wasmtime-py: pip install wasmtime
43
+
44
+ The WASM plugin must export these functions:
45
+ - get_manifest() -> ptr (JSON string)
46
+ - init(config_ptr: i32, config_len: i32)
47
+ - validate_action(name_ptr, name_len, memory_ptr, memory_len, state_ptr, state_len) -> i32
48
+ - invoke_action(name_ptr, name_len, memory_ptr, memory_len, state_ptr, state_len, options_ptr, options_len) -> ptr
49
+ - get_provider(name_ptr, name_len, memory_ptr, memory_len, state_ptr, state_len) -> ptr
50
+ - validate_evaluator(name_ptr, name_len, memory_ptr, memory_len, state_ptr, state_len) -> i32
51
+ - invoke_evaluator(name_ptr, name_len, memory_ptr, memory_len, state_ptr, state_len) -> ptr
52
+ - alloc(size: i32) -> ptr
53
+ - dealloc(ptr: i32, size: i32)
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ wasm_path: str | Path,
59
+ *,
60
+ max_module_bytes: int | None = None,
61
+ max_memory_bytes: int | None = None,
62
+ fuel: int | None = None,
63
+ max_string_bytes: int = 1_000_000,
64
+ ) -> None:
65
+ """
66
+ Initialize the WASM plugin loader.
67
+
68
+ Args:
69
+ wasm_path: Path to the WASM file.
70
+
71
+ Raises:
72
+ ImportError: If wasmtime is not installed.
73
+ FileNotFoundError: If the WASM file doesn't exist.
74
+ """
75
+ if not HAS_WASMTIME:
76
+ raise ImportError(
77
+ "wasmtime is required for WASM plugin loading. "
78
+ "Install with: pip install wasmtime"
79
+ )
80
+
81
+ self.wasm_path = Path(wasm_path)
82
+ if not self.wasm_path.exists():
83
+ raise FileNotFoundError(f"WASM file not found: {wasm_path}")
84
+
85
+ if max_module_bytes is not None:
86
+ size = self.wasm_path.stat().st_size
87
+ if size > max_module_bytes:
88
+ raise ValueError(
89
+ f"WASM module too large ({size} bytes > {max_module_bytes} bytes)"
90
+ )
91
+
92
+ self.max_memory_bytes = max_memory_bytes
93
+ self.max_string_bytes = max_string_bytes
94
+
95
+ # Initialize wasmtime engine and store
96
+ if fuel is not None:
97
+ config = wasmtime.Config()
98
+ config.consume_fuel = True
99
+ self.engine = wasmtime.Engine(config)
100
+ else:
101
+ self.engine = wasmtime.Engine()
102
+ self.store = wasmtime.Store(self.engine)
103
+ if fuel is not None:
104
+ self.store.add_fuel(fuel)
105
+ self.linker = wasmtime.Linker(self.engine)
106
+
107
+ # Load and instantiate the module
108
+ self._setup_imports()
109
+ self._load_module()
110
+ self.manifest: dict[str, Any] | None = None
111
+
112
+ def _setup_imports(self) -> None:
113
+ """Set up the import functions for the WASM module."""
114
+ # WASI imports (minimal stubs)
115
+ wasi_config = wasmtime.WasiConfig()
116
+ wasi_config.inherit_stdout()
117
+ wasi_config.inherit_stderr()
118
+ self.store.set_wasi(wasi_config)
119
+ self.linker.define_wasi()
120
+
121
+ # Environment imports for console logging
122
+ @wasmtime.Func(self.store, wasmtime.FuncType([wasmtime.ValType.i32(), wasmtime.ValType.i32()], []))
123
+ def console_log(ptr: int, len_: int) -> None:
124
+ data = self._read_memory(ptr, len_)
125
+ print(data.decode("utf-8"))
126
+
127
+ @wasmtime.Func(self.store, wasmtime.FuncType([wasmtime.ValType.i32(), wasmtime.ValType.i32()], []))
128
+ def console_error(ptr: int, len_: int) -> None:
129
+ data = self._read_memory(ptr, len_)
130
+ print(f"[ERROR] {data.decode('utf-8')}")
131
+
132
+ self.linker.define(self.store, "env", "console_log", console_log)
133
+ self.linker.define(self.store, "env", "console_error", console_error)
134
+
135
+ def _load_module(self) -> None:
136
+ """Load and instantiate the WASM module."""
137
+ with open(self.wasm_path, "rb") as f:
138
+ wasm_bytes = f.read()
139
+
140
+ self.module = wasmtime.Module(self.engine, wasm_bytes)
141
+ self.instance = self.linker.instantiate(self.store, self.module)
142
+
143
+ # Get memory export
144
+ memory_export = self.instance.exports(self.store).get("memory")
145
+ if memory_export is None:
146
+ raise RuntimeError("WASM module does not export 'memory'")
147
+ self.memory = memory_export
148
+
149
+ if self.max_memory_bytes is not None:
150
+ mem_data = self.memory.data_ptr(self.store)
151
+ if len(mem_data) > self.max_memory_bytes:
152
+ raise RuntimeError(
153
+ f"WASM memory too large ({len(mem_data)} bytes > {self.max_memory_bytes} bytes)"
154
+ )
155
+
156
+ def _read_memory(self, ptr: int, length: int) -> bytes:
157
+ """Read bytes from WASM memory."""
158
+ data = self.memory.data_ptr(self.store)
159
+ return bytes(data[ptr:ptr + length])
160
+
161
+ def _write_memory(self, ptr: int, data: bytes) -> None:
162
+ """Write bytes to WASM memory."""
163
+ mem_data = self.memory.data_ptr(self.store)
164
+ for i, byte in enumerate(data):
165
+ mem_data[ptr + i] = byte
166
+
167
+ def _read_string(self, ptr: int) -> str:
168
+ """Read a null-terminated string from WASM memory."""
169
+ mem_data = self.memory.data_ptr(self.store)
170
+ end = ptr
171
+ limit = ptr + self.max_string_bytes
172
+ while end < len(mem_data) and end < limit and mem_data[end] != 0:
173
+ end += 1
174
+ if end >= len(mem_data) or end >= limit:
175
+ raise RuntimeError("WASM string exceeded maximum length or memory bounds")
176
+ return bytes(mem_data[ptr:end]).decode("utf-8")
177
+
178
+ def _call_with_string(self, func_name: str, *strings: str) -> str | None:
179
+ """Call a WASM function with string arguments and get string result."""
180
+ exports = self.instance.exports(self.store)
181
+ func = exports.get(func_name)
182
+ if func is None:
183
+ raise RuntimeError(f"WASM function '{func_name}' not found")
184
+
185
+ alloc = exports.get("alloc")
186
+ dealloc = exports.get("dealloc")
187
+ if alloc is None or dealloc is None:
188
+ raise RuntimeError("WASM module must export 'alloc' and 'dealloc'")
189
+
190
+ # Allocate and write each string
191
+ ptrs_and_lens: list[tuple[int, int]] = []
192
+ for s in strings:
193
+ encoded = s.encode("utf-8")
194
+ ptr = alloc(self.store, len(encoded))
195
+ self._write_memory(ptr, encoded)
196
+ ptrs_and_lens.append((ptr, len(encoded)))
197
+
198
+ # Build arguments (alternating ptr, len for each string)
199
+ args = []
200
+ for ptr, length in ptrs_and_lens:
201
+ args.extend([ptr, length])
202
+
203
+ # Call the function
204
+ result_ptr = func(self.store, *args)
205
+
206
+ # Cleanup input strings
207
+ for ptr, length in ptrs_and_lens:
208
+ dealloc(self.store, ptr, length)
209
+
210
+ if result_ptr == 0:
211
+ return None
212
+
213
+ return self._read_string(result_ptr)
214
+
215
+ def get_manifest(self) -> dict[str, Any]:
216
+ """Get the plugin manifest from the WASM module."""
217
+ if self.manifest is not None:
218
+ return self.manifest
219
+
220
+ exports = self.instance.exports(self.store)
221
+ get_manifest_fn = exports.get("get_manifest")
222
+ if get_manifest_fn is None:
223
+ raise RuntimeError("WASM module must export 'get_manifest'")
224
+
225
+ result_ptr = get_manifest_fn(self.store)
226
+ if result_ptr == 0:
227
+ raise RuntimeError("get_manifest returned null")
228
+
229
+ json_str = self._read_string(result_ptr)
230
+ self.manifest = json.loads(json_str)
231
+ return self.manifest
232
+
233
+ def init(self, config: dict[str, str]) -> None:
234
+ """Initialize the plugin with configuration."""
235
+ exports = self.instance.exports(self.store)
236
+ init_fn = exports.get("init")
237
+ if init_fn is None:
238
+ return # init is optional
239
+
240
+ alloc = exports.get("alloc")
241
+ if alloc is None:
242
+ return
243
+
244
+ config_json = json.dumps(config).encode("utf-8")
245
+ ptr = alloc(self.store, len(config_json))
246
+ self._write_memory(ptr, config_json)
247
+ init_fn(self.store, ptr, len(config_json))
248
+
249
+ def validate_action(self, name: str, memory: Memory, state: State | None) -> bool:
250
+ """Validate an action."""
251
+ result = self._call_validate("validate_action", name, memory, state)
252
+ return result != 0
253
+
254
+ def invoke_action(
255
+ self, name: str, memory: Memory, state: State | None, options: dict[str, Any] | None
256
+ ) -> ActionResult:
257
+ """Invoke an action."""
258
+ memory_json = json.dumps(memory.model_dump() if hasattr(memory, "model_dump") else memory)
259
+ state_json = json.dumps(
260
+ state.model_dump() if state and hasattr(state, "model_dump") else state
261
+ )
262
+ options_json = json.dumps(options or {})
263
+
264
+ result_json = self._call_with_string(
265
+ "invoke_action", name, memory_json, state_json, options_json
266
+ )
267
+ if not result_json:
268
+ return ActionResult(success=False, error="No result from WASM plugin")
269
+
270
+ result_data = json.loads(result_json)
271
+ return ActionResult(**result_data)
272
+
273
+ def get_provider(self, name: str, memory: Memory, state: State) -> ProviderResult:
274
+ """Get provider data."""
275
+ memory_json = json.dumps(memory.model_dump() if hasattr(memory, "model_dump") else memory)
276
+ state_json = json.dumps(state.model_dump() if hasattr(state, "model_dump") else state)
277
+
278
+ result_json = self._call_with_string("get_provider", name, memory_json, state_json)
279
+ if not result_json:
280
+ return ProviderResult()
281
+
282
+ result_data = json.loads(result_json)
283
+ return ProviderResult(**result_data)
284
+
285
+ def validate_evaluator(self, name: str, memory: Memory, state: State | None) -> bool:
286
+ """Validate an evaluator."""
287
+ result = self._call_validate("validate_evaluator", name, memory, state)
288
+ return result != 0
289
+
290
+ def invoke_evaluator(self, name: str, memory: Memory, state: State | None) -> ActionResult | None:
291
+ """Invoke an evaluator."""
292
+ memory_json = json.dumps(memory.model_dump() if hasattr(memory, "model_dump") else memory)
293
+ state_json = json.dumps(
294
+ state.model_dump() if state and hasattr(state, "model_dump") else state
295
+ )
296
+
297
+ result_json = self._call_with_string("invoke_evaluator", name, memory_json, state_json)
298
+ if not result_json or result_json == "null":
299
+ return None
300
+
301
+ result_data = json.loads(result_json)
302
+ return ActionResult(**result_data)
303
+
304
+ def _call_validate(
305
+ self, func_name: str, name: str, memory: Memory, state: State | None
306
+ ) -> int:
307
+ """Call a validation function."""
308
+ exports = self.instance.exports(self.store)
309
+ func = exports.get(func_name)
310
+ alloc = exports.get("alloc")
311
+ dealloc = exports.get("dealloc")
312
+
313
+ if func is None or alloc is None or dealloc is None:
314
+ return 0
315
+
316
+ name_bytes = name.encode("utf-8")
317
+ memory_json = json.dumps(
318
+ memory.model_dump() if hasattr(memory, "model_dump") else memory
319
+ ).encode("utf-8")
320
+ state_json = json.dumps(
321
+ state.model_dump() if state and hasattr(state, "model_dump") else state
322
+ ).encode("utf-8")
323
+
324
+ name_ptr = alloc(self.store, len(name_bytes))
325
+ mem_ptr = alloc(self.store, len(memory_json))
326
+ state_ptr = alloc(self.store, len(state_json))
327
+
328
+ self._write_memory(name_ptr, name_bytes)
329
+ self._write_memory(mem_ptr, memory_json)
330
+ self._write_memory(state_ptr, state_json)
331
+
332
+ result = func(
333
+ self.store,
334
+ name_ptr, len(name_bytes),
335
+ mem_ptr, len(memory_json),
336
+ state_ptr, len(state_json),
337
+ )
338
+
339
+ dealloc(self.store, name_ptr, len(name_bytes))
340
+ dealloc(self.store, mem_ptr, len(memory_json))
341
+ dealloc(self.store, state_ptr, len(state_json))
342
+
343
+ return result
344
+
345
+
346
+ def load_wasm_plugin(
347
+ wasm_path: str | Path,
348
+ manifest_path: str | Path | None = None,
349
+ *,
350
+ max_module_bytes: int | None = None,
351
+ max_memory_bytes: int | None = None,
352
+ fuel: int | None = None,
353
+ ) -> Plugin:
354
+ """
355
+ Load a WASM plugin and return an elizaOS Plugin interface.
356
+
357
+ Args:
358
+ wasm_path: Path to the WASM file.
359
+ manifest_path: Optional path to external manifest JSON (uses embedded if not provided).
360
+
361
+ Returns:
362
+ elizaOS Plugin instance.
363
+ """
364
+ loader = WasmPluginLoader(
365
+ wasm_path,
366
+ max_module_bytes=max_module_bytes,
367
+ max_memory_bytes=max_memory_bytes,
368
+ fuel=fuel,
369
+ )
370
+
371
+ # Get manifest
372
+ if manifest_path:
373
+ with open(manifest_path) as f:
374
+ manifest = json.load(f)
375
+ else:
376
+ manifest = loader.get_manifest()
377
+
378
+ # Create action wrappers
379
+ actions: list[Action] = []
380
+ for action_def in manifest.get("actions", []):
381
+ action_name = action_def["name"]
382
+
383
+ def make_validate(name: str) -> Callable[..., Awaitable[bool]]:
384
+ async def validate(runtime: Any, message: Memory, state: State | None) -> bool:
385
+ return loader.validate_action(name, message, state)
386
+ return validate
387
+
388
+ def make_handler(name: str) -> Callable[..., Awaitable[ActionResult | None]]:
389
+ async def handler(
390
+ runtime: Any,
391
+ message: Memory,
392
+ state: State | None,
393
+ options: HandlerOptions | None,
394
+ callback: Any,
395
+ responses: Any,
396
+ ) -> ActionResult | None:
397
+ return loader.invoke_action(
398
+ name, message, state, options.model_dump() if options else None
399
+ )
400
+ return handler
401
+
402
+ validate_fn = make_validate(action_name)
403
+ handler_fn = make_handler(action_name)
404
+
405
+ actions.append(
406
+ Action(
407
+ name=action_name,
408
+ description=action_def.get("description", ""),
409
+ similes=action_def.get("similes"),
410
+ examples=action_def.get("examples"),
411
+ validate=validate_fn, # type: ignore
412
+ handler=handler_fn, # type: ignore
413
+ )
414
+ )
415
+
416
+ # Create provider wrappers
417
+ providers: list[Provider] = []
418
+ for provider_def in manifest.get("providers", []):
419
+ provider_name = provider_def["name"]
420
+
421
+ def make_get(name: str) -> Callable[..., Awaitable[ProviderResult]]:
422
+ async def get(runtime: Any, message: Memory, state: State) -> ProviderResult:
423
+ return loader.get_provider(name, message, state)
424
+ return get
425
+
426
+ get_fn = make_get(provider_name)
427
+
428
+ providers.append(
429
+ Provider(
430
+ name=provider_name,
431
+ description=provider_def.get("description"),
432
+ dynamic=provider_def.get("dynamic"),
433
+ position=provider_def.get("position"),
434
+ private=provider_def.get("private"),
435
+ get=get_fn, # type: ignore
436
+ )
437
+ )
438
+
439
+ # Create evaluator wrappers
440
+ evaluators: list[Evaluator] = []
441
+ for eval_def in manifest.get("evaluators", []):
442
+ eval_name = eval_def["name"]
443
+
444
+ def make_eval_validate(name: str) -> Callable[..., Awaitable[bool]]:
445
+ async def validate(runtime: Any, message: Memory, state: State | None) -> bool:
446
+ return loader.validate_evaluator(name, message, state)
447
+ return validate
448
+
449
+ def make_eval_handler(name: str) -> Callable[..., Awaitable[ActionResult | None]]:
450
+ async def handler(
451
+ runtime: Any,
452
+ message: Memory,
453
+ state: State | None,
454
+ options: HandlerOptions | None,
455
+ callback: Any,
456
+ responses: Any,
457
+ ) -> ActionResult | None:
458
+ return loader.invoke_evaluator(name, message, state)
459
+ return handler
460
+
461
+ validate_fn = make_eval_validate(eval_name)
462
+ handler_fn = make_eval_handler(eval_name)
463
+
464
+ evaluators.append(
465
+ Evaluator(
466
+ name=eval_name,
467
+ description=eval_def.get("description", ""),
468
+ always_run=eval_def.get("alwaysRun"),
469
+ similes=eval_def.get("similes"),
470
+ examples=[],
471
+ validate=validate_fn, # type: ignore
472
+ handler=handler_fn, # type: ignore
473
+ )
474
+ )
475
+
476
+ # Create init function
477
+ async def init(config: dict[str, str], runtime: Any) -> None:
478
+ loader.init(config)
479
+
480
+ return Plugin(
481
+ name=manifest["name"],
482
+ description=manifest["description"],
483
+ init=init,
484
+ config=manifest.get("config"),
485
+ dependencies=manifest.get("dependencies"),
486
+ actions=actions if actions else None,
487
+ providers=providers if providers else None,
488
+ evaluators=evaluators if evaluators else None,
489
+ )
490
+
491
+
492
+ def validate_wasm_plugin(wasm_path: str | Path) -> dict[str, Any]:
493
+ """
494
+ Validate a WASM plugin without fully loading it.
495
+
496
+ Args:
497
+ wasm_path: Path to the WASM file.
498
+
499
+ Returns:
500
+ Dict with 'valid', 'manifest', and optionally 'error' keys.
501
+ """
502
+ try:
503
+ loader = WasmPluginLoader(wasm_path)
504
+ manifest = loader.get_manifest()
505
+
506
+ if not manifest.get("name") or not manifest.get("description"):
507
+ return {"valid": False, "error": "Missing required manifest fields"}
508
+
509
+ return {"valid": True, "manifest": manifest}
510
+ except Exception as e:
511
+ return {"valid": False, "error": str(e)}
512
+
513
+
514
+
515
+
516
+
517
+