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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
93
frontends/desktop_pet.pyw
Normal 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
BIN
frontends/pet.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -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
29
ga.py
@@ -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
46
memory/keychain.py
Normal 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)
|
||||||
Reference in New Issue
Block a user