localailb/assistant
0
1"""2agent_memory.py — Shared multi-turn memory helpers for every smolagents3CodeAgent in the app (General Chat agentic mode, RAG Chat agentic mode,4Data Analysis), following the official smolagents memory guide exactly:5https://huggingface.co/docs/smolagents/tutorials/memory6 7Standard smolagents memory model8---------------------------------9smolagents keeps an agent's own history in `agent.memory.steps` — a plain10Python list of TaskStep / ActionStep / PlanningStep objects. Two rules11from the official tutorial cover everything this app needs:12 13 1. `agent.run(task, reset=False)` keeps `agent.memory.steps` across14 calls, instead of the `reset=True` default that wipes it at the15 start of every single call — this is what lets a follow-up like16 "and what about last year?" refer back to what was already asked.17 Every chat handler (chat.py / data_analysis.py) already does this18 correctly: `agent.run(task, reset=not use_memory)`.19 2. Memory can be edited directly, as a plain list. The tutorial's own20 "Dynamically change the agent's memory" section does exactly this21 (`agent.memory.steps = previous_agent.memory.steps`, or trimming via22 a step_callback) — cap_agent_memory() below does the same thing23 (trim old turns by reassigning `agent.memory.steps`), and24 reset_memory() below calls smolagents' own `agent.memory.reset()`25 for the same effect when the whole conversation should be dropped.26 27This module is intentionally just those two small helpers now. It used28to also serialize `agent.memory.steps` to JSON on disk so memory would29survive an app restart, not just a model swap within the same run — that30was NEVER part of smolagents' own memory model (smolagents doesn't ship31save/load for `agent.memory` at all — see the still-open32https://github.com/huggingface/smolagents/issues/1216), and hand-rolling33it was the source of several real bugs:34 35 - Reconstructing ToolCall / ChatMessage / Timing objects from a36 flattened dict is version-sensitive; a smolagents upgrade can37 silently rename/drop a field, which needed several defensive38 "filter down to whatever kwargs this version's constructor accepts"39 layers just to avoid crashing on load — a lot of fragility for a40 feature smolagents itself doesn't officially support.41 - Memory was persisted GLOBALLY per tab (shared across every model used42 in that tab), so replaying a step written by one model's tool-call43 format into a DIFFERENT model's chat template could produce a44 genuinely confused answer — a real quality/correctness bug, not just45 a theoretical risk.46 - Worse, it actively defeated Data Analysis's "a new dataset was47 uploaded, so old memory about the previous one shouldn't carry over"48 logic: that code correctly detected the dataset had changed and reset49 the agent — but building a replacement agent right afterward then50 reloaded the *exact same stale, pre-reset memory straight back off51 disk*, silently undoing the reset it was supposed to perform.52 53This file now sticks to the plain, official, RAM-only workflow instead:54an agent's memory lives exactly as long as its Python object does. It's55gone the moment the model is switched, "Clear" is clicked, or the app56restarts — matching smolagents' own tutorial exactly, and matching the57"🧠 Conversation Memory (Experimental)" checkbox's own label: this trades58"memory survives a restart / model switch" for "memory behaves correctly59and predictably", which is the right trade given the bugs above.60 61Compaction (the third helper, added later)62-------------------------------------------63`compact_agent_memory()` upgrades the plain drop-oldest cap: instead of64throwing the oldest turns away when the conversation grows, it FIRST65measures the agent memory's token footprint against the model's context66window, and — only when it's about to overflow — asks the model itself to67condense the older turns into a short rolling summary that is prepended68to the system prompt (the most recent turns stay verbatim). This is the69"true" compaction approach (approach #4 in the standard context-window70playbook, with the token-usage trigger of #3), and it deliberately keeps71the exact same RAM-only lifecycle as everything else here: the summary72lives on the agent object (`agent._rolling_summary`), dies with it, and73is cleared by reset_memory() together with the steps. On any failure it74degrades to dropping the oldest turns — the old behaviour — so the chat75loop is never worse off than before.76"""77 78from smolagents.memory import TaskStep79 80 81# ──────────────────────────────────────────────────────────────────82# Turn-count cap (approach #2 — drop oldest turns)83# ──────────────────────────────────────────────────────────────────84def cap_agent_memory(agent, max_turns: int = 6) -> None:85 """Keep only the most recent `max_turns` conversation turns in86 `agent.memory.steps` — the same "edit the list directly" pattern87 smolagents' own tutorial uses for trimming memory, just dropping88 whole old turns instead of individual fields/screenshots.89 90 Each turn starts with a `TaskStep` (the user's message, or in Data91 Analysis's case the full task prompt) and is followed by whatever92 `ActionStep`/`PlanningStep`s the agent took to answer it. This drops93 whole old turns rather than truncating mid-turn, so memory always94 stays structurally valid for smolagents to replay.95 96 The agent's system prompt lives separately in97 `agent.memory.system_prompt` and is never touched by this.98 """99 steps = agent.memory.steps100 task_indices = [i for i, s in enumerate(steps) if isinstance(s, TaskStep)]101 if len(task_indices) > max_turns:102 cutoff = task_indices[-max_turns]103 agent.memory.steps = steps[cutoff:]104 105 106# ──────────────────────────────────────────────────────────────────107# Token-budget-aware rolling compaction (approaches #3 + #4)108# ──────────────────────────────────────────────────────────────────109# How much of the context window the memory may use before compaction110# fires, before the generation reserve is subtracted. Conservative by111# design: the upcoming answer shares the window with whatever memory we112# keep, and llama-server's native KV-cache shift remains the final safety113# net on the server backend.114COMPACTION_BUDGET_RATIO = 0.75115 116# Target size of the LLM-written rolling summary per compaction.117COMPACTION_MAX_SUMMARY_TOKENS = 512118 119# Longest single step fed to the summarizer (action steps carry full120# tool observations — a pasted webpage or a large DataFrame print must121# not flood the summary call).122_COMPACTION_STEP_TEXT_LIMIT = 2000123 124# Hard cap on the stored ROLLING summary (chars) — a very long125# conversation must never let the summary itself re-fill the window126# (which would otherwise trigger a compaction loop where the summary is127# the bulk of the prompt). ~1500 tokens at the 4-chars/token heuristic.128_COMPACTION_ROLLING_SUMMARY_MAX_CHARS = 6000129 130 131def estimate_text_tokens(text, tokenizer=None) -> int:132 """Approximate token count of `text` for context-window budgeting.133 134 Uses a real tokenizer when one is available (llama-cpp-python's135 `Llama.tokenize`, or a transformers tokenizer), falling back to a136 documented ~4-characters-per-token heuristic for every other backend137 (llama-server HTTP, HF Inference API, LiteLLM). Always wrapped in138 try/except so a tokenizer quirk can never break the chat loop — the139 heuristic is an estimate, and the compaction threshold has slack."""140 if not text:141 return 0142 if tokenizer is not None:143 try:144 return max(1, len(tokenizer(str(text))))145 except Exception:146 pass147 return max(1, int(len(str(text)) / 4))148 149 150def _get_model_tokenizer(llm):151 """Best-effort tokenizer callable for a smolagents Model object, or152 None when none is cheaply accessible (callers fall back to the char153 heuristic). Version-robust: every attribute is read defensively."""154 if llm is None:155 return None156 # In-process llama.cpp backend: LlamaCppModel wraps llama_cpp.Llama157 # as `.llm`, whose `.tokenize(text)` returns the token id list — the158 # most accurate count this app can get, since it is the exact same159 # tokenizer the KV-cache is sized against.160 inner = getattr(llm, "llm", None)161 if inner is not None and callable(getattr(inner, "tokenize", None)):162 return inner.tokenize163 # smolagents TransformersModel exposes `.tokenizer` in most versions.164 tok = getattr(llm, "tokenizer", None)165 if tok is not None:166 if callable(getattr(tok, "encode", None)):167 return lambda t: tok.encode(t, add_special_tokens=False)168 if callable(getattr(tok, "tokenize", None)):169 return tok.tokenize170 return None171 172 173def _effective_n_ctx(llm, fallback_n_ctx: int) -> int:174 """Refine the context-window budget from the live model when175 possible. In-process llama.cpp reports its actual KV-cache size via176 `n_ctx()`; HF checkpoints report their trained context length via177 `config.max_position_embeddings`. Falls back to the value the caller178 passed (models._llm_n_ctx, or the persisted context-window setting)."""179 if llm is None:180 return fallback_n_ctx181 inner = getattr(llm, "llm", None)182 if inner is not None and callable(getattr(inner, "n_ctx", None)):183 try:184 return int(inner.n_ctx())185 except Exception:186 pass187 config = getattr(getattr(llm, "model", None), "config", None)188 mpe = getattr(config, "max_position_embeddings", None)189 if mpe:190 try:191 return int(mpe)192 except Exception:193 pass194 return fallback_n_ctx195 196 197def _step_to_text(step, limit: int = _COMPACTION_STEP_TEXT_LIMIT) -> str:198 """Version-robust plain-text rendition of one smolagents memory step199 (TaskStep / ActionStep / PlanningStep / ...). Prefers the step's own200 `to_string()`; falls back to repr. Truncated so a single huge201 observation (full webpage text, DataFrame print) can't flood the202 summarizer."""203 try:204 text = step.to_string()205 except Exception:206 try:207 text = str(step)208 except Exception:209 return ""210 text = str(text or "").strip()211 if not text:212 return ""213 if limit is not None and len(text) > limit:214 text = text[:limit] + "\n…(truncated)"215 return text216 217 218def _memory_to_text(agent, *, truncate: bool = True) -> str:219 """Flatten the agent's whole memory (system prompt + every step) into220 one text blob, for token estimation / summarization input.221 222 With `truncate=True` (the summarizer input) each step is capped so a223 single huge observation can't flood the summary call. With224 `truncate=False` (the token-budget estimate) the FULL step text is225 counted — the estimate must match what llama.cpp actually builds into226 the prompt, or a window overflow could slip past the budget check."""227 parts = []228 sp = getattr(agent.memory, "system_prompt", None)229 if sp is not None:230 parts.append(_step_to_text(sp, limit=4000 if truncate else None))231 for step in list(getattr(agent.memory, "steps", []) or []):232 parts.append(_step_to_text(step, limit=_COMPACTION_STEP_TEXT_LIMIT if truncate else None))233 return "\n\n".join(p for p in parts if p)234 235 236def _system_prompt_text(mem) -> str:237 """Read the agent memory's system prompt as a plain string — handles238 SystemPromptStep objects AND bare-string system prompts."""239 sp = getattr(mem, "system_prompt", None)240 if sp is None:241 return ""242 if isinstance(sp, str):243 return sp244 content = getattr(sp, "content", None)245 if isinstance(content, str):246 return content247 try:248 return str(sp.to_string())249 except Exception:250 return str(sp)251 252 253def _set_system_prompt_text(mem, text: str) -> None:254 """Replace the agent memory's system prompt in place. Version-robust:255 SystemPromptStep exposes a `.content` string in current smolagents;256 fall back to reassigning the slot for any older shape."""257 sp = getattr(mem, "system_prompt", None)258 if sp is None:259 return260 if isinstance(sp, str):261 mem.system_prompt = text262 return263 if hasattr(sp, "content"):264 try:265 sp.content = text266 return267 except Exception:268 pass269 try:270 mem.system_prompt = text271 except Exception:272 pass273 274 275def _inject_rolling_summary(agent, rolling: str) -> None:276 """Append the rolling conversation summary to the agent's system277 prompt, so it is included in every future run's prompt (smolagents278 always renders memory.system_prompt at the top of the next prompt).279 280 The ORIGINAL system prompt is captured on the agent object the first281 time, so reset_memory() can restore it (smolagents' memory.reset()282 clears `steps` but deliberately leaves `system_prompt` untouched)."""283 base = getattr(agent, "_base_system_prompt", None)284 if base is None:285 base = _system_prompt_text(agent.memory)286 agent._base_system_prompt = base287 block = (288 "\n\n---\n"289 "ROLLING CONVERSATION SUMMARY (the older turns of this conversation "290 "were compacted; treat the condensed history below as fact):\n"291 f"{rolling}"292 )293 _set_system_prompt_text(agent.memory, base + block)294 295 296_SUMMARY_SYSTEM = (297 "You are the memory compactor of a local AI assistant. You will be "298 "given the OLDER turns of a conversation. Produce ONE compact rolling "299 "summary that preserves, in order of importance: "300 "(1) key facts, numbers and results, "301 "(2) the user's preferences, instructions and decisions, "302 "(3) any open questions or unfinished tasks. "303 "Be dense and factual — no pleasantries, no repetition, no markdown "304 "headers. Write in the same language as the conversation. "305 "Keep it under {max_tokens} tokens."306)307 308 309def _strip_old_observations(agent, keep_recent_turns: int = 1) -> bool:310 """Strip or truncate heavy tool observations from older ActionSteps311 before dropping entire turns, preserving tool call reasoning while312 reclaiming token budget."""313 changed = False314 try:315 steps = list(getattr(agent.memory, "steps", []) or [])316 task_indices = [i for i, s in enumerate(steps) if isinstance(s, TaskStep)]317 if len(task_indices) <= keep_recent_turns:318 return False319 cutoff = task_indices[-keep_recent_turns]320 for step in steps[:cutoff]:321 if hasattr(step, "observations") and step.observations:322 obs = str(step.observations)323 if len(obs) > 200:324 step.observations = obs[:200] + "… (observation summarized/truncated)"325 changed = True326 except Exception:327 pass328 return changed329 330 331def _trim_memory_to_budget(agent, budget: int, tokenizer=None) -> bool:332 """Degraded fallback for compact_agent_memory(): when the LLM summary333 call fails — or when memory is already over budget before there are334 enough turns to summarize (a single heavy turn, e.g. a Deep Research335 turn whose sub-agent report lives in the manager's memory, can336 overflow the window on turn 2) — first strip heavy observations from337 older turns, then drop the OLDEST turns (never the newest) until the338 estimated memory size fits under `budget`, keeping at least one user339 turn's task + final answer. Returns True if anything was changed."""340 changed = False341 try:342 # Step 1: try stripping bulky observations from older turns first343 if _strip_old_observations(agent):344 changed = True345 if estimate_text_tokens(_memory_to_text(agent, truncate=False), tokenizer) <= budget:346 return True347 348 # Step 2: drop oldest turns as needed349 while True:350 steps = list(getattr(agent.memory, "steps", []) or [])351 task_indices = [i for i, s in enumerate(steps) if isinstance(s, TaskStep)]352 if not steps:353 break354 if estimate_text_tokens(_memory_to_text(agent, truncate=False), tokenizer) <= budget:355 break356 if len(task_indices) >= 2:357 # Two or more turns — drop the oldest whole turn(s),358 # leaving the newest turn verbatim.359 agent.memory.steps = steps[task_indices[1]:]360 changed = True361 continue362 # Exactly one turn (a single heavy turn overflowed the window)363 # — keep the task and the final-answer step, dropping the364 # bloated middle (sub-agent reports / large tool observations)365 # which are the usual token hogs.366 if task_indices and len(steps) > 2:367 agent.memory.steps = [steps[task_indices[0]]] + steps[-1:]368 changed = True369 continue370 break # task-only memory still over budget — nothing left to drop371 except Exception:372 pass373 return changed374 375 376def guard_memory_within_run(agent, llm=None, n_ctx=None,377 budget_ratio: float = COMPACTION_BUDGET_RATIO) -> bool:378 """Intra-run memory guard — the missing twin of compact_agent_memory().379 380 compact_agent_memory() only runs BETWEEN turns (before agent.run()),381 so a single long agentic run — a Deep Research manager that collects382 twelve web-search observations in ONE turn — could grow its memory383 far past the model's context window mid-run and die with llama-server's384 HTTP 400 exceed_context_size_error (observed: 62723 prompt tokens vs385 a 32768-token window at step ~12).386 387 This guard is called by stream_agent_steps() after EVERY completed388 step: when the estimated full-memory token cost crosses389 `n_ctx * budget_ratio`, _trim_memory_to_budget() strips heavy older390 observations / drops the bloated middle so the NEXT step's prompt fits.391 Only fires for LOCAL-backed models whose window this app can measure392 (is_local_backed_model) — remote backends are left untouched. Returns393 True when memory was trimmed."""394 try:395 if llm is None:396 llm = (getattr(agent, "llm", None)397 or getattr(agent, "model", None))398 if not is_local_backed_model(llm):399 return False400 if n_ctx is None:401 try:402 from backend.model_registry import get_saved_context_window403 n_ctx = get_saved_context_window()404 except Exception:405 return False406 try:407 n_ctx = int(n_ctx)408 except Exception:409 return False410 if n_ctx <= 0:411 return False412 budget = int(n_ctx * budget_ratio)413 est = estimate_text_tokens(_memory_to_text(agent, truncate=False),414 _get_model_tokenizer(llm))415 if est <= budget:416 return False417 print(f"[MemoryGuard] Agent memory ≈{est} tokens exceeds the "418 f"{budget}-token budget ({n_ctx} ctx × {budget_ratio:g}) — "419 f"trimming older steps …")420 return _trim_memory_to_budget(agent, budget,421 tokenizer=_get_model_tokenizer(llm))422 except Exception:423 return False424 425 426def is_local_backed_model(llm) -> bool:427 """True when `llm` is backed by LOCAL weights whose context window428 this app can measure: an in-process llama.cpp backend (`.llm`), the429 external llama-server backend (a `.gguf` model_path), or an430 in-process transformers model (`.model`). Remote backends (HF431 Inference API / LiteLLM) return False — their real context is432 unknowable here and typically far larger than the saved433 GGUF-oriented context-window fallback, so trimming against a434 guessed budget would fire every few turns for nothing. Shared by435 compact_agent_memory() and the direct chat paths' token-budget436 memory trimming (chat.py::_recent_memory_messages)."""437 if llm is None:438 return False439 return (bool(getattr(llm, "llm", None))440 or str(getattr(llm, "model_path", "") or "").lower().endswith(".gguf")441 or getattr(llm, "model", None) is not None)442 443 444def trim_messages_to_budget(messages: list, budget: int, tokenizer=None) -> list:445 """Keep the most recent `messages` — a list of plain446 {"role", "content"} dicts — whose estimated token footprint fits447 under `budget`, dropping whole OLD messages from the oldest end448 (never the newest) and always keeping at least one full exchange449 (two messages) when available. Same token-estimation machinery as450 compact_agent_memory(); used by the direct (non-agentic) chat451 paths' short-term memory so it is bounded by the context window452 instead of a fixed turn count. Returns `messages` unchanged when453 the budget is non-positive or an estimate hiccups."""454 if budget <= 0 or not messages:455 return messages456 try:457 costs = [estimate_text_tokens(str(m.get("content", "") or ""), tokenizer)458 for m in messages]459 if sum(costs) <= budget:460 return messages461 n = len(messages)462 min_keep = min(n, 2) # never trim below one full user+assistant exchange463 acc = sum(costs[n - min_keep:])464 best_start = n - min_keep465 for i in range(n - min_keep - 1, -1, -1):466 acc += costs[i]467 if acc <= budget:468 best_start = i469 else:470 break471 return messages[best_start:]472 except Exception:473 return messages474 475 476def compact_agent_memory(agent, model_id: str, n_ctx: int,477 keep_recent_turns: int = 2,478 budget_ratio: float = COMPACTION_BUDGET_RATIO,479 max_summary_tokens: int = COMPACTION_MAX_SUMMARY_TOKENS,480 lang_key: str = "kh") -> bool:481 """Token-budget-aware rolling compaction for an agentic CodeAgent —482 the meaningful upgrade over the plain turn-based cap.483 484 When the agent's memory would push the next prompt past the context485 window, the model condenses the OLDER turns into a short rolling486 summary that is prepended to the system prompt, while the most recent487 `keep_recent_turns` turns stay verbatim — preserving content the488 fixed turn-cap throws away, at the cost of one extra generation every489 time the window fills. Runs BEFORE the next agent.run() so the cost490 is only paid when a new message actually arrives.491 492 Returns True if memory was changed (summarized, or token-trimmed as a493 degraded fallback), so callers can surface a one-line status. Never494 raises: any failure degrades to dropping the oldest turns, and the495 existing post-run cap_agent_memory() call sites remain in place as496 the structural safety net. Returns False with zero changes when the497 "🧹 Memory Compaction" toggle (Settings) is off, when memory is498 still comfortably within budget, or when there aren't enough turns499 yet to make summarizing worthwhile AND memory is under budget (a500 single heavy turn that is already over budget — e.g. Deep Research,501 whose sub-agent report lives in the manager's memory — degrades to502 the token-budget trim instead, so the next run can't overflow).503 """504 from backend import model_registry as mr505 from backend import models506 507 if not mr.get_saved_memory_compaction_enabled():508 return False509 510 budget = 0511 tokenizer = None512 try:513 steps = list(getattr(agent.memory, "steps", []) or [])514 task_indices = [i for i, s in enumerate(steps) if isinstance(s, TaskStep)]515 516 # Remote backends (HF Inference API / LiteLLM) have no in-process517 # tokenizer or model config, so their real context is unknowable518 # here — and it is typically far larger than the saved context-519 # window fallback (which is GGUF-oriented). Compacting those would520 # fire every few turns and burn unnecessary remote API calls for521 # nothing, so skip them; the fixed turn-caps still bound their522 # memory.523 model_obj = getattr(agent, "model", None)524 if not is_local_backed_model(model_obj):525 return False526 527 n_ctx = _effective_n_ctx(model_obj, n_ctx)528 # Generation reserve: the upcoming answer shares the window with529 # whatever memory we keep, so the budget must leave room for it.530 budget = int(n_ctx * budget_ratio) - mr.get_saved_max_new_tokens()531 if budget <= 0:532 return False533 534 tokenizer = _get_model_tokenizer(getattr(agent, "model", None))535 if estimate_text_tokens(_memory_to_text(agent, truncate=False), tokenizer) <= budget:536 return False537 538 # Too few turns to make summarizing worthwhile — BUT memory is539 # already over budget (a single heavy turn can overflow the540 # window, e.g. Deep Research, whose sub-agent report lives in the541 # manager's memory — the "Requested tokens (N) exceed context542 # window" failure on turn 2). Degrade to the token-budget trim so543 # the next run never crashes with a context overflow.544 if len(task_indices) <= keep_recent_turns + 1:545 return _trim_memory_to_budget(agent, budget, tokenizer)546 547 # Everything before the last `keep_recent_turns` user turns is548 # compacted; those recent turns stay verbatim.549 cutoff_idx = task_indices[-keep_recent_turns]550 old_text = "\n\n".join(551 _step_to_text(s) for s in steps[:cutoff_idx]552 ).strip()553 if not old_text:554 return False555 556 lang_note = "ខ្មែរ (Khmer)" if lang_key == "kh" else "English"557 # Cap the summary generation itself: the compaction call must NOT558 # inherit the model's full MAX_NEW_TOKENS budget — a slow local559 # model would stall behind the "Compacting…" status for minutes,560 # and a rambling model would permanently bloat the prompt. Passed561 # through _call_llm's optional max_tokens kwarg, which falls back562 # gracefully if a backend's Model wrapper rejects it.563 summary, _, _n_tok = models._call_llm(564 model_id,565 _SUMMARY_SYSTEM.format(max_tokens=max_summary_tokens)566 + f"\nLanguage: {lang_note}.",567 old_text,568 max_tokens=max_summary_tokens,569 )570 summary = (summary or "").strip()571 if not summary or summary.startswith("❌"):572 # Summarizer failed (network, template, budget…) — degrade to573 # token-aware trimming of the oldest turns rather than the574 # full compaction.575 return _trim_memory_to_budget(agent, budget, tokenizer)576 # Defensive truncation: even a capped generation can exceed the577 # requested size — never let a single summary bloat the prompt.578 max_chars = max(512, max_summary_tokens * 4)579 if len(summary) > max_chars:580 summary = summary[:max_chars].rstrip() + "…"581 582 prev = getattr(agent, "_rolling_summary", "") or ""583 rolling = (prev + "\n\n" + summary).strip() if prev else summary584 if len(rolling) > _COMPACTION_ROLLING_SUMMARY_MAX_CHARS:585 # Keep the NEWEST summary content and drop the oldest chunk,586 # so a long conversation can't let the summary itself re-fill587 # the window (which would trigger a compaction loop).588 rolling = "…" + rolling[-_COMPACTION_ROLLING_SUMMARY_MAX_CHARS:].lstrip("\n")589 _inject_rolling_summary(agent, rolling)590 agent.memory.steps = steps[cutoff_idx:]591 agent._rolling_summary = rolling592 # Counter surfaced in the chat response footer ("compacted N×") so593 # users can see when the rolling summary is being used. Reset594 # together with the rest of memory in reset_memory().595 agent._compaction_count = (getattr(agent, "_compaction_count", 0) or 0) + 1596 print(f"[memory] Compaction: {len(task_indices)} turns -> rolling "597 f"summary + {len(steps[cutoff_idx:])} steps kept verbatim.")598 return True599 except Exception:600 import traceback601 traceback.print_exc()602 if budget > 0:603 try:604 return _trim_memory_to_budget(agent, budget, tokenizer)605 except Exception:606 pass607 return False608 609 610def reset_memory(agent) -> None:611 """Wipe an agent's own conversation memory in place, WITHOUT rebuilding612 the agent object itself.613 614 Calls smolagents' own `agent.memory.reset()` — see smolagents/memory.py:615 `AgentMemory.reset()` sets `self.steps = []` and explicitly leaves616 `self.system_prompt` (a separate `SystemPromptStep`, not part of617 `steps`) untouched. Using the library's own method here rather than618 reassigning `agent.memory.steps = []` by hand keeps this in step with619 whatever AgentMemory.reset() does internally in a future smolagents620 version, instead of this file quietly re-implementing (and621 potentially drifting from) that logic.622 623 Also restores the ORIGINAL system prompt, drops any rolling624 compaction summary the agent carries, and zeroes the compaction625 counter (see compact_agent_memory()): a reset must never replay626 compacted history from the previous conversation, so the compacted627 system-prompt overlay is undone here.628 629 Used whenever something other than an explicit model switch should630 invalidate memory — e.g. Data Analysis's "a new dataset was uploaded"631 check (see reset_if_context_changed() below). Clearing memory in632 place like this — rather than tearing down and rebuilding the whole633 CodeAgent, as an earlier version of this file did — is both cheaper634 (no tool re-initialization) and avoids the stale-memory-reload bug635 described in this module's docstring.636 """637 agent.memory.reset()638 if hasattr(agent, "_base_system_prompt"):639 _set_system_prompt_text(agent.memory, agent._base_system_prompt)640 if hasattr(agent, "_rolling_summary"):641 agent._rolling_summary = None642 if hasattr(agent, "_compaction_count"):643 agent._compaction_count = 0644 645 646def reset_if_context_changed(agent, state: dict, new_key) -> bool:647 """Helper for tabs where a NEW context (e.g. a newly uploaded dataset)648 should invalidate old agent memory even though the underlying649 model/agent object hasn't changed — otherwise the agent would keep650 "remembering" a previous file's columns/stats while analyzing a651 different one.652 653 `state` is a small mutable dict (e.g. module-level `{"key": None}`)654 used to remember the last context key seen. Returns True if a reset655 was triggered.656 """657 if state.get("key") != new_key:658 reset_memory(agent)659 state["key"] = new_key660 return True661 return False662 