From 0da9bd15c9848ec87942def39b10cf19c75e79d6 Mon Sep 17 00:00:00 2001 From: Liang Jiaqing Date: Mon, 13 Apr 2026 18:27:17 +0800 Subject: [PATCH] refactor: turn_end_callback + desktop pet + keychain - agent_loop: next_prompt_patcher -> turn_end_callback with full context - agent_loop: exit logic unified (break + callback), no early return - ga: summary extraction moved from tool_after_callback to turn_end_callback - ga: _turn_end_hooks support for external subscribers - stapp: desktop pet button with HTTP status push - keychain: XOR-masked secret storage with SecretStr - gitignore: whitelist keychain.py --- .gitignore | 3 ++ agent_loop.py | 18 ++++---- frontends/desktop_pet.pyw | 93 ++++++++++++++++++++++++++++++++++++++ frontends/pet.gif | Bin 0 -> 2511 bytes frontends/stapp.py | 20 +++++++- ga.py | 29 ++++++------ memory/keychain.py | 46 +++++++++++++++++++ 7 files changed, 185 insertions(+), 24 deletions(-) create mode 100644 frontends/desktop_pet.pyw create mode 100644 frontends/pet.gif create mode 100644 memory/keychain.py diff --git a/.gitignore b/.gitignore index f40285f..5668042 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,9 @@ memory/L4_raw_sessions/* # ADB UI tool !memory/adb_ui.py +# Keychain +!memory/keychain.py + # Visual Studio .vs/ restore_commit.txt diff --git a/agent_loop.py b/agent_loop.py index 6cb9d09..227b2bf 100644 --- a/agent_loop.py +++ b/agent_loop.py @@ -14,7 +14,7 @@ def try_call_generator(func, *args, **kwargs): class BaseHandler: def tool_before_callback(self, tool_name, args, response): pass def tool_after_callback(self, tool_name, args, response, ret): pass - def next_prompt_patcher(self, next_prompt, outcome, turn): return next_prompt + def turn_end_callback(self, response, tool_calls, tool_results, turn, next_prompt, exit_reason): return next_prompt def dispatch(self, tool_name, args, response, index=0): method_name = f"do_{tool_name}" if hasattr(self, method_name): @@ -65,7 +65,7 @@ def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, else: tool_calls = [{'tool_name': tc.function.name, 'args': json.loads(tc.function.arguments), 'id': tc.id} for tc in response.tool_calls] - tool_results = []; next_prompts = set(); should_exit = None + tool_results = []; next_prompts = set(); exit_reason = None for ii, tc in enumerate(tool_calls): tool_name, args, tid = tc['tool_name'], tc['args'], tc.get('id', '') if tool_name == 'no_tool': pass @@ -82,20 +82,22 @@ def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, if verbose: yield '`````\n' except StopIteration as e: outcome = e.value - if outcome.should_exit: return {'result': 'EXITED', 'data': outcome.data} # should_exit is only used for immediate exit + if outcome.should_exit: + exit_reason = {'result': 'EXITED', 'data': outcome.data}; break if not outcome.next_prompt: - should_exit = {'result': 'CURRENT_TASK_DONE', 'data': outcome.data}; break + exit_reason = {'result': 'CURRENT_TASK_DONE', 'data': outcome.data}; break if outcome.next_prompt.startswith('未知工具'): client.last_tools = '' if outcome.data is not None and tool_name != 'no_tool': datastr = json.dumps(outcome.data, ensure_ascii=False, default=json_default) if type(outcome.data) in [dict, list] else str(outcome.data) tool_results.append({'tool_use_id': tid, 'content': datastr}) next_prompts.add(outcome.next_prompt) - if len(next_prompts) == 0: - if len(handler._done_hooks) == 0: return should_exit + if len(next_prompts) == 0 or exit_reason: + if len(handler._done_hooks) == 0: break next_prompts.add(handler._done_hooks.pop(0)) - next_prompt = handler.next_prompt_patcher("\n".join(next_prompts), None, turn) + next_prompt = handler.turn_end_callback(response, tool_calls, tool_results, turn, '\n'.join(next_prompts), exit_reason) messages = [{"role": "user", "content": next_prompt, "tool_results": tool_results}] # just new message, history is kept in *Session - return {'result': 'MAX_TURNS_EXCEEDED'} + if exit_reason: handler.turn_end_callback(response, tool_calls, tool_results, turn, '', exit_reason) + return exit_reason or {'result': 'MAX_TURNS_EXCEEDED'} def _clean_content(text): if not text: return '' diff --git a/frontends/desktop_pet.pyw b/frontends/desktop_pet.pyw new file mode 100644 index 0000000..0dd22ec --- /dev/null +++ b/frontends/desktop_pet.pyw @@ -0,0 +1,93 @@ +"""Desktop Pet with HTTP Toast — ~90 lines""" +import tkinter as tk, threading, random, os, sys +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs + +PORT = 51983 +GIF = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), 'pet.gif') + +class Pet: + def __init__(self): + self.root = tk.Tk() + self.root.overrideredirect(True) + self.root.wm_attributes('-topmost', True) + self.root.wm_attributes('-transparentcolor', '#01FF01') + self.root.config(bg='#01FF01') + self.root.after(50, lambda: self.root.geometry('+300+500')) + # load GIF frames + self.frames, i = [], 0 + while True: + try: self.frames.append(tk.PhotoImage(file=GIF, format=f'gif -index {i}')); i += 1 + except: break + if not self.frames: raise FileNotFoundError(f'No GIF: {GIF}') + self.idx = 0 + self.label = tk.Label(self.root, image=self.frames[0], bg='#01FF01', bd=0) + self.label.pack() + # drag + self.label.bind('', lambda e: setattr(self, '_d', (e.x, e.y))) + self.label.bind('', self._drag) + self.label.bind('', lambda e: (self.root.destroy(), os._exit(0))) + # start loops + self._animate() + self._wander() + self._start_server() + self.root.mainloop() + + def _drag(self, e): + x, y = self.root.winfo_x() + e.x - self._d[0], self.root.winfo_y() + e.y - self._d[1] + self.root.geometry(f'+{x}+{y}') + + def _animate(self): + self.idx = (self.idx + 1) % len(self.frames) + self.label.config(image=self.frames[self.idx]) + self.root.after(150, self._animate) + + def _wander(self): + if random.random() < 0.25: + x = self.root.winfo_x() + random.randint(-15, 15) + y = self.root.winfo_y() + random.randint(-5, 5) + self.root.geometry(f'+{x}+{y}') + self.root.after(4000, self._wander) + + def show_toast(self, msg): + """Show a speech bubble near the pet that auto-dismisses.""" + tw = tk.Toplevel(self.root) + tw.overrideredirect(True) + tw.wm_attributes('-topmost', True) + tw.config(bg='#FFFDE7') + px, py = self.root.winfo_x(), self.root.winfo_y() + tw.geometry(f'+{px + 30}+{py - 50}') + # bubble content + f = tk.Frame(tw, bg='#FFFDE7', highlightbackground='#888', highlightthickness=1, padx=8, pady=4) + f.pack() + tk.Label(f, text=msg, bg='#FFFDE7', fg='#333', font=('Segoe UI', 10), wraplength=220, justify='left').pack() + # auto dismiss + tw.after(3000, tw.destroy) + + def _start_server(self): + pet = self + class H(BaseHTTPRequestHandler): + def do_GET(self): + qs = parse_qs(urlparse(self.path).query) + msg = qs.get('msg', [''])[0] + if msg: + pet.root.after(0, pet.show_toast, msg) + self.send_response(200); self.end_headers(); self.wfile.write(b'ok') + else: + self.send_response(400); self.end_headers(); self.wfile.write(b'?msg=xxx') + def do_POST(self): + body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode() + if body: + pet.root.after(0, pet.show_toast, body) + self.send_response(200); self.end_headers(); self.wfile.write(b'ok') + else: + self.send_response(400); self.end_headers(); self.wfile.write(b'empty body') + def log_message(self, *a): pass + HTTPServer.allow_reuse_address = False + srv = HTTPServer(('127.0.0.1', PORT), H) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + print(f'Toast server: http://127.0.0.1:{PORT}/?msg=hello') + +if __name__ == '__main__': + Pet() diff --git a/frontends/pet.gif b/frontends/pet.gif new file mode 100644 index 0000000000000000000000000000000000000000..a297e0fbda77359ddb35b5bd6f03fdffbc751f51 GIT binary patch literal 2511 zcmds&Yc$mB9>@QK#<HxE1>t z$t^U}xE12KMHt#_+O=I$?i}`7XSLUPcY1lAXRYUX@q5>j-_O#@Lf_yb4!{BN zz~OT1ax=;@s@*@>wP-XN4u|jW?;DYg{wn_Wp|~$#Z|+PqGj%Z6RM+4J9n=*!dH6&Q zZp`7^!*2n|J^+Hq5~>T^;vr%u+`Foa+LNG4)(;8HV)kt*Ex-3&%txJR3TDZ&RyB<7 zbm)glva;yEl3+_Q9i8uG&^c{y|#X z9`*HVli-uj?+GiOWfKrK(SkXC7Co6TY+s7Ys@a=|L6M;lL+##HqCokn$4!9u5J5mT z`?Fh_|G1syixlGtY8>~oOH<#3j5I$L*2HWJ%V{+R&OcDP5!z~m3gLchF3%fzG&1bN ztsv36Qq#@r*OumQ^Gcn;MQvn%`oP;zHMR9=cI90GEw5l_XSfSA<{1TAu#kz~J)-O7 z6yM8`CyQt{r8$bjI0*Qep!FeWxS{2m&g= z$-m(LPw4+cRp9`xn6mZ(RkH*T(mLbj+ahxs6y?W2b7t8o2s4Yyg#ivV9Z5{ZSS>Sb zGF7lO!B%3Wgd9oA$69GoA2X2QmDTqPPYbDlsi*MqEiuX<$RBLH`Q?WBYvR0HoyO!#j}u{6Cu%~IZoEOPt4-&}?w1M~F{v{dG=Z`A zh#FR0XR@9l?ftnqGB;QlpA~NK#8@WhyG7nx!s1vcBGhP<<%DTb>=kpki+B@;@9A0 zZlB-3R!seQE*s+1F={N{y3-&v(|`Y_*M4uQ?pGAY1K2z3920#syC4cP!_K-P@l28! z?`nlcBoeI%qBJR3onn%R3Im$>cPL;$7SI8ZKMuvq^Z?2UTi-*~z~bO+0uyPT2-PyQ z^*vDSHbm$0ZyQXq6%EP9FU1;U*rY2Gev8b@hYjf4F2n&_`36epjD z(j~^u2VRRSjfj!Z$8B8eY)hQm!~3&Xpmw-4mgX+ba!-#(=#|PUJiXkf(LJ@+{0>Rs zE<7XLytAV1GpbkRwnr%ZhVM?Z6TA8liwoeAztms1@jTq;WlIQ3eGlQ&&T7r!UV2j> z60-O&6f(vhhCRD99^#_oADa~&o>RHPKT5c#-jmT^#EWyd7t3B>whXLYx_hc)2e@z? z;DTWAoKTRmJ~U471i=CKT?ak@4*YZ=#9=S|Assll!{p&!hspE84wENQ6QEi$2g!R| zrpPGrFB9$^Yrg~LEyr{ZB2Q;a`U){gqOz1= zrR%W?|LBxVM<$ff!76SJlW0ET#JWCD@I7U3=hf`B}K_QE6 zs#E^AW3GfH8!s9XJh2y|Oh>{^EbM;TQrt?0Sa*p0(K;8Mwr)p@Hi4P$0eb^wuz&1s zN)}SLVGY?%XqB4^wlIV8NgHUg_IQGz%fi%Y0g4p#I}8zk2T%eGei+7`LTWtZ2>f8; z%(MYeC9wWjHK{F0RLkDkcc7>v4QddotzUSIKu22gqq|qh9U0>1YWx>9k+w9gpb^1$ zj+gDzu_(8-O|5c=G!qKAMa2O}W#H~>2J@e+aLg5`aLJ1he_~3vel8bqBGkva>XK

d+GJYbQd8cOQRV4Y#2E=GOm_A7izC8l_2rW*v+ZI2>*t_gI0{`qOg zY)X=VNxAHX*XVsAJrt`sVti35ae7#NZE3QZYBS-sdy_-nOy`*9qvXSkHorn)tWNCP zDE#I|Z>*4s1a-GoV|R4ej_U&3Id02tSd#c>Cfh5XWDF^C6tzcZS_7%ho(VF4hFwhh zx@!r$>AIqWyd``*m}L3)F7^L1IzD%BQHfmYUP0HoxOhdy_Hs8^v)Qr7k*B;^RBw8^ zswb^d^q0AwG^NNr*$2Mlfeb5edm*=ync)JV47te%U+`QFj%8CA1Li!tfsZjPbKpvM zGYW}vH`2Gf)f*%)f(g9-YT@NMC-$kVC~DJa$>W9jR@If_7Y57GcpjCR&R#!a zEMfa3eWX(!0PW`!h32NIkr!((M1bWsCe3&y#1%Y+mG#dh*tMA}#`}8s4qS<0uBAsQ UQ5I%Q%kF9yTk@CalW+j`-+e0>cK`qY literal 0 HcmV?d00001 diff --git a/frontends/stapp.py b/frontends/stapp.py index ed271cd..0774849 100644 --- a/frontends/stapp.py +++ b/frontends/stapp.py @@ -1,4 +1,4 @@ -import os, sys +import os, sys, subprocess if sys.stdout is None: sys.stdout = open(os.devnull, "w") if sys.stderr is None: sys.stderr = open(os.devnull, "w") try: sys.stdout.reconfigure(errors='replace') @@ -47,6 +47,24 @@ def render_sidebar(): if st.button("重新注入System Prompt"): agent.llmclient.last_tools = '' st.toast("下次将重新注入System Prompt") + if st.button("🐱 桌面宠物"): + kwargs = {'creationflags': 0x08} if sys.platform == 'win32' else {} + subprocess.Popen([sys.executable, os.path.join(os.path.dirname(__file__), 'desktop_pet.pyw')], **kwargs) + if not hasattr(agent, '_turn_end_hooks'): agent._turn_end_hooks = {} + def _pet_hook(ctx): + parts = [f"🔄 Turn {ctx.get('turn','?')}"] + if ctx.get('summary'): parts.append(ctx['summary']) + if ctx.get('exit_reason'): parts.append('✅ 任务已完成') + msg = '\n'.join(parts) + def send(): + try: + from urllib.request import urlopen + from urllib.parse import quote + urlopen(f'http://127.0.0.1:51983/?msg={quote(msg)}', timeout=2) + except: pass + threading.Thread(target=send, daemon=True).start() + agent._turn_end_hooks['pet'] = _pet_hook + st.toast("桌面宠物已启动") st.divider() if st.button("开始空闲自主行动"): diff --git a/ga.py b/ga.py index 3917dfb..73300dc 100644 --- a/ga.py +++ b/ga.py @@ -266,20 +266,7 @@ class GenericAgentHandler(BaseHandler): def _get_abs_path(self, path): if not path: return "" - return os.path.abspath(os.path.join(self.cwd, path)) - - def tool_after_callback(self, tool_name, args, response, ret): - if args.get('_index', 0) > 0: return - _c = re.sub(r'```.*?```|.*?', '', response.content, flags=re.DOTALL) - rsumm = re.search(r"

(.*?)", _c, re.DOTALL) - if rsumm: summary = rsumm.group(1).strip()[:200] - else: - clean_args = {k: v for k, v in args.items() if not k.startswith('_')} - summary = f"调用工具{tool_name}, args: {clean_args}" - if tool_name == 'no_tool': summary = "直接回答了用户问题" - if type(ret.next_prompt) is str: - ret.next_prompt += "\nPROTOCOL_VIOLATION: 上一轮遗漏了。 已根据物理动作自动补全。请务必在下次回复中记得协议。" - self.history_info.append('[Agent] ' + smart_format(summary, max_str_len=100)) + return os.path.abspath(os.path.join(self.cwd, path)) def _extract_code_block(self, response, code_type): matches = re.findall(rf"```{code_type}\n(.*?)\n```", response.content, re.DOTALL) @@ -505,7 +492,18 @@ class GenericAgentHandler(BaseHandler): except: pass return prompt - def next_prompt_patcher(self, next_prompt, outcome, turn): + def turn_end_callback(self, response, tool_calls, tool_results, turn, next_prompt, exit_reason): + _c = re.sub(r'```.*?```|.*?', '', response.content, flags=re.DOTALL) + rsumm = re.search(r"(.*?)", _c, re.DOTALL) + if rsumm: summary = rsumm.group(1).strip() + else: + tc = tool_calls[0]; tool_name, args = tc['tool_name'], tc['args'] # at least one because no_tool + clean_args = {k: v for k, v in args.items() if not k.startswith('_')} + summary = f"调用工具{tool_name}, args: {clean_args}" + if tool_name == 'no_tool': summary = "直接回答了用户问题" + next_prompt += "\n[DANGER] 上一轮遗漏了,已根据物理动作自动补全。在下次回复中记得协议。" + summary = smart_format(summary, max_str_len=100) + self.history_info.append(f'[Agent] {summary}') if turn % 35 == 0 and 'plan' not in str(self.working.get('related_sop')): next_prompt += f"\n\n[DANGER] 已连续执行第 {turn} 轮。你必须总结情况进行ask_user,不允许继续重试。" elif turn % 7 == 0: @@ -515,6 +513,7 @@ class GenericAgentHandler(BaseHandler): injprompt = consume_file(self.parent.task_dir, '_intervene') if injkeyinfo: self.working['key_info'] = self.working.get('key_info', '') + f"\n[MASTER] {injkeyinfo}" if injprompt: next_prompt += f"\n\n[MASTER] {injprompt}\n" + for hook in getattr(self.parent, '_turn_end_hooks', {}).values(): hook(locals()) # current readonly return next_prompt def get_global_memory(): diff --git a/memory/keychain.py b/memory/keychain.py new file mode 100644 index 0000000..cb580dc --- /dev/null +++ b/memory/keychain.py @@ -0,0 +1,46 @@ +"""Keychain: save key to a file, then keys.set("name", file="path"); keys.name.use() to retrieve (use but no print).""" +import json, os, hashlib, pathlib + +_PATH = pathlib.Path.home() / "ga_keychain.enc" +_MASK = hashlib.sha256(f"{os.getlogin()}@ga_keychain".encode()).digest() + +def _xor(data: bytes) -> bytes: + return bytes(b ^ _MASK[i % len(_MASK)] for i, b in enumerate(data)) + +class SecretStr: + def __init__(self, name: str, val: str): + self._name, self._val = name, val + def use(self) -> str: + return self._val + def __repr__(self): + n = len(self._val) + if n <= 4: preview = '***' + elif n <= 16: preview = f"{self._val[:3]}···{self._val[-3:]}" + elif n <= 40: preview = f"{self._val[:6]}···{self._val[-6:]} len={n}" + else: preview = f"{self._val[:10]}···{self._val[-6:]} len={n}" + return f"SecretStr({self._name}={preview}) # .use() to get raw, do not print raw value" + __str__ = __repr__ + +class _Keys: + def __init__(self): + self._d = {} + if _PATH.exists(): + try: + self._d = json.loads(_xor(_PATH.read_bytes())) + except Exception as e: + print(f"[keychain] WARNING: failed to load {_PATH}: {e}") + print(f"[keychain] Starting with empty keychain. Old file kept as .bak") + _PATH.rename(_PATH.with_suffix('.enc.bak')) + def __getattr__(self, k): + if k.startswith('_'): raise AttributeError(k) + if k not in self._d: raise KeyError(f"No secret: {k}") + return SecretStr(k, self._d[k]) + def set(self, k, v=None, *, file=None): + if file: v = pathlib.Path(file).read_text().strip() + self._d[k] = v + _PATH.write_bytes(_xor(json.dumps(self._d).encode())) + def ls(self): return list(self._d.keys()) + +keys = _Keys() + +def __getattr__(name): return getattr(keys, name)