Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changes/525.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Incognito chats without memory keep memories out of helpers too.** Before, a helper or the reader started from such
a chat could still be handed the memory and past-chat search tools. Now nothing a chat starts gets more than the chat
itself: no memory tools, no memories and not your name.
19 changes: 14 additions & 5 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,13 +405,14 @@ accept or loosen a rule.
- nothing is learned from it: no fact extraction after a turn, no memory flush when it is compressed, and no
`chat.turn_completed` event, so skill review and repair, the user model and dreaming never see it. Its skill
views don't count as skill use.
- `memory_remember` and `skill_save` are not offered, and refuse with an error when called in it (also from a
helper it started). Nothing is stored, not even held for review.
- `memory_remember` and `skill_save` are not offered to it or to a helper it started, and refuse with an error
when called anyway. Nothing is stored, not even held for review.
- keyword search (`GET /api/sessions/search`, `memory_search_history`), conversation summaries,
`history_time_search`, the user model's evidence and skill review leave it out.
- reading memory follows `chat.incognito_uses_memory` (default `true`): recalled facts, user-model insights and
the memory tools work as usual, and the reply's `memory_sources` list them. Off, its prompt has no profile,
memory file, daily notes, facts or insights, and the memory look-up tools are not offered and return nothing.
memory file, daily notes, facts or insights, and the memory tools are not offered and refuse to run, also in the
helpers, reader and scripts it starts (section 10, "Turns without memory").
The prompt also tells the model the chat is incognito.
- it is deleted: the window deletes it when the user leaves it; the engine deletes every incognito chat at start
and shutdown (after replies and background work have finished), and one with no new message for
Expand Down Expand Up @@ -2705,8 +2706,8 @@ Every new tool declares a `Risk`; approvals behave as in section 1.
content and a `text`, `email` or `url` value came back (the run is then marked, ADR 0018); a result of only dates,
numbers, yes or no, ids and choices does not mark it. Refused (`{error}`) for tools that aren't look-ups, that
could send data out, whose rule is Ask, Only if I asked or Hand it to me, that are hidden, the subagent tools, and
an address that could carry data to a site a marked run hasn't visited. Tool selection offers it with any tool
that reads outside content.
an address that could carry data to a site a marked run hasn't visited, and, in a turn without memory (below), the
memory and history tools. Tool selection offers it with any tool that reads outside content.
- **Subagent** `{subagent_id, session_id, parent_call_id, goal, kind: "worker"|"reader", status: "running"|"completed"|"error"|"cancelled", background, summary, error, tool_calls, started_at, finished_at, events: [ProgressUpdate]}`.
A reader (`kind: "reader"`) has `tool_calls: 0`, `goal` `"Read <source> for: <field names>"`, `summary` the JSON of
the fields returned, and two events: `info` (the fields asked for, with their kinds) and `final_answer` (the fields
Expand All @@ -2716,6 +2717,14 @@ Every new tool declares a `Risk`; approvals behave as in section 1.
- While a foreground subagent runs, the parent emits `tool_progress` with `kind: "subagent"` and `data: {subagent_id, message}`.
- Subagents cannot spawn subagents, use the voice role, or run tools of effective risk `send` or `exec`; those calls are
denied with an explanation the parent can relay.
- **Turns without memory** (#525). Each chat turn decides once whether it may use what Sentient knows about the user
(`Agent.memory_allowed(session, channel)`: false in an incognito chat while `chat.incognito_uses_memory` is off)
and carries the answer in `ToolContext.memory_allowed`. Everything the turn starts copies it: helpers
(`delegate_task`, `delegate_tasks`, background ones too), the reader and `execute_code` scripts. Without memory,
the memory tools (`memory_recall`, `memory_search_by_source`, `memory_search_history`, `history_semantic_search`,
`history_time_search`, `user_model_ask`, `memory_forget`) are not offered by `run_loop` whoever built the list,
are not listed to scripts, and refuse to run (`{error}`) however they are called. A helper's prompt then leaves out
the user's name too.
- Limits: `subagents.max_rounds` steps (default 24), `subagents.timeout_minutes` (20), and on cloud models
`subagents.max_tokens` (1000000) and `subagents.max_cost_usd` (2.0, when the price is known; `0` = no limit). The
repeated-call breaker applies too. Reaching one ends the subagent with `status: "error"` and a plain `error`
Expand Down
44 changes: 28 additions & 16 deletions sentient/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
describe_call,
effective_risk,
is_unprompted,
without_memory_tools,
)
from sentient.tools.builtin.time_tool import resolve_tz
from sentient.tools.registry import ToolRegistry
Expand Down Expand Up @@ -191,12 +192,9 @@
"Do not call it again with these arguments. Try something different, or answer the user with what you have."
)
# Incognito chats (#154): tools that would keep something from the chat are never offered or run, and the model is
# told so it does not promise to remember. Without ``chat.incognito_uses_memory`` the memory look-ups go too.
# told so it does not promise to remember. Without ``chat.incognito_uses_memory`` the memory tools go too: that is the
# turn's ``memory_allowed`` decision (#525). ``run_loop`` leaves both out of every run of the chat, helpers included.
INCOGNITO_LEARNING_TOOLS = frozenset({"memory_remember", "skill_save"})
INCOGNITO_MEMORY_TOOLS = frozenset({
"memory_recall", "memory_search_by_source", "memory_search_history", "history_semantic_search",
"history_time_search", "user_model_ask",
})
INCOGNITO_NOTE = (
"## Incognito chat\nThis is an incognito chat. Nothing from it is saved to memory, and it is deleted when the "
"user closes it. Never say you will remember something from it."
Expand Down Expand Up @@ -723,15 +721,24 @@ async def _prompt_notes(self, user_text: str, scope: str | None = None) -> list[
async def system_prompt(self, user_text: str, channel: str, session: dict | None = None) -> str:
return (await self._system_prompt_with_sources(user_text, channel, session))[0]

def memory_allowed(self, session: dict | None, channel: str) -> bool:
"""Whether a chat turn may use what Sentient knows about the user (#525): memories, the profile, the user
model and other chats. Decided once per turn, here only; the turn puts it in ``ToolContext.memory_allowed``
so its prompt, its tools and everything it starts (helpers, the reader, scripts) follow the same answer.
False in an incognito chat while ``chat.incognito_uses_memory`` is off. Another kind of turn without memory
(an app's turn the user hasn't allowed to read memories, #479) adds its condition here."""
incognito = bool((session or {}).get("incognito"))
return not incognito or self.config.chat.incognito_uses_memory

async def _system_prompt_with_sources(
self, user_text: str, channel: str, session: dict | None = None
self, user_text: str, channel: str, session: dict | None = None, *, memory_allowed: bool | None = None
) -> tuple[str, MemorySources, list[dict]]:
"""The system prompt, the memories put into it (recalled facts, user-model insights) and the pieces of the
user's notes put into it (#354). An incognito chat (#154) reads memory and notes only while
``chat.incognito_uses_memory`` is on, and is told nothing will be kept. The chat's memory scope (#155) decides
which facts are recalled; a work or project chat also leaves out the personal profile files."""
incognito = bool((session or {}).get("incognito"))
reads_memory = not incognito or self.config.chat.incognito_uses_memory
reads_memory = self.memory_allowed(session, channel) if memory_allowed is None else memory_allowed
scope = memory_scopes.of_session(self.config, session)
personal = memory_scopes.is_personal(scope)
if reads_memory:
Expand Down Expand Up @@ -774,12 +781,6 @@ async def _system_prompt_with_sources(
prompt += "\n\n" + (INCOGNITO_NOTE if reads_memory else INCOGNITO_NO_MEMORY_NOTE)
return prompt, sources, notes

def _incognito_tools(self, tool_names: list[str] | None) -> list[str]:
"""``tool_names`` (None: every offered tool) without the tools an incognito chat must not use (#154)."""
names = tool_names if tool_names is not None else [t.name for t in self.registry.tools()]
left_out = INCOGNITO_LEARNING_TOOLS | (frozenset() if self.config.chat.incognito_uses_memory else INCOGNITO_MEMORY_TOOLS)
return [n for n in names if n not in left_out]

def _read_only_apps(self) -> list[str]:
"""Names of the connected apps set to Read only that have tools Sentient now can't use (#141), so the model
can say why instead of looking for another way."""
Expand Down Expand Up @@ -846,6 +847,14 @@ async def run_loop(
unprompted = is_unprompted(getattr(ctx, "origin", None)) or is_unprompted(source)
if unprompted and not is_unprompted(getattr(ctx, "origin", None)):
ctx.origin = str(source).strip().lower() # tools this run starts (a subagent) must see it as unprompted too
if ctx.incognito or not ctx.memory_allowed:
# whoever built the list (a chat turn, a helper, a task), a run is never offered more than its turn
# (#525): nothing that would keep something from an incognito chat (#154), and no memory tools without
# memory. A call made anyway is refused as not offered, and the tools themselves refuse too.
names = tool_names if tool_names is not None else [t.name for t in self.registry.tools()]
if ctx.incognito:
names = [n for n in names if n not in INCOGNITO_LEARNING_TOOLS]
tool_names = names if ctx.memory_allowed else without_memory_tools(names)
tools = self.registry.openai_schemas(tool_names) or None
result.tool_result_limit = await self.tool_result_limit(role, model)
rounds = max_rounds or self.config.models.max_tool_rounds
Expand Down Expand Up @@ -1899,7 +1908,10 @@ def release() -> list[str]:

session = await self.store.get_session(session_id)
incognito = bool((session or {}).get("incognito"))
system, sources, notes = await self._system_prompt_with_sources(user_text, channel, session)
memory_allowed = self.memory_allowed(session, channel) # once per turn (#525)
system, sources, notes = await self._system_prompt_with_sources(
user_text, channel, session, memory_allowed=memory_allowed
)
history = await self.store.recent_messages(session_id, self.config.chat.history_window)
convo = self.mark_outside_messages(history_to_openai(history))
result.browser_click_history.extend(browser_click_history(history))
Expand Down Expand Up @@ -1935,6 +1947,7 @@ def release() -> list[str]:
messages: list[dict] = [{"role": "system", "content": system}, *convo]
ctx = self.tool_context(session_id, channel)
ctx.incognito = incognito
ctx.memory_allowed = memory_allowed # helpers, the reader and scripts it starts copy it (#525)
ctx.memory_scope = memory_scopes.of_session(self.config, session) # recall and saving use it (#155)
# a turn an MCP app steers is outside content from the start (issue #443): its text is not
# the user's words, so it never fills user_words, and the chat is marked before anything
Expand Down Expand Up @@ -1974,8 +1987,6 @@ def release() -> list[str]:
except Exception as exc: # selection must never break a turn
log.warning("tool selection failed, offering all tools: %s", exc)
tool_names = None
if incognito:
tool_names = self._incognito_tools(tool_names)

async def persist(role_: str, content: str | None, **fields: Any) -> None:
mid = await self.store.add_message(session_id, role_, content, **fields)
Expand Down Expand Up @@ -2132,6 +2143,7 @@ async def _quick_turn(
session = await self.store.get_session(session_id)
ctx = self.tool_context(session_id, channel)
ctx.incognito = bool((session or {}).get("incognito"))
ctx.memory_allowed = self.memory_allowed(session, channel)
ctx.extra = {**ctx.extra, "quick": True, "quick_language": plan.language.code}
results: list[tuple[Any, bool]] = []
checks: list[dict] = []
Expand Down
33 changes: 25 additions & 8 deletions sentient/agent/subagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from sentient.llm.jobs import detached
from sentient.services import Service, cancel_tasks
from sentient.store.db import new_id, now_iso
from sentient.tools.base import Risk, Tool
from sentient.tools.base import MEMORY_TOOLS, Risk, Tool

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -190,11 +190,14 @@ async def delegate(
on_update: UpdateFn | None = None,
origin: str = "user",
untrusted: str = "",
memory_allowed: bool = True,
) -> dict:
"""Start a subagent. Foreground: waits and returns ``{subagent_id, status, summary, files_created}``
(plus ``error``). Background: returns ``{subagent_id, status: "running"}`` right away. ``origin`` is the
parent run's ``ToolContext.origin``: a subagent of work nobody asked for may only read too (ADR 0017).
``untrusted`` is the parent's ``ToolContext.untrusted``: outside content it read still counts (ADR 0018)."""
``untrusted`` is the parent's ``ToolContext.untrusted``: outside content it read still counts (ADR 0018).
``memory_allowed`` is the parent's ``ToolContext.memory_allowed`` (#525): False, the helper gets no memory
tools, no user name and nothing Sentient knows, like the turn that started it."""
cfg = self.app.config.subagents
if not cfg.enabled:
return {"status": "error", "error": "Subagents are turned off in Settings."}
Expand All @@ -214,7 +217,8 @@ async def delegate(
)
self._publish(await self.get(sub_id, events=False))
task = asyncio.create_task(
self._run(sub_id, goal, context or "", tools, session_id, bool(background), on_update, origin, untrusted),
self._run(sub_id, goal, context or "", tools, session_id, bool(background), on_update, origin, untrusted,
memory_allowed),
name=f"subagent:{sub_id}",
# a foreground helper is part of whatever waits on it (a chat reply); a background one is task work (#149)
context=detached("task") if background else None,
Expand Down Expand Up @@ -301,8 +305,10 @@ async def delegate_many(
on_update: UpdateFn | None = None,
origin: str = "user",
untrusted: str = "",
memory_allowed: bool = True,
) -> list[dict]:
"""Run several foreground subagents; at most ``subagents.max_concurrent`` at a time."""
"""Run several foreground subagents; at most ``subagents.max_concurrent`` at a time. ``origin``,
``untrusted`` and ``memory_allowed`` are the parent's, as for ``delegate``."""
jobs = [
self.delegate(
str(t.get("goal") or ""),
Expand All @@ -313,6 +319,7 @@ async def delegate_many(
on_update=on_update,
origin=origin,
untrusted=untrusted,
memory_allowed=memory_allowed,
)
for t in tasks
]
Expand Down Expand Up @@ -365,12 +372,16 @@ def check(tool: Tool, risk: Risk, arguments: dict) -> str | None:
return check

async def _tool_names(
self, goal: str, context: str, tools: list[str] | None, role: str, model: str | None = None
self, goal: str, context: str, tools: list[str] | None, role: str, model: str | None = None,
*, memory_allowed: bool = True,
) -> list[str]:
"""The helper's tools: never wider than its parent turn's, so without memory (#525) no memory tools, even
when the goal names them."""
reg = self.app.registry
usable = [
t for t in reg.tools()
if t.plugin != PLUGIN_ID and not (t.risk >= Risk.send and t.risk_fn is None)
and (memory_allowed or t.name not in MEMORY_TOOLS)
]
usable_names = {t.name for t in usable}
if tools:
Expand All @@ -394,7 +405,8 @@ async def _tool_names(
return [n for n in selected if n in usable_names]

def _system_prompt(self, *, private: bool = False) -> str:
"""``private``: started from an incognito chat that doesn't use memory (#154), so the user's name stays out."""
"""``private``: started from a turn without memory (an incognito chat that doesn't use it, #154, #525), so the
user's name stays out."""
from sentient.tools.builtin.time_tool import resolve_tz

cfg = self.app.config.assistant
Expand All @@ -416,6 +428,7 @@ async def _run(
on_update: UpdateFn | None,
origin: str = "user",
untrusted: str = "",
memory_allowed: bool = True,
) -> None:
cfg = self.app.config.subagents
agent = self.app.agent
Expand All @@ -437,18 +450,22 @@ def note(message: dict, text: str | None = None) -> None:
async with self._slot():
note({"type": "info", "content": f"Started: {goal[:200]}"}, "Started")
incognito = await self.app.store.is_incognito(session_id) # kept if the chat is deleted (#154)
private = incognito and not self.app.config.chat.incognito_uses_memory
# never wider than the parent turn (#525); the chat's own setting still holds for a caller that
# didn't pass the parent's decision
allowed = memory_allowed and not (incognito and not self.app.config.chat.incognito_uses_memory)
private = not allowed
messages = [
{"role": "system", "content": self._system_prompt(private=private)},
{"role": "user", "content": f"Goal: {goal}" + (f"\n\nContext:\n{context}" if context else "")},
]
ctx = agent.tool_context(session_id, "subagent", origin=origin)
ctx.incognito = incognito
ctx.memory_allowed = allowed
ctx.untrusted = untrusted
ctx.extra["subagent_id"] = sub_id
# where it would use the primary model, a helper uses the chat's own model (#312)
model = await agent.helper_model(session_id, cfg.role)
names = await self._tool_names(goal, context, tools, cfg.role, model)
names = await self._tool_names(goal, context, tools, cfg.role, model, memory_allowed=allowed)
narration = ""
async with asyncio.timeout(cfg.timeout_minutes * 60):
async for event in agent.run_loop(
Expand Down
Loading
Loading