Files
2026-07-13 13:04:19 +08:00

598 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Slash-command prompt builders + scheduler-task discovery.
Goal of this module: keep TUI files (tuiapp_v2.py / tui_v3.py) thin. They only
need to forward `/update`, `/autorun`, `/morphling`, `/goal`, `/hive`
to the corresponding `build_*_prompt(args)` here, and ask
`list_scheduler_tasks()` / `start_scheduler_task()` for the `/scheduler` picker.
Design (per user 2026-05-27):
- All non-/scheduler commands are *prompt injection*: we craft a system-style
request and feed it to the main agent as a normal user message (the TUI is
free to display the raw `/cmd ...` as the visible bubble). This keeps the
agent in-session, lets it use every tool/SOP it normally would, and means
this file owns zero LLM logic.
- `/scheduler` is the only exception — it touches local FS state directly via
`sche_tasks/*.json` and the existing scheduler daemon, no LLM needed.
- All prompts deliberately *name* the relevant SOP file so the agent re-reads
it before acting (per CONSTITUTION rule 2: SOP-first).
This module has zero TUI imports — both frontends can depend on it without
either depending on the other.
"""
from __future__ import annotations
import json
import os
import shutil
import sys
import subprocess
import time
from pathlib import Path
from typing import Optional
_USER_SHELL: tuple[list[str], str] | None = None
COMMIT_SIGNATURE_PROMPT = 'When you create a git commit, append "Co-Authored-By: GenericAgent <bot@gaagent.ai>" as the final line of the commit message.'
def detect_user_shell() -> tuple[list[str], str]:
"""Return `([executable, ...flags_for_-c], display_name)` for the user's
interactive shell. Cached after first call.
`!cmd` in tui_v2 / tui_v3 invokes this so commands like `ls`, pipes,
globs, and shell builtins behave the way the user expects in whatever
shell launched the app, instead of hardcoding cmd.exe / /bin/sh.
Resolution order:
1. `$SHELL` if it points to an existing file (Unix, Git Bash, WSL)
2. Windows only: Git Bash at the canonical install paths
3. `bash` anywhere on PATH (WSL bash, Cygwin, MSYS2, etc.)
4. Windows only: `pwsh` then `powershell.exe` on PATH
5. Unix `/bin/sh` / Windows `%COMSPEC%` (cmd.exe) — last resort
"""
global _USER_SHELL
if _USER_SHELL is not None:
return _USER_SHELL
s = os.environ.get("SHELL")
if s and os.path.exists(s):
name = os.path.basename(s)
if name.lower().endswith(".exe"):
name = name[:-4]
_USER_SHELL = ([s, "-c"], name)
return _USER_SHELL
if sys.platform == "win32":
for p in (
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files (x86)\Git\bin\bash.exe",
):
if os.path.exists(p):
_USER_SHELL = ([p, "-c"], "bash")
return _USER_SHELL
bash = shutil.which("bash")
if bash:
_USER_SHELL = ([bash, "-c"], "bash")
return _USER_SHELL
for name in ("pwsh", "powershell"):
p = shutil.which(name)
if p:
# -NoProfile keeps each `!cmd` snappy + reproducible.
_USER_SHELL = ([p, "-NoProfile", "-Command"], name)
return _USER_SHELL
cmd = os.environ.get("COMSPEC", "cmd.exe")
_USER_SHELL = ([cmd, "/d", "/s", "/c"], "cmd")
return _USER_SHELL
_USER_SHELL = (["/bin/sh", "-c"], "sh")
return _USER_SHELL
# Repo root = parent of frontends/. Avoid hard-coding; both TUIs live next to
# this file and share the same anchor.
_ROOT = Path(__file__).resolve().parent.parent
# Language resolution is owned here (not passed in as a formal arg) so every
# prompt builder stays single-parameter and TUI call sites don't need to know
# which prompt happens to be bilingual. Source of truth, in order:
# 1. `GA_LANG` env var (scriptable override; matches tui_v3 convention)
# 2. tui_v3's persisted settings file (same path as tui_v3.py:_SETTINGS_PATH)
# 3. system locale (zh* → 'zh', else 'en')
# When the user switches language inside tui_v3 (set_lang persists), the next
# call here picks it up automatically -- no formal coupling, just a shared file.
_SETTINGS_PATH = _ROOT / "temp" / "tui_v3_settings.json"
def _current_lang() -> str:
env = (os.environ.get("GA_LANG") or "").strip().lower()
if env in ("zh", "en"):
return env
try:
with open(_SETTINGS_PATH, "r", encoding="utf-8") as f:
saved = (json.load(f) or {}).get("lang")
if saved in ("zh", "en"):
return saved
except Exception:
pass
for var in ("LC_ALL", "LC_MESSAGES", "LANG"):
v = os.environ.get(var, "")
if v:
return "zh" if v.lower().startswith("zh") else "en"
return "en"
# ----- prompt builders (pure functions, no I/O) ---------------------------
# SOP paths are written inline as literal strings in each builder below: a
# literal is self-documenting and locally readable, and a stale path is a
# zero-radius failure (the prompt is a hint to an intelligent agent, which
# re-reads the dir / asks if a SOP moved) — so we deliberately do NOT wrap it
# in a registry + existence-check machinery.
def _tail(args_text: str, label: str = "额外指示") -> str:
"""Append user-supplied args after a slash command as a free-form suffix.
User pattern (2026-05-27): the base prompt is a fixed injection that names
the SOP path; anything the user types after `/cmd ` is appended verbatim so
they can add per-invocation hints (e.g. `/morphling https://github.com/...`
or `/goal 调研 X,预算 50k token`).
"""
extra = (args_text or "").strip()
return f"\n\n{label}: {extra}" if extra else ""
def build_update_prompt(args_text: str = "") -> str:
"""Prompt-only /update orchestration; actual git work stays in-agent.
The TUI owns zero git/LLM logic. This prompt asks the normal agent loop to
do a user-friendly preflight (upstream commits + diff) before pulling.
Language follows `_current_lang()` so a /language switch in tui_v3 (or a
`GA_LANG=...` shell override) automatically flips this prompt too.
"""
if _current_lang() == "en":
return (
"Update this GenericAgent checkout from the official upstream "
"https://github.com/Lsdefine/GenericAgent .\n"
"1. `git fetch upstream`; note the current branch and any local commits ahead.\n"
"2. Preview: upstream commits not yet local (short hash + subject + date) "
"plus a changed-files summary.\n"
"3. Align history, upstream first: with local commits ahead, `git merge upstream/main` "
"(conflicts favor upstream); otherwise `git reset --mixed upstream/main`. "
"Never create a commit.\n"
"4. Reconcile the WORKING TREE — step 3 moves only history and the index, so files "
"holding uncommitted local edits keep shadowing upstream. For each file in "
"`git diff --name-only upstream/main`, decide upstream-first:\n"
" - Stale leftovers take upstream: back the file up, then "
"`git checkout upstream/main -- <file>`.\n"
" - Genuine local enhancements upstream lacks (local config, key templates, "
"fork-only features) stay, re-applied on top of upstream's version.\n"
" - Files with a `Fork-only local overlay` marker comment (e.g. `.gitignore`) are "
"always a MERGE: take upstream as the base, then re-append everything under the "
"marker verbatim — never drop that block.\n"
" Never `git add -A`, never blanket-checkout the branch, never blindly keep everything.\n"
"5. Verify every overlay marker still exists (grep for it); re-append any that got lost.\n"
"6. Summarize: branch HEAD, distance vs upstream, per-file outcome, backup location.\n"
"\n"
"After a successful update, say: \"Congratulations! 🎉 You have successfully updated "
"GenericAgent!\" Then you may ask: \"If you found this helpful, would you like to star "
"the GenericAgent repository? It helps the project grow! ⭐\""
f"{_tail(args_text, 'Extra instructions')}"
)
return (
"请更新当前 GenericAgent 仓库,官方上游为 https://github.com/Lsdefine/GenericAgent 。\n"
"1. `git fetch upstream`;确认当前分支及是否有领先上游的本地 commit。\n"
"2. 预览:本地尚未包含的上游提交(短 hash + 标题 + 日期)及变更文件摘要。\n"
"3. 对齐提交历史,上游优先:有本地 commit 则 `git merge upstream/main`(冲突取上游);"
"否则 `git reset --mixed upstream/main`。全程不创建任何 commit。\n"
"4. 调和【工作区】——第 3 步只动历史与索引,带未提交改动的文件仍遮蔽上游最新版。对 "
"`git diff --name-only upstream/main` 列出的每个文件按上游优先裁决:\n"
" - 过时残留取上游:先备份,再 `git checkout upstream/main -- <file>`。\n"
" - 上游没有的真本地增强(本机配置、密钥模板、fork 专属功能)保留,并在上游最新版上重新适配。\n"
" - 带 `Fork-only local overlay` 标记注释的文件(如 `.gitignore`)一律【合并】:以上游为底,"
"把标记之下的内容逐字追加回去——绝不丢弃该块。\n"
" 禁止 `git add -A`、禁止整分支 checkout 覆盖、禁止盲目全保留。\n"
"5. 校验每个 overlay 标记仍存在(grep 标记);丢失即重新追加。\n"
"6. 小结:分支 HEAD、与上游差距、逐文件处理结果、备份位置。\n"
"\n"
"更新成功后,请说:\"Congratulations! 🎉 你已成功更新 GenericAgent\"随后可邀请:"
"\"如果觉得有帮助,要不要给 GenericAgent 仓库点个 Star?这会让项目成长更快!⭐\""
f"{_tail(args_text)}"
)
def build_autorun_prompt(args_text: str = "") -> str:
return (
"请进入「自主探索 / autonomous 模式」:先读 "
"memory/autonomous_operation_sop.md。"
"全程自驱,不可逆 / 高风险动作先 ask_user "
"结案给一份简明回执(做了什么 / 产物在哪 / 下一步)。"
f"{_tail(args_text, '任务种子')}"
)
def build_morphling_prompt(args_text: str = "") -> str:
return (
"请启用 Morphling 模式吞噬 / 蒸馏外部项目到本仓库:先读 "
"memory/morphling_sop.md。"
"没有目标先 ask_user 取 GitHub 仓库 / 本地路径 / 能力描述。"
f"{_tail(args_text, '目标技能/仓库')}"
)
def build_goal_prompt(args_text: str = "") -> str:
return (
"请进入 Goal 模式:先读 memory/goal_mode_sop.md。"
"若未给目标,先 ask_user 一次性问清:一句话目标 + condition 约束。"
f"{_tail(args_text, '用户目标')}"
)
def build_hive_prompt(args_text: str = "") -> str:
return (
"请进入 Goal Hive 模式(多 worker 协作版 goal):先读 "
"memory/goal_hive_sop.md。"
"集群目标 / worker 配额 / 终止条件未明确时先 ask_user 补齐再启动。"
f"{_tail(args_text, '集群目标')}"
)
def build_conductor_prompt(args_text: str = "") -> str:
"""`/conductor <task>` → run `frontends/conductor.py` on the task.
Upstream `memory/` ships no conductor SOP, so we deliberately keep the
prompt short: name the entrypoint and forward the task verbatim. The
agent is expected to know how to drive `conductor.py` (or consult a
local SOP if one happens to be installed).
"""
args_text = (args_text or "").strip()
if args_text:
return f"请调用 frontends/conductor.py 执行:{args_text}"
return (
"请调用 frontends/conductor.py,根据后续指令完成多 subagent 编排。"
"若任务描述缺失,先 ask_user 一次性补齐。"
)
# ----- /scheduler reflect-task discovery + launch -------------------------
def list_reflect_tasks() -> list[dict]:
"""Return [{name, path, doc}] for every reflect/*.py task script.
`doc` is the module docstring's first line (best-effort) so the picker can
show a one-liner next to each name. Empty list if reflect/ doesn't exist.
"""
out: list[dict] = []
refl = _ROOT / "reflect"
if not refl.is_dir():
return out
for p in sorted(refl.glob("*.py")):
if p.name.startswith("_"):
continue
doc = ""
try:
# Cheap docstring sniff: read first ~40 lines, look for """...""".
head = p.read_text(encoding="utf-8", errors="ignore").splitlines()[:40]
joined = "\n".join(head)
for q in ('"""', "'''"):
i = joined.find(q)
if i != -1:
j = joined.find(q, i + 3)
if j != -1:
doc = joined[i + 3:j].strip().splitlines()[0].strip()
break
except Exception:
pass
out.append({"name": p.stem, "path": str(p), "doc": doc})
return out
# ----- hub.pyw parity: every launchable service ---------------------------
_HUB_EXCLUDES = {"goal_mode.py", "chatapp_common.py", "tuiapp.py"}
def _sniff_doc(p) -> str:
"""Best-effort first line of a module docstring (cheap ~40-line read)."""
try:
head = p.read_text(encoding="utf-8", errors="ignore").splitlines()[:40]
joined = "\n".join(head)
for q in ('"""', "'''"):
i = joined.find(q)
if i != -1:
j = joined.find(q, i + 3)
if j != -1:
body = joined[i + 3:j].strip()
if body:
return body.splitlines()[0].strip()
except Exception:
pass
return ""
def list_launchable_services() -> list[dict]:
"""Mirror hub.pyw's discover_services() so `/scheduler` shows the *same*
set of launchable services as the GUI launcher.
Sources (hub.pyw EXCLUDES = goal_mode.py / chatapp_common.py / tuiapp.py):
• reflect/*.py (not '_'-prefixed, not excluded)
→ cmd = [python, agentmain.py, --reflect, reflect/<f>]
• frontends/*app*.py (not excluded)
→ 'stapp' → `python -m streamlit run … --server.headless=true`
others → `python frontends/<f>`
Returns [{name, cmd, doc, kind}] where `name` is the hub-style path
('reflect/foo.py' / 'frontends/bar.py') and doubles as the picker value.
"""
out: list[dict] = []
refl = _ROOT / "reflect"
if refl.is_dir():
for p in sorted(refl.glob("*.py")):
if p.name.startswith("_") or p.name in _HUB_EXCLUDES:
continue
rel = "reflect/" + p.name
out.append({
"name": rel,
"cmd": [sys.executable, "agentmain.py", "--reflect", rel],
"doc": _sniff_doc(p),
"kind": "reflect",
})
fe = _ROOT / "frontends"
if fe.is_dir():
for p in sorted(fe.glob("*.py")):
if "app" not in p.name or p.name in _HUB_EXCLUDES:
continue
rel = "frontends/" + p.name
if "stapp" in p.name:
cmd = [sys.executable, "-m", "streamlit", "run", rel,
"--server.headless=true"]
else:
cmd = [sys.executable, rel]
out.append({"name": rel, "cmd": cmd, "doc": _sniff_doc(p),
"kind": "frontend"})
return out
def start_service(name: str) -> tuple[bool, str]:
"""Launch a service from list_launchable_services(), detached & window-less
(CONSTITUTION rule 14: creationflags at the launch layer only, never via
subprocess.Popen monkeypatch).
`name` accepts the hub-style path ('reflect/foo.py') or a bare reflect stem
('foo') for backward-compat with `/scheduler start <stem>`.
"""
svcs = list_launchable_services()
svc = next((s for s in svcs if s["name"] == name), None)
if svc is None: # bare reflect stem fallback
cand = "reflect/" + name + ".py"
svc = next((s for s in svcs if s["name"] == cand), None)
if svc is None:
return False, f"未知服务: {name}"
try:
flags = 0
if os.name == "nt":
flags = 0x00000200 | 0x08000000 # NEW_PROCESS_GROUP | NO_WINDOW
proc = subprocess.Popen(
svc["cmd"],
cwd=str(_ROOT),
creationflags=flags,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
# Poll-and-confirm: if the child dies immediately (bad path, import
# error, port-in-use, etc) Popen still returns happily — without this
# check the picker would tick "✅ started" while nothing is running,
# which is exactly the bug#4 the user hit. 0.4s is the smallest
# window that catches "explodes at import" without making the UI
# feel laggy on healthy starts.
time.sleep(0.4)
rc = proc.poll()
if rc is not None:
return False, f"启动失败 (退出码 {rc}): {svc['name']}"
invalidate_running_cache()
return True, f"已启动 {svc['name']} (pid={proc.pid})"
except Exception as e:
return False, f"启动失败: {type(e).__name__}: {e}"
# ----- running-state introspection (bug#4) --------------------------------
# Why psutil cmdline-scan instead of a launched-by-us pid registry?
# • Services launched by a previous TUI run, or by hub.pyw, must also be
# recognised — otherwise /scheduler would happily start a duplicate.
# • A registry tied to this process dies when the TUI restarts, but the
# services keep running (CREATE_NEW_PROCESS_GROUP). Cmdline scan is the
# only single source of truth across launchers, surviving restarts.
# Trade-off: it costs ~30ms per /scheduler open, and matches by cmdline tail,
# so two checkouts of GA can collide. We accept that — running two GAs out
# of two clones is already an unsupported configuration.
def _match_service(cmdline: list[str], svc: dict) -> bool:
"""Does this OS process belong to `svc`? Match on the trailing script
arg (`reflect/foo.py` for reflect tasks, `frontends/bar.py` for apps),
which is invariant across `python` vs `pythonw` vs venv shims.
Reflect detection used to require BOTH `agentmain.py` AND the reflect
path in cmdline. That rejected tasks launched directly (`python
reflect/scheduler.py`) by launch.pyw, dev scripts, or by an earlier
TUI run that used a different launcher — they showed unticked in
/scheduler even when alive. Path-only match handles both styles; the
Python-process pre-filter in `running_services` keeps false positives
(greps, editors with the file open) from sneaking in."""
if not cmdline:
return False
rel = svc["name"] # 'reflect/foo.py' | 'frontends/bar.py'
rel_norm = rel.replace("/", os.sep)
return any(rel_norm in (a or "") or rel in (a or "")
for a in cmdline)
# 2s TTL cache + name-prefilter: ~2.1s → ~1.0s cold, ~0ms warm.
# cmdline() is the per-proc cost; only pay it for python-ish survivors.
_RUNNING_CACHE: tuple[float, dict[str, int]] | None = None
_RUNNING_TTL = 2.0
def invalidate_running_cache() -> None:
"""Drop the snapshot. Call after start/stop so the next read is fresh."""
global _RUNNING_CACHE
_RUNNING_CACHE = None
def running_services(use_cache: bool = True) -> dict[str, int]:
"""{service_name: pid} for live services. {} if psutil missing."""
global _RUNNING_CACHE
if use_cache and _RUNNING_CACHE and time.time() - _RUNNING_CACHE[0] < _RUNNING_TTL:
return dict(_RUNNING_CACHE[1])
try:
import psutil # type: ignore
except Exception:
return {}
svcs = list_launchable_services()
out: dict[str, int] = {}
me = os.getpid()
for proc in psutil.process_iter(["pid", "name"]):
try:
if proc.info["pid"] == me:
continue
nm = (proc.info.get("name") or "").lower()
if "python" not in nm and "py.exe" not in nm:
continue
cmd = proc.cmdline()
except Exception:
continue
for svc in svcs:
if svc["name"] not in out and _match_service(cmd, svc):
out[svc["name"]] = proc.info["pid"]
break
_RUNNING_CACHE = (time.time(), dict(out))
return out
def stop_service(name: str) -> tuple[bool, str]:
"""Terminate the service `name` if running. Returns (ok, message).
Sends SIGTERM-equivalent (Popen.terminate on Windows = TerminateProcess),
waits up to 3s, then escalates to kill. Also reaps obvious children
(e.g. `python -m streamlit` spawns the actual streamlit worker) so we
don't leave orphans behind.
"""
try:
import psutil # type: ignore
except Exception:
return False, "未安装 psutil,无法停止服务"
running = running_services()
pid = running.get(name)
if pid is None:
return False, f"{name} 未在运行"
try:
parent = psutil.Process(pid)
kids = parent.children(recursive=True)
for p in [parent, *kids]:
try:
p.terminate()
except Exception:
pass
gone, alive = psutil.wait_procs([parent, *kids], timeout=3.0)
for p in alive:
try:
p.kill()
except Exception:
pass
invalidate_running_cache()
return True, f"已停止 {name} (pid={pid})"
except psutil.NoSuchProcess:
invalidate_running_cache()
return True, f"{name} 已退出"
except Exception as e:
return False, f"停止失败: {type(e).__name__}: {e}"
def list_scheduler_tasks() -> list[dict]:
"""Return [{name, path, schedule, enabled}] for every sche_tasks/*.json.
Used by the /scheduler picker so users can also toggle traditional cron
tasks, not just reflect.* scripts.
"""
out: list[dict] = []
sd = _ROOT / "sche_tasks"
if not sd.is_dir():
return out
for p in sorted(sd.glob("*.json")):
try:
data = json.loads(p.read_text(encoding="utf-8"))
except Exception:
data = {}
out.append({
"name": p.stem,
"path": str(p),
"schedule": data.get("schedule") or data.get("cron") or data.get("every") or "",
"enabled": bool(data.get("enabled", True)),
})
return out
def start_reflect_task(name: str) -> tuple[bool, str]:
"""Spawn `python reflect/<name>.py` detached. Returns (ok, message).
Detached because reflect tasks are long-running; we don't want them to die
with the TUI. On Windows we use CREATE_NEW_PROCESS_GROUP|CREATE_NO_WINDOW
so no console pops up (per CONSTITUTION rule 14: only at launch layer, no
monkeypatching subprocess.Popen).
"""
script = _ROOT / "reflect" / f"{name}.py"
if not script.is_file():
return False, f"reflect/{name}.py 不存在"
try:
flags = 0
if os.name == "nt":
flags = 0x00000200 | 0x08000000 # NEW_PROCESS_GROUP | NO_WINDOW
subprocess.Popen(
[sys.executable, str(script)],
cwd=str(_ROOT),
creationflags=flags,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
return True, f"已启动 reflect/{name}.py"
except Exception as e:
return False, f"启动失败: {type(e).__name__}: {e}"
# ----- dispatch table for the TUI to register against ---------------------
# (cmd, arg_hint, desc) — kept identical between v2 and v3 so the palette
# stays consistent across frontends.
PALETTE_ENTRIES: list[tuple[str, str, str]] = [
("/update", "[note]", "git pull 更新 GA 仓库并报告影响面"),
("/autorun", "[seed]", "进入 autonomous_operation 自主模式"),
("/morphling", "[target]", "启用 Morphling 蒸馏 / 吞噬外部技能"),
("/goal", "[goal]", "进入 Goal 模式(需 condition 约束)"),
("/hive", "[target]", "进入 Hive 多 worker 协作模式"),
("/conductor", "[task]", "调用 frontends/conductor.py 多 subagent 编排"),
("/scheduler", "", "多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)"),
("/resume", "", "列出最近会话并恢复其中一个(GA 端展开 prompt"),
]
def prompt_for(cmd: str, args_text: str) -> Optional[str]:
"""Return the injected user-message for a given slash command, or None if
the command isn't one of ours (e.g. /scheduler — handled by TUI directly).
Language is resolved inside the builders that care about it (see
`_current_lang()`); callers never thread it through, so both TUIs keep a
single uniform call site.
"""
table = {
"/update": build_update_prompt,
"/autorun": build_autorun_prompt,
"/morphling": build_morphling_prompt,
"/goal": build_goal_prompt,
"/hive": build_hive_prompt,
"/conductor": build_conductor_prompt,
}
fn = table.get(cmd)
return fn(args_text) if fn else None