Merge remote-tracking branch 'upstream/main' into merge-upstream-main-20260710

This commit is contained in:
yanghang committed 2026-07-10 18:56:03 +08:00
commit eacd3d176d
9 files changed
+69 -67

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ SuperGrok Local Proxy - CLI
python assets/supergrok_proxy.py login
python assets/supergrok_proxy.py serve --port 15433
python assets/supergrok_proxy.py models
python assets/supergrok_proxy.py test --model grok-4.3
python assets/supergrok_proxy.py test --model grok-4.5
GenericAgent/mykey 配置示例:
native_oai_config_supergrok_proxy = {
+26 -8
View File
@@ -84,6 +84,20 @@ def _apply_desktop_model(agent: "GenericAgent") -> None:
_fallback_usable_model(agent)
def _select_llm(agent: "GenericAgent", llm: Any) -> bool:
if llm is None or str(llm).strip() == "": return False
q = str(llm).strip()
if isinstance(llm, int) or q.isdigit(): agent.next_llm(int(q)); return True
q = q.lower(); agent.load_llm_sessions()
for i, c in enumerate(agent.llmclients):
vals = []
for fn in (lambda: agent.get_llm_name(c), lambda: agent.get_llm_name(c, model=True), lambda: c.backend.name, lambda: c.backend.model):
try: vals.append(str(fn()).lower())
except Exception: pass
if any(q in v for v in vals): agent.next_llm(i); return True
raise ValueError(f"llm not found: {llm}")
@asynccontextmanager
async def lifespan(app: FastAPI):
# 服务启动(事件循环已就绪):捕获 loop 供工作线程跨线程推 WS 广播,并起主agent
@@ -104,6 +118,7 @@ class ChatIn(BaseModel):
class StartSubagentIn(BaseModel):
prompt: str
llm: Any = None
class ApprovalIn(BaseModel):
prompt: str
@@ -112,6 +127,7 @@ class ApprovalIn(BaseModel):
class SubagentActionIn(BaseModel):
action: str = "intervene" # intervene | abort | kill
msg: str = ""
llm: Any = None
@dataclass
class SubAgentState:
@@ -257,13 +273,13 @@ class SubagentPool:
s.agent.task_queue.put("EXIT")
with self.lock: self.subagents.pop(sid, None)
if to_abort: push_cards()
def start_subagent(self, prompt: str) -> dict:
def start_subagent(self, prompt: str, llm: Any = None) -> dict:
sid = short_id()
agent = GenericAgent()
agent.inc_out = True
agent.verbose = False
agent.no_print = True
_apply_desktop_model(agent)
if not _select_llm(agent, llm): _apply_desktop_model(agent)
th = start_agent_runner(agent, f"subagent-{sid}")
state = SubAgentState(id=sid, agent=agent, prompt=prompt, status="running", thread=th)
with self.lock: self.subagents[sid] = state
@@ -275,10 +291,11 @@ class SubagentPool:
threading.Thread(target=monitor_display_queue, args=(sid, dq, True), name=f"monitor-{sid}", daemon=True).start()
push_cards()
return {"id": sid, "status": "running"}
def input_subagent(self, sid: str, msg: str) -> dict:
def input_subagent(self, sid: str, msg: str, llm: Any = None) -> dict:
with self.lock: s = self.subagents.get(sid)
if not s: return {"error": "subagent not found", "id": sid}
if s.status == "running": return {"error": "subagent is still running, cannot input/reply. Start a new subagent instead.", "id": sid}
_select_llm(s.agent, llm)
s.prompt = msg
s.reply = ""
s.status = "running"
@@ -299,10 +316,10 @@ READMES = {
Conductor API\tBase: http://{HOST}:{PORT}
POST /chat\tbody: {{"msg": "..."}}\t给用户发消息
POST /subagent\tbody: {{"prompt": "..."}}\t启动新subagent,返回 {{"id": "xxx"}}
POST /subagent\tbody: {{"prompt": "..."}}\t启动新subagent,返回 {{"id": "xxx"}};指定模型加参数llm(数字/名称)
POST /approval\tbody: {{"prompt": "...", "source": "..."}}\t推一条待批任务到前端(后端不存),用户同意则直接派发为subagent
POST /subagent/{{id}}\tbody: {{"action": "keyinfo", "msg": "..."}}\t注入key_info(agent下轮可见)
POST /subagent/{{id}}\tbody: {{"action": "input", "msg": "..."}}\t开新一轮任务(agent停下后追加)
POST /subagent/{{id}}\tbody: {{"action": "input", "msg": "..."}}\t开新一轮任务;指定模型加参数llm(数字/名称)
POST /subagent/{{id}}\tbody: {{"action": "stop"}}\t中断执行但保留(可继续input/reply)
GET /chat?last=N\t返回最近N条对话(默认20)
GET /subagent\t返回 {{"items": [...]}}\t查看所有subagent状态
@@ -506,7 +523,7 @@ INSTR_DISPATCHED = "Task received. I'll handle THIS TASK from here. You MUST to
@app.post("/subagent")
def api_start_subagent(body: StartSubagentIn):
result = pool.start_subagent(body.prompt)
result = pool.start_subagent(body.prompt, body.llm)
result["instruction"] = INSTR_DISPATCHED
return result
@@ -520,7 +537,7 @@ def api_subagent_action(sid: str, body: SubagentActionIn):
result["instruction"] = "Received. I'll incorporate this. You MUST to do other task or end your reply."
return result
if action in ("input", "reply", "append", "message", "msg"):
result = pool.input_subagent(sid, body.msg)
result = pool.input_subagent(sid, body.msg, body.llm)
result["instruction"] = INSTR_DISPATCHED
return result
if action in ("abort", "stop"):
@@ -533,10 +550,11 @@ def api_subagent_action(sid: str, body: SubagentActionIn):
@app.get("/chat")
def api_get_chat(last: int = 20):
items = [m.copy() for m in chat_messages[-last:]]
for m in chat_messages:
if m.get("role") == "user" and not m.get("read"): m["read"] = True
schedule_broadcast({"type": "chat_read"})
return {"items": chat_messages[-last:]}
return {"items": items}
@app.post("/chat")
def api_chat(body: ChatIn):
+3 -1
View File
@@ -88,6 +88,7 @@ def get_controller():
threading.Thread(target=ag.run, daemon=True).start()
while True:
b['ev'].wait(); b['ev'].clear()
if ag.llm_no != agent.llm_no: ag.next_llm(agent.llm_no)
dq = ag.put_task(build_prompt(b['obj']), source="controller")
while 'done' not in (it := dq.get()): pass
ms = re.findall(r'<next_prompt>(.*?)</next_prompt>', it['done'], re.S)
@@ -278,7 +279,8 @@ def render_main_stream(prompt=None):
b = get_controller()
b['obj'] = st.session_state.get('loop_prompt_input', ''); b['ready'] = False; b['ev'].set()
if "messages" not in st.session_state: st.session_state.messages = []
if not hasattr(agent, "_ui_messages"): agent._ui_messages = st.session_state.get("messages", [])
if "messages" not in st.session_state: st.session_state.messages = agent._ui_messages
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
# 用 slot=st.empty() + with slot.container(): ... 的外壳,DOM 路径和流式渲染完全一致,跨 rerun 对齐
+10 -6
View File
@@ -1081,10 +1081,9 @@ def render_native_history(history, ts=None):
`_parse_native_history(_pairs(render_native_history(h))) == h`.
Byte-for-byte matches llmcore's `_write_llm_log` for the tool backend:
Prompt = `json.dumps(merged, ensure_ascii=False, indent=2)`; Response =
`str(content_blocks)` which for a list equals `repr(...)` (survives
True/False/None that JSON would emit as true/false/null and break
`ast.literal_eval`). Only complete (user, assistant) pairs are emitted."""
Prompt/Response each put one content block per line, with their wrappers
attached to the first/last lines. Response keeps Python repr so
True/False/None survive `ast.literal_eval`. Only complete pairs are emitted."""
ts = ts or datetime.now().strftime('%Y-%m-%d %H:%M:%S')
out, i, n = [], 0, len(history)
while i < n:
@@ -1092,8 +1091,13 @@ def render_native_history(history, ts=None):
if isinstance(u, dict) and u.get('role') == 'user' and i + 1 < n:
a = history[i + 1]
if isinstance(a, dict) and a.get('role') == 'assistant':
out.append(f"=== Prompt === {ts}\n{json.dumps(u, ensure_ascii=False, indent=2)}\n\n")
out.append(f"=== Response === {ts}\n{a.get('content')!r}\n\n")
prompt_blocks = u.get('content', [])
prompt_raw = '{"role": "user", "content": [\n' + ",\n".join(
json.dumps(b, ensure_ascii=False) for b in prompt_blocks) + "]}"
out.append(f"=== Prompt === {ts}\n{prompt_raw}\n\n")
blocks = a.get('content', [])
raw = "[" + ",\n".join(repr(b) for b in blocks) + "]"
out.append(f"=== Response === {ts}\n{raw}\n\n")
i += 2
continue
i += 1
+5 -3
View File
@@ -400,6 +400,7 @@ class GenericAgentHandler(BaseHandler):
with open(path, 'a' if mode == "append" else 'w', encoding="utf-8") as f: f.write(new_content)
yield f"[Status] ✅ {mode.capitalize()} 成功 ({len(new_content)} bytes)\n"
next_prompt = self._get_anchor_prompt(skip=args.get('_index', 0) > 0)
if len(new_content) > 5000: next_prompt = "\n[SYSTEM TIPS] WRITE TOO LONG! MUST RECHECK HALLUCINATIONS! SMALL WRITES OR PATCHES NEXT TIME!"
return StepOutcome({"status": "success", 'writed_bytes': len(new_content)}, next_prompt=next_prompt)
except Exception as e:
yield f"[Status] ❌ 写入异常: {str(e)}\n"
@@ -427,6 +428,7 @@ class GenericAgentHandler(BaseHandler):
def export_history(self, fn):
with open(fn, 'w', encoding='utf-8') as f: json.dump(self.parent.llmclient.backend.history, f, ensure_ascii=False)
def enter_project_mode(self, name): self.parent._ga_project_mode_name = name
def _in_plan_mode(self): return self.working.get('in_plan_mode')
def _exit_plan_mode(self): self.working.pop('in_plan_mode', None)
def enter_plan_mode(self, plan_path):
@@ -539,9 +541,9 @@ class GenericAgentHandler(BaseHandler):
def _get_anchor_prompt(self, skip=False):
if skip: return "\n"
h = self.history_info; W = 30
earlier = f'<earlier_context>\n{self._fold_earlier(h[:-W])}\n</earlier_context>\n' if len(h) > W else ""
h_str = "\n".join(h[-W:])
prompt = f"\n### [WORKING MEMORY]\n{earlier}<history>\n{h_str}\n</history>"
earlier = f'<earlier_context>\n{self._fold_earlier(h[:-W])}\n</earlier_context>\n' if len(h) > W and self.current_turn % 4 == 1 else ""
history = f'<history>\n{"\n".join(h[-W:])}\n</history>' if self.current_turn % 2 == 1 else ""
prompt = f"\n### [WORKING MEMORY]\n{earlier}{history}"
prompt += f"\nCurrent turn: {self.current_turn}\n"
if self.working.get('key_info'): prompt += f"\n<key_info>{self.working.get('key_info')}</key_info>"
if self.working.get('related_sop'): prompt += f"\n有不清晰的地方请再次读取{self.working.get('related_sop')}"
+13 -3
View File
@@ -117,6 +117,10 @@ def auto_make_url(base, path):
return f"{b}/{p}" if re.search(r'/v\d+(/|$)', b) else f"{b}/v1/{p}"
def _parse_claude_json(data):
if data.get("stop_reason") == "refusal":
err = "[Error: Claude refusal]"
yield err
return [{"type": "text", "text": err}]
content_blocks = data.get("content", [])
_record_usage(data.get("usage", {}), "messages")
for b in content_blocks:
@@ -182,6 +186,7 @@ def _parse_claude_sse(resp_lines):
if not warn:
if not got_message_stop and not stop_reason: warn = "\n\n[!!! 流异常中断,未收到完整响应 !!!]"
elif stop_reason == "max_tokens": warn = "\n\n[!!! Response truncated: max_tokens !!!]"
elif stop_reason == "refusal": warn = "\n\n[Error: Claude refusal]"
if current_block:
if current_block["type"] == "tool_use":
try: current_block["input"] = json.loads(tool_json_buf) if tool_json_buf else {}
@@ -552,6 +557,7 @@ class BaseSession:
self.service_tier = _enum('service_tier', {'auto', 'default', 'priority', 'flex'})
self.thinking_type = _enum('thinking_type', {'adaptive', 'enabled', 'disabled'})
self.thinking_budget_tokens = cfg.get('thinking_budget_tokens')
self.omit_thinking = cfg.get('omit_thinking', False) # Exclude thinking from session history
mode = str(cfg.get('api_mode', 'chat_completions')).strip().lower().replace('-', '_')
self.api_mode = 'responses' if mode in ('responses', 'response') else 'chat_completions'
self.temperature = cfg.get('temperature', 1)
@@ -730,7 +736,9 @@ class NativeClaudeSession(BaseSession):
except StopIteration as e: content_blocks = e.value or []
if content_blocks and (_injected := _ensure_text_block(content_blocks)): yield _injected
if content_blocks and not (len(content_blocks) == 1 and content_blocks[0].get("text", "").startswith("!!!Error:")):
self.history.append({"role": "assistant", "content": content_blocks})
history_blocks = content_blocks
if self.omit_thinking: history_blocks = [b for b in content_blocks if b.get("type") != "thinking"]
self.history.append({"role": "assistant", "content": history_blocks})
text_parts = [b["text"] for b in content_blocks if b.get("type") == "text"]
content = "\n".join(text_parts).strip()
tool_calls = [MockToolCall(b["name"], b.get("input", {}), id=b.get("id", "")) for b in content_blocks if b.get("type") == "tool_use"]
@@ -743,7 +751,8 @@ class NativeClaudeSession(BaseSession):
if think_match:
thinking = think_match.group(1).strip()
content = re.sub(think_pattern, "", content, flags=re.DOTALL)
return MockResponse(thinking, content, tool_calls, str(content_blocks))
raw = "[" + ",\n".join(repr(b) for b in content_blocks) + "]"
return MockResponse(thinking, content, tool_calls, raw)
class NativeOAISession(NativeClaudeSession):
native_ua = "codex_exec/0.139.0 (Windows 10.0.26200; x86_64) unknown (codex_exec; 0.139.0)"
@@ -1055,7 +1064,8 @@ class NativeToolClient:
final_content = tool_result_blocks + filtered_content
if not final_content: final_content = [{"type": "text", "text": "."}]
merged = {"role": "user", "content": final_content}
_write_llm_log('Prompt', json.dumps(merged, ensure_ascii=False, indent=2), self.log_path)
prompt_raw = '{"role": "user", "content": [\n' + ",\n".join(json.dumps(b, ensure_ascii=False) for b in final_content) + "]}"
_write_llm_log('Prompt', prompt_raw, self.log_path)
gen = self.backend.ask(merged)
try:
while True:
@@ -156,6 +156,7 @@ def batch_process(src, l4_dir=None, dry_run=True):
l4_dir = os.path.normpath(l4_dir or L4_DIR)
raw_files = sorted(src) if isinstance(src, (list, tuple)) else \
sorted(glob.glob(os.path.join(src, 'model_responses_*.txt')))
raw_files = sorted(raw_files, key=os.path.getmtime)[:-10] # always retain 10 newest raw files
if not raw_files:
print("No raw files found"); return {'processed': 0, 'skipped': 0, 'errors': 0, 'new_sessions': 0}
+5 -6
View File
@@ -7,14 +7,14 @@ Project Mode = 跨会话保持项目认知的工作模式
## 进入
锚 = `./.active_project.<宿主pid>`,只对当前 GA 进程有效:GA 关闭即自动失活;多开 GA 各自激活不同项目,互不干扰。(下文路径一律以 cwd 为基准,cwd 即 GA 的 temp 目录;禁写 `temp/xxx` 前缀,会嵌套出 temp/temp)
激活态保存在当前 Agent 实例;各会话互不干扰,关闭 GA 自动失效。(下文路径以 cwd 为基准,禁写 `temp/xxx` 前缀)
- 用户只说「进入项目模式」未指明项目:列出 `./projects/` 下各项目(名字 + memory 行数 + 最后修改时间),ask_user 让用户选定后再继续
- 用户明确说「进入/切换到 <项目名> 项目」:视为已确认,直接执行:
1. 建目录 `./projects/<项目名>/`,无则创建 `project_memory.md`(空文件即可)
2. 写文件锚,必须用 code_run(锚文件名含宿主 pid,ppid 即 GA 宿主进程):
`import os; open(f'./.active_project.{os.getppid()}', 'w').write('<项目名>')`
2. 用 `code_run` 的 `inline_eval=true` 绑定当前 Agent(`handler` 已注入,无需 import):
`handler.enter_project_mode('<项目名>')`
3. 回读 `project_memory.md` 全文,向用户复述项目现状
## 期间纪律
@@ -25,6 +25,5 @@ Project Mode = 跨会话保持项目认知的工作模式
## 离开
用户表示「离开项目模式」时:删除 `./.active_project.<宿主pid>`(仅关闭激活态,项目目录与 `project_memory.md` 原样保留)
切换到另一项目时无需先离开,直接按「进入」覆盖文件锚即可
GA 关闭不需任何操作:锚随进程消亡自动失效,残留文件由插件下次启动时清扫
明确要求离开时,用 `code_run`(`inline_eval=true`)执行 `handler.enter_project_mode(None)`。
切换项目直接换项目名
+5 -39
View File
@@ -6,11 +6,7 @@
由模型按 L1 中的指针与线索(行数/大小)自行判断是否用 file 工具读取。
利用 messages 是 list 引用的事实,直接 mutate 即反映到真正发给 LLM 的内容。
激活态载体 = 文件锚 temp/.active_project.<宿主pid>(存当前项目名)。PID 键控:
- 锚只对写它的那个 GA 进程有效 → 多开 GA 各自激活不同项目,互不可见
- GA 关闭即自动失活(重启后 pid 变,旧锚作废);重新激活需经用户确认(SOP 指示)
- 进入:agent 读 project_mode_sop,经用户确认后写锚(code_run 中 os.getppid() 即宿主 pid)
- 退出:删除该文件。插件加载时清扫旧版无后缀锚与自己 pid 的前世残留(不碰他进程的锚)
激活态保存在当前 GenericAgent 实例的 _ga_project_mode_name;各会话互不干扰。
目录约定:
temp/projects/<项目名>/project_memory.md 单文件全文注入的项目记忆
@@ -21,43 +17,13 @@ import plugins.hooks as hooks
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_TEMP = os.path.join(_PROJECT_ROOT, 'temp')
_ANCHOR = os.path.join(_TEMP, f'.active_project.{os.getpid()}')
def _cleanup_stale_anchors():
"""清扫无主锚:仅删两类能纯靠文件名判定的——旧版无后缀的、自己 pid 的(刚启动
不可能写过锚,必是 pid 复用的前世残留)。他进程的锚一律不碰:故意不做存活探测,
os.kill(pid, 0) 在 Windows 上语义是终止进程而非探活,任何平台分支都不值得为
删除无害残留文件引入杀进程风险(激活判定只认自己 pid 的锚,残留不影响行为)。"""
import glob
for path in glob.glob(os.path.join(_TEMP, '.active_project*')):
pid = path.rsplit('.', 1)[-1]
if path != _ANCHOR and pid.isdigit():
continue # 他进程的锚(含已死进程的),不碰
try: os.remove(path)
except OSError: pass
_cleanup_stale_anchors()
def _active_project(ctx=None):
"""返回当前激活的项目名;未激活返回 None。
兼容策略:
- 新 TUI 多会话可在当前 GenericAgent 实例上设置 _ga_project_mode_name。
只要该属性存在,就以它为准;值为 None/空串表示该 agent 普通模式。
- 其它 UI / 旧 SOP 不设置该属性,继续读取 pid 键控文件锚。
异常不在此捕获——hooks.trigger 统一捕获并打印,保持可观测。"""
parent = None
if isinstance(ctx, dict):
handler = ctx.get('handler')
parent = getattr(handler, 'parent', None)
if parent is not None and hasattr(parent, '_ga_project_mode_name'):
return getattr(parent, '_ga_project_mode_name', None) or None
if not os.path.isfile(_ANCHOR):
return None
return open(_ANCHOR, encoding='utf-8').read().strip() or None
"""返回当前 Agent 实例激活的项目名;未激活返回 None。"""
handler = ctx.get('handler') if isinstance(ctx, dict) else None
parent = getattr(handler, 'parent', None)
return getattr(parent, '_ga_project_mode_name', None) or None
def _project_dir(name):