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 0000000..a297e0f Binary files /dev/null and b/frontends/pet.gif differ 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)