diff --git a/README.md b/README.md index 127a9af..7c42c85 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,8 @@ irm https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/install_window 之后再启动会沿用上次的 provider、模型和推理强度(low / medium / high / max),不需要任何参数或环境变量。 界面里用 `/provider` 切换(没保存过 key 的会当场询问),`/model`、`/effort` 切换模型和推理强度,`/key` 更换 key。 -完整环境变量说明:[docs/env_openai.md](docs/env_openai.md)。 +完整环境变量说明:[docs/env_openai.md](docs/env_openai.md)。 +多设备议会(密码 Realm / 主席 / 常驻同步):[docs/parliament.md](docs/parliament.md)。 | 平台 | 一键脚本 | |---|---| diff --git a/docs/parliament.md b/docs/parliament.md new file mode 100644 index 0000000..b1846a7 --- /dev/null +++ b/docs/parliament.md @@ -0,0 +1,73 @@ +# Parliament(议会)快速上手 + +密码串派生 Realm → 登录夺主席 → 常驻服务同步上下文。同一时刻只有一个主席;换项目清空全部议会上下文。 + +## 一次性初始化 + +```bash +# 交互输入密码,或事先 export CST_REALM_PASSWORD=… +cst-agent --realm-password-init +``` + +磁盘只存 salt + 公钥,**不存私钥**。 + +## 日常:登录即夺主席 + +```bash +export CST_INSTANCE_ID=laptop-a # 可选;默认 chair-local +cst-agent --login --realm-label "thinkpad" +# 也可:CST_REALM_PASSWORD=… cst-agent --login +``` + +效果: + +1. 用密码解锁 Realm,本机成为**唯一主席**(上一主席被交接) +2. 写入本地登录票(24h) +3. 拉起后台议会服务(发现 + 按 revision 同步 `parliament.context`) + +查看状态: + +```bash +cst-agent # 进入 TUI 后 /status +# 或 CLI +cst-agent --service-stop # 停服务 +``` + +密码 Realm 开启后,直接打开 agent 会要求登录(TTY 下可当场输入密码)。 + +## 协作模式 + +`settings.json` 里 `"collaboration": "parliament"`(或环境变量 `CST_COLLABORATION=parliament`)时: + +- 提示词不再鼓动 `task` / `worker_start` +- 本机用工具直接干活;跨设备靠 Realm peer 席位,而不是进程内子 agent + +密码 Realm 存在时也会按议会模式注入 Environment(chair / 共享 context / 设备席位)。 + +## 换项目(清场) + +```bash +cst-agent --new-project my-app +``` + +清空共享上下文、撤销主席与登录票,并停止本机服务。Realm 密钥保留。 + +## Docker 冒烟 + +```bash +bash scripts/docker_parliament_test.sh +``` + +Alpine 内构建,chair↔member 握手。 + +## 相关标志 + +| 标志 | 作用 | +|------|------| +| `--realm-password-init` | 创建密码 Realm | +| `--login` | 登录 + 夺主席 + 启服务 | +| `--service` / `--service-fg` | 仅启服务 | +| `--service-stop` | 停服务 | +| `--new-project ID` | 清场换项目 | +| `--peer-port N` | 登录/服务用的 TCP 端口(默认 9100) | +| `--instance ID` | 实例 id(也可用 `CST_INSTANCE_ID`) | diff --git a/include/cst_agent.h b/include/cst_agent.h index fd4e8bf..25909ac 100644 --- a/include/cst_agent.h +++ b/include/cst_agent.h @@ -477,6 +477,8 @@ int cst_creds_resolve(const char *provider, char *out, size_t out_sz, const char **from); /* Hidden console prompt (no echo). */ int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz); +/* Generic hidden prompt for passwords / secrets (TTY only). */ +int cst_prompt_secret(const char *prompt, char *out, size_t out_sz); /* Last used provider, model per provider, effort and working mode, in * /settings.json. */ diff --git a/include/cst_version.h b/include/cst_version.h index adc3dcf..3a86578 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 9 -#define CST_VERSION_PATCH 14 +#define CST_VERSION_PATCH 15 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/src/agent_loop.c b/src/agent_loop.c index ad203d6..8d5e1f7 100644 --- a/src/agent_loop.c +++ b/src/agent_loop.c @@ -1193,6 +1193,15 @@ static void exec_begin(turn_t *t, pcall_t *pc, exec_t *x) { !(def->parallel && def->parallel(c, def->ud))); uint64_t h = call_hash(c); x->h = h; + if (fail_count(a, h, 0) >= 3) { + const char *msg = + "error: this exact call already failed 3+ times this turn; skipped. " + "Change approach instead of repeating the same call."; + set_output(pc, msg, strlen(msg), 1); + pc->status = PC_DENIED; + hook_status(t, "skip repeated failure"); + return; + } int dup = 0; /* plugin and MCP results can change between identical calls */ int external = def->owner && strncmp(def->owner, "builtin:", 8) != 0; @@ -3173,6 +3182,26 @@ static int run_turn_once(cst_agent_t *a, const char *user_text, t.ncalls = kept; if (t.ncalls > 0) persist_calls(&t); + /* Worker request-gate exhaustion is recoverable: keep partial progress + * and return a clear budget error instead of a generic llm: failure. */ + int budget = + lerr[0] && (strstr(lerr, "budget:") || strstr(lerr, "budget exhausted")); + if (budget) { + const char *body = t.text.buf ? trim_left(t.text.buf) : ""; + if (text_is_meaningful(body, strlen(body))) + append_reply(&t, body, NULL, meta_json("budget", proto)); + else if (tools_ran || t.ncalls > 0) { + append_msg(a, CST_ROLE_ASSISTANT, + "Stopped: shared worker request budget is exhausted. " + "Partial tool work above is kept; raise max_requests or " + "finish without further worker HTTP calls.", + NULL, NULL, NULL, meta_json("budget", proto)); + } + turn_free(&t); + snprintf(err, err_sz, "budget: worker request budget exhausted"); + rc = 1; + break; + } turn_free(&t); snprintf(err, err_sz, "llm: %s", lerr); break; diff --git a/src/creds.c b/src/creds.c index 7a0098f..6121499 100644 --- a/src/creds.c +++ b/src/creds.c @@ -160,12 +160,11 @@ int cst_creds_resolve(const char *provider, char *out, size_t out_sz, return -1; } -int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz) { +int cst_prompt_secret(const char *prompt, char *out, size_t out_sz) { if (!out || out_sz < 2) return -1; out[0] = 0; - fprintf(stderr, "API key for %s (input hidden, saved to the config dir): ", - provider ? provider : "?"); + fprintf(stderr, "%s", prompt && prompt[0] ? prompt : "Secret: "); fflush(stderr); #ifdef _WIN32 HANDLE hin = GetStdHandle(STD_INPUT_HANDLE); @@ -198,3 +197,11 @@ int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz) { chomp(out); return out[0] ? 0 : -1; } + +int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz) { + char prompt[128]; + snprintf(prompt, sizeof(prompt), + "API key for %s (input hidden, saved to the config dir): ", + provider ? provider : "?"); + return cst_prompt_secret(prompt, out, out_sz); +} diff --git a/src/llm_openai.c b/src/llm_openai.c index a9981e0..04da334 100644 --- a/src/llm_openai.c +++ b/src/llm_openai.c @@ -1137,7 +1137,7 @@ int cst_llm_chat_stream(const cst_llm_request_t *req, char *err, if (rep_dir && rep_dir[0]) { if (req->request_gate && !req->request_gate(req->request_gate_ud)) { - snprintf(err, err_sz, "worker request budget exhausted"); + snprintf(err, err_sz, "budget: worker request budget exhausted"); ret = -1; goto finish; } @@ -1158,7 +1158,7 @@ int cst_llm_chat_stream(const cst_llm_request_t *req, char *err, for (int attempt = 0;; attempt++) { if (req->request_gate && !req->request_gate(req->request_gate_ud)) { - snprintf(err, err_sz, "worker request budget exhausted"); + snprintf(err, err_sz, "budget: worker request budget exhausted"); ret = -1; break; } diff --git a/src/main.c b/src/main.c index abb9209..747a65e 100644 --- a/src/main.c +++ b/src/main.c @@ -52,7 +52,7 @@ static void usage(const char *argv0) { " --no-orchestrate override workspace orchestration setting\n" " --realm-new create Realm file keys (legacy/dev)\n" " --realm-password-init create password-derived Realm (no sk on disk)\n" - " --login unlock; claim chair; start parliament service\n" + " --login unlock (CST_REALM_PASSWORD or prompt); claim chair\n" " --service run parliament service (discover+context sync)\n" " --service-fg same as --service, stay in foreground\n" " --service-stop stop the parliament background service\n" @@ -754,7 +754,12 @@ int main(int argc, char **argv) { char rdir[OS_PATH_MAX]; if (cst_realm_dir(rdir, sizeof(rdir)) == 0 && cst_realm_is_password_mode(rdir) && !cst_parliament_session_ok(NULL, 0)) { + char pwbuf[256]; const char *pw = getenv("CST_REALM_PASSWORD"); + if ((!pw || !pw[0]) && isatty(0) && + cst_prompt_secret("Realm password to open agent (hidden): ", pwbuf, + sizeof(pwbuf)) == 0) + pw = pwbuf; if (pw && pw[0]) { const char *iid = parliament_instance ? parliament_instance : getenv("CST_INSTANCE_ID"); @@ -765,6 +770,8 @@ int main(int argc, char **argv) { cst_parliament_login(pw, iid, realm_label, err, sizeof(err)); if (!hand) { fprintf(stderr, "%s\n", err[0] ? err : "login required"); + if (pw == pwbuf) + memset(pwbuf, 0, sizeof(pwbuf)); curl_global_cleanup(); return 1; } @@ -773,6 +780,8 @@ int main(int argc, char **argv) { sizeof(err)) != 0) fprintf(stderr, "warning: parliament service: %s\n", err[0] ? err : "start failed"); + if (pw == pwbuf) + memset(pwbuf, 0, sizeof(pwbuf)); } else { fprintf(stderr, "password Realm active: run --login (or set " diff --git a/src/parliament_cli.c b/src/parliament_cli.c index cdd412e..f66603b 100644 --- a/src/parliament_cli.c +++ b/src/parliament_cli.c @@ -2,6 +2,7 @@ #define _DEFAULT_SOURCE #endif #include "cst_parliament_cli.h" +#include "cst_agent.h" #include "cst_discover.h" #include "cst_net.h" #include "cst_parliament.h" @@ -29,6 +30,21 @@ static void sleep_ms(int ms) { #endif } +/* Prefer CST_REALM_PASSWORD; otherwise prompt on a TTY. */ +static int resolve_password(char *buf, size_t n, const char **out) { + const char *env = getenv("CST_REALM_PASSWORD"); + if (env && env[0]) { + *out = env; + return 0; + } + if (cst_prompt_secret("Realm password (input hidden): ", buf, n) != 0) { + fprintf(stderr, "password required (set CST_REALM_PASSWORD or type it)\n"); + return -1; + } + *out = buf; + return 0; +} + int cst_parliament_cli_realm_new(void) { char dir[OS_PATH_MAX], fp[33]; if (cst_realm_dir(dir, sizeof(dir)) != 0) { @@ -46,24 +62,26 @@ int cst_parliament_cli_realm_new(void) { } int cst_parliament_cli_realm_password_init(const char *password) { - if (!password || !password[0]) { - fprintf(stderr, "password required (or CST_REALM_PASSWORD)\n"); + char pwbuf[256]; + const char *pw = password; + if ((!pw || !pw[0]) && resolve_password(pwbuf, sizeof(pwbuf), &pw) != 0) return 1; - } char dir[OS_PATH_MAX], fp[33]; if (cst_realm_dir(dir, sizeof(dir)) != 0) { fprintf(stderr, "cannot resolve realm directory\n"); return 1; } - if (cst_realm_init_password(dir, password) != 0) { + if (cst_realm_init_password(dir, pw) != 0) { fprintf(stderr, "password realm init failed\n"); return 1; } cst_realm_keys_t k; - if (cst_realm_unlock_password(dir, password, &k) != 0) + if (cst_realm_unlock_password(dir, pw, &k) != 0) return 1; cst_realm_fingerprint(k.pk, fp); printf("realm_password_mode dir=%s fingerprint=%s\n", dir, fp); + if (pw == pwbuf) + memset(pwbuf, 0, sizeof(pwbuf)); return 0; } @@ -76,52 +94,66 @@ int cst_parliament_cli_login(const char *password, const char *instance_id, int cst_parliament_cli_login_and_serve(const char *password, const char *instance_id, const char *device_label, int peer_port) { - char err[256]; - char *hand = cst_parliament_login(password, instance_id, device_label, err, + char err[256], pwbuf[256]; + const char *pw = password; + if ((!pw || !pw[0]) && resolve_password(pwbuf, sizeof(pwbuf), &pw) != 0) + return 1; + char *hand = cst_parliament_login(pw, instance_id, device_label, err, sizeof(err)); if (!hand) { fprintf(stderr, "%s\n", err[0] ? err : "login failed"); + if (pw == pwbuf) + memset(pwbuf, 0, sizeof(pwbuf)); return 1; } fputs(hand, stdout); free(hand); - if (cst_parliament_daemon_start(password, instance_id, + if (cst_parliament_daemon_start(pw, instance_id, peer_port > 0 ? peer_port : 9100, err, sizeof(err)) != 0) { fprintf(stderr, "warning: service: %s (chair login still valid)\n", err[0] ? err : "start failed"); printf("service=failed\n"); + if (pw == pwbuf) + memset(pwbuf, 0, sizeof(pwbuf)); return 0; } if (cst_parliament_daemon_running()) printf("service=running\n"); + if (pw == pwbuf) + memset(pwbuf, 0, sizeof(pwbuf)); return 0; } int cst_parliament_cli_service(const char *password, const char *instance_id, int peer_port, int foreground) { - char err[256]; + char err[256], pwbuf[256]; + const char *pw = password; if (!instance_id || !instance_id[0]) { fprintf(stderr, "instance id required\n"); return 1; } + if ((!pw || !pw[0]) && resolve_password(pwbuf, sizeof(pwbuf), &pw) != 0) + return 1; + int rc = 0; if (foreground) { - if (cst_parliament_daemon_run(password, instance_id, + if (cst_parliament_daemon_run(pw, instance_id, peer_port > 0 ? peer_port : 9100, err, sizeof(err)) != 0) { fprintf(stderr, "%s\n", err[0] ? err : "service failed"); - return 1; + rc = 1; } - return 0; - } - if (cst_parliament_daemon_start(password, instance_id, - peer_port > 0 ? peer_port : 9100, err, - sizeof(err)) != 0) { + } else if (cst_parliament_daemon_start(pw, instance_id, + peer_port > 0 ? peer_port : 9100, err, + sizeof(err)) != 0) { fprintf(stderr, "%s\n", err[0] ? err : "service start failed"); - return 1; + rc = 1; + } else { + printf("service=running\n"); } - printf("service=running\n"); - return 0; + if (pw == pwbuf) + memset(pwbuf, 0, sizeof(pwbuf)); + return rc; } int cst_parliament_cli_service_stop(void) { diff --git a/src/subagent.c b/src/subagent.c index aebf3d5..f225c7b 100644 --- a/src/subagent.c +++ b/src/subagent.c @@ -1087,10 +1087,20 @@ static int task_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, } long tokens = cst_agent_last_prompt_tokens(run.child); cst_buf_t b = {0}; - if (rc != 0) - cst_buf_printf(&b, "error: sub-agent %s: %s\n", cancelled ? "cancelled" - : "failed", - err[0] ? err : "unknown error"); + if (rc != 0) { + int budget = err[0] && (strstr(err, "budget:") || + strstr(err, "budget exhausted")); + if (budget) { + cst_buf_puts(&b, "error: worker request budget exhausted\n"); + if (err[0]) + cst_buf_printf(&b, "detail: %s\n", err); + cst_buf_puts(&b, "hint: raise .agent/workers.json max_requests, or " + "finish the remaining work yourself with local tools.\n"); + } else + cst_buf_printf(&b, "error: sub-agent %s: %s\n", + cancelled ? "cancelled" : "failed", + err[0] ? err : "unknown error"); + } if (final) { while (*final == ' ' || *final == '\n') final++;