CoolFace
Apppublic

localailb/assistant

sourceHugging Facemitupdated 1mo agoView on Hugging Face
0likes
agent_memory.py662 linesDownload Raw Back to agents
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