Files
2026-07-13 13:04:19 +08:00

1182 lines
49 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""`/continue` command: list & restore past model_responses sessions.
Pure functions + one `install(cls)` monkey-patch entry. No side effects at import.
"""
import ast, atexit, glob, json, os, random, re, shutil, threading, time
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'temp', 'model_responses')
_LOG_GLOB = os.path.join(_LOG_DIR, 'model_responses_*.txt')
_BLOCK_RE = re.compile(r'^=== (Prompt|Response) ===.*?\n(.*?)(?=^=== (?:Prompt|Response) ===|\Z)',
re.DOTALL | re.MULTILINE)
_SUMMARY_RE = re.compile(r'<summary>\s*(.*?)\s*</summary>', re.DOTALL)
_ROUND_HEADER_RE = re.compile(rb'^=== (Prompt|Response) ===', re.MULTILINE)
_ROUNDS_CACHE_PATH = os.path.join(os.path.expanduser('~'), '.genericagent', 'continue_rounds_cache.json')
_ROUNDS_CACHE_VERSION = 1
_rounds_cache = None
_rounds_cache_dirty = False
def _rel_time(mtime):
d = int(time.time() - mtime)
if d < 60: return f'{d}秒前'
if d < 3600: return f'{d // 60}分前'
if d < 86400: return f'{d // 3600}小时前'
return f'{d // 86400}天前'
def _pairs(content):
blocks, pairs, pending = _BLOCK_RE.findall(content or ''), [], None
for label, body in blocks:
if label == 'Prompt': pending = body.strip()
elif pending is not None:
pairs.append((pending, body.strip())); pending = None
return pairs
def _first_user(pairs):
for p, _ in pairs:
try: msg = json.loads(p)
except Exception: continue
if not isinstance(msg, dict): continue
for blk in msg.get('content', []) or []:
if isinstance(blk, dict) and blk.get('type') == 'text':
t = strip_project_mode(blk.get('text') or '').strip()
if t and '<history>' not in t and not t.startswith('### [WORKING MEMORY]'):
return t
for p, _ in pairs[:1]:
for line in p.splitlines():
s = line.strip()
if s and not s.startswith('###'): return s
return ''
def _last_user(text):
"""Last real user prompt. Scans `=== Prompt ===` blocks directly (no
Prompt/Response pairing, so response-less/aborted sessions still preview),
newest-first, returning the first one `_user_text` accepts (it drops
tool_result continuations + all _INJECT_MARKERS). Better preview anchor than
the first prompt — reflects what the session was most recently about."""
for label, body in reversed(_BLOCK_RE.findall(text or '')):
if label == 'Prompt':
t = _user_text(body)
if t:
return t
return ''
def _last_summary(pairs):
for _, response_body in reversed(pairs):
try:
blocks = ast.literal_eval(response_body)
except Exception:
continue
if not isinstance(blocks, list):
continue
text_parts = []
for block in blocks:
if isinstance(block, dict) and block.get('type') == 'text':
text = block.get('text', '')
if isinstance(text, str) and text:
text_parts.append(text)
match = _SUMMARY_RE.search('\n'.join(text_parts))
if match:
summary = match.group(1).strip()
if summary:
return summary
return ''
def _preview_text(pairs):
return _last_summary(pairs) or _first_user(pairs)
def _recent_context(my_pid, n=5):
"""扫描最近 n 个 model_response 文件(排除自身),提取 lastQ / lastA。"""
out = []
for f in sorted(glob.glob(_LOG_GLOB), key=os.path.getmtime, reverse=True):
m = re.search(r'model_responses_(\d+)', os.path.basename(f))
if not m or m.group(1) == str(my_pid): continue
try: c = open(f, encoding='utf-8', errors='ignore').read()
except Exception: continue
q = s = ""
for hm in re.finditer(r'<history>(.*?)</history>', c, re.DOTALL):
u = re.search(r'\[USER\]:\s*(.+?)(?:\\n|<)', hm.group(1))
if u: q = u.group(1)
sm = _SUMMARY_RE.search(c)
if sm: s = sm.group(1).strip()
q, s = q[:60].strip(), s[:60].replace('\n', ' ').strip()
out.append(f{m.group(1)} | lastQ: {q or "-"} | lastA: {s or "-"}')
if len(out) >= n: break
return ('[RecentContext] 近期并行会话(非当前):\n' + '\n'.join(out) + '\n[/RecentContext]') if out else ""
def _parse_native_history(pairs):
history = []
for p, r in pairs:
try: user_msg = json.loads(p)
except Exception: return None
try: blocks = ast.literal_eval(r)
except Exception: return None
if not (isinstance(user_msg, dict) and user_msg.get('role') == 'user'): return None
if not isinstance(blocks, list): return None
history.append(user_msg) # runtime history 尊重日志真实 prompt(含 project-mode 当轮注入)
history.append({'role': 'assistant', 'content': blocks})
return history
def parse_native_log(path, allow_empty=False):
"""Parse a native `model_responses_*.txt` log into backend.history.
Public wrapper around the mature `/continue` parser. It intentionally only
restores complete Prompt→Response pairs; dangling Prompt / partial turns keep
the current continue semantics and are ignored. Returns:
- list (possibly [] when allow_empty=True) on native success;
- None when the file is unreadable / non-native / empty without allow_empty.
"""
try:
with open(path, encoding='utf-8', errors='replace') as fh:
content = fh.read()
except Exception:
return [] if allow_empty else None
pairs = _pairs(content)
if not pairs:
# Native-looking but incomplete logs (e.g. dangling Prompt without Response)
# have block headers but no complete pairs. For worldline's allow_empty mode
# this means "0 completed rounds", not "parse failed → trust live history".
if allow_empty and (_is_empty_log(path) or _BLOCK_RE.findall(content or '')):
return []
return None
return _parse_native_history(pairs)
def _derive_hist_info(history):
"""从 native history 重建 history_info(轮级纪要):真实用户提问 → `[USER]: …`;每条
assistant(=一轮) → `[Agent] <summary>`(无 summary 取首行)。与 ga.turn_end_callback /
worldline 树同口径。纯函数、不依赖 worldline,供续接 opt-in 恢复工作记忆(见 restore_wm)。"""
def _all_text(m):
c = m.get('content') if isinstance(m, dict) else None
if isinstance(c, str): return c
if isinstance(c, list):
return '\n'.join(b.get('text', '') for b in c
if isinstance(b, dict) and b.get('type') == 'text')
return ''
def _is_tool_result(m):
c = m.get('content') if isinstance(m, dict) else None
return isinstance(c, list) and any(
isinstance(b, dict) and b.get('type') == 'tool_result' for b in c)
out = []
for m in history or []:
if not isinstance(m, dict): continue
role = m.get('role')
if role == 'user':
if _is_tool_result(m): continue # tool_result 轮不是提问
t = strip_project_mode(_all_text(m)).strip()
if t and not t.startswith(_INJECT_MARKERS):
out.append(f'[USER]: {t}')
elif role == 'assistant':
txt = re.sub(r'```.*?```|<thinking>.*?</thinking>', '', _all_text(m), flags=re.DOTALL)
mt = re.search(r'<summary>(.*?)</summary>', txt, re.DOTALL)
s = mt.group(1).strip()[:80] if (mt and mt.group(1).strip()) else ''
if not s:
s = next((ln.strip()[:80] for ln in txt.splitlines() if ln.strip()), '(无摘要)')
out.append(f'[Agent] {s}')
return out
_PREVIEW_WIN = 32 * 1024
# Content-grep budget for `/continue` search box: read at most this many bytes
# per session (head window) so 17MB files don't stall the UI. Empirically the
# user-typed prompt + first model reply + early summaries live in the first MB,
# which is what users actually want to recall sessions by.
_GREP_WIN = 1 * 1024 * 1024
def file_contains_all(path, terms, max_bytes=_GREP_WIN):
"""True iff every lowercase term in `terms` appears in the first
`max_bytes` of `path` (case-insensitive). Empty `terms` returns True so
callers can short-circuit. Reads as bytes + .lower() to avoid utf-8 cost
and stays within a fixed memory envelope regardless of file size.
"""
if not terms:
return True
try:
with open(path, 'rb') as fh:
buf = fh.read(max_bytes)
except OSError:
return False
if not buf:
return False
hay = buf.lower()
for t in terms:
if t and t.encode('utf-8', errors='ignore') not in hay:
return False
return True
def search_sessions(query, sessions, max_bytes=_GREP_WIN):
"""Filter `sessions` ([(path, mtime, preview, n), ...]) by content grep.
`query` is whitespace-split into AND terms (case-insensitive). Each
session is kept iff its path/preview already match OR the first
`max_bytes` of its file contain every term. Order is preserved.
Empty/whitespace query returns the list as-is.
"""
q = (query or '').strip().lower()
if not q:
return list(sessions or [])
terms = [t for t in q.split() if t]
if not terms:
return list(sessions or [])
out = []
for item in sessions or []:
path = item[0] if len(item) > 0 else ''
preview = item[2] if len(item) > 2 else ''
meta = (os.path.basename(path) + '\n' + (preview or '')).lower()
if all(t in meta for t in terms):
out.append(item)
continue
if file_contains_all(path, terms, max_bytes=max_bytes):
out.append(item)
return out
def _preview_from_file(path):
"""Cheap preview: last <summary> in tail window, else first user line in head window."""
try:
sz = os.path.getsize(path)
with open(path, 'rb') as fh:
if sz <= _PREVIEW_WIN * 2:
head = tail = fh.read()
else:
head = fh.read(_PREVIEW_WIN)
fh.seek(-_PREVIEW_WIN, 2); tail = fh.read()
except OSError: return ''
tail_s = tail.decode('utf-8', errors='replace')
# Use only the latest <summary>, and reject it if dirty. Models sometimes emit
# an unclosed <summary>, so the non-greedy DOTALL match pairs it with a far-away
# </summary> and swallows === block headers / JSON across rounds. Treat such a
# match as invalid and fall through to the last user prompt (don't dig older ones).
cands = _SUMMARY_RE.findall(tail_s)
if cands:
s = ' '.join(cands[-1].split())
if s and '=== ' not in s and '"role"' not in s and len(s) <= 200:
return s
# Summary invalid/absent -> last real user prompt (JSON-aware, skips anchors;
# scans Prompt blocks directly so response-less sessions still preview).
lu = _last_user(tail_s) or _last_user(head.decode('utf-8', errors='replace'))
if lu:
return ' '.join(lu.split())[:120]
return ''
def _rounds_cache_key(path):
return os.path.normcase(os.path.abspath(path))
def _load_rounds_cache():
"""Load lazy mtime/size keyed round-count cache for /continue.
Cache is intentionally triggered only by list_sessions(): no TUI startup cost,
no logging-path coupling. Missing/stale entries are recomputed on demand.
"""
global _rounds_cache
if _rounds_cache is not None:
return _rounds_cache
_rounds_cache = {}
try:
with open(_ROUNDS_CACHE_PATH, encoding='utf-8') as fh:
data = json.load(fh)
if isinstance(data, dict) and data.get('version') == _ROUNDS_CACHE_VERSION:
items = data.get('items')
if isinstance(items, dict):
_rounds_cache = items
except Exception:
_rounds_cache = {}
return _rounds_cache
def _save_rounds_cache(valid_keys=None):
global _rounds_cache_dirty
if not _rounds_cache_dirty or _rounds_cache is None:
return
try:
if valid_keys is not None:
keep = set(valid_keys)
for k in list(_rounds_cache.keys()):
if k not in keep:
_rounds_cache.pop(k, None)
os.makedirs(os.path.dirname(_ROUNDS_CACHE_PATH), exist_ok=True)
tmp = _ROUNDS_CACHE_PATH + '.tmp'
data = {'version': _ROUNDS_CACHE_VERSION, 'items': _rounds_cache}
with open(tmp, 'w', encoding='utf-8') as fh:
json.dump(data, fh, ensure_ascii=False, separators=(',', ':'))
os.replace(tmp, _ROUNDS_CACHE_PATH)
_rounds_cache_dirty = False
except Exception:
# Cache is a performance hint only; never break /continue on cache I/O.
pass
def _count_complete_rounds_from_file(path):
"""Count completed Prompt→Response pairs using only block headers.
Counting Prompt headers alone overcounts an in-flight/incomplete last round.
Header-pair counting matched `_pairs()` on sampled real logs while avoiding
expensive UTF-8 decode / body regex parsing.
"""
try:
with open(path, 'rb') as fh:
data = fh.read()
except OSError:
return 0
pending = False
rounds = 0
for m in _ROUND_HEADER_RE.finditer(data):
if m.group(1) == b'Prompt':
pending = True
elif pending:
rounds += 1
pending = False
return rounds
def _rounds_for_file(path, st):
global _rounds_cache_dirty
cache = _load_rounds_cache()
key = _rounds_cache_key(path)
size = int(getattr(st, 'st_size', 0))
mtime_ns = int(getattr(st, 'st_mtime_ns', int(getattr(st, 'st_mtime', 0) * 1_000_000_000)))
ent = cache.get(key)
if isinstance(ent, dict) and ent.get('size') == size and ent.get('mtime_ns') == mtime_ns:
try:
return int(ent.get('rounds', 0)), key
except Exception:
pass
n = _count_complete_rounds_from_file(path)
cache[key] = {'size': size, 'mtime_ns': mtime_ns, 'rounds': int(n)}
_rounds_cache_dirty = True
return n, key
def list_sessions(exclude_pid=None, exclude_log=None, rewind_root=None):
"""Newest-first list of (path, mtime, preview_text, n_rounds). Preview uses head/tail window only.
`exclude_log` (basename, e.g. 'model_responses_123456.txt') drops the caller's
OWN current session — preferred over `exclude_pid`, which assumed the log file
was named by PID (it isn't: agentmain mints a random 6-digit logid), so the
pid tag never matched and the current session leaked into its own list."""
files = glob.glob(_LOG_GLOB)
if exclude_pid is not None:
tag = f'model_responses_{exclude_pid}.txt'
files = [f for f in files if not f.endswith(tag)]
if exclude_log:
files = [f for f in files if os.path.basename(f) != exclude_log]
out = []
valid_keys = []
for f in files:
try:
st = os.stat(f)
mtime, sz = st.st_mtime, st.st_size
except OSError:
continue
if sz < 32:
continue
preview = _preview_from_file(f)
if not preview:
continue
rounds, key = _rounds_for_file(f, st)
valid_keys.append(key)
out.append((f, mtime, preview, rounds))
_save_rounds_cache(valid_keys)
# 【门控·worldline】树感知发现:日志存在但为空且有非空世界线树的会话(回退到起点后日志被
# 清空 → 上面 sz<32 跳过了)。仅当调用方显式传 rewind_root 时启用 → 其他 UI 不传,
# 行为逐字节不变。只读 tree.json 的 nodes/head(不依赖 worldline 模块)。
if rewind_root and os.path.isdir(rewind_root):
have = {os.path.basename(p) for p, *_ in out}
try:
keys = os.listdir(rewind_root)
except OSError:
keys = []
for key in keys:
if not key.startswith('model_responses_'):
continue
log_name = key + '.txt'
if log_name in have or log_name == exclude_log:
continue
log_path = os.path.join(_LOG_DIR, log_name)
try: # 仅收"日志确实为空"的(非空日志已被主循环收录)
if os.path.getsize(log_path) >= 32:
continue
except OSError:
continue # 日志缺失(如已归档)不作为可续会话展示
try:
with open(os.path.join(rewind_root, key, 'tree.json'), encoding='utf-8') as fh:
d = json.load(fh)
except Exception:
continue
nodes = d.get('nodes') or {}
real = [v for v in nodes.values() if v.get('kind') != 'origin']
if not real: # 只有 origin 的空树 → 无内容,跳过
continue
try:
mtime = os.path.getmtime(os.path.join(rewind_root, key, 'tree.json'))
except OSError:
mtime = 0
head = d.get('head')
title = (nodes.get(head, {}).get('title') if head else '') or '(已回退至会话起点)'
out.append((log_path, mtime, f'[世界线] {title}', len(real)))
out.sort(key=lambda x: x[1], reverse=True)
return out
_MD_ESCAPE_RE = re.compile(r'([\\`*_\[\]])')
def _escape_md(s): return _MD_ESCAPE_RE.sub(r'\\\1', s)
def _agent_clients(agent):
clients = []
for client in getattr(agent, 'llmclients', []) or []:
if client not in clients:
clients.append(client)
current = getattr(agent, 'llmclient', None)
if current is not None and current not in clients:
clients.insert(0, current)
return clients
def _replace_backend_history(agent, history):
backend = getattr(getattr(agent, 'llmclient', None), 'backend', None)
if backend is not None and hasattr(backend, 'history'):
backend.history = list(history or [])
def _current_log_path(pid=None):
pid = os.getpid() if pid is None else pid
return os.path.join(_LOG_DIR, f'model_responses_{pid}.txt')
def _snapshot_current_log(pid=None):
"""Persist current PID log as a standalone recoverable snapshot, then clear it."""
path = _current_log_path(pid)
if not os.path.isfile(path):
return None
try:
with open(path, encoding='utf-8', errors='replace') as fh:
content = fh.read()
except Exception:
return None
if not _pairs(content):
return None
os.makedirs(_LOG_DIR, exist_ok=True)
pid = os.getpid() if pid is None else pid
stamp = time.strftime('%Y%m%d_%H%M%S')
snapshot = os.path.join(_LOG_DIR, f'model_responses_snapshot_{pid}_{stamp}_{time.time_ns() % 1_000_000_000:09d}.txt')
with open(snapshot, 'w', encoding='utf-8', errors='replace') as fh:
fh.write(content)
with open(path, 'w', encoding='utf-8', errors='replace'):
pass
return snapshot
def reset_conversation(agent, message='🆕 已开启新对话,当前上下文已清空'):
"""Abort current work and clear all known frontend-visible conversation state."""
try:
agent.abort()
except Exception:
pass
_snapshot_current_log()
if hasattr(agent, 'history'):
agent.history = []
for client in _agent_clients(agent):
backend = getattr(client, 'backend', None)
if backend is not None and hasattr(backend, 'history'):
backend.history = []
if hasattr(client, 'last_tools'):
client.last_tools = ''
if hasattr(agent, 'handler'):
agent.handler = None
return message
def format_list(sessions, limit=20):
if not sessions: return '❌ 没有可恢复的历史会话'
lines = ['**可恢复会话**(输入 `/continue N` 恢复第 N 个):', '']
for i, (_, mtime, first, n) in enumerate(sessions[:limit], 1):
preview = _escape_md((first or '(无法预览)').replace('\n', ' ')[:60])
lines.append(f'{i}. `{_rel_time(mtime)}` · **{n} 轮** · {preview}')
return '\n'.join(lines)
def restore(agent, path):
"""Restore session at path. Returns (msg, is_full)."""
try:
with open(path, encoding='utf-8', errors='replace') as fh:
content = fh.read()
except Exception as e: return f'❌ 读取失败: {e}', False
pairs = _pairs(content)
if not pairs: return f'❌ {os.path.basename(path)} 为空或格式不符', False
history = _parse_native_history(pairs)
name = os.path.basename(path)
if history is not None:
agent.abort()
_replace_backend_history(agent, history)
return f'✅ 已恢复 {len(pairs)} 轮完整对话({name}\n(已写入 backend.history,可直接继续)', True
from chatapp_common import _restore_native_history, _restore_text_pairs
summary = _restore_text_pairs(content) or _restore_native_history(content)
if not summary: return f'❌ {name} 无法解析(非 native 且无摘要可提取)', False
agent.abort()
agent.history.extend(summary)
n = sum(1 for l in summary if l.startswith('[USER]: '))
return f'⚠️ 非 native 格式,已降级恢复 {n} 轮摘要({name}\n(请输入新问题继续)', False
def handle(agent, query, display_queue):
"""Dispatch /continue or /continue N. Returns None if consumed else original query."""
s = (query or '').strip()
if s == '/continue':
display_queue.put({'done': format_list(list_sessions(exclude_pid=os.getpid())), 'source': 'system'})
return None
m = re.match(r'/continue\s+(\d+)\s*$', s)
if m:
sessions = list_sessions(exclude_pid=os.getpid())
idx = int(m.group(1)) - 1
if not (0 <= idx < len(sessions)):
display_queue.put({'done': f'❌ 索引越界(有效范围 1-{len(sessions)}', 'source': 'system'})
return None
reset_conversation(agent, message=None)
msg, _ = restore(agent, sessions[idx][0])
display_queue.put({'done': msg, 'source': 'system'})
return None
return query
_INJECT_MARKERS = ('### [WORKING MEMORY]', '[SYSTEM TIPS]', '[SYSTEM]', '[System]',
'[DANGER]', '### [总结提炼经验]',
'Continue from where you left off')
# project_mode 插件把 `\n\n---\n[PROJECT MODE: <name>]\n…\n---` 追加到当轮 user
# message(见 plugins/project_mode._build_injection)。runtime history 尊重日志真实 prompt,
# 但 UI 预览/显示与派生 history_info 需要只取用户原话。老日志的 WORKING MEMORY 里还
# 可能嵌着已污染的 `[USER]: ... [PROJECT MODE] ... [Agent] ...`,所以剥离支持 text
# 内任意位置与被截断的单行 title 形态。不能加进 _INJECT_MARKERS——那会把整条用户原话一起丢弃。
_PM_BLOCK_RE = re.compile(r"\s*-{3,}\s*\[PROJECT MODE:.*?(?:\n-{3,}\s*|$)", re.DOTALL)
def strip_project_mode(text: str) -> str:
"""剔除文本中由 project_mode 插件追加的 L1 注入块。"""
return _PM_BLOCK_RE.sub("", text or "")
def _user_text(prompt_body):
"""User-typed text from a prompt JSON; '' if this is an agent auto-continuation.
A Prompt is auto-continue when *either* (a) it carries any tool_result block
(so it's the next round of an in-flight LLM call), or (b) its text blocks all
match known injection prefixes ([WORKING MEMORY], [SYSTEM TIPS], [System]
regenerate prompts, [DANGER] guards, etc.). Real first-prompts only contain
one plain text block with no injection markers.
"""
try: msg = json.loads(prompt_body)
except Exception: return ''
if not isinstance(msg, dict): return ''
blocks = msg.get('content', []) or []
if any(isinstance(b, dict) and b.get('type') == 'tool_result' for b in blocks):
return ''
for blk in blocks:
if isinstance(blk, dict) and blk.get('type') == 'text':
t = strip_project_mode(blk.get('text') or '').strip()
if t and not any(mk in t for mk in _INJECT_MARKERS): return t
return ''
def _assistant_text(response_body):
"""Joined plain text from a response blocks repr; '' on parse failure.
Used by /export to grab the model's prose only, without tool noise.
"""
try: blocks = ast.literal_eval(response_body)
except Exception: return ''
if not isinstance(blocks, list): return ''
return '\n'.join(b['text'] for b in blocks
if isinstance(b, dict) and b.get('type') == 'text'
and isinstance(b.get('text'), str) and b['text'].strip())
def _format_tool_use(block):
"""Match agent_loop.py:78 verbose tool-call header byte-for-byte.
MUST use agent_loop's `get_pretty_json`, not a plain `json.dumps`: the
former rewrites a `script` arg's `"; "` into `";\\n "`, so for tools
carrying `script` (code_run, web_execute_js) a plain dumps produces a
*different* fence body. The TUI's write/read/code cards content-address
their captures by `hash(get_pretty_json(args))`; a mismatched fence here
means the hash misses and the card silently falls back to the raw block."""
name = block.get('name', '?')
args = block.get('input', {})
try:
from agent_loop import get_pretty_json
pretty = get_pretty_json(args)
except Exception:
try: pretty = json.dumps(args, indent=2, ensure_ascii=False).replace('\\n', '\n')
except Exception: pretty = str(args)
return f"🛠️ Tool: `{name}` 📥 args:\n````text\n{pretty}\n````\n"
def _format_tool_result(content):
"""Match agent_loop.py:79-81 five-backtick fence around tool output."""
if isinstance(content, list):
parts = []
for b in content:
if isinstance(b, dict) and b.get('type') == 'text':
parts.append(b.get('text', '') or '')
elif isinstance(b, str):
parts.append(b)
body = '\n'.join(parts)
else:
body = '' if content is None else str(content)
return f"`````\n{body}\n`````\n"
def _tool_results_from_prompt(prompt_body):
"""Return {tool_use_id: formatted_fence} from a Prompt JSON's content blocks."""
try: msg = json.loads(prompt_body)
except Exception: return {}
if not isinstance(msg, dict): return {}
out = {}
for blk in msg.get('content', []) or []:
if isinstance(blk, dict) and blk.get('type') == 'tool_result':
tid = blk.get('tool_use_id') or ''
if tid: out[tid] = _format_tool_result(blk.get('content'))
return out
def _format_response_segment(response_body, tool_results):
"""Rebuild one LLM call's transcript slice: text blocks + tool_use headers +
matching tool_result fences. Mirrors agent_loop verbose output so fold_turns
sees the same string shape as live mode.
"""
try: blocks = ast.literal_eval(response_body)
except Exception: return ''
if not isinstance(blocks, list): return ''
texts, tool_parts = [], []
for b in blocks:
if not isinstance(b, dict): continue
t = b.get('type')
if t == 'text':
s = b.get('text', '')
if isinstance(s, str) and s.strip(): texts.append(s)
elif t == 'tool_use':
tool_parts.append(_format_tool_use(b))
tid = b.get('id') or ''
if tid and tid in tool_results: tool_parts.append(tool_results[tid])
return '\n\n'.join(p for p in ['\n\n'.join(texts), '\n'.join(tool_parts)] if p)
_PLAN_ENTRY_RE = re.compile(r'enter_plan_mode\(\s*[\'"]([^\'"]+plan\.md)[\'"]')
def find_plan_entry(path):
"""Last `enter_plan_mode("…plan.md")` call in a model_responses log.
Plan mode has exactly one entry point (plan_sop.md): a `code_run` tool call
whose inline_eval script invokes `handler.enter_plan_mode(...)`. That call
survives in the log as a structured `tool_use` block — unlike a plan path
merely *mentioned* in chat text, it cannot be produced by the user typing
a filename. Scanning these blocks is therefore the restore criterion for
the plan card; the last match wins so re-entered plans track the newest.
Returns the plan.md path string as written in the script, or None.
"""
try:
with open(path, encoding='utf-8', errors='replace') as f:
content = f.read()
except Exception:
return None
last = None
for _prompt, response in _pairs(content):
try:
blocks = ast.literal_eval(response)
except Exception:
continue
if not isinstance(blocks, list):
continue
for b in blocks:
if not (isinstance(b, dict) and b.get('type') == 'tool_use'
and b.get('name') == 'code_run'):
continue
m = _PLAN_ENTRY_RE.search(str((b.get('input') or {}).get('script') or ''))
if m:
last = m.group(1)
return last
def iter_write_captures(path):
"""Replay a log's file_write/file_patch/file_read calls into capture dicts
the TUI can feed to its card renderers (`_WRITE_CAP`), keyed later by
hash(get_pretty_json).
Live mode fills `_WRITE_CAP` from tool_before/tool_after hooks (with a real
pre-write disk snapshot); on /continue that history is gone, but the
structured `tool_use.input` survives in the log — clean, complete args. We
also track each path's content *within this session* so a file
written/patched several times shows real old→new diffs (not N× full "new
file"). Files first touched by an untracked on-disk state still fall back
to a full-content block.
Returns write entries `{"name", "args", "existed", "old", "status", "msg"}`
and read entries `{"name", "args", "content"}` in call order. `status`/`msg`
come from the matching tool_result so the header can show ✗ on a failed
write; a read's `content` is the raw tool_result text (the read card strips
its LLM-facing chrome itself).
"""
try:
with open(path, encoding='utf-8', errors='replace') as f:
content = f.read()
except Exception:
return []
pairs = _pairs(content)
# tool_use_id -> (status, msg) from any prompt's tool_result blocks (the
# result lands in the *next* round's Prompt as a tool_result whose content
# is the json-dumped outcome.data, e.g. {"status":"success","msg":...}).
# tr_raw keeps the undecoded text — a file_read result is plain text.
tr_status, tr_raw = {}, {}
for prompt, _ in pairs:
try:
msg_obj = json.loads(prompt)
except Exception:
continue
if not isinstance(msg_obj, dict):
continue
for blk in msg_obj.get('content', []) or []:
if not (isinstance(blk, dict) and blk.get('type') == 'tool_result'):
continue
tid = blk.get('tool_use_id')
c = blk.get('content')
if isinstance(c, list):
c = ''.join(b.get('text', '') for b in c
if isinstance(b, dict) and b.get('type') == 'text')
if tid and isinstance(c, str):
tr_raw[tid] = c
try:
d = json.loads(c) if isinstance(c, str) else None
except Exception:
d = None
if tid and isinstance(d, dict):
tr_status[tid] = (d.get('status'), str(d.get('msg') or ''))
out, state = [], {}
for _prompt, response in pairs:
try:
blocks = ast.literal_eval(response)
except Exception:
continue
if not isinstance(blocks, list):
continue
for b in blocks:
if not (isinstance(b, dict) and b.get('type') == 'tool_use'):
continue
name = b.get('name')
if name not in ('file_write', 'file_patch', 'file_read', 'code_run'):
continue
args = b.get('input') or {}
p = args.get('path')
if name == 'file_read':
out.append({'name': name, 'args': args,
'content': tr_raw.get(b.get('id'))})
continue
if name == 'code_run':
# data = the tool_result text; a dict result is JSON, an
# inline_eval / code-missing result is plain text. Pass the
# parsed dict when possible so the card reads exit_code/stdout;
# else the raw string (the card handles both).
raw = tr_raw.get(b.get('id'))
d = raw
try:
parsed = json.loads(raw) if isinstance(raw, str) else None
if isinstance(parsed, dict):
d = parsed
except Exception:
pass
out.append({'name': name, 'args': args, 'data': d})
continue
st, mg = tr_status.get(b.get('id'), (None, ''))
if name == 'file_patch':
# If this file's content is tracked within the session, pass it as
# the pre-write full file so the renderer can do a whole-file diff
# (real line numbers + context); else fall back to the fragment.
pre = state.get(p, '')
out.append({'name': name, 'args': args,
'existed': p in state, 'old': pre,
'status': st, 'msg': mg})
if st == 'error':
continue # failed call left the disk untouched — don't book it
old = args.get('old_content') or ''
if p in state and old:
state[p] = state[p].replace(old, args.get('new_content') or '', 1)
else: # file_write
existed = p in state
old = state.get(p, '')
new = str(args.get('content') or '')
mode = str(args.get('mode') or 'overwrite')
out.append({'name': name, 'args': args, 'existed': existed, 'old': old,
'status': st, 'msg': mg})
if st == 'error':
continue # failed call left the disk untouched — don't book it
if mode == 'append':
state[p] = old + new
elif mode == 'prepend':
state[p] = new + old
else:
state[p] = new
return out
def extract_ui_messages(path):
"""Parse a model_responses log into [{role, content}, ...] for UI replay.
Each user-initiated round becomes one user bubble plus one assistant bubble.
Auto-continuation LLM calls are concatenated into the same assistant bubble,
separated by ``**LLM Running (Turn N) ...**`` markers. Tool calls and their
results are rendered into the assistant content using the same string format
that agent_loop yields live, so fold_turns can fold them identically.
"""
try:
with open(path, encoding='utf-8', errors='replace') as f: content = f.read()
except Exception: return []
pairs = _pairs(content)
if not pairs: return []
# tool_results live in the *next* Prompt's content; index look-ahead.
next_tr = [{} for _ in pairs]
for i in range(len(pairs) - 1):
next_tr[i] = _tool_results_from_prompt(pairs[i + 1][0])
out, assistant, round_turn = [], None, 0
for i, (prompt, response) in enumerate(pairs):
user = _user_text(prompt)
seg = _format_response_segment(response, next_tr[i])
if user:
if assistant is not None: out.append(assistant)
out.append({'role': 'user', 'content': user})
# Turn 1 marker too — agent_loop yields one per LLM call, including the
# first, so fold_turns treats every non-last call uniformly as a fold.
assistant = {'role': 'assistant',
'content': f"\n\n**LLM Running (Turn 1) ...**\n\n{seg}"}
round_turn = 1
else:
if assistant is None:
assistant = {'role': 'assistant', 'content': ''}
round_turn = 1
round_turn += 1
marker = f"\n\n**LLM Running (Turn {round_turn}) ...**\n\n"
assistant['content'] = (assistant['content'] or '') + marker + seg
if assistant is not None: out.append(assistant)
return [m for m in out if (m.get('content') or '').strip()]
def handle_frontend_command(agent, query, exclude_pid=None):
"""Frontend-friendly /continue entry that returns text directly."""
s = (query or '').strip()
exclude_pid = os.getpid() if exclude_pid is None else exclude_pid
if s == '/continue':
return format_list(list_sessions(exclude_pid=exclude_pid))
m = re.match(r'/continue\s+(\d+)\s*$', s)
if not m:
return '用法: /continue 或 /continue N'
sessions = list_sessions(exclude_pid=exclude_pid)
idx = int(m.group(1)) - 1
if not (0 <= idx < len(sessions)):
return f'❌ 索引越界(有效范围 1-{len(sessions)}'
reset_conversation(agent, message=None)
msg, _ = restore(agent, sessions[idx][0])
return msg
# ===========================================================================
# 原地复原(in-place continue)共享层 —— 仅供 TUI(tui_v2/tui_v3 及其 rewind 副本)
# 调用;其它前端(IM/qt/streamlit…)不调用这些函数,行为完全不受影响。
#
# 模型:每个会话 = 一个 `model_responses_<logid>.txt`,身份就是文件本身。
# · 原地续 X = 把 agent 的 log_path 指回 X,之后的轮次追加到 X 本身(同一会话延续)。
# · 拷贝续 X = 铸一个新 logid、把 X 拷进去,在副本上续;X 原件不动(并发安全)。
# · 切走/新对话 = 释放当前锁、旧日志原样留作"空闲会话"(不存快照、不清空),新对话铸新 logid。
#
# 独占:每个 TUI 会话出生即持有自己日志的一把锁(`.locks/<logid>.lock`);
# 整进程共用一个心跳线程,每 ~5s touch 锁文件 mtime(无 fsync)。
# 判活 = 锁 mtime 在 30s 内新鲜;超 30s 视为持锁者已死,可被接管。
# 抢锁用原子 O_EXCL;锁基础设施任何故障都降级为"假定空闲、放行续接",绝不阻断 /continue。
# ===========================================================================
_LOCK_DIR = os.path.join(_LOG_DIR, '.locks')
_HB_INTERVAL = 5.0 # 心跳间隔(秒)
_STALE_AFTER = 30.0 # 超过这么久无心跳 → 持锁者视为已死,可接管
_held_locks = set() # 本进程当前持有的 log_path 集合
_hb_lock = threading.Lock()
_hb_thread = None
def _lock_path(log_path):
base = os.path.splitext(os.path.basename(log_path))[0]
return os.path.join(_LOCK_DIR, base + '.lock')
def _read_lock(lock_file):
try:
with open(lock_file, encoding='utf-8') as fh:
return json.load(fh)
except Exception:
return None
def _lock_fresh(lock_file):
"""心跳新鲜度 = 锁文件 mtime 距今 < _STALE_AFTER。"""
try:
return (time.time() - os.path.getmtime(lock_file)) < _STALE_AFTER
except OSError:
return False
def session_occupant(log_path):
"""若 `log_path` 正被一个活着的(心跳新鲜)进程持有,返回其 owner 元数据 dict;
否则返回 None(空闲,或锁已过期可接管)。供 TUI 判断"原地 / 弹窗拷贝"。"""
lf = _lock_path(log_path)
meta = _read_lock(lf)
if meta is not None and _lock_fresh(lf):
return meta
return None
def acquire_lock(log_path, agent_id=None):
"""尝试独占 `log_path`。成功(或锁设施故障降级)返回 True;
仅当被另一活进程(心跳新鲜)持有时返回 False。"""
try:
os.makedirs(_LOCK_DIR, exist_ok=True)
lf = _lock_path(log_path)
meta = {'pid': os.getpid(), 'agent_id': agent_id,
'log': os.path.basename(log_path),
'started': time.time()}
blob = json.dumps(meta, ensure_ascii=False)
try:
fd = os.open(lf, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8') as fh:
fh.write(blob)
except FileExistsError:
cur = _read_lock(lf)
if cur and cur.get('pid') != os.getpid() and _lock_fresh(lf):
return False # 被另一活进程持有
# 过期锁 / 本进程自己的 → 接管(覆盖)。小竞态窗口可接受。
try: os.remove(lf)
except OSError: pass
try:
fd = os.open(lf, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8') as fh:
fh.write(blob)
except FileExistsError:
cur2 = _read_lock(lf)
if cur2 and cur2.get('pid') != os.getpid() and _lock_fresh(lf):
return False # 抢锁竞态输了
with open(lf, 'w', encoding='utf-8') as fh:
fh.write(blob)
with _hb_lock:
_held_locks.add(log_path)
_ensure_hb_thread()
return True
except Exception:
# 锁设施故障绝不阻断续接 —— 降级为"假定空闲、放行"。
return True
def release_lock(log_path):
"""释放本进程对 `log_path` 的锁(只删自己持有/无主的锁文件)。"""
with _hb_lock:
_held_locks.discard(log_path)
try:
lf = _lock_path(log_path)
cur = _read_lock(lf)
if cur is None or cur.get('pid') == os.getpid():
os.remove(lf)
except Exception:
pass
def _hb_tick():
now = time.time()
with _hb_lock:
items = list(_held_locks)
for lp in items:
try:
os.utime(_lock_path(lp), (now, now)) # 仅更新 mtime,无 fsync
except OSError:
pass
def _hb_loop():
while True:
time.sleep(_HB_INTERVAL)
try:
_hb_tick()
except Exception:
pass
def _ensure_hb_thread():
global _hb_thread
with _hb_lock:
if _hb_thread is None:
_hb_thread = threading.Thread(target=_hb_loop,
name='ga-session-heartbeat', daemon=True)
_hb_thread.start()
@atexit.register
def _release_all_locks():
for lp in list(_held_locks):
release_lock(lp)
def _new_log_path():
"""铸一个新的 6 位 logid 日志路径(与 agentmain 同公式)。"""
logid = f'{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}'
return os.path.join(_LOG_DIR, f'model_responses_{logid}.txt')
def _retarget_log(agent, new_path):
"""把 agent(及其所有 llmclient)的日志写入点切到 new_path —— 之后的轮次写这里。"""
try:
agent.log_path = new_path
except Exception:
pass
for client in _agent_clients(agent):
try: client.log_path = new_path
except Exception: pass
def is_snapshot(path):
"""遗留快照存档(model_responses_snapshot_*.txt)。这类只能拷贝续,不参与原地
(provisional,待 worktree 复审)。"""
return os.path.basename(path).startswith('model_responses_snapshot_')
def _clear_conversation_state(agent):
"""清空对话状态(对齐 reset_conversation,但不碰日志文件)。"""
if hasattr(agent, 'history'):
agent.history = []
for client in _agent_clients(agent):
backend = getattr(client, 'backend', None)
if backend is not None and hasattr(backend, 'history'):
backend.history = []
if hasattr(client, 'last_tools'):
client.last_tools = ''
if hasattr(agent, 'handler'):
agent.handler = None
def acquire_birth_lock(agent, agent_id=None):
"""会话出生时持有自己当前日志的锁(新 logid 必然抢到)。TUI 在建会话时调用,
使本会话对"占用检测"可见 —— 别的会话才能据此判定它是否还活着。"""
lp = getattr(agent, 'log_path', '') or ''
if lp:
acquire_lock(lp, agent_id)
def release_current(agent):
"""切走:释放 agent 当前日志的锁,旧日志原样留作"空闲会话"(不存快照、不清空)。"""
lp = getattr(agent, 'log_path', '') or ''
if lp:
release_lock(lp)
def begin_fresh_session(agent, agent_id=None):
"""新对话 / clear:释放当前锁(旧日志留作空闲会话)→ 铸新 logid 重指 → 持新锁 →
清空对话状态。**替代 TUI 里的 reset_conversation**(不再存快照/清空旧日志)。"""
try: agent.abort()
except Exception: pass
release_current(agent)
newp = _new_log_path()
_retarget_log(agent, newp)
acquire_lock(newp, agent_id)
_clear_conversation_state(agent)
def _load_history_into(agent, path, restore_wm=False):
"""把 `path` 解析进 backend.history(native;否则降级摘要)。镜像 restore() 的解析,
但不 abort/不快照(日志重指由调用方先做好)。返回 (msg, is_full)。
`restore_wm`(默认 False,仅显式传入的调用方启用,如 worldline TUI):从同一份日志
派生 history_info 写回 `agent.history`,使续接后工作记忆不丢。默认关闭 → 其它前端
行为完全不变。"""
try:
with open(path, encoding='utf-8', errors='replace') as fh:
content = fh.read()
except Exception as e:
return f'❌ 读取失败: {e}', False
pairs = _pairs(content)
if not pairs:
return f'❌ {os.path.basename(path)} 为空或格式不符', False
history = _parse_native_history(pairs)
name = os.path.basename(path)
if history is not None:
_replace_backend_history(agent, history)
if restore_wm and hasattr(agent, 'history'):
agent.history = _derive_hist_info(history) # 续接恢复工作记忆(opt-in)
return f'✅ 已恢复 {len(pairs)} 轮完整对话({name}', True
from chatapp_common import _restore_native_history, _restore_text_pairs
summary = _restore_text_pairs(content) or _restore_native_history(content)
if not summary:
return f'❌ {name} 无法解析(非 native 且无摘要可提取)', False
if hasattr(agent, 'history'):
agent.history.extend(summary)
n = sum(1 for l in summary if l.startswith('[USER]: '))
return f'⚠️ 非 native 格式,降级恢复 {n} 轮摘要({name}', False
def _is_empty_log(path):
"""日志空(<32 字节)或缺失。用于 allow_empty:回退到会话起点后日志被清空的会话。"""
try:
return os.path.getsize(path) < 32
except OSError:
return True
def continue_inplace(agent, path, agent_id=None, allow_empty=False, restore_wm=False):
"""原地续:把 agent 的日志指回 `path` 本身,之后轮次追加到 X,延续同一会话。
调用方应已确认空闲(session_occupant 为 None);抢锁失败(被占)返回错误。
`allow_empty`(仅 worldline UI 传):日志为空时不报错,按【空会话】恢复(清空对话,
由调用方按 `.ga_rewind` 树重连),用于"回退至会话起点"的会话。
`restore_wm`(opt-in):续接后从日志派生 history_info 恢复工作记忆。返回 (msg, ok)。"""
try: agent.abort()
except Exception: pass
if not acquire_lock(path, agent_id): # 先抢到目标锁;失败则保持现状,不丢自己的锁
return '❌ 会话已被占用,无法原地接管', False
cur = getattr(agent, 'log_path', '') or ''
if cur and os.path.basename(cur) != os.path.basename(path):
release_lock(cur) # 目标到手,旧会话释放为空闲(同一文件则不放)
_retarget_log(agent, path)
msg, ok = _load_history_into(agent, path, restore_wm=restore_wm)
if not ok and allow_empty and _is_empty_log(path):
_replace_backend_history(agent, []) # 空会话:清空对话(载入失败时它没被清)
return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True
return msg, ok
def continue_copy(agent, path, agent_id=None, allow_empty=False, restore_wm=False):
"""拷贝续:铸新 logid、把 `path` 内容拷进去,在副本上续;`path` 原件不动。
用于"被占用→用户选拷贝"以及快照源。`restore_wm`(opt-in)同 continue_inplace。
返回 (msg, ok)。"""
try: agent.abort()
except Exception: pass
release_current(agent)
newp = _new_log_path()
try:
shutil.copyfile(path, newp)
except Exception:
pass
acquire_lock(newp, agent_id)
_retarget_log(agent, newp)
msg, ok = _load_history_into(agent, newp, restore_wm=restore_wm)
if not ok and allow_empty and _is_empty_log(newp):
_replace_backend_history(agent, [])
return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True
return msg, ok
def install(cls):
"""Wrap cls._handle_slash_cmd so /continue is handled before original dispatch."""
orig = cls._handle_slash_cmd
if getattr(orig, '_continue_patched', False): return
def patched(self, raw_query, display_queue):
if (raw_query or '').startswith('/continue'):
r = handle(self, raw_query, display_queue)
if r is None: return None
return orig(self, raw_query, display_queue)
patched._continue_patched = True
cls._handle_slash_cmd = patched