Merge pull request 'Route four models and send current-turn images' (#563) from feature/four-model-vision into main
ci / svc-realcombo (push) Successful in 2m5s
ci / test (push) Successful in 5m55s
ci / test-pg (push) Successful in 8m33s

Why: land four-model routing and current-turn image delivery.
Evidence: test, test-pg, and svc-realcombo success on f938b3ca97, run 3303.
Impact: main serves images on capable models; 8788 is not restarted.
Refs: #563
This commit was merged in pull request #563.
This commit is contained in:
2026-10-15 06:38:23 +00:00
6 changed files with 543 additions and 32 deletions
File diff suppressed because one or more lines are too long
+5
View File
@@ -810,7 +810,12 @@ scripts/svc.sh prod deploy --env .env.prod # 等价
| `SVC_SRC_DIR` | 独立部署源仓路径(默认 `~/.cache/agent-system-svc/src`) |
| `SVC_PG_BIN` | PostgreSQL 工具链 bindir |
| `SVC_MODEL_BASE/KEY/NAME` | 主模型(播种 `settings.models.chat`) |
| `SVC_MODEL_FALLBACKS` | 主模型回退链(逗号分隔,播种 `settings.models.chat.fallbacks`) |
| `SVC_LIGHT_BASE/KEY/NAME` | 轻量模型 |
| `SVC_VISION_MODEL` | 视觉兜底模型(播种 `settings.models.vision.model`) |
| `SVC_VISION_CAPABLE` | 可看图模型清单(逗号分隔,播种 `settings.models.vision.capable`) |
| `SVC_MINIMAX_BASE/KEY` | 原 MiniMax 端点(播种 `settings.models.reserve.minimax`,不替换当前对话模型) |
| `SVC_MINIMAX_CHAT/FAST` | 原 MiniMax 对话模型与快速模型名 |
| `SVC_TAVILY_KEYS` | Tavily 密钥池(逗号分隔) |
### 9.4 持久化语义
+3
View File
@@ -27,7 +27,9 @@
"backend/app backend/tools",
"backend/app backend/workflow",
"backend/cardsvc backend/store",
"backend/ctxasm backend/vision",
"backend/hostapi backend/store",
"backend/ingest backend/vision",
"backend/pgbus backend/bus",
"backend/pgbus backend/store",
"backend/plugins backend/settings",
@@ -58,6 +60,7 @@
"backend/runtime backend/quality",
"backend/runtime backend/squadadapter",
"backend/runtime backend/wfcompile",
"backend/runtime backend/vision",
"backend/runtime backend/workflow",
"backend/server backend/cardsvc",
"backend/server backend/cmdbus",
+141 -31
View File
@@ -572,7 +572,12 @@ dotenv 格式:KEY=VALUE,支持 # 注释、成对引号、export 前缀;只
SVC_SRC_DIR 独立部署源仓路径(默认 ~/.cache/agent-system-svc/src)
SVC_PG_BIN postgres 工具链 bindir(默认 pg_config --bindir 探测)
SVC_MODEL_BASE/KEY/NAME 主模型(agent 启动健康后播种)
SVC_MODEL_FALLBACKS 主模型回退链(逗号分隔,播种 models.chat.fallbacks)
SVC_LIGHT_BASE/KEY/NAME 轻量模型
SVC_VISION_MODEL 视觉兜底模型(播种 models.vision.model)
SVC_VISION_CAPABLE 可看图模型清单(逗号分隔,播种 models.vision.capable)
SVC_MINIMAX_BASE/KEY 原 MiniMax 端点(播种 models.reserve.minimax,不作为当前对话模型)
SVC_MINIMAX_CHAT/FAST 原 MiniMax 对话模型与快速模型名
SVC_TAVILY_KEYS Tavily 密钥池
SVC_SEED_OWNER 播种目标账号(缺省取库内首个管理员)
播种幂等:设置持久化在 agent 数据目录,播过一次后续启动无需再传。
@@ -726,6 +731,39 @@ load_env_file() {
done < "$f"
}
# load_env_file_if_unset FILE:只填当前为空的变量,不覆盖调用方已经传入的环境。
load_env_file_if_unset() {
local f="$1" line k v
[ -f "$f" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
case "$line" in ''|'#'*) continue ;; esac
[[ "$line" =~ ^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*= ]] || continue
k="${line%%=*}"
k="${k#"${k%%[![:space:]]*}"}"
k="${k#export}"
k="${k#"${k%%[![:space:]]*}"}"
[ -n "${!k:-}" ] && continue
v="${line#*=}"
v="${v#"${v%%[![:space:]]*}"}"
v="${v%\"}"; v="${v%\'}"; v="${v#\"}"; v="${v#\'}"
printf -v "$k" '%s' "$v"
done < "$f"
}
# load_saved_envfile:svc.env 记住的 envfile= 在播种前补进空变量。
load_saved_envfile() {
local f
[ -n "${SVC_ENVFILE:-}" ] && return 0
f="$(svc_get envfile)"
[ -n "$f" ] || return 0
case "$f" in
/*) ;;
*) f="$ROOT/$f" ;;
esac
load_env_file_if_unset "$f"
}
# ---- 命令行参数(覆盖层)----
CLI_ADDR=""; CLI_HOST=""; CLI_PORT=""; CLI_DATA=""; CLI_DSN=""
CLI_AUTH=""; CLI_MOCK=0; CLI_PLUGINS=""; CLI_EXTRA=""; CLI_LOGS_N=""
@@ -900,7 +938,7 @@ svc_merge_tags() {
# agent 服务经统一解析启动后按生效态写 db=(pg/mock;显式 sqlite)与 dbref=/dsn=
# (托管绑定不重复存 DSN,外部直连存受保护 dsn=);未走解析的调用保持旧值。
record_state() {
local pid prev_src prev_dbref prev_conffile prev_db prev_dsn prev_plugins plugins_out merged_tags
local pid prev_src prev_dbref prev_conffile prev_db prev_dsn prev_envfile prev_plugins plugins_out merged_tags
pid="$(read_pid)"
prev_src=""
[ -f "$ENV_DIR/svc.env" ] && prev_src="$(svc_get source)"
@@ -908,6 +946,7 @@ record_state() {
prev_conffile="$(svc_get conffile)"
prev_db="$(svc_get db)"
prev_dsn="$(svc_get dsn)"
prev_envfile="$(svc_get envfile)"
prev_plugins="$(svc_get plugins)"
plugins_out="$prev_plugins"
# 只有激活校验和 UI 探活都通过之后才提交新的 plugins=。中途 record_state
@@ -952,6 +991,11 @@ record_state() {
[ -n "$dbref_out" ] && printf 'dbref=%s\n' "$dbref_out"
[ -n "$dsn_out" ] && printf 'dsn=%s\n' "$dsn_out"
[ -n "$prev_conffile" ] && printf 'conffile=%s\n' "$prev_conffile"
if [ -n "${SVC_ENVFILE:-}" ]; then
printf 'envfile=%s\n' "$SVC_ENVFILE"
elif [ -n "$prev_envfile" ]; then
printf 'envfile=%s\n' "$prev_envfile"
fi
[ -n "$plugins_out" ] && printf 'plugins=%s\n' "$plugins_out"
[ $# -gt 0 ] && printf '%s\n' "$@"
} > "$tmp"
@@ -3421,6 +3465,48 @@ print_agent_plan() {
return 0
}
# model_seed_payload:把当前环境里的模型配置打成系统设置 JSON。没有可写项时输出空。
model_seed_payload() {
local cb="${CLI_MBASE:-${SVC_MODEL_BASE:-}}" ck="${CLI_MKEY:-${SVC_MODEL_KEY:-}}" cn="${CLI_MNAME:-${SVC_MODEL_NAME:-}}"
local lb="${CLI_LBASE:-${SVC_LIGHT_BASE:-}}" lk="${CLI_LKEY:-${SVC_LIGHT_KEY:-}}" ln="${CLI_LNAME:-${SVC_LIGHT_NAME:-}}"
local tk="${CLI_TAVILY:-${SVC_TAVILY_KEYS:-}}"
local fb="${SVC_MODEL_FALLBACKS:-}" vm="${SVC_VISION_MODEL:-}" vc="${SVC_VISION_CAPABLE:-}"
local mb="${SVC_MINIMAX_BASE:-}" mk="${SVC_MINIMAX_KEY:-}" mc="${SVC_MINIMAX_CHAT:-}" mf="${SVC_MINIMAX_FAST:-}"
[ -z "$cb$ck$cn$lb$lk$ln$tk$fb$vm$vc$mb$mk$mc$mf" ] && return 0
command -v python3 >/dev/null 2>&1 || return 1
python3 - "$cb" "$ck" "$cn" "$lb" "$lk" "$ln" "$tk" "$fb" "$vm" "$vc" "$mb" "$mk" "$mc" "$mf" <<'PY'
import json, sys
cb, ck, cn, lb, lk, ln, tk, fb, vm, vc, mb, mk, mc, mf = sys.argv[1:15]
def sub(base, key, name):
d = {}
if base: d["baseUrl"] = base
if key: d["apiKey"] = key
if name: d["model"] = name
return d
chat, light = sub(cb, ck, cn), sub(lb, lk, ln)
if fb:
chat["fallbacks"] = fb
models = {}
if chat: models["chat"] = chat
if light: models["lightweight"] = light
vision = {}
if vm: vision["model"] = vm
if vc: vision["capable"] = vc
if vision: models["vision"] = vision
reserve = {}
if mb: reserve["baseUrl"] = mb
if mk: reserve["apiKey"] = mk
if mc: reserve["chat"] = mc
if mf: reserve["fast"] = mf
if reserve:
models["reserve"] = {"minimax": reserve}
out = {}
if models: out["models"] = models
if tk: out["tools"] = {"tavily": {"apiKeys": tk}}
print(json.dumps(out))
PY
}
# seed_settings:把 CLI/环境变量给出的模型与搜索配置写入系统设置(幂等)。
# P2 受管(提案 2026-10-13 rdm-settings-members-duties-perms-increment):models.*
# 与 tools.tavily.* 是系统受管项,user 层写入整笔拒绝,播种改走系统管理面
@@ -3430,31 +3516,10 @@ print_agent_plan() {
# 铸 key 失败维持无头 PUT(仅无认证单机档可成)。
seed_settings() {
local host="$1" port="$2"
local cb="${CLI_MBASE:-${SVC_MODEL_BASE:-}}" ck="${CLI_MKEY:-${SVC_MODEL_KEY:-}}" cn="${CLI_MNAME:-${SVC_MODEL_NAME:-}}"
local lb="${CLI_LBASE:-${SVC_LIGHT_BASE:-}}" lk="${CLI_LKEY:-${SVC_LIGHT_KEY:-}}" ln="${CLI_LNAME:-${SVC_LIGHT_NAME:-}}"
local tk="${CLI_TAVILY:-${SVC_TAVILY_KEYS:-}}"
[ -z "$cb$ck$cn$lb$lk$ln$tk" ] && return 0
command -v python3 >/dev/null 2>&1 || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; }
local payload
payload="$(python3 - "$cb" "$ck" "$cn" "$lb" "$lk" "$ln" "$tk" <<'PY'
import json, sys
cb, ck, cn, lb, lk, ln, tk = sys.argv[1:8]
out = {}
def sub(base, key, name):
d = {}
if base: d["baseUrl"] = base
if key: d["apiKey"] = key
if name: d["model"] = name
return d
chat, light = sub(cb, ck, cn), sub(lb, lk, ln)
models = {}
if chat: models["chat"] = chat
if light: models["lightweight"] = light
if models: out["models"] = models
if tk: out["tools"] = {"tavily": {"apiKeys": tk}}
print(json.dumps(out))
PY
)"
payload="$(model_seed_payload)" || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; }
[ -n "$payload" ] && [ "$payload" != "{}" ] || return 0
command -v python3 >/dev/null 2>&1 || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; }
# 认证开启时的播种凭证:临时 key,明文只存在于本函数局部变量,不回显、不落盘。
# apikey 子命令不读环境 DSN,只能经 CLI 传递(进程列表短暂可见;serve 走
# AGENT_SYSTEM_DSN 环境注入,此路对 CLI 子命令不可用)。
@@ -3492,15 +3557,59 @@ for u in users:
fi
if echo "$resp" | grep -q '"ok": *true'; then
local did=""
[ -n "$cb$ck$cn" ] && did="主模型"
[ -n "$lb$lk$ln" ] && did="${did:+$did、}轻量模型"
[ -n "$tk" ] && did="${did:+$did、}Tavily"
[ -n "${CLI_MBASE:-${SVC_MODEL_BASE:-}}${CLI_MKEY:-${SVC_MODEL_KEY:-}}${CLI_MNAME:-${SVC_MODEL_NAME:-}}" ] && did="主模型"
[ -n "${CLI_LBASE:-${SVC_LIGHT_BASE:-}}${CLI_LKEY:-${SVC_LIGHT_KEY:-}}${CLI_LNAME:-${SVC_LIGHT_NAME:-}}" ] && did="${did:+$did、}轻量模型"
[ -n "${SVC_MODEL_FALLBACKS:-}" ] && did="${did:+$did、}回退链"
[ -n "${SVC_VISION_MODEL:-}${SVC_VISION_CAPABLE:-}" ] && did="${did:+$did、}视觉模型"
[ -n "${SVC_MINIMAX_BASE:-}${SVC_MINIMAX_KEY:-}${SVC_MINIMAX_CHAT:-}${SVC_MINIMAX_FAST:-}" ] && did="${did:+$did、}MiniMax 备用"
[ -n "${CLI_TAVILY:-${SVC_TAVILY_KEYS:-}}" ] && did="${did:+$did、}Tavily"
c_ok "[$ENV_NAME] 已播种设置:$did(持久化于 $ENV_DATA,后续启动无需再传)"
else
c_warn "[$ENV_NAME] 设置播种未成功:${resp:-<无响应>}(认证开启时需库内已有账号以铸临时密钥,或用 --seed-owner 指定;也可在设置页手动配置)"
fi
}
# seed_settings_db:rdm 产物不能铸 API key 时,把同一份配置合并进嵌入式系统设置。
# 库还不存在时不创建,避免在空数据目录里写出半套表。
seed_settings_db() {
local payload db
payload="$(model_seed_payload)" || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; }
[ -n "$payload" ] && [ "$payload" != "{}" ] || return 0
db="${ENV_DATA:-}/agent-system.db"
[ -f "$db" ] || return 0
if python3 - "$db" "$payload" <<'PY'
import json, sqlite3, sys
db, payload = sys.argv[1], sys.argv[2]
incoming = json.loads(payload)
con = sqlite3.connect(db, timeout=10)
tables = {row[0] for row in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if "settings" not in tables:
raise SystemExit(2)
row = con.execute('SELECT "values" FROM settings WHERE owner=? AND scope=?', ("", "system")).fetchone()
current = json.loads(row[0]) if row else {}
def merge(dst, src):
for key, value in src.items():
if isinstance(value, dict) and isinstance(dst.get(key), dict):
merge(dst[key], value)
else:
dst[key] = value
return dst
blob = json.dumps(merge(current, incoming), ensure_ascii=False, separators=(",", ":"))
if row:
con.execute('UPDATE settings SET "values"=? WHERE owner=? AND scope=?', (blob, "", "system"))
else:
con.execute('INSERT INTO settings(owner, scope, "values") VALUES(?,?,?)', ("", "system", blob))
con.commit()
PY
then
c_ok "[$ENV_NAME] 已把模型配置写入数据目录(rdm 产物无播种命令,直接更新系统设置)"
else
c_warn "[$ENV_NAME] 数据目录中的模型配置未写上"
fi
}
# dsn_from_dbref DBREF:由 dbref(pg实例/库名)从该实例登记重建连接串。
# 实例未登记 / 非 postgres / 缺 addr·user·password 时返回非零且不输出。
dsn_from_dbref() {
@@ -3919,10 +4028,11 @@ agent_do_start() {
svc_mark_autostart
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$phost:${probe##*:}(来源 ${SVC_SOURCE:-?},日志:$ENV_DIR/svc.log)"
hist_event start ok "$(read_pid)" "http://$phost:${probe##*:}"
# rdm 组合产物无 user/apikey CLI 子命令:设置播种(--model-*/--tavily-keys)仅
# 基座产物支持;记录态 svc.env 的 source=rdm@* 跨裸 start/restart 稳定标识形态。
load_saved_envfile
# rdm 组合产物没有 user/apikey 子命令,不能铸临时管理员密钥走 HTTP。
# 嵌入式库改为直接合并系统设置,避免重启后回到旧模型。
case "${SVC_SOURCE:-}" in
rdm@*) ;;
rdm@*) seed_settings_db ;;
*) seed_settings "$phost" "${probe##*:}" ;;
esac
return 0
+20
View File
@@ -713,6 +713,26 @@ class SvcDbRecoveryTests(unittest.TestCase):
self.assertIn("apikey create --dsn %s --owner op --name svc-seed" % dsn, cli)
self.assertIn("apikey revoke --dsn %s --owner op --name svc-seed" % dsn, cli)
def test_seed_writes_fallbacks_and_vision(self):
self.register_pg("pg1", 18951, password="seed-pw-v")
dsn = "postgresql://agent:seed-pw-v@127.0.0.1:18951/agent"
d = self.runtime("dev")
self.seed_agent("dev", 18952,
"--addr 127.0.0.1:18952 --data %s --plugins %s --auth auto --dsn %s"
% (d / "data", d / "plugins", dsn))
result = self.svc("dev", "start", env=self.model_env(
SVC_MODEL_FALLBACKS="ZHIPU/GLM-5.3-FlashX,ZHIPU/GLM-5.3-Flash",
SVC_VISION_MODEL="qwen3.8-flash",
SVC_VISION_CAPABLE="deepseek-v4.1-flash,qwen3.8-flash",
))
self.assertIn("已播种设置:主模型", result.stdout)
self.assertIn("回退链", result.stdout)
self.assertIn("视觉模型", result.stdout)
body_part = self.put_record().split("BODY=", 1)[1]
self.assertIn('"fallbacks": "ZHIPU/GLM-5.3-FlashX,ZHIPU/GLM-5.3-Flash"', body_part)
self.assertIn('"model": "qwen3.8-flash"', body_part)
self.assertIn('"capable": "deepseek-v4.1-flash,qwen3.8-flash"', body_part)
def test_seed_owner_override_skips_account_lookup(self):
self.register_pg("pg1", 18833, password="seed-pw-2")
dsn = "postgresql://agent:seed-pw-2@127.0.0.1:18833/agent"