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
This commit is contained in:
Liang Jiaqing
2026-04-13 18:27:17 +08:00
parent 9da32c07ce
commit 0da9bd15c9
7 changed files with 185 additions and 24 deletions

3
.gitignore vendored
View File

@@ -73,6 +73,9 @@ memory/L4_raw_sessions/*
# ADB UI tool # ADB UI tool
!memory/adb_ui.py !memory/adb_ui.py
# Keychain
!memory/keychain.py
# Visual Studio # Visual Studio
.vs/ .vs/
restore_commit.txt restore_commit.txt

View File

@@ -14,7 +14,7 @@ def try_call_generator(func, *args, **kwargs):
class BaseHandler: class BaseHandler:
def tool_before_callback(self, tool_name, args, response): pass def tool_before_callback(self, tool_name, args, response): pass
def tool_after_callback(self, tool_name, args, response, ret): 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): def dispatch(self, tool_name, args, response, index=0):
method_name = f"do_{tool_name}" method_name = f"do_{tool_name}"
if hasattr(self, method_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} else: tool_calls = [{'tool_name': tc.function.name, 'args': json.loads(tc.function.arguments), 'id': tc.id}
for tc in response.tool_calls] 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): for ii, tc in enumerate(tool_calls):
tool_name, args, tid = tc['tool_name'], tc['args'], tc.get('id', '') tool_name, args, tid = tc['tool_name'], tc['args'], tc.get('id', '')
if tool_name == 'no_tool': pass 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' if verbose: yield '`````\n'
except StopIteration as e: outcome = e.value 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: 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.next_prompt.startswith('未知工具'): client.last_tools = ''
if outcome.data is not None and tool_name != 'no_tool': 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) 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}) tool_results.append({'tool_use_id': tid, 'content': datastr})
next_prompts.add(outcome.next_prompt) next_prompts.add(outcome.next_prompt)
if len(next_prompts) == 0: if len(next_prompts) == 0 or exit_reason:
if len(handler._done_hooks) == 0: return should_exit if len(handler._done_hooks) == 0: break
next_prompts.add(handler._done_hooks.pop(0)) 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 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): def _clean_content(text):
if not text: return '' if not text: return ''

93
frontends/desktop_pet.pyw Normal file
View File

@@ -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('<Button-1>', lambda e: setattr(self, '_d', (e.x, e.y)))
self.label.bind('<B1-Motion>', self._drag)
self.label.bind('<Double-1>', 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()

BIN
frontends/pet.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,4 +1,4 @@
import os, sys import os, sys, subprocess
if sys.stdout is None: sys.stdout = open(os.devnull, "w") if sys.stdout is None: sys.stdout = open(os.devnull, "w")
if sys.stderr is None: sys.stderr = open(os.devnull, "w") if sys.stderr is None: sys.stderr = open(os.devnull, "w")
try: sys.stdout.reconfigure(errors='replace') try: sys.stdout.reconfigure(errors='replace')
@@ -47,6 +47,24 @@ def render_sidebar():
if st.button("重新注入System Prompt"): if st.button("重新注入System Prompt"):
agent.llmclient.last_tools = '' agent.llmclient.last_tools = ''
st.toast("下次将重新注入System Prompt") 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() st.divider()
if st.button("开始空闲自主行动"): if st.button("开始空闲自主行动"):

29
ga.py
View File

@@ -266,20 +266,7 @@ class GenericAgentHandler(BaseHandler):
def _get_abs_path(self, path): def _get_abs_path(self, path):
if not path: return "" if not path: return ""
return os.path.abspath(os.path.join(self.cwd, path)) 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'```.*?```|<thinking>.*?</thinking>', '', response.content, flags=re.DOTALL)
rsumm = re.search(r"<summary>(.*?)</summary>", _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: 上一轮遗漏了<summary>。 已根据物理动作自动补全。请务必在下次回复中记得<summary>协议。"
self.history_info.append('[Agent] ' + smart_format(summary, max_str_len=100))
def _extract_code_block(self, response, code_type): def _extract_code_block(self, response, code_type):
matches = re.findall(rf"```{code_type}\n(.*?)\n```", response.content, re.DOTALL) matches = re.findall(rf"```{code_type}\n(.*?)\n```", response.content, re.DOTALL)
@@ -505,7 +492,18 @@ class GenericAgentHandler(BaseHandler):
except: pass except: pass
return prompt 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'```.*?```|<thinking>.*?</thinking>', '', response.content, flags=re.DOTALL)
rsumm = re.search(r"<summary>(.*?)</summary>", _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>,已根据物理动作自动补全。在下次回复中记得<summary>协议。"
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')): if turn % 35 == 0 and 'plan' not in str(self.working.get('related_sop')):
next_prompt += f"\n\n[DANGER] 已连续执行第 {turn} 轮。你必须总结情况进行ask_user不允许继续重试。" next_prompt += f"\n\n[DANGER] 已连续执行第 {turn} 轮。你必须总结情况进行ask_user不允许继续重试。"
elif turn % 7 == 0: elif turn % 7 == 0:
@@ -515,6 +513,7 @@ class GenericAgentHandler(BaseHandler):
injprompt = consume_file(self.parent.task_dir, '_intervene') injprompt = consume_file(self.parent.task_dir, '_intervene')
if injkeyinfo: self.working['key_info'] = self.working.get('key_info', '') + f"\n[MASTER] {injkeyinfo}" 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" 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 return next_prompt
def get_global_memory(): def get_global_memory():

46
memory/keychain.py Normal file
View File

@@ -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)