feat(plugins): consolidate official plugins with independent releases (LANG-180) (#2797)

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-28 17:32:24 +08:00
committed by GitHub
co-authored by multica-agent
parent d099033d79
commit 3a77306c3c
1144 changed files with 142049 additions and 130 deletions
@@ -0,0 +1,64 @@
from __future__ import annotations
import logging
from typing import Any
from langbot_plugin.api.definition.components.tool.tool import Tool
from langbot_plugin.api.entities.builtin.provider import session as provider_session
from langbot_plugin.api.proxies.query_based_api import QueryBasedAPIProxy
logger = logging.getLogger(__name__)
class Forget(Tool):
async def call(
self,
params: dict[str, Any],
session: provider_session.Session,
query_id: int,
) -> str:
store = self.plugin.memory_store
api = QueryBasedAPIProxy(
query_id=query_id,
plugin_runtime_handler=self.plugin.plugin_runtime_handler,
)
bot_uuid = await api.get_bot_uuid()
query_vars = await api.get_query_vars()
session_key, user_key, kb_id, _, config = await store.resolve_user_context(
session, bot_uuid
)
if not kb_id:
return "Error: no memory knowledge base configured."
episode_id = params.get("episode_id", "")
if not episode_id:
return "Error: episode_id is required."
logger.info(
"[LongTermMemory] forget called: query_id=%s kb_id=%s user_key=%s episode_id=%s",
query_id,
kb_id,
user_key,
episode_id,
)
deleted = await store.delete_episode_by_id(
collection_id=kb_id,
episode_id=episode_id,
user_key=user_key,
)
await store.append_audit_entry(
scope_key=session_key,
user_key=user_key,
operation="forget",
target_type="episode",
target_id=str(episode_id),
summary=f"Deleted episode {episode_id}",
sender_id=str(query_vars.get("sender_id", "") or ""),
sender_name=str(query_vars.get("sender_name", "") or ""),
query_id=query_id,
metadata={"kb_id": kb_id, "deleted": deleted},
)
return f"Deleted episode {episode_id}."
@@ -0,0 +1,36 @@
apiVersion: v1
kind: Tool
metadata:
name: forget
label:
en_US: Forget
zh_Hans: 遗忘
description:
en_US: Delete a specific episodic memory by its ID.
zh_Hans: 通过 ID 删除一条特定的情景记忆。
spec:
parameters:
type: object
properties:
episode_id:
type: string
description: The ID of the episode to delete.
required:
- episode_id
llm_prompt: |
Delete a specific episodic memory by its ID.
Use this tool when the user explicitly asks to forget or remove a specific memory.
The episode_id can be obtained from recall_memory search results or the !memory list / !memory search commands.
Only call this tool when the user clearly wants to delete a memory.
Do NOT proactively delete memories without user consent.
Parameters:
- episode_id (required): The ID of the episode to delete.
execution:
python:
path: forget.py
attr: Forget
@@ -0,0 +1,157 @@
from __future__ import annotations
import json
import logging
from typing import Any
from langbot_plugin.api.definition.components.tool.tool import Tool
from langbot_plugin.api.entities.builtin.provider import session as provider_session
from langbot_plugin.api.proxies.query_based_api import QueryBasedAPIProxy
logger = logging.getLogger(__name__)
class RecallMemory(Tool):
@staticmethod
def _preview_text(value: str, max_len: int = 120) -> str:
text = value.strip().replace("\n", " ")
if len(text) <= max_len:
return text
return f"{text[:max_len]}..."
async def call(
self,
params: dict[str, Any],
session: provider_session.Session,
query_id: int,
) -> str:
store = self.plugin.memory_store
api = QueryBasedAPIProxy(
query_id=query_id,
plugin_runtime_handler=self.plugin.plugin_runtime_handler,
)
logger.info(
"[LongTermMemory] recall_memory called: query_id=%s params_keys=%s",
query_id,
sorted(params.keys()),
)
bot_uuid = await api.get_bot_uuid()
_, user_key, kb_id, _isolation, config = await store.resolve_user_context(
session, bot_uuid
)
if not kb_id:
return "Error: no memory knowledge base configured. Create one first."
pipeline_kbs = await api.list_pipeline_knowledge_bases()
if not any(kb.get("uuid") == kb_id for kb in pipeline_kbs):
return "Error: memory knowledge base is not configured for the current pipeline."
embedding_model_uuid = config.get("embedding_model_uuid", "")
if not embedding_model_uuid:
return "Error: no embedding model configured in knowledge base."
query = params.get("query", "")
if not isinstance(query, str) or not query.strip():
return "Error: query is required."
top_k = params.get("top_k", 5)
if not isinstance(top_k, int) or top_k <= 0:
return "Error: top_k must be a positive integer."
speaker_id = params.get("speaker_id", "")
if speaker_id is None:
speaker_id = ""
if not isinstance(speaker_id, str):
return "Error: speaker_id must be a string."
speaker_name = params.get("speaker_name", "")
if speaker_name is None:
speaker_name = ""
if not isinstance(speaker_name, str):
return "Error: speaker_name must be a string."
time_after = params.get("time_after", "")
if time_after is None:
time_after = ""
if not isinstance(time_after, str):
return "Error: time_after must be a string."
time_before = params.get("time_before", "")
if time_before is None:
time_before = ""
if not isinstance(time_before, str):
return "Error: time_before must be a string."
source = params.get("source", "")
if source is None:
source = ""
if not isinstance(source, str):
return "Error: source must be a string."
status = params.get("status", "active")
if status is None:
status = "active"
if not isinstance(status, str):
return "Error: status must be a string."
status = status.strip().lower() or "active"
if status not in store.EPISODE_STATUSES:
return "Error: status must be one of active, superseded, archived, or deleted."
importance_min = params.get("importance_min")
if importance_min is not None and (
not isinstance(importance_min, int) or importance_min < 1 or importance_min > 5
):
return "Error: importance_min must be an integer between 1 and 5."
logger.info(
"[LongTermMemory] recall_memory searching: query_id=%s kb_id=%s user_key=%s top_k=%s speaker_id=%s speaker_name=%s source=%s status=%s importance_min=%s time_after=%s time_before=%s query_len=%s",
query_id,
kb_id,
user_key,
top_k,
speaker_id.strip(),
speaker_name.strip(),
source.strip(),
status,
importance_min,
time_after.strip(),
time_before.strip(),
len(query),
)
try:
episodes = await store.search_episodes(
collection_id=kb_id,
embedding_model_uuid=embedding_model_uuid,
query=query.strip(),
user_key=user_key,
top_k=top_k,
sender_id=speaker_id.strip(),
sender_name=speaker_name.strip(),
time_after=time_after.strip(),
time_before=time_before.strip(),
importance_min=importance_min,
source=source.strip(),
include_statuses=[status],
retrieval_strategy=config.get("retrieval_strategy", "auto"),
vector_weight=config.get("vector_weight", 0.7),
exact_match_boost=bool(config.get("exact_match_boost", True)),
)
except ValueError as exc:
return f"Error: {exc}"
if not episodes:
logger.info(
"[LongTermMemory] recall_memory no results: query_id=%s kb_id=%s",
query_id,
kb_id,
)
return "No relevant memories found."
logger.info(
"[LongTermMemory] recall_memory completed: query_id=%s kb_id=%s result_count=%s",
query_id,
kb_id,
len(episodes),
)
return json.dumps(episodes, ensure_ascii=False)
@@ -0,0 +1,83 @@
apiVersion: v1
kind: Tool
metadata:
name: recall_memory
label:
en_US: RecallMemory
zh_Hans: 主动回忆
description:
en_US: Search long-term episodic memories with controlled filters such as speaker and time range.
zh_Hans: 使用受控过滤条件(如说话人、时间范围)主动搜索长期情景记忆。
spec:
parameters:
type: object
properties:
query:
type: string
description: Semantic query for the memory search.
top_k:
type: integer
description: Number of memories to return. Optional, default is 5.
speaker_id:
type: string
description: Optional speaker ID filter. Use when you need memories from a specific participant.
speaker_name:
type: string
description: Optional speaker name filter. Use when the participant name is known.
time_after:
type: string
description: Optional inclusive lower bound for timestamp filtering, preferably in ISO-8601 format.
time_before:
type: string
description: Optional inclusive upper bound for timestamp filtering, preferably in ISO-8601 format.
importance_min:
type: integer
description: Optional minimum importance from 1 to 5.
source:
type: string
description: Optional source filter, for example "agent" or "import".
status:
type: string
enum:
- active
- superseded
- archived
- deleted
description: Optional lifecycle status filter. Default is "active"; only use other values when explicitly inspecting old or hidden memories.
required:
- query
llm_prompt: |
Search long-term episodic memories when the default automatic memory recall is not sufficient.
Use this tool when you need to actively inspect memory with a specific retrieval intent, such as:
- recalling what a specific person said
- searching memories within a time range
- focusing on more important memories
- checking imported memories versus agent-written memories
Available controlled filters:
- speaker_id
- speaker_name
- time_after
- time_before
- importance_min
- source
- status
Important behavior:
- This is a read-only lookup tool. Do not call remember or update_profile just because recall returned no result.
- These filters narrow results rather than broaden them.
- By default, recall only returns active memories. Use status=superseded or status=archived only when the user asks to inspect older hidden records.
- When multiple filters are provided together, they are typically combined as restrictive conditions.
- Do not add filters unless they are clearly needed.
- Start with only the semantic query whenever possible, then add at most one or two filters if you need to narrow the result set.
- When you are investigating conflicting memories, pay attention to timestamps and explicit correction/update records. Prefer newer explicit corrections over older conflicting statements.
Do not invent raw metadata filters outside these parameters.
execution:
python:
path: recall_memory.py
attr: RecallMemory
@@ -0,0 +1,110 @@
from __future__ import annotations
import logging
from typing import Any
from langbot_plugin.api.definition.components.tool.tool import Tool
from langbot_plugin.api.entities.builtin.provider import session as provider_session
from langbot_plugin.api.proxies.query_based_api import QueryBasedAPIProxy
logger = logging.getLogger(__name__)
class Remember(Tool):
@staticmethod
def _preview_text(value: str, max_len: int = 120) -> str:
text = value.strip().replace("\n", " ")
if len(text) <= max_len:
return text
return f"{text[:max_len]}..."
async def call(
self,
params: dict[str, Any],
session: provider_session.Session,
query_id: int,
) -> str:
store = self.plugin.memory_store
api = QueryBasedAPIProxy(
query_id=query_id,
plugin_runtime_handler=self.plugin.plugin_runtime_handler,
)
logger.info(
"[LongTermMemory] remember called: query_id=%s params_keys=%s",
query_id,
sorted(params.keys()),
)
bot_uuid = await api.get_bot_uuid()
query_vars = await api.get_query_vars()
session_key, user_key, kb_id, _, config = await store.resolve_user_context(
session, bot_uuid
)
if not kb_id:
return "Error: no memory knowledge base configured. Create one first."
pipeline_kbs = await api.list_pipeline_knowledge_bases()
if not any(kb.get("uuid") == kb_id for kb in pipeline_kbs):
return "Error: memory knowledge base is not configured for the current pipeline."
embedding_model_uuid = config.get("embedding_model_uuid", "")
if not embedding_model_uuid:
return "Error: no embedding model configured in knowledge base."
content = params.get("content", "")
if not content:
return "Error: content is required."
tags = params.get("tags", [])
importance = params.get("importance", 2)
sender_id = str(query_vars.get("sender_id", "") or "")
sender_name = str(query_vars.get("sender_name", "") or "")
logger.info(
"[LongTermMemory] remember storing episode: query_id=%s kb_id=%s user_key=%s sender_id=%s importance=%s tags=%s content_len=%s",
query_id,
kb_id,
user_key,
sender_id,
importance,
tags,
len(str(content)),
)
episode = await store.add_episode(
collection_id=kb_id,
embedding_model_uuid=embedding_model_uuid,
user_key=user_key,
content=content,
tags=tags,
importance=importance,
sender_id=sender_id,
sender_name=sender_name,
bot_uuid=bot_uuid,
)
logger.info(
"[LongTermMemory] remember stored: query_id=%s episode_id=%s user_key=%s content_len=%s",
query_id,
episode["id"],
user_key,
len(str(content)),
)
await store.append_audit_entry(
scope_key=session_key,
user_key=user_key,
operation="remember",
target_type="episode",
target_id=episode["id"],
summary=self._preview_text(str(content)),
sender_id=sender_id,
sender_name=sender_name,
query_id=query_id,
metadata={
"kb_id": kb_id,
"tags": tags,
"importance": importance,
"status": episode.get("status", "active"),
},
)
return f"Remembered: {content}"
@@ -0,0 +1,82 @@
apiVersion: v1
kind: Tool
metadata:
name: remember
label:
en_US: Remember
zh_Hans: 记住
description:
en_US: Remember an event, plan, or situational fact for future conversations.
zh_Hans: 记住一个事件、计划或情境信息,供未来对话使用。
spec:
parameters:
type: object
properties:
content:
type: string
description: The information to remember. Be specific and factual.
tags:
type: array
items:
type: string
description: Short category tags for this memory (e.g. "travel", "work", "schedule").
importance:
type: integer
description: How important this memory is, from 1 (low) to 5 (critical). Default 2.
minimum: 1
maximum: 5
required:
- content
llm_prompt: |
Store an episodic memory — events, plans, experiences, or situational facts.
Episodic memory is L2: use it for history and future recall, not for the current stable profile.
Call this tool when the user mentions something that may be relevant later, such as:
- Events and experiences ("I just got back from Tokyo", "had a fight with my boss")
- Plans and schedules ("I'm traveling to Paris next month", "deadline is Friday")
- Project context ("working on a migration from MySQL to PostgreSQL")
- Situational facts ("my cat is sick", "just moved to a new apartment")
- Group decisions ("The project group agreed to release on Friday")
IMPORTANT: Always include the speaker's name or identifier in the content.
For example, write "Alice is traveling to Paris next month" instead of "traveling to Paris next month".
This ensures memories are correctly attributed when multiple people share the same conversation.
Do NOT use this for stable identity information — use "update_profile" instead:
- Name, job title, personality traits → update_profile
- Communication preferences → update_profile
Do NOT store:
- one-off greetings, jokes, or small talk
- secrets, credentials, API keys, one-time codes, or private tokens
- unconfirmed sensitive information or third-party private gossip
- harmful inferred labels about a person
- facts that are only useful for the current immediate answer
If the user explicitly corrects, retracts, or revises an earlier statement:
- store the correction as an episodic memory with clear wording
- describe the new statement as a correction, not as an unrelated new fact
- when appropriate, add tags such as ["correction"], ["profile-update"], or ["preference-change"]
- if the correction changes the user's current stable profile, also update that profile through "update_profile"
- when updating a stable preference or trait that may change later, prefer giving update_profile a reusable fact_key such as "coffee_preference"
- if the correction explicitly replaces an earlier stable fact, and the earlier value is known, also pass it as previous_value to update_profile
Example correction memory:
- "Alice corrected a previous preference statement and said she actually does not like coffee."
Examples:
- Private chat L2: "Alice said her flight to Tokyo is next Tuesday."
- Group chat L2: "In the design group, Bob decided to own the database migration this week."
- Not L2: "Alice prefers concise answers." Use update_profile instead.
Parameters:
- content (required): What to remember. Be concise and factual. Always attribute to the speaker.
- tags (optional): Category tags like ["travel", "work"].
- importance (optional): 1-5 scale, default 2. Use 4-5 for critical facts.
execution:
python:
path: remember.py
attr: Remember
@@ -0,0 +1,182 @@
from __future__ import annotations
import logging
from typing import Any
from langbot_plugin.api.definition.components.tool.tool import Tool
from langbot_plugin.api.entities.builtin.provider import session as provider_session
from langbot_plugin.api.proxies.query_based_api import QueryBasedAPIProxy
logger = logging.getLogger(__name__)
class UpdateProfile(Tool):
@staticmethod
def _preview_text(value: str, max_len: int = 80) -> str:
text = value.strip().replace("\n", " ")
if len(text) <= max_len:
return text
return f"{text[:max_len]}..."
@staticmethod
def _normalize_scope(scope: Any) -> str:
if scope is None or scope == "":
return ""
if not isinstance(scope, str):
return "__invalid__"
scope = scope.strip().lower()
if scope in ("session", "speaker"):
return scope
return "__invalid__"
@staticmethod
def _infer_scope(field: str, explicit_scope: str) -> str:
if explicit_scope:
return explicit_scope
# Default to speaker-scoped profiles for stable person facts,
# and session-scoped notes for shared conversational context.
if field in ("name", "traits", "preferences"):
return "speaker"
return "session"
@staticmethod
def _normalize_fact_key(value: Any) -> str:
if value is None or value == "":
return ""
if not isinstance(value, str):
return "__invalid__"
return " ".join(value.strip().split())
async def call(
self,
params: dict[str, Any],
session: provider_session.Session,
query_id: int,
) -> str:
store = self.plugin.memory_store
field = params.get("field", "")
action = params.get("action", "")
value = params.get("value", "")
scope = self._normalize_scope(params.get("scope", ""))
fact_key = self._normalize_fact_key(params.get("fact_key", ""))
previous_value = params.get("previous_value", "")
logger.info(
"[LongTermMemory] update_profile called: query_id=%s field=%s action=%s scope=%s fact_key=%s has_previous_value=%s",
query_id,
field,
action,
scope,
fact_key,
bool(previous_value),
)
if not all([field, action, value]):
return "Error: field, action, and value are all required."
if field not in ("name", "traits", "preferences", "notes"):
return f"Error: invalid field '{field}'."
if action not in ("set", "add", "remove"):
return f"Error: invalid action '{action}'."
if scope == "__invalid__":
return "Error: invalid scope. Use 'session' or 'speaker'."
if fact_key == "__invalid__":
return "Error: fact_key must be a string."
if fact_key and field not in ("traits", "preferences"):
return "Error: fact_key is only supported for 'traits' and 'preferences'."
if previous_value is None:
previous_value = ""
if not isinstance(previous_value, str):
return "Error: previous_value must be a string."
api = QueryBasedAPIProxy(
query_id=query_id,
plugin_runtime_handler=self.plugin.plugin_runtime_handler,
)
bot_uuid = await api.get_bot_uuid()
query_vars = await api.get_query_vars()
sender_id = str(query_vars.get("sender_id", "") or "")
sender_name = str(query_vars.get("sender_name", "") or "")
session_key, user_key, kb_id, _isolation, _ = await store.resolve_user_context(
session, bot_uuid
)
if not kb_id:
return "Error: no memory knowledge base configured. Create one first."
pipeline_kbs = await api.list_pipeline_knowledge_bases()
if not any(kb.get("uuid") == kb_id for kb in pipeline_kbs):
return "Error: memory knowledge base is not configured for the current pipeline."
target_scope = self._infer_scope(field, scope)
logger.info(
"[LongTermMemory] update_profile resolved scope: query_id=%s target_scope=%s session_key=%s sender_id=%s value_len=%s",
query_id,
target_scope,
session_key,
sender_id,
len(str(value)),
)
if target_scope == "speaker":
if not sender_id:
return "Error: current speaker is unavailable."
profile = await store.update_speaker_profile_field(
scope_key=session_key,
sender_id=sender_id,
field=field,
action=action,
value=value,
fact_key=fact_key,
previous_value=previous_value.strip(),
)
else:
profile = await store.update_session_profile_field(
scope_key=session_key,
field=field,
action=action,
value=value,
fact_key=fact_key,
previous_value=previous_value.strip(),
)
logger.info(
"[LongTermMemory] update_profile stored: query_id=%s target_scope=%s field=%s action=%s session_key=%s",
query_id,
target_scope,
field,
action,
session_key,
)
target_id = session_key if target_scope == "session" else f"{session_key}:{sender_id}"
await store.append_audit_entry(
scope_key=session_key,
user_key=user_key,
operation="update_profile",
target_type=f"{target_scope}_profile",
target_id=target_id,
summary=(
f"{action} {target_scope} profile {field}"
f"{f' ({fact_key})' if fact_key else ''}: {self._preview_text(str(value))}"
),
sender_id=sender_id,
sender_name=sender_name,
query_id=query_id,
metadata={
"field": field,
"action": action,
"target_scope": target_scope,
"fact_key": fact_key,
"has_previous_value": bool(previous_value),
},
)
return (
f"{target_scope.capitalize()} profile updated.\n"
+ store.format_profile_text(profile)
)
@@ -0,0 +1,104 @@
apiVersion: v1
kind: Tool
metadata:
name: update_profile
label:
en_US: UpdateProfile
zh_Hans: 更新画像
description:
en_US: Update the session's core profile information.
zh_Hans: 更新当前会话的核心画像信息。
spec:
parameters:
type: object
properties:
scope:
type: string
enum:
- session
- speaker
description: "session: shared profile for the whole chat. speaker: profile for the current speaker."
field:
type: string
enum:
- name
- traits
- preferences
- notes
description: Which profile field to update.
action:
type: string
enum:
- set
- add
- remove
description: "set: replace the value. add: append to the list. remove: remove from the list."
value:
type: string
description: The value to set/add/remove.
fact_key:
type: string
description: Optional logical slot key for traits/preferences, such as "coffee_preference", "occupation", or "reply_language". Use this when the fact may be corrected later.
previous_value:
type: string
description: Optional previous value for the same fact when the user is explicitly correcting or revising it. Useful for preserving history when moving from an old fact to a new one.
required:
- field
- action
- value
llm_prompt: |
Update the session profile — shared context about this conversation (group chat or private chat).
Profile memory is L1: use it for stable, low-frequency, currently valid facts.
This tool can update either:
- the shared session profile for the whole chat
- the current speaker's profile for person-specific stable facts
Fields:
- name: A label for this session, or a participant's preferred name.
- traits: Key stable facts about a participant or the session.
- preferences: Communication preferences for the current speaker or the whole session.
- notes: Free-form notes, usually shared session context.
fact_key:
- Optional for traits/preferences.
- Use a stable logical key when the same fact may change over time.
- Good examples: "coffee_preference", "occupation", "reply_language", "timezone".
- Reusing the same fact_key lets the system keep the current value and preserve previous values as history.
previous_value:
- Optional, mainly for correction scenarios.
- Use it when the user explicitly replaces an earlier stable fact and you know the old value.
- Example: fact_key="coffee_preference", previous_value="Alice likes coffee", value="Alice does not like coffee".
Scopes:
- speaker: Use for stable facts about the current speaker, such as "Alice likes coffee" or "Alice is a software engineer".
- session: Use for shared context about the whole chat, such as "group for project X" or "respond in Chinese here".
Actions:
- set: Replace the field value entirely.
- add: Add an item to a list field (traits/preferences) or append to notes.
- remove: Remove an item from a list field or clear notes.
Important:
- When adding facts about a person in a group chat, prefer scope="speaker".
- When adding shared group context, use scope="session".
- When adding traits about specific people, include their name (e.g. "Alice likes coffee" not just "likes coffee").
- Do not write short-lived plans, deadlines, recent events, or one-off states into L1; use remember for those.
- Do not store secrets, credentials, one-time codes, unconfirmed sensitive claims, or harmful inferred labels.
- For traits/preferences that may later be corrected, prefer providing fact_key so the plugin can keep one current value plus history.
- When a user corrects, retracts, or negates a previously stored stable fact, do NOT keep contradictory current profile values in parallel.
- Prefer replacing the old current value. In practice, use the same fact_key with action="set" for the corrected current fact. If the old value is known, also provide previous_value so it is preserved as history.
- If the correction itself is historically important, also store it with the "remember" tool so the timeline is preserved.
Examples:
- Private chat L1 speaker profile: "Alice prefers concise technical answers."
- Group chat L1 session profile: "This group coordinates the LangBot release."
- Correction: if Alice says "Actually I prefer English replies, not Chinese", set the same reply_language fact_key to the new current value and pass the old value as previous_value if known.
- Not L1: "Alice has a meeting tomorrow at 10." Use remember instead.
execution:
python:
path: update_profile.py
attr: UpdateProfile