fix(honcho): enforce local mode and cache-safe warmup
This commit is contained in:
parent
c047c03e82
commit
87cc5287a8
2 changed files with 212 additions and 147 deletions
265
run_agent.py
265
run_agent.py
|
|
@ -183,6 +183,8 @@ class AIAgent:
|
||||||
skip_memory: bool = False,
|
skip_memory: bool = False,
|
||||||
session_db=None,
|
session_db=None,
|
||||||
honcho_session_key: str = None,
|
honcho_session_key: str = None,
|
||||||
|
honcho_manager=None,
|
||||||
|
honcho_config=None,
|
||||||
iteration_budget: "IterationBudget" = None,
|
iteration_budget: "IterationBudget" = None,
|
||||||
fallback_model: Dict[str, Any] = None,
|
fallback_model: Dict[str, Any] = None,
|
||||||
checkpoints_enabled: bool = False,
|
checkpoints_enabled: bool = False,
|
||||||
|
|
@ -228,6 +230,8 @@ class AIAgent:
|
||||||
polluting trajectories with user-specific persona or project instructions.
|
polluting trajectories with user-specific persona or project instructions.
|
||||||
honcho_session_key (str): Session key for Honcho integration (e.g., "telegram:123456" or CLI session_id).
|
honcho_session_key (str): Session key for Honcho integration (e.g., "telegram:123456" or CLI session_id).
|
||||||
When provided and Honcho is enabled in config, enables persistent cross-session user modeling.
|
When provided and Honcho is enabled in config, enables persistent cross-session user modeling.
|
||||||
|
honcho_manager: Optional shared HonchoSessionManager owned by the caller.
|
||||||
|
honcho_config: Optional HonchoClientConfig corresponding to honcho_manager.
|
||||||
"""
|
"""
|
||||||
self.model = model
|
self.model = model
|
||||||
self.max_iterations = max_iterations
|
self.max_iterations = max_iterations
|
||||||
|
|
@ -548,10 +552,22 @@ class AIAgent:
|
||||||
self._honcho_config = None # HonchoClientConfig | None
|
self._honcho_config = None # HonchoClientConfig | None
|
||||||
if not skip_memory:
|
if not skip_memory:
|
||||||
try:
|
try:
|
||||||
|
if honcho_manager is not None:
|
||||||
|
hcfg = honcho_config or getattr(honcho_manager, "_config", None)
|
||||||
|
self._honcho_config = hcfg
|
||||||
|
if hcfg and self._honcho_should_activate(hcfg):
|
||||||
|
self._honcho = honcho_manager
|
||||||
|
self._activate_honcho(
|
||||||
|
hcfg,
|
||||||
|
enabled_toolsets=enabled_toolsets,
|
||||||
|
disabled_toolsets=disabled_toolsets,
|
||||||
|
session_db=session_db,
|
||||||
|
)
|
||||||
|
else:
|
||||||
from honcho_integration.client import HonchoClientConfig, get_honcho_client
|
from honcho_integration.client import HonchoClientConfig, get_honcho_client
|
||||||
hcfg = HonchoClientConfig.from_global_config()
|
hcfg = HonchoClientConfig.from_global_config()
|
||||||
self._honcho_config = hcfg
|
self._honcho_config = hcfg
|
||||||
if hcfg.enabled and hcfg.api_key:
|
if self._honcho_should_activate(hcfg):
|
||||||
from honcho_integration.session import HonchoSessionManager
|
from honcho_integration.session import HonchoSessionManager
|
||||||
client = get_honcho_client(hcfg)
|
client = get_honcho_client(hcfg)
|
||||||
self._honcho = HonchoSessionManager(
|
self._honcho = HonchoSessionManager(
|
||||||
|
|
@ -559,123 +575,19 @@ class AIAgent:
|
||||||
config=hcfg,
|
config=hcfg,
|
||||||
context_tokens=hcfg.context_tokens,
|
context_tokens=hcfg.context_tokens,
|
||||||
)
|
)
|
||||||
# Resolve session key: explicit arg > sessions map > title > per-session id > directory
|
self._activate_honcho(
|
||||||
if not self._honcho_session_key:
|
hcfg,
|
||||||
# Pull title from SessionDB if available
|
|
||||||
session_title = None
|
|
||||||
if session_db is not None:
|
|
||||||
try:
|
|
||||||
session_title = session_db.get_session_title(session_id or "")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._honcho_session_key = (
|
|
||||||
hcfg.resolve_session_name(
|
|
||||||
session_title=session_title,
|
|
||||||
session_id=self.session_id,
|
|
||||||
)
|
|
||||||
or "hermes-default"
|
|
||||||
)
|
|
||||||
# Ensure session exists in Honcho; migrate local data on first activation
|
|
||||||
honcho_sess = self._honcho.get_or_create(self._honcho_session_key)
|
|
||||||
if not honcho_sess.messages:
|
|
||||||
# New Honcho session — migrate any existing local data
|
|
||||||
_conv = getattr(self, 'conversation_history', None) or []
|
|
||||||
if _conv:
|
|
||||||
try:
|
|
||||||
self._honcho.migrate_local_history(
|
|
||||||
self._honcho_session_key, _conv
|
|
||||||
)
|
|
||||||
logger.info("Migrated %d local messages to Honcho", len(_conv))
|
|
||||||
except Exception as _e:
|
|
||||||
logger.debug("Local history migration failed (non-fatal): %s", _e)
|
|
||||||
try:
|
|
||||||
from hermes_cli.config import get_hermes_home
|
|
||||||
_mem_dir = str(get_hermes_home() / "memories")
|
|
||||||
self._honcho.migrate_memory_files(
|
|
||||||
self._honcho_session_key, _mem_dir
|
|
||||||
)
|
|
||||||
except Exception as _e:
|
|
||||||
logger.debug("Memory files migration failed (non-fatal): %s", _e)
|
|
||||||
# Inject session context into the honcho tool module
|
|
||||||
from tools.honcho_tools import set_session_context
|
|
||||||
set_session_context(self._honcho, self._honcho_session_key)
|
|
||||||
|
|
||||||
# In "context" mode, skip honcho tool registration entirely —
|
|
||||||
# all memory retrieval comes from the pre-warmed system prompt.
|
|
||||||
if hcfg.recall_mode != "context":
|
|
||||||
# Rebuild tool definitions now that Honcho check_fn will pass.
|
|
||||||
# (Tools were built before Honcho init, so honcho_context
|
|
||||||
# was filtered out by _check_honcho_available() returning False.)
|
|
||||||
self.tools = get_tool_definitions(
|
|
||||||
enabled_toolsets=enabled_toolsets,
|
enabled_toolsets=enabled_toolsets,
|
||||||
disabled_toolsets=disabled_toolsets,
|
disabled_toolsets=disabled_toolsets,
|
||||||
quiet_mode=True, # already printed tool list above
|
session_db=session_db,
|
||||||
)
|
)
|
||||||
self.valid_tool_names = {
|
|
||||||
tool["function"]["name"] for tool in self.tools
|
|
||||||
} if self.tools else set()
|
|
||||||
if not self.quiet_mode:
|
|
||||||
print(f" Honcho active — recall_mode: {hcfg.recall_mode}")
|
|
||||||
else:
|
|
||||||
if not self.quiet_mode:
|
|
||||||
print(" Honcho active — recall_mode: context (tools suppressed)")
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Honcho active (session: %s, user: %s, workspace: %s, "
|
|
||||||
"write_frequency: %s, memory_mode: %s)",
|
|
||||||
self._honcho_session_key, hcfg.peer_name, hcfg.workspace_id,
|
|
||||||
hcfg.write_frequency, hcfg.memory_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Warm caches when recall_mode allows pre-loaded context.
|
|
||||||
# "tools" mode skips warm entirely (tool calls handle recall).
|
|
||||||
_recall_mode = hcfg.recall_mode
|
|
||||||
if _recall_mode != "tools":
|
|
||||||
try:
|
|
||||||
_ctx = self._honcho.get_prefetch_context(self._honcho_session_key)
|
|
||||||
if _ctx:
|
|
||||||
self._honcho._context_cache[self._honcho_session_key] = _ctx
|
|
||||||
logger.debug("Honcho context pre-warmed for first turn")
|
|
||||||
except Exception as _e:
|
|
||||||
logger.debug("Honcho context prefetch failed (non-fatal): %s", _e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
_cwd = os.path.basename(os.getcwd())
|
|
||||||
_dialectic = self._honcho.dialectic_query(
|
|
||||||
self._honcho_session_key,
|
|
||||||
f"What has the user been working on recently in {_cwd}? "
|
|
||||||
"Summarize the current project context and where we left off.",
|
|
||||||
)
|
|
||||||
if _dialectic:
|
|
||||||
self._honcho._dialectic_cache[self._honcho_session_key] = _dialectic
|
|
||||||
logger.debug("Honcho dialectic pre-warmed for first turn")
|
|
||||||
except Exception as _e:
|
|
||||||
logger.debug("Honcho dialectic prefetch failed (non-fatal): %s", _e)
|
|
||||||
|
|
||||||
# Register SIGTERM/SIGINT handlers to flush pending async writes
|
|
||||||
# before the process exits. signal.signal() only works on the main
|
|
||||||
# thread; AIAgent may be initialised from a worker thread in cli.py.
|
|
||||||
import signal as _signal
|
|
||||||
import threading as _threading
|
|
||||||
_honcho_ref = self._honcho
|
|
||||||
|
|
||||||
if _threading.current_thread() is _threading.main_thread():
|
|
||||||
def _honcho_flush_handler(signum, frame):
|
|
||||||
try:
|
|
||||||
_honcho_ref.flush_all()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if signum == _signal.SIGINT:
|
|
||||||
raise KeyboardInterrupt
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
_signal.signal(_signal.SIGTERM, _honcho_flush_handler)
|
|
||||||
_signal.signal(_signal.SIGINT, _honcho_flush_handler)
|
|
||||||
else:
|
else:
|
||||||
if not hcfg.enabled:
|
if not hcfg.enabled:
|
||||||
logger.debug("Honcho disabled in global config")
|
logger.debug("Honcho disabled in global config")
|
||||||
elif not hcfg.api_key:
|
elif not hcfg.api_key:
|
||||||
logger.debug("Honcho enabled but no API key configured")
|
logger.debug("Honcho enabled but no API key configured")
|
||||||
|
else:
|
||||||
|
logger.debug("Honcho local-only mode active; remote Honcho init skipped")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Honcho init failed — memory disabled: %s", e)
|
logger.warning("Honcho init failed — memory disabled: %s", e)
|
||||||
print(f" Honcho init failed: {e}")
|
print(f" Honcho init failed: {e}")
|
||||||
|
|
@ -1433,16 +1345,113 @@ class AIAgent:
|
||||||
|
|
||||||
# ── Honcho integration helpers ──
|
# ── Honcho integration helpers ──
|
||||||
|
|
||||||
|
def _honcho_should_activate(self, hcfg) -> bool:
|
||||||
|
"""Return True when remote Honcho should be active."""
|
||||||
|
if not hcfg or not hcfg.enabled or not hcfg.api_key:
|
||||||
|
return False
|
||||||
|
return not all(
|
||||||
|
hcfg.peer_memory_mode(peer) == "local"
|
||||||
|
for peer in (hcfg.ai_peer, hcfg.peer_name or "user")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _activate_honcho(
|
||||||
|
self,
|
||||||
|
hcfg,
|
||||||
|
*,
|
||||||
|
enabled_toolsets: Optional[List[str]],
|
||||||
|
disabled_toolsets: Optional[List[str]],
|
||||||
|
session_db,
|
||||||
|
) -> None:
|
||||||
|
"""Finish Honcho setup once a session manager is available."""
|
||||||
|
if not self._honcho:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._honcho_session_key:
|
||||||
|
session_title = None
|
||||||
|
if session_db is not None:
|
||||||
|
try:
|
||||||
|
session_title = session_db.get_session_title(self.session_id or "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._honcho_session_key = (
|
||||||
|
hcfg.resolve_session_name(
|
||||||
|
session_title=session_title,
|
||||||
|
session_id=self.session_id,
|
||||||
|
)
|
||||||
|
or "hermes-default"
|
||||||
|
)
|
||||||
|
|
||||||
|
honcho_sess = self._honcho.get_or_create(self._honcho_session_key)
|
||||||
|
if not honcho_sess.messages:
|
||||||
|
try:
|
||||||
|
from hermes_cli.config import get_hermes_home
|
||||||
|
|
||||||
|
mem_dir = str(get_hermes_home() / "memories")
|
||||||
|
self._honcho.migrate_memory_files(
|
||||||
|
self._honcho_session_key,
|
||||||
|
mem_dir,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Memory files migration failed (non-fatal): %s", exc)
|
||||||
|
|
||||||
|
from tools.honcho_tools import set_session_context
|
||||||
|
|
||||||
|
set_session_context(self._honcho, self._honcho_session_key)
|
||||||
|
|
||||||
|
if hcfg.recall_mode != "context":
|
||||||
|
self.tools = get_tool_definitions(
|
||||||
|
enabled_toolsets=enabled_toolsets,
|
||||||
|
disabled_toolsets=disabled_toolsets,
|
||||||
|
quiet_mode=True,
|
||||||
|
)
|
||||||
|
self.valid_tool_names = {
|
||||||
|
tool["function"]["name"] for tool in self.tools
|
||||||
|
} if self.tools else set()
|
||||||
|
if not self.quiet_mode:
|
||||||
|
print(f" Honcho active — recall_mode: {hcfg.recall_mode}")
|
||||||
|
elif not self.quiet_mode:
|
||||||
|
print(" Honcho active — recall_mode: context (tools suppressed)")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Honcho active (session: %s, user: %s, workspace: %s, "
|
||||||
|
"write_frequency: %s, memory_mode: %s)",
|
||||||
|
self._honcho_session_key,
|
||||||
|
hcfg.peer_name,
|
||||||
|
hcfg.workspace_id,
|
||||||
|
hcfg.write_frequency,
|
||||||
|
hcfg.memory_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
recall_mode = hcfg.recall_mode
|
||||||
|
if recall_mode != "tools":
|
||||||
|
try:
|
||||||
|
ctx = self._honcho.get_prefetch_context(self._honcho_session_key)
|
||||||
|
if ctx:
|
||||||
|
self._honcho._context_cache[self._honcho_session_key] = ctx
|
||||||
|
logger.debug("Honcho context pre-warmed for first turn")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Honcho context prefetch failed (non-fatal): %s", exc)
|
||||||
|
|
||||||
|
import signal as _signal
|
||||||
|
import threading as _threading
|
||||||
|
|
||||||
|
honcho_ref = self._honcho
|
||||||
|
|
||||||
|
if _threading.current_thread() is _threading.main_thread():
|
||||||
|
def _honcho_flush_handler(signum, frame):
|
||||||
|
try:
|
||||||
|
honcho_ref.flush_all()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if signum == _signal.SIGINT:
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
_signal.signal(_signal.SIGTERM, _honcho_flush_handler)
|
||||||
|
_signal.signal(_signal.SIGINT, _honcho_flush_handler)
|
||||||
|
|
||||||
def _honcho_prefetch(self, user_message: str) -> str:
|
def _honcho_prefetch(self, user_message: str) -> str:
|
||||||
"""Assemble Honcho context from cached background fetches.
|
"""Assemble the first-turn Honcho context from the pre-warmed cache."""
|
||||||
|
|
||||||
Both session.context() and peer.chat() (dialectic) are fired as
|
|
||||||
background threads at the end of each turn via _honcho_fire_prefetch().
|
|
||||||
This method just reads the cached results — no blocking HTTP calls.
|
|
||||||
|
|
||||||
First turn uses synchronously pre-warmed caches from init.
|
|
||||||
Subsequent turns use async prefetch results from the previous turn end.
|
|
||||||
"""
|
|
||||||
if not self._honcho or not self._honcho_session_key:
|
if not self._honcho or not self._honcho_session_key:
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
|
|
@ -1463,10 +1472,6 @@ class AIAgent:
|
||||||
if ai_card:
|
if ai_card:
|
||||||
parts.append(ai_card)
|
parts.append(ai_card)
|
||||||
|
|
||||||
dialectic = self._honcho.pop_dialectic_result(self._honcho_session_key)
|
|
||||||
if dialectic:
|
|
||||||
parts.append(f"[Honcho dialectic]\n{dialectic}")
|
|
||||||
|
|
||||||
if not parts:
|
if not parts:
|
||||||
return ""
|
return ""
|
||||||
header = (
|
header = (
|
||||||
|
|
@ -1480,13 +1485,6 @@ class AIAgent:
|
||||||
logger.debug("Honcho prefetch failed (non-fatal): %s", e)
|
logger.debug("Honcho prefetch failed (non-fatal): %s", e)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _honcho_fire_prefetch(self, user_message: str) -> None:
|
|
||||||
"""Fire both Honcho background fetches for the next turn (non-blocking)."""
|
|
||||||
if not self._honcho or not self._honcho_session_key:
|
|
||||||
return
|
|
||||||
self._honcho.prefetch_context(self._honcho_session_key, user_message)
|
|
||||||
self._honcho.prefetch_dialectic(self._honcho_session_key, user_message)
|
|
||||||
|
|
||||||
def _honcho_save_user_observation(self, content: str) -> str:
|
def _honcho_save_user_observation(self, content: str) -> str:
|
||||||
"""Route a memory tool target=user add to Honcho.
|
"""Route a memory tool target=user add to Honcho.
|
||||||
|
|
||||||
|
|
@ -3381,8 +3379,10 @@ class AIAgent:
|
||||||
)
|
)
|
||||||
self._iters_since_skill = 0
|
self._iters_since_skill = 0
|
||||||
|
|
||||||
# Honcho: read cached context from last turn's background fetch (non-blocking),
|
# Honcho: on the first turn only, read the pre-warmed context snapshot and
|
||||||
# then fire both fetches for next turn. Skip in "tools" mode (no context injection).
|
# bake it into the system prompt. We intentionally avoid per-turn refreshes
|
||||||
|
# here because changing the system prompt would destroy provider prompt-cache
|
||||||
|
# reuse for the rest of the session.
|
||||||
self._honcho_context = ""
|
self._honcho_context = ""
|
||||||
_recall_mode = (self._honcho_config.recall_mode if self._honcho_config else "hybrid")
|
_recall_mode = (self._honcho_config.recall_mode if self._honcho_config else "hybrid")
|
||||||
if self._honcho and self._honcho_session_key and not conversation_history and _recall_mode != "tools":
|
if self._honcho and self._honcho_session_key and not conversation_history and _recall_mode != "tools":
|
||||||
|
|
@ -3390,7 +3390,6 @@ class AIAgent:
|
||||||
self._honcho_context = self._honcho_prefetch(user_message)
|
self._honcho_context = self._honcho_prefetch(user_message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Honcho prefetch failed (non-fatal): %s", e)
|
logger.debug("Honcho prefetch failed (non-fatal): %s", e)
|
||||||
self._honcho_fire_prefetch(user_message)
|
|
||||||
|
|
||||||
# Add user message
|
# Add user message
|
||||||
user_msg = {"role": "user", "content": user_message}
|
user_msg = {"role": "user", "content": user_message}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from unittest.mock import MagicMock, patch, PropertyMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from honcho_integration.client import HonchoClientConfig
|
||||||
from run_agent import AIAgent
|
from run_agent import AIAgent
|
||||||
from agent.prompt_builder import DEFAULT_AGENT_IDENTITY, PLATFORM_HINTS
|
from agent.prompt_builder import DEFAULT_AGENT_IDENTITY, PLATFORM_HINTS
|
||||||
|
|
||||||
|
|
@ -1208,3 +1209,68 @@ class TestSystemPromptStability:
|
||||||
conversation_history = []
|
conversation_history = []
|
||||||
should_prefetch = not conversation_history
|
should_prefetch = not conversation_history
|
||||||
assert should_prefetch is True
|
assert should_prefetch is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestHonchoActivation:
|
||||||
|
def test_local_mode_skips_honcho_init(self):
|
||||||
|
hcfg = HonchoClientConfig(
|
||||||
|
enabled=True,
|
||||||
|
api_key="honcho-key",
|
||||||
|
memory_mode="local",
|
||||||
|
peer_name="user",
|
||||||
|
ai_peer="hermes",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
||||||
|
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||||
|
patch("run_agent.OpenAI"),
|
||||||
|
patch("honcho_integration.client.HonchoClientConfig.from_global_config", return_value=hcfg),
|
||||||
|
patch("honcho_integration.client.get_honcho_client") as mock_client,
|
||||||
|
):
|
||||||
|
agent = AIAgent(
|
||||||
|
api_key="test-key-1234567890",
|
||||||
|
quiet_mode=True,
|
||||||
|
skip_context_files=True,
|
||||||
|
skip_memory=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agent._honcho is None
|
||||||
|
assert agent._honcho_config is hcfg
|
||||||
|
mock_client.assert_not_called()
|
||||||
|
|
||||||
|
def test_injected_honcho_manager_skips_fresh_client_init(self):
|
||||||
|
hcfg = HonchoClientConfig(
|
||||||
|
enabled=True,
|
||||||
|
api_key="honcho-key",
|
||||||
|
memory_mode="hybrid",
|
||||||
|
peer_name="user",
|
||||||
|
ai_peer="hermes",
|
||||||
|
recall_mode="hybrid",
|
||||||
|
)
|
||||||
|
manager = MagicMock()
|
||||||
|
manager._config = hcfg
|
||||||
|
manager.get_or_create.return_value = SimpleNamespace(messages=[])
|
||||||
|
manager.get_prefetch_context.return_value = {"representation": "Known user", "card": ""}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
||||||
|
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||||
|
patch("run_agent.OpenAI"),
|
||||||
|
patch("honcho_integration.client.get_honcho_client") as mock_client,
|
||||||
|
patch("tools.honcho_tools.set_session_context"),
|
||||||
|
):
|
||||||
|
agent = AIAgent(
|
||||||
|
api_key="test-key-1234567890",
|
||||||
|
quiet_mode=True,
|
||||||
|
skip_context_files=True,
|
||||||
|
skip_memory=False,
|
||||||
|
honcho_session_key="gateway-session",
|
||||||
|
honcho_manager=manager,
|
||||||
|
honcho_config=hcfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agent._honcho is manager
|
||||||
|
manager.get_or_create.assert_called_once_with("gateway-session")
|
||||||
|
manager.get_prefetch_context.assert_called_once_with("gateway-session")
|
||||||
|
mock_client.assert_not_called()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue