
    sj,C                     `   d Z ddlZddlZddlZddlZddlZddlZddlZddlZddl	m
Z
mZmZ  ej        e          ZdZdZdZdZdZd	Zd
Z ej        d          ZdZ ej                    Zdad ZefdedefdZd.dZ de!fdZ"de#de#fdZ$de#de#dz  fdZ%de#fdZ&de#de#fdZ'e
fde#dede(e#e!f         fdZ)de#de#fdZ*de#de#de!fd Z+d!e#d"e!d#ed$e#de#f
d%Z, ej        d&ej-                  Z.de#de#dz  fd'Z/dedfde#d(e#de#d)ed*ee0z  dz  de#fd+Z1defd,e2e3         d)ede2e3         fd-Z4dS )/u	  Tool result persistence -- preserves large outputs instead of truncating.

Defense against context-window overflow operates at three levels:

1. **Per-tool output cap** (inside each tool): Tools like search_files
   pre-truncate their own output before returning. This is the first line
   of defense and the only one the tool author controls.

2. **Per-result persistence** (maybe_persist_tool_result): After a tool
   returns, if its output exceeds the tool's registered threshold
   (registry.get_max_result_size), the full output is persisted and the
   in-context content is replaced with a preview + file path reference.

   The canonical home is ALWAYS host-side:
   ``$HERMES_HOME/cache/spillover/{tool_use_id}.txt`` — alongside the other
   Hermes-owned caches (images, audio, documents, ...) instead of littering
   the OS temp dir. This needs no sandbox environment, so it also works for
   sessions that never ran a terminal command (MCP-only, cron, gateway) —
   previously those hit the inline-truncate fallback because
   ``get_active_env()`` returned None until the first terminal call created
   an environment.

   What the model sees depends on the backend:

   - **Local backend (or no active env):** the host path itself.
   - **Remote backends (docker/ssh/modal/daytona):** ``cache/spillover`` is
     in the auto-mounted/synced cache-dir list (tools/credential_files.py),
     so the reference is the translated in-sandbox path (probed for
     readability first). When the sandbox can't see it (e.g. a persistent
     container created before spillover joined the mount list), fall back
     to writing a copy into the sandbox temp dir via env.execute().

   The spillover dir is pruned two ways: the gateway housekeeping loop
   sweeps it hourly with the other media caches, and a once-per-process
   best-effort prune runs on the first spill so CLI-only installs (which
   never run gateway housekeeping) self-clean too.

3. **Per-turn aggregate budget** (enforce_turn_budget): After all tool
   results in a single assistant turn are collected, if the total exceeds
   MAX_TURN_BUDGET_CHARS (200K), the largest non-persisted results are
   spilled to disk until the aggregate is under budget. This catches cases
   where many medium-sized results combine to overflow context.
    N)DEFAULT_PREVIEW_SIZE_CHARSBudgetConfigDEFAULT_BUDGETz<persisted-output>z</persisted-output>z/tmp/hermes-resultszcache/spillover   HERMES_PERSIST_EOF__budget_enforcement__z[^A-Za-z0-9_.-]+x   Fc                  2    ddl m}   |             t          z  S )z<Return $HERMES_HOME/cache/spillover as a Path (not created).r   get_hermes_home)hermes_constantsr   SPILLOVER_SUBDIRr   s    =/home/agent/.hermes/hermes-agent/tools/tool_result_storage.pyget_spillover_dirr   K   s(    000000?///    max_age_hoursreturnc                 |   t          j                     | dz  z
  }d}	 t          t                                                                }n# t          $ r Y dS w xY w|D ]]}	 |                                r6|                                j        |k     r|                                 |dz  }N# t          $ r Y Zw xY w|S )u'  Delete spillover files older than *max_age_hours*.

    Same contract as the ``cleanup_*_cache`` helpers in
    ``gateway.platforms.base`` — returns the number of files removed —
    so the gateway housekeeping loop can prune this dir on the same
    hourly cadence as the media caches.
    i  r      )	timelistr   iterdirOSErroris_filestatst_mtimeunlink)r   cutoffremovedentriesfs        r   cleanup_spillover_cacher"   R   s     Y[[MD01FG(**224455   qq  	yy{{ qvvxx0699


1 	 	 	H	Ns$   -A 
AA!A
B,,
B98B9c                  4   t           5  t          r	 ddd           dS daddd           n# 1 swxY w Y   	 t                      } | rt                              d|            dS dS # t
          $ r&}t                              d|           Y d}~dS d}~ww xY w)u   Best-effort prune, at most once per process.

    The gateway housekeeping loop prunes hourly, but CLI-only installs
    never run it — without this, spillover files would accumulate
    forever on pure-CLI setups.
    NTz#Pruned %d expired spillover file(s)zSpillover prune failed: %s)_spillover_prune_lock_spillover_pruned_oncer"   loggerdebug	Exception)r   excs     r   _prune_spillover_oncer*   j   s     
 & &! 	& & & & & & & & "&& & & & & & & & & & & & & & &8)++ 	ILL>HHHHH	I 	I 8 8 8137777777778s'   	,,00+A' '
B1BBc                 Z    | dS 	 ddl m} t          | |          S # t          $ r Y dS w xY w)u  True when the spill file should be written by this process directly.

    Covers ``env=None`` (no sandbox environment active — e.g. a session
    that has not run a terminal command yet) and the local backend
    (where env.execute() runs on this same host anyway). Remote backends
    (docker/ssh/modal/daytona) return False: their read_file resolves
    inside the sandbox, so the spill must be written there.
    NTr   )LocalEnvironmentF)tools.environments.localr,   
isinstancer(   )envr,   s     r   _is_host_side_envr0   ~   sX     {t======#/000   uus    
**contentfilenamec                 ,   	 t                      }|                    dd           ||z  }|                    | dd           n4# t          $ r'}t                              d||           Y d}~dS d}~ww xY wt                       t          |          S )zWrite content host-side to $HERMES_HOME/cache/spillover.

    Returns the absolute path string on success, None on failure.
    T)parentsexist_okutf-8replace)encodingerrorsz!Spillover write failed for %s: %sN)r   mkdir
write_textr   r&   warningr*   str)r1   r2   	spill_dirpathr)   s        r   _write_to_spilloverr@      s    
%''	t4448#')DDDD   :HcJJJttttt t99s   AA 
A6A11A6	host_pathc                 D   	 ddl m}  ||           }n3# t          $ r&}t                              d|           Y d}~dS d}~ww xY wt          |dd          }|J	 |                    d           n2# t          $ r%}t                              d|           Y d}~nd}~ww xY w	 |                    d	t          j	        |           d
          }|
                    dd          dk    r|S n2# t          $ r%}t                              d|           Y d}~nd}~ww xY wdS )u5  Return the path where a remote backend can read *host_path*, or None.

    ``cache/spillover`` is one of the auto-mounted/synced cache dirs
    (tools/credential_files.py), so on docker it is bind-mounted and on
    modal/ssh/daytona it is file-synced into the sandbox. Translate the
    host path with the same helper the image tools use, force a sync for
    synced backends, then PROBE readability — a persistent docker
    container created before spillover joined the mount list won't have
    the bind mount, and must fall back to the in-sandbox write.
    r   )to_agent_visible_cache_pathz%Spillover path translation failed: %sN_sync_managerT)forcezSpillover sync failed: %sztest -r    )timeout
returncoder   z&Spillover readability probe failed: %s)tools.credential_filesrC   r(   r&   r'   getattrsyncexecuteshlexquoteget)rA   r/   rC   visibler)   sync_managerresults          r   _sandbox_visible_spillover_pathrS      s   FFFFFF--i88   <cBBBttttt 366L	;D)))) 	; 	; 	;LL4c::::::::	;D>G(<(<>>KK::lA&&!++N , D D D=sCCCCCCCCD4sC    
A?AA2 2
B!<BB!%AC. .
D8DDc                    | |t          | dd          }t          |          r\	  |            }|r|                    d          pd}| dS n2# t          $ r%}t                              d|           Y d}~nd}~ww xY wt          S )z=Return the best temp-backed storage dir for this environment.Nget_temp_dir/z/hermes-resultsz"Could not resolve env temp dir: %s)rJ   callablerstripr(   r&   r'   STORAGE_DIR)r/   rU   temp_dirr)   s       r   _resolve_storage_dirr[      s    
sND99L!! 	88'<>>  8's33:sH&77778  H H HA3GGGGGGGGH s   
A 
A<A77A<tool_use_idc                    t          | pd          }t                              d|                              d          }||k    }|sd}d}|st	          |          t
          k    rlt          j        |                    d                    	                                dd         }|dt
                   
                    d          pd}| d| }| dS )	z3Return a single safe filename for a tool result id.tool_result_z._-Tr6   N   z.txt)r=   _UNSAFE_RESULT_FILENAME_CHARSsubstriplen_MAX_RESULT_FILENAME_STEMhashlibsha256encode	hexdigestrX   )r\   raw_id	safe_stemchangeddigests        r   _safe_result_filenamern      s    -..F-11#v>>DDUKKI6!G !	 ,#i..#<<<g 6 677AACCCRCH8889@@GGX=	 ++6++	r   	max_charsc                     t          |           |k    r| dfS | d|         }|                    d          }||dz  k    r|d|dz            }|dfS )zGTruncate at last newline within max_chars. Returns (preview, has_more).FN
   r   T)rd   rfind)r1   ro   	truncatedlast_nls       r   generate_previewrv      sg    
7||y  ~

#Iood##Galw{l+	d?r   c                 h    t           | vrt           S dt          j                    j        dd          S )z=Return a heredoc delimiter that doesn't collide with content.HERMES_PERSIST_N   )HEREDOC_MARKERuuiduuid4hex)r1   s    r   _heredoc_markerr~      s3    W$$3TZ\\-bqb1333r   remote_pathc                     t           j                            |          }dt          j        |           dt          j        |           }|                    |d|           }|                    dd          dk    S )u  Write content into the sandbox via env.execute(). Returns True on success.

    Pushes ``content`` through stdin rather than embedding it in the command
    string. Linux's ``MAX_ARG_STRLEN`` caps any single argv element at 128 KB
    (32 * PAGE_SIZE), so the previous heredoc-in-the-command-string approach
    silently failed with ``OSError: [Errno 7] Argument list too long`` for any
    tool result over ~128 KB — exactly the case persistence exists to handle.
    Routing through stdin removes that ceiling on local + ssh (``_stdin_mode
    == "pipe"``); remote backends with ``_stdin_mode == "heredoc"`` keep their
    existing API-body sized limit, which is orders of magnitude larger than
    the exec-arg ceiling.
    z	mkdir -p z
 && cat >    )rG   
stdin_datarH   r   r   )osr?   dirnamerM   rN   rL   rO   )r1   r   r/   storage_dircmdrR   s         r   _write_to_sandboxr      sp     '//+..K
Tek+..
T
T%+k:R:R
T
TC[[bW[==F::lA&&!++r   previewhas_moreoriginal_size	file_pathc                     |dz  }|dk    r
|dz  dd}n|dd}t            d}|d|dd| d	z  }|d
| dz  }|dz  }|dz  }|dt          |            dz  }|| z  }|r|dz  }|dt           z  }|S )z/Build the <persisted-output> replacement block.i   z.1fz MBz KBrq   z This tool result was too large (,z characters, z).
zFull output saved to: zYUse the read_file tool with offset and limit to access specific sections of this output.
u   Recovery: page through the saved file with read_file (offset/limit) or process it with execute_code — do NOT re-request the same data from the remote API; the full result is already on disk.

zPreview (first z	 chars):
z
...)PERSISTED_OUTPUT_TAGrd   PERSISTED_OUTPUT_CLOSING_TAG)r   r   r   r   size_kbsize_strmsgs          r   _build_persisted_messager     s     d"G$n----&&&&!
%
%
%CZmZZZHZZZZC1I1111CggC	>C
 5S\\5555C7NC w.,...CJr   z^Full output saved to: (.+)$c                     t          | t                    r	t          | vrdS t                              |           }|r'|                    d                                          ndS )a'  Return the file path from a <persisted-output> replacement block.

    Used by the result-reference stubbing guard (agent/tool_guardrails.py) so
    a stub referencing a persisted first occurrence can carry the spillover
    path instead of dangling. Returns None for non-persisted content.
    Nr   )r.   r=   r   _PERSISTED_PATH_REsearchgrouprc   )r1   matchs     r   extract_persisted_pathr   -  s_     gs## ';7'J'Jt%%g..E%*45;;q>>!!!4r   	tool_nameconfig	thresholdc                 @   ||n|                     |          }|t          d          k    r| S t          |           |k    r| S t          |          }t	          | |j                  \  }}	t          | |          }
t          |          rN|
Jt          	                    d||t          |           |
           t          ||	t          |           |
          S n||
]t          |
|          }|Kt          	                    d||t          |           ||
           t          ||	t          |           |          S t          |          }| d| }	 t          | ||          rJt          	                    d||t          |           |           t          ||	t          |           |          S n3# t          $ r&}t                              d||           Y d}~nd}~ww xY wt          	                    d|t          |                      | d	t          |           d
dS )a  Layer 2: persist oversized result into the sandbox, return preview + path.

    Writes via env.execute() so the file is accessible from any backend
    (local, Docker, SSH, Modal, Daytona). Falls back to inline truncation
    if write fails or no env is available.

    Args:
        content: Raw tool result string.
        tool_name: Name of the tool (used for threshold lookup).
        tool_use_id: Unique ID for this tool call (used as filename).
        env: The active BaseEnvironment instance, or None.
        config: BudgetConfig controlling thresholds and preview size.
        threshold: Explicit override; takes precedence over config resolution.

    Returns:
        Original content if small, or <persisted-output> replacement.
    Ninf)ro   z4Persisted large tool result: %s (%s, %d chars -> %s)z?Persisted large tool result: %s (%s, %d chars -> %s [host: %s])rV   zSandbox write failed for %s: %szDInline-truncating large tool result: %s (%d chars, no sandbox write)z 

[Truncated: tool response was r   z3 chars. Full output could not be saved to sandbox.])resolve_thresholdfloatrd   rn   rv   preview_sizer@   r0   r&   infor   rS   r[   r   r(   r<   )r1   r   r\   r/   r   r   effective_thresholdr2   r   r   rA   rP   r   r   r)   s                  r   maybe_persist_tool_resultr   :  sz   2 (1'<))&BZBZ[dBeBeeEll**
7||***$[11H(F<OPPPGX
 $GX66I P KKF;Gi   ,GXs7||YWWW ! 
  5iEEG"U{CLL'9   03w<<QXYYY +3//$11x11	P +s;; ^J{CLL+   03w<<Q\]]]^  	P 	P 	PNN<k3OOOOOOOO	P KKN3w<<  
  	7 	7),WA	7 	7 	7s   AF. .
G8GGtool_messagesc           	      N   g }d}t          |           D ]O\  }}|                    dd          }t          |          }||z  }t          |vr|                    ||f           P||j        k    r| S |                    d d           |D ]\  }	}||j        k    r n| |	         }|d         }|                    dd|	           }
t          |t          |
||d	          }||k    r>||z  }|t          |          z  }|| |	         d<   t          
                    d
|
|           | S )a"  Layer 3: enforce aggregate budget across all tool results in a turn.

    If total chars exceed budget, persist the largest non-persisted results
    first (via sandbox write) until under budget. Already-persisted results
    are skipped.

    Mutates the list in-place and returns it.
    r   r1    c                     | d         S )Nr    )xs    r   <lambda>z%enforce_turn_budget.<locals>.<lambda>  s
    !A$ r   T)keyreversetool_call_idbudget_)r1   r   r\   r/   r   r   z7Budget enforcement: persisted tool result %s (%d chars))	enumeraterO   rd   r   appendturn_budgetsortr   _BUDGET_TOOL_NAMEr&   r   )r   r/   r   
candidates
total_sizeir   r1   sizeidxr\   replacements               r   enforce_turn_budgetr     sx    JJM** ) )3'')R((7||d
w..q$i(((V'''OOO555  	T+++EC i.ggnooo>>/'#
 
 
 '!!$J#k***J,7M#y)KKIT  
 r   )r   N)5__doc__rf   loggingr   rerM   	threadingr   r{   tools.budget_configr   r   r   	getLogger__name__r&   r   r   rY   r   SPILLOVER_MAX_AGE_HOURSrz   r   compilera   re   Lockr$   r%   r   intr"   r*   boolr0   r=   r@   rS   r[   rn   tuplerv   r~   r   r   	MULTILINEr   r   r   r   r   dictr   r   r   r   <module>r      s  * *X   				 				                 
	8	$	$+ 4 #$  %,  *
+> ? ?  &	((  0 0 0 2I  3 S    08 8 8 8(d    &     " s  C$J        F     s s    $ 5O  c c SXY\^bYbSc    4S 4S 4 4 4 4,s , ,d , , , ,&  	
 	   <  RZ ?NN 
5C 
5C$J 
5 
5 
5 
5" 	)$(R RRR R
 R U{T!R 	R R R Rn 	)3 3:3 3 
$Z	3 3 3 3 3 3r   