@agentlayer.tech/wallet 0.1.55 → 0.1.64
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/.openclaw/extensions/agent-wallet/openclaw.plugin.json +1 -1
- package/.openclaw/extensions/agent-wallet/package.json +1 -1
- package/CHANGELOG.md +81 -0
- package/README.md +1 -1
- package/VERSION +1 -1
- package/agent-wallet/.env.example +1 -0
- package/agent-wallet/agent_wallet/__init__.py +1 -1
- package/agent-wallet/agent_wallet/boot_key_migration.py +158 -0
- package/agent-wallet/agent_wallet/boot_key_recovery.py +46 -0
- package/agent-wallet/agent_wallet/config.py +30 -1
- package/agent-wallet/agent_wallet/keystore.py +303 -0
- package/agent-wallet/agent_wallet/openclaw_cli.py +115 -39
- package/agent-wallet/agent_wallet/openclaw_runtime.py +28 -3
- package/agent-wallet/agent_wallet/providers/jupiter.py +34 -0
- package/agent-wallet/agent_wallet/user_wallets.py +46 -3
- package/agent-wallet/agent_wallet/wallet_layer/solana.py +34 -8
- package/agent-wallet/openclaw.plugin.json +1 -1
- package/agent-wallet/pyproject.toml +1 -1
- package/bin/openclaw-agent-wallet.mjs +73 -4
- package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
- package/claude-code/plugins/agent-wallet/commands/wallet-sol.md +9 -15
- package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/README.md +9 -0
- package/codex/plugins/agent-wallet/server.py +353 -4
- package/codex/plugins/agent-wallet/skills/wallet-sol/SKILL.md +29 -0
- package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
- package/package.json +1 -1
- package/wdk-btc-wallet/package.json +1 -1
- package/wdk-evm-wallet/package.json +1 -1
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import atexit
|
|
5
6
|
import asyncio
|
|
6
7
|
import copy
|
|
7
8
|
import base64
|
|
8
9
|
import hashlib
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
12
|
+
import selectors
|
|
13
|
+
import signal
|
|
11
14
|
import subprocess
|
|
12
15
|
import sys
|
|
13
16
|
import threading
|
|
@@ -75,15 +78,21 @@ APPROVAL_CONTEXT_MISSING_MESSAGE = (
|
|
|
75
78
|
"operation again, wait for explicit user confirmation, then retry execute. Do not ask the "
|
|
76
79
|
"user for a manual approval token."
|
|
77
80
|
)
|
|
81
|
+
RESIDENT_READ_ONLY_TOOLS = {
|
|
82
|
+
"get_wallet_balance",
|
|
83
|
+
"get_wallet_portfolio",
|
|
84
|
+
}
|
|
78
85
|
|
|
79
86
|
selected_wallet_backend: str | None = None
|
|
80
87
|
selected_solana_network: str | None = None
|
|
81
88
|
selected_evm_network: str | None = None
|
|
82
89
|
selected_btc_network: str | None = None
|
|
83
90
|
approval_preview_cache: dict[str, dict[str, Any]] = {}
|
|
91
|
+
resident_read_workers: dict[str, "_ResidentReadWorker"] = {}
|
|
84
92
|
# Guards approval_preview_cache against races once wallet calls run concurrently
|
|
85
93
|
# via asyncio.to_thread. Reentrant so prune helpers can be nested under writers.
|
|
86
94
|
_approval_cache_lock = threading.RLock()
|
|
95
|
+
_resident_worker_lock = threading.RLock()
|
|
87
96
|
|
|
88
97
|
|
|
89
98
|
class WalletCliError(RuntimeError):
|
|
@@ -93,6 +102,10 @@ class WalletCliError(RuntimeError):
|
|
|
93
102
|
self.details = details or {}
|
|
94
103
|
|
|
95
104
|
|
|
105
|
+
class ResidentReadWorkerTransportError(RuntimeError):
|
|
106
|
+
"""Raised when the long-lived read worker transport fails."""
|
|
107
|
+
|
|
108
|
+
|
|
96
109
|
def _plugin_root() -> Path:
|
|
97
110
|
return Path(__file__).resolve().parent
|
|
98
111
|
|
|
@@ -625,6 +638,311 @@ def _invoke_tool(tool_name: str, arguments: dict[str, Any], config: dict[str, An
|
|
|
625
638
|
)
|
|
626
639
|
|
|
627
640
|
|
|
641
|
+
class _ResidentReadWorker:
|
|
642
|
+
def __init__(self, *, user_id: str, config: dict[str, Any]):
|
|
643
|
+
self.user_id = user_id
|
|
644
|
+
self.config = copy.deepcopy(config)
|
|
645
|
+
self.package_root = _resolve_package_root()
|
|
646
|
+
self._process: subprocess.Popen[str] | None = None
|
|
647
|
+
self._lock = threading.RLock()
|
|
648
|
+
self._request_id = 0
|
|
649
|
+
self._stderr_lines: list[str] = []
|
|
650
|
+
self._stderr_thread: threading.Thread | None = None
|
|
651
|
+
self._last_used = time.monotonic()
|
|
652
|
+
|
|
653
|
+
def touch(self) -> None:
|
|
654
|
+
self._last_used = time.monotonic()
|
|
655
|
+
|
|
656
|
+
def idle_seconds(self) -> float:
|
|
657
|
+
return time.monotonic() - self._last_used
|
|
658
|
+
|
|
659
|
+
def _command(self) -> list[str]:
|
|
660
|
+
return [
|
|
661
|
+
_python_bin(self.package_root),
|
|
662
|
+
"-m",
|
|
663
|
+
"agent_wallet.openclaw_cli",
|
|
664
|
+
"read-worker",
|
|
665
|
+
"--user-id",
|
|
666
|
+
self.user_id,
|
|
667
|
+
"--config-json",
|
|
668
|
+
json.dumps(self.config),
|
|
669
|
+
]
|
|
670
|
+
|
|
671
|
+
def _drain_stderr(self) -> None:
|
|
672
|
+
process = self._process
|
|
673
|
+
if process is None or process.stderr is None:
|
|
674
|
+
return
|
|
675
|
+
try:
|
|
676
|
+
for raw_line in process.stderr:
|
|
677
|
+
line = raw_line.strip()
|
|
678
|
+
if not line:
|
|
679
|
+
continue
|
|
680
|
+
with self._lock:
|
|
681
|
+
self._stderr_lines.append(line)
|
|
682
|
+
if len(self._stderr_lines) > 20:
|
|
683
|
+
self._stderr_lines = self._stderr_lines[-20:]
|
|
684
|
+
except Exception:
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
def _stderr_summary(self) -> str:
|
|
688
|
+
with self._lock:
|
|
689
|
+
if not self._stderr_lines:
|
|
690
|
+
return ""
|
|
691
|
+
return " | ".join(self._stderr_lines[-5:])
|
|
692
|
+
|
|
693
|
+
def _ensure_started(self) -> subprocess.Popen[str]:
|
|
694
|
+
with self._lock:
|
|
695
|
+
process = self._process
|
|
696
|
+
if process is not None and process.poll() is None:
|
|
697
|
+
return process
|
|
698
|
+
try:
|
|
699
|
+
process = subprocess.Popen(
|
|
700
|
+
self._command(),
|
|
701
|
+
cwd=str(self.package_root),
|
|
702
|
+
env=_cli_env(self.package_root),
|
|
703
|
+
text=True,
|
|
704
|
+
stdin=subprocess.PIPE,
|
|
705
|
+
stdout=subprocess.PIPE,
|
|
706
|
+
stderr=subprocess.PIPE,
|
|
707
|
+
bufsize=1,
|
|
708
|
+
)
|
|
709
|
+
except Exception as exc:
|
|
710
|
+
raise ResidentReadWorkerTransportError(
|
|
711
|
+
f"Could not start resident read worker: {exc}"
|
|
712
|
+
) from exc
|
|
713
|
+
self._process = process
|
|
714
|
+
self._stderr_lines = []
|
|
715
|
+
self._stderr_thread = threading.Thread(
|
|
716
|
+
target=self._drain_stderr,
|
|
717
|
+
name="agent-wallet-read-worker-stderr",
|
|
718
|
+
daemon=True,
|
|
719
|
+
)
|
|
720
|
+
self._stderr_thread.start()
|
|
721
|
+
return process
|
|
722
|
+
|
|
723
|
+
def warm(self) -> None:
|
|
724
|
+
"""Spawn the worker eagerly, without waiting for a request/response.
|
|
725
|
+
|
|
726
|
+
Lets the interpreter boot, module imports, and read-only onboarding
|
|
727
|
+
happen off the critical path of the first real read-only tool call.
|
|
728
|
+
"""
|
|
729
|
+
try:
|
|
730
|
+
self._ensure_started()
|
|
731
|
+
except ResidentReadWorkerTransportError:
|
|
732
|
+
pass
|
|
733
|
+
|
|
734
|
+
def close(self) -> None:
|
|
735
|
+
with self._lock:
|
|
736
|
+
process = self._process
|
|
737
|
+
self._process = None
|
|
738
|
+
if process is None:
|
|
739
|
+
return
|
|
740
|
+
try:
|
|
741
|
+
if process.poll() is None and process.stdin is not None:
|
|
742
|
+
process.stdin.write(json.dumps({"op": "shutdown"}) + "\n")
|
|
743
|
+
process.stdin.flush()
|
|
744
|
+
except Exception:
|
|
745
|
+
pass
|
|
746
|
+
try:
|
|
747
|
+
process.wait(timeout=1)
|
|
748
|
+
except Exception:
|
|
749
|
+
try:
|
|
750
|
+
process.terminate()
|
|
751
|
+
process.wait(timeout=1)
|
|
752
|
+
except Exception:
|
|
753
|
+
try:
|
|
754
|
+
process.kill()
|
|
755
|
+
except Exception:
|
|
756
|
+
pass
|
|
757
|
+
|
|
758
|
+
def invoke(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
759
|
+
with self._lock:
|
|
760
|
+
self.touch()
|
|
761
|
+
process = self._ensure_started()
|
|
762
|
+
if process.stdin is None or process.stdout is None:
|
|
763
|
+
self.close()
|
|
764
|
+
raise ResidentReadWorkerTransportError("Resident read worker stdio is unavailable.")
|
|
765
|
+
self._request_id += 1
|
|
766
|
+
request_id = str(self._request_id)
|
|
767
|
+
request = {
|
|
768
|
+
"id": request_id,
|
|
769
|
+
"tool": tool_name,
|
|
770
|
+
"arguments": arguments,
|
|
771
|
+
}
|
|
772
|
+
try:
|
|
773
|
+
process.stdin.write(json.dumps(request) + "\n")
|
|
774
|
+
process.stdin.flush()
|
|
775
|
+
except Exception as exc:
|
|
776
|
+
self.close()
|
|
777
|
+
raise ResidentReadWorkerTransportError(
|
|
778
|
+
f"Could not write to resident read worker: {exc}"
|
|
779
|
+
) from exc
|
|
780
|
+
|
|
781
|
+
selector = selectors.DefaultSelector()
|
|
782
|
+
selector.register(process.stdout, selectors.EVENT_READ)
|
|
783
|
+
events = selector.select(timeout=_cli_timeout_seconds())
|
|
784
|
+
selector.close()
|
|
785
|
+
if not events:
|
|
786
|
+
self.close()
|
|
787
|
+
raise ResidentReadWorkerTransportError(
|
|
788
|
+
f"Resident read worker timed out after {_cli_timeout_seconds():g}s."
|
|
789
|
+
)
|
|
790
|
+
response_line = process.stdout.readline()
|
|
791
|
+
if not response_line:
|
|
792
|
+
self.close()
|
|
793
|
+
stderr_summary = self._stderr_summary()
|
|
794
|
+
detail = f" Worker stderr: {stderr_summary}" if stderr_summary else ""
|
|
795
|
+
raise ResidentReadWorkerTransportError(
|
|
796
|
+
"Resident read worker exited without a response." + detail
|
|
797
|
+
)
|
|
798
|
+
try:
|
|
799
|
+
response = json.loads(response_line)
|
|
800
|
+
except json.JSONDecodeError as exc:
|
|
801
|
+
self.close()
|
|
802
|
+
raise ResidentReadWorkerTransportError(
|
|
803
|
+
f"Resident read worker returned invalid JSON: {exc}"
|
|
804
|
+
) from exc
|
|
805
|
+
if str(response.get("id") or "") != request_id:
|
|
806
|
+
self.close()
|
|
807
|
+
raise ResidentReadWorkerTransportError(
|
|
808
|
+
"Resident read worker response id did not match the request."
|
|
809
|
+
)
|
|
810
|
+
if response.get("ok") is False:
|
|
811
|
+
raise WalletCliError(
|
|
812
|
+
str(response.get("error") or "resident read worker failed."),
|
|
813
|
+
code=str(response.get("code") or ""),
|
|
814
|
+
details=response.get("details")
|
|
815
|
+
if isinstance(response.get("details"), dict)
|
|
816
|
+
else {},
|
|
817
|
+
)
|
|
818
|
+
payload = response.get("payload")
|
|
819
|
+
if not isinstance(payload, dict):
|
|
820
|
+
raise ResidentReadWorkerTransportError(
|
|
821
|
+
"Resident read worker returned a non-object payload."
|
|
822
|
+
)
|
|
823
|
+
return payload
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _resident_worker_cache_key(user_id: str, config: dict[str, Any]) -> str:
|
|
827
|
+
return _canonical_json_text(
|
|
828
|
+
{
|
|
829
|
+
"user_id": user_id,
|
|
830
|
+
"config": config,
|
|
831
|
+
}
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _resident_worker_idle_seconds() -> float:
|
|
836
|
+
"""Idle threshold (seconds) after which an unused resident worker for a
|
|
837
|
+
config that is no longer the active one gets reaped. Falls back to 10
|
|
838
|
+
minutes on bad values."""
|
|
839
|
+
raw = os.getenv("AGENT_WALLET_READ_WORKER_IDLE_SECONDS", "600")
|
|
840
|
+
try:
|
|
841
|
+
value = float(raw)
|
|
842
|
+
except (TypeError, ValueError):
|
|
843
|
+
return 600.0
|
|
844
|
+
return value if value > 0 else 600.0
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _evict_idle_resident_read_workers(*, keep_key: str) -> None:
|
|
848
|
+
"""Close resident workers other than `keep_key` that have been idle past
|
|
849
|
+
the configured threshold.
|
|
850
|
+
|
|
851
|
+
Each distinct (user_id, config) pair (e.g. switching Solana network or
|
|
852
|
+
wallet backend mid-session) keeps its own resident subprocess alive
|
|
853
|
+
indefinitely otherwise, since nothing previously evicted them. This
|
|
854
|
+
bounds that growth without adding a background timer thread: eviction
|
|
855
|
+
piggybacks on the next lookup instead.
|
|
856
|
+
"""
|
|
857
|
+
idle_limit = _resident_worker_idle_seconds()
|
|
858
|
+
with _resident_worker_lock:
|
|
859
|
+
stale_keys = [
|
|
860
|
+
key
|
|
861
|
+
for key, worker in resident_read_workers.items()
|
|
862
|
+
if key != keep_key and worker.idle_seconds() > idle_limit
|
|
863
|
+
]
|
|
864
|
+
stale_workers = [resident_read_workers.pop(key) for key in stale_keys]
|
|
865
|
+
for worker in stale_workers:
|
|
866
|
+
worker.close()
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _resident_read_worker_for_config(user_id: str, config: dict[str, Any]) -> _ResidentReadWorker:
|
|
870
|
+
key = _resident_worker_cache_key(user_id, config)
|
|
871
|
+
_evict_idle_resident_read_workers(keep_key=key)
|
|
872
|
+
with _resident_worker_lock:
|
|
873
|
+
worker = resident_read_workers.get(key)
|
|
874
|
+
if worker is None:
|
|
875
|
+
worker = _ResidentReadWorker(user_id=user_id, config=config)
|
|
876
|
+
resident_read_workers[key] = worker
|
|
877
|
+
return worker
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _shutdown_resident_read_workers() -> None:
|
|
881
|
+
with _resident_worker_lock:
|
|
882
|
+
workers = list(resident_read_workers.values())
|
|
883
|
+
resident_read_workers.clear()
|
|
884
|
+
for worker in workers:
|
|
885
|
+
worker.close()
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
atexit.register(_shutdown_resident_read_workers)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _handle_termination_signal(signum: int, frame: Any) -> None:
|
|
892
|
+
"""Close resident read worker subprocesses on SIGTERM, then terminate
|
|
893
|
+
normally.
|
|
894
|
+
|
|
895
|
+
atexit handlers only run on normal interpreter shutdown; Python's
|
|
896
|
+
default SIGTERM disposition terminates the process immediately without
|
|
897
|
+
unwinding to atexit. Hosts that gracefully stop this MCP server (e.g.
|
|
898
|
+
closing a Claude Code / Codex session) send SIGTERM, so without this the
|
|
899
|
+
resident worker subprocesses leaked past every normal shutdown, not just
|
|
900
|
+
an abrupt kill -9. (SIGINT is not handled here: Python's default handler
|
|
901
|
+
raises KeyboardInterrupt, which unwinds to a normal interpreter
|
|
902
|
+
shutdown and already triggers the atexit hook above.)
|
|
903
|
+
"""
|
|
904
|
+
_shutdown_resident_read_workers()
|
|
905
|
+
signal.signal(signum, signal.SIG_DFL)
|
|
906
|
+
os.kill(os.getpid(), signum)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _install_termination_signal_handlers() -> None:
|
|
910
|
+
try:
|
|
911
|
+
signal.signal(signal.SIGTERM, _handle_termination_signal)
|
|
912
|
+
except (ValueError, OSError):
|
|
913
|
+
# e.g. signal.signal() called outside the main thread.
|
|
914
|
+
pass
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _prewarm_resident_read_worker() -> None:
|
|
918
|
+
"""Best-effort background warm-up of the default resident read worker.
|
|
919
|
+
|
|
920
|
+
server.py is a persistent stdio MCP process kept alive for the whole
|
|
921
|
+
host session, but the resident worker itself only used to spawn lazily
|
|
922
|
+
on the first read-only tool call (get_wallet_balance /
|
|
923
|
+
get_wallet_portfolio, e.g. /wallet-sol), putting interpreter boot +
|
|
924
|
+
onboarding on the critical path of that first call. Kick it off in a
|
|
925
|
+
daemon thread instead so it overlaps with the user issuing the command.
|
|
926
|
+
Failures here are silently ignored: the lazy path in
|
|
927
|
+
_invoke_read_tool_blocking remains the source of truth and will retry.
|
|
928
|
+
"""
|
|
929
|
+
if os.getenv("AGENT_WALLET_PREWARM_READ_WORKER", "1").strip().lower() in {"0", "false", "no"}:
|
|
930
|
+
return
|
|
931
|
+
|
|
932
|
+
def _run() -> None:
|
|
933
|
+
try:
|
|
934
|
+
config = _base_config({}, tool_name="get_wallet_portfolio")
|
|
935
|
+
_resident_read_worker_for_config(_user_id(), config).warm()
|
|
936
|
+
except Exception:
|
|
937
|
+
pass
|
|
938
|
+
|
|
939
|
+
threading.Thread(
|
|
940
|
+
target=_run,
|
|
941
|
+
name="agent-wallet-read-worker-prewarm",
|
|
942
|
+
daemon=True,
|
|
943
|
+
).start()
|
|
944
|
+
|
|
945
|
+
|
|
628
946
|
def _issue_approval_token(
|
|
629
947
|
tool_name: str,
|
|
630
948
|
config: dict[str, Any],
|
|
@@ -654,6 +972,15 @@ def _issue_approval_token(
|
|
|
654
972
|
return token
|
|
655
973
|
|
|
656
974
|
|
|
975
|
+
def _invoke_resident_read_tool(tool_name: str, arguments: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
|
976
|
+
worker = _resident_read_worker_for_config(_user_id(), config)
|
|
977
|
+
try:
|
|
978
|
+
return worker.invoke(tool_name, arguments)
|
|
979
|
+
except ResidentReadWorkerTransportError:
|
|
980
|
+
worker.close()
|
|
981
|
+
raise
|
|
982
|
+
|
|
983
|
+
|
|
657
984
|
def _is_solana_swap_intent_execute(params: dict[str, Any]) -> bool:
|
|
658
985
|
return str(params.get("mode") or "") == "intent_execute"
|
|
659
986
|
|
|
@@ -1033,7 +1360,9 @@ async def _handle_get_wallet_overview(params: dict[str, Any]) -> dict[str, Any]:
|
|
|
1033
1360
|
if params.get("address") is not None:
|
|
1034
1361
|
effective_params["address"] = params.get("address")
|
|
1035
1362
|
|
|
1036
|
-
payload = await asyncio.to_thread(
|
|
1363
|
+
payload = await asyncio.to_thread(
|
|
1364
|
+
_invoke_read_tool_blocking, "get_wallet_balance", effective_params, config
|
|
1365
|
+
)
|
|
1037
1366
|
if payload.get("ok") is False:
|
|
1038
1367
|
raise RuntimeError(str(payload.get("error") or "get_wallet_overview failed"))
|
|
1039
1368
|
data = payload.get("data", {})
|
|
@@ -1072,6 +1401,19 @@ def _invoke_wallet_tool_blocking(
|
|
|
1072
1401
|
return payload
|
|
1073
1402
|
|
|
1074
1403
|
|
|
1404
|
+
def _invoke_read_tool_blocking(
|
|
1405
|
+
tool_name: str,
|
|
1406
|
+
effective_params: dict[str, Any],
|
|
1407
|
+
config: dict[str, Any],
|
|
1408
|
+
) -> dict[str, Any]:
|
|
1409
|
+
if tool_name not in RESIDENT_READ_ONLY_TOOLS:
|
|
1410
|
+
return _invoke_tool(tool_name, effective_params, config)
|
|
1411
|
+
try:
|
|
1412
|
+
return _invoke_resident_read_tool(tool_name, effective_params, config)
|
|
1413
|
+
except ResidentReadWorkerTransportError:
|
|
1414
|
+
return _invoke_tool(tool_name, effective_params, config)
|
|
1415
|
+
|
|
1416
|
+
|
|
1075
1417
|
async def _handle_wallet_tool(tool_name: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
1076
1418
|
config = _base_config(params, tool_name=tool_name)
|
|
1077
1419
|
backend = _normalize_wallet_backend(config.get("backend"))
|
|
@@ -1080,9 +1422,14 @@ async def _handle_wallet_tool(tool_name: str, params: dict[str, Any]) -> dict[st
|
|
|
1080
1422
|
config["network"] = selected_evm_network
|
|
1081
1423
|
|
|
1082
1424
|
effective_params = dict(params)
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1425
|
+
if tool_name in RESIDENT_READ_ONLY_TOOLS:
|
|
1426
|
+
payload = await asyncio.to_thread(
|
|
1427
|
+
_invoke_read_tool_blocking, tool_name, effective_params, config
|
|
1428
|
+
)
|
|
1429
|
+
else:
|
|
1430
|
+
payload = await asyncio.to_thread(
|
|
1431
|
+
_invoke_wallet_tool_blocking, tool_name, config, effective_params
|
|
1432
|
+
)
|
|
1086
1433
|
|
|
1087
1434
|
if payload.get("ok") is False:
|
|
1088
1435
|
raise RuntimeError(str(payload.get("error") or f"{tool_name} failed"))
|
|
@@ -1173,6 +1520,8 @@ def build_server():
|
|
|
1173
1520
|
|
|
1174
1521
|
|
|
1175
1522
|
def main() -> None:
|
|
1523
|
+
_install_termination_signal_handlers()
|
|
1524
|
+
_prewarm_resident_read_worker()
|
|
1176
1525
|
build_server().run(show_banner=False)
|
|
1177
1526
|
|
|
1178
1527
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "wallet-sol"
|
|
3
|
+
description: "Show the current Solana wallet portfolio from the local AgentLayer wallet in a compact chat table. Use when the user asks for /wallet-sol or wants the Solana wallet shown directly in chat."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Solana Wallet Portfolio
|
|
7
|
+
|
|
8
|
+
Use the local AgentLayer wallet MCP tools only.
|
|
9
|
+
|
|
10
|
+
Workflow:
|
|
11
|
+
|
|
12
|
+
1. Call `set_wallet_backend` with `backend=solana`.
|
|
13
|
+
2. Call `get_wallet_portfolio` with no address override.
|
|
14
|
+
3. Render the result directly in chat in this shape:
|
|
15
|
+
- title: `Solana Wallet Portfolio`
|
|
16
|
+
- bullets for `Chain`, `Network`, `Address`, and `Total Value (USD)` when available
|
|
17
|
+
- one compact Markdown table with columns: `Asset | Type | Amount | USD Value`
|
|
18
|
+
|
|
19
|
+
Formatting rules:
|
|
20
|
+
|
|
21
|
+
- Use `assets` when present.
|
|
22
|
+
- For the asset label, prefer `symbol`, then `name`, then `mint`, then `token_address`, then `asset_type`.
|
|
23
|
+
- When the label came from `symbol`/`name` (not the mint/token_address itself) and a `mint` or `token_address` is present, append it shortened in parentheses next to the label as `first6…last4` (e.g. `USDC (EPjFWd…TDt1v)`) — keep the contract visible but compact, never show it twice.
|
|
24
|
+
- For the asset type, use `asset_type` when present; otherwise infer `native` for the native asset and `token` for the rest.
|
|
25
|
+
- For the amount, prefer `amount_ui`, then `balance_ui`, then `balance_native`, then `amount_raw`.
|
|
26
|
+
- For the USD value, prefer `value_usd`, then `balance_usd`.
|
|
27
|
+
- Do not include source metadata lines or extra footer fields such as `source`, `token_discovery_source`, `pricing_source`, or `pricing_errors`.
|
|
28
|
+
- Keep the response concise. Do not add strategy suggestions, swap ideas, or write actions unless the user explicitly asks for them.
|
|
29
|
+
- If the wallet tool fails, surface the tool error plainly and stop.
|
package/package.json
CHANGED