forked from enderofwings/NexusOS
feat: per-conversation project binding + action-tool consent gate
- Conversations bind to a project on creation; RAG scopes to the conversation's project, not the global setting. - Action tools (web_search/fetch_url/remember) are withheld unless allow_action_tools is enabled (off by default). Settings toggle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ const DEFAULTS = {
|
|||||||
num_ctx: 0, // context window in tokens; 0 = model default
|
num_ctx: 0, // context window in tokens; 0 = model default
|
||||||
rag_top_k: 3, // document chunks injected into chat
|
rag_top_k: 3, // document chunks injected into chat
|
||||||
rag_min_score: 0.6, // min cosine similarity for a chunk to count
|
rag_min_score: 0.6, // min cosine similarity for a chunk to count
|
||||||
|
allow_action_tools: false, // consent gate for web/fetch/memory-write tools
|
||||||
system_prompt: "",
|
system_prompt: "",
|
||||||
timeout: 120,
|
timeout: 120,
|
||||||
gpu_offload: -1, // -1 = Auto; 0–100 = percent of layers forced onto the GPU
|
gpu_offload: -1, // -1 = Auto; 0–100 = percent of layers forced onto the GPU
|
||||||
@@ -241,6 +242,22 @@ export function Settings() {
|
|||||||
similarity each must clear. Higher relevance = fewer, tighter matches.
|
similarity each must clear. Higher relevance = fewer, tighter matches.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1.25rem" }}>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "0.6rem", cursor: "pointer" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.allow_action_tools}
|
||||||
|
onChange={e => update("allow_action_tools", e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span style={labelStyle}>Allow action tools</span>
|
||||||
|
</label>
|
||||||
|
<div style={{ fontSize: "0.72rem", color: form.allow_action_tools ? "#c9a227" : "#555", marginTop: "0.2rem" }}>
|
||||||
|
Lets playbooks run tools that act: web search, fetching a URL, and writing
|
||||||
|
to memory. Off by default — a playbook can list them, but they only fire
|
||||||
|
when this is on.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: "1.25rem" }}>
|
<div style={{ marginTop: "1.25rem" }}>
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
GPU Offload
|
GPU Offload
|
||||||
|
|||||||
+15
-4
@@ -285,12 +285,17 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
separator = "\n\n---\nRelevant past exchanges (use as background context only):\n\n"
|
separator = "\n\n---\nRelevant past exchanges (use as background context only):\n\n"
|
||||||
system_prompt = (system_prompt + separator + memory_block) if system_prompt else memory_block
|
system_prompt = (system_prompt + separator + memory_block) if system_prompt else memory_block
|
||||||
|
|
||||||
|
# Resolve the RAG scope: an existing conversation keeps its bound project;
|
||||||
|
# a brand-new one inherits the current workspace (active_project setting).
|
||||||
|
_conv_proj = store.conversation_project(conversation_id)
|
||||||
|
rag_scope = _conv_proj if _conv_proj is not None else app_settings.get("active_project", "")
|
||||||
|
|
||||||
# Retrieve relevant uploaded documents (RAG) and inject the top chunks.
|
# Retrieve relevant uploaded documents (RAG) and inject the top chunks.
|
||||||
doc_hits = await store.search_documents(
|
doc_hits = await store.search_documents(
|
||||||
message, get_ollama_manager().embed,
|
message, get_ollama_manager().embed,
|
||||||
limit=app_settings.get("rag_top_k", 3),
|
limit=app_settings.get("rag_top_k", 3),
|
||||||
min_score=app_settings.get("rag_min_score", 0.6),
|
min_score=app_settings.get("rag_min_score", 0.6),
|
||||||
project_id=app_settings.get("active_project", "") or None,
|
project_id=rag_scope or None,
|
||||||
)
|
)
|
||||||
doc_titles: list = []
|
doc_titles: list = []
|
||||||
if doc_hits:
|
if doc_hits:
|
||||||
@@ -365,14 +370,20 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
|
|||||||
metadata["images"] = images
|
metadata["images"] = images
|
||||||
|
|
||||||
# Tool-using playbook: advertise the active playbook's allowlisted tools.
|
# Tool-using playbook: advertise the active playbook's allowlisted tools.
|
||||||
|
# Action tools (web/fetch/write) are withheld unless the consent gate is on.
|
||||||
if _main_pb and getattr(_main_pb, "tools", None):
|
if _main_pb and getattr(_main_pb, "tools", None):
|
||||||
schemas = _tools.schemas_for(_main_pb.tools)
|
allow_actions = bool(app_settings.get("allow_action_tools", False))
|
||||||
|
schemas = _tools.schemas_for(_main_pb.tools, allow_actions)
|
||||||
if schemas:
|
if schemas:
|
||||||
metadata["tools"] = schemas
|
metadata["tools"] = schemas
|
||||||
_synapse_trace(f" TOOLS : {', '.join(_main_pb.tools)}\n")
|
_granted = [t for t in _main_pb.tools if not _tools.is_action(t) or allow_actions]
|
||||||
|
_withheld = [t for t in _main_pb.tools if _tools.is_action(t) and not allow_actions]
|
||||||
|
_synapse_trace(f" TOOLS : {', '.join(_granted)}\n")
|
||||||
|
if _withheld:
|
||||||
|
_synapse_trace(f" WITHHELD: {', '.join(_withheld)} (action tools off)\n")
|
||||||
|
|
||||||
# Persist conversation and user message before streaming
|
# Persist conversation and user message before streaming
|
||||||
store.create_conversation(conversation_id)
|
store.create_conversation(conversation_id, rag_scope or "")
|
||||||
store.add_message(conversation_id, "user", rendered_message)
|
store.add_message(conversation_id, "user", rendered_message)
|
||||||
|
|
||||||
async def event_stream() -> AsyncGenerator[str, None]:
|
async def event_stream() -> AsyncGenerator[str, None]:
|
||||||
|
|||||||
+21
-3
@@ -140,6 +140,11 @@ class PersistentMemoryStore:
|
|||||||
cur.execute("ALTER TABLE conversations ADD COLUMN title TEXT")
|
cur.execute("ALTER TABLE conversations ADD COLUMN title TEXT")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Migrate: bind a conversation to a project ("" = unscoped).
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE conversations ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
@@ -364,20 +369,30 @@ class PersistentMemoryStore:
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Conversation API
|
# Conversation API
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
def create_conversation(self, conversation_id: str) -> ConversationItem:
|
def create_conversation(self, conversation_id: str, project_id: str = "") -> ConversationItem:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT OR IGNORE INTO conversations (id, created_at, updated_at) VALUES (?, ?, ?)",
|
"INSERT OR IGNORE INTO conversations (id, created_at, updated_at, project_id) VALUES (?, ?, ?, ?)",
|
||||||
(conversation_id, now, now)
|
(conversation_id, now, now, project_id or "")
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return ConversationItem(id=conversation_id, created_at=now, updated_at=now)
|
return ConversationItem(id=conversation_id, created_at=now, updated_at=now)
|
||||||
|
|
||||||
|
def conversation_project(self, conversation_id: str) -> Optional[str]:
|
||||||
|
"""The conversation's bound project ('' = unscoped), or None if it doesn't
|
||||||
|
exist yet — lets a new chat inherit the current workspace."""
|
||||||
|
conn = self._connect()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT project_id FROM conversations WHERE id = ?", (conversation_id,)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return None if row is None else (row["project_id"] or "")
|
||||||
|
|
||||||
def set_conversation_title(self, conversation_id: str, title: str):
|
def set_conversation_title(self, conversation_id: str, title: str):
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
@@ -983,6 +998,9 @@ class PersistentMemoryStore:
|
|||||||
"rag_min_score": 0.6,
|
"rag_min_score": 0.6,
|
||||||
# Active project/workspace; "" = all documents (unscoped).
|
# Active project/workspace; "" = all documents (unscoped).
|
||||||
"active_project": "",
|
"active_project": "",
|
||||||
|
# Consent gate for tools that act (web_search/fetch_url/remember). Off by
|
||||||
|
# default: a playbook can list them, but they only run when this is on.
|
||||||
|
"allow_action_tools": False,
|
||||||
"system_prompt": "",
|
"system_prompt": "",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
# How long Ollama keeps the model resident in VRAM between messages.
|
# How long Ollama keeps the model resident in VRAM between messages.
|
||||||
|
|||||||
+18
-3
@@ -211,9 +211,24 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def schemas_for(names: list[str]) -> list[dict]:
|
# Tools that act (write local state or reach the network). These require an
|
||||||
"""Tool schemas for a playbook's allowlist; unknown names are dropped."""
|
# explicit consent gate (settings.allow_action_tools) on top of the per-playbook
|
||||||
return [REGISTRY[n][0] for n in (names or []) if n in REGISTRY]
|
# allowlist — a playbook granting one isn't enough on its own.
|
||||||
|
ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"})
|
||||||
|
|
||||||
|
|
||||||
|
def is_action(name: str) -> bool:
|
||||||
|
return name in ACTION_TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
def schemas_for(names: list[str], allow_actions: bool = True) -> list[dict]:
|
||||||
|
"""Tool schemas for a playbook's allowlist; unknown names are dropped.
|
||||||
|
When allow_actions is False, action tools are withheld so the model can't
|
||||||
|
even call them."""
|
||||||
|
return [
|
||||||
|
REGISTRY[n][0] for n in (names or [])
|
||||||
|
if n in REGISTRY and (allow_actions or not is_action(n))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def dispatch(name: str, args: dict | None) -> str:
|
async def dispatch(name: str, args: dict | None) -> str:
|
||||||
|
|||||||
@@ -57,6 +57,17 @@ def test_search_empty_query_returns_nothing():
|
|||||||
assert asyncio.run(s.search_documents("", _fake_embed)) == []
|
assert asyncio.run(s.search_documents("", _fake_embed)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_conversation_project_binding():
|
||||||
|
s = _store()
|
||||||
|
assert s.conversation_project("nope") is None # not created yet
|
||||||
|
s.create_conversation("c1", "projX")
|
||||||
|
assert s.conversation_project("c1") == "projX"
|
||||||
|
s.create_conversation("c1", "other") # idempotent: keeps projX
|
||||||
|
assert s.conversation_project("c1") == "projX"
|
||||||
|
s.create_conversation("c2")
|
||||||
|
assert s.conversation_project("c2") == "" # unscoped
|
||||||
|
|
||||||
|
|
||||||
def test_conversation_recall_uses_vec_and_matches_brute_force():
|
def test_conversation_recall_uses_vec_and_matches_brute_force():
|
||||||
s = _store()
|
s = _store()
|
||||||
if not s.vec_enabled:
|
if not s.vec_enabled:
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ def test_action_tools_registered():
|
|||||||
assert names == ["web_search", "remember"]
|
assert names == ["web_search", "remember"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_tools_gated_by_consent():
|
||||||
|
allow = ["search_memory", "web_search", "remember", "fetch_url"]
|
||||||
|
on = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=True)]
|
||||||
|
off = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=False)]
|
||||||
|
assert set(on) == set(allow) # all pass when actions allowed
|
||||||
|
assert off == ["search_memory"] # action tools withheld when not
|
||||||
|
assert tools.is_action("remember") and not tools.is_action("search_memory")
|
||||||
|
|
||||||
|
|
||||||
class _FakeManager:
|
class _FakeManager:
|
||||||
"""Returns a tool_call on the first chat() call, plain content after."""
|
"""Returns a tool_call on the first chat() call, plain content after."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user