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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 136 additions & 3 deletions frontends/continue_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def _parse_native_history(pairs):
history.append({'role': 'assistant', 'content': blocks})
return history


_PREVIEW_WIN = 32 * 1024

# Content-grep budget for `/continue` search box: read at most this many bytes
Expand Down Expand Up @@ -475,11 +476,22 @@ def _assistant_text(response_body):


def _format_tool_use(block):
"""Match agent_loop.py:72 verbose tool-call header."""
"""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: pretty = json.dumps(args, indent=2, ensure_ascii=False).replace('\\n', '\n')
except Exception: pretty = str(args)
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"


Expand Down Expand Up @@ -533,6 +545,127 @@ def _format_response_segment(response_body, tool_results):
return '\n\n'.join(p for p in ['\n\n'.join(texts), '\n'.join(tool_parts)] if p)


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.

Expand Down
120 changes: 120 additions & 0 deletions frontends/model_cmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""/model 命令的 agent 无关逻辑: 拉模型列表 + 运行时改 model。
被 tuiapp_v2 import; 不 patch 任何类, 不依赖 llmcore。"""
from typing import Optional, List, Tuple

import requests


def _is_mixin(b) -> bool:
return type(b).__name__ == 'MixinSession'


def _resolve(agent, sub: Optional[int] = None):
"""返回 (真正持有 model 的 session, mixin或None)。"""
b = agent.llmclient.backend
if _is_mixin(b):
return b._sessions[b._cur_idx if sub is None else sub], b
return b, None


def list_subsessions(agent) -> Optional[List[Tuple[int, str, bool]]]:
"""mixin 渠道 → [(idx, name, is_current)]; 普通渠道 → None。"""
b = agent.llmclient.backend
if not _is_mixin(b):
return None
return [(i, s.name, i == b._cur_idx) for i, s in enumerate(b._sessions)]


def current_model(agent, sub: Optional[int] = None) -> str:
s, _ = _resolve(agent, sub)
return s.model


def fetch_models(agent, sub: Optional[int] = None, timeout: int = 10) -> List[str]:
"""GET models 列表, 自动尝试 /models 与 /v1/models、原生头与 Bearer。"""
s, _ = _resolve(agent, sub)
base = s.api_base.rstrip('/')
urls = [f'{base}/models'] + ([] if base.endswith('/v1') else [f'{base}/v1/models'])
heads = [{'Authorization': f'Bearer {s.api_key}'}]
if 'Claude' in type(s).__name__:
heads.insert(0, {'x-api-key': s.api_key, 'anthropic-version': '2023-06-01'})
err = None
for url in urls:
for h in heads:
try:
r = requests.get(url, headers=h, timeout=timeout,
proxies=getattr(s, 'proxies', None),
verify=getattr(s, 'verify', True))
r.raise_for_status()
data = r.json().get('data', []) # 非 JSON(如 HTML 首页)会抛错进入下一候选
ids = {m['id'] for m in data if isinstance(m, dict) and m.get('id')}
if ids:
return sorted(ids)
except Exception as e:
err = e
raise err or RuntimeError('no models endpoint')


def set_model(agent, model: str, sub: Optional[int] = None) -> str:
"""运行时改 model(内存态, mykey 重载/重启后还原)。返回结果描述。"""
s, mixin = _resolve(agent, sub)
old = s.model
s.model = model # mixin 的 model 是只读 property, 必须落子 session
warn = ""
try: # 对齐 agentmain.next_llm 的中文 schema 切换
from agentmain import load_tool_schema
load_tool_schema('_cn' if any(x in model.lower() for x in ('glm', 'minimax', 'kimi')) else '')
except Exception as ex: # schema 选错会实际影响 agent 行为, 半径不为零, 不静默
warn = f" (⚠ schema 切换失败: {type(ex).__name__})"
where = f"[{s.name}]" if mixin else s.name
return f"{where}: {old} → {model}{warn}"


# GA 配置层允许的全部档位(llmcore BaseSession._enum)。各协议的真实支持面不同:
# Claude(output_config.effort) 只认 low/medium/high + xhigh→max, none/minimal
# 会被 _apply_claude_thinking 打 WARN 忽略; OpenAI 系(reasoning_effort /
# reasoning.effort) 原样透传, 由渠道端校验。
EFFORT_LEVELS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']


def _protocols(agent) -> set:
"""当前渠道涉及的协议集合 {'claude', 'openai'}。mixin 看全部子渠道(广播
会落到每一个)。NativeOAISession 名字不含 'Claude', 正确归入 openai。"""
b = agent.llmclient.backend
sessions = b._sessions if _is_mixin(b) else [b]
return {('claude' if 'Claude' in type(s).__name__ else 'openai')
for s in sessions}


def effort_note(level, protocols) -> str:
"""单点描述某档位在给定协议上的特殊行为(空串=无特殊)。set_effort 的结果
描述与 /effort picker 的行内备注共用它, 领域知识只在此编码一次。"""
if level and 'claude' in protocols:
if level in ('none', 'minimal'):
return 'Claude 渠道忽略'
if level == 'xhigh':
return 'Claude 对应 max'
return ''


def current_effort(agent) -> str:
return getattr(agent.llmclient.backend, 'reasoning_effort', None) or ''


def set_effort(agent, value) -> str:
"""运行时改 reasoning_effort(内存态)。空值/off 清除(不再发送 effort 字段)。
直接设在 backend 上: MixinSession 把它列为 _BROADCAST_ATTRS, 会同步到所有
子渠道(故障切换后档位不丢); 普通渠道就是 session 本身。请求时各协议现读
该属性, 立即生效。"""
e = (value or '').strip().lower()
if e in ('', 'off', 'clear', 'unset'):
e = None
elif e not in EFFORT_LEVELS:
return (f"无效 effort: {value!r}"
f" (可选 {'/'.join(EFFORT_LEVELS)}, 留空或 off 清除)")
b = agent.llmclient.backend
old = getattr(b, 'reasoning_effort', None)
b.reasoning_effort = e
note = effort_note(e, _protocols(agent))
tail = f" ({note})" if note else ""
return f"reasoning_effort: {old or '(未设置)'} → {e or '(清除)'}{tail}"
Loading