Compare commits

...
Author SHA1 Message Date
shufeng2012 121e155248 Merge branch 'main' of ssh://git.cancerai.top:2000/qing/blackbird into dev_shufeng2012 2026-08-18 15:39:16 +08:00
qing d513c5f892 feat(aibox): interactive model profile manager (model_config.py)
Menu-driven list/add/delete/connectivity-test for config.json models
section - no hand-written JSON needed. Key convention preserved: env
var name only, never plaintext (or empty = keyless local endpoint).
Delete auto-cleans dangling fallback refs; default profile via
api.default_model; save failure reported. 17 unit tests, 441 passed.
2026-08-18 03:26:53 +00:00
qing 4cb97318b5 docs: sync aibox audit batches G/H/I in root README and aibox README
- root README: aibox section notes four audit rounds (Batch E/F/G/H/I)
- aibox README: v3.5 changelog (Batch G/H/I), test suite count 424,
  test file listing and aibox.py line count refreshed
2026-08-18 03:15:10 +00:00
qing 31b2a4d6ca docs: batch I audit fixes record in aibox audit report
Batch I (remaining-item sweep): 10 fixes landed, 16 new tests
(test_i_fixes.py), 424 passed. Q7a / M4 / Q9 / F8 kept as-is with
rationales recorded.
2026-08-18 03:09:18 +00:00
qing db54921066 fix(aibox): batch I audit fixes - prompt write cap, light-subagent cancel propagation, atomic history writes
Remaining-item sweep (audit report §12):
- N9: cap prompt file writes (identity/soul/status, 10k chars) to bound
  persistent-injection surface
- F5: run_light_subagent switches to stream_chat + parent cancel_event
  (mid-stream interrupt); ExpressEmotionTool injects parent event
- Q4: busy_timeout=10s on mark_as_triggered (multi-process DB contention)
- Q5: malformed reminder_time tasks returned as due (were silently dropped
  by SQL-level string filter, which is now Python-side authoritative check)
- Q7b: load_conversation realpath prefix check (../ traversal defense)
- Q8: atomic JSON writes (tmp+os.replace) for index/current/archive
- F6: dedicated ws command executor for /new, /reload_prompts
- F9: --once hard-exit backstop after soft timeout
- PromptManager singleton lock; limit=0 semantics (None = no LIMIT)
2026-08-18 03:09:05 +00:00
qing 9fee9a1478 docs: batch H audit fixes record in aibox audit report
Fourth round (HEAD d1f780e): 3-way parallel review + main-reviewer
verification + Linux PoC 6/6; 13 fixes landed, 26 new tests
(test_h_fixes.py), 408 passed.
2026-08-18 02:33:34 +00:00
qing d3acccd146 fix(aibox): batch H audit fixes - subagent cancel propagation, scheduler timeout, exec process-group reaping
Fourth audit round (see docs/reports/aibox_audit_20260817.md §11):
- H1: shared parent cancel event no longer cleared on subagent first think
  (N5 regression); parent-already-cancelled skips subagent start
- M1: /reload_prompts moved off event loop (run_in_executor, N3 sibling)
- M2: skill --help scan prewarmed before serving connections
- M3: scheduler task-level timeout (scheduler.task_timeout, default 600s)
- M4: get_recent_memories(None) capped instead of full-table load
- M5: memo delete now physically removes rows (was complete-only)
- M6: PromptManager.reload builds new list then atomically swaps
- L1-L12: read_file arg coercion, proxy save failure reported, SSE
  utf-8 tolerance, persona realpath check, truncation char boundary,
  compressor div0, skill path quoting, ask_user required, POSIX
  process-group kill (pgid captured at Popen), web scheme whitelist,
  registry redact substring match, Bearer redaction in failover logs
2026-08-18 02:33:17 +00:00
qing d1f780e381 feat(aibox): verify SOCKS5 connectivity before saving proxy config
- set_proxy 先做完整 SOCKS5 握手 + generate_204 探测(trust_env=False,超时 4s/6s),验证失败不落盘并按类型归因提示(auth/timeout/connect/missing_dependency/probe_failed)

- 异常类型优先分类,消息关键词兜底:Windows 本地化错误消息不影响归因

- 新增 skip_check 逃生口(代理未启动的预配置场景);禁用代理不验证;password_env 指向空环境变量时提前精准提示

- 测试:+9 用例(mock 网络 + 真实异常类型),全量 383 passed 零回归
2026-08-18 07:44:17 +08:00
qing acb6ddbb4c docs: batch G audit fixes record in aibox audit report
Add round-3 audit (section 9): legacy item re-verification (17 items), v3.4 multi-model profile + L4 failover review (P1-P7), architecture compliance matrix. Add batch G fix record (section 10): 17 fixes across 5 waves, 20 new regression tests, full suite 375 passed.
2026-08-18 07:21:34 +08:00
qing cccc946660 fix(aibox): batch G audit fixes - llm hardening, thread lifecycle, cancel propagation, db transactions
P-series (llm_client): truncate non-transient HTTP error body to 200 chars, cap Retry-After at 3600s, validate fallback profiles (missing model skipped with warning), dedupe fallback list, remove dead _cancelled flag, document FailoverClient concurrency contract.

N-series (aibox/registry): batch-level drain thread with try/finally reclamation (N2), subagent cancel propagation via shared parent event + watcher thread (N5), /new reset moved off event loop (N3), streaming forward bounded by TOOL_MAX_RESULT_LENGTH (N8), persona path boundary check (N12), broadcast except narrowed + discard (N13), per-connection task set registration (N14).

db/history: FTS5 quote escaping (N7), mark_as_triggered single BEGIN IMMEDIATE transaction (N10), LIMIT parameterization (L1), archive filename collision suffix (N16).

Tests: +20 cases in test_g_fixes.py, full suite 375 passed.
2026-08-18 07:21:29 +08:00
qing aa15841d73 docs: sync aibox multi-model profile (fallback failover) in root README and ARCHITECTURE 2026-08-18 00:53:51 +08:00
qing 0246943165 feat(aibox): failover observability, timeout budget and Retry-After (L4c)
Observability: FailoverClient.get_stats() exposes aggregate counters
(requests / failovers / cooldown_skips / exhausted, snapshot copy);
new on_failover(from_name, to_name, reason) callback fires on every
failover decision — to_name=None marks chain exhaustion — for future
admin-panel/frontend wiring, wrapped so callback exceptions never
affect the request path.

Timeout budget: profile field "failover_timeout_budget" caps the total
seconds spent walking the chain (default unlimited; values <= 0 are
rejected to None with a warning). Once elapsed, remaining profiles are
skipped — prevents worst-case N x timeout stacking on longer chains.
Exhaustion is counted once per call via a single end-of-loop increment
(an initial double count at the budget branch was caught by the new
budget test and folded into the unified counter).

Retry-After: TransientLLMError now carries retry_after parsed from the
numeric Retry-After response header (non-numeric values like HTTP-dates
are ignored); FailoverClient._mark_fail uses it to extend that
profile's cooldown beyond the default 60s, respecting server-side
throttling on 429s. _fail_at entries became (fail_time, cooldown)
tuples; three existing tests that poke the internal map were updated.

Config: create_llm_client passes profile.failover_timeout_budget into
the FailoverClient constructor.

Tests: new TestL4cObservabilityAndBudget (13 cases: stats on
success/exhaustion/cooldown-skip, snapshot copy, hook called with
chain-tail None, hook exception safety, budget stops chain via mocked
monotonic, invalid budget ignored, config field plumbing, default
no-budget regression, Retry-After parse/ignore/extend-cooldown). Full
suite 355 passed.
2026-08-18 00:52:39 +08:00
qing d53eba77f0 feat(aibox): stream_chat first-event failover + cancel-flag guard (L4b)
FailoverClient.stream_chat now walks the chain but only fails over
while no event has been produced yet — replay before the first
reasoning/content/tool_call event is invisible to the user. Once any
event is out, failures propagate directly (replay would duplicate
frontend output and corrupt tool-call accumulation).

DeepSeekClient.stream_chat classifies non-200 responses: 429/5xx raise
TransientLLMError (failover-eligible), auth/param 4xx keep raising
RuntimeError. Chain exhaustion re-raises the last exception so the
existing rollback/error handling in think_and_respond catches it; an
all-cooling chain raises RuntimeError.

Cancel-flag guard: LLMClient.cancel() sets _cancelled because the
RequestException raised by force-closing the connection is
indistinguishable from a real network fault. FailoverClient.cancel()
is now defined explicitly (overriding __getattr__ delegation),
broadcasts to every client in the chain and sets the wrapper flag;
stream_chat/chat_completion reset it per call and suppress failover
when it is set — otherwise interrupting would make the system hop to
another vendor and keep generating against the user's intent.

Tests: new TestL4bStreamFailover (12 cases: failover before first
event on TransientLLMError/ConnectionError, no failover after first
event, cancel never fails over on both paths, cancel broadcast, chain
exhaustion, all-cooling, cooldown skip, success isolation, transient/
auth status classification). Full suite 342 passed.
2026-08-18 00:46:23 +08:00
qing 3d7f5ca3cb feat(aibox): chat_completion cross-profile failover via fallback chain (L4a)
When a profile declares "fallback": ["kimi", "local"], create_llm_client
now returns a FailoverClient wrapper holding the ordered chain. For the
idempotent non-streaming path (chat_completion — conversation
compression, light subagents):

- Fail over on transient errors only: HTTP 429/5xx (now raised as
  TransientLLMError by DeepSeekClient) and connection errors/timeouts
  (previously swallowed into None by chat_completion's catch-all, now
  re-raised). Auth/param 4xx failures keep returning None with no
  failover — config problems stay loud, consistent with the H1/H2
  error-early philosophy.
- Failed profiles enter a 60s in-process cooldown and are skipped
  until it expires; chain exhaustion returns None per the existing
  failure contract.
- Config structure errors fail at construction (non-list fallback,
  unknown profile name, self-reference — H2-style). Environmental
  problems (e.g. fallback profile missing its API key) skip that
  fallback with a warning instead of blocking the primary.
- Unspecified attributes delegate to the primary client via
  __getattr__ (name/model/api_key/cancel), so DeepSeekChat and
  set_client keep working unchanged. stream_chat still delegates to
  the primary directly; first-token failover is L4b.

create_llm_client refactored: single-profile construction extracted
into _construct_profile_client, shared by the main path and the
fallback chain (flat-api path untouched).

Tests: new TestL4aChatCompletionFailover (15 cases: chain build,
validation, 429/5xx/connection failover, 401 no-failover, exhaustion,
cooldown skip/expiry, missing-key skip with warning, transient raise,
4xx None regression, stream delegation); full suite 330 passed.
2026-08-18 00:42:57 +08:00
qing d29bb5504a feat(aibox): L-batch profile polish (display name, prefix escape, temperature, reasoning aliases)
L1 display name: client.name now resolves to the profile's
display_name field, falling back to the profile key — the dialogue
context no longer labels Kimi/Qwen sessions "DeepSeek". The legacy
flat api section keeps "DeepSeek" (zero breakage).
_resolve_model_profile now returns (profile, model, profile_name).

L2 prefix escape: "model:NAME" forces bare-model semantics even when
a profile happens to be named NAME; "profile:NAME" forces profile
semantics and raises a clear error (listing available profiles) when
missing, instead of silently falling back to bare-name behavior.
Empty prefixes are rejected. Wired through --model / create_llm_client
/ subagent model params.

L3 profile temperature: new profile field "temperature" becomes the
client's default_temperature. chat_completion/stream_chat treat
temperature=None as "use profile default, else the old hardcoded
0.3/0.7", so explicit caller values still win. DeepSeekChat and
run_light_subagent resolve CLI > profile > constants.DEFAULT_TEMPERATURE.

L5 reasoning field aliases: _parse_delta recognizes
reasoning_content / reasoning / thinking / thinking_content (first
non-empty wins), so non-DeepSeek vendors' thinking streams are no
longer silently dropped.

L4 (cross-profile failover) intentionally deferred: streaming
failover needs request-replay semantics and is feature-level work.

Tests: new TestLBatch (16 cases); 7 existing unpack sites updated to
the 3-tuple. Full suite 315 passed.
2026-08-18 00:06:23 +08:00
qing a583f44d5d refactor(aibox): make profile reasoning_enabled a constructor param (M3)
Profile-level reasoning_enabled was attached as an instance attribute
after construction only when a profile matched; the base class never
declared it and consumers probed with double getattr. Any code that
accidentally set client.reasoning_enabled would silently override the
global switch.

The flag is now an explicit LLMClient constructor parameter with
three-state semantics: True/False (profile-level explicit, or the
global api.reasoning_enabled value concretized when the profile omits
the field) and None (no profile -> consumer falls back to global).
create_llm_client passes it at construction and no longer mutates the
client afterwards; DeepSeekChat._reasoning_enabled simplifies to a
single getattr probe that still tolerates clients without the attr.

Tests: TestM3ReasoningConstructor (4 cases) covering profile True,
profile-omitted -> global concretized, base default None, and the
None -> global fallback at the consumer; one legacy assertion updated
to the new contract (attribute exists, value None). Full suite 299
passed.
2026-08-17 23:58:45 +08:00
qing 5f3ca9a246 fix(aibox): sync compressor and snapshots on client swap via set_client (M1)
run_full_subagent replaced sub_chat.client by direct assignment after
construction, leaving ConversationCompressor (bound to the original
client in DeepSeekChat.__init__) and the api_key/name snapshots
pointing at the default model — compression requests silently kept
using the default endpoint/model while the dialogue itself used the
requested one.

DeepSeekChat gains set_client(): swaps self.client and refreshes
api_key, name, and compressor.llm_client in one place. run_full_subagent
now routes model overrides through it.

Tests: new TestM1SetClient (2 cases: full reference refresh on a real
DeepSeekChat instance, and run_full_subagent calling set_client with
the new client); full suite 295 passed.
2026-08-17 23:48:40 +08:00
qing 77262f8c3a fix(aibox): inject available model profile names into create_subagent (M2)
The LLM had no way to know which profile names exist in the models
config section; a hallucinated name fell through to bare-name semantics
(default endpoint + bogus model) and failed at request time with API
400.

SubAgentTool now builds the model hint dynamically from config via
_model_names_hint(): description and the model parameter's JSON Schema
description both list the profile names, with the default profile
(api.default_model) marked. Empty or malformed models sections keep
the original wording untouched (zero breakage); exceptions degrade to
no hint.

README: tool table note, test count 293, new v3.4 changelog entry
summarizing the H1/H2/H3/M2 profile-hardening batch.

Tests: new TestM2SubAgentModelHint (5 cases); full suite 293 passed.
2026-08-17 23:42:04 +08:00
qing 84a3f6dd5e fix(aibox): make --model effective in WebSocket server mode (H3)
--model was accepted by parse_args but only consumed inside
handle_once_mode, so `python aibox.py --model kimi` (server mode)
silently ignored the override.

The model now flows as a process-wide override:
main() -> EnhancedAIManager(model=...) -> DeepSeekChat(model=...) in
all three AI creation paths: add_ai (main dialogue),
WebSocketHandler._create_session_ai (per-connection sessions), and
TaskScheduler._create_task_ai (scheduled tasks). Manager logs
"CLI 模型覆盖已启用" at startup for observability. --once mode
plumbing is unchanged; --model=None keeps config-driven behavior.

Tests: new TestH3ModelPlumbing (6 cases) mocking DeepSeekChat to
assert the constructor kwarg across manager/session/task paths plus
parse_args; full suite 288 passed.
2026-08-17 23:37:59 +08:00
qing 0e9efe4895 fix(aibox): validate model profile schema at config source (H2)
Profiles previously had no validation, so config errors surfaced far
from their origin and cryptically:
- explicit profile missing `model` fell back to the profile NAME as the
  model (a profile named "local" would send "model": "local")
- default profile missing `model` with no model param produced
  "model": null -> API 400
- missing `api_url` surfaced as requests InvalidURL

_resolve_model_profile now runs _validate_profile (dict type + api_url)
before reading fields and _require_model on the effective model,
raising ValueErrors that name the profile, the missing field, and the
config.json path to fix. Bare model names still legally substitute for
a default profile's missing model field.

Tests: new TestH2SchemaValidation (6 cases: missing model/api_url,
null-model via default profile, bare-name substitution, non-dict
profile, e2e create_llm_client); full suite 282 passed.
2026-08-17 23:31:06 +08:00
qing 4480bce684 fix(aibox): never fall back to deepseek.key for model profile endpoints (H1)
~/.cert/deepseek.key is a DeepSeek-only credential; when a models profile
specified its own api_key_env (e.g. MOONSHOT_API_KEY) but the env var was
unset, resolve_api_key silently read that file and sent the DeepSeek key
as Bearer to a third-party endpoint (cross-vendor credential leak).

resolve_api_key gains allow_key_file (default True for the legacy flat
api section); create_llm_client passes allow_key_file=profile is None so
any profile path raises a clear RuntimeError instead, pointing users to
set the env var or use "" for local keyless endpoints.

Tests: new TestH1KeyIsolation (4 cases) with a mocked readable
deepseek.key proving the profile path errors out while the flat api
section still reads the file; full suite 276 passed.
2026-08-17 23:28:25 +08:00
qing 58725c8a12 docs: sync async inference loop into module docs
pybot --loop HEF 后端异步闭环(执行上一步动作与 NPU 推理并行)与 actpred predict_async 流水线落地后未同步文档,本次补齐:

- pybot/README:--loop 补异步推理路径说明(滞后一拍,~16ms→~12ms)

- actpred/README:Hailo 节新增 predict_async 小节(缓冲语义/2.6× 实测)

- 根 README、MODULES.md、ARCHITECTURE.md:pybot 行与机器人数据链同步

- MODULES.md:docs md 计数 70→76;ARCHITECTURE §9 挂 panel 设计稿规划项
2026-08-17 22:56:30 +08:00
qing 90f5544673 docs(aibox): changelog F2 (registry timeout cleanup + reader finalization)
Batch F bullet now split into F1/F2 entries; F2 covers unified
timeout-path cleanup of tool streaming attributes and reader-thread
finalization with the CPython stream-lock correction; test count 272.
2026-08-17 22:49:45 +08:00
qing 5bcf22f9a7 fix(aibox): registry timeout cleanup + reader thread finalization (F2/N6+N4)
N6: both streaming-forward timeout paths (deadline hit, join(5)
expiry) previously returned early and skipped the
tool.streaming_output / tool.output_queue cleanup, leaving the daemon
thread writing through the instance attribute — zombie output could
leak into the next session's queue once the attribute got reassigned.
Now unified through a timed_out flag with cleanup at the single exit;
stream_tool_timeout becomes an instance attribute (3600s default,
test-tunable).

N4: reader-thread finalization after timeout/grace break. First
implementation followed the audit suggestion (close stdout + join),
but PoC regression proved close() blocks: CPython stream objects hold
an internal lock, so closing while readline is blocked parks until
readline returns (PoC-1 regressed 3s -> 15.2s, exactly the
Start-Sleep duration). Corrected strategy: when reader already got
EOF, close stdout to release the pipe fd; when reader is still
blocked, only join(timeout=2) then give up (daemon-bounded, thread
ends naturally when the grandchild exits and EOF arrives), keeping
the main flow prompt. Audit report updated with this correction.

Tests: new tests/test_f2_fixes.py (5 cases), PoC regression 5/5
(PoC-1/4 back to ~5s), full suite 272 passed.
2026-08-17 22:47:37 +08:00
qing 6ef5d11d9d docs(aibox): sync README with Batch E/F security fixes
- feature list & tool table: set_proxy password semantics (memory-only,
  password_env for persistence)
- config example: fix stale flat proxy section to actual proxy.socks5
  structure with password_env; add plaintext-not-persisted note
- changelog: new v3.3 section covering Batch E (command exec protection
  chain) and Batch F (credential protection), test count 267
2026-08-17 22:41:24 +08:00
qing eb88a77238 fix(aibox): never persist SOCKS5 proxy password to disk (F1/N1, +N11)
- config.set_proxy gains password_env (aligned with api_key_env
  convention): config file persists only the env var name; plaintext
  password lives in memory for the current session only
- _save_config deep-copies and nulls proxy.socks5.password before
  json.dump, so every save path is sanitized (defense in depth)
- get_proxy resolves password as in-memory plaintext first, then via
  password_env from os.environ; downstream consumers (llm_client,
  web tool curl args) unchanged
- legacy migration on load: plaintext found on disk is kept in memory
  for the session, file is immediately rewritten sanitized
- SetProxyTool exposes password_env and reports password source
- registry debug logs mask password/api_key/token/secret/authorization
  kwargs (N11)

Tests: new tests/test_config.py (3 cases: no plaintext on save,
password_env resolution, legacy migration); full suite 267 passed.
Audit report updated with Batch F progress.
2026-08-17 22:37:30 +08:00
qing a833cd0d5f fix(memdb): expire semantics + Boost 1.83 portability + backlog & test harness
- expire 改为 update+refresh_ttl:键不存在返回 false,与 del/update 约定一致
- _require_string 适配 Boost 1.83(boost::json::string 已非 std::string)
- 新增 --backlog(默认 128,libmsg 默认 10 短连接突刺易丢连接)
- Makefile 补 HOST/PORT 默认值,test-memdb 用 PID 文件替代失效的 kill %1
- 补测试:复制构造/源销毁后副本有效/refresh 延长 TTL/expire 不存在键
2026-08-17 14:29:41 +00:00
qing 207137c0dd fix(libmemdb): deep-copy semantics so indices point into the copy's own list
默认复制只拷贝容器,keys/time_touch 中的迭代器仍指向源对象的 list 节点,
源销毁或改动后副本悬垂(UB)。改为保留时间戳的深拷贝构造 + copy-and-swap 赋值。
2026-08-17 14:29:39 +00:00
qing b2ecb8e12b fix(libmsg): close fd-reuse race silently killing short-lived connections
worker 收 EOF 失败后过早清除 busy 标记,事件循环对同一 fd 重复投递任务,
迟到的失败标记经 fd 复用落在新连接上被 _reap_failed 误杀(客户端收到空 EOF)。
失败路径改由 _reap_failed 统一清除 busy 并关闭;_handle_client 增加防重复投递守卫。
memdb 短连接压力测试错误率从 5-9% 降至 0。
2026-08-17 14:29:37 +00:00
qing 910ab8aa38 feat(pybot): 闭环异步推理——执行上一步动作与 NPU 推理并行
run_loop 自动检测 predictor.predict_async 走异步路径:提交推理后并行
执行上一步动作(滞后一拍,10Hz 下 100ms),隐藏真实 I2C 执行耗时;
同步路径不变。pybot 97 测试全过(新增异步路径 + 滞后一拍执行验证)。
2026-08-17 21:59:25 +08:00
qing 8de7f6696c feat(actpred): HailoPredictor 异步推理接口——run_async 流水线 2.6× 吞吐
新增 predict_async()(4 缓冲池 + 完成回调 + _AsyncJob.result),与同步
predict 语义/输出完全一致(重构公共 _prepare_inputs/_fill_bindings/
_postprocess)。真实 HEF 实测:连续吞吐 6.0ms vs 同步 15.5ms;扩展测试
10 seed 随机/60 任务乱序对应/混合调用/缓冲压力/300 次稳定性/4 线程并发
全部 0 差异,190 单测 OK。
2026-08-17 21:46:26 +08:00
qing 1348660cd8 docs(aibox): second-round audit re-review at HEAD 177a9ac
- Batch E fixes re-verified: H1/H3/M1 solid, H2 solid but with two
  residuals (reader thread leak, subagent timeout blind spot), M2
  incomplete (timeout paths skip tool attribute cleanup)
- Legacy M3/M4/L1/L3 confirmed unfixed; L1 found second instance at
  db.py:459 (get_immediate_memos)
- New findings: 1 high (SOCKS5 proxy password persisted in plaintext),
  9 medium (drain thread leak, event-loop blocking RLock, FTS5 keyword
  injection, streaming bypass of result length cap, persistent prompt
  injection storage, TOCTOU on mark_as_triggered, etc.), 7 low
- Updated compliance matrix and proposed Batch F fix plan (F1-F6)
2026-08-17 17:51:43 +08:00
qing 177a9ac9a6 fix(aibox): close audit findings H1-H3/M1-M2 (command exec protection chain)
- H1: CommandLineTool.execute_streaming now purely delegates to injected
  executor; removes 150-line duplicate Popen loop that bypassed 1MB cap,
  cancel() and timeout protections
- H2: thread the blocking readline into a daemon reader thread; main loop
  polls queue with 0.1s ticks so timeout/1MB checks fire even for silent
  commands (verified: 3s timeout on 15s sleep, was 15s+ before)
- H3: stdin=DEVNULL on both Popen sites; stdin-waiting commands now hit
  EOF instead of deadlocking the tool thread forever
- M1: heredoc delimiter uniquification in write_file guidance (content
  line equal to AIBOX_EOF no longer truncates into shell injection)
- M2: 3600s total deadline on registry streaming forward loop

Verification: PoC regression 5/5 PASS (timeout/1MB/cancel/delegation),
full unit suite 264 passed. Audit report updated with Batch E results.
2026-08-17 17:28:41 +08:00
qing ce142fe32f docs(aibox): sync once-mode docs (MODULES/ARCHITECTURE/dev-doc)
- MODULES.md/ARCHITECTURE.md:aibox 条目补 --once CLI 与 models 多模型档案(三处同步惯例)
- 开发文档新增第 7 节实施状态:四批提交映射、与计划的 4 处偏差(--once-timeout 改名/models 默认空/worker-only 精简 prompt/退出码 1 边界)、验证结果
- 原 7/8 节顺延为 8/9
2026-08-17 17:12:15 +08:00
qing d03dbc7f91 feat(aibox): batch D multi-model profiles + docs
- models 配置节:多 OpenAI Chat Completions 兼容端点按档案名切换,默认空 dict 零破坏回退旧 api 节
- --model 双语义:档案名(命中 models 节完整采用)/ 裸模型名(同端点换模型)
- resolve_api_key 档案级 env 三级语义:未配置回退 api.api_key_env / 显式名称 / 空串免密(Ollama/vLLM)
- 档案级 reasoning_enabled 附加 client 属性,DeepSeekChat._reasoning_enabled 优先读取
- llm_client 增加 openai_compatible/openai provider 别名
- README 更新:aibox/README 六处(特性/配置/多模型档案专节/CLI 参数/更新日志 v3.2/测试计数 264)+ 根 README aibox 小节
- 单测 17 项新增(test_models_profile.py),全套 264 项全过
2026-08-17 16:58:33 +08:00
shufeng2012 d3a672a2a8 ✨ 添加代码块语言显示 2026-08-15 20:46:44 +08:00
48 changed files with 6001 additions and 648 deletions

No files matched your search

+4 -3
View File
@@ -78,7 +78,7 @@
| [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 | | [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 |
| [BotOps](BotOps/) | Python | — | aibox 智能体 TUI 前端(Textual),WebSocket 连接 aibox,支持 Markdown 增量流式渲染、思考模式展示、任务独立侧栏、自动重连 | | [BotOps](BotOps/) | Python | — | aibox 智能体 TUI 前端(Textual),WebSocket 连接 aibox,支持 Markdown 增量流式渲染、思考模式展示、任务独立侧栏、自动重连 |
| [bot4cj](bot4cj/) | 仓颉(Cangjie) | — | 机器人硬件脚本解析器,解析 .mt 矩阵脚本通过 I2C 驱动 PCA9685 舵机 / MPU6050 陀螺仪,面向树莓派 Dashun 蜘蛛机器人平台(独立硬件项目) | | [bot4cj](bot4cj/) | 仓颉(Cangjie) | — | 机器人硬件脚本解析器,解析 .mt 矩阵脚本通过 I2C 驱动 PCA9685 舵机 / MPU6050 陀螺仪,面向树莓派 Dashun 蜘蛛机器人平台(独立硬件项目) |
| [pybot](pybot/) | Python | — | bot4cj 的 Python 迁移版:.mt 动作脚本解析器(零第三方依赖,与仓颉版逐语义对齐);支持执行 / `--dry-run` / `--record` 录制 actpred episode(含 imu.npy)/ `--loop` 实时预测闭环 | | [pybot](pybot/) | Python | — | bot4cj 的 Python 迁移版:.mt 动作脚本解析器(零第三方依赖,与仓颉版逐语义对齐);支持执行 / `--dry-run` / `--record` 录制 actpred episode(含 imu.npy)/ `--loop` 实时预测闭环(HEF 后端自动异步,推理与执行并行) |
| [mtc](mtc/) | Python | — | 命令式动作脚本(Python DSL)→ .mt 编译器:非阻塞时间轴 + 10ms 栅格展开,输出可由 pybot/bot4cj 直接执行;零第三方依赖 | | [mtc](mtc/) | Python | — | 命令式动作脚本(Python DSL)→ .mt 编译器:非阻塞时间轴 + 10ms 栅格展开,输出可由 pybot/bot4cj 直接执行;零第三方依赖 |
| [actpred](actpred/) | Python(PyTorch) | — | 动作预测模型(图像 + 动作历史 → 下一动作),bot4cj/pybot 的「大脑」配套;两阶段训练(.mt 预训练 → episode 微调),ONNX/Hailo HEF 部署(实验) | | [actpred](actpred/) | Python(PyTorch) | — | 动作预测模型(图像 + 动作历史 → 下一动作),bot4cj/pybot 的「大脑」配套;两阶段训练(.mt 预训练 → episode 微调),ONNX/Hailo HEF 部署(实验) |
| [lci](lci/) | C++ | — | 嵌入式帧缓冲图形界面(LCI),基于 `/dev/fb0` 直接操作显存,无需 X11/Wayland,矢量字体、图片渲染、实时数据可视化,面向树莓派等嵌入式环境 | | [lci](lci/) | C++ | — | 嵌入式帧缓冲图形界面(LCI),基于 `/dev/fb0` 直接操作显存,无需 X11/Wayland,矢量字体、图片渲染、实时数据可视化,面向树莓派等嵌入式环境 |
@@ -235,7 +235,8 @@
- 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs2 等主干微服务无调用关系。 - 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs2 等主干微服务无调用关系。
- 自带技能系统(`skills/`)、任务调度器、记忆管理、前端页面与语音客户端(`voice_client.py`)。 - 自带技能系统(`skills/`)、任务调度器、记忆管理、前端页面与语音客户端(`voice_client.py`)。
- 默认模型 `deepseek-v4-flash`,配置在 `config.json`;详见 [aibox/README.md](aibox/README.md)。 - 默认模型 `deepseek-v4-flash`,配置在 `config.json`;`models` 配置节可挂多个 OpenAI 兼容端点按档案名切换(档案级 reasoning/温度/展示名/`fallback` 失败转移),`--once` 支持无 WebSocket 的 CLI 一次性对话;详见 [aibox/README.md](aibox/README.md)。
- **四轮安全审计修复(2026-08,Batch E/F/G/H/I 共 60+ 项)**:命令执行保护链(1MB/超时/中断)、凭据脱敏(代理密码不落盘、日志打码、Bearer 脱敏)、取消传播(子代理/情绪子代理/调度器超时)、DB 事务与 FTS5 转义、POSIX 进程组终止等;审计报告见 [docs/reports/aibox_audit_20260817.md](docs/reports/aibox_audit_20260817.md)。
### BotOps(aibox 智能体 TUI 前端) ### BotOps(aibox 智能体 TUI 前端)
@@ -256,7 +257,7 @@
### pybot(机器人动作脚本解析器 · Python 迁移版) ### pybot(机器人动作脚本解析器 · Python 迁移版)
- [bot4cj](#bot4cj机器人硬件脚本解析器) 的 Python 迁移版,零第三方依赖(纯标准库),与仓颉版逐语义对齐(含占位符 `-` 递归继承、微步分解、块序依赖)。 - [bot4cj](#bot4cj机器人硬件脚本解析器) 的 Python 迁移版,零第三方依赖(纯标准库),与仓颉版逐语义对齐(含占位符 `-` 递归继承、微步分解、块序依赖)。
- 三种运行模式:脚本执行 `run()`、`--record` 录制 actpred episode(images + actions.npy + imu.npy,≤4 个 MPU6050 按块行序拼接)、`--loop` 实时预测闭环(daemon 线程队列非阻塞取帧,overrun 告警)。 - 三种运行模式:脚本执行 `run()`、`--record` 录制 actpred episode(images + actions.npy + imu.npy,≤4 个 MPU6050 按块行序拼接)、`--loop` 实时预测闭环(daemon 线程队列非阻塞取帧,overrun 告警;HEF 后端自动走异步推理——执行上一步动作与 NPU 推理并行,滞后一拍)。
- 扬声器静音语义:`frequency <= 0` 不出声仅保持节拍(对齐仓颉版 sleep 占位语义)。 - 扬声器静音语义:`frequency <= 0` 不出声仅保持节拍(对齐仓颉版 sleep 占位语义)。
- Windows 开发机可跑测试(fcntl mock + 符号链接 fallback);详见 [pybot/README.md](pybot/README.md)。 - Windows 开发机可跑测试(fcntl mock + 符号链接 fallback);详见 [pybot/README.md](pybot/README.md)。
+14
View File
@@ -311,3 +311,17 @@ python3 dfc_compile.py # 产物 checkpoints/actpred_pretrain.hef
`--pred-token` 喂入学习值、`--stats` 喂入归一化统计、`--imu` 喂入姿态。 `--pred-token` 喂入学习值、`--stats` 喂入归一化统计、`--imu` 喂入姿态。
环境调研与 API 踩坑记录见 环境调研与 API 踩坑记录见
[docs/reports/actpred_hailo_hat2_inference_research_20260813.md](../docs/reports/actpred_hailo_hat2_inference_research_20260813.md)。 [docs/reports/actpred_hailo_hat2_inference_research_20260813.md](../docs/reports/actpred_hailo_hat2_inference_research_20260813.md)。
### 异步推理(`predict_async`,run_async 流水线)
`HailoPredictor.predict_async(images, action_history, pred_token=None, imu=None)`
提交异步推理并立即返回 `_AsyncJob`,需要结果时 `job.result(timeout)` 阻塞获取:
- 与同步 `predict` 语义/输出完全一致(公共 `_prepare_inputs/_fill_bindings/_postprocess`)
- 内部维护 4 个绑定缓冲池 + 完成回调;在途任务超过缓冲数时提交会阻塞等待
空闲缓冲,`job.result` 超时**不会**释放缓冲(缓冲由完成回调释放)
- 连续提交(≥2 个在途)时 NPU 保持满负荷:实测连续吞吐 6.0ms vs 同步
15.5ms(约 2.6×);扩展测试覆盖乱序取结果/混合调用/缓冲压力/300 次
稳定性/4 线程并发,输出与同步 0 差异
- pybot `--loop` 检测到 `predict_async` 即自动走异步闭环(推理期间并行
执行上一步动作),见 [pybot/README.md](../pybot/README.md)
+130 -29
View File
@@ -31,6 +31,8 @@ import contextlib
import json import json
import os import os
import re import re
import threading
import time
import numpy as np import numpy as np
from PIL import Image from PIL import Image
@@ -51,6 +53,30 @@ from bins import TOTAL_BINS, decode as bin_decode, fixed_zero_columns
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
# 异步推理缓冲数:≥4 时流水线吞吐趋于饱和(实测 4 缓冲 ~2.7×、6 缓冲 ~2.9×)
_ASYNC_BUFFERS = 4
class _AsyncJob:
"""predict_async 的推理句柄:result() 阻塞返回 (action_dim,) 动作向量"""
def __init__(self):
self._done = threading.Event()
self._result = None
self._error = None
def result(self, timeout: float = None) -> np.ndarray:
if not self._done.wait(timeout):
raise TimeoutError("async HEF inference timeout")
if self._error is not None:
raise self._error
return self._result
def _complete(self, result=None, error=None):
self._result = result
self._error = error
self._done.set()
def _input_layer_index(name: str) -> int: def _input_layer_index(name: str) -> int:
m = re.search(r"input_layer(\d+)$", name) m = re.search(r"input_layer(\d+)$", name)
@@ -223,6 +249,11 @@ class HailoPredictor:
for f in self._frame_infos): for f in self._frame_infos):
self._frame_luts = self._build_frame_luts() self._frame_luts = self._build_frame_luts()
# 异步推理缓冲池(predict_async 复用)
self._async_buffers = [self._configured.create_bindings()
for _ in range(_ASYNC_BUFFERS)]
self._async_flags = [[True] for _ in range(_ASYNC_BUFFERS)]
h_shape = tuple(self._history_info.shape) h_shape = tuple(self._history_info.shape)
self.history_len, self.action_dim = h_shape[1], h_shape[2] self.history_len, self.action_dim = h_shape[1], h_shape[2]
@@ -258,16 +289,8 @@ class HailoPredictor:
# ---------------- 推理 ---------------- # ---------------- 推理 ----------------
def predict(self, images, action_history: np.ndarray, def _prepare_inputs(self, images, action_history):
pred_token: np.ndarray = None, """输入校验 + 动作历史归一化(predict/predict_async 共用)"""
imu: np.ndarray = None) -> np.ndarray:
"""images: 长度为 frame_len 的 RGB 图像列表(旧→新,末帧为当前帧)
imu: 可选原始姿态向量(roll,pitch,ax,ay,az,gx,gy,gz,HEF 含 imu
输入时生效,内部按物理量程归一化;None 等价零向量)
返回 (action_dim,) float32 动作向量。pred_token 仅新版 5 输入 HEF
需要;默认喂零,抖动校准的新 HEF 建议喂 checkpoint 学习值。
"""
if not isinstance(images, (list, tuple)): if not isinstance(images, (list, tuple)):
images = [images] images = [images]
if len(images) != self.frame_len: if len(images) != self.frame_len:
@@ -285,9 +308,12 @@ class HailoPredictor:
mode=self._range_mode) mode=self._range_mode)
elif self._action_mean is not None: elif self._action_mean is not None:
action_history = (action_history - self._action_mean) / self._action_std action_history = (action_history - self._action_mean) / self._action_std
return images, action_history
def _fill_bindings(self, bindings, images, action_history,
pred_token=None, imu=None):
"""按 HEF 布局把(已归一化的)输入填充到 bindings 缓冲"""
from hailo_platform import FormatType from hailo_platform import FormatType
bindings = self._configured.create_bindings()
if self._frame_luts is not None: if self._frame_luts is not None:
# cv2.LUT 快路径:逐帧独立输入,直接绑定量化缓冲 # cv2.LUT 快路径:逐帧独立输入,直接绑定量化缓冲
@@ -338,15 +364,16 @@ class HailoPredictor:
out_buf = np.zeros(out_shape, dtype=np.float32) out_buf = np.zeros(out_shape, dtype=np.float32)
bindings.output(self._out_info.name).set_buffer(out_buf) bindings.output(self._out_info.name).set_buffer(out_buf)
self._configured.run([bindings], timeout=10000) def _postprocess(self, raw_result, history_tail=None) -> np.ndarray:
"""反量化 + 解码/反归一化 → (action_dim,)(predict/predict_async 共用)
result = np.asarray( history_tail: 归一化空间的历史末步(predict_delta 残差模式用)。
bindings.output(self._out_info.name).get_buffer(), """
dtype=np.float32) from hailo_platform import FormatType
if self._out_info.format.type == FormatType.UINT8: if self._out_info.format.type == FormatType.UINT8:
q = self._out_info.quant_info q = self._out_info.quant_info
result = (result - q.qp_zp) * q.qp_scale raw_result = (raw_result - q.qp_zp) * q.qp_scale
result = result.reshape(-1) result = raw_result.reshape(-1)
if self._onehot: if self._onehot:
# 多头 logits → 期望值解码 → 原始量纲(箱生活在原始空间, # 多头 logits → 期望值解码 → 原始量纲(箱生活在原始空间,
# 无需反归一化);权重 0 列固定为常量(与 predict.py 对齐), # 无需反归一化);权重 0 列固定为常量(与 predict.py 对齐),
@@ -360,23 +387,97 @@ class HailoPredictor:
fixed = fixed_zero_columns(w, self.action_dim) fixed = fixed_zero_columns(w, self.action_dim)
result[w[:self.action_dim] <= 0.0] = ( result[w[:self.action_dim] <= 0.0] = (
fixed[w[:self.action_dim] <= 0.0]) fixed[w[:self.action_dim] <= 0.0])
if not np.all(np.isfinite(result)): else:
print("WARNING: HEF output contains NaN/Inf, replacing with zeros") if self._predict_delta:
result = np.nan_to_num(result, nan=0.0, posinf=0.0, neginf=0.0) # 残差模式:Δ̂ + a_{t-1}(归一化空间),与 predict.py 对齐
return result result = result + history_tail
if self._predict_delta: if self._range_mode:
# 残差模式:Δ̂ + a_{t-1}(归一化空间),与 predict.py 对齐 result = denormalize_action(result, self.action_dim,
result = result + action_history[-1] mode=self._range_mode)
if self._range_mode: elif self._action_mean is not None:
result = denormalize_action(result, self.action_dim, result = result * self._action_std + self._action_mean
mode=self._range_mode)
elif self._action_mean is not None:
result = result * self._action_std + self._action_mean
if not np.all(np.isfinite(result)): if not np.all(np.isfinite(result)):
print("WARNING: HEF output contains NaN/Inf, replacing with zeros") print("WARNING: HEF output contains NaN/Inf, replacing with zeros")
result = np.nan_to_num(result, nan=0.0, posinf=0.0, neginf=0.0) result = np.nan_to_num(result, nan=0.0, posinf=0.0, neginf=0.0)
return result return result
def predict(self, images, action_history: np.ndarray,
pred_token: np.ndarray = None,
imu: np.ndarray = None) -> np.ndarray:
"""images: 长度为 frame_len 的 RGB 图像列表(旧→新,末帧为当前帧)
imu: 可选原始姿态向量(roll,pitch,ax,ay,az,gx,gy,gz,HEF 含 imu
输入时生效,内部按物理量程归一化;None 等价零向量)
返回 (action_dim,) float32 动作向量。pred_token 仅新版 5 输入 HEF
需要;默认喂零,抖动校准的新 HEF 建议喂 checkpoint 学习值。
"""
images, action_history = self._prepare_inputs(images, action_history)
bindings = self._configured.create_bindings()
self._fill_bindings(bindings, images, action_history,
pred_token=pred_token, imu=imu)
self._configured.run([bindings], timeout=10000)
raw = np.asarray(
bindings.output(self._out_info.name).get_buffer(),
dtype=np.float32)
return self._postprocess(raw, action_history[-1])
# ---------------- 异步推理 ----------------
def predict_async(self, images, action_history: np.ndarray,
pred_token: np.ndarray = None,
imu: np.ndarray = None) -> "_AsyncJob":
"""提交异步推理(run_async 流水线,多缓冲复用)
与 predict() 语义/输出完全一致,但立即返回 job;调用方可在
NPU 推理期间并行做其他工作(如抓帧/执行上一步动作),需要结果
时 job.result() 阻塞获取。连续提交时(≥2 个在途)NPU 保持满负荷,
实测吞吐可达同步 predict 的 ~2.9×。
注意:绑定缓冲有限(_ASYNC_BUFFERS 个),在途任务超过缓冲数时
本调用会阻塞等待空闲缓冲;job.result(timeout) 超时不会释放缓冲,
缓冲由完成回调释放。
"""
images, action_history = self._prepare_inputs(images, action_history)
history_tail = action_history[-1]
idx = self._acquire_async_buffer()
bindings = self._async_buffers[idx]
job = _AsyncJob()
try:
self._fill_bindings(bindings, images, action_history,
pred_token=pred_token, imu=imu)
except Exception:
self._release_async_buffer(idx)
raise
def cb(completion_info):
try:
exc = getattr(completion_info, "exception", None)
if exc:
raise exc
raw = np.asarray(
bindings.output(self._out_info.name).get_buffer(),
dtype=np.float32)
job._complete(result=self._postprocess(raw, history_tail))
except Exception as e: # noqa: BLE001 —— 回调内兜底
job._complete(error=e)
finally:
self._release_async_buffer(idx)
self._configured.run_async([bindings], callback=cb)
return job
def _acquire_async_buffer(self) -> int:
"""取一个空闲绑定缓冲(在途任务超限时阻塞等待)"""
while True:
for i in range(len(self._async_buffers)):
if self._async_flags[i][0]:
self._async_flags[i][0] = False
return i
time.sleep(0.0001)
def _release_async_buffer(self, idx: int):
self._async_flags[idx][0] = True
def _frame_float(self, image) -> np.ndarray: def _frame_float(self, image) -> np.ndarray:
"""Resize → float32 HWC;归一化 HEF(负零偏)再走 ImageNet 归一化""" """Resize → float32 HWC;归一化 HEF(负零偏)再走 ImageNet 归一化"""
img = Image.fromarray(image) if isinstance(image, np.ndarray) else image img = Image.fromarray(image) if isinstance(image, np.ndarray) else image
+41
View File
@@ -1279,6 +1279,13 @@ class _FakeConfigured:
for b in bindings_list: for b in bindings_list:
self._transform(b) self._transform(b)
def run_async(self, bindings_list, callback=None):
# 模拟异步:同步完成 transform 后触发回调(completion_info 无异常)
for b in bindings_list:
self._transform(b)
if callback is not None:
callback(completion_info=types.SimpleNamespace(exception=None))
class _FakeInferModel: class _FakeInferModel:
def __init__(self, transform): def __init__(self, transform):
@@ -1582,6 +1589,40 @@ class TestHailoPredictor(unittest.TestCase):
result = p.predict(self._frames(2), history) result = p.predict(self._frames(2), history)
np.testing.assert_allclose(result, [3, 3, 3], atol=1e-6) np.testing.assert_allclose(result, [3, 3, 3], atol=1e-6)
def test_predict_async_matches_predict(self):
from hailo_infer import HailoPredictor
infos_in = [
_FakeVStreamInfo("net/images", (1, 2, 3, 4, 4), _FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/history", (1, 2, 3), _FakeFormatType.FLOAT32),
]
infos_out = [_FakeVStreamInfo("net/action", (1, 3), _FakeFormatType.FLOAT32)]
_install_fake_hailo(self, infos_in, infos_out,
_first_row_transform("net/history", "net/action"))
history = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)
with HailoPredictor("fake.hef") as p:
sync = p.predict(self._frames(2), history)
job = p.predict_async(self._frames(2), history)
async_result = job.result(timeout=5.0)
np.testing.assert_allclose(async_result, sync, atol=1e-6)
def test_predict_async_buffer_pool_roundtrip(self):
"""连续提交超过缓冲池数量的任务,输出互不串扰"""
from hailo_infer import HailoPredictor
infos_in = [
_FakeVStreamInfo("net/images", (1, 2, 3, 4, 4), _FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/history", (1, 2, 3), _FakeFormatType.FLOAT32),
]
infos_out = [_FakeVStreamInfo("net/action", (1, 3), _FakeFormatType.FLOAT32)]
_install_fake_hailo(self, infos_in, infos_out,
_first_row_transform("net/history", "net/action"))
with HailoPredictor("fake.hef") as p:
jobs = []
for _ in range(6): # 超过 _ASYNC_BUFFERS=4
jobs.append(p.predict_async(self._frames(2), [[1, 2, 3]] * 2))
for job in jobs:
np.testing.assert_allclose(job.result(timeout=5.0), [1, 2, 3],
atol=1e-6)
class TestDfcCompile(unittest.TestCase): class TestDfcCompile(unittest.TestCase):
"""dfc_compile.py 的 HAR 输入层连通性检查(编译前拦截输入断连)""" """dfc_compile.py 的 HAR 输入层连通性检查(编译前拦截输入断连)"""
+188 -15
View File
@@ -17,12 +17,14 @@
- 🧬 **智能体自我进化**:通过 `update_soul` / `update_identity` 工具动态更新角色定位与用户画像 - 🧬 **智能体自我进化**:通过 `update_soul` / `update_identity` 工具动态更新角色定位与用户画像
- 🤖 **子代理**:通过 `create_subagent` 工具派生出拥有独立上下文/角色设定的轻量子代理,主代理可分解长任务、隔离上下文,子代理可选用不同模型 - 🤖 **子代理**:通过 `create_subagent` 工具派生出拥有独立上下文/角色设定的轻量子代理,主代理可分解长任务、隔离上下文,子代理可选用不同模型
- 🔄 **技能热重载**:无需重启即可添加、修改或删除扩展技能 - 🔄 **技能热重载**:无需重启即可添加、修改或删除扩展技能
- 🔐 **SOCKS5 代理**:通过 `set_proxy` / `get_proxy` 工具为网页访问与 API 调用配置代理,可持久化 - 🔐 **SOCKS5 代理**:通过 `set_proxy` / `get_proxy` 工具为网页访问与 API 调用配置代理,可持久化;密码明文仅存内存不落盘,跨重启经 `password_env` 环境变量注入(对齐 `api_key_env` 惯例)
- 💾 **对话管理**:历史对话归档、开始新对话(重置 AI 记忆并重新加载提示词) - 💾 **对话管理**:历史对话归档、开始新对话(重置 AI 记忆并重新加载提示词)
- 📦 **对话压缩**:消息数或 Token 数达到阈值时自动压缩,保持上下文质量 - 📦 **对话压缩**:消息数或 Token 数达到阈值时自动压缩,保持上下文质量
- 🛡️ **内存安全防护**:文件读取按需加载不全载、命令输出 1MB 限制、单行超长截断、工具结果多层防护防止 OOM - 🛡️ **内存安全防护**:文件读取按需加载不全载、命令输出 1MB 限制、单行超长截断、工具结果多层防护防止 OOM
- 🔒 **错误自动恢复**:API 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换) - 🔒 **错误自动恢复**:API 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换)
- 🛑 **可靠中断机制**:支持 Ctrl+C 实时中断,通过 `threading.Event` + HTTP 连接关闭 + 子进程 kill 三层取消,流式响应和长时间运行命令均可立即终止 - 🛑 **可靠中断机制**:支持 Ctrl+C 实时中断,通过 `threading.Event` + HTTP 连接关闭 + 子进程 kill 三层取消,流式响应和长时间运行命令均可立即终止
- 💻 **CLI 一次性对话**:`--once "问题"` 不启动 WebSocket 直接执行并退出,stdout/stderr 分流可管道组合,支持 `--quiet` / `--show-reasoning` / `--worker-only` / `--model` / `--temperature` / `--once-timeout`
- 🔀 **多模型档案**:`config.json` 的 `models` 节可配置多个 OpenAI Chat Completions 兼容端点(DeepSeek/Kimi/Qwen/本地 Ollama 等),按名称切换,档案级 api_url/model/api_key_env/reasoning_enabled/timeout/temperature/display_name;`--model` 支持 `model:`/`profile:` 前缀逃逸
## 🚀 快速开始 ## 🚀 快速开始
@@ -55,13 +57,28 @@ python aibox.py
启动后,WebSocket 服务器将运行在 `ws://localhost:8765`(地址与端口可在 `config.json` 的 `system.websocket_host` / `system.websocket_port` 中修改)。 启动后,WebSocket 服务器将运行在 `ws://localhost:8765`(地址与端口可在 `config.json` 的 `system.websocket_host` / `system.websocket_port` 中修改)。
> 注意:本模块入口文件为 `aibox.py`(当前约 3018 行,集成 WebSocket 服务器、AI 引擎、工具/技能系统、任务调度器于一体;自 v3.0 起工具类与子系统已抽离至 `tools/`、`subsystems/`、`common/`、`tests/` 包,aibox.py 仅保留核心类与编排逻辑)。`client.py` 为示例 WebSocket 客户端,`voice_client.py` 为语音客户端,`llm_client.py` 为多厂商大模型客户端抽象层,`_run.sh` 为被 `core` 拉起的启动脚本。 ### 管理大模型接入点(不用手写配置)
不想手写 `config.json` 的 `models` 节?用交互式菜单工具:
```bash
python model_config.py # 交互式菜单:列出 / 添加 / 删除 / 连通性验证
python model_config.py --list # 非交互:列出当前接入点
```
- **添加接入点**:按提示填写档案名、API 地址(附常见厂商端点参考)、模型名;Key 两种方式——环境变量名(推荐,Key 不落盘)或免密本地端点(Ollama 等);可选显示名/温度/超时/思考模式/失败转移(从已有接入点多选)/设为默认
- **删除接入点**:编号选择 + 确认,自动清理其他档案的失败转移引用
- **连通性验证**:对选中接入点发一次最小请求,直接给出成败原因
- 添加后可立即使用:`python aibox.py --model <档案名>` 指定,或设为默认后无需 `--model`
> 注意:本模块入口文件为 `aibox.py`(当前约 3408 行,集成 WebSocket 服务器、AI 引擎、工具/技能系统、任务调度器于一体;自 v3.0 起工具类与子系统已抽离至 `tools/`、`subsystems/`、`common/`、`tests/` 包,aibox.py 仅保留核心类与编排逻辑)。`client.py` 为示例 WebSocket 客户端,`voice_client.py` 为语音客户端,`llm_client.py` 为多厂商大模型客户端抽象层,`_run.sh` 为被 `core` 拉起的启动脚本。
## 📁 目录结构 ## 📁 目录结构
``` ```
aibox/ aibox/
├── aibox.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 任务调度器 + 核心类 DeepSeekChat/WebSocketHandler/EnhancedAIManager) ├── aibox.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 任务调度器 + 核心类 DeepSeekChat/WebSocketHandler/EnhancedAIManager)
├── model_config.py # 大模型接入点交互式管理工具(列表/添加/删除/连通性验证,不用手写配置)
├── client.py # 示例 WebSocket 客户端 ├── client.py # 示例 WebSocket 客户端
├── voice_client.py # 语音客户端 ├── voice_client.py # 语音客户端
├── llm_client.py # 大模型客户端抽象层(多厂商切换,DeepSeekClient 兼容 OpenAI 协议) ├── llm_client.py # 大模型客户端抽象层(多厂商切换,DeepSeekClient 兼容 OpenAI 协议)
@@ -90,7 +107,7 @@ aibox/
│ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation │ ├── prompt_tools.py # B 批:UpdateIdentity/UpdateSoul/SaveMemoryAndEndConversation
│ ├── proxy_tools.py # B 批:SetProxy/GetProxy │ ├── proxy_tools.py # B 批:SetProxy/GetProxy
│ └── misc_tools.py # B 批:AskUserTool │ └── misc_tools.py # B 批:AskUserTool
├── tests/ # 单元测试套件(232 项全过) ├── tests/ # 单元测试套件(441 项全过)
│ ├── conftest.py # sys.path 配置 + websockets 桩 │ ├── conftest.py # sys.path 配置 + websockets 桩
│ ├── test_basic_tools.py # A 批工具 43 项 │ ├── test_basic_tools.py # A 批工具 43 项
│ ├── test_b_tools.py # B 批工具 58 项 │ ├── test_b_tools.py # B 批工具 58 项
@@ -99,7 +116,15 @@ aibox/
│ ├── test_db.py # 记忆/备忘录数据库(含中文检索/迁移) │ ├── test_db.py # 记忆/备忘录数据库(含中文检索/迁移)
│ ├── test_conversation_history.py # 对话历史 │ ├── test_conversation_history.py # 对话历史
│ ├── test_conversation_compressor.py # 对话压缩器 │ ├── test_conversation_compressor.py # 对话压缩器
│ └── test_skill_manager.py # 技能管理器 │ ├── test_skill_manager.py # 技能管理器
│ ├── test_once_mode.py # CLI 一次性对话模式(Batch A/B/C 15 项)
│ ├── test_models_profile.py # 多模型档案(Batch D 17 项)
│ ├── test_config.py # 配置脱敏(F1 3 项)
│ ├── test_f2_fixes.py # 超时清理/线程收尾(F2 5 项)
│ ├── test_g_fixes.py # 第三轮审计 Batch G(20 项)
│ ├── test_h_fixes.py # 第四轮审计 Batch H(26 项)
│ ├── test_i_fixes.py # 遗留收尾 Batch I(16 项)
│ └── test_model_config.py # 模型接入点管理工具(17 项)
├── skills/ # 内置扩展技能(*.py 可执行脚本 / *.md Markdown 技能) ├── skills/ # 内置扩展技能(*.py 可执行脚本 / *.md Markdown 技能)
│ ├── hello-world.py │ ├── hello-world.py
│ ├── aes-crypto.py │ ├── aes-crypto.py
@@ -134,7 +159,7 @@ common ← subsystems ← tools ← aibox.py
```bash ```bash
# 从仓库根目录运行 # 从仓库根目录运行
py -m pytest aibox/tests -q py -m pytest aibox/tests -q
# 当前:232 项全过,约 11s # 当前:264 项全过
``` ```
运行期数据存放于 `~/.aibox/`: 运行期数据存放于 `~/.aibox/`:
@@ -186,7 +211,7 @@ py -m pytest aibox/tests -q
| `memory` | 保存、搜索(支持分页)、删除记忆 | | `memory` | 保存、搜索(支持分页)、删除记忆 |
| `memo` | 创建、查看、完成、搜索任务 | | `memo` | 创建、查看、完成、搜索任务 |
| `express_emotion` | 表达情绪并记录当前情绪状态(anger/joy/sadness/surprise),可指定 `persona` 从 `~/.aibox/skills/` 加载人格技能(如 angry/coquettish/tsundere)派生携带该人格的情绪子代理,保证人格独立、不污染主对话上下文 | | `express_emotion` | 表达情绪并记录当前情绪状态(anger/joy/sadness/surprise),可指定 `persona` 从 `~/.aibox/skills/` 加载人格技能(如 angry/coquettish/tsundere)派生携带该人格的情绪子代理,保证人格独立、不污染主对话上下文 |
| `create_subagent` | 派生子代理:独立上下文/角色设定,仅拥有执行面工具(无记忆/人格/派生/提问能力),可选独立模型,用于长任务分解、上下文隔离、独立角色专注处理 | | `create_subagent` | 派生子代理:独立上下文/角色设定,仅拥有执行面工具(无记忆/人格/派生/提问能力),可选独立模型(工具描述动态列出 `models` 节可用档案名,防 LLM 编造名称),用于长任务分解、上下文隔离、独立角色专注处理 |
| `search_skills` | 搜索扩展技能(支持 Markdown 和可执行技能),查到后派生子代理执行 | | `search_skills` | 搜索扩展技能(支持 Markdown 和可执行技能),查到后派生子代理执行 |
| `reload_skills` | 热重载扩展技能 | | `reload_skills` | 热重载扩展技能 |
| `create_skill` | 创建新技能(AI 自主编写 Python 脚本,实际写入经子代理完成) | | `create_skill` | 创建新技能(AI 自主编写 Python 脚本,实际写入经子代理完成) |
@@ -196,7 +221,7 @@ py -m pytest aibox/tests -q
| `update_soul` | 更新 AI 自我定义(`99_SOUL.md`,智能体进化) | | `update_soul` | 更新 AI 自我定义(`99_SOUL.md`,智能体进化) |
| `save_memory_and_end_conversation` | 保存记忆并结束对话 | | `save_memory_and_end_conversation` | 保存记忆并结束对话 |
| `get_current_time` | 获取当前时间 | | `get_current_time` | 获取当前时间 |
| `set_proxy` | 设置 SOCKS5 代理(host/port/可选账号密码,可持久化,即时生效) | | `set_proxy` | 设置 SOCKS5 代理(host/port/可选账号密码,可持久化,即时生效);`password` 明文仅本次会话有效不落盘,跨重启用 `password_env` 指定存有密码的环境变量名 |
| `get_proxy` | 查看当前 SOCKS5 代理配置 | | `get_proxy` | 查看当前 SOCKS5 代理配置 |
### 子代理(worker)工具集 ### 子代理(worker)工具集
@@ -502,15 +527,69 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
"broadcast_enabled": true "broadcast_enabled": true
}, },
"api": { "api": {
"provider": "deepseek", "provider": "openai_compatible",
"deepseek_api_url": "https://api.deepseek.com/chat/completions", "deepseek_api_url": "https://api.deepseek.com/chat/completions",
"deepseek_model": "deepseek-v4-flash", "deepseek_model": "deepseek-v4-flash",
"reasoning_enabled": false, "reasoning_enabled": false,
"api_key_env": "DEEPSEEK_API_KEY", "api_key_env": "DEEPSEEK_API_KEY",
"timeout": 120, "timeout": 120,
"temperature": 0.7 "temperature": 0.7,
"default_model": "deepseek"
}, },
"context": { "models": {},
```
### 🔀 多模型档案(models 节)
`models` 可配置多个 OpenAI Chat Completions 协议兼容端点,按**档案名**切换(v3.2):
```json
{
"models": {
"kimi": {
"api_url": "https://api.moonshot.cn/v1/chat/completions",
"model": "kimi-k2",
"api_key_env": "MOONSHOT_API_KEY",
"reasoning_enabled": false,
"timeout": 120
},
"local": {
"api_url": "http://localhost:11434/v1/chat/completions",
"model": "qwen3:32b",
"api_key_env": ""
}
},
"api": { "default_model": "kimi" }
}
```
**档案字段**:
| 字段 | 说明 |
|---|---|
| `api_url` | **必填**。端点 URL(OpenAI Chat Completions 协议) |
| `model` | **必填**(除非调用时传裸模型名)。该档案使用的模型名 |
| `api_key_env` | API Key 环境变量名;**未配置回退 `api.api_key_env`;显式空串 `""` 为本地免密端点**(Ollama/vLLM)。档案端点环境变量取不到时**直接报错,绝不回退 `~/.cert/deepseek.key`**——该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏(key 文件回退仅对无档案的旧 `api` 扁平节生效) |
| `reasoning_enabled` | 档案级思考模式开关(缺省回退 `api.reasoning_enabled`) |
| `timeout` | 请求超时秒数 |
| `temperature` | 档案级默认采样温度(可选)。优先级:CLI/调用方显式传入 > 本字段 > 全局默认;reasoning 模型建议按厂商文档设置 |
| `display_name` | 展示名(可选,缺省用档案名)。`client.name` 用于对话上下文中的 assistant 名,不再恒显示 "DeepSeek" |
| `fallback` | 失败转移档案名列表(可选,如 `["kimi", "local"]`,一层不递归)。主档案瞬时故障(HTTP 429/5xx、连接失败/超时)时按序转移;认证/参数类 4xx **不转移**(配置问题响亮暴露);失败档案进入 60s 冷却期(429 响应带 Retry-After 数字秒时按其延长)。`chat_completion`(对话压缩/轻量子代理)无条件转移;`stream_chat`(主对话流式)仅**首事件前**转移(已输出内容/用户中断绝不转移) |
| `failover_timeout_budget` | 整条转移链的总时间预算秒数(可选,缺省不限)。耗尽即停止尝试剩余档案,防止长链故障时超时叠加(3 档案最坏 3×timeout);`FailoverClient.get_stats()` 暴露 requests/failovers/cooldown_skips/exhausted 计数,`on_failover(from, to, reason)` 回调供管理面/前端通知接入 |
**选择优先级**:`--model <档案名>`(命中 `models` 节)→ `api.default_model` 指向档案 → 回退旧 `api` 扁平节。`--model` 为**进程级覆盖**:WebSocket 服务模式(主对话、每个会话实例、定时任务实例)与 `--once` 一次性模式均生效。
`--model` 传**裸模型名**(未命中档案)时,沿用默认端点与 Key,仅覆盖模型名——即 "同端点换模型" 语义。
**前缀逃逸**(档案名与裸模型名歧义时):`--model model:NAME` 强制按裸模型名解析(档案恰好叫 NAME 时仍走裸名语义);`--model profile:NAME` 强制按档案名解析(不存在则明确报错,不再静默落回裸名语义)。
**兼容性**:`models` 留空 `{}` 时行为与旧版完全一致(走 `api` 节),已有配置无需任何改动。
其余配置节:
```json
{
"context": {
"max_history_messages": 500, "max_history_messages": 500,
"max_context_messages": 120, "max_context_messages": 120,
"max_tokens": 8192, "max_tokens": 8192,
@@ -562,11 +641,14 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
"llm_max_tokens": 800 "llm_max_tokens": 800
}, },
"proxy": { "proxy": {
"type": "", "socks5": {
"host": "", "enabled": false,
"port": 0, "host": "127.0.0.1",
"username": "", "port": 1080,
"password": "" "username": "",
"password": null,
"password_env": "SOCKS5_PASSWORD"
}
} }
} }
``` ```
@@ -574,6 +656,8 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。设置 `api.reasoning_enabled: true` 可启用思考模式,此时会在请求中添加 `thinking: {type: "enabled"}` 参数,模型会先进行思考再回答,实时展示思考过程(灰色斜体区域),回答质量更高且**支持工具调用**(文件操作、命令执行、记忆搜索等工具均可在思考模式下正常使用)。 > 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。设置 `api.reasoning_enabled: true` 可启用思考模式,此时会在请求中添加 `thinking: {type: "enabled"}` 参数,模型会先进行思考再回答,实时展示思考过程(灰色斜体区域),回答质量更高且**支持工具调用**(文件操作、命令执行、记忆搜索等工具均可在思考模式下正常使用)。
> >
> API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。 > API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。
>
> **代理密码不落盘**:`proxy.socks5.password` 仅在运行期内存中有效,保存配置时强制置空;跨重启持久化请将密码存入环境变量(如 `SOCKS5_PASSWORD`),配置 `password_env` 指向该变量名。加载时检测到旧版明文密码会自动迁移(本会话保留可用,文件立即重写脱敏)。
> >
> **多厂商切换**:大模型调用已通过 `aibox/llm_client.py` 抽象层解耦,业务层不再直接依赖具体厂商。配置 `api.provider`(默认 `deepseek`)即可切换厂商,当前内置 `DeepSeekClient`(兼容 OpenAI Chat Completions 协议,Kimi / Qwen / GLM / Doubao 等绝大多数厂商均可复用)。新增厂商只需在此文件新增一个 `LLMClient` 子类并在 `create_llm_client` 工厂中注册,无需改动业务代码,为后续子代理/多模型共发预留了扩展点。 > **多厂商切换**:大模型调用已通过 `aibox/llm_client.py` 抽象层解耦,业务层不再直接依赖具体厂商。配置 `api.provider`(默认 `deepseek`)即可切换厂商,当前内置 `DeepSeekClient`(兼容 OpenAI Chat Completions 协议,Kimi / Qwen / GLM / Doubao 等绝大多数厂商均可复用)。新增厂商只需在此文件新增一个 `LLMClient` 子类并在 `create_llm_client` 工厂中注册,无需改动业务代码,为后续子代理/多模型共发预留了扩展点。
@@ -595,6 +679,14 @@ python aibox.py [选项]
--memo-add "标题|内容|时间" 添加任务 --memo-add "标题|内容|时间" 添加任务
--memo-list 列出任务 --memo-list 列出任务
--memo-complete ID 完成任务 --memo-complete ID 完成任务
--once QUERY 一次性对话:执行 QUERY 后退出(不启动 WebSocket)
--quiet 一次性模式静默输出(stdout 仅回答正文)
--show-reasoning 一次性模式输出思考流(默认静默,走 stderr)
--no-save-memory 一次性模式禁用记忆工具(干净跳过,不进请求 schema)
--worker-only 一次性模式纯执行面(worker profile,无人格/记忆工具)
--model NAME 模型档案名(models 节)或裸模型名(同端点换模型);歧义时 model:NAME 强制裸名 / profile:NAME 强制档案;服务模式与 --once 均生效(进程级覆盖)
--temperature T 覆盖配置的对话温度
--once-timeout SEC 一次性模式全局超时(默认 300 秒)
``` ```
### 使用示例 ### 使用示例
@@ -616,6 +708,30 @@ python aibox.py --memo-add "提醒开会|下午3点会议室A|+1h"
python aibox.py --memo-list python aibox.py --memo-list
``` ```
### 💻 CLI 一次性对话(--once)
不启动 WebSocket,单次执行后退出。回答正文走 **stdout**,工具过程/思考流走 **stderr**,可直接管道组合:
```bash
# 基础问答
python aibox.py --once "帮我看下当前日期"
# 管道组合(--quiet 静默模式)
python aibox.py --once --quiet "列出当前目录 py 文件" | grep ".py" | wc -l
# 按档案名选模型 / 同端点换模型
python aibox.py --once --model kimi "总结这段代码"
python aibox.py --once --model deepseek-v4-lite "1+1?"
# 纯执行面(无人格/记忆,单次收敛,适合脚本调用)
python aibox.py --once --worker-only "执行 echo hello 并返回输出"
# 显式开启思考流 / 禁用记忆写入
python aibox.py --once --show-reasoning --no-save-memory "分析这个问题"
```
**退出码**:`0` 成功 / `1` 工具执行失败 / `2` API 调用失败 / `3` 用户中断(Ctrl+C)/ `4` 全局超时。
## 🔄 架构流程 ## 🔄 架构流程
``` ```
@@ -795,6 +911,63 @@ elif command == "/mycommand":
## 📄 更新日志 ## 📄 更新日志
### v3.5
- ✅ **安全审计与修复(第三轮 Batch G + 第四轮 Batch H + 遗留收尾 Batch I)**(完整记录见 [docs/reports/aibox_audit_20260817.md](../docs/reports/aibox_audit_20260817.md)):
- **Batch G(llm 加固 + 线程生命周期 + 取消传播 + DB 事务)**:
- llm_client:非瞬时 HTTP 错误体截断 `[:200]`(密钥/错误页不进异常栈)、Retry-After 上限 cap 3600s、fallback 档案缺 model 跳过并告警、fallback 去重、删除 `LLMClient._cancelled` 死状态、FailoverClient 并发契约文档化
- 子代理取消传播:`run_full_subagent` 共享父 `_cancel_event` + 0.2s 观察者线程转发深度中断(强关在途 HTTP、kill 子进程);`SubAgentTool` 构造注入父事件
- drain 线程重构:批次级单线程 + stop 事件 + try/finally 统一回收(消除每次工具调用泄漏一个常驻线程);`/new` 的 `reset_conversation` 移入线程池(不再同步获取 `_msg_lock` 冻结事件循环);broadcast 收窄 except + unregister 改 discard;`_processing_tasks` 改按连接的任务集合登记(关闭时取消全部在途任务)
- DB:FTS5 关键词双引号翻倍转义(`"` → `""`)、两处 `LIMIT` f-string 参数化、`mark_as_triggered` 读写收进单连接 `BEGIN IMMEDIATE` 事务(消灭并发触发竞态);历史索引裸 except 收窄 + 归档文件名同秒加序号
- **Batch H(第四轮审计,3 路并行审读 + Linux PoC 6/6 实证)**:
- **H1(高危)**:修复 N5 回归——子代理共享父取消事件后首轮 think 无条件 `clear()` 会吞掉父级已置位的取消信号(用户中断 / --once 超时被绕过),共享事件时跳过 clear + 父已取消直接早退
- `/reload_prompts` 移入线程池(N3 同类遗漏);技能扫描(串行 `--help` 子进程)启动前预热,不再在首条消息/首个任务时冻结事件循环
- 调度器任务级超时(`scheduler.task_timeout`,默认 600s)——AI think 卡死不再停摆整个调度循环
- `get_recent_memories(limit=None)` 默认上限(不再全表加载);`memo delete` 改物理删除(此前只标记完成);`PromptManager.reload` 本地构建后原子替换(并发读者不再读到空/半截系统提示词)
- read_file 的 sed 参数强转 int(防引号注入);`set_proxy` 落盘失败如实报告;SSE 非 UTF-8 行容错;persona 路径 realpath 校验(软链越界拒绝);1MB 截断无半个字符;compressor 除零防护;InstallSkillTool 指引命令路径引用;ask_user 缺参明确报错
- **POSIX 进程组终止**:executor 的 `Popen` 加 `start_new_session` + 组信号 kill(组 ID 在 Popen 后立即捕获)——`(sleep 300 &)` 形态的孙进程持管道不再悬挂 reader 线程/泄漏 fd(实证修复前孙进程残留)
- web 工具 scheme 白名单(http/https,`file://` 等拒绝);registry 日志打码改包含匹配(apiKey/passwd 变体);FailoverClient 日志 Bearer 令牌正则脱敏(网关回显 Authorization 头不再进日志)
- **Batch I(遗留收尾)**:
- **N9**:prompt 写入内容长度上限(`MAX_CONTENT_LENGTH=10000`,identity/soul/status 超长拒绝)——约束持久化注入面
- **F5**:情绪子代理取消传播——`run_light_subagent` 改走 stream_chat + 观察者线程,用户中断可即时终止(此前阻塞至请求超时)
- 提醒时间格式异常任务按"到期"返回(此前被 SQL 层字符串比较静默丢弃,任务永不执行);`mark_as_triggered` 加 `busy_timeout=10000`(多进程同库写竞争等待而非立即报 locked)
- 对话文件原子写(tmp + os.replace,崩溃不留半截 JSON);`load_conversation` realpath 越界防护;WS 命令专用线程池(`/new`、`/reload_prompts` 不再占用默认池);--once 硬超时兜底(软超时后宽限一个周期强杀进程)
- PromptManager 单例加锁;`limit=0` 语义修正(None 才不加 LIMIT)
- ✅ **测试**:424 项全过(Linux 环境;Batch G 20 + Batch H 26 + Batch I 16 项新增;`test_execute_timeout`/`test_flat_api_still_reads_key_file` 为 Windows 向用例,Linux 预存失败与本轮无关)
### v3.4
- ✅ **多模型档案加固(H1/H2/H3/M1/M2/M3 六项修复)**:
- **H1(凭据隔离)**:档案端点环境变量取不到时直接报错,**绝不回退读 `~/.cert/deepseek.key`**(该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏);key 文件回退仅对无档案的旧 `api` 扁平节生效
- **H2(schema 校验前移)**:档案缺 `api_url`/`model` 或类型错误时,解析期即抛出指明档案名与缺失字段的 `ValueError`——不再把档案名当模型名发出、不再延迟到 `requests InvalidURL` 或 API 400
- **H3(--model 服务模式生效)**:`--model` 升级为进程级覆盖,WebSocket 服务模式(主对话/每个会话实例/定时任务实例)与 `--once` 均生效,此前服务模式静默忽略
- **M2(防模型名幻觉)**:`create_subagent` 工具描述与 JSON Schema 动态携带 `models` 节可用档案名列表(默认档案标注),LLM 无从编造名称;`models` 为空时描述保持原样(零破坏)
- **M1(换模型同步派生引用)**:新增 `DeepSeekChat.set_client()` 统一替换 LLM 客户端——同步刷新 `compressor.llm_client` 与 `api_key`/`name` 快照;`create_subagent` 子代理换模型经此入口,压缩链不再残留默认模型
- **M3(reasoning 契约显式化)**:档案级 `reasoning_enabled` 从"构造后挂实例属性"改为 `LLMClient` 构造参数(三态:`True`/`False` 档案级显式、`None` 未配置回退全局),外部误设属性无从静默覆盖全局开关
- ✅ **L 批体验与兼容修复(L1/L2/L3/L5 四项)**:
- **L1(展示名按档案)**:`client.name` = 档案 `display_name` > 档案名,用 Kimi/Qwen 时对话上下文不再误标 "DeepSeek";旧扁平节保持 "DeepSeek"(零破坏)
- **L2(前缀逃逸)**:`--model model:NAME` 强制裸模型名语义、`--model profile:NAME` 强制档案语义(不存在时明确报错并列出可用档案),档案名与模型名歧义可逃逸
- **L3(档案级温度)**:档案 `temperature` 字段作为默认采样温度,优先级:CLI/调用方显式传入 > 档案字段 > 全局默认(0.7/0.3),主对话/轻量子代理/compressor 全链路生效
- **L5(思考流字段别名)**:`_parse_delta` 识别 `reasoning_content`/`reasoning`/`thinking`/`thinking_content` 四种常见思考字段(首个命中生效),其他厂商思考流不再静默丢失
- ✅ **L4a(chat_completion 跨档案失败转移)**:档案新字段 `fallback`(档案名列表,一层不递归);`create_llm_client` 构造 `FailoverClient` 包装链(主档案 + fallback)。瞬时故障(HTTP 429/5xx 抛 `TransientLLMError`、连接失败/超时)按链转移,认证/参数类 4xx 不转移(配置问题响亮暴露);失败档案 60s 冷却;fallback 档案缺 Key 等环境问题跳过 + warning 不阻断主档案;配置结构错误(类型/不存在/自引用)启动期硬性拦截。当前覆盖对话压缩与轻量子代理链路,流式(`stream_chat`)转移属 L4b
- ✅ **L4b(stream_chat 首事件前转移 + 中断联动)**:流式仅在**未产出任何事件**(reasoning/content/tool_call)时转移——重放对用户不可见;已产出事件后失败直接上抛(防前端重复输出/工具增量错乱)。`LLMClient.cancel()` 置 `_cancelled` 标志,`FailoverClient.cancel()` 广播整条链并置包装器标志——用户中断引发的连接异常与真实故障不可区分,凭标志跳过转移(否则用户越中断、系统越换厂商重试);`chat_completion` 同受标志保护。链耗尽抛最后异常(既有回滚/报错处理接住),全链冷却抛 RuntimeError
- ✅ **L4c(转移可观测性 + 预算控制 + Retry-After)**:`FailoverClient.get_stats()` 暴露 requests/failovers/cooldown_skips/exhausted 聚合计数;`on_failover(from, to, reason)` 回调(to=None 表示链耗尽,回调异常不影响主流程)供管理面/前端通知接入;档案字段 `failover_timeout_budget` 限制整链尝试总秒数(缺省不限,<=0 视为未配置),耗尽停止尝试剩余档案防超时叠加;429 响应 Retry-After(数字秒)经 `TransientLLMError.retry_after` 延长该档案冷却期(非数字格式忽略)
- ✅ **测试**:355 项全过(H1 隔离 4 + H2 校验 6 + H3 传导 6 + M2 提示 5 + M1 替换 2 + M3 契约 4 + L 批 16 + L4a 转移 15 + L4b 流式 12 + L4c 可观测 13 项新增)
### v3.3
- ✅ **安全审计与修复**(两轮审计报告见 [docs/reports/aibox_audit_20260817.md](../docs/reports/aibox_audit_20260817.md)):
- **Batch E(命令执行保护链修复)**:`CommandLineTool.execute_streaming` 纯委托注入的 executor(删除绕过 1MB/超时/中断保护的 150 行重复 Popen 实现);`SubprocessCommandExecutor` 读取循环线程化(后台 reader 线程 + 0.1s 超时轮询,静默命令超时不再失效);两处 `Popen` 改 `stdin=DEVNULL`(等待输入的命令不再永久挂起工具线程);write_file 指引 heredoc 定界符唯一化;registry 流式转发循环加 3600s 总超时兜底。PoC 回归 5/5 + 全量单测通过
- **Batch F(凭据保护 + 线程收尾)**:
- **F1**:SOCKS5 代理密码不再明文落盘——`_save_config` 落盘前强制脱敏(纵深防御),新增 `password_env` 环境变量注入(对齐 `api_key_env` 惯例),旧版明文配置加载时自动迁移清除;`registry` debug 日志对 `password`/`api_key`/`token`/`secret`/`authorization` 参数打码
- **F2**:registry 流式转发超时路径统一清理 `tool.streaming_output`/`tool.output_queue`(消除僵尸 daemon 线程经实例属性把输出混入后续会话队列的串流路径),`stream_tool_timeout` 提为实例属性(默认 3600s,测试可调);`SubprocessCommandExecutor` reader 线程收尾——正常 EOF 后显式 close 释放管道 fd,仍阻塞时有限 join 后放弃(daemon 兜底);实证修正:CPython 流锁下阻塞中的 readline 无法被 `close()` 中断(首版实现致超时被拖满命令时长,已按 PoC 回归修正)
- ✅ **测试**:272 项全过(Batch E PoC 回归 + F1 配置脱敏 3 项 + F2 超时清理/线程收尾 5 项新增)
### v3.2
- ✅ **CLI 一次性对话模式(`--once`)**(分三批落地,开发文档见 [docs/reports/aibox_cli_once_mode_dev_doc.md](../docs/reports/aibox_cli_once_mode_dev_doc.md)):
- **Batch A**:`--once QUERY` / `--quiet` / `--once-timeout`;不启动 WebSocket,执行后退出;回答走 stdout、过程走 stderr,可管道组合;SIGINT 与超时复用三层取消机制
- **Batch B**:`--no-save-memory`(`enable_memory` 开关干净禁用 MemoryTool)、`--show-reasoning`(思考流默认静默);压缩提示改走 output_callback 不再污染 stdout;退出码 1(工具执行失败)
- **Batch C**:SQLite WAL(多 CLI 进程并发写锁缓解)、`--worker-only`(纯执行面 + 精简 system prompt 防人格污染)、`--model` / `--temperature` 覆盖、子代理工具失败标记透传(`_SUBAGENT_TOOL_FAILED_MARKER` → orchestrator stats)
- ✅ **多模型档案(`models` 配置节)**:配置多个 OpenAI Chat Completions 兼容端点(DeepSeek/Kimi/Qwen/本地 Ollama 等)按档案名切换;`--model` 支持档案名与裸模型名(同端点换模型)双语义;档案级 `api_key_env` 三级语义(回退 / 显式 / 空串免密);档案级 `reasoning_enabled` 优先于全局;`models: {}` 时零破坏回退旧 `api` 节;`llm_client.py` 增加 `openai_compatible` / `openai` provider 别名
- ✅ **测试**:264 项全过(once 模式 15 项 + 多模型档案 17 项新增)
### v3.1 ### v3.1
- ✅ **修复 FTS5 中文搜索失效**([subsystems/db.py](subsystems/db.py)): - ✅ **修复 FTS5 中文搜索失效**([subsystems/db.py](subsystems/db.py)):
- FTS5 虚拟表改用 `tokenize='trigram'` 分词器(需 SQLite >= 3.34,环境不支持时自动退回 unicode61) - FTS5 虚拟表改用 `tokenize='trigram'` 分词器(需 SQLite >= 3.34,环境不支持时自动退回 unicode61)
+408 -157
View File
@@ -26,6 +26,7 @@ import locale
import asyncio import asyncio
import websockets import websockets
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List, Dict, Optional, Any, Tuple, Callable, Union, Set from typing import List, Dict, Optional, Any, Tuple, Callable, Union, Set
import argparse import argparse
@@ -116,9 +117,11 @@ class TaskScheduler:
# 每个任务使用独立的命令执行器,互不共享,避免 cancel() 误杀其他任务的子进程 # 每个任务使用独立的命令执行器,互不共享,避免 cancel() 误杀其他任务的子进程
command_executor=SubprocessCommandExecutor(self.logger), command_executor=SubprocessCommandExecutor(self.logger),
prompt_manager=self.ai_manager.prompt_manager if self.ai_manager else None, prompt_manager=self.ai_manager.prompt_manager if self.ai_manager else None,
skill_manager=self._get_shared_skill_manager() skill_manager=self._get_shared_skill_manager(),
# H3:CLI --model 为进程级覆盖,定时任务实例同样生效
model=getattr(self.ai_manager, "model", None) if self.ai_manager else None
) )
def set_websocket_handler(self, handler): def set_websocket_handler(self, handler):
"""设置WebSocket处理器,用于广播任务执行状态""" """设置WebSocket处理器,用于广播任务执行状态"""
self.ws_handler = handler self.ws_handler = handler
@@ -326,14 +329,35 @@ class TaskScheduler:
ai_thread.daemon = True ai_thread.daemon = True
ai_thread.start() ai_thread.start()
# 等待 AI 完成,同时处理可能的取消 # M3(审计 2026-08-18):任务级超时兜底。此前 await ai_future
# 无任何超时/取消路径——AI think 卡死(LLM 挂起、子进程不可杀)
# 时该任务永不返回,调度器 run 循环顺序 await 被阻塞,后续全部
# 定时/即时任务不再执行且任务已 mark_as_triggered 不会重试。
# 到点 call_later 触发 ai.cancel()(置取消事件 + 关在途 HTTP 流),
# worker 线程自然收尾,调度循环继续。
task_timeout = float(ConfigManager().get("scheduler.task_timeout", 600))
timed_out = threading.Event()
def _timeout_cancel():
timed_out.set()
try:
ai.cancel()
except Exception:
pass
timeout_handle = loop.call_later(task_timeout, _timeout_cancel)
try: try:
response = await ai_future response = await ai_future
result = response or "AI无响应" result = response or "AI无响应"
except Exception as e: except Exception as e:
self.logger.error(f"AI对话失败: {e}") if timed_out.is_set():
result = f"任务执行但AI对话失败: {str(e)}" self.logger.error(f"任务执行超时({task_timeout}秒),AI 已取消")
result = f"任务执行超时({task_timeout}秒),AI 已取消"
else:
self.logger.error(f"AI对话失败: {e}")
result = f"任务执行但AI对话失败: {str(e)}"
finally: finally:
timeout_handle.cancel()
# 确保线程结束 # 确保线程结束
ai_thread.join(timeout=1.0) ai_thread.join(timeout=1.0)
@@ -488,6 +512,12 @@ class TaskScheduler:
class ExpressEmotionTool(Tool): class ExpressEmotionTool(Tool):
def __init__(self, logger=None, cancel_event: threading.Event = None):
super().__init__(logger)
# I 批(审计 2026-08-18):父代理取消事件注入——情绪子代理此前
# 用独立 client 且不可中断,父级 cancel 后仍阻塞至请求自然结束
self.cancel_event = cancel_event
def get_name(self) -> str: def get_name(self) -> str:
return "express_emotion" return "express_emotion"
@@ -532,6 +562,15 @@ class ExpressEmotionTool(Tool):
if not name: if not name:
return "" return ""
path = os.path.join(constants.SKILLS_DIR, name + ".md") path = os.path.join(constants.SKILLS_DIR, name + ".md")
# N12 + L4(审计 2026-08-18):persona 为 LLM 可控输入。abspath 前缀
# 检查拦截 ../ 越界,但符号链接可绕过(skills 目录内软链指向任意文件
# 即被读取注入);realpath 解析软链后再做前缀校验。
skills_root = os.path.normcase(os.path.abspath(constants.SKILLS_DIR))
resolved = os.path.normcase(os.path.realpath(path))
if not resolved.startswith(skills_root + os.sep):
self.logger.warning(f"人格技能名非法(越界路径): {name}")
return ""
path = resolved
if not os.path.isfile(path): if not os.path.isfile(path):
self.logger.warning(f"未找到人格技能文件: {path}") self.logger.warning(f"未找到人格技能文件: {path}")
return "" return ""
@@ -562,7 +601,8 @@ class ExpressEmotionTool(Tool):
task = "\n".join(task_lines) task = "\n".join(task_lines)
# 情绪子代理:独立上下文/人格,生成情绪化回应 # 情绪子代理:独立上下文/人格,生成情绪化回应
return run_light_subagent(role, task, self.config, logger=self.logger, max_tokens=2048) return run_light_subagent(role, task, self.config, logger=self.logger,
max_tokens=2048, cancel_event=self.cancel_event)
except Exception as e: except Exception as e:
self.logger.error(f"情绪子代理执行异常: {e}") self.logger.error(f"情绪子代理执行异常: {e}")
return f"❌ 情绪子代理执行失败: {str(e)}" return f"❌ 情绪子代理执行失败: {str(e)}"
@@ -574,26 +614,62 @@ class ExpressEmotionTool(Tool):
def run_light_subagent(role, task, config, logger=None, model=None, def run_light_subagent(role, task, config, logger=None, model=None,
temperature=None, max_tokens=2048) -> str: temperature=None, max_tokens=2048,
cancel_event: threading.Event = None) -> str:
"""轻量子代理执行:独立上下文/角色,无工具,一次 LLM 补全返回结果。 """轻量子代理执行:独立上下文/角色,无工具,一次 LLM 补全返回结果。
供 express_emotion 情绪子代理使用。 供 express_emotion 情绪子代理使用。
返回子代理生成的完整文本;失败时返回带 ❌ 的错误信息。 返回子代理生成的完整文本;失败时返回带 ❌ 的错误信息。
cancel_event(I 批,审计 2026-08-18):父代理取消事件。此前本函数用
非流式 chat_completion,父级 cancel 只能等请求自然结束(最长 timeout,
期间持锁阻塞);改走 stream_chat 后由观察者线程转发 sub_client.cancel()
强关在途 HTTP 流,用户中断可即时终止。
""" """
if cancel_event is not None and cancel_event.is_set():
return "❌ 子代理已因父级取消而终止"
try: try:
# 幂等校验:限制 max_tokens 范围,防止意外超限大量消耗 # 幂等校验:限制 max_tokens 范围,防止意外超限大量消耗
max_tokens = max(1, min(int(max_tokens or 2048), constants.DEFAULT_MAX_TOKENS)) max_tokens = max(1, min(int(max_tokens or 2048), constants.DEFAULT_MAX_TOKENS))
temp = temperature if temperature is not None else constants.DEFAULT_TEMPERATURE
# 子代理使用独立的 LLM 客户端(可选不同模型),独立上下文,不污染主对话 # 子代理使用独立的 LLM 客户端(可选不同模型),独立上下文,不污染主对话
sub_client = create_llm_client(config, api_key=None, logger=logger, model=model) sub_client = create_llm_client(config, api_key=None, logger=logger, model=model)
# L3:调用方 temperature > 档案级 default_temperature > 全局默认
client_temp = getattr(sub_client, "default_temperature", None)
temp = temperature if temperature is not None else (
client_temp if client_temp is not None else constants.DEFAULT_TEMPERATURE)
messages = [ messages = [
{"role": "system", "content": role}, {"role": "system", "content": role},
{"role": "user", "content": task} {"role": "user", "content": task}
] ]
if logger is not None: if logger is not None:
logger.info(f"子代理执行: model={model or sub_client.model}, role={str(role)[:40]}...") logger.info(f"子代理执行: model={model or sub_client.model}, role={str(role)[:40]}...")
result = sub_client.chat_completion(messages, temperature=temp, max_tokens=max_tokens)
if result is None or not str(result).strip(): _watch_done = threading.Event()
if cancel_event is not None:
def _watch_parent_cancel():
while not _watch_done.wait(timeout=0.2):
if cancel_event.is_set():
try:
sub_client.cancel()
except Exception:
pass
return
threading.Thread(target=_watch_parent_cancel, daemon=True,
name="light-subagent-cancel-watch").start()
# I 批:改走流式(可被 cancel 强关在途 HTTP),拼接 content 事件
parts = []
try:
for event in sub_client.stream_chat(messages, temperature=temp,
max_tokens=max_tokens):
if event.get("type") == "content":
parts.append(event.get("text") or "")
finally:
_watch_done.set()
result = "".join(parts)
if not str(result).strip():
return "❌ 子代理执行失败:未获得有效响应" return "❌ 子代理执行失败:未获得有效响应"
return str(result).strip() return str(result).strip()
except Exception as e: except Exception as e:
@@ -607,7 +683,8 @@ _SUBAGENT_TOOL_FAILED_MARKER = "[SUBAGENT_TOOL_FAILED]\n"
def run_full_subagent(role, task, config, logger=None, model=None, def run_full_subagent(role, task, config, logger=None, model=None,
temperature=None, max_tokens=2048, max_turns=10, temperature=None, max_tokens=2048, max_turns=10,
output_callback=None, outcome: dict = None) -> str: output_callback=None, outcome: dict = None,
cancel_event=None) -> str:
"""完整子代理:拥有全部工具,独立上下文,多轮对话直到任务完成。 """完整子代理:拥有全部工具,独立上下文,多轮对话直到任务完成。
子代理是完整的 DeepSeekChat 实例,拥有文件操作、命令执行、网页访问、 子代理是完整的 DeepSeekChat 实例,拥有文件操作、命令执行、网页访问、
@@ -618,8 +695,12 @@ def run_full_subagent(role, task, config, logger=None, model=None,
output_callback: 可选回调(msg_type, content),用于转发子代理的实时思考过程 output_callback: 可选回调(msg_type, content),用于转发子代理的实时思考过程
(reasoning_start / reasoning / reasoning_end),不传则静默执行。 (reasoning_start / reasoning / reasoning_end),不传则静默执行。
cancel_event: 父代理取消事件(N5)。传入时子代理共享该事件并起观察者
线程转发深度中断——父级取消(用户中断 / --once 超时)可即时终止子代理。
供 create_subagent 工具使用。 供 create_subagent 工具使用。
""" """
_watch_done = threading.Event() # N5:取消观察者线程退出信号(finally 置位)
try: try:
max_tokens = max(1, min(int(max_tokens or 2048), constants.DEFAULT_MAX_TOKENS)) max_tokens = max(1, min(int(max_tokens or 2048), constants.DEFAULT_MAX_TOKENS))
temp = temperature if temperature is not None else constants.DEFAULT_TEMPERATURE temp = temperature if temperature is not None else constants.DEFAULT_TEMPERATURE
@@ -648,9 +729,36 @@ def run_full_subagent(role, task, config, logger=None, model=None,
exit_callback=None, exit_callback=None,
) )
# N5(审计 2026-08-18):父代理取消传播。子代理此前持完全独立的
# _cancel_event/executor/HTTP client,父级 cancel 或 --once 超时杀不到
# 它,需等子代理自行结束。现共享父取消事件(子代理在 think 前置、
# 工具调用间、流式事件间的既有检查点立即感知),并由观察者线程转发
# 深度中断(sub_chat.cancel() 强关在途 HTTP 流并 kill 其子进程)。
if cancel_event is not None:
sub_chat._cancel_event = cancel_event
# H1(审计 2026-08-18):共享父事件后禁止子代理首轮 clear——
# _think_and_respond_impl 首轮无条件 clear() 会吞掉父级已置位的
# 取消信号(用户中断 / --once 超时恰落在子代理生成窗口时被清掉,
# 观察者线程随之失效,子代理跑完全程)。置位后立即检查一次,
# 父级已取消则不再启动子代理。
sub_chat._shared_cancel_event = True
if cancel_event.is_set():
return "❌ 子代理已因父级取消而终止"
def _watch_parent_cancel():
while not _watch_done.wait(timeout=0.2):
if cancel_event.is_set():
sub_chat.cancel()
return
threading.Thread(target=_watch_parent_cancel, daemon=True,
name="subagent-cancel-watch").start()
# 覆盖模型(如果指定了不同模型) # 覆盖模型(如果指定了不同模型)
# M1:经 set_client 统一替换——同步刷新 compressor.llm_client 与
# api_key/name 快照,避免压缩链仍用默认模型发请求
if model: if model:
sub_chat.client = create_llm_client(config, api_key=None, logger=logger, model=model) sub_chat.set_client(create_llm_client(config, api_key=None, logger=logger, model=model))
# 轮次限制:防止工具调用无限循环 # 轮次限制:防止工具调用无限循环
# 注意:超限时不置 conversation_active=False——那会触发 think_and_respond # 注意:超限时不置 conversation_active=False——那会触发 think_and_respond
@@ -733,6 +841,9 @@ def run_full_subagent(role, task, config, logger=None, model=None,
if logger: if logger:
logger.error(f"子代理执行异常: {e}") logger.error(f"子代理执行异常: {e}")
return f"❌ 子代理执行失败: {str(e)}" return f"❌ 子代理执行失败: {str(e)}"
finally:
# N5:通知取消观察者线程退出(子代理已结束,无论成败)
_watch_done.set()
class SubAgentTool(Tool): class SubAgentTool(Tool):
@@ -743,22 +854,53 @@ class SubAgentTool(Tool):
结束后由子代理基于完整执行过程生成最终报告,返回给父代理。 结束后由子代理基于完整执行过程生成最终报告,返回给父代理。
""" """
def __init__(self, logger=None, cancel_event=None):
super().__init__(logger)
# N5:父代理(DeepSeekChat)取消事件——父级被取消(用户中断/--once
# 超时)时传播给 run_full_subagent,即时终止子代理
self.cancel_event = cancel_event
def get_name(self) -> str: def get_name(self) -> str:
return "create_subagent" return "create_subagent"
def _model_names_hint(self) -> str:
"""M2:从配置读取可用模型档案名,注入工具描述供 LLM 选择。
此前 LLM 不知道有哪些合法档案名,幻觉出的名字走裸名语义
(默认端点 + 错误模型名),运行时 API 400。models 节为空时
返回空串,工具描述保持原样(零破坏)。
"""
try:
models = self.config.get("models") or {}
if not isinstance(models, dict) or not models:
return ""
default_name = self.config.get("api.default_model")
names = [f"{n}(默认)" if n == default_name else str(n) for n in models]
return f"可用模型档案:{', '.join(names)}"
except Exception:
return ""
def get_description(self) -> str: def get_description(self) -> str:
return """派生子代理执行任务。子代理拥有全部工具能力和独立的上下文窗口,不会污染主对话上下文。 hint = self._model_names_hint()
model_line = "- model:子代理使用的模型(可选,默认与主代理一致)"
if hint:
model_line += f"。{hint},请从中选择,不要编造其他名称"
return f"""派生子代理执行任务。子代理拥有全部工具能力和独立的上下文窗口,不会污染主对话上下文。
适用场景:需要多步骤操作的任务(如文件处理、命令执行、信息检索)、需要独立上下文的复杂任务。 适用场景:需要多步骤操作的任务(如文件处理、命令执行、信息检索)、需要独立上下文的复杂任务。
子代理可进行多轮工具调用,任务完成后由子代理基于完整执行过程生成最终报告(结论/关键过程/产物清单/未解决问题)返回。 子代理可进行多轮工具调用,任务完成后由子代理基于完整执行过程生成最终报告(结论/关键过程/产物清单/未解决问题)返回。
参数说明: 参数说明:
- role:子代理的角色设定(作为系统提示词),如"你是一位资深代码审查专家" - role:子代理的角色设定(作为系统提示词),如"你是一位资深代码审查专家"
- task:要交给子代理执行的具体任务 - task:要交给子代理执行的具体任务
- model:子代理使用的模型(可选,默认与主代理一致) {model_line}
- temperature:采样温度(可选,默认使用主配置) - temperature:采样温度(可选,默认使用主配置)
- max_tokens:最大输出 token 数(可选,默认 2048) - max_tokens:最大输出 token 数(可选,默认 2048)
- max_turns:最大对话轮次(可选,默认 10,防止无限循环)""" - max_turns:最大对话轮次(可选,默认 10,防止无限循环)"""
def get_parameters(self) -> Dict: def get_parameters(self) -> Dict:
model_desc = "子代理使用的模型名称,默认与主代理一致"
hint = self._model_names_hint()
if hint:
model_desc += f"。{hint},请从中选择"
return { return {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -772,7 +914,7 @@ class SubAgentTool(Tool):
}, },
"model": { "model": {
"type": "string", "type": "string",
"description": "子代理使用的模型名称,默认与主代理一致" "description": model_desc
}, },
"temperature": { "temperature": {
"type": "number", "type": "number",
@@ -827,7 +969,8 @@ class SubAgentTool(Tool):
model=model, temperature=temperature, model=model, temperature=temperature,
max_tokens=max_tokens, max_turns=max_turns, max_tokens=max_tokens, max_turns=max_turns,
output_callback=self._subagent_output, output_callback=self._subagent_output,
outcome=outcome) outcome=outcome,
cancel_event=self.cancel_event)
# 冲刷子代理回答缓冲(最后一段 chunk 可能尚未发出) # 冲刷子代理回答缓冲(最后一段 chunk 可能尚未发出)
self._flush_subagent_answer() self._flush_subagent_answer()
# 子代理内部工具失败 → 打上标记,由 _handle_tool_calls 检测并置 orchestrator stats # 子代理内部工具失败 → 打上标记,由 _handle_tool_calls 检测并置 orchestrator stats
@@ -886,7 +1029,11 @@ class DeepSeekChat:
self.api_key = self.client.api_key self.api_key = self.client.api_key
self.name = self.client.name self.name = self.client.name
# temperature 显式传入时覆盖 constants 默认(一次性 CLI 可指定更确定性的采样) # temperature 显式传入时覆盖 constants 默认(一次性 CLI 可指定更确定性的采样)
self.temperature = temperature if temperature is not None else constants.DEFAULT_TEMPERATURE # L3:CLI/调用方 temperature > 档案级 default_temperature > 全局默认
self.temperature = temperature if temperature is not None else (
self.client.default_temperature
if getattr(self.client, "default_temperature", None) is not None
else constants.DEFAULT_TEMPERATURE)
self.history_manager = history_manager self.history_manager = history_manager
# enable_memory=False 时置空,注册层(_register_tools)依此跳过 MemoryTool, # enable_memory=False 时置空,注册层(_register_tools)依此跳过 MemoryTool,
# 使记忆工具 schema 不出现在请求中,LLM 无从调用——干净禁用而非运行期拦截 # 使记忆工具 schema 不出现在请求中,LLM 无从调用——干净禁用而非运行期拦截
@@ -911,11 +1058,23 @@ class DeepSeekChat:
self._command_executor = command_executor # 保存命令执行器引用,用于取消时kill子进程 self._command_executor = command_executor # 保存命令执行器引用,用于取消时kill子进程
# 消息锁:保护 self.messages 免受 WebSocket 线程与调度器线程并发修改导致对话历史污染 # 消息锁:保护 self.messages 免受 WebSocket 线程与调度器线程并发修改导致对话历史污染
self._msg_lock = threading.RLock() self._msg_lock = threading.RLock()
self._init_system_prompts(system_prompt) self._init_system_prompts(system_prompt)
self.tool_registry = ToolRegistry(self.logger) self.tool_registry = ToolRegistry(self.logger)
self._register_tools(exit_callback, command_executor) self._register_tools(exit_callback, command_executor)
def set_client(self, client) -> None:
"""M1:统一替换 LLM 客户端(换模型/换档案),同步刷新派生引用。
直接赋值 self.client 会让 ConversationCompressor 等持有旧 client 的
组件继续用默认模型发请求(压缩链与实际对话模型不一致)。本方法
一并更新 compressor.llm_client 与 api_key/name 快照。
"""
self.client = client
self.api_key = client.api_key
self.name = client.name
self.compressor.llm_client = client
def cancel(self): def cancel(self):
"""中断当前正在进行的AI思考/工具执行。线程安全,可从任意线程调用。""" """中断当前正在进行的AI思考/工具执行。线程安全,可从任意线程调用。"""
@@ -934,6 +1093,12 @@ class DeepSeekChat:
except Exception: except Exception:
pass pass
def _reasoning_enabled(self) -> bool:
"""思考模式开关:client.reasoning_enabled 非 None 时优先(档案级构造参数),
否则回退全局配置;getattr 兼容未实现该契约的自定义 client"""
val = getattr(self.client, "reasoning_enabled", None)
return val if val is not None else constants.REASONING_ENABLED
def _is_cancelled(self) -> bool: def _is_cancelled(self) -> bool:
"""检查是否已被取消。""" """检查是否已被取消。"""
return self._cancel_event.is_set() return self._cancel_event.is_set()
@@ -1037,8 +1202,9 @@ class DeepSeekChat:
builtin_tools.append(SaveMemoryAndEndConversationTool(exit_callback, self.memory_db, self.prompt_manager, self.logger)) builtin_tools.append(SaveMemoryAndEndConversationTool(exit_callback, self.memory_db, self.prompt_manager, self.logger))
# 添加询问用户的工具 # 添加询问用户的工具
# I 批:ExpressEmotionTool 注入父取消事件(情绪子代理可被中断)
builtin_tools.append(AskUserTool(self.logger)) builtin_tools.append(AskUserTool(self.logger))
builtin_tools.append(ExpressEmotionTool(self.logger)) builtin_tools.append(ExpressEmotionTool(self.logger, cancel_event=self._cancel_event))
# 添加技能热重载工具 & 技能安装指引工具 & 创建技能工具 # 添加技能热重载工具 & 技能安装指引工具 & 创建技能工具
builtin_tools.append(ReloadSkillsTool(self.tool_registry, self.logger)) builtin_tools.append(ReloadSkillsTool(self.tool_registry, self.logger))
@@ -1049,7 +1215,8 @@ class DeepSeekChat:
builtin_tools.append(GetProxyTool(self.logger)) builtin_tools.append(GetProxyTool(self.logger))
# 子代理工具:主代理可派生独立上下文/角色的轻量子代理执行任务 # 子代理工具:主代理可派生独立上下文/角色的轻量子代理执行任务
builtin_tools.append(SubAgentTool(self.logger)) # cancel_event 注入(N5):父级取消传播到子代理
builtin_tools.append(SubAgentTool(self.logger, cancel_event=self._cancel_event))
# 注册内置工具(标记为内置) # 注册内置工具(标记为内置)
self.tool_registry.register_many(builtin_tools, is_builtin=True) self.tool_registry.register_many(builtin_tools, is_builtin=True)
@@ -1189,122 +1356,140 @@ class DeepSeekChat:
# 结果大小阈值(字符数),从配置获取 # 结果大小阈值(字符数),从配置获取
MAX_RESULT_LENGTH = constants.TOOL_MAX_RESULT_LENGTH MAX_RESULT_LENGTH = constants.TOOL_MAX_RESULT_LENGTH
for tool_call in tool_calls: # N2 修复(审计 2026-08-18):drain 线程改为整个工具批次仅创建一个,
# 每个工具调用前检查取消信号 # 由独立 stop 事件控制生命周期,批次结束(正常/异常/中断)在 finally
if self._is_cancelled(): # 中统一置位、join 并排空残余。此前每次工具调用都启动一个仅靠 cancel
self.logger.info("工具调用被用户中断") # 事件退出的常驻线程(注释声称"队列空自动退出",循环条件却从未检查
break # 队列是否为空),闭包长期持有 output_callback(含 websocket 引用)
# 阻碍 GC,常驻服务下线程与内存持续累积。
function = tool_call.get("function", {}) drain_stop = threading.Event()
tool_name = function.get("name", "unknown")
arguments = function.get("arguments", "{}") def _drain_queue(q=output_queue, cb=output_callback, stop_event=drain_stop):
tc_id = tool_call.get("id") or f"call_fallback_{self.stats['tool_calls']}_{int(time.time()*1000)}" """持续排空 output_queue 并转发 line 输出(长时间工具执行期间防 WS 空闲断线)"""
while not stop_event.is_set():
try:
args = json.loads(arguments) if arguments else {}
except json.JSONDecodeError:
args = {}
# 开始调用工具时发送提示
if output_callback:
output_callback("line", "\n\n")
output_callback("line", f"🔧 调用工具: {tool_name}\n")
result_raw = None
if self.tool_profile == TOOL_PROFILE_ORCHESTRATOR and tool_name in WORKER_ONLY_TOOLS:
# 编排者不持有执行面工具:拦截并纠正,引导改用 create_subagent
result_raw = (
f"⛔ '{tool_name}' 调用被拦截:你是编排者,没有执行面工具(命令/文件/网页由 worker 子代理持有)。\n"
"请改用 create_subagent 派生子代理执行该任务:在 role 中写明执行者身份,"
"在 task 中写清目标、具体步骤与验收标准(任务描述须自包含,子代理无法向你提问)。"
)
self.logger.warning(f"编排者误调执行面工具 {tool_name},已拦截并引导改用 create_subagent")
else:
try: try:
# 启动后台线程持续排空 output_queue,将流式输出实时转发给 output_callback, msg_type, content = q.get(timeout=0.1)
# 避免 SubAgent 等长时间工具执行期间前端收不到数据导致 WS 空闲断线 if cb and msg_type == "line":
def _drain_queue(q=output_queue, cb=output_callback, stop_event=self._cancel_event): cb("line", str(content) if content is not None else "")
while not stop_event.is_set(): except queue.Empty:
try: continue
msg_type, content = q.get(timeout=0.1)
if cb and msg_type == "line":
cb("line", str(content) if content is not None else "")
except queue.Empty:
continue
import threading as _th drain_thread = threading.Thread(target=_drain_queue, daemon=True,
drain_thread = _th.Thread(target=_drain_queue, daemon=True) name="tool-output-drain")
drain_thread.start() drain_thread.start()
result_raw = self.tool_registry.execute_tool(tool_name, **args) try:
for tool_call in tool_calls:
# 每个工具调用前检查取消信号
if self._is_cancelled():
self.logger.info("工具调用被用户中断")
break
# 工具执行完毕,排空队列中剩余内容并等待 drain 线程退出 function = tool_call.get("function", {})
while not output_queue.empty(): tool_name = function.get("name", "unknown")
try: arguments = function.get("arguments", "{}")
msg_type, content = output_queue.get_nowait() tc_id = tool_call.get("id") or f"call_fallback_{self.stats['tool_calls']}_{int(time.time()*1000)}"
if output_callback and msg_type == "line":
output_callback("line", str(content) if content is not None else "")
except queue.Empty:
break
# drain 线程会在下一次 get 超时后自动退出(队列已空)
except Exception as e:
result_raw = f"❌ 工具 {tool_name} 执行异常: {str(e)}"
self.logger.error(f"工具 {tool_name} 执行异常: {e}")
self.stats["tool_failed"] = True
# 子代理内部工具失败透传:SubAgentTool 在结果前缀打标记,此处检测并置 stats try:
if tool_name == "create_subagent" and result_raw and \ args = json.loads(arguments) if arguments else {}
str(result_raw).startswith(_SUBAGENT_TOOL_FAILED_MARKER): except json.JSONDecodeError:
self.stats["tool_failed"] = True args = {}
result_raw = str(result_raw)[len(_SUBAGENT_TOOL_FAILED_MARKER):]
# 安全转换并清理结果 # 开始调用工具时发送提示
result = self._sanitize_tool_result(result_raw)
# drain 线程已排空队列中的 line 输出,这里只需判断是否有流式输出
has_stream_output = not output_queue.empty()
# 普通(非流式)工具没有队列输出,直接发送结果
if not has_stream_output and output_callback and result:
output_callback("line", result + "\n")
# 检查结果大小,如果超过阈值,不保留截断内容,仅返回提示信息
if len(result) > MAX_RESULT_LENGTH:
truncated_note = f"⚠️ 工具调用结果长度超过 {MAX_RESULT_LENGTH} 字符,已丢弃。请使用更精确的查询获取所需信息。"
result = truncated_note
if output_callback: if output_callback:
output_callback("line", truncated_note + "\n") output_callback("line", "\n\n")
output_callback("line", f"🔧 调用工具: {tool_name}\n")
# 结束调用工具时发送换行
if output_callback: result_raw = None
output_callback("line", "\n\n") if self.tool_profile == TOOL_PROFILE_ORCHESTRATOR and tool_name in WORKER_ONLY_TOOLS:
# 编排者不持有执行面工具:拦截并纠正,引导改用 create_subagent
# 历史记录(安全截断) result_raw = (
if self.history_manager: f"⛔ '{tool_name}' 调用被拦截:你是编排者,没有执行面工具(命令/文件/网页由 worker 子代理持有)。\n"
"请改用 create_subagent 派生子代理执行该任务:在 role 中写明执行者身份,"
"在 task 中写清目标、具体步骤与验收标准(任务描述须自包含,子代理无法向你提问)。"
)
self.logger.warning(f"编排者误调执行面工具 {tool_name},已拦截并引导改用 create_subagent")
else:
try:
result_raw = self.tool_registry.execute_tool(tool_name, **args)
# 排空队列中剩余标记(complete 标记供 has_stream_output 判定、
# 残余 line 补发)——与 drain 线程并发消费安全(每项仅被消费一次)
while not output_queue.empty():
try:
msg_type, content = output_queue.get_nowait()
if output_callback and msg_type == "line":
output_callback("line", str(content) if content is not None else "")
except queue.Empty:
break
except Exception as e:
result_raw = f"❌ 工具 {tool_name} 执行异常: {str(e)}"
self.logger.error(f"工具 {tool_name} 执行异常: {e}")
self.stats["tool_failed"] = True
# 子代理内部工具失败透传:SubAgentTool 在结果前缀打标记,此处检测并置 stats
if tool_name == "create_subagent" and result_raw and \
str(result_raw).startswith(_SUBAGENT_TOOL_FAILED_MARKER):
self.stats["tool_failed"] = True
result_raw = str(result_raw)[len(_SUBAGENT_TOOL_FAILED_MARKER):]
# 安全转换并清理结果
result = self._sanitize_tool_result(result_raw)
# drain 线程持续转发 line 输出,这里通过 complete 标记判断是否流式工具
has_stream_output = not output_queue.empty()
# 普通(非流式)工具没有队列输出,直接发送结果
if not has_stream_output and output_callback and result:
output_callback("line", result + "\n")
# 检查结果大小,如果超过阈值,不保留截断内容,仅返回提示信息
if len(result) > MAX_RESULT_LENGTH:
truncated_note = f"⚠️ 工具调用结果长度超过 {MAX_RESULT_LENGTH} 字符,已丢弃。请使用更精确的查询获取所需信息。"
result = truncated_note
if output_callback:
output_callback("line", truncated_note + "\n")
# 结束调用工具时发送换行
if output_callback:
output_callback("line", "\n\n")
# 历史记录(安全截断)
if self.history_manager:
try:
history_preview = result[:200] + "..." if len(result) > 200 else result
self.history_manager.add_tool_call(tool_name, args, history_preview)
except Exception:
pass
# 检查是否结束对话
end_conversation = (tool_name == "save_memory_and_end_conversation" and "✅" in result)
if end_conversation:
self.logger.info("检测到保存记忆并结束对话工具调用")
self.conversation_active = False
tool_messages.append({
"role": "tool",
"tool_call_id": tc_id,
"content": result
})
if end_conversation:
break
finally:
# 停止 drain 线程并回收(线程 0.1s 轮询粒度内感知置位)
drain_stop.set()
drain_thread.join(timeout=2)
# 排空残余输出(drain 停止后无并发消费者,残余 line 补发)
while True:
try: try:
history_preview = result[:200] + "..." if len(result) > 200 else result msg_type, content = output_queue.get_nowait()
self.history_manager.add_tool_call(tool_name, args, history_preview) if output_callback and msg_type == "line":
except Exception: output_callback("line", str(content) if content is not None else "")
pass except queue.Empty:
break
# 检查是否结束对话 # 清除输出队列
end_conversation = (tool_name == "save_memory_and_end_conversation" and "✅" in result) self.tool_registry.set_output_queue(None)
if end_conversation:
self.logger.info("检测到保存记忆并结束对话工具调用")
self.conversation_active = False
tool_messages.append({
"role": "tool",
"tool_call_id": tc_id,
"content": result
})
if end_conversation:
break
# 清除输出队列
self.tool_registry.set_output_queue(None)
return tool_messages return tool_messages
def _should_search_memory(self, user_input: str) -> bool: def _should_search_memory(self, user_input: str) -> bool:
@@ -1343,8 +1528,9 @@ class DeepSeekChat:
def _think_and_respond_impl(self, input_text: str, output_callback: Callable = None, is_continuation: bool = False) -> Optional[str]: def _think_and_respond_impl(self, input_text: str, output_callback: Callable = None, is_continuation: bool = False) -> Optional[str]:
"""think_and_respond 的实际实现,在 _msg_lock 保护下执行。""" """think_and_respond 的实际实现,在 _msg_lock 保护下执行。"""
# 首轮开始时清除取消标志 # 首轮开始时清除取消标志(H1:共享父事件的子代理不清除——清除会
if not is_continuation: # 吞掉父级已置位的取消信号,使中断/--once 超时失效)
if not is_continuation and not getattr(self, "_shared_cancel_event", False):
self._cancel_event.clear() self._cancel_event.clear()
# 立即检查是否已被取消 # 立即检查是否已被取消
@@ -1451,7 +1637,7 @@ class DeepSeekChat:
# 工具调用后的递归继续(is_continuation=True)时不重复发送"思考中"提示, # 工具调用后的递归继续(is_continuation=True)时不重复发送"思考中"提示,
# 因为前端已处于活跃AI状态,重复提示会导致冗余/重复输出 # 因为前端已处于活跃AI状态,重复提示会导致冗余/重复输出
output_callback("line", f"\n{i18n.get('thinking', current_time)}\n") output_callback("line", f"\n{i18n.get('thinking', current_time)}\n")
if constants.REASONING_ENABLED: if self._reasoning_enabled():
output_callback("reasoning_start", "🤔 思考中...") output_callback("reasoning_start", "🤔 思考中...")
else: else:
# 普通模式先显示助手前缀 # 普通模式先显示助手前缀
@@ -1476,7 +1662,7 @@ class DeepSeekChat:
temperature=self.temperature, temperature=self.temperature,
max_tokens=constants.DEFAULT_MAX_TOKENS, max_tokens=constants.DEFAULT_MAX_TOKENS,
tools=tools, tools=tools,
reasoning_enabled=constants.REASONING_ENABLED, reasoning_enabled=self._reasoning_enabled(),
): ):
# 流式循环中检查取消信号 # 流式循环中检查取消信号
if self._is_cancelled(): if self._is_cancelled():
@@ -1906,8 +2092,19 @@ class WebSocketHandler:
self.logger = Logger("WebSocket") self.logger = Logger("WebSocket")
self.connected_clients = set() self.connected_clients = set()
self.client_sessions = {} self.client_sessions = {}
self._processing_tasks = {} # websocket id -> 当前正在处理的asyncio Task self._processing_tasks = {} # websocket -> {asyncio.Task}(N14:集合登记,支持并发多任务)
self._shared_skill_manager = None # 共享技能管理器(懒加载,供所有连接复用) self._shared_skill_manager = None # 共享技能管理器(懒加载,供所有连接复用)
# I 批(审计 2026-08-18):/new、/reload_prompts 等命令专用线程池——
# 此前 run_in_executor(None) 与 AI think 共用默认池,繁忙时 reset 任务
# 排队等锁,多连接并发 /new 可耗尽默认池(阻塞新消息处理)
self._cmd_executor = ThreadPoolExecutor(max_workers=2,
thread_name_prefix="ws-cmd")
def shutdown(self):
"""I 批:释放命令专用线程池(服务退出时调用)"""
if self._cmd_executor is not None:
self._cmd_executor.shutdown(wait=False)
self._cmd_executor = None
def _get_shared_skill_manager(self): def _get_shared_skill_manager(self):
"""获取共享技能管理器(懒加载一次,供所有连接实例复用,避免重复扫描技能)""" """获取共享技能管理器(懒加载一次,供所有连接实例复用,避免重复扫描技能)"""
@@ -1932,7 +2129,9 @@ class WebSocketHandler:
# 每个连接使用独立的命令执行器,互不共享,避免 cancel() 误杀其他用户/任务正在运行的子进程 # 每个连接使用独立的命令执行器,互不共享,避免 cancel() 误杀其他用户/任务正在运行的子进程
command_executor=SubprocessCommandExecutor(self.logger), command_executor=SubprocessCommandExecutor(self.logger),
prompt_manager=self.ai_manager.prompt_manager, prompt_manager=self.ai_manager.prompt_manager,
skill_manager=self._get_shared_skill_manager() skill_manager=self._get_shared_skill_manager(),
# H3:CLI --model 进程级覆盖传导到每个会话实例
model=getattr(self.ai_manager, "model", None)
) )
def _get_session(self, websocket) -> dict: def _get_session(self, websocket) -> dict:
@@ -1964,7 +2163,9 @@ class WebSocketHandler:
async def unregister(self, websocket): async def unregister(self, websocket):
"""注销客户端""" """注销客户端"""
self.connected_clients.remove(websocket) # N13:discard 替代 remove——并发注销/重复注销时 remove 抛 KeyError
# 会逃逸到调用方(如 broadcast 的 except 分支),discard 幂等
self.connected_clients.discard(websocket)
if websocket in self.client_sessions: if websocket in self.client_sessions:
session = self.client_sessions.pop(websocket) session = self.client_sessions.pop(websocket)
# 释放该连接独立的AI实例资源 # 释放该连接独立的AI实例资源
@@ -2072,7 +2273,8 @@ class WebSocketHandler:
for websocket in self.connected_clients.copy(): for websocket in self.connected_clients.copy():
try: try:
await self.send_message(websocket, message) await self.send_message(websocket, message)
except: except Exception:
# N13:不再裸 except——吞掉 CancelledError 会破坏任务取消语义
await self.unregister(websocket) await self.unregister(websocket)
async def broadcast_task_status(self, task: Dict, status: str, message: str = None): async def broadcast_task_status(self, task: Dict, status: str, message: str = None):
@@ -2266,12 +2468,20 @@ class WebSocketHandler:
if not ai.is_busy(): if not ai.is_busy():
break break
# 重置对话状态,重新加载系统提示词 # 重置对话状态,重新加载系统提示词
ai.reset_conversation(reload_prompts=True) # N3:reset_conversation 内部同步获取 _msg_lock(繁忙对话持锁
# 可达数十秒),直接调用会冻结整个事件循环(所有连接的心跳/
# 消息收发全部停摆)。移入线程池执行,事件循环仅等待结果。
loop = asyncio.get_running_loop()
def _do_reset():
ai.reset_conversation(reload_prompts=True)
# 重新初始化提示词管理器(确保最新)
self.ai_manager.prompt_manager.reload()
await loop.run_in_executor(self._cmd_executor, _do_reset)
# 重置该连接对话进行中标记 # 重置该连接对话进行中标记
if session: if session:
session["dialogue_in_progress"] = False session["dialogue_in_progress"] = False
# 重新初始化提示词管理器(确保最新)
self.ai_manager.prompt_manager.reload()
self.logger.info(i18n.get('new_conversation_reset')) self.logger.info(i18n.get('new_conversation_reset'))
await self.send_message(websocket, { await self.send_message(websocket, {
@@ -2366,10 +2576,17 @@ class WebSocketHandler:
elif command == "/reload_prompts": elif command == "/reload_prompts":
session = self._get_session(websocket) session = self._get_session(websocket)
ai = session.get("ai") if session else None ai = session.get("ai") if session else None
if ai: # M1(审计 2026-08-18):reload_prompts 内部同步获取 _msg_lock
new_count = ai.reload_prompts() # (繁忙对话持锁可达数十秒),直接调用会冻结整个事件循环——
else: # N3 只修了 /new,这里同类遗漏。移入线程池执行。
new_count = self.ai_manager.prompt_manager.reload() loop = asyncio.get_running_loop()
def _do_reload_prompts():
if ai:
return ai.reload_prompts()
return self.ai_manager.prompt_manager.reload()
new_count = await loop.run_in_executor(self._cmd_executor, _do_reload_prompts)
await self.send_message(websocket, { await self.send_message(websocket, {
"type": "command_result", "type": "command_result",
"content": i18n.get('prompts_reloaded', new_count) "content": i18n.get('prompts_reloaded', new_count)
@@ -2563,18 +2780,24 @@ class WebSocketHandler:
class EnhancedAIManager: class EnhancedAIManager:
"""增强的AI对话管理器 - 集成任务调度器""" """增强的AI对话管理器 - 集成任务调度器"""
def __init__(self, debug_level: int = None, load_history: bool = None, command_executor: CommandExecutor = None): def __init__(self, debug_level: int = None, load_history: bool = None, command_executor: CommandExecutor = None,
model: str = None):
self.ai: Optional[DeepSeekChat] = None self.ai: Optional[DeepSeekChat] = None
self.dialogue_history: List[Dict] = [] self.dialogue_history: List[Dict] = []
self.config = ConfigManager() self.config = ConfigManager()
self.command_executor = command_executor or SubprocessCommandExecutor() self.command_executor = command_executor or SubprocessCommandExecutor()
self.prompt_manager = PromptManager() # 创建提示词管理器 self.prompt_manager = PromptManager() # 创建提示词管理器
self.scheduler: Optional[TaskScheduler] = None # 任务调度器 self.scheduler: Optional[TaskScheduler] = None # 任务调度器
# H3:CLI --model 进程级覆盖(档案名或裸模型名),传导到本进程创建的所有 AI 实例
# (主对话 add_ai / WebSocket 会话 / 定时任务),此前服务模式该参数被静默忽略
self.model = model
if debug_level is None: if debug_level is None:
debug_level = self.config.get("system.debug_level", 1) debug_level = self.config.get("system.debug_level", 1)
self.logger = Logger("Manager", debug_level) self.logger = Logger("Manager", debug_level)
if model:
self.logger.info(f"CLI 模型覆盖已启用: {model}")
self.running = True self.running = True
# 确保 ~/.aibox 目录存在 # 确保 ~/.aibox 目录存在
@@ -2622,7 +2845,8 @@ class EnhancedAIManager:
memory_db=self.memory_db, memory_db=self.memory_db,
memo_db=self.memo_db, memo_db=self.memo_db,
command_executor=self.command_executor, command_executor=self.command_executor,
prompt_manager=self.prompt_manager prompt_manager=self.prompt_manager,
model=self.model # H3:--model 进程级覆盖
) )
self.ai = ai self.ai = ai
@@ -2722,20 +2946,26 @@ class EnhancedAIManager:
task = asyncio.create_task( task = asyncio.create_task(
self.ws_handler.handle_message(websocket, message) self.ws_handler.handle_message(websocket, message)
) )
self.ws_handler._processing_tasks[id(websocket)] = task # N14:按连接登记任务集合。此前按 ws id 覆盖式登记,同连接并发
# 多任务时先完成任务的 done_callback 会误 pop 掉后续任务的登记
tasks = self.ws_handler._processing_tasks.setdefault(websocket, set())
tasks.add(task)
def _cleanup_task(t, ws_id=id(websocket)): def _cleanup_task(t, ws=websocket):
self.ws_handler._processing_tasks.pop(ws_id, None) pending = self.ws_handler._processing_tasks.get(ws)
if pending is not None:
pending.discard(t)
task.add_done_callback(_cleanup_task) task.add_done_callback(_cleanup_task)
except websockets.exceptions.ConnectionClosed as e: except websockets.exceptions.ConnectionClosed as e:
self.logger.info(f"连接关闭: {e}") self.logger.info(f"连接关闭: {e}")
except Exception as e: except Exception as e:
self.logger.error(f"处理错误: {e}") self.logger.error(f"处理错误: {e}")
finally: finally:
# 连接关闭时取消正在进行的处理任务 # 连接关闭时取消该连接所有仍在进行的处理任务(N14:此前仅最后一个)
task = self.ws_handler._processing_tasks.pop(id(websocket), None) pending = self.ws_handler._processing_tasks.pop(websocket, set())
if task and not task.done(): for t in pending:
task.cancel() if not t.done():
t.cancel()
await self.ws_handler.unregister(websocket) await self.ws_handler.unregister(websocket)
async def websocket_server(self): async def websocket_server(self):
@@ -2744,12 +2974,17 @@ class EnhancedAIManager:
port = constants.WEBSOCKET_PORT port = constants.WEBSOCKET_PORT
self.ws_handler = WebSocketHandler(self) self.ws_handler = WebSocketHandler(self)
# M2(审计 2026-08-18):技能扫描对每个可执行技能串行跑 --help
# 子进程(各 ≤10s),懒加载于首条消息/首个任务时在事件循环线程
# 执行会冻结整个服务。启动前预热,把扫描成本移到连接建立之前。
self.ws_handler._get_shared_skill_manager()
# 如果启用了任务调度器,创建并启动 # 如果启用了任务调度器,创建并启动
if constants.SCHEDULER_ENABLED: if constants.SCHEDULER_ENABLED:
self.scheduler = TaskScheduler(asyncio.get_running_loop(), self.logger) self.scheduler = TaskScheduler(asyncio.get_running_loop(), self.logger)
self.scheduler.set_websocket_handler(self.ws_handler) self.scheduler.set_websocket_handler(self.ws_handler)
self.scheduler.set_ai_manager(self) # 设置AI管理器引用 self.scheduler.set_ai_manager(self) # 设置AI管理器引用
self.scheduler._get_shared_skill_manager()
async def handler(websocket): async def handler(websocket):
await self.handle_connection(websocket, None) await self.handle_connection(websocket, None)
@@ -2775,6 +3010,8 @@ class EnhancedAIManager:
finally: finally:
if self.scheduler: if self.scheduler:
self.scheduler.stop() self.scheduler.stop()
# I 批:释放命令专用线程池
self.ws_handler.shutdown()
def run(self): def run(self):
"""运行WebSocket服务器""" """运行WebSocket服务器"""
@@ -2846,7 +3083,7 @@ def parse_args():
parser.add_argument('--no-save-memory', action='store_true', help='一次性对话不注册记忆工具,禁止写入记忆数据库') parser.add_argument('--no-save-memory', action='store_true', help='一次性对话不注册记忆工具,禁止写入记忆数据库')
parser.add_argument('--show-reasoning', action='store_true', help='一次性对话在 stderr 显示思考过程流(默认不显示)') parser.add_argument('--show-reasoning', action='store_true', help='一次性对话在 stderr 显示思考过程流(默认不显示)')
parser.add_argument('--worker-only', action='store_true', help='一次性对话以 worker 纯执行面模式运行(7 种执行工具,无记忆/人格/派生)') parser.add_argument('--worker-only', action='store_true', help='一次性对话以 worker 纯执行面模式运行(7 种执行工具,无记忆/人格/派生)')
parser.add_argument('--model', type=str, metavar='NAME', help='覆盖配置中的模型名(如 deepseek-chat / deepseek-reasoner)') parser.add_argument('--model', type=str, metavar='NAME', help='覆盖配置中的模型:档案名(models 节,如 kimi)或裸模型名(同端点换模型);歧义时可用 model:NAME 强制裸名 / profile:NAME 强制档案。服务模式与 --once 均生效(进程级覆盖)')
parser.add_argument('--temperature', type=float, metavar='T', help='覆盖默认采样温度(0.0~2.0,越低越确定)') parser.add_argument('--temperature', type=float, metavar='T', help='覆盖默认采样温度(0.0~2.0,越低越确定)')
return parser.parse_args() return parser.parse_args()
@@ -3047,6 +3284,19 @@ def handle_once_mode(manager, args) -> bool:
def _on_timeout(): def _on_timeout():
state["timed_out"] = True state["timed_out"] = True
manager.cancel() manager.cancel()
# I 批(F9,审计 2026-08-18):软超时兜底——cancel 后若 AI 卡死在
# 不可中断路径(HTTP 请求空窗、忽略信号的子进程),think 可能永不
# 返回、进程挂起(退出码 4 永远到不了)。宽限一个超时周期后强杀进程,
# 保证 --once 有硬性结束语义
def _hard_exit():
print(f"\n⏰ 一次性对话强制超时({args.once_timeout * 2} 秒),已终止",
file=sys.stderr)
sys.stderr.flush()
os._exit(4)
hard_timer = threading.Timer(max(1, args.once_timeout), _hard_exit)
hard_timer.daemon = True
hard_timer.start()
timer = threading.Timer(max(1, args.once_timeout), _on_timeout) timer = threading.Timer(max(1, args.once_timeout), _on_timeout)
timer.daemon = True timer.daemon = True
timer.start() timer.start()
@@ -3136,7 +3386,8 @@ def main():
manager = EnhancedAIManager( manager = EnhancedAIManager(
debug_level=args.debug, debug_level=args.debug,
command_executor=command_executor command_executor=command_executor,
model=args.model # H3:--model 服务模式同样生效(进程级覆盖)
) )
if handle_check_commands(manager): if handle_check_commands(manager):
+83 -18
View File
@@ -8,6 +8,7 @@ common.config - aibox 配置管理器
import os import os
import json import json
import copy
from typing import Dict, Optional from typing import Dict, Optional
from .i18n import i18n from .i18n import i18n
@@ -64,14 +65,36 @@ class ConfigManager:
# 大模型调用接口 # 大模型调用接口
"api": { "api": {
"provider": "openai_compatible", # 提供商:deepseek / openai_compatible(OpenAI Chat Completions 协议兼容)
"deepseek_api_url": "https://api.deepseek.com/chat/completions", # deepseek API URL "deepseek_api_url": "https://api.deepseek.com/chat/completions", # deepseek API URL
"deepseek_model": "deepseek-v4-flash", # 模型名字 "deepseek_model": "deepseek-v4-flash", # 模型名字
"reasoning_enabled": False, # 开启思维链 "reasoning_enabled": False, # 开启思维链
"api_key_env": "DEEPSEEK_API_KEY", # API-KEY所在的环境变量 "api_key_env": "DEEPSEEK_API_KEY", # API-KEY所在的环境变量
"timeout": 120, # 调用大模型请求超时时间 "timeout": 120, # 调用大模型请求超时时间
"temperature": 0.7 # 大模型对话温度(类似思维发散程度) "temperature": 0.7, # 大模型对话温度(类似思维发散程度)
"default_model": "deepseek" # 默认模型档案名(对应 models 节的 key)
}, },
# 多模型档案:可配置多个 OpenAI Chat Completions 兼容端点,按名称选择
# 示例(在 ~/.aibox/config.json 中添加):
# "models": {
# "kimi": {
# "api_url": "https://api.moonshot.cn/v1/chat/completions",
# "model": "kimi-k2",
# "api_key_env": "MOONSHOT_API_KEY", # 未配置回退 api.api_key_env;空串 "" 为免密端点
# "reasoning_enabled": false,
# "timeout": 120
# },
# "local": {
# "api_url": "http://localhost:11434/v1/chat/completions",
# "model": "qwen3:32b",
# "api_key_env": ""
# }
# }
# 选择优先级:--model <档案名> > api.default_model > 回退旧 api 扁平节。
# 默认留空:用户未配置档案时走旧 api 节(零破坏)。
"models": {},
# 大模型上下文 # 大模型上下文
"context": { "context": {
"max_history_messages": 500, # 最大历史消息数量 "max_history_messages": 500, # 最大历史消息数量
@@ -165,6 +188,7 @@ class ConfigManager:
print(i18n.get('config_not_found', config_path)) print(i18n.get('config_not_found', config_path))
self._config = self._get_default_config() self._config = self._get_default_config()
self._save_default_config(config_path) self._save_default_config(config_path)
self._migrate_legacy_proxy_password()
def _merge_configs(self, default: Dict, user: Dict) -> Dict: def _merge_configs(self, default: Dict, user: Dict) -> Dict:
result = default.copy() result = default.copy()
@@ -198,6 +222,28 @@ class ConfigManager:
self._load_config() self._load_config()
def _resolve_proxy_password(self, proxy_conf: Dict) -> Optional[str]:
"""解析代理密码:内存明文优先,否则按 password_env 查环境变量。
对齐 api_key_env 惯例:配置文件只持久化环境变量名,明文密码不落盘。
"""
password = proxy_conf.get("password")
if password:
return password
env_name = proxy_conf.get("password_env")
if env_name:
return os.environ.get(env_name) or None
return None
def _migrate_legacy_proxy_password(self):
"""一次性迁移:旧版把代理密码明文落盘。加载后明文保留在内存供本会话
使用,同时立即重写脱敏配置文件,清除盘上存量明文。"""
socks = (self._config or {}).get("proxy", {}).get("socks5")
if socks and socks.get("password"):
print("⚠️ 检测到配置文件中的代理明文密码:已保留供本次会话使用,不再落盘。"
"跨重启请将密码存入环境变量,并把 proxy.socks5.password_env 指向该变量名")
self._save_config()
def get_proxy(self) -> Optional[Dict[str, str]]: def get_proxy(self) -> Optional[Dict[str, str]]:
"""获取当前代理配置(返回 requests 可用的 proxies 字典)""" """获取当前代理配置(返回 requests 可用的 proxies 字典)"""
proxy_conf = self.get("proxy.socks5") proxy_conf = self.get("proxy.socks5")
@@ -206,15 +252,20 @@ class ConfigManager:
host = proxy_conf.get("host", "127.0.0.1") host = proxy_conf.get("host", "127.0.0.1")
port = proxy_conf.get("port", 1080) port = proxy_conf.get("port", 1080)
username = proxy_conf.get("username") username = proxy_conf.get("username")
password = proxy_conf.get("password") password = self._resolve_proxy_password(proxy_conf)
if username and password: if username and password:
proxy_url = f"socks5://{username}:{password}@{host}:{port}" proxy_url = f"socks5://{username}:{password}@{host}:{port}"
else: else:
proxy_url = f"socks5://{host}:{port}" proxy_url = f"socks5://{host}:{port}"
return {"http": proxy_url, "https": proxy_url} return {"http": proxy_url, "https": proxy_url}
def set_proxy(self, host: str, port: int, username: str = None, password: str = None, enabled: bool = True, save: bool = True): def set_proxy(self, host: str, port: int, username: str = None, password: str = None,
"""动态设置代理配置(内存中更新,并可选择写入配置文件)""" password_env: str = None, enabled: bool = True, save: bool = True):
"""动态设置代理配置(内存中更新,并可选择写入配置文件)。
password 明文仅存内存(_save_config 落盘时强制脱敏);
跨重启持久化请用 password_env 指向存有密码的环境变量名。
"""
# 确保 proxy 节点存在 # 确保 proxy 节点存在
if "proxy" not in self._config: if "proxy" not in self._config:
self._config["proxy"] = {} self._config["proxy"] = {}
@@ -223,31 +274,45 @@ class ConfigManager:
"host": host, "host": host,
"port": port, "port": port,
"username": username, "username": username,
"password": password "password": password,
"password_env": password_env
} }
if save: if save:
self._save_config() return self._save_config()
return True return True
def _save_config(self): def _save_config(self) -> bool:
"""将当前配置保存到配置文件(覆盖)""" """将当前配置保存到配置文件(覆盖)。返回是否成功落盘。
落盘前对代理密码做纵深防御脱敏:无论调用路径如何,明文密码一律不写入文件。
L2(审计 2026-08-18):此前异常仅 print,set_proxy 无条件报"已保存",
磁盘只读等场景下用户以为已持久化,重启后配置丢失。
"""
config_path = self._config_path config_path = self._config_path
if config_path: if not config_path:
try: return False
os.makedirs(os.path.dirname(config_path), exist_ok=True) try:
with open(config_path, 'w', encoding='utf-8') as f: safe_config = copy.deepcopy(self._config)
json.dump(self._config, f, ensure_ascii=False, indent=2) socks = safe_config.get("proxy", {}).get("socks5")
print(f"✅ 配置已保存到 {config_path}") if socks and socks.get("password"):
except Exception as e: socks["password"] = None
print(f"⚠️ 保存配置失败: {e}") os.makedirs(os.path.dirname(config_path), exist_ok=True)
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(safe_config, f, ensure_ascii=False, indent=2)
print(f"✅ 配置已保存到 {config_path}")
return True
except Exception as e:
print(f"⚠️ 保存配置失败: {e}")
return False
def get_proxy_info(self) -> Dict: def get_proxy_info(self) -> Dict:
"""获取代理配置的详细信息(用于显示)""" """获取代理配置的详细信息(用于显示,不回传任何密码值)"""
proxy_conf = self.get("proxy.socks5", {}) proxy_conf = self.get("proxy.socks5", {})
return { return {
"enabled": proxy_conf.get("enabled", False), "enabled": proxy_conf.get("enabled", False),
"host": proxy_conf.get("host", ""), "host": proxy_conf.get("host", ""),
"port": proxy_conf.get("port", 0), "port": proxy_conf.get("port", 0),
"username": proxy_conf.get("username"), "username": proxy_conf.get("username"),
"password": proxy_conf.get("password") is not None # 只显示是否存在 "password": self._resolve_proxy_password(proxy_conf) is not None, # 只显示是否存在
"password_env": proxy_conf.get("password_env") or ""
} }
+489 -34
View File
@@ -22,27 +22,48 @@ from typing import Dict, Iterator, List, Optional
import json import json
import os import os
import re
import time
import requests import requests
# L12(审计 2026-08-18):瞬时错误体可能回显网关/WAF 错误页中的
# Authorization 头,抹掉 Bearer 令牌后再进日志,防密钥落盘
_AUTH_HEADER_RE = re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]+")
def _sanitize_log_msg(msg: str) -> str:
"""日志脱敏:替换错误消息中的 Bearer 令牌。"""
if not msg:
return msg
return _AUTH_HEADER_RE.sub("Bearer ***", msg)
class LLMClient(ABC): class LLMClient(ABC):
"""大模型客户端抽象基类。子类需实现流式/非流式两种调用,并归一化为统一事件格式。""" """大模型客户端抽象基类。子类需实现流式/非流式两种调用,并归一化为统一事件格式。"""
def __init__(self, api_key: str, model: str, base_url: str, def __init__(self, api_key: str, model: str, base_url: str,
timeout: int, config=None, logger=None): timeout: int, config=None, logger=None,
reasoning_enabled: bool = None,
display_name: str = None, default_temperature: float = None):
self.api_key = api_key self.api_key = api_key
self.model = model self.model = model
self.base_url = base_url self.base_url = base_url
self.timeout = timeout self.timeout = timeout
self.config = config # 可选,用于读取代理等动态配置 self.config = config # 可选,用于读取代理等动态配置
self.logger = logger # 可选,用于日志输出 self.logger = logger # 可选,用于日志输出
# M3:档案级思考模式开关为构造契约;None = 未配置(消费方回退全局 api.reasoning_enabled)
self.reasoning_enabled = reasoning_enabled
# L1:展示名(默认取档案名),业务层上下文(如 history 的 assistant 名)不再误标 "DeepSeek"
self._display_name = display_name
# L3:档案级默认采样温度;None = 未配置(方法参数未显式传入时回退硬编码默认值)
self.default_temperature = default_temperature
# 当前活动的 HTTP 响应引用,供 cancel() 强制关闭底层连接以中断阻塞读取 # 当前活动的 HTTP 响应引用,供 cancel() 强制关闭底层连接以中断阻塞读取
self._current_response = None self._current_response = None
@property @property
def name(self) -> str: def name(self) -> str:
"""厂商/模型展示名,业务层可用于上下文(如 history 里的 assistant 名)。""" """厂商/模型展示名,业务层可用于上下文(如 history 里的 assistant 名)。"""
return "LLM" return self._display_name or "LLM"
# ---------- 工具方法 ---------- # ---------- 工具方法 ----------
@@ -55,12 +76,16 @@ class LLMClient(ABC):
def _log(self, level: str, msg: str): def _log(self, level: str, msg: str):
if self.logger is not None and hasattr(self.logger, level): if self.logger is not None and hasattr(self.logger, level):
try: try:
getattr(self.logger, level)(msg) getattr(self.logger, level)(_sanitize_log_msg(msg))
except Exception: except Exception:
pass pass
def cancel(self): def cancel(self):
"""中断当前流式请求。线程安全,可从任意线程调用。""" """中断当前流式请求。线程安全,可从任意线程调用。
注:用户中断标志由 FailoverClient 维护(其 cancel() 广播整条链并置
自身 _cancelled,据此跳过转移)——底层 client 只负责强关连接。
"""
resp = self._current_response resp = self._current_response
self._current_response = None self._current_response = None
if resp is not None: if resp is not None:
@@ -78,16 +103,18 @@ class LLMClient(ABC):
# ---------- 抽象接口 ---------- # ---------- 抽象接口 ----------
@abstractmethod @abstractmethod
def chat_completion(self, messages: List[Dict], temperature: float = 0.3, def chat_completion(self, messages: List[Dict], temperature: float = None,
max_tokens: int = 1024, **kwargs) -> str: max_tokens: int = 1024, **kwargs) -> str:
"""非流式对话,返回完整文本。用于对话压缩等离线摘要场景。""" """非流式对话,返回完整文本。用于对话压缩等离线摘要场景。
temperature=None 时按 default_temperature(档案级)→ 0.3 回退。"""
@abstractmethod @abstractmethod
def stream_chat(self, messages: List[Dict], temperature: float = 0.7, def stream_chat(self, messages: List[Dict], temperature: float = None,
max_tokens: int = 8192, tools: Optional[List[Dict]] = None, max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
tool_choice: Optional[str] = None, tool_choice: Optional[str] = None,
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]: reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
"""流式对话,逐条 yield 统一事件格式的 dict。业务层据此驱动输出/工具调用。""" """流式对话,逐条 yield 统一事件格式的 dict。业务层据此驱动输出/工具调用。
temperature=None 时按 default_temperature(档案级)→ 0.7 回退。"""
class DeepSeekClient(LLMClient): class DeepSeekClient(LLMClient):
@@ -99,12 +126,18 @@ class DeepSeekClient(LLMClient):
""" """
def __init__(self, api_key: str, model: str, base_url: str, def __init__(self, api_key: str, model: str, base_url: str,
timeout: int = 30, config=None, logger=None): timeout: int = 30, config=None, logger=None,
super().__init__(api_key, model, base_url, timeout, config, logger) reasoning_enabled: bool = None,
display_name: str = None, default_temperature: float = None):
super().__init__(api_key, model, base_url, timeout, config, logger,
reasoning_enabled=reasoning_enabled,
display_name=display_name,
default_temperature=default_temperature)
@property @property
def name(self) -> str: def name(self) -> str:
return "DeepSeek" # L1:档案命中时展示档案名(或档案 display_name),旧扁平节保持 "DeepSeek"
return self._display_name or "DeepSeek"
def _headers(self) -> Dict[str, str]: def _headers(self) -> Dict[str, str]:
return { return {
@@ -112,8 +145,11 @@ class DeepSeekClient(LLMClient):
"Content-Type": "application/json", "Content-Type": "application/json",
} }
def chat_completion(self, messages: List[Dict], temperature: float = 0.3, def chat_completion(self, messages: List[Dict], temperature: float = None,
max_tokens: int = 1024, **kwargs) -> str: max_tokens: int = 1024, **kwargs) -> str:
# L3:None → 档案级 default_temperature → 硬编码默认(与旧版行为一致)
if temperature is None:
temperature = self.default_temperature if self.default_temperature is not None else 0.3
payload = { payload = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
@@ -132,16 +168,29 @@ class DeepSeekClient(LLMClient):
if response.status_code == 200: if response.status_code == 200:
result = response.json() result = response.json()
return result["choices"][0]["message"]["content"].strip() return result["choices"][0]["message"]["content"].strip()
if response.status_code == 429 or response.status_code >= 500:
# L4a:瞬时故障显式抛出——FailoverClient 据此转移下一档案;
# 无包装器时由调用方 catch-all 接住,下游仍走失败分支(语义等价)
raise TransientLLMError(
f"LLM 请求瞬时失败: HTTP {response.status_code}: {response.text[:200]}",
retry_after=_retry_after_of(response))
self._log("error", f"LLM 压缩请求失败: HTTP {response.status_code}") self._log("error", f"LLM 压缩请求失败: HTTP {response.status_code}")
return None return None
except TransientLLMError:
raise # L4a:瞬时故障上抛供 FailoverClient 转移,不吞
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
raise # L4a:连接类瞬时故障同样上抛(原被吞成 None,无法转移)
except Exception as e: except Exception as e:
self._log("error", f"LLM 压缩请求异常: {e}") self._log("error", f"LLM 压缩请求异常: {e}")
return None return None
def stream_chat(self, messages: List[Dict], temperature: float = 0.7, def stream_chat(self, messages: List[Dict], temperature: float = None,
max_tokens: int = 8192, tools: Optional[List[Dict]] = None, max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
tool_choice: Optional[str] = None, tool_choice: Optional[str] = None,
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]: reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
# L3:None → 档案级 default_temperature → 硬编码默认(与旧版行为一致)
if temperature is None:
temperature = self.default_temperature if self.default_temperature is not None else 0.7
payload = { payload = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
@@ -170,14 +219,19 @@ class DeepSeekClient(LLMClient):
self._current_response = response self._current_response = response
if response.status_code != 200: if response.status_code != 200:
if response.status_code == 429 or response.status_code >= 500:
# L4b:瞬时故障显式分类(此时尚未产出任何事件,可安全转移重放)
raise TransientLLMError(
f"LLM 流式请求瞬时失败: HTTP {response.status_code}: {response.text[:200]}",
retry_after=_retry_after_of(response))
raise RuntimeError( raise RuntimeError(
f"HTTP {response.status_code}: {response.text}") f"HTTP {response.status_code}: {response.text[:200]}")
try: try:
for line in response.iter_lines(): for line in response.iter_lines():
if not line: if not line:
continue continue
line_text = line.decode("utf-8") line_text = line.decode("utf-8", errors="replace") # L3:非 UTF-8 行不中断流
if not line_text.startswith("data: "): if not line_text.startswith("data: "):
continue continue
data = line_text[6:] data = line_text[6:]
@@ -198,11 +252,19 @@ class DeepSeekClient(LLMClient):
finally: finally:
self._current_response = None self._current_response = None
# L5:思考内容的常见厂商字段别名(首个命中的非空字段生效)
REASONING_FIELDS = ("reasoning_content", "reasoning", "thinking", "thinking_content")
def _parse_delta(self, delta: Dict, reasoning_enabled: bool) -> Iterator[Dict]: def _parse_delta(self, delta: Dict, reasoning_enabled: bool) -> Iterator[Dict]:
"""将单条 delta 归一化为统一事件。子类覆写以适配不同厂商字段。""" """将单条 delta 归一化为统一事件。子类覆写以适配不同厂商字段。"""
# 思考内容 # 思考内容:DeepSeek reasoning_content / OpenRouter reasoning /
if reasoning_enabled and delta.get("reasoning_content"): # 部分 GLM thinking(_content) —— 此前仅识别 DeepSeek 字段,其他厂商思考流静默丢失
yield {"type": "reasoning", "text": delta["reasoning_content"]} if reasoning_enabled:
for field in self.REASONING_FIELDS:
value = delta.get(field)
if value:
yield {"type": "reasoning", "text": value}
break
# 正文 # 正文
if delta.get("content"): if delta.get("content"):
yield {"type": "content", "text": delta["content"]} yield {"type": "content", "text": delta["content"]}
@@ -219,17 +281,251 @@ class DeepSeekClient(LLMClient):
} }
def resolve_api_key(api_key: Optional[str], config=None) -> str: class TransientLLMError(RuntimeError):
"""解析 API Key:优先显式传入,其次环境变量,最后 ~/.cert/deepseek.key。""" """瞬时 LLM 故障(HTTP 429/5xx)——FailoverClient 据此转移下一档案(L4a)。
L4c:retry_after 携带服务端 Retry-After 响应头(数字秒)建议冷却时长,
FailoverClient 以其替代默认冷却期(429 限流时尊重服务端节奏)。
"""
def __init__(self, message: str, retry_after: Optional[float] = None):
super().__init__(message)
self.retry_after = retry_after
def _retry_after_of(response) -> Optional[float]:
"""从 Retry-After 响应头解析冷却秒数(仅支持数字秒格式,其余忽略返回 None)。
P2:上限 cap 3600s——异常/恶意服务端返回超大值(如 999999)时,
避免档案被单条响应头冷却数天。
"""
try:
v = response.headers.get("Retry-After")
if v is None:
return None
return min(max(1.0, float(v)), 3600.0)
except (TypeError, ValueError, AttributeError):
return None
class FailoverClient:
"""跨档案失败转移包装器(L4a)。
持有序 client 链(主档案 + fallback 档案),对外实现与 LLMClient 相同的
消费面(未显式定义的属性经 __getattr__ 委托主 client)。
L4a 范围——chat_completion(非流式幂等)按链转移:
- 转移:HTTP 429/5xx(TransientLLMError)、连接失败/超时(requests 连接类异常)
- 不转移:认证/参数类 4xx(底层返回 None,直接透传——配置问题应响亮暴露)
失败档案进入冷却期(cooldown_seconds,默认 60s),期间直接跳过,
避免每次请求都撞刚失败的端点。链耗尽返回 None(与 chat_completion 失败契约一致)。
L4b 范围——stream_chat 仅**首事件前**故障转移(连接失败/HTTP 429/5xx,
尚未产出任何 reasoning/content/tool_call 事件,重放对用户不可见);
已产出事件后失败直接上抛(重放会导致前端重复输出/工具增量错乱);
用户中断(cancel() 置位)引发的连接异常绝不转移。
L4c 范围——可观测性与预算控制:
- get_stats() 暴露 requests/failovers/cooldown_skips/exhausted 聚合计数
- on_failover(from_name, to_name, reason) 回调(to_name=None 表示链耗尽),
供管理面/前端通知接入,回调异常不影响主流程
- timeout_budget 限制整条链尝试的总秒数(缺省不限),耗尽即停止尝试剩余档案,
防止长链故障时超时叠加(3 档案最坏 3×timeout)
- 429 响应带 Retry-After(数字秒)时按其延长该档案冷却期
并发契约(P4):实例非线程安全(_cancelled/_call_started/_stats 无锁)。
当前调用面满足"单线程串行调用 + cancel() 可从其他线程"的模式(compressor
在 _msg_lock 内同步调用、子代理各持独立实例);如需多线程共享调用,
需由调用方加锁或为实例补锁。
"""
def __init__(self, clients: List, logger=None, cooldown_seconds: float = 60.0,
timeout_budget: Optional[float] = None, on_failover=None):
self._clients = list(clients)
self.logger = logger
self.cooldown_seconds = cooldown_seconds
self._fail_at = {} # client.name → (失败时刻, 冷却秒数)
self._cancelled = False
self._stats = {"requests": 0, "failovers": 0, "cooldown_skips": 0, "exhausted": 0}
if timeout_budget is not None and timeout_budget <= 0:
self._log("warning", f"timeout_budget 非法({timeout_budget} <= 0),按未配置处理")
timeout_budget = None
self.timeout_budget = timeout_budget
self.on_failover = on_failover
self._call_started = None
def __getattr__(self, item):
# 未显式定义的属性/方法委托主 client(name/model/api_key/cancel 等);
# 下划线属性直接报错,防 __init__ 前访问 self._clients 的无限递归
if item.startswith("_"):
raise AttributeError(item)
return getattr(self._clients[0], item)
def _log(self, level: str, msg: str):
if self.logger is not None and hasattr(self.logger, level):
try:
getattr(self.logger, level)(_sanitize_log_msg(msg))
except Exception:
pass
def _cooling(self, client) -> bool:
entry = self._fail_at.get(client.name)
if entry is None:
return False
t, cd = entry
return (time.monotonic() - t) < cd
def _mark_fail(self, client, cooldown: Optional[float] = None):
# L4c:429 Retry-After(经 TransientLLMError.retry_after)覆盖默认冷却时长
self._fail_at[client.name] = (time.monotonic(),
cooldown if cooldown and cooldown > 0
else self.cooldown_seconds)
def _budget_exhausted(self) -> bool:
if self.timeout_budget is None or self._call_started is None:
return False
return (time.monotonic() - self._call_started) >= self.timeout_budget
def get_stats(self) -> Dict:
"""L4c:转移统计快照(requests/failovers/cooldown_skips/exhausted)。"""
return dict(self._stats)
def _notify_failover(self, from_name: str, to_name, reason: str):
self._stats["failovers"] += 1
try:
if self.on_failover is not None:
self.on_failover(from_name, to_name, _sanitize_log_msg(reason))
except Exception as e:
self._log("warning", f"on_failover 回调异常(忽略): {e}")
def cancel(self):
"""L4b:中断链上全部 client(任一可能正在流式中)并置自身中断标志。
显式定义以覆盖 __getattr__ 委托——包装器必须同时通知整条链。
"""
self._cancelled = True
for client in self._clients:
try:
client.cancel()
except Exception:
pass
def chat_completion(self, messages: List[Dict], temperature: float = None,
max_tokens: int = 1024, **kwargs):
self._cancelled = False # 新一轮请求,清除上一轮中断标志
self._call_started = time.monotonic()
self._stats["requests"] += 1
last_exc = None
tried = 0
for idx, client in enumerate(self._clients):
if self._cooling(client):
self._stats["cooldown_skips"] += 1
self._log("info", f"模型档案 {client.name} 处于失败冷却期,跳过")
continue
if self._budget_exhausted():
self._log("warning",
f"失败转移时间预算({self.timeout_budget}s)已耗尽,停止尝试剩余档案")
break
tried += 1
try:
result = client.chat_completion(messages, temperature=temperature,
max_tokens=max_tokens, **kwargs)
if result is not None:
return result
# None:认证/参数类 4xx 或响应异常——不可转移,直接透传
return None
except (TransientLLMError, requests.exceptions.RequestException) as e:
if self._cancelled:
return None # 用户中断引发的异常:不转移
last_exc = e
self._mark_fail(client, cooldown=getattr(e, "retry_after", None))
to_name = (self._clients[idx + 1].name
if idx + 1 < len(self._clients) else None)
self._notify_failover(client.name, to_name, str(e))
self._log("warning", f"模型档案 {client.name} 瞬时故障({e}),转移下一档案")
self._stats["exhausted"] += 1 # 末尾统一计数(链耗尽/预算耗尽/全冷却)
self._log("error", f"全部 {len(self._clients)} 个模型档案均不可用(尝试 {tried} 个):{last_exc}")
return None
def stream_chat(self, messages: List[Dict], temperature: float = None,
max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
tool_choice: Optional[str] = None,
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
"""L4b:流式转移——仅首事件前故障可安全重放(对用户不可见)。"""
self._cancelled = False # 新一轮请求,清除上一轮中断标志
self._call_started = time.monotonic()
self._stats["requests"] += 1
last_exc = None
for idx, client in enumerate(self._clients):
if self._cooling(client):
self._stats["cooldown_skips"] += 1
self._log("info", f"模型档案 {client.name} 处于失败冷却期,跳过")
continue
if self._budget_exhausted():
self._log("warning",
f"失败转移时间预算({self.timeout_budget}s)已耗尽,停止尝试剩余档案")
break
produced_any = False
try:
for event in client.stream_chat(
messages, temperature=temperature, max_tokens=max_tokens,
tools=tools, tool_choice=tool_choice,
reasoning_enabled=reasoning_enabled, **extra):
produced_any = True
yield event
return # 自然结束(含 done 事件)视为成功
except (TransientLLMError, requests.exceptions.RequestException) as e:
if self._cancelled:
raise # 用户中断(cancel 强关连接):绝不转移
if produced_any:
raise # 已向消费方产出事件,重放会重复输出/工具增量错乱
last_exc = e
self._mark_fail(client, cooldown=getattr(e, "retry_after", None))
to_name = (self._clients[idx + 1].name
if idx + 1 < len(self._clients) else None)
self._notify_failover(client.name, to_name, str(e))
self._log("warning",
f"模型档案 {client.name} 流式瞬时故障(首事件前,{e}),转移下一档案")
self._stats["exhausted"] += 1
if last_exc is not None:
raise last_exc # 链耗尽:抛最后异常,由既有错误处理(回滚/报错)接住
raise RuntimeError(
f"全部 {len(self._clients)} 个模型档案不可用(冷却期或转移时间预算耗尽)")
def resolve_api_key(api_key: Optional[str], config=None,
env_name: Optional[str] = None,
allow_missing: bool = False,
allow_key_file: bool = True) -> str:
"""解析 API Key:优先显式传入,其次环境变量,最后 ~/.cert/deepseek.key。
env_name: 档案级环境变量名(来自 models.<name>.api_key_env);
None 时回退 config 的 api.api_key_env。
allow_missing: 本地免密端点(models.<name>.api_key_env 为空串)时,
允许无 Key,返回空串(Bearer 空头,Ollama/vLLM 忽略)。
allow_key_file: 是否允许回退读取 ~/.cert/deepseek.key。该文件是 DeepSeek
专属凭据,模型档案端点(其他厂商)必须传 False——环境变量取不到
时直接报错,绝不把 A 厂商 Key 发给 B 厂商(跨厂商凭据泄漏)。
"""
if api_key: if api_key:
return api_key return api_key
env_name = None if env_name is None:
if config is not None and hasattr(config, "get"): if config is not None and hasattr(config, "get"):
env_name = config.get("api.api_key_env") env_name = config.get("api.api_key_env")
env_name = env_name or "DEEPSEEK_API_KEY" env_name = env_name or "DEEPSEEK_API_KEY"
if not env_name: # 档案显式置空 → 免密端点
if allow_missing:
return ""
raise RuntimeError("模型档案 api_key_env 配置为空且未启用免密")
env_key = os.environ.get(env_name) env_key = os.environ.get(env_name)
if env_key: if env_key:
return env_key return env_key
if allow_missing:
return ""
if not allow_key_file:
raise RuntimeError(
f"模型档案端点缺少 API Key:请设置环境变量 {env_name},"
f"或将该档案的 api_key_env 配置为空串以启用本地免密端点")
try: try:
home = os.environ.get("HOME", os.environ["USERPROFILE"]) home = os.environ.get("HOME", os.environ["USERPROFILE"])
key_path = os.path.join(home, ".cert", "deepseek.key") key_path = os.path.join(home, ".cert", "deepseek.key")
@@ -240,26 +536,184 @@ def resolve_api_key(api_key: Optional[str], config=None) -> str:
f"请设置环境变量 {env_name} 或在配置文件中设置 API Key") f"请设置环境变量 {env_name} 或在配置文件中设置 API Key")
def _validate_profile(name: str, profile) -> None:
"""H2:档案类型与 api_url 校验(在读取档案字段之前调用)。"""
if not isinstance(profile, dict):
raise ValueError(
f"模型档案 '{name}' 配置无效:应为对象(字典),"
f"实际为 {type(profile).__name__},请检查 config.json 的 models.{name}")
if not str(profile.get("api_url") or "").strip():
raise ValueError(
f"模型档案 '{name}' 缺少 api_url 字段,"
f"请在 config.json 的 models.{name} 中补齐(OpenAI Chat Completions 端点 URL)")
fb = profile.get("fallback")
if fb is not None and (not isinstance(fb, list) or not all(isinstance(x, str) for x in fb)):
raise ValueError(
f"模型档案 '{name}' 的 fallback 字段无效:应为档案名字符串列表"
f"(如 [\"kimi\", \"local\"]),实际为 {type(fb).__name__},"
f"请检查 config.json 的 models.{name}")
def _require_model(name: str, effective_model) -> None:
"""H2:有效模型名校验——缺 model 会把档案名当模型名发给 API(或 "model": null → 400)。"""
if not str(effective_model or "").strip():
raise ValueError(
f"模型档案 '{name}' 缺少 model 字段(且未传入裸模型名),"
f"请在 config.json 的 models.{name} 中补齐,或调用时用 --model 指定模型名")
def _resolve_model_profile(config, model: Optional[str]):
"""解析模型档案。返回 (profile_dict, effective_model_name, profile_name)。
优先级:model 命中 models.<name> 档案 > api.default_model 档案 > None(回退旧 api 节)。
model 未命中档案时视为裸模型名(同端点换模型,用默认档案的 url/key,模型名用裸名)。
L2 前缀逃逸(档案名与裸模型名歧义时使用):
- "model:NAME" 强制按裸模型名解析(档案恰好叫 NAME 时仍走裸名语义)
- "profile:NAME" 强制按档案名解析(不存在则报错,不再静默落回裸名语义)
"""
if config is None or not hasattr(config, "get"):
return None, model, None
models = config.get("models") or {}
if not isinstance(models, dict):
return None, model, None
force_bare = False
if isinstance(model, str) and ":" in model:
prefix, _, rest = model.partition(":")
if prefix == "model":
if not rest.strip():
raise ValueError("model: 前缀后为空,请提供裸模型名,如 model:deepseek-chat")
model, force_bare = rest, True
elif prefix == "profile":
if not rest.strip():
raise ValueError("profile: 前缀后为空,请提供档案名,如 profile:kimi")
prof = models.get(rest)
if prof is None:
raise ValueError(
f"模型档案 '{rest}' 不存在,可用档案:{', '.join(models) or '(无)'}")
_validate_profile(rest, prof)
effective = prof.get("model")
_require_model(rest, effective)
return prof, effective, rest
explicit = models.get(model) if (model and not force_bare) else None
default_name = config.get("api.default_model")
default_profile = models.get(default_name) if default_name else None
if explicit is not None:
# model 是档案名 → 完整采用该档案(url/key/模型名);
# 档案缺 model 属配置错误,直接报错(不再把档案名当模型名发出)
_validate_profile(model, explicit)
effective = explicit.get("model")
_require_model(model, effective)
return explicit, effective, model
if default_profile is not None:
# model 是裸名或未指定 → 默认档案的 url/key,模型名被裸名覆盖
_validate_profile(default_name, default_profile)
effective = model or default_profile.get("model")
_require_model(default_name, effective)
return default_profile, effective, default_name
return None, model, None
def _construct_profile_client(config, profile: Dict, profile_name: str,
effective_model: str, logger=None,
api_key: Optional[str] = None) -> LLMClient:
"""L4a:按档案构造单个 DeepSeekClient(主路径与 fallback 链复用,不处理 fallback 字段)。"""
env_name = profile.get("api_key_env")
allow_missing = (env_name == "") # 档案显式空串 → 本地免密端点
resolved_key = resolve_api_key(api_key, config, env_name=env_name,
allow_missing=allow_missing, allow_key_file=False)
return DeepSeekClient(
api_key=resolved_key,
model=effective_model,
base_url=profile.get("api_url"),
timeout=profile.get("timeout") or config.get("api.timeout", 30),
config=config,
logger=logger,
reasoning_enabled=profile.get("reasoning_enabled",
config.get("api.reasoning_enabled", False)),
display_name=profile.get("display_name") or profile_name,
default_temperature=profile.get("temperature"),
)
def create_llm_client(config, api_key: Optional[str] = None, def create_llm_client(config, api_key: Optional[str] = None,
logger=None, model: Optional[str] = None) -> LLMClient: logger=None, model: Optional[str] = None) -> LLMClient:
"""根据配置创建对应厂商的 LLM 客户端。可通过 `api.provider` 切换厂商。 """根据配置创建对应厂商的 LLM 客户端。
模型选择优先级:
1. model 命中 models 节档案名 → 采用该档案(api_url/model/api_key_env/timeout)
2. api.default_model 指向的档案 → model 为裸模型名时覆盖档案内模型名
3. 均未配置 → 回退旧 api 扁平节(deepseek_api_url/deepseek_model)
L2 前缀逃逸:"model:NAME" 强制裸名 / "profile:NAME" 强制档案(--model 同样支持)。
provider: deepseek / openai_compatible / openai 均映射到 DeepSeekClient
(OpenAI Chat Completions 协议兼容实现,厂商差异由子类覆写 _parse_delta)。
Args: Args:
config: 配置对象(需提供 get 方法)。 config: 配置对象(需提供 get 方法)。
api_key: 显式传入的 API Key;为 None 时按 resolve_api_key 解析。 api_key: 显式传入的 API Key;为 None 时按 resolve_api_key 解析。
logger: 可选日志对象。 logger: 可选日志对象。
model: 可选模型名覆盖;为 None 时使用配置中的 `api.deepseek_model`。 model: 档案名或裸模型名(支持 model:/profile: 前缀逃逸)。
子代理等场景可通过此参数显式指定与主代理不同的模型。
""" """
provider = (config.get("api.provider", "deepseek") or "deepseek").lower() profile, model, profile_name = _resolve_model_profile(config, model)
base_url = config.get("api.deepseek_api_url")
model = model or config.get("api.deepseek_model", "deepseek-v4-flash")
timeout = config.get("api.timeout", 30)
resolved_key = resolve_api_key(api_key, config) if profile is None:
# 旧 api 扁平节(唯一允许回退 ~/.cert/deepseek.key 的路径,H1)
provider = (config.get("api.provider", "deepseek") or "deepseek").lower()
base_url = config.get("api.deepseek_api_url")
model = model or config.get("api.deepseek_model", "deepseek-v4-flash")
timeout = config.get("api.timeout", 30)
resolved_key = resolve_api_key(api_key, config, env_name=None,
allow_missing=False, allow_key_file=True)
else:
provider = "openai_compatible"
primary = _construct_profile_client(config, profile, profile_name,
model, logger=logger, api_key=api_key)
fallback_names = profile.get("fallback") or []
if not fallback_names:
return primary
# L4a:构造 fallback 链(一层,不递归——fallback 档案自身的 fallback 字段被忽略)
models = config.get("models") or {}
chain = [primary]
seen = {profile_name} # P6:重复项去重(冷却键同名冲突/重复撞同端点)
for fb_name in fallback_names:
if fb_name == profile_name:
raise ValueError(
f"模型档案 '{profile_name}' 的 fallback 不能包含自身,"
f"请检查 config.json 的 models.{profile_name}.fallback")
if fb_name in seen:
if logger is not None and hasattr(logger, "warning"):
logger.warning(f"fallback 列表重复项 '{fb_name}',已忽略")
continue
seen.add(fb_name)
fb_profile = models.get(fb_name)
if fb_profile is None:
raise ValueError(
f"模型档案 '{profile_name}' 的 fallback 指向不存在的档案 '{fb_name}',"
f"可用档案:{', '.join(models) or '(无)'}")
try:
# P3:fallback 档案与主档案同等校验(缺 api_url/model 时跳过并告警,
# 而非构造出 model=None 的 client 在请求期表现为神秘失败)
_validate_profile(fb_name, fb_profile)
fb_model = fb_profile.get("model")
_require_model(fb_name, fb_model)
chain.append(_construct_profile_client(
config, fb_profile, fb_name, fb_model,
logger=logger))
except (RuntimeError, ValueError) as e:
# 环境问题(如 fallback 档案缺 API Key)或配置缺陷(缺 model):
# 跳过该 fallback 并告警,不阻断主档案可用性
# (配置结构错误——fallback 指向不存在的档案——在上方已硬性拦截)
if logger is not None and hasattr(logger, "warning"):
logger.warning(f"fallback 档案 '{fb_name}' 构造失败,已忽略:{e}")
return FailoverClient(chain, logger=logger,
timeout_budget=profile.get("failover_timeout_budget"))
if provider in ("deepseek", "deepseek-chat"): if provider in ("deepseek", "deepseek-chat", "openai_compatible", "openai"):
return DeepSeekClient( client = DeepSeekClient(
api_key=resolved_key, api_key=resolved_key,
model=model, model=model,
base_url=base_url, base_url=base_url,
@@ -267,6 +721,7 @@ def create_llm_client(config, api_key: Optional[str] = None,
config=config, config=config,
logger=logger, logger=logger,
) )
return client
# 其他 OpenAI 兼容厂商:可直接复用 DeepSeekClient(继承其协议解析), # 其他 OpenAI 兼容厂商:可直接复用 DeepSeekClient(继承其协议解析),
# 或在此接入专有实现。新增厂商时在此扩展即可。 # 或在此接入专有实现。新增厂商时在此扩展即可。
+415
View File
@@ -0,0 +1,415 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
大模型接入点(模型档案)交互式管理工具
不用手写 config.json:菜单式列出 / 添加 / 删除 / 连通性验证
`models` 节的模型接入点(OpenAI Chat Completions 兼容端点:
DeepSeek / Kimi / Qwen / GLM / 本地 Ollama 等)。
用法:
python model_config.py # 交互式菜单
python model_config.py --list # 非交互:列出当前接入点
安全约定(与 aibox 审计一致):API Key 一律不写入配置文件——
只保存环境变量名(api_key_env),或留空表示免密本地端点。
"""
import argparse
import os
import re
import sys
from typing import Dict, List, Optional
# 允许从仓库根目录或 aibox/ 目录启动
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import ConfigManager # noqa: E402
PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
# 常见厂商端点提示(首行是默认建议值)
PROVIDER_HINTS = {
"DeepSeek": "https://api.deepseek.com/v1/chat/completions",
"Kimi (Moonshot)": "https://api.moonshot.cn/v1/chat/completions",
"Qwen (阿里云百炼)": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
"GLM (智谱)": "https://open.bigmodel.cn/api/paas/v4/chat/completions",
"Ollama 本地": "http://localhost:11434/v1/chat/completions",
}
# ==================== 核心逻辑(可单测,不依赖 input) ====================
def get_models(config: ConfigManager) -> Dict:
"""取 models 节(不存在则初始化为空 dict)。"""
models = config.get("models") or {}
if not isinstance(models, dict):
models = {}
return models
def list_profiles(config: ConfigManager) -> List[Dict]:
"""列出所有档案的归一化展示数据。"""
models = get_models(config)
default = config.get("api.default_model") or ""
profiles = []
for name, profile in models.items():
if not isinstance(profile, dict):
continue
profiles.append({
"name": name,
"is_default": name == default,
"display_name": profile.get("display_name") or name,
"api_url": profile.get("api_url", ""),
"model": profile.get("model", ""),
"api_key_env": profile.get("api_key_env", ""),
"reasoning_enabled": bool(profile.get("reasoning_enabled", False)),
"temperature": profile.get("temperature"),
"timeout": profile.get("timeout"),
"fallback": profile.get("fallback") or [],
})
return profiles
def validate_new_profile(config: ConfigManager, name: str, api_url: str,
model: str) -> Optional[str]:
"""校验新档案字段,返回错误信息(合法返回 None)。"""
if not name:
return "档案名不能为空"
if not PROFILE_NAME_RE.match(name):
return f"档案名只能包含字母/数字/下划线/短横线(当前: {name})"
models = get_models(config)
if name in models:
return f"档案名 '{name}' 已存在"
if not api_url or not str(api_url).strip().lower().startswith(("http://", "https://")):
return "API 地址必须是 http:// 或 https:// 开头的完整 URL"
if not model or not str(model).strip():
return "模型名不能为空(如 deepseek-chat / gpt-4o / qwen-plus)"
return None
def add_profile(config: ConfigManager, name: str, api_url: str, model: str,
api_key_env: str = "", display_name: str = None,
temperature: float = None, timeout: int = None,
reasoning_enabled: bool = False,
fallback: Optional[List[str]] = None,
set_default: bool = False) -> List[str]:
"""添加档案。返回提示信息列表;字段非法时返回带 ❌ 的错误信息。"""
err = validate_new_profile(config, name, api_url, model)
if err:
return [f"❌ {err}"]
models = get_models(config)
profile = {
"api_url": str(api_url).strip(),
"model": str(model).strip(),
}
if api_key_env:
profile["api_key_env"] = api_key_env.strip()
else:
# 空串 = 免密本地端点(Ollama/vLLM),显式声明
profile["api_key_env"] = ""
if display_name:
profile["display_name"] = display_name.strip()
if temperature is not None:
try:
profile["temperature"] = max(0.0, min(2.0, float(temperature)))
except (TypeError, ValueError):
return ["❌ 温度必须是 0.0~2.0 的数字"]
if timeout is not None:
try:
profile["timeout"] = max(5, int(timeout))
except (TypeError, ValueError):
return ["❌ 超时必须是整数秒"]
if reasoning_enabled:
profile["reasoning_enabled"] = True
if fallback:
# 只保留已存在的档案,避免悬空引用(L4a 启动期硬性报错)
known = set(models.keys())
invalid = [f for f in fallback if f not in known or f == name]
if invalid:
return [f"❌ 失败转移包含不存在的档案或自身: {invalid},请重新选择"]
profile["fallback"] = list(fallback)
models[name] = profile
config._config["models"] = models
if set_default:
config._config.setdefault("api", {})["default_model"] = name
saved = config._save_config()
msgs = [f"✅ 接入点 '{name}' 已添加({profile['api_url']},模型 {profile['model']})"]
if not saved:
msgs.append("⚠️ 保存失败(仅本次运行生效,重启后丢失)——请检查配置文件目录权限")
if set_default:
msgs.append(f"⭐ 已设为默认接入点(--model 不指定时使用)")
if not api_key_env:
msgs.append("ℹ️ 免密端点:请求不携带 API Key(适用于 Ollama/vLLM 等本地服务)")
else:
msgs.append(f"🔑 Key 从环境变量 {api_key_env} 读取(未设置时该档案不可用)")
return msgs
def delete_profile(config: ConfigManager, name: str) -> List[str]:
"""删除档案,并自动清理其他档案 fallback 中的悬空引用。"""
models = get_models(config)
if name not in models:
return [f"❌ 档案 '{name}' 不存在"]
del models[name]
# 清理其他档案对该档案的 fallback 引用(悬空引用会导致启动期报错)
cleaned = []
for other, profile in models.items():
if not isinstance(profile, dict):
continue
fb = profile.get("fallback") or []
if name in fb:
profile["fallback"] = [f for f in fb if f != name]
cleaned.append(other)
config._config["models"] = models
# 若删除的是默认档案,清除默认标记(回退旧 api 节语义)
if config.get("api.default_model") == name:
config._config.setdefault("api", {}).pop("default_model", None)
saved = config._save_config()
msgs = [f"✅ 接入点 '{name}' 已删除"]
if cleaned:
msgs.append(f"ℹ️ 已同时清理 {len(cleaned)} 个档案的失败转移引用: {', '.join(cleaned)}")
if not saved:
msgs.append("⚠️ 保存失败——请检查配置文件目录权限")
return msgs
def test_profile(config: ConfigManager, name: str, logger=None) -> str:
"""连通性验证:向该档案端点发一次最小请求。"""
models = get_models(config)
if name not in models:
return f"❌ 档案 '{name}' 不存在"
try:
from llm_client import create_llm_client
client = create_llm_client(config, api_key=None, logger=logger, model=name)
result = client.chat_completion(
[{"role": "user", "content": "ping"}], max_tokens=5)
if result is None or not str(result).strip():
return f"❌ 档案 '{name}' 验证失败:端点返回空响应(多为 4xx 认证/参数错误)"
return f"✅ 档案 '{name}' 连通正常:{str(result).strip()[:60]}"
except Exception as e:
return f"❌ 档案 '{name}' 验证失败:{str(e)}"
# ==================== 交互式菜单 ====================
def _ask(prompt: str, default: str = "") -> str:
if default:
val = input(f"{prompt}(回车默认: {default}): ").strip()
return val if val else default
return input(f"{prompt}: ").strip()
def _ask_yes_no(prompt: str, default: bool = False) -> bool:
hint = "y/n" if not default else "Y/n"
while True:
val = input(f"{prompt} [{hint}] ").strip().lower()
if not val:
return default
if val in ("y", "yes", "是"):
return True
if val in ("n", "no", "否"):
return False
print(" 请输入 y 或 n")
def _choose(prompt: str, options: List[str], multi: bool = False) -> List[str]:
"""编号选择(单选或多选)。多选用逗号分隔,直接回车 = 不选。"""
for i, opt in enumerate(options, 1):
print(f" {i}. {opt}")
if multi:
while True:
raw = input(f"{prompt}(多选,逗号分隔,直接回车不选): ").strip()
if not raw:
return []
try:
idxs = [int(x) for x in raw.replace(",", ",").split(",") if x.strip()]
except ValueError:
print(" 请输入数字编号,逗号分隔")
continue
if all(1 <= i <= len(options) for i in idxs):
return [options[i - 1] for i in idxs]
print(f" 编号超出范围(1~{len(options)})")
else:
while True:
raw = input(f"{prompt}(1~{len(options)},直接回车不选): ").strip()
if not raw:
return []
try:
idx = int(raw)
except ValueError:
print(" 请输入数字编号")
continue
if 1 <= idx <= len(options):
return [options[idx - 1]]
print(f" 编号超出范围(1~{len(options)})")
def _print_profiles(profiles: List[Dict]):
if not profiles:
print(" (当前没有任何接入点。旧版 api 节仍可用,或选 2 添加)")
return
for p in profiles:
mark = " ⭐默认" if p["is_default"] else ""
key_desc = f"环境变量 {p['api_key_env']}" if p["api_key_env"] else "免密"
fb = f",失败转移 → {', '.join(p['fallback'])}" if p["fallback"] else ""
print(f" {p['name']}{mark} [{p['display_name']}]")
print(f" 地址: {p['api_url']} 模型: {p['model']}")
print(f" Key: {key_desc} 温度: {p['temperature'] or '默认'} 超时: {p['timeout'] or '默认'}{fb}")
def _add_flow(config: ConfigManager):
print("\n── 添加接入点 ──")
print("常见厂商端点参考:")
for k, v in PROVIDER_HINTS.items():
print(f" {k}: {v}")
name = _ask("档案名(英文,如 deepseek/kimi/local)")
if not name:
print("❌ 已取消(档案名不能为空)")
return
if not PROFILE_NAME_RE.match(name):
print(f"❌ 档案名只能包含字母/数字/下划线/短横线")
return
if name in get_models(config):
print(f"❌ 档案名 '{name}' 已存在")
return
api_url = _ask("API 地址")
model = _ask("模型名(如 deepseek-chat / qwen-plus)")
print("Key 方式:")
print(" 1. 环境变量名(推荐,如 DEEPSEEK_API_KEY——Key 不落盘,重启不丢)")
print(" 2. 免密本地端点(如 Ollama,不需要 Key)")
key_mode = _ask("选择 [1/2]", "1")
api_key_env = ""
if key_mode == "1":
api_key_env = _ask("环境变量名", "DEEPSEEK_API_KEY")
elif key_mode != "2":
print("❌ 已取消(Key 方式无效)")
return
display_name = _ask("显示名(可选,回车用档案名)")
temperature_raw = _ask("温度 0.0~2.0(可选,越低越确定)")
timeout_raw = _ask("超时秒数(可选)")
reasoning = _ask_yes_no("启用思考模式?", default=False)
profiles = list_profiles(config)
fallback = []
if profiles:
print("失败转移:主接入点故障时按顺序尝试的备用接入点(可选)")
fallback = _choose("选择备用接入点", [p["name"] for p in profiles], multi=True)
set_default = _ask_yes_no("设为默认接入点?", default=(len(profiles) == 0))
print("\n── 确认 ──")
print(f" 档案名: {name}")
print(f" 地址: {api_url} 模型: {model}")
print(f" Key: {'环境变量 ' + api_key_env if api_key_env else '免密(不携带)'}")
if display_name:
print(f" 显示名: {display_name}")
if temperature_raw:
print(f" 温度: {temperature_raw}")
if timeout_raw:
print(f" 超时: {timeout_raw}s")
if reasoning:
print(" 思考模式: 开启")
if fallback:
print(f" 失败转移: {', '.join(fallback)}")
if not _ask_yes_no("确认保存?", default=True):
print("已取消")
return
msgs = add_profile(config, name, api_url, model,
api_key_env=api_key_env, display_name=display_name,
temperature=float(temperature_raw) if temperature_raw else None,
timeout=int(timeout_raw) if timeout_raw else None,
reasoning_enabled=reasoning, fallback=fallback,
set_default=set_default)
for m in msgs:
print(m)
def _delete_flow(config: ConfigManager):
profiles = list_profiles(config)
if not profiles:
print("\n(当前没有任何接入点)")
return
print("\n── 删除接入点 ──")
picked = _choose("选择要删除的接入点", [p["name"] for p in profiles])
if not picked:
print("已取消")
return
name = picked[0]
if not _ask_yes_no(f"确认删除 '{name}'?(其他档案的失败转移引用会自动清理)", default=False):
print("已取消")
return
for m in delete_profile(config, name):
print(m)
def _test_flow(config: ConfigManager):
profiles = list_profiles(config)
if not profiles:
print("\n(当前没有任何接入点)")
return
print("\n── 连通性验证 ──")
picked = _choose("选择要验证的接入点", [p["name"] for p in profiles])
if not picked:
print("已取消")
return
print(" 正在请求端点(最长 20 秒)...")
print(test_profile(config, picked[0]))
def run_interactive(config: ConfigManager = None):
"""交互式主循环。config 可注入(测试用)。"""
config = config or ConfigManager()
config_path = getattr(config, "_config_path", None) or "(默认位置)"
print("=" * 56)
print(" aibox 大模型接入点管理工具")
print(f" 配置文件: {config_path}")
print(" 说明:API Key 只存环境变量名,不落盘;免密端点适合 Ollama 等本地服务")
print("=" * 56)
while True:
print("\n请选择操作:")
print(" 1. 📋 列出接入点")
print(" 2. ➕ 添加接入点")
print(" 3. 🗑️ 删除接入点")
print(" 4. 🔍 验证接入点连通性")
print(" 0. 退出")
choice = input("> ").strip()
if choice == "1":
_print_profiles(list_profiles(config))
elif choice == "2":
_add_flow(config)
elif choice == "3":
_delete_flow(config)
elif choice == "4":
_test_flow(config)
elif choice in ("0", "q", "exit", "退出"):
print("再见")
return
else:
print("无效选择,请输入 0~4")
def main():
parser = argparse.ArgumentParser(description="aibox 大模型接入点管理工具")
parser.add_argument("--list", action="store_true", help="非交互:列出当前接入点")
args = parser.parse_args()
config = ConfigManager()
if args.list:
_print_profiles(list_profiles(config))
return
run_interactive(config)
if __name__ == "__main__":
main()
+2 -1
View File
@@ -514,7 +514,8 @@ class ConversationCompressor:
final_tokens = self.estimate_tokens([m for m in final_messages if m.get("role") != "system"]) final_tokens = self.estimate_tokens([m for m in final_messages if m.get("role") != "system"])
print(f"\n✅ {i18n.get('conversation_compressed', original_count, final_count)}") print(f"\n✅ {i18n.get('conversation_compressed', original_count, final_count)}")
print(f" Token: {original_tokens} -> {final_tokens} (压缩率: {final_tokens/original_tokens:.1%})") # L6:压缩块全空时 original_tokens 可能为 0,除零防护
print(f" Token: {original_tokens} -> {final_tokens} (压缩率: {final_tokens / max(original_tokens, 1):.1%})")
# 检查是否压缩到阈值以下 # 检查是否压缩到阈值以下
if final_count >= threshold: if final_count >= threshold:
+31 -8
View File
@@ -15,6 +15,7 @@ subsystems.conversation_history - 对话历史管理器
""" """
import json import json
import os
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -61,14 +62,26 @@ class ConversationHistory:
try: try:
with open(self.history_file, 'r', encoding='utf-8') as f: with open(self.history_file, 'r', encoding='utf-8') as f:
return json.load(f) return json.load(f)
except: except Exception as e:
# N16:收窄为 Exception——裸 except 连 KeyboardInterrupt/SystemExit
# 也吞掉;损坏时重置为空索引并告警
self.logger.warning(f"历史索引文件损坏,已重置: {e}")
return {"conversations": [], "last_id": 0} return {"conversations": [], "last_id": 0}
return {"conversations": [], "last_id": 0} return {"conversations": [], "last_id": 0}
@staticmethod
def _atomic_write_json(path: Path, data) -> None:
"""原子写 JSON(I 批,审计 2026-08-18):先写同目录临时文件再
os.replace——此前 'w' 直写,进程中断/崩溃留半截 JSON(current 损坏
后仅静默置 None,历史内容丢失)"""
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
def _save_history_index(self): def _save_history_index(self):
"""保存历史索引""" """保存历史索引"""
with open(self.history_file, 'w', encoding='utf-8') as f: self._atomic_write_json(self.history_file, self.history_index)
json.dump(self.history_index, f, ensure_ascii=False, indent=2)
def _load_current_conversation(self): def _load_current_conversation(self):
"""加载当前的对话(如果有)""" """加载当前的对话(如果有)"""
@@ -83,8 +96,7 @@ class ConversationHistory:
def save_current_conversation(self): def save_current_conversation(self):
"""保存当前对话""" """保存当前对话"""
if self.current_conversation: if self.current_conversation:
with open(self.current_file, 'w', encoding='utf-8') as f: self._atomic_write_json(self.current_file, self.current_conversation)
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
def start_new_conversation(self, topic: str = None) -> Dict: def start_new_conversation(self, topic: str = None) -> Dict:
"""开始新对话""" """开始新对话"""
@@ -132,12 +144,17 @@ class ConversationHistory:
importance=2 importance=2
) )
# N16:归档文件名加序号防同秒覆盖——DATE_FORMAT 精度为秒,
# 同一秒内两次归档(如测试/快速重置)会静默覆盖前一份
timestamp = datetime.now().strftime(constants.DATE_FORMAT) timestamp = datetime.now().strftime(constants.DATE_FORMAT)
archive_filename = f"conversation_{timestamp}.json" archive_filename = f"conversation_{timestamp}.json"
counter = 1
while (self.archive_dir / archive_filename).exists():
archive_filename = f"conversation_{timestamp}_{counter}.json"
counter += 1
archive_path = self.archive_dir / archive_filename archive_path = self.archive_dir / archive_filename
with open(archive_path, 'w', encoding='utf-8') as f: self._atomic_write_json(archive_path, self.current_conversation)
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
self.history_index["conversations"].append({ self.history_index["conversations"].append({
"id": self.current_conversation["id"], "id": self.current_conversation["id"],
@@ -341,7 +358,13 @@ class ConversationHistory:
if file_name == constants.CURRENT_CONVERSATION_FILE and self.current_conversation: if file_name == constants.CURRENT_CONVERSATION_FILE and self.current_conversation:
return self.current_conversation return self.current_conversation
file_path = self.archive_dir / file_name # I 批(审计 2026-08-18):file_name 目前无 LLM 可控调用面,但一旦
# 接入即成为任意 .json 读取通道——realpath 解析软链后再做目录前缀校验
archive_root = os.path.normcase(os.path.realpath(str(self.archive_dir)))
target = os.path.normcase(os.path.realpath(str(self.archive_dir / file_name)))
if not target.startswith(archive_root + os.sep):
return None
file_path = Path(target)
if file_path.exists(): if file_path.exists():
with open(file_path, 'r', encoding='utf-8') as f: with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f) return json.load(f)
+100 -55
View File
@@ -176,7 +176,10 @@ class MemoryDatabase:
def _search_via_fts(self, cursor, keywords: List[str], match_all: bool, limit: int) -> List[Dict]: def _search_via_fts(self, cursor, keywords: List[str], match_all: bool, limit: int) -> List[Dict]:
"""FTS5 全文检索(关键词含特殊字符导致 MATCH 语法错误时返回空,走 LIKE 兜底)""" """FTS5 全文检索(关键词含特殊字符导致 MATCH 语法错误时返回空,走 LIKE 兜底)"""
joiner = " AND " if match_all else " OR " joiner = " AND " if match_all else " OR "
query = joiner.join([f'"{kw}"' for kw in keywords]) # N7:短语内双引号翻倍转义——关键词含 " 时若不翻倍会提前闭合短语
# 边界,破坏 MATCH 语法或改变匹配语义(FTS5 短语引号转义规则)
terms = [f'"{kw.replace(chr(34), chr(34) * 2)}"' for kw in keywords]
query = joiner.join(terms)
try: try:
cursor.execute(''' cursor.execute('''
@@ -229,14 +232,15 @@ class MemoryDatabase:
return results return results
def get_recent_memories(self, limit: int = 10) -> List[Dict]: def get_recent_memories(self, limit: int = 10) -> List[Dict]:
"""获取最近的记忆。limit=None 时不限制返回条数(供分页使用)。""" """获取最近的记忆。limit=None 时使用默认上限(M4:此前 None 无 LIMIT
全表加载,记忆库增长后单次调用 fetchall 全量入内存;与 search_memories
的 None→默认上限语义对齐)。"""
if limit is None:
limit = constants.MEMORY_MAX_SEARCH_RESULTS
with sqlite3.connect(self.db_path) as conn: with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
cursor = conn.cursor() cursor = conn.cursor()
if limit is None: cursor.execute('SELECT * FROM memories ORDER BY end_time DESC LIMIT ?', (limit,))
cursor.execute('SELECT * FROM memories ORDER BY end_time DESC')
else:
cursor.execute('SELECT * FROM memories ORDER BY end_time DESC LIMIT ?', (limit,))
results = [] results = []
for row in cursor.fetchall(): for row in cursor.fetchall():
@@ -398,11 +402,14 @@ class MemoDatabase:
# 如果传入了 before_time,使用它,否则使用当前时间 # 如果传入了 before_time,使用它,否则使用当前时间
compare_time = before_time if before_time else now compare_time = before_time if before_time else now
# I 批(审计 2026-08-18):去掉 SQL 层 reminder_time <= ? 预过滤——
# 字符串比较会把格式异常的时间(如 "不是时间")静默排除在 SQL 层,
# Python 二次验证永远看不到它们(Q5:格式异常任务按到期返回)。
# 时间过滤改由下方 Python 解析做权威判断(本地小库全扫描可忽略)。
query = ''' query = '''
SELECT * FROM memos SELECT * FROM memos
WHERE is_completed = 0 WHERE is_completed = 0
AND reminder_time IS NOT NULL AND reminder_time IS NOT NULL
AND reminder_time <= ? -- 严格小于等于比较时间
AND (repeat_end_time IS NULL OR repeat_end_time >= ?) AND (repeat_end_time IS NULL OR repeat_end_time >= ?)
''' '''
@@ -411,10 +418,13 @@ class MemoDatabase:
query += ' ORDER BY reminder_time ASC, priority DESC' query += ' ORDER BY reminder_time ASC, priority DESC'
params = [compare_time, compare_time] params = [compare_time]
if limit: # I 批:limit=0 语义修正——此前 `if limit:` 使 0 等同"不限",
query += f' LIMIT {limit}' # 与 None=不限的惯例冲突;改为 None 才不加 LIMIT
if limit is not None:
query += ' LIMIT ?'
params.append(max(0, int(limit)))
cursor.execute(query, params) cursor.execute(query, params)
@@ -435,7 +445,11 @@ class MemoDatabase:
# 调试日志 # 调试日志
self.logger.debug(f"过滤掉未到期任务 #{memo['id']}: {memo['reminder_time']} > {compare_time}") self.logger.debug(f"过滤掉未到期任务 #{memo['id']}: {memo['reminder_time']} > {compare_time}")
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败: {e}") # I 批(审计 2026-08-18):格式异常此前被静默丢弃——
# 任务永不执行且用户无感知。改为按"到期"返回并告警,
# 至少保证任务被处理而非永久丢失
self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败,按到期处理: {e}")
results.append(memo)
else: else:
# 没有提醒时间的任务不应该在待执行列表中 # 没有提醒时间的任务不应该在待执行列表中
self.logger.warning(f"任务 #{memo['id']} 没有提醒时间,但被返回为待执行") self.logger.warning(f"任务 #{memo['id']} 没有提醒时间,但被返回为待执行")
@@ -455,10 +469,14 @@ class MemoDatabase:
ORDER BY created_at ASC ORDER BY created_at ASC
''' '''
if limit: params = []
query += f' LIMIT {limit}' # I 批:limit=0 语义修正——此前 `if limit:` 使 0 等同"不限",
# 与 None=不限的惯例冲突;改为 None 才不加 LIMIT
if limit is not None:
query += ' LIMIT ?'
params.append(max(0, int(limit)))
cursor.execute(query) cursor.execute(query, params)
results = [] results = []
for row in cursor.fetchall(): for row in cursor.fetchall():
@@ -508,64 +526,82 @@ class MemoDatabase:
def mark_as_triggered(self, memo_id: int) -> Optional[str]: def mark_as_triggered(self, memo_id: int) -> Optional[str]:
"""标记任务已被触发,并根据重复规则计算下一次提醒时间""" """标记任务已被触发,并根据重复规则计算下一次提醒时间"""
memo = self.get_memo(memo_id) # N10:读取与更新收进同一连接的 BEGIN IMMEDIATE 事务。此前先
if not memo: # get_memo(独立连接)后 UPDATE,check-then-act 之间存在竞态:
return None # 调度器并发触发同一任务会双计数/双推进 repeat。IMMEDIATE 在事务
# 开始即取写锁,串行化并发触发。
now = datetime.now() now = datetime.now()
next_reminder = None next_reminder = None
with sqlite3.connect(self.db_path) as conn: with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor() cursor = conn.cursor()
# I 批(审计 2026-08-18):多进程同库(CLI --memo-add + 服务调度)
# 写竞争时默认 5s 后直接报 database is locked——busy_timeout 让
# SQLite 等待锁释放(最长 10s)而不是立即失败
cursor.execute("PRAGMA busy_timeout = 10000")
cursor.execute("BEGIN IMMEDIATE")
try:
cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,))
row = cursor.fetchone()
if row is None:
conn.rollback()
return None
memo = dict(row)
if memo.get('tags'):
memo['tags'] = memo['tags'].split(',')
cursor.execute(''' cursor.execute('''
UPDATE memos UPDATE memos
SET last_triggered_at = ?, trigger_count = trigger_count + 1 SET last_triggered_at = ?, trigger_count = trigger_count + 1
WHERE id = ? WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id)) ''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
# 处理重复任务 # 处理重复任务
if memo['repeat_type'] != 'none' and not self._should_end_repeat(memo, now): if memo['repeat_type'] != 'none' and not self._should_end_repeat(memo, now):
# 使用当前时间作为基准来计算下一次执行时间 # 使用当前时间作为基准来计算下一次执行时间
next_reminder = self._calculate_next_reminder(memo, now) next_reminder = self._calculate_next_reminder(memo, now)
if next_reminder: if next_reminder:
cursor.execute(''' cursor.execute('''
UPDATE memos UPDATE memos
SET reminder_time = ? SET reminder_time = ?
WHERE id = ? WHERE id = ?
''', (next_reminder.strftime(constants.DATETIME_FORMAT), memo_id)) ''', (next_reminder.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"任务 #{memo_id} 已更新下一次执行时间: {next_reminder}") self.logger.info(f"任务 #{memo_id} 已更新下一次执行时间: {next_reminder}")
else: else:
# 重复结束,标记为完成 # 重复结束,标记为完成
cursor.execute('''
UPDATE memos
SET is_completed = 1, completed_at = ?
WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"任务 #{memo_id} 重复结束,已标记为完成")
elif memo['is_immediate']:
# 即时任务执行后标记为完成
cursor.execute(''' cursor.execute('''
UPDATE memos UPDATE memos
SET is_completed = 1, completed_at = ? SET is_completed = 1, completed_at = ?
WHERE id = ? WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id)) ''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"任务 #{memo_id} 重复结束,已标记为完成") self.logger.info(f"即时任务 #{memo_id} 已完成")
elif memo['is_immediate']: else:
# 即时任务执行后标记为完成 # 一次性任务,标记为完成
cursor.execute(''' cursor.execute('''
UPDATE memos UPDATE memos
SET is_completed = 1, completed_at = ? SET is_completed = 1, completed_at = ?
WHERE id = ? WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id)) ''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"即时任务 #{memo_id} 已完成") self.logger.info(f"任务 #{memo_id} 已完成")
else:
# 一次性任务,标记为完成
cursor.execute('''
UPDATE memos
SET is_completed = 1, completed_at = ?
WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"任务 #{memo_id} 已完成") conn.commit()
except Exception:
conn.commit() conn.rollback()
raise
return next_reminder.strftime(constants.DATETIME_FORMAT) if next_reminder else None return next_reminder.strftime(constants.DATETIME_FORMAT) if next_reminder else None
@@ -652,6 +688,15 @@ class MemoDatabase:
return True return True
return False return False
def delete_memo(self, memo_id: int) -> bool:
"""物理删除任务(M5:delete 语义此前被 complete_memo 顶替——只标记
完成不删行,重复任务还会被推进而非移除,stats 计数失真)"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM memos WHERE id = ?", (memo_id,))
conn.commit()
return cursor.rowcount > 0
def get_memo_stats(self) -> Dict: def get_memo_stats(self) -> Dict:
"""获取任务统计信息""" """获取任务统计信息"""
with sqlite3.connect(self.db_path) as conn: with sqlite3.connect(self.db_path) as conn:
+148 -84
View File
@@ -13,6 +13,7 @@ subsystems.executors - 命令执行器
import os import os
import queue import queue
import signal
import subprocess import subprocess
import threading import threading
import time import time
@@ -71,62 +72,148 @@ class SubprocessCommandExecutor(CommandExecutor):
def __init__(self, logger=None): def __init__(self, logger=None):
super().__init__(logger) super().__init__(logger)
self._current_process = None self._current_process = None
self._current_pgid = None # L9:Popen 后立即捕获,shell 死亡后 getpgid 会失败
self._process_lock = threading.Lock() self._process_lock = threading.Lock()
def _terminate_tree(self, proc, force: bool = False):
"""终止整个进程组(L9:POSIX 下 shell=True 只杀 shell 时,孙进程
(`(x &)` 等)仍持管道写端——reader 阻塞无 EOF、fd/线程悬挂至孙
进程退出,写满 64KB 管道缓冲后孙进程也卡死。start_new_session 使
shell 成为会话首进程,按组信号可一次覆盖整棵进程树)。
组 ID 在 Popen 后立即保存:shell 死亡后 os.getpgid(pid) 会抛
ProcessLookupError,届时无法再从进程反查组。
Windows 无进程组概念,退回单进程 terminate/kill。"""
try:
if os.name == "posix":
sig = signal.SIGKILL if force else signal.SIGTERM
with self._process_lock:
pgid = self._current_pgid
if pgid is None:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, sig)
elif force:
proc.kill()
else:
proc.terminate()
except Exception:
try:
proc.kill()
except Exception:
pass
def cancel(self): def cancel(self):
"""取消当前正在运行的命令(kill子进程)。线程安全。""" """取消当前正在运行的命令(kill子进程组)。线程安全。"""
with self._process_lock: with self._process_lock:
proc = self._current_process proc = self._current_process
if proc is not None: if proc is not None:
try: try:
if proc.poll() is None: if proc.poll() is None:
proc.kill() self._terminate_tree(proc, force=True)
self.logger.info("已终止正在运行的子进程") self.logger.info("已终止正在运行的子进程组")
except Exception: except Exception:
pass pass
def _read_with_limit(self, process, timeout: int, start_time: float, output_queue=None) -> Tuple[str, bool, bool]: def _read_with_limit(self, process, timeout: int, start_time: float, output_queue=None) -> Tuple[str, bool, bool]:
"""从进程读取输出,限制总大小和超时。 """从进程读取输出,限制总大小和超时。返回 (output_str, timeout_occurred, truncated)。
返回 (output_str, timeout_occurred, truncated)
H2 修复:阻塞 readline 在独立 reader 线程执行,主循环带超时轮询队列。
此前超时检查只在两次 readline 之间执行,无输出命令会让 readline
阻塞到进程自然退出,timeout 形同虚设(审计 PoC 实证)。
""" """
output_chunks = [] output_chunks = []
total_bytes = 0 total_bytes = 0
timeout_occurred = False timeout_occurred = False
truncated = False truncated = False
line_queue: queue.Queue = queue.Queue()
reader_done = threading.Event()
def _reader():
"""后台 reader 线程:阻塞 readline,行入队;EOF/异常结束"""
try:
for line in iter(process.stdout.readline, ""):
line_queue.put(line)
except Exception:
pass
finally:
reader_done.set()
reader = threading.Thread(target=_reader, daemon=True)
reader.start()
shell_dead_since = None # shell 进程已死但 reader 未收到 EOF 的起始时刻
while True: while True:
# 检查超时 # 检查超时(readline 阻塞不再影响本判断)
if timeout > 0 and time.time() - start_time > timeout: if timeout > 0 and time.time() - start_time > timeout:
process.terminate() self._terminate_tree(process)
timeout_occurred = True timeout_occurred = True
break break
# 检查输出大小 # 检查输出大小
if total_bytes >= self.MAX_OUTPUT_BYTES: if total_bytes >= self.MAX_OUTPUT_BYTES:
truncated = True truncated = True
process.terminate() self._terminate_tree(process)
break break
# 读取一行 try:
line = process.stdout.readline() line = line_queue.get(timeout=0.1)
if not line and process.poll() is not None: shell_dead_since = None
break except queue.Empty:
if line: if reader_done.is_set():
line_bytes = len(line.encode('utf-8', errors='replace'))
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
allowed = self.MAX_OUTPUT_BYTES - total_bytes
if allowed > 0:
output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace'))
total_bytes = self.MAX_OUTPUT_BYTES
truncated = True
process.terminate()
break break
# shell 已被 kill/退出但 reader 仍阻塞(如 Windows shell=True
# 下 cancel 只杀死包装进程,孙进程仍持有管道写端导致无 EOF):
# 宽限 1 秒排空残留输出后放弃等待
if process.poll() is not None:
if shell_dead_since is None:
shell_dead_since = time.time()
elif time.time() - shell_dead_since > 1.0:
# L9:shell 已死但孙进程仍持管道写端(`(sleep 300 &) ;
# echo done` 形态)。只靠 daemon reader 兜底会悬挂线程
# 与管道 fd 至孙进程退出,写满 64KB 缓冲后孙进程自身
# 也卡死。此处强杀进程组收尾(nohup 重定向输出的
# 真正脱离场景不持管道,不会走到这里)。
self._terminate_tree(process, force=True)
break
continue
output_chunks.append(line) line_bytes = len(line.encode('utf-8', errors='replace'))
total_bytes += line_bytes if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
allowed = self.MAX_OUTPUT_BYTES - total_bytes
if allowed > 0:
# L5:decode 用 errors='ignore'——按字节截断可能落在多字节
# 字符中间,errors='replace' 会在行尾留下半个字符(�)
output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='ignore'))
total_bytes = self.MAX_OUTPUT_BYTES
truncated = True
self._terminate_tree(process)
break
if output_queue: output_chunks.append(line)
output_queue.put(("line", line.rstrip('\n') + "\n")) total_bytes += line_bytes
if output_queue:
output_queue.put(("line", line.rstrip('\n') + "\n"))
# N4 修复(审计 2026-08-18,二次修正):reader 线程收尾。
# 超时/截断/宽限到期 break 后,reader 可能仍阻塞在 readline
# (Windows shell=True 下孙进程持管道写端,永远等不到 EOF)。
# 注意:此时不能 close stdout——CPython 流对象内部有锁,
# readline 持锁阻塞时 close() 会一同挂起直到 readline 返回
# (实测命令超时被拖满整个 Start-Sleep 时长)。reader 为 daemon
# 线程,孙进程退出收到 EOF 后自然结束,不阻塞主流程;
# reader 已正常退出的场景才 close 显式释放管道 fd。
if reader_done.is_set():
try:
process.stdout.close()
except Exception:
pass
else:
reader.join(timeout=2)
if reader.is_alive():
self.logger.warning(
"reader 线程仍阻塞在管道读取(孙进程持写端),已放弃等待,"
"线程由 daemon 兜底、随管道 EOF 自然退出")
return ''.join(output_chunks), timeout_occurred, truncated return ''.join(output_chunks), timeout_occurred, truncated
@@ -153,30 +240,35 @@ class SubprocessCommandExecutor(CommandExecutor):
shell=True, shell=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE, stdin=subprocess.DEVNULL, # H3 修复:不挂 stdin,防等待输入命令永久挂起
text=True, text=True,
bufsize=1, bufsize=1,
universal_newlines=True, universal_newlines=True,
env=exec_env, env=exec_env,
cwd=os.path.expanduser(work_dir) cwd=os.path.expanduser(work_dir),
# L9:POSIX 下 shell 成为会话首进程,超时/取消可按进程组
# 一次终止整棵进程树(含 & 后台孙进程),消除管道悬挂
start_new_session=(os.name == "posix")
) )
# 保存进程引用以便cancel()可以kill它 # 保存进程引用以便cancel()可以kill它
with self._process_lock: with self._process_lock:
self._current_process = process self._current_process = process
if os.name == "posix":
try:
self._current_pgid = os.getpgid(process.pid)
except Exception:
self._current_pgid = None
start_time = time.time() start_time = time.time()
output_str, timeout_occurred, truncated = self._read_with_limit(process, timeout, start_time) output_str, timeout_occurred, truncated = self._read_with_limit(process, timeout, start_time)
# 确保进程终止 # 确保进程终止
if process.poll() is None: if process.poll() is None:
if timeout_occurred or truncated: self._terminate_tree(process, force=timeout_occurred or truncated)
process.kill()
else:
process.terminate()
try: try:
process.wait(timeout=2) process.wait(timeout=2)
except: except:
process.kill() self._terminate_tree(process, force=True)
return_code = process.returncode if process.returncode is not None else -1 return_code = process.returncode if process.returncode is not None else -1
@@ -204,10 +296,11 @@ class SubprocessCommandExecutor(CommandExecutor):
return f"❌ 执行错误: {str(e)}" return f"❌ 执行错误: {str(e)}"
finally: finally:
if process and process.poll() is None: if process and process.poll() is None:
process.kill() self._terminate_tree(process, force=True)
# 清除当前进程引用 # 清除当前进程引用
with self._process_lock: with self._process_lock:
self._current_process = None self._current_process = None
self._current_pgid = None
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30, def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30,
work_dir: str = None, env: Dict = None, **kwargs): work_dir: str = None, env: Dict = None, **kwargs):
@@ -240,67 +333,39 @@ class SubprocessCommandExecutor(CommandExecutor):
shell=True, shell=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE, stdin=subprocess.DEVNULL, # H3 修复:不挂 stdin,防等待输入命令永久挂起
text=True, text=True,
bufsize=1, bufsize=1,
universal_newlines=True, universal_newlines=True,
env=exec_env, env=exec_env,
cwd=os.path.expanduser(work_dir) cwd=os.path.expanduser(work_dir),
# L9:POSIX 下 shell 成为会话首进程,超时/取消可按进程组
# 一次终止整棵进程树(含 & 后台孙进程),消除管道悬挂
start_new_session=(os.name == "posix")
) )
self.process = process self.process = process
# 保存进程引用以便cancel()可以kill它 # 保存进程引用以便cancel()可以kill它
with self._process_lock: with self._process_lock:
self._current_process = process self._current_process = process
if os.name == "posix":
try:
self._current_pgid = os.getpgid(process.pid)
except Exception:
self._current_pgid = None
start_time = time.time() start_time = time.time()
output_lines = [] # H2 修复:复用线程化读取(reader 线程 + 超时轮询),行经 output_queue 流式转发
total_bytes = 0 output_str, timeout_occurred, truncated = self._read_with_limit(
timeout_occurred = False process, timeout, start_time, output_queue=output_queue)
truncated = False
# 实时读取输出 if timeout_occurred:
while True: output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒)\n"))
# 检查超时 elif truncated:
if timeout > 0 and time.time() - start_time > timeout: output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
process.terminate()
timeout_occurred = True
output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒)\n"))
break
# 检查输出大小
if total_bytes >= self.MAX_OUTPUT_BYTES:
truncated = True
process.terminate()
output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
break
# 读取一行输出
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
line_bytes = len(line.encode('utf-8', errors='replace'))
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
allowed = self.MAX_OUTPUT_BYTES - total_bytes
if allowed > 0:
truncated_line = line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace')
output_queue.put(("line", truncated_line))
output_lines.append(truncated_line)
total_bytes = self.MAX_OUTPUT_BYTES
truncated = True
process.terminate()
output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
break
stripped = line.rstrip('\n')
output_queue.put(("line", stripped + "\n"))
output_lines.append(line)
total_bytes += line_bytes
# 如果超时或截断,确保进程被终止 # 如果超时或截断,确保进程被终止
if (timeout_occurred or truncated) and process.poll() is None: if (timeout_occurred or truncated) and process.poll() is None:
process.kill() self._terminate_tree(process, force=True)
try: try:
process.wait(timeout=2) process.wait(timeout=2)
except: except:
@@ -317,10 +382,8 @@ class SubprocessCommandExecutor(CommandExecutor):
else: else:
output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n")) output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n"))
# 准备完整结果
full_output = "".join(output_lines)
data = { data = {
"输出": full_output.strip() if full_output.strip() else "(无输出)", "输出": output_str.strip() if output_str.strip() else "(无输出)",
"返回码": return_code "返回码": return_code
} }
@@ -339,11 +402,12 @@ class SubprocessCommandExecutor(CommandExecutor):
output_queue.put(("complete", f"❌ 执行错误: {str(e)}")) output_queue.put(("complete", f"❌ 执行错误: {str(e)}"))
finally: finally:
if process and process.poll() is None: if process and process.poll() is None:
process.terminate() self._terminate_tree(process)
try: try:
process.wait(timeout=2) process.wait(timeout=2)
except: except:
process.kill() self._terminate_tree(process, force=True)
self.process = None self.process = None
with self._process_lock: with self._process_lock:
self._current_process = None self._current_process = None
self._current_pgid = None
+33 -16
View File
@@ -18,6 +18,7 @@ subsystems.prompt_manager - 提示词管理器
import os import os
import re import re
import shutil import shutil
import threading
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional from typing import Dict, List, Optional
@@ -35,6 +36,7 @@ class PromptManager:
""" """
_instance = None _instance = None
_instance_lock = threading.Lock() # I 批:并发首例化竞态防护
_prompts = [] _prompts = []
_prompts_dir = None _prompts_dir = None
@@ -43,12 +45,18 @@ class PromptManager:
STATUS_FILE = "100_STATUS.md" STATUS_FILE = "100_STATUS.md"
SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容 SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容
# N9(审计 2026-08-18):Agent 可控内容直写 00/99/100 文件并作为后续
# 会话系统提示词加载(持久化注入面)。内容长度上限约束注入规模,
# 超长内容拒绝写入(工具层有更友好的提示)。
MAX_CONTENT_LENGTH = 10000
# 基础技能文件名模式:N_xxx.md(N 为数字,如 1_memory_search.md、10_task_handling.md) # 基础技能文件名模式:N_xxx.md(N 为数字,如 1_memory_search.md、10_task_handling.md)
_SKILL_FILE_RE = re.compile(r'^(\d+)_') _SKILL_FILE_RE = re.compile(r'^(\d+)_')
def __new__(cls): def __new__(cls):
if cls._instance is None: with cls._instance_lock:
cls._instance = super().__new__(cls) if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance return cls._instance
def __init__(self): def __init__(self):
@@ -60,10 +68,16 @@ class PromptManager:
prompts_dir = config.get("system.prompts_dir", "~/.aibox/prompts") prompts_dir = config.get("system.prompts_dir", "~/.aibox/prompts")
return os.path.expanduser(prompts_dir) return os.path.expanduser(prompts_dir)
def _load_prompts(self): def _load_prompts(self, target: Optional[List[Dict]] = None):
"""加载提示词到 target 列表(缺省 self._prompts)。
M6:reload 传入新列表本地构建,完成后由 reload 原子替换——
此前先清空 self._prompts 再逐文件重读,并发读者(其他连接构建
system 消息)会读到空/半截提示词列表。
"""
prompts = target if target is not None else self._prompts
self._prompts_dir = self._get_prompts_dir() self._prompts_dir = self._get_prompts_dir()
os.makedirs(self._prompts_dir, exist_ok=True) os.makedirs(self._prompts_dir, exist_ok=True)
self._prompts = []
# 确保核心文件存在 # 确保核心文件存在
self._ensure_identity_file() self._ensure_identity_file()
@@ -86,17 +100,14 @@ class PromptManager:
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, 'r', encoding='utf-8') as f:
content = f.read().strip() content = f.read().strip()
if content: if content:
self._prompts.append({ prompts.append({
'file': filename, 'file': filename,
'content': content 'content': content
}) })
except Exception as e: except Exception as e:
print(f"⚠️ 读取提示词文件 {filename} 失败: {e}") print(f"⚠️ 读取提示词文件 {filename} 失败: {e}")
essential_files = [self.IDENTITY_FILE, self.STATUS_FILE, self.SOUL_FILE] if not prompts:
other_files = [f for f in prompt_files if f not in essential_files] if prompt_files else []
if not self._prompts:
self._create_default_prompts() self._create_default_prompts()
def _get_template_dir(self) -> str: def _get_template_dir(self) -> str:
@@ -148,6 +159,9 @@ class PromptManager:
def update_identity(self, content: str) -> bool: def update_identity(self, content: str) -> bool:
"""更新用户身份信息""" """更新用户身份信息"""
if len(content or "") > self.MAX_CONTENT_LENGTH:
print(f"⚠️ 身份内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
return False
identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE) identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE)
try: try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -168,6 +182,9 @@ class PromptManager:
def update_status(self, content: str) -> bool: def update_status(self, content: str) -> bool:
"""更新智能体状态""" """更新智能体状态"""
if len(content or "") > self.MAX_CONTENT_LENGTH:
print(f"⚠️ 状态内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
return False
status_path = os.path.join(self._prompts_dir, self.STATUS_FILE) status_path = os.path.join(self._prompts_dir, self.STATUS_FILE)
try: try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -188,6 +205,9 @@ class PromptManager:
def update_soul(self, content: str) -> bool: def update_soul(self, content: str) -> bool:
"""更新智能体灵魂定义 - 所有角色定位内容""" """更新智能体灵魂定义 - 所有角色定位内容"""
if len(content or "") > self.MAX_CONTENT_LENGTH:
print(f"⚠️ 灵魂内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
return False
soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE) soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE)
try: try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -240,10 +260,6 @@ class PromptManager:
return """我是DeepSeek,一个智能AI助手。 return """我是DeepSeek,一个智能AI助手。
请创建 99_SOUL.md 文件来定义我的完整身份、角色、性格和能力边界。""" 请创建 99_SOUL.md 文件来定义我的完整身份、角色、性格和能力边界。"""
def get_skill_prompts(self) -> List[Dict]:
"""获取所有基础技能提示词(以1_开头的文件)"""
return [p for p in self._prompts if p['file'].startswith(self.SKILL_PREFIX)]
def get_soul_prompt(self) -> Optional[Dict]: def get_soul_prompt(self) -> Optional[Dict]:
"""获取灵魂提示词(99_SOUL.md)""" """获取灵魂提示词(99_SOUL.md)"""
for p in self._prompts: for p in self._prompts:
@@ -296,9 +312,10 @@ class PromptManager:
return [p for p in self._prompts if self._skill_sort_key(p['file']) is not None] return [p for p in self._prompts if self._skill_sort_key(p['file']) is not None]
def reload(self): def reload(self):
"""重新加载所有提示词""" """重新加载所有提示词(M6:构建新列表后原子替换,并发读者始终读到完整列表)"""
self._prompts = [] new_prompts: List[Dict] = []
self._load_prompts() self._load_prompts(new_prompts)
self._prompts = new_prompts
return len(self._prompts) return len(self._prompts)
def get_prompt_count(self) -> int: def get_prompt_count(self) -> int:
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""审计修复 PoC 回归验证(H1/H2/H3 + 1MB + cancel)。
验证 2026-08-17 审计五项修复的实际运行效果:
PoC-1 (H2): 无输出命令 + timeout=3s → 应 ~3s 返回超时(修复前阻塞 15s+)
PoC-2 (H3): 等待 stdin 的命令 + timeout=3s → 应超时返回而非永久挂起
PoC-3 : 输出 >1MB → 应截断且总输出 ≈1MB
PoC-4 : cancel() 应在宽限时间内终止长命令
PoC-5 (H1) : CommandLineTool(工具层,非 executor)跑 >1MB 输出 →
截断生效即证明已走 executor 委托路径
"""
import queue
import sys
import threading
import time
import types
from pathlib import Path
AIBOX_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(AIBOX_DIR))
sys.modules.setdefault("websockets", types.ModuleType("websockets"))
from subsystems.executors import SubprocessCommandExecutor
from tools.command_tool import CommandLineTool
results = []
def check(name, ok, detail):
results.append((name, ok, detail))
print(f"[{'PASS' if ok else 'FAIL'}] {name}: {detail}")
# ---------- PoC-1 (H2): 无输出命令超时 ----------
ex = SubprocessCommandExecutor()
t0 = time.time()
r = ex.execute("powershell -NoProfile -Command Start-Sleep 15", timeout=3)
elapsed = time.time() - t0
check("PoC-1 无输出命令超时(H2)", elapsed < 6 and "超时" in r,
f"{elapsed:.1f}s 返回(修复前阻塞15s+),结果含超时: {'超时' in r}")
# ---------- PoC-2 (H3): 等待 stdin 的命令 ----------
# stdin=DEVNULL 下命令立即收到 EOF:要么快速退出,要么按 timeout 返回;
# 判定标准 = 不永久挂起(修复前 readline 无 EOF 永久阻塞)。
t0 = time.time()
r = ex.execute("powershell -NoProfile -Command Read-Host", timeout=3)
elapsed = time.time() - t0
check("PoC-2 stdin等待命令不挂起(H3)", elapsed < 6,
f"{elapsed:.1f}s 返回(修复前永久挂起)")
# ---------- PoC-3: 1MB 输出截断 ----------
t0 = time.time()
r = ex.execute(
'powershell -NoProfile -Command "$s=\'x\'*2048; 1..1024 | ForEach-Object { $s }"',
timeout=60)
elapsed = time.time() - t0
size_mb = len(r.encode("utf-8", errors="replace")) / 1024 / 1024
check("PoC-3 1MB输出截断", "截断" in r and size_mb < 1.15,
f"{elapsed:.1f}s,结果 {size_mb:.2f}MB,含截断标记: {'截断' in r}")
# ---------- PoC-4: cancel() 中断 ----------
out_q = queue.Queue()
done = threading.Event()
def _run():
ex.execute_streaming(
"powershell -NoProfile -Command Start-Sleep 60",
out_q, timeout=120)
done.set()
t0 = time.time()
th = threading.Thread(target=_run, daemon=True)
th.start()
time.sleep(2)
ex.cancel()
done.wait(timeout=10)
elapsed = time.time() - t0
check("PoC-4 cancel中断", done.is_set() and elapsed < 8,
f"cancel 后 {elapsed:.1f}s 结束(修复前杀不到进程需等满120s)")
# ---------- PoC-5 (H1): 工具层委托 executor(截断保护贯通) ----------
tool = CommandLineTool(executor=ex)
t0 = time.time()
r = tool.execute(
command='powershell -NoProfile -Command "$s=\'y\'*2048; 1..1024 | ForEach-Object { $s }"',
timeout=60)
elapsed = time.time() - t0
size_mb = len(r.encode("utf-8", errors="replace")) / 1024 / 1024
check("PoC-5 工具层1MB贯通(H1)", "截断" in r and size_mb < 1.15,
f"{elapsed:.1f}s,工具层结果 {size_mb:.2f}MB(修复前无限累积)")
# ---------- 汇总 ----------
fails = [n for n, ok, _ in results if not ok]
print("\n==== 汇总: %d/%d PASS ====" % (len(results) - len(fails), len(results)))
sys.exit(1 if fails else 0)
+2
View File
@@ -13,6 +13,8 @@ from pathlib import Path
# 把 aibox 目录加入 sys.path(无需安装为包即可 import tools/common) # 把 aibox 目录加入 sys.path(无需安装为包即可 import tools/common)
AIBOX_DIR = Path(__file__).resolve().parent.parent AIBOX_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(AIBOX_DIR)) sys.path.insert(0, str(AIBOX_DIR))
# tests 目录也加入:测试文件互相导入(如 test_models_profile 复用 test_once_mode 的 _make_ai)
sys.path.insert(0, str(Path(__file__).resolve().parent))
# 桩掉 websockets(aibox.py 顶部 import websockets,测试环境可能未安装) # 桩掉 websockets(aibox.py 顶部 import websockets,测试环境可能未安装)
if "websockets" not in sys.modules: if "websockets" not in sys.modules:
+155 -1
View File
@@ -15,7 +15,7 @@ from datetime import datetime, timedelta
import pytest import pytest
from common import constants from common import ConfigManager, constants
from subsystems import ( from subsystems import (
MemoryDatabase, MemoDatabase, PromptManager, SkillManager, MemoryDatabase, MemoDatabase, PromptManager, SkillManager,
SubprocessCommandExecutor, SubprocessCommandExecutor,
@@ -425,6 +425,160 @@ def test_get_proxy_when_disabled():
assert "未启用" in r or "启用" in r assert "未启用" in r or "启用" in r
# ---------- SetProxyTool 连通性验证(设置前验证,失败不落盘) ----------
@pytest.fixture
def fresh_config(tmp_path, monkeypatch):
"""隔离 ConfigManager 单例:AI_CONFIG_PATH 指向临时文件,前后重置类状态"""
cfg_file = tmp_path / "config.json"
monkeypatch.setenv("AI_CONFIG_PATH", str(cfg_file))
def _reset():
ConfigManager._instance = None
ConfigManager._config = None
ConfigManager._config_path = None
_reset()
yield ConfigManager
_reset()
class _FakeResp:
def __init__(self, status_code):
self.status_code = status_code
def _fake_session_factory(exc=None, resp=None):
"""构造替代 requests.Session 的假类:get() 抛异常或返回假响应"""
calls = []
class _FakeSession:
def __init__(self):
self.trust_env = True
def get(self, *args, **kwargs):
calls.append((args, kwargs))
if exc:
raise exc
return resp
_FakeSession.calls = calls
return _FakeSession
def test_check_connectivity_ok(monkeypatch):
fake = _fake_session_factory(resp=_FakeResp(204))
monkeypatch.setattr("tools.proxy_tools.requests.Session", fake)
t = SetProxyTool()
ok, kind, detail = t._check_connectivity("127.0.0.1", 1080, None, None)
assert ok and kind == ""
# trust_env 必须关掉:排除系统 http_proxy 环境变量干扰
assert fake.calls, "应发出探测请求"
def test_check_connectivity_classification(monkeypatch, tmp_path):
"""异常类型/消息 → 错误类型分类(与系统语言无关)"""
import requests as requests_lib
cases = [
# Windows 本地化的连接拒绝消息:靠异常类型而非英文关键词分类
(requests_lib.exceptions.ConnectionError(
"[WinError 10061] 由于目标计算机积极拒绝,无法连接。"), "connect_failed"),
# ConnectTimeout 同时继承 ConnectionError/Timeout,必须按 Timeout 归类
(requests_lib.exceptions.ConnectTimeout("connection timeout"), "timeout"),
(requests_lib.exceptions.ConnectionError(
"SOCKS5 authentication failed"), "auth_failed"),
(requests_lib.exceptions.ConnectionError(
"Missing dependencies for SOCKS support."), "missing_dependency"),
(Exception("Read timed out"), "timeout"), # 兜底:非 requests 异常按消息
(Exception("Connection refused"), "connect_failed"),
(Exception("something weird"), "error"),
]
t = SetProxyTool()
for exc, expect_kind in cases:
monkeypatch.setattr(
"tools.proxy_tools.requests.Session",
_fake_session_factory(exc=exc))
ok, kind, _ = t._check_connectivity("127.0.0.1", 1080, "u", "p")
assert not ok
assert kind == expect_kind, f"{exc!r} 应分类为 {expect_kind},实际 {kind}"
def test_check_connectivity_bad_status(monkeypatch):
"""探测端点返回非 200/204 → probe_failed"""
monkeypatch.setattr(
"tools.proxy_tools.requests.Session",
_fake_session_factory(resp=_FakeResp(502)))
t = SetProxyTool()
ok, kind, detail = t._check_connectivity("127.0.0.1", 1080, None, None)
assert not ok and kind == "probe_failed"
def test_set_proxy_rejected_when_unreachable(fresh_config, monkeypatch):
"""验证失败:返回 ❌ 且配置不落盘"""
monkeypatch.setattr(
SetProxyTool, "_check_connectivity",
lambda self, h, p, u, pw: (False, "connect_failed", "Connection refused"))
t = SetProxyTool()
r = t.execute(host="10.255.255.1", port=1080)
assert "❌" in r
assert "未保存" in r
# 配置未写入:代理仍未启用
assert fresh_config().get_proxy() is None
def test_set_proxy_saved_when_check_passes(fresh_config, monkeypatch):
"""验证通过:配置保存且结果带连通性详情"""
monkeypatch.setattr(
SetProxyTool, "_check_connectivity",
lambda self, h, p, u, pw: (True, "", "探测成功 HTTP 204"))
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080)
assert "✅" in r
assert "探测成功" in r
proxies = fresh_config().get_proxy()
assert proxies and "127.0.0.1:1080" in proxies["http"]
def test_set_proxy_skip_check_bypasses_validation(fresh_config, monkeypatch):
"""skip_check=True:不验证直接保存(代理未启动的预配置场景)"""
called = []
def _no_call(self, *a, **kw):
called.append(1)
raise AssertionError("skip_check=True 时不应调用验证")
monkeypatch.setattr(SetProxyTool, "_check_connectivity", _no_call)
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080, skip_check=True)
assert "✅" in r
assert not called
assert fresh_config().get_proxy() is not None
def test_set_proxy_disabled_skips_check(fresh_config, monkeypatch):
"""enabled=False:禁用代理无需验证"""
def _no_call(self, *a, **kw):
raise AssertionError("禁用代理不应触发验证")
monkeypatch.setattr(SetProxyTool, "_check_connectivity", _no_call)
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080, enabled=False)
assert "✅" in r
assert fresh_config().get_proxy() is None # 禁用态 get_proxy 返回 None
def test_set_proxy_empty_env_hint(fresh_config, monkeypatch):
"""username + password_env 指向未设环境变量:精准提示而非裸 auth 失败"""
monkeypatch.delenv("AIBOX_TEST_SOCKS_PW", raising=False)
t = SetProxyTool()
r = t.execute(host="127.0.0.1", port=1080,
username="u1", password_env="AIBOX_TEST_SOCKS_PW")
assert "❌" in r
assert "环境变量" in r
assert "未保存" in r
assert fresh_config().get_proxy() is None
# ==================== AskUserTool ==================== # ==================== AskUserTool ====================
def test_ask_user_interface(): def test_ask_user_interface():
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""F1(N1 高危)修复回归测试:代理密码不落盘。
覆盖三点:
1. set_proxy(password=..., save=True) 后配置文件无明文密码(_save_config 脱敏)
2. password_env 解析:get_proxy() 从环境变量取密码拼 socks5:// URL
3. 旧版明文配置迁移:加载后盘上明文被清除,内存中本会话仍可用
"""
import json
import pytest
from common.config import ConfigManager
@pytest.fixture
def fresh_config(tmp_path, monkeypatch):
"""隔离 ConfigManager 单例:AI_CONFIG_PATH 指向临时文件,前后重置类状态"""
cfg_file = tmp_path / "config.json"
monkeypatch.setenv("AI_CONFIG_PATH", str(cfg_file))
def _reset():
ConfigManager._instance = None
ConfigManager._config = None
ConfigManager._config_path = None
_reset()
yield ConfigManager
_reset()
def _read_cfg_file() -> dict:
path = ConfigManager()._config_path
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def test_set_proxy_sanitizes_password_on_save(fresh_config, tmp_path):
"""save=True 落盘后,文件中不得出现明文密码(纵深防御脱敏)"""
cfg = fresh_config()
cfg.set_proxy("127.0.0.1", 1080, username="u1", password="secret123",
password_env="MY_SOCKS_PW", save=True)
on_disk = _read_cfg_file()
socks = on_disk["proxy"]["socks5"]
assert socks["password"] in (None, ""), "落盘文件不得含明文密码"
assert socks["password_env"] == "MY_SOCKS_PW", "password_env 应持久化"
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
assert "secret123" not in raw, "整个配置文件不得出现明文密码字符串"
# 内存中本会话仍可用
proxies = cfg.get_proxy()
assert proxies is not None
assert "u1:secret123@" in proxies["https"]
def test_password_env_resolution(fresh_config, monkeypatch):
"""password 为空时按 password_env 从环境变量解析"""
monkeypatch.setenv("TEST_SOCKS_PW", "env-secret-42")
cfg = fresh_config()
cfg.set_proxy("10.0.0.8", 7890, username="u2", password=None,
password_env="TEST_SOCKS_PW", save=True)
proxies = cfg.get_proxy()
assert proxies is not None
assert "u2:env-secret-42@10.0.0.8:7890" in proxies["https"]
# get_proxy_info 不泄漏密码值,只给布尔 + 变量名
info = cfg.get_proxy_info()
assert info["password"] is True
assert info["password_env"] == "TEST_SOCKS_PW"
def test_legacy_plaintext_migration(fresh_config, tmp_path):
"""旧版明文配置:加载后盘上清除,内存保留供本会话使用"""
cfg_file = tmp_path / "config.json"
legacy = {
"proxy": {
"socks5": {
"enabled": True, "host": "1.2.3.4", "port": 1080,
"username": "old_u", "password": "legacy_plain_pw"
}
}
}
cfg_file.write_text(json.dumps(legacy, ensure_ascii=False), encoding="utf-8")
cfg = fresh_config() # 触发 _load_config → 迁移
# 盘上已脱敏
on_disk = _read_cfg_file()
assert on_disk["proxy"]["socks5"]["password"] in (None, "")
raw = cfg_file.read_text(encoding="utf-8")
assert "legacy_plain_pw" not in raw
# 内存中本会话仍可解析出旧密码(代理不断)
proxies = cfg.get_proxy()
assert proxies is not None
assert "old_u:legacy_plain_pw@1.2.3.4:1080" in proxies["https"]
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""F2 修复回归测试:N6 registry 超时清理 + N4 reader 线程收尾。
N6:流式转发超时后必须清理 tool.streaming_output / tool.output_queue,
防止僵尸 daemon 线程经实例属性把输出混入后续会话队列。
N4:_read_with_limit 超时/宽限 break 后必须 close stdout + join reader,
防止 Windows shell=True 孙进程持管道场景下 reader 线程与 fd 泄漏。
"""
import queue
import threading
import time
import pytest
from subsystems.executors import SubprocessCommandExecutor
from tools.base import Tool
from tools.registry import ToolRegistry
# ---------- N6:registry 超时路径清理 ----------
class _StuckStreamTool(Tool):
"""execute_streaming 永不结束(模拟卡死的工具,如等待外部资源)"""
def get_name(self):
return "stuck_tool"
def get_description(self):
return "test only"
def get_parameters(self):
return {"type": "object", "properties": {}}
def execute(self, **kwargs):
time.sleep(30)
return "should not reach"
def execute_streaming(self, output_queue: queue.Queue, **kwargs):
self.output_queue = output_queue
self.streaming_output = True
try:
time.sleep(30) # 卡死,永不产出 complete
finally:
self.streaming_output = False
def test_registry_timeout_cleans_tool_state():
"""转发总超时后:返回超时消息,且工具实例属性被清理"""
reg = ToolRegistry()
reg.stream_tool_timeout = 1 # 测试调小(默认 3600)
tool = _StuckStreamTool()
reg.register(tool)
reg.set_output_queue(queue.Queue())
t0 = time.time()
result = reg.execute_tool("stuck_tool")
elapsed = time.time() - t0
assert "超时" in result, f"应返回超时消息,实际: {result}"
assert elapsed < 10, f"应 ~1s 返回,实际 {elapsed:.1f}s"
# N6 核心:超时路径不得跳过清理
assert tool.output_queue is None, "超时后 tool.output_queue 必须置 None(防僵尸输出串流)"
assert tool.streaming_output is False, "超时后 tool.streaming_output 必须复位"
def test_registry_normal_path_cleans_tool_state():
"""正常流式完成后:清理同样执行,结果透传"""
reg = ToolRegistry()
class _OkTool(_StuckStreamTool):
def get_name(self):
return "ok_tool"
def execute_streaming(self, output_queue: queue.Queue, **kwargs):
self.output_queue = output_queue
self.streaming_output = True
try:
output_queue.put(("line", "hello"))
output_queue.put(("complete", "done-result"))
finally:
pass
tool = _OkTool()
reg.register(tool)
reg.set_output_queue(queue.Queue())
result = reg.execute_tool("ok_tool")
assert result == "done-result"
assert tool.output_queue is None
assert tool.streaming_output is False
# ---------- N4:reader 线程收尾 ----------
class _FakeStdout:
"""模拟管道 stdout,可配置阻塞时长"""
def __init__(self, block_seconds: float = 8):
self.closed = False
self._block = block_seconds
def readline(self):
time.sleep(self._block) # > join 宽限 2s 即模拟"无 EOF 阻塞"
return ""
def close(self):
self.closed = True
class _FakeProcess:
def __init__(self, block_seconds: float = 8):
self.stdout = _FakeStdout(block_seconds)
def poll(self):
return None # 进程"活着"
def terminate(self):
pass
def kill(self):
pass
def test_read_with_limit_timeout_returns_promptly():
"""超时 break 后 reader 仍阻塞:只做有限 join,不 close、不挂死。
CPython 流锁决定了此时 close(stdout) 会阻塞到 readline 返回,
故收尾策略为放弃等待(daemon 兜底),主流程必须及时返回。
"""
ex = SubprocessCommandExecutor()
proc = _FakeProcess(block_seconds=8)
t0 = time.time()
output, timeout_occurred, truncated = ex._read_with_limit(
proc, timeout=1, start_time=time.time())
elapsed = time.time() - t0
assert timeout_occurred is True
assert truncated is False
# 超时 1s + join 宽限 2s,绝不能等到 block 8s
assert elapsed < 5, f"应收尾后及时返回,实际 {elapsed:.1f}s"
def test_read_with_limit_grace_path_returns_promptly():
"""shell 死亡宽限 break 后 reader 仍阻塞:同样及时返回"""
ex = SubprocessCommandExecutor()
proc = _FakeProcess(block_seconds=8)
proc.poll = lambda: 0 # shell 已死,但 reader 无 EOF(孙进程持管道)
t0 = time.time()
output, timeout_occurred, truncated = ex._read_with_limit(
proc, timeout=0, start_time=time.time()) # timeout=0 不触发超时
elapsed = time.time() - t0
assert timeout_occurred is False
# 宽限 1s + join 2s 上界,不能等到 block 8s
assert elapsed < 5, f"宽限+收尾应 ~3s 内返回,实际 {elapsed:.1f}s"
def test_read_with_limit_closes_stdout_after_normal_eof():
"""reader 正常收到 EOF 退出后:显式 close stdout 释放管道 fd"""
ex = SubprocessCommandExecutor()
proc = _FakeProcess(block_seconds=0) # readline 立即返回 ""(EOF)
output, timeout_occurred, truncated = ex._read_with_limit(
proc, timeout=5, start_time=time.time())
assert timeout_occurred is False
assert output == ""
assert proc.stdout.closed is True, "reader 正常退出后必须 close stdout 释放 fd"
+359
View File
@@ -0,0 +1,359 @@
"""aibox 第三轮审计 Batch G 修复回归测试(2026-08-18,审计报告 §9.4)
覆盖:
- P1:非瞬时 HTTP 错误响应体截断(密钥/超大错误页不进异常栈)
- P2:Retry-After 解析上限 cap 3600s
- P3:fallback 档案缺 model → 跳过并告警(与缺 Key 行为对齐)
- P5:LLMClient 死状态 _cancelled 已删除
- P6:fallback 列表重复项去重
- N2:drain 线程批次结束即回收(不再泄漏常驻线程)
- N5:子代理共享父代理取消事件
- N7:FTS5 关键词内双引号翻倍转义
- N8:registry 流式转发受 TOOL_MAX_RESULT_LENGTH 约束
- N10:mark_as_triggered 单事务(缺失 id 路径回归)
- N12:persona 路径边界校验(拒绝越界路径)
- N16:同秒归档不再覆盖(文件名加序号)
N3(/new 移线程池)/N13(broadcast)/N14(任务登记集合)为 async WebSocket
路径,依赖事件循环与真实连接对象,由现有 WebSocket 测试面回归,不在此单测。
"""
import os
import queue
import sys
import threading
import types
from unittest import mock
import pytest
# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ----
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import aibox # noqa: E402
from aibox import DeepSeekChat, run_full_subagent # noqa: E402
from llm_client import ( # noqa: E402
DeepSeekClient,
FailoverClient,
_retry_after_of,
create_llm_client,
)
from subsystems.db import MemoryDatabase, MemoDatabase # noqa: E402
from subsystems.conversation_history import ConversationHistory # noqa: E402
from tools.base import Tool # noqa: E402
from tools.registry import ToolRegistry # noqa: E402
from common import constants # noqa: E402
# ==================== P1/P2:错误响应体截断 + Retry-After cap ====================
class TestP1P2LlmClient:
def test_p1_non_transient_error_body_truncated(self):
"""400 响应体 1000 字符 → 异常消息仅含前 200 字符"""
c = DeepSeekClient(api_key="k", model="m", base_url="https://x")
resp = mock.MagicMock()
resp.status_code = 400
resp.text = "A" * 1000
resp.headers = {}
with mock.patch("llm_client.requests.post", return_value=resp):
with pytest.raises(RuntimeError) as ei:
list(c.stream_chat([{"role": "user", "content": "hi"}]))
msg = str(ei.value)
assert "HTTP 400" in msg
assert len(msg) < 300 # 200 字符正文 + 状态行前缀
assert msg.count("A") <= 200
def test_p2_retry_after_capped(self):
"""异常/恶意 Retry-After=999999 → cap 到 3600s"""
resp = mock.MagicMock()
resp.headers = {"Retry-After": "999999"}
assert _retry_after_of(resp) == 3600.0
def test_p2_retry_after_normal_value_kept(self):
resp = mock.MagicMock()
resp.headers = {"Retry-After": "30"}
assert _retry_after_of(resp) == 30.0
def test_p2_retry_after_invalid_returns_none(self):
resp = mock.MagicMock()
resp.headers = {"Retry-After": "not-a-number"}
assert _retry_after_of(resp) is None
# ==================== P3/P5/P6:fallback 链构造 ====================
def _g_cfg():
"""主档案 main 的 fallback 链:缺 model 的 broken + 重复 dupe + 正常 ok2"""
return {
"models": {
"main": {
"api_url": "https://a.example/v1/chat/completions",
"model": "m1", "api_key_env": "MAIN_KEY",
"fallback": ["nomodel", "dupe", "dupe", "ok2"],
},
"nomodel": { # P3:缺 model 字段
"api_url": "https://b.example/v1/chat/completions",
"api_key_env": "B_KEY",
},
"dupe": {
"api_url": "https://c.example/v1/chat/completions",
"model": "c1", "api_key_env": "C_KEY",
},
"ok2": {
"api_url": "https://d.example/v1/chat/completions",
"model": "d1", "api_key_env": "D_KEY",
},
}
}
class TestP3P5P6FallbackChain:
def test_p3_p6_fallback_missing_model_skipped_and_deduped(self):
"""缺 model 的 fallback 跳过;重复项去重;链 = [main, dupe, ok2]"""
env = {k: f"sk-{k.lower()}" for k in
("MAIN_KEY", "B_KEY", "C_KEY", "D_KEY")}
with mock.patch.dict(os.environ, env):
c = create_llm_client(_g_cfg(), model="main")
assert isinstance(c, FailoverClient)
names = [cl.name for cl in c._clients]
assert names == ["main", "dupe", "ok2"]
def test_p5_dead_cancel_flag_removed(self):
"""LLMClient 不再持有只写不读的 _cancelled 死状态"""
c = DeepSeekClient(api_key="k", model="m", base_url="https://x")
assert not hasattr(c, "_cancelled")
c.cancel() # 无在途响应时为安全 no-op
# ==================== N2:drain 线程生命周期 ====================
class _EchoTool(Tool):
def get_name(self):
return "g_echo"
def get_description(self):
return "测试用回显工具"
def get_parameters(self):
return {"type": "object", "properties": {}}
def execute(self, **kwargs):
return "ok"
class TestN2DrainThread:
def test_drain_thread_reclaimed_after_batch(self):
"""_handle_tool_calls 返回后 drain 线程必须已退出(finally join)"""
from test_once_mode import _make_ai
ai = _make_ai()
ai.tool_registry.register(_EchoTool(ai.logger))
tool_calls = [{"id": "c1", "function": {"name": "g_echo", "arguments": "{}"}}]
ai._handle_tool_calls(tool_calls, output_callback=lambda t, c: None)
drains = [t for t in threading.enumerate() if t.name == "tool-output-drain"]
assert drains == [] or all(not t.is_alive() for t in drains)
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
def test_drain_thread_reclaimed_on_tool_exception(self):
"""工具执行抛异常时 drain 线程同样被回收(finally 路径)"""
from test_once_mode import _make_ai
class _BoomTool(_EchoTool):
def get_name(self):
return "g_boom"
def execute(self, **kwargs):
raise RuntimeError("boom")
ai = _make_ai()
ai.tool_registry.register(_BoomTool(ai.logger))
tool_calls = [{"id": "c1", "function": {"name": "g_boom", "arguments": "{}"}}]
ai._handle_tool_calls(tool_calls, output_callback=lambda t, c: None)
drains = [t for t in threading.enumerate() if t.name == "tool-output-drain"]
assert drains == [] or all(not t.is_alive() for t in drains)
# ==================== N5:子代理取消传播 ====================
def _fake_sub_chat():
fake = mock.MagicMock()
fake.think_and_respond = mock.MagicMock(return_value="任务完成报告")
fake.stats = {}
fake.messages = []
fake._cancel_event = threading.Event()
return fake
class TestN5SubagentCancel:
def test_subagent_shares_parent_cancel_event(self):
"""cancel_event 传入 → 子代理取消事件被替换为父事件(检查点联动)"""
fake = _fake_sub_chat()
parent_event = threading.Event()
with mock.patch("aibox.DeepSeekChat", return_value=fake), \
mock.patch("aibox.create_llm_client"), \
mock.patch("aibox.SubprocessCommandExecutor"):
run_full_subagent("r", "t", None, cancel_event=parent_event)
assert fake._cancel_event is parent_event
def test_subagent_keeps_own_event_when_not_provided(self):
"""cancel_event=None → 兼容旧行为,子代理保留自身事件"""
fake = _fake_sub_chat()
own = fake._cancel_event
with mock.patch("aibox.DeepSeekChat", return_value=fake), \
mock.patch("aibox.create_llm_client"), \
mock.patch("aibox.SubprocessCommandExecutor"):
run_full_subagent("r", "t", None)
assert fake._cancel_event is own
def test_subagent_tool_registered_with_parent_event(self):
"""DeepSeekChat 注册 SubAgentTool 时注入自身 _cancel_event(N5 链路)"""
from test_once_mode import _make_ai
ai = _make_ai()
tool = next((t for t in ai.tool_registry.get_all_tools()
if t.get_name() == "create_subagent"), None)
assert tool is not None
assert tool.cancel_event is ai._cancel_event
# ==================== N7:FTS5 引号转义 ====================
class TestN7FtsQuoteEscape:
def test_keyword_with_quote_does_not_break_search(self, tmp_path):
"""关键词含双引号 → MATCH 语法合法(翻倍转义),不抛异常"""
db = MemoryDatabase(db_path=str(tmp_path / "mem.db"))
db.add_memory(topic="引号话题", summary="摘要",
content='含"引号"的记忆内容',
start_time="2026-08-18 00:00:00",
end_time="2026-08-18 00:00:01", importance=2)
res = db.search_memories(['带"引号"的关键词'])
assert isinstance(res, list) # 不抛 OperationalError 即通过
def test_normal_keyword_still_matches(self, tmp_path):
"""常规关键词检索行为不回归"""
db = MemoryDatabase(db_path=str(tmp_path / "mem.db"))
db.add_memory(topic="普通话题", summary="天气摘要",
content="今天天气晴朗",
start_time="2026-08-18 00:00:00",
end_time="2026-08-18 00:00:01", importance=2)
res = db.search_memories(["天气"])
assert len(res) >= 1
# ==================== N8:流式转发长度上限 ====================
class _BigStreamTool(Tool):
"""流式工具:发出 3 行 × 40000 字符(总 120000 > 100000 上限)"""
def get_name(self):
return "g_bigstream"
def get_description(self):
return "测试用超长流式工具"
def get_parameters(self):
return {"type": "object", "properties": {}}
def execute(self, **kwargs):
return "done"
def execute_streaming(self, output_queue, **kwargs):
for _ in range(3):
output_queue.put(("line", "x" * 40000))
output_queue.put(("complete", "done"))
class TestN8StreamingLimit:
def test_streaming_forward_truncated_to_budget(self):
registry = ToolRegistry()
registry.register(_BigStreamTool())
captured = queue.Queue()
registry.set_output_queue(captured)
result = registry.execute_tool("g_bigstream")
lines = []
while not captured.empty():
msg_type, content = captured.get_nowait()
if msg_type == "line":
lines.append(content)
budget = constants.TOOL_MAX_RESULT_LENGTH
total = sum(len(ln) for ln in lines)
# 截断标记行本身占少量额外字符(含提示文案)
assert total <= budget + 200
assert any("已停止转发" in ln or "已截断" in ln for ln in lines)
assert result == "done"
# ==================== N10:mark_as_triggered 单事务 ====================
class TestN10MarkTriggered:
def test_missing_id_returns_none_via_transaction(self, tmp_path):
"""事务内读取不存在行 → rollback + None(不再走独立 get_memo)"""
db = MemoDatabase(db_path=str(tmp_path / "memo.db"))
assert db.mark_as_triggered(99999) is None
def test_single_shot_memo_completes(self, tmp_path):
"""一次性任务触发 → completed + 返回 None(无下次提醒)"""
db = MemoDatabase(db_path=str(tmp_path / "memo.db"))
mid = db.add_memo(title="一次性", content="c",
reminder_time="2026-08-18 00:00:00")
assert mid is not None
assert db.mark_as_triggered(mid) is None
memo = db.get_memo(mid)
assert memo["is_completed"] == 1
assert memo["trigger_count"] == 1
# ==================== N12:persona 路径边界 ====================
class TestN12PersonaBoundary:
def _tool(self):
return aibox.ExpressEmotionTool()
def test_traversal_persona_rejected(self, tmp_path):
"""persona 含 ../ 越界 → 拒绝(返回空串,不读文件)"""
tool = self._tool()
skills = tmp_path / "skills"
skills.mkdir()
with mock.patch.object(type(aibox.constants), "SKILLS_DIR",
new_callable=mock.PropertyMock(return_value=str(skills))):
assert tool._load_persona("../../etc/passwd", None) == ""
assert tool._load_persona("../outside", None) == ""
def test_absolute_persona_rejected(self, tmp_path):
"""persona 为绝对路径 → 解析结果不在技能目录内,拒绝"""
tool = self._tool()
skills = tmp_path / "skills"
skills.mkdir()
with mock.patch.object(type(aibox.constants), "SKILLS_DIR",
new_callable=mock.PropertyMock(return_value=str(skills))):
assert tool._load_persona(str(tmp_path / "evil"), None) == ""
def test_normal_persona_loads(self, tmp_path):
"""合法 persona(技能目录内)正常加载"""
tool = self._tool()
skills = tmp_path / "skills"
skills.mkdir()
(skills / "ok.md").write_text("人格内容", encoding="utf-8")
with mock.patch.object(type(aibox.constants), "SKILLS_DIR",
new_callable=mock.PropertyMock(return_value=str(skills))):
assert "人格内容" in tool._load_persona("ok", None)
# ==================== N16:同秒归档防覆盖 ====================
class TestN16ArchiveCollision:
def test_same_second_archive_no_overwrite(self, tmp_path):
"""同一秒内两次归档 → 文件名加序号,互不覆盖"""
hm = ConversationHistory(save_dir=str(tmp_path))
hm.start_new_conversation(topic="t1")
hm.add_message("user", "hi", speaker="u")
p1 = hm.archive_current_conversation()
hm.start_new_conversation(topic="t2")
hm.add_message("user", "hi2", speaker="u")
p2 = hm.archive_current_conversation()
assert p1 != p2
assert os.path.exists(p1) and os.path.exists(p2)
+408
View File
@@ -0,0 +1,408 @@
"""aibox 第四轮审计 Batch H 修复回归测试(2026-08-18,审计报告 §十一)
覆盖:
- H1:子代理共享父取消事件后首轮 think 不再清除父事件(N5 回归)+ 父已取消时跳过启动
- M3:调度器任务级超时兜底(AI think 卡死不再停摆调度循环)
- M4:get_recent_memories(limit=None) 使用默认上限(不再全表加载)
- M5:memo delete 物理删除(此前只标记完成)
- M6:PromptManager.reload 原子替换(并发读者不再读到空/半截列表)
- L1:read_file 的 sed 参数强转 int(防引号注入)
- L2:set_proxy 落盘失败如实报告(不再静默报"已保存")
- L3:SSE 非 UTF-8 行不中断流(errors=replace)
- L4:persona 路径校验解析符号链接(软链越界拒绝)
- L5:1MB 截断不再产生半个字符(�)
- L6:compressor 空内容压缩不再除零
- L7:InstallSkillTool 指引命令路径引用(防拆词/注入)
- L8:ask_user 缺参返回明确错误(不再 TypeError)
- L10:web 工具仅允许 http/https(file:// 等拒绝)
- L11:registry 日志打码改包含匹配(apiKey/access_token 等变体)
- L12:FailoverClient 日志 Bearer 令牌脱敏
M1(/reload_prompts 移线程池)/M2(技能扫描启动前预热)为 async WebSocket /
启动路径,依赖事件循环与真实连接对象,由既有 WebSocket 测试面与启动逻辑回归。
"""
import asyncio
import os
import queue
import sys
import threading
import types
from unittest import mock
import pytest
# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ----
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import aibox # noqa: E402
from aibox import DeepSeekChat, TaskScheduler, run_full_subagent # noqa: E402
from llm_client import DeepSeekClient, FailoverClient, _sanitize_log_msg # noqa: E402
from subsystems.db import MemoryDatabase, MemoDatabase # noqa: E402
from subsystems.prompt_manager import PromptManager # noqa: E402
from subsystems.conversation_compressor import ConversationCompressor # noqa: E402
from common import constants # noqa: E402
from test_conversation_compressor import FakeLLMClient # noqa: E402
from test_once_mode import _make_ai # noqa: E402
def _fake_sub_chat():
fake = mock.MagicMock()
fake.think_and_respond = mock.MagicMock(return_value="任务完成报告")
fake.stats = {}
fake.messages = []
fake._cancel_event = threading.Event()
return fake
# ==================== H1:子代理取消事件清除回归 ====================
class TestH1SubagentCancelClear:
def test_shared_event_not_cleared_on_first_think(self):
"""共享父事件后首轮 think 不清除事件——父级已置位的取消信号不被吞掉"""
ai = _make_ai()
parent_event = threading.Event()
parent_event.set()
ai._cancel_event = parent_event
ai._shared_cancel_event = True
with pytest.raises(InterruptedError):
ai._think_and_respond_impl("hi")
assert parent_event.is_set()
def test_own_event_still_cleared_on_first_think(self):
"""未共享(普通会话)时首轮仍清除自身事件——原语义不回归"""
import contextlib
ai = _make_ai()
ai._cancel_event.set()
with contextlib.suppress(Exception):
ai._think_and_respond_impl("hi")
assert not ai._cancel_event.is_set()
def test_subagent_not_started_when_parent_already_cancelled(self):
"""父事件已置位 → 直接返回取消提示,不启动子代理"""
fake = _fake_sub_chat()
parent_event = threading.Event()
parent_event.set()
with mock.patch("aibox.DeepSeekChat", return_value=fake), \
mock.patch("aibox.create_llm_client"), \
mock.patch("aibox.SubprocessCommandExecutor"):
result = run_full_subagent("r", "t", None, cancel_event=parent_event)
assert "取消" in result
fake.think_and_respond.assert_not_called()
# ==================== M3:调度器任务超时 ====================
class _BlockingAI:
"""think 卡死直到被 cancel 的伪 AI(模拟 LLM 挂起)"""
def __init__(self):
self.cancelled = threading.Event()
self.cancel_called = 0
def cancel(self):
self.cancel_called += 1
self.cancelled.set()
def think_and_respond(self, input_text, output_callback=None,
is_continuation=False):
self.cancelled.wait(timeout=15)
raise InterruptedError("用户中断")
class _FakeMemoDB:
def get_memo(self, memo_id):
return None
class TestM3SchedulerTimeout:
def test_stuck_task_times_out_and_scheduler_continues(self):
from aibox import ConfigManager
loop = asyncio.new_event_loop()
try:
scheduler = TaskScheduler(loop=loop, logger=None)
fake_ai = _BlockingAI()
memo = {"id": 7, "title": "卡死任务", "content": "内容", "trigger_count": 0}
orig_get = ConfigManager.get
def fake_get(self, key, default=None):
# 只把任务超时压到 0.3s,其余配置读取走原实现(防止
# DATETIME_FORMAT 等属性读到错误类型)
if key == "scheduler.task_timeout":
return 0.3
return orig_get(self, key, default)
with mock.patch.object(ConfigManager, "get", fake_get), \
mock.patch.object(scheduler, "_create_task_ai", return_value=fake_ai):
result = loop.run_until_complete(
scheduler.execute_task_as_conversation(memo, _FakeMemoDB()))
assert "超时" in result
assert fake_ai.cancel_called >= 1
finally:
loop.close()
# ==================== M4/M5:记忆上限与任务删除 ====================
class TestM4M5Db:
def test_recent_memories_none_capped(self, tmp_path, monkeypatch):
"""limit=None 不再全表加载,使用默认上限(对齐 search_memories)"""
from test_conversation_compressor import patch_const
db = MemoryDatabase(db_path=str(tmp_path / "m.db"))
for i in range(7):
db.add_memory(topic=f"t{i}", summary=f"s{i}", content="c",
start_time="2026-08-18 00:00:00",
end_time="2026-08-18 00:00:01", importance=1)
patch_const(monkeypatch, "MEMORY_MAX_SEARCH_RESULTS", 5)
res = db.get_recent_memories(limit=None)
assert len(res) == 5
def test_delete_memo_physically_removes(self, tmp_path):
"""delete_memo 真实删除行(此前 complete_memo 只标记完成)"""
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = db.add_memo(title="待删任务", content="内容")
assert db.get_memo(mid) is not None
assert db.delete_memo(mid) is True
assert db.get_memo(mid) is None
assert db.delete_memo(mid) is False # 已不存在
def test_memo_tool_delete_uses_physical_delete(self, tmp_path):
"""MemoTool delete 走 delete_memo:行消失而非仅标记完成"""
from tools import MemoTool
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = db.add_memo(title="重复任务", content="内容", repeat_type="daily")
tool = MemoTool(db, None)
result = tool.execute(action="delete", memo_id=mid, confirm=True)
assert "已删除" in result
assert db.get_memo(mid) is None
stats = db.get_memo_stats()
assert stats["total"] == 0
# ==================== M6:PromptManager 原子 reload ====================
class TestM6PromptReloadAtomic:
def test_reload_builds_new_list_then_swaps(self):
"""reload 在本地新列表构建完成后才原子替换——读者始终看到完整列表"""
pm = PromptManager()
old_list = pm._prompts
seen = []
def fake_load(target):
seen.append(target)
target.append({"file": "X_test.md", "content": "y"})
with mock.patch.object(pm, "_load_prompts", side_effect=fake_load):
n = pm.reload()
assert len(seen) == 1
assert seen[0] is not old_list # 构建用的是新列表
assert pm._prompts is seen[0] # 完成后才替换
assert n == 1
# ==================== L1:read_file 参数强转 ====================
class TestL1ReadFileCoercion:
def test_non_integer_lines_rejected(self, tmp_path):
from tools import ReadFileTool
f = tmp_path / "f.txt"
f.write_text("line1\nline2\n", encoding="utf-8")
tool = ReadFileTool()
r = tool.execute(filepath=str(f), start_line="1'; rm -rf /; echo '",
end_line=5)
assert "参数错误" in r
def test_valid_integers_still_work(self, tmp_path):
from tools import ReadFileTool
f = tmp_path / "f.txt"
f.write_text("line1\nline2\n", encoding="utf-8")
tool = ReadFileTool()
r = tool.execute(filepath=str(f), start_line=1, end_line=2)
assert "sed -n" in r and "参数错误" not in r
# ==================== L2:set_proxy 落盘失败如实报告 ====================
class TestL2ProxySaveFailure:
def test_set_proxy_returns_false_on_save_failure(self, tmp_path):
from common import ConfigManager
cm = ConfigManager()
cm._config_path = str(tmp_path / "config.json")
with mock.patch("builtins.open", side_effect=PermissionError("disk ro")):
ok = cm.set_proxy("127.0.0.1", 1080, save=True)
assert ok is False
def test_proxy_tool_reports_save_failure(self, tmp_path):
from tools import SetProxyTool
tool = SetProxyTool(None)
with mock.patch("builtins.open", side_effect=PermissionError("disk ro")):
r = tool.execute(host="127.0.0.1", port=1080, save=True,
skip_check=True, enabled=True)
assert "保存失败" in r
# ==================== L3:SSE 非 UTF-8 行不中断流 ====================
class TestL3SseDecode:
def test_non_utf8_line_does_not_break_stream(self):
c = DeepSeekClient(api_key="k", model="m", base_url="https://x")
resp = mock.MagicMock()
resp.status_code = 200
resp.iter_lines.return_value = [
b"data: \xff\xfe",
b'data: {"choices":[{"delta":{"content":"ok"}}]}',
b"data: [DONE]",
]
with mock.patch("llm_client.requests.post", return_value=resp):
events = list(c.stream_chat([{"role": "user", "content": "hi"}]))
assert any(e.get("type") == "content" and e.get("text") == "ok"
for e in events)
# ==================== L4:persona 软链越界 ====================
class TestL4PersonaSymlink:
def test_symlink_outside_skills_dir_rejected(self, tmp_path, monkeypatch):
from test_conversation_compressor import patch_const
from aibox import ExpressEmotionTool
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
outside = tmp_path / "outside.md"
outside.write_text("秘密内容", encoding="utf-8")
(skills_dir / "evil.md").symlink_to(outside)
tool = ExpressEmotionTool()
patch_const(monkeypatch, "SKILLS_DIR", str(skills_dir))
content = tool._load_persona("evil", "")
assert content == ""
def test_normal_persona_still_loads(self, tmp_path, monkeypatch):
from test_conversation_compressor import patch_const
from aibox import ExpressEmotionTool
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
(skills_dir / "happy.md").write_text("快乐人格", encoding="utf-8")
tool = ExpressEmotionTool()
patch_const(monkeypatch, "SKILLS_DIR", str(skills_dir))
content = tool._load_persona("happy", "")
assert content == "快乐人格"
# ==================== L5:截断无半个字符 ====================
class TestL5NoPartialChar:
def test_truncated_output_has_no_replacement_char(self):
from subsystems.executors import SubprocessCommandExecutor
ex = SubprocessCommandExecutor()
r = ex.execute("python3 -c \"print('中' * 600000)\"", timeout=60)
assert "截断" in r
assert "\ufffd" not in r
@pytest.mark.skipif(os.name != "posix", reason="Linux 进程组语义")
def test_grandchild_holding_pipe_is_reaped(self):
"""`(sleep N &) ; echo done` 形态:shell 退出后孙进程持管道写端,
宽限窗口到期强杀进程组——不悬挂 reader、孙进程被清理"""
import subprocess as sp
from subsystems.executors import SubprocessCommandExecutor
marker = f"sleep {12340}"
ex = SubprocessCommandExecutor()
r = ex.execute(f"({marker} &) ; echo done", timeout=8)
assert "done" in r
leftover = sp.run(["pgrep", "-f", marker],
capture_output=True, text=True).stdout.strip()
assert leftover == ""
# ==================== L6:compressor 除零 ====================
class TestL6CompressorDivZero:
def test_empty_content_compress_no_div0(self, monkeypatch):
comp = ConversationCompressor(llm_client=FakeLLMClient(),
logger=None)
from test_conversation_compressor import patch_const
patch_const(monkeypatch, "COMPRESS_MESSAGE_THRESHOLD", 10)
messages = [{"role": "system", "content": "SYS"}] + [
{"role": "user" if i % 2 == 0 else "assistant", "content": ""}
for i in range(15)
]
result = comp.compress_conversation(messages)
assert isinstance(result, list)
# ==================== L7:InstallSkillTool 路径引用 ====================
class TestL7SkillPathQuoting:
def test_guide_commands_quote_paths(self):
from tools import InstallSkillTool
tool = InstallSkillTool()
r = tool.execute(skill_path="/tmp/a b; rm -rf /")
assert "ls -l '/tmp/a b; rm -rf /'" in r
assert "cp '/tmp/a b; rm -rf /' " in r
# ==================== L8:ask_user 缺参 ====================
class TestL8AskUser:
def test_missing_question_returns_error(self):
from tools import AskUserTool
tool = AskUserTool()
assert "需要提供问题" in tool.execute()
def test_question_passed_through(self):
from tools import AskUserTool
tool = AskUserTool()
assert "测试问题" in tool.execute(question="测试问题")
# ==================== L10:web 工具 scheme 白名单 ====================
class TestL10WebScheme:
def test_file_url_rejected(self):
from tools import SimpleWebCrawlerTool
tool = SimpleWebCrawlerTool()
r = tool.execute(action="get", url="file:///etc/shadow")
assert "仅支持 http/https" in r
def test_http_url_accepted(self):
from tools import SimpleWebCrawlerTool
tool = SimpleWebCrawlerTool()
r = tool.execute(action="get", url="https://example.com")
assert "curl" in r and "仅支持" not in r
# ==================== L11:registry 打码包含匹配 ====================
class TestL11SanitizeSubstring:
def test_variant_keys_redacted(self):
from tools.registry import _sanitize_kwargs
out = _sanitize_kwargs({
"apiKey": "x", "access_token": "y", "passwd": "z",
"url": "https://ok",
})
assert out == {"apiKey": "***", "access_token": "***",
"passwd": "***", "url": "https://ok"}
def test_normal_keys_untouched(self):
from tools.registry import _sanitize_kwargs
out = _sanitize_kwargs({"filepath": "/tmp/a", "question": "hi"})
assert out == {"filepath": "/tmp/a", "question": "hi"}
# ==================== L12:failover 日志 Bearer 脱敏 ====================
class TestL12LogSanitize:
def test_sanitize_msg_redacts_bearer(self):
msg = "瞬时故障: HTTP 401: Bearer sk-abc123xyz"
out = _sanitize_log_msg(msg)
assert "sk-abc123xyz" not in out
assert "Bearer ***" in out
def test_failover_log_redacts_bearer(self):
logger = mock.MagicMock()
c = FailoverClient([], logger=logger)
c._log("warning", "瞬时故障(Bearer sk-secret456)")
logged = logger.warning.call_args[0][0]
assert "sk-secret456" not in logged
assert "Bearer ***" in logged
+227
View File
@@ -0,0 +1,227 @@
"""aibox 第四轮审计 Batch I 修复回归测试(2026-08-18,审计报告 §十二)
覆盖:
- N9:prompt 写入内容长度上限(持久化注入面约束)
- Q7b:load_conversation 越界路径拒绝(realpath 前缀校验)
- F5:run_light_subagent 取消传播(流式可中断 + 父已取消早退)
- Q5:提醒时间格式异常的任务按到期返回(不再静默丢弃)
- Q8:对话文件原子写(无 .tmp 残留、JSON 完整)
- F6:WebSocketHandler 命令专用线程池存在且可释放
- limit=0 语义修正(None 才不加 LIMIT)
Q4(busy_timeout 10s)为时序性行为(默认 5s 等待已能通过短竞争),
不做慢速单测;F9(--once 硬超时)为进程级行为,由 CLI 集成面覆盖。
"""
import os
import queue
import sqlite3
import sys
import threading
import time
import types
from pathlib import Path
from unittest import mock
import pytest
# ---- 环境桩:websockets 未安装时保证 import aibox.py 不失败 ----
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import aibox # noqa: E402
from aibox import run_light_subagent # noqa: E402
from subsystems.db import MemoryDatabase, MemoDatabase # noqa: E402
from subsystems.prompt_manager import PromptManager # noqa: E402
from subsystems.conversation_history import ConversationHistory # noqa: E402
from tools.prompt_tools import ( # noqa: E402
UpdateIdentityTool,
UpdateSoulTool,
SaveMemoryAndEndConversationTool,
)
# ==================== N9:prompt 写入长度上限 ====================
class TestN9PromptContentCap:
def test_update_identity_oversized_rejected(self, tmp_path, monkeypatch):
"""manager 层兜底:超长身份内容拒绝写入"""
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
assert pm.update_identity("x" * (PromptManager.MAX_CONTENT_LENGTH + 1)) is False
assert not (tmp_path / pm.IDENTITY_FILE).exists()
def test_update_identity_tool_friendly_error(self, tmp_path):
"""工具层:超长内容返回明确提示而非写入"""
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
tool = UpdateIdentityTool(pm, None)
r = tool.execute(content="x" * (PromptManager.MAX_CONTENT_LENGTH + 1))
assert "内容过长" in r
def test_update_soul_tool_friendly_error(self, tmp_path):
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
tool = UpdateSoulTool(pm, None)
r = tool.execute(content="y" * (PromptManager.MAX_CONTENT_LENGTH + 1))
assert "内容过长" in r
def test_save_memory_status_oversized_rejected(self, tmp_path):
"""save_memory_and_end_conversation:状态内容超长拒绝"""
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
tool = SaveMemoryAndEndConversationTool(
exit_callback=lambda *a: None,
memory_db=None, prompt_manager=pm, logger=None)
r = tool.execute(summary="这是一段足够长的对话总结" * 20,
current_status="x" * PromptManager.MAX_CONTENT_LENGTH)
assert "状态内容过长" in r
def test_normal_content_still_written(self, tmp_path):
pm = PromptManager()
with mock.patch.object(pm, "_prompts_dir", str(tmp_path)):
assert pm.update_identity("正常身份内容") is True
assert "正常身份内容" in (tmp_path / pm.IDENTITY_FILE).read_text(encoding="utf-8")
# ==================== Q7b:load_conversation 路径校验 ====================
class TestQ7bLoadConversationBoundary:
def test_traversal_rejected(self, tmp_path):
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
secret = tmp_path / "secret.json"
secret.write_text('{"leak": true}', encoding="utf-8")
assert h.load_conversation("../secret.json") is None
def test_normal_file_still_loads(self, tmp_path):
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
h.current_conversation = {
"id": "1", "start_time": "2026-08-18 00:00:00", "end_time": None,
"topic": "t", "messages": [{"role": "user", "content": "hi"}],
"tool_calls": [], "stats": {},
}
h.save_current_conversation()
h.archive_current_conversation()
archives = list(h.archive_dir.glob("conversation_*.json"))
assert archives
loaded = h.load_conversation(archives[0].name)
assert loaded is not None and loaded["id"] == "1"
# ==================== F5:run_light_subagent 取消传播 ====================
class _BlockingLightClient:
"""流式产出首段后卡住,直到 cancel() 才中断的伪 LLM 客户端"""
model = "fake-model"
default_temperature = None
def __init__(self):
self.cancelled = threading.Event()
def cancel(self):
self.cancelled.set()
def stream_chat(self, messages, **kwargs):
yield {"type": "content", "text": "开头"}
while not self.cancelled.wait(timeout=0.1):
pass
raise RuntimeError("连接已中断(cancel)")
class TestF5LightSubagentCancel:
def test_cancelled_before_start_returns_early(self):
"""父事件已置位 → 直接返回取消提示,不创建客户端"""
parent = threading.Event()
parent.set()
with mock.patch("aibox.create_llm_client") as mk:
r = run_light_subagent("r", "t", None, cancel_event=parent)
assert "取消" in r
mk.assert_not_called()
def test_cancel_during_stream_aborts(self):
"""流式中父级取消 → 观察者线程 cancel 客户端 → 立即中断返回"""
parent = threading.Event()
fake = _BlockingLightClient()
with mock.patch("aibox.create_llm_client", return_value=fake):
result_holder = {}
def _run():
result_holder["r"] = run_light_subagent(
"r", "t", None, cancel_event=parent)
th = threading.Thread(target=_run, daemon=True)
th.start()
time.sleep(0.5) # 等待首段产出、观察者就绪
parent.set()
th.join(timeout=5)
assert not th.is_alive() # 未挂死
assert "失败" in result_holder.get("r", "")
def test_express_emotion_registered_with_parent_event(self):
"""DeepSeekChat 注册 ExpressEmotionTool 时注入自身取消事件"""
from test_once_mode import _make_ai
ai = _make_ai()
tool = next((t for t in ai.tool_registry.get_all_tools()
if t.get_name() == "express_emotion"), None)
assert tool is not None
assert tool.cancel_event is ai._cancel_event
# ==================== Q5:格式异常任务不丢弃 ====================
class TestQ5MalformedReminderTime:
def test_malformed_time_task_returned_as_due(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
mid = db.add_memo(title="格式异常", content="c", reminder_time="不是时间")
pending = db.get_pending_memos(before_time="2026-08-18 00:00:00")
assert any(m["id"] == mid for m in pending)
def test_normal_pending_still_filtered(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
db.add_memo(title="未来任务", content="c", reminder_time="2099-01-01 00:00:00")
pending = db.get_pending_memos(before_time="2026-08-18 00:00:00")
assert pending == []
# ==================== Q8:对话文件原子写 ====================
class TestQ8AtomicWrites:
def test_no_tmp_leftover_and_valid_json(self, tmp_path):
h = ConversationHistory(save_dir=str(tmp_path / "save"), load_history=False)
h.current_conversation = {
"id": "1", "start_time": "2026-08-18 00:00:00", "end_time": None,
"topic": "t", "messages": [], "tool_calls": [], "stats": {},
}
h.save_current_conversation()
assert h.current_file.exists() # 文件名可能被用户配置覆盖,用实例属性
import json
json.loads(h.current_file.read_text(encoding="utf-8")) # 不抛即合法 JSON
h.archive_current_conversation() # 归档后 current 文件按设计移除
assert list(Path(tmp_path).rglob("*.tmp")) == []
# ==================== F6:WS 命令专用线程池 ====================
class TestF6CmdExecutor:
def test_handler_has_dedicated_executor(self):
from aibox import WebSocketHandler
h = WebSocketHandler(ai_manager=None)
assert h._cmd_executor is not None
h.shutdown()
assert h._cmd_executor is None
# ==================== limit=0 语义 ====================
class TestLimitZeroSemantics:
def test_pending_memos_limit_zero_returns_none(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
db.add_memo(title="t", content="c", reminder_time="2026-01-01 00:00:00")
assert db.get_pending_memos(limit=0, before_time="2026-08-18 00:00:00") == []
def test_immediate_memos_limit_zero_returns_none(self, tmp_path):
db = MemoDatabase(db_path=str(tmp_path / "m.db"))
db.add_memo(title="t", content="c", is_immediate=True)
assert db.get_immediate_memos(limit=0) == []
+198
View File
@@ -0,0 +1,198 @@
"""aibox 模型接入点管理工具(model_config.py)单元测试(2026-08-18)
覆盖:
- 列表归一化(默认标记/免密/失败转移展示字段)
- 添加:字段校验(档案名/地址/模型名/重复)、Key 两种方式、温度/超时钳制、
失败转移引用校验、设为默认、落盘(含脱敏)
- 删除:物理移除、fallback 悬空引用清理、默认档案标记清除
- 连通性验证:成功 / 失败 / 档案不存在
"""
import sys
import types
from unittest import mock
import pytest
if "websockets" not in sys.modules:
sys.modules["websockets"] = types.ModuleType("websockets")
import model_config # noqa: E402
from common import ConfigManager # noqa: E402
def _cfg(tmp_path, initial: dict = None) -> ConfigManager:
"""构造指向临时配置文件的 ConfigManager(避免碰真实 ~/.aibox)"""
cm = ConfigManager()
cm._config_path = str(tmp_path / "config.json")
cm._config = initial if initial is not None else {}
return cm
def _base_models():
return {
"models": {
"deepseek": {
"api_url": "https://api.deepseek.com/v1/chat/completions",
"model": "deepseek-chat",
"api_key_env": "DEEPSEEK_API_KEY",
},
"local": {
"api_url": "http://localhost:11434/v1/chat/completions",
"model": "qwen3",
"api_key_env": "",
},
},
"api": {"default_model": "deepseek"},
}
# ==================== 列表 ====================
class TestListProfiles:
def test_list_normalized(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
profiles = model_config.list_profiles(cm)
assert len(profiles) == 2
ds = next(p for p in profiles if p["name"] == "deepseek")
assert ds["is_default"] is True
assert ds["api_key_env"] == "DEEPSEEK_API_KEY"
local = next(p for p in profiles if p["name"] == "local")
assert local["api_key_env"] == ""
assert local["is_default"] is False
def test_list_empty_models_section(self, tmp_path):
cm = _cfg(tmp_path, {})
assert model_config.list_profiles(cm) == []
def test_list_ignores_malformed_entries(self, tmp_path):
cm = _cfg(tmp_path, {"models": {"bad": "not-a-dict"}})
assert model_config.list_profiles(cm) == []
# ==================== 校验 ====================
class TestValidateNewProfile:
def test_name_rules(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
assert model_config.validate_new_profile(cm, "", "https://x", "m")
assert model_config.validate_new_profile(cm, "坏名字!", "https://x", "m")
assert model_config.validate_new_profile(cm, "deepseek", "https://x", "m") # 已存在
assert model_config.validate_new_profile(cm, "ok_name", "https://x", "m") is None
def test_url_scheme_and_model_required(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
assert model_config.validate_new_profile(cm, "kimi", "ftp://x", "m")
assert model_config.validate_new_profile(cm, "kimi", "https://x", "")
# ==================== 添加 ====================
class TestAddProfile:
def test_add_with_env_key_and_default(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
msgs = model_config.add_profile(
cm, "kimi", "https://api.moonshot.cn/v1/chat/completions",
"moonshot-v1-8k", api_key_env="KIMI_KEY",
display_name="Kimi", set_default=True)
assert any("已添加" in m for m in msgs)
assert any("设为默认" in m for m in msgs)
assert cm.get("api.default_model") == "kimi"
assert cm.get("models.kimi.api_key_env") == "KIMI_KEY"
assert cm.get("models.kimi.api_url").startswith("https://")
# 落盘文件存在且可解析
import json
disk = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
assert disk["models"]["kimi"]["model"] == "moonshot-v1-8k"
def test_add_without_key_is_exempt_endpoint(self, tmp_path):
cm = _cfg(tmp_path, {})
msgs = model_config.add_profile(
cm, "local", "http://localhost:11434/v1/chat/completions",
"qwen3", api_key_env="")
assert any("免密" in m for m in msgs)
assert cm.get("models.local.api_key_env") == ""
def test_add_rejects_invalid_fallback(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
msgs = model_config.add_profile(
cm, "kimi", "https://x/v1", "m1",
fallback=["ghost", "deepseek"])
assert any("失败转移" in m for m in msgs)
assert cm.get("models.kimi") is None # 未写入
def test_add_temperature_timeout_clamped(self, tmp_path):
cm = _cfg(tmp_path, {})
model_config.add_profile(cm, "a", "https://x/v1", "m1",
temperature=9.9, timeout=1)
assert cm.get("models.a.temperature") == 2.0
assert cm.get("models.a.timeout") == 5
def test_add_invalid_temperature_rejected(self, tmp_path):
cm = _cfg(tmp_path, {})
msgs = model_config.add_profile(cm, "a", "https://x/v1", "m1",
temperature="abc")
assert any("温度" in m for m in msgs)
assert cm.get("models.a") is None
# ==================== 删除 ====================
class TestDeleteProfile:
def test_delete_cleans_fallback_refs(self, tmp_path):
cm = _cfg(tmp_path, {
"models": {
"main": {"api_url": "https://a", "model": "m1",
"fallback": ["backup"]},
"backup": {"api_url": "https://b", "model": "m2"},
},
})
msgs = model_config.delete_profile(cm, "backup")
assert any("已删除" in m for m in msgs)
assert any("失败转移引用" in m for m in msgs)
assert cm.get("models.main.fallback") == []
def test_delete_default_clears_default_flag(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
model_config.delete_profile(cm, "deepseek")
assert cm.get("api.default_model") is None
def test_delete_nonexistent(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
msgs = model_config.delete_profile(cm, "ghost")
assert any("不存在" in m for m in msgs)
# ==================== 连通性验证 ====================
class TestTestProfile:
def test_connectivity_ok(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
fake = mock.MagicMock()
fake.chat_completion.return_value = "pong"
with mock.patch("llm_client.create_llm_client", return_value=fake):
r = model_config.test_profile(cm, "deepseek")
assert "连通正常" in r and "pong" in r
def test_connectivity_failure(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
fake = mock.MagicMock()
fake.chat_completion.side_effect = RuntimeError("连接被拒绝")
with mock.patch("llm_client.create_llm_client", return_value=fake):
r = model_config.test_profile(cm, "deepseek")
assert "验证失败" in r and "连接被拒绝" in r
def test_connectivity_missing_profile(self, tmp_path):
cm = _cfg(tmp_path, _base_models())
assert "不存在" in model_config.test_profile(cm, "ghost")
# ==================== 落盘失败感知 ====================
class TestSaveFailure:
def test_add_reports_save_failure(self, tmp_path):
cm = _cfg(tmp_path, {})
with mock.patch("builtins.open", side_effect=PermissionError("disk ro")):
msgs = model_config.add_profile(
cm, "a", "https://x/v1", "m1", api_key_env="K")
assert any("保存失败" in m for m in msgs)
File diff suppressed because it is too large. Load diff
+24 -1
View File
@@ -96,6 +96,16 @@ class ReadFileTool(FileToolBase):
# 限制max_lines上限,防止请求超大范围 # 限制max_lines上限,防止请求超大范围
max_lines = min(max_lines, 500) max_lines = min(max_lines, 500)
# L1(审计 2026-08-18):start/end_line 为 LLM 可控参数,此前未强转
# 直拼 sed 单引号模板——LLM 传含引号的字符串可闭合引号注入命令。
# 强转 int(非法输入在此拦截)并夹取合理范围。
try:
start_line = int(start_line)
end_line = int(end_line)
except (TypeError, ValueError):
return "❌ 参数错误: start_line/end_line 必须为整数"
start_line = max(1, start_line)
end_line = max(0, min(end_line, 10_000_000))
quoted = shlex.quote(abs_path) quoted = shlex.quote(abs_path)
# 构造等价的命令行读取命令 # 构造等价的命令行读取命令
@@ -175,8 +185,15 @@ class WriteFileTool(FileToolBase):
dir_cmd = f"mkdir -p {shlex.quote(directory)}" if directory else "" dir_cmd = f"mkdir -p {shlex.quote(directory)}" if directory else ""
# 内容写入使用 heredoc,避免引号/特殊字符转义问题 # 内容写入使用 heredoc,避免引号/特殊字符转义问题
# M1 修复(审计 2026-08-17):定界符唯一化——若 content 中含独立行
# 与定界符相同(如 "AIBOX_EOF"),heredoc 会提前终止,其后内容被
# 当作 shell 命令执行(注入)。冲突时递增后缀直到唯一。
delimiter = "AIBOX_EOF"
content_lines = {line.strip() for line in content.split("\n")}
while delimiter in content_lines:
delimiter += "_X"
redirect = ">" if mode == "w" else ">>" redirect = ">" if mode == "w" else ">>"
write_cmd = f"cat {redirect} {quoted} <<'AIBOX_EOF'\n{content}\nAIBOX_EOF" write_cmd = f"cat {redirect} {quoted} <<'{delimiter}'\n{content}\n{delimiter}"
if dir_cmd: if dir_cmd:
write_cmd = f"{dir_cmd} && {write_cmd}" write_cmd = f"{dir_cmd} && {write_cmd}"
@@ -313,6 +330,12 @@ class SimpleWebCrawlerTool(Tool):
if action not in ("get", "text", "title", "links", "search"): if action not in ("get", "text", "title", "links", "search"):
return f"❌ 未知操作: {action}" return f"❌ 未知操作: {action}"
# L10(审计 2026-08-18):url 为 LLM 可控输入(可被网页内容诱导),
# 此前不限 scheme——file:// 可经生成的 curl 指引读取本地文件(SSRF)。
# 白名单 http/https 后其余协议直接拒绝。
if not url or not str(url).strip().lower().startswith(("http://", "https://")):
return "❌ 仅支持 http/https 协议地址(已拦截 file:// 等非网络协议)"
quoted_url = shlex.quote(url) quoted_url = shlex.quote(url)
timeout = constants.WEB_TIMEOUT timeout = constants.WEB_TIMEOUT
user_agent = shlex.quote(constants.WEB_USER_AGENT) user_agent = shlex.quote(constants.WEB_USER_AGENT)
+12 -153
View File
@@ -5,14 +5,15 @@ tools.command_tool - 命令行执行工具
抽离自 aibox.py,使用抽象的 CommandExecutor 实现命令执行(流式 + 超时控制)。 抽离自 aibox.py,使用抽象的 CommandExecutor 实现命令执行(流式 + 超时控制)。
工具本身不持有 subprocess 逻辑,而是委托给 SubprocessCommandExecutor。 工具本身不持有 subprocess 逻辑,而是委托给 SubprocessCommandExecutor。
H1 修复(审计 2026-08-17):此前本类内置一套重复的 Popen 读取循环,
完全绕过注入的 executor,导致 1MB 输出上限、cancel() 三层中断、
超时机制三项保护同时失效。现改为纯委托,行为与 executor 完全一致。
依赖:common (ConfigManager, constants) + tools.base.Tool + subsystems.executors 依赖:common (ConfigManager, constants) + tools.base.Tool + subsystems.executors
不反向依赖 aibox 任何符号。 不反向依赖 aibox 任何符号。
""" """
import os
import queue import queue
import subprocess
import time
from typing import Dict from typing import Dict
from common import ConfigManager from common import ConfigManager
@@ -60,163 +61,21 @@ class CommandLineTool(Tool):
} }
def execute_streaming(self, output_queue: queue.Queue, **kwargs): def execute_streaming(self, output_queue: queue.Queue, **kwargs):
"""流式执行命令,支持超时控制""" """流式执行命令:纯委托给 executor(1MB 上限/超时/cancel 全部生效)"""
command = kwargs.get("command") command = kwargs.get("command")
timeout = kwargs.get("timeout", 30)
work_dir = kwargs.get("work_dir", ".")
extra_env = kwargs.get("env", {})
if not command: if not command:
output_queue.put(("line", "❌ 错误:缺少命令参数\n")) output_queue.put(("line", "❌ 错误:缺少命令参数\n"))
output_queue.put(("complete", "❌ 错误:缺少命令参数")) output_queue.put(("complete", "❌ 错误:缺少命令参数"))
return return
# 发送开始执行的消息 self.executor.execute_streaming(
output_queue.put(("line", f"\n🔧 执行命令: {command}\n")) command,
output_queue.put(("line", f"⏱️ 超时设置: {timeout} 秒\n")) output_queue,
if work_dir != ".": timeout=kwargs.get("timeout", 30),
output_queue.put(("line", f"📁 工作目录: {work_dir}\n")) work_dir=kwargs.get("work_dir", "."),
output_queue.put(("line", "-" * 50 + "\n")) env=kwargs.get("env"),
)
# 准备环境变量
env = os.environ.copy()
if extra_env:
env.update(extra_env)
try:
# 使用 subprocess.Popen 实现流式输出,支持超时
process = subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True,
env=env,
cwd=os.path.expanduser(work_dir)
)
start_time = time.time()
output_lines = []
timeout_occurred = False
# 实时读取输出
while True:
# 检查超时
elapsed = time.time() - start_time
if timeout > 0 and elapsed > timeout:
process.terminate()
timeout_occurred = True
output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒),已终止\n"))
break
# 使用非阻塞方式读取一行
import select
import sys
# 检查是否有输出可读
if sys.platform == 'win32':
# Windows 下使用 readline 带超时
try:
import msvcrt
if msvcrt.kbhit():
pass
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
line = line.rstrip('\n')
output_queue.put(("line", line + "\n"))
output_lines.append(line)
else:
time.sleep(0.05)
except:
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
line = line.rstrip('\n')
output_queue.put(("line", line + "\n"))
output_lines.append(line)
else:
# Unix 系统可以使用 select
import select
rlist, _, _ = select.select([process.stdout], [], [], 0.5)
if rlist:
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
line = line.rstrip('\n')
output_queue.put(("line", line + "\n"))
output_lines.append(line)
else:
# 检查进程是否结束
if process.poll() is not None:
# 读取剩余输出
remaining = process.stdout.read()
if remaining:
remaining = remaining.rstrip('\n')
if remaining:
output_queue.put(("line", remaining + "\n"))
output_lines.append(remaining)
break
# 如果超时,确保进程被终止
if timeout_occurred and process.poll() is None:
process.kill()
process.wait(timeout=2)
# 获取返回码
return_code = process.returncode if process.returncode is not None else -1
output_queue.put(("line", "-" * 50 + "\n"))
if timeout_occurred:
output_queue.put(("line", f"⏰ 命令执行超时({timeout}秒),返回码: {return_code}\n"))
else:
output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n"))
# 准备完整结果
full_output = "\n".join(output_lines)
data = {
"输出": full_output if full_output else "(无输出)",
"返回码": return_code
}
if timeout_occurred:
result = self.format_result(False, f"命令执行超时({timeout}秒)", data)
else:
result = self.format_result(return_code == 0, "命令执行完成", data)
output_queue.put(("complete", result))
except subprocess.TimeoutExpired:
error_msg = f"❌ 命令执行超时({timeout}秒)"
output_queue.put(("line", error_msg + "\n"))
output_queue.put(("complete", error_msg))
except FileNotFoundError as e:
error_msg = f"❌ 命令执行错误: 命令未找到 - {str(e)}"
output_queue.put(("line", error_msg + "\n"))
output_queue.put(("complete", error_msg))
except PermissionError as e:
error_msg = f"❌ 命令执行错误: 权限不足 - {str(e)}"
output_queue.put(("line", error_msg + "\n"))
output_queue.put(("complete", error_msg))
except Exception as e:
error_msg = f"❌ 执行错误: {str(e)}"
output_queue.put(("line", error_msg + "\n"))
output_queue.put(("complete", error_msg))
finally:
# 确保进程被清理
if 'process' in locals() and process and process.poll() is None:
try:
process.terminate()
process.wait(timeout=2)
except:
process.kill()
def execute(self, command: str, timeout: int = 30, def execute(self, command: str, timeout: int = 30,
work_dir: str = ".", env: Dict = None, **kwargs) -> str: work_dir: str = ".", env: Dict = None, **kwargs) -> str:
+3 -1
View File
@@ -752,7 +752,9 @@ class MemoTool(Tool):
memo_id = kwargs.get("memo_id") memo_id = kwargs.get("memo_id")
if memo_id: if memo_id:
if self.memo_db.complete_memo(memo_id, force_complete=True): # M5:真实物理删除(此前调 complete_memo 只标记完成,行不删除,
# 重复任务被推进而非移除,stats 计数失真)
if self.memo_db.delete_memo(memo_id):
return self.format_result(True, f"任务 #{memo_id} 已删除") return self.format_result(True, f"任务 #{memo_id} 已删除")
else: else:
return f"❌ 无法删除任务 #{memo_id}" return f"❌ 无法删除任务 #{memo_id}"
+5 -2
View File
@@ -39,10 +39,13 @@ class AskUserTool(Tool):
) )
} }
}, },
"required": [] # L8:此前 required 为空,LLM 不传 question 时 str + None 抛 TypeError
"required": ["question"]
} }
def execute(self, question: str, **kwargs) -> str: def execute(self, question: str = None, **kwargs) -> str:
if not question:
return "❌ 需要提供问题内容(question 参数)"
return ( return (
"你遇到了想搞清楚的事情: " + question + "\n你现在可以询问用户。" "你遇到了想搞清楚的事情: " + question + "\n你现在可以询问用户。"
) )
+15
View File
@@ -56,6 +56,11 @@ class UpdateIdentityTool(Tool):
if not content: if not content:
return "❌ 需要提供身份信息内容" return "❌ 需要提供身份信息内容"
# N9:持久化注入面约束——内容超限拒绝写入(管理端另有兜底)
if len(content) > PromptManager.MAX_CONTENT_LENGTH:
return (f"❌ 内容过长({len(content)} > {PromptManager.MAX_CONTENT_LENGTH} 字符),"
f"身份文件作为系统提示词加载,请精简后重试")
if not confirm: if not confirm:
return "❌ 身份更新已取消,如需更新请设置 confirm=true" return "❌ 身份更新已取消,如需更新请设置 confirm=true"
@@ -119,6 +124,11 @@ class UpdateSoulTool(Tool):
if not content: if not content:
return "❌ 需要提供智能体自我认识定义内容" return "❌ 需要提供智能体自我认识定义内容"
# N9:持久化注入面约束——内容超限拒绝写入(管理端另有兜底)
if len(content) > PromptManager.MAX_CONTENT_LENGTH:
return (f"❌ 内容过长({len(content)} > {PromptManager.MAX_CONTENT_LENGTH} 字符),"
f"灵魂文件作为系统提示词加载,请精简后重试")
if not confirm: if not confirm:
return "❌ 自我认识更新已取消,如需更新请设置 confirm=true" return "❌ 自我认识更新已取消,如需更新请设置 confirm=true"
@@ -223,6 +233,11 @@ class SaveMemoryAndEndConversationTool(Tool):
status_content += f"【下一步计划】\n{next_goals}\n\n" status_content += f"【下一步计划】\n{next_goals}\n\n"
status_content += f"【对话总结】\n{summary}" status_content += f"【对话总结】\n{summary}"
# N9:状态文件作为系统提示词加载,超长拒绝写入(约束注入规模)
if len(status_content) > PromptManager.MAX_CONTENT_LENGTH:
return (f"❌ 状态内容过长({len(status_content)} > "
f"{PromptManager.MAX_CONTENT_LENGTH} 字符),请精简 current_status/summary 后重试")
# 更新状态文件 # 更新状态文件
status_updated = self.prompt_manager.update_status(status_content) status_updated = self.prompt_manager.update_status(status_content)
+121 -7
View File
@@ -3,18 +3,26 @@
tools.proxy_tools - 代理配置工具 tools.proxy_tools - 代理配置工具
抽离自 aibox.py,包含 2 个工具: 抽离自 aibox.py,包含 2 个工具:
- SetProxyTool : 设置 SOCKS5 代理 - SetProxyTool : 设置 SOCKS5 代理(设置前验证连通性)
- GetProxyTool : 查看当前代理配置 - GetProxyTool : 查看当前代理配置
依赖:common (ConfigManager) + tools.base.Tool 依赖:common (ConfigManager) + tools.base.Tool
不反向依赖 aibox 任何符号。 不反向依赖 aibox 任何符号。
""" """
from typing import Dict import os
from typing import Dict, Optional, Tuple
import requests
from common import ConfigManager from common import ConfigManager
from .base import Tool from .base import Tool
# 探测端点:无内容的轻量响应,验证完整 SOCKS5 握手(含认证)与出网连通性
_PROBE_URL = "http://www.gstatic.com/generate_204"
# (连接超时, 读超时):验证总耗时上限约 10s,避免阻塞工具调用过久
_PROBE_TIMEOUT = (4, 6)
class SetProxyTool(Tool): class SetProxyTool(Tool):
"""设置 SOCKS5 代理的工具""" """设置 SOCKS5 代理的工具"""
@@ -25,6 +33,8 @@ class SetProxyTool(Tool):
def get_description(self) -> str: def get_description(self) -> str:
return """设置智能体的 SOCKS5 代理配置。 return """设置智能体的 SOCKS5 代理配置。
调用后所有 HTTP/HTTPS 请求(包括网页访问、API 调用)都会通过该代理。 调用后所有 HTTP/HTTPS 请求(包括网页访问、API 调用)都会通过该代理。
启用代理时会先验证连通性(完整 SOCKS5 握手 + 探测请求),验证失败不保存配置;
代理暂未启动等场景可用 skip_check=true 跳过验证强制保存。
可随时修改,即时生效。""" 可随时修改,即时生效。"""
def get_parameters(self) -> Dict: def get_parameters(self) -> Dict:
@@ -34,24 +44,128 @@ class SetProxyTool(Tool):
"host": {"type": "string", "description": "代理服务器地址,如 127.0.0.1"}, "host": {"type": "string", "description": "代理服务器地址,如 127.0.0.1"},
"port": {"type": "integer", "description": "代理端口,如 1080"}, "port": {"type": "integer", "description": "代理端口,如 1080"},
"username": {"type": "string", "description": "用户名(可选)"}, "username": {"type": "string", "description": "用户名(可选)"},
"password": {"type": "string", "description": "密码(可选)"}, "password": {"type": "string", "description": "密码(可选,明文仅本次会话有效,不落盘)"},
"password_env": {"type": "string", "description": "存有密码的环境变量名(可选,跨重启持久的推荐方式)"},
"enabled": {"type": "boolean", "description": "是否启用代理,默认 true"}, "enabled": {"type": "boolean", "description": "是否启用代理,默认 true"},
"save": {"type": "boolean", "description": "是否保存到配置文件(持久化),默认 true"} "save": {"type": "boolean", "description": "是否保存到配置文件(持久化),默认 true"},
"skip_check": {"type": "boolean", "description": "跳过连通性验证强制保存(默认 false)", "default": False}
}, },
"required": ["host", "port"] "required": ["host", "port"]
} }
@staticmethod
def _resolve_password(password: Optional[str], password_env: Optional[str]) -> Optional[str]:
"""对齐 ConfigManager._resolve_proxy_password:明文优先,否则查环境变量"""
if password:
return password
if password_env:
return os.environ.get(password_env) or None
return None
def _check_connectivity(self, host: str, port: int,
username: Optional[str], password: Optional[str]) -> Tuple[bool, str, str]:
"""验证代理连通性:走一次完整 SOCKS5 握手 + 探测请求。
只做 TCP connect 验不了用户名/密码,必须发真实请求。
返回 (ok, error_kind, detail);error_kind 取值:
auth_failed / timeout / connect_failed / probe_failed / error
"""
if username and password:
proxy_url = f"socks5://{username}:{password}@{host}:{port}"
else:
proxy_url = f"socks5://{host}:{port}"
try:
# trust_env=False:排除系统环境变量代理(http_proxy 等)干扰,只验证目标代理
session = requests.Session()
session.trust_env = False
resp = session.get(
_PROBE_URL,
proxies={"http": proxy_url, "https": proxy_url},
timeout=_PROBE_TIMEOUT,
)
# generate_204 正常返回 204;部分代理/目标返回 200 也视为连通
if resp.status_code in (200, 204):
return True, "", f"探测成功 HTTP {resp.status_code}"
return False, "probe_failed", f"探测端点返回 HTTP {resp.status_code}"
except requests.exceptions.Timeout as e:
# 含 ConnectTimeout(连接阶段超时):异常类型与系统语言无关,优先于消息匹配
return False, "timeout", str(e)
except requests.exceptions.ConnectionError as e:
# 连接层失败(拒绝/不可达/中断)。PySocks 依赖缺失与认证失败
# 也会以 ConnectionError 形态冒出,按消息细分。
msg = str(e)
low = msg.lower()
if "missing dependencies for socks" in low:
return False, "missing_dependency", msg
if "authentication" in low or "auth" in low:
return False, "auth_failed", msg
return False, "connect_failed", msg
except Exception as e:
# 兜底:仅按库自身(非本地化)消息关键词分类
msg = str(e)
low = msg.lower()
if "missing dependencies for socks" in low:
return False, "missing_dependency", msg
if "authentication" in low or "auth" in low:
return False, "auth_failed", msg
if "timed out" in low or "timeout" in low:
return False, "timeout", msg
if "refused" in low or "unreachable" in low or "closed" in low:
return False, "connect_failed", msg
return False, "error", msg
def execute(self, host: str, port: int, username: str = None, password: str = None, def execute(self, host: str, port: int, username: str = None, password: str = None,
enabled: bool = True, save: bool = True, **kwargs) -> str: password_env: str = None, enabled: bool = True, save: bool = True,
skip_check: bool = False, **kwargs) -> str:
# 设置前连通性验证:坏地址/凭据在入口一次性暴露,避免静默落盘后在
# 后续 web/API 调用中离场景报错、LLM 难以归因。skip_check 留给
# "代理暂未启动但先写配置"的场景;禁用代理无需验证。
check_detail = ""
if enabled and not skip_check:
pw = self._resolve_password(password, password_env)
# 指定了 password_env 但环境变量为空:认证凭据缺失,提前给出精准提示
if username and not password and password_env and pw is None:
return self.format_result(False, "代理验证失败,未保存配置", {
"错误类型": "环境变量为空",
"详情": f"环境变量 {password_env} 未设置或为空",
"提示": "请先设置该环境变量,或改用 password 参数(仅本次会话有效)",
})
ok, kind, detail = self._check_connectivity(host, port, username, pw)
if not ok:
hints = {
"auth_failed": "用户名/密码错误,请核对凭据",
"timeout": "代理不可达(超时),请检查地址与网络",
"connect_failed": "连接被拒绝或不可达,请检查 host/port",
"missing_dependency": "缺少 SOCKS 支持库,请安装:pip install pysocks",
}
return self.format_result(False, "代理不可达,未保存配置", {
"错误类型": kind,
"详情": detail[:300],
"提示": hints.get(kind, "如确认配置无误可设置 skip_check=true 跳过验证"),
})
check_detail = detail
config = ConfigManager() config = ConfigManager()
config.set_proxy(host, port, username, password, enabled, save) saved = config.set_proxy(host, port, username, password, password_env, enabled, save)
proxy_info = config.get_proxy_info() proxy_info = config.get_proxy_info()
if proxy_info.get("password_env"):
pw_source = "环境变量 " + proxy_info["password_env"]
elif proxy_info.get("password"):
pw_source = "仅本次会话(不落盘)"
else:
pw_source = "无"
data = { data = {
"代理状态": "启用" if proxy_info["enabled"] else "禁用", "代理状态": "启用" if proxy_info["enabled"] else "禁用",
"服务器": f"{proxy_info['host']}:{proxy_info['port']}", "服务器": f"{proxy_info['host']}:{proxy_info['port']}",
"认证": "有" if proxy_info.get("username") else "无", "认证": "有" if proxy_info.get("username") else "无",
"持久化": "已保存" if save else "仅内存" "密码来源": pw_source,
"持久化": "已保存" if saved else "保存失败(仅本次会话有效,重启后丢失)"
} }
if check_detail:
data["连通性"] = check_detail
# L2:落盘失败(磁盘只读等)如实报告,不再静默报"已保存"
if save and not saved:
return self.format_result(False, "代理已配置但保存失败(仅本次会话有效,重启后丢失)", data)
return self.format_result(True, "代理设置成功", data) return self.format_result(True, "代理设置成功", data)
+72 -11
View File
@@ -11,10 +11,26 @@ import threading
import time import time
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from common import Logger, i18n from common import Logger, constants, i18n
from .base import Tool from .base import Tool
# 工具参数名命中这些关键词时,debug 日志里打码(防密码/密钥进日志文件)
# L11(审计 2026-08-18):精确匹配漏掉 apiKey/access_token/passwd 等变体,
# 改包含匹配——日志场景下过度打码可接受,漏打码才是问题。
_SENSITIVE_PARAM_KEYS = (
"password", "passwd", "api_key", "apikey", "token", "secret",
"authorization", "credential",
)
def _sanitize_kwargs(kwargs: Dict) -> Dict:
"""复制一份参数并把敏感键的值替换为 ***(仅用于日志输出)"""
return {
k: ("***" if any(s in str(k).lower() for s in _SENSITIVE_PARAM_KEYS) else v)
for k, v in kwargs.items()
}
class ToolRegistry: class ToolRegistry:
"""工具注册器 - 支持技能热重载""" """工具注册器 - 支持技能热重载"""
@@ -25,6 +41,8 @@ class ToolRegistry:
self.logger = logger or Logger("ToolRegistry") self.logger = logger or Logger("ToolRegistry")
self.current_output_queue = None # 用于流式输出的队列 self.current_output_queue = None # 用于流式输出的队列
self.skill_manager: Optional['SkillManager'] = None # 技能管理器引用 self.skill_manager: Optional['SkillManager'] = None # 技能管理器引用
# 单工具流式执行总上限(工具自身秒级超时之外的兜底;测试可调小)
self.stream_tool_timeout = 3600
def register(self, tool: Tool, is_builtin: bool = False): def register(self, tool: Tool, is_builtin: bool = False):
"""注册工具,可以标记是否为内置工具""" """注册工具,可以标记是否为内置工具"""
@@ -63,7 +81,7 @@ class ToolRegistry:
if not tool: if not tool:
return f"❌ 错误:找不到工具 '{name}'" return f"❌ 错误:找不到工具 '{name}'"
self.logger.debug(f"工具调用: {name}({kwargs})") self.logger.debug(f"工具调用: {name}({_sanitize_kwargs(kwargs)})")
start_time = time.time() start_time = time.time()
try: try:
@@ -84,16 +102,52 @@ class ToolRegistry:
) )
thread.daemon = True thread.daemon = True
thread.start() thread.start()
# 从队列收集输出并发送到当前队列 # M2 修复(审计 2026-08-17):转发循环加总超时。
# 此前 while thread.is_alive() 在工具内部卡死(如子进程
# 等待 stdin)时永不退出;超时后放弃等待,避免本调用
# 线程被连带挂起。
# N6 修复(审计 2026-08-18):超时路径此前直接 return,
# 跳过下方清理——daemon 线程继续持有 tool.output_queue
# 实例属性,属性被后续调用覆盖到新会话队列后,僵尸输出
# 会混入后续对话流。现在统一在出口清理。
tool_timeout = self.stream_tool_timeout
deadline = time.time() + tool_timeout
final_result = None final_result = None
timed_out = False
# N8:流式转发同样受 TOOL_MAX_RESULT_LENGTH 约束——此前仅非流式
# 返回值受限,超长内容(如记忆全文)经流式通道可完整推到前端
line_budget = constants.TOOL_MAX_RESULT_LENGTH
forwarded = 0
truncation_noted = False
while thread.is_alive() or not output_queue.empty(): while thread.is_alive() or not output_queue.empty():
if time.time() > deadline:
self.logger.warning(
f"工具 {name} 流式执行超过 {tool_timeout}s 总上限,放弃等待")
timed_out = True
break
try: try:
msg_type, content = output_queue.get(timeout=0.1) msg_type, content = output_queue.get(timeout=0.1)
if msg_type == "line": if msg_type == "line":
piece = str(content) if content is not None else ""
if forwarded >= line_budget:
# 预算耗尽:停止转发,仅提示一次
if not truncation_noted:
truncation_noted = True
if self.current_output_queue:
self.current_output_queue.put(
("line", f"\n⚠️ 流式输出超过 {line_budget} 字符上限,已停止转发剩余内容\n"))
continue
remaining = line_budget - forwarded
if len(piece) > remaining:
piece = piece[:remaining] + \
f"\n⚠️ 流式输出超过 {line_budget} 字符上限,已截断"
forwarded = line_budget
else:
forwarded += len(piece)
# 发送到当前队列(会通过WebSocket发送) # 发送到当前队列(会通过WebSocket发送)
if self.current_output_queue: if self.current_output_queue:
self.current_output_queue.put(("line", content)) self.current_output_queue.put(("line", piece))
elif msg_type == "complete": elif msg_type == "complete":
final_result = content final_result = content
# 也发送完整结果 # 也发送完整结果
@@ -101,14 +155,21 @@ class ToolRegistry:
self.current_output_queue.put(("complete", content)) self.current_output_queue.put(("complete", content))
except queue.Empty: except queue.Empty:
continue continue
thread.join(timeout=5) if not timed_out:
if thread.is_alive(): thread.join(timeout=5)
return "❌ 工具执行超时" if thread.is_alive():
timed_out = True
result = final_result or ""
# 统一清理(正常与超时路径都必须执行):断开实例属性,
# 僵尸 daemon 线程的 send_line 落到 None 上不再外写
tool.streaming_output = False tool.streaming_output = False
tool.output_queue = None tool.output_queue = None
if timed_out:
result = f"❌ 工具执行超时(>{tool_timeout}s,已放弃等待)"
else:
result = final_result or ""
else: else:
# 普通执行 # 普通执行
result = tool.execute(**kwargs) result = tool.execute(**kwargs)
+8 -3
View File
@@ -16,6 +16,7 @@ tools.skill_tools - 技能相关工具
import os import os
import queue import queue
import re import re
import shlex
from typing import Dict, Optional from typing import Dict, Optional
from common import constants, i18n, Logger from common import constants, i18n, Logger
@@ -253,10 +254,14 @@ class InstallSkillTool(Tool):
skills_dir = constants.SKILLS_DIR skills_dir = constants.SKILLS_DIR
# 如果给了具体路径,在说明里带入,否则用占位符 # 如果给了具体路径,在说明里带入,否则用占位符
src = skill_path.strip() if skill_path else "/path/to/your_skill.md" raw_src = skill_path.strip() if skill_path else ""
# 去掉扩展名作为技能名称,再统一补 .md 作为目标文件名 # 去掉扩展名作为技能名称,再统一补 .md 作为目标文件名
base = os.path.splitext(os.path.basename(src))[0] if src else "your_skill" base = os.path.splitext(os.path.basename(raw_src))[0] if raw_src else "your_skill"
dst = os.path.join(skills_dir, base + ".md") dst = os.path.join(skills_dir, base + ".md")
# L7(审计 2026-08-18):src 为 LLM 可控输入,含空格/元字符时
# 未经引用的命令会被 shell 拆词/注入(指引由子代理经 run_command 执行)
src = shlex.quote(raw_src) if raw_src else "/path/to/your_skill.md"
dst_q = shlex.quote(dst)
guide = f"""📦 扩展技能安装指引 guide = f"""📦 扩展技能安装指引
{"=" * 55} {"=" * 55}
@@ -282,7 +287,7 @@ class InstallSkillTool(Tool):
调用 create_subagent 派生子代理执行复制命令(目标文件名统一补 .md 扩展名): 调用 create_subagent 派生子代理执行复制命令(目标文件名统一补 .md 扩展名):
cp {src} {dst} cp {src} {dst_q}
⚠️ 注意:目标文件名必须带 .md 扩展名, ⚠️ 注意:目标文件名必须带 .md 扩展名,
系统以 .md 文件作为技能加载,且技能名称取文件名(不含 .md)。 系统以 .md 文件作为技能加载,且技能名称取文件名(不含 .md)。
+4 -3
View File
@@ -2,7 +2,7 @@
> 权威架构参考。本文以 `core/services.yaml`(实际运行清单)与 `git HEAD` 为事实来源。 > 权威架构参考。本文以 `core/services.yaml`(实际运行清单)与 `git HEAD` 为事实来源。
> 与根目录 `README.md` 的偏差见文末「§10 与 README 的偏差」。 > 与根目录 `README.md` 的偏差见文末「§10 与 README 的偏差」。
> 最后核对于:2026-08-15。 > 最后核对于:2026-08-17。
--- ---
@@ -85,7 +85,7 @@ Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe
└─────────────────────────────────────┘ └─────────────────────────────────────┘
独立 / 外设(不在主链路): 独立 / 外设(不在主链路):
aibox :8765 (Python WebSocket, 独立产品) aibox :8765 (Python WebSocket, 独立产品; --once CLI 单次对话 + models 多模型档案含 fallback 失败转移)
BotOps (aibox 的 TUI 前端) BotOps (aibox 的 TUI 前端)
bot4cj (Cangjie + 树莓派 I2C, 硬件) bot4cj (Cangjie + 树莓派 I2C, 硬件)
pybot (Python, bot4cj 的迁移版: .mt 解析/录制/实时闭环) pybot (Python, bot4cj 的迁移版: .mt 解析/录制/实时闭环)
@@ -148,7 +148,7 @@ Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe
| **aibox** | Python | 8765 | 127.0.0.1 | 独立 AI 智能体(WebSocket) | 独立产品 | | **aibox** | Python | 8765 | 127.0.0.1 | 独立 AI 智能体(WebSocket) | 独立产品 |
| **BotOps** | Python | — | — | aibox 的 TUI 前端 | 独立 | | **BotOps** | Python | — | — | aibox 的 TUI 前端 | 独立 |
| **bot4cj** | Cangjie | — | 树莓派 I2C | 机器人脚本解析器 | 独立硬件 | | **bot4cj** | Cangjie | — | 树莓派 I2C | 机器人脚本解析器 | 独立硬件 |
| **pybot** | Python | — | 树莓派 | bot4cj 的 Python 迁移版(.mt 解析、actpred episode 录制、--loop 实时预测闭环) | 独立硬件 | | **pybot** | Python | — | 树莓派 | bot4cj 的 Python 迁移版(.mt 解析、actpred episode 录制、--loop 实时预测闭环;HEF 后端自动异步,推理与执行上一步动作并行) | 独立硬件 |
| **mtc** | Python | — | — | 命令式动作脚本(Python DSL)→ .mt 编译器(非阻塞时间轴 IR + 10ms 栅格化),pybot/bot4cj 的脚本 authoring 工具 | 开发期工具 | | **mtc** | Python | — | — | 命令式动作脚本(Python DSL)→ .mt 编译器(非阻塞时间轴 IR + 10ms 栅格化),pybot/bot4cj 的脚本 authoring 工具 | 开发期工具 |
| **lci** | C++ | — | /dev/fb0 | 嵌入式帧缓冲 GUI | 独立硬件 | | **lci** | C++ | — | /dev/fb0 | 嵌入式帧缓冲 GUI | 独立硬件 |
| **actpred** | Python | — | — | 动作预测(图像+动作历史→下一动作),bot4cj/pybot 的「大脑」配套 | 实验 | | **actpred** | Python | — | — | 动作预测(图像+动作历史→下一动作),bot4cj/pybot 的「大脑」配套 | 实验 |
@@ -236,6 +236,7 @@ Blackbird 是一套 **进程托管式轻量微服务** 系统(自研 UniServe
- **smtp3 待上线**:C++ 重写已完成并通过安全审计,但 services.yaml 仍指向 Python `smtp`,建议择机平替以消除最后一块 Python 有状态服务。 - **smtp3 待上线**:C++ 重写已完成并通过安全审计,但 services.yaml 仍指向 Python `smtp`,建议择机平替以消除最后一块 Python 有状态服务。
- **可观测性缺口**:缺 tracing/metrics/告警,建议后续引入轻量方案(或复用 db2 Logs + 定时聚合)。 - **可观测性缺口**:缺 tracing/metrics/告警,建议后续引入轻量方案(或复用 db2 Logs + 定时聚合)。
- **水平扩展限制**:smtp/captcha 内存态为单点,若需多实例需引入 Redis 等共享存储。 - **水平扩展限制**:smtp/captcha 内存态为单点,若需多实例需引入 Redis 等共享存储。
- **panel 面板微服务(规划中)**:设计稿见 [docs/panel_service.md](panel_service.md)——背景视图 + 各服务自持控件片段(iframe 沙箱 + BFF 转发),解决整页应用互相改前端代码的耦合问题;落地后需三处同步(本文件 / MODULES.md / 根 README)。
--- ---
+4 -4
View File
@@ -44,7 +44,7 @@
| 模块 | 语言 | 端口 | 职责 | | 模块 | 语言 | 端口 | 职责 |
|------|------|------|------| |------|------|------|------|
| aibox | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度/前端与语音客户端 | | aibox | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度/前端与语音客户端;`--once` CLI 单次对话(stdout/stderr 分流),`models` 配置节支持多 OpenAI 兼容端点按档案名切换 |
| BotOps | Python/Textual | — | aibox 的终端 TUI 前端,WebSocket 连接 aibox,Markdown 增量流式渲染、思考模式、任务侧栏、自动重连 | | BotOps | Python/Textual | — | aibox 的终端 TUI 前端,WebSocket 连接 aibox,Markdown 增量流式渲染、思考模式、任务侧栏、自动重连 |
| actpred | Python/PyTorch | — | 动作预测(图像 + 动作历史 → 预测下一个动作),bot4cj 的「大脑」配套;支持从 `.mt` 脚本预训练 | | actpred | Python/PyTorch | — | 动作预测(图像 + 动作历史 → 预测下一个动作),bot4cj 的「大脑」配套;支持从 `.mt` 脚本预训练 |
@@ -53,7 +53,7 @@
| 模块 | 语言 | 端口 | 职责 | | 模块 | 语言 | 端口 | 职责 |
|------|------|------|------| |------|------|------|------|
| bot4cj | 仓颉/Cangjie | — (树莓派 I2C) | 机器人硬件脚本解析器,解析 `.mt` 驱动 PCA9685 舵机 / MPU6050 陀螺仪 / 扬声器,面向 Dashun 蜘蛛机器人 | | bot4cj | 仓颉/Cangjie | — (树莓派 I2C) | 机器人硬件脚本解析器,解析 `.mt` 驱动 PCA9685 舵机 / MPU6050 陀螺仪 / 扬声器,面向 Dashun 蜘蛛机器人 |
| pybot | Python | — (树莓派) | bot4cj 的 Python 迁移版:`.mt` 动作脚本解析器(零第三方依赖,与仓颉版逐语义对齐);支持执行 / `--dry-run` / `--record` 录制 actpred episode(含 imu.npy)/ `--loop` 实时预测闭环 | | pybot | Python | — (树莓派) | bot4cj 的 Python 迁移版:`.mt` 动作脚本解析器(零第三方依赖,与仓颉版逐语义对齐);支持执行 / `--dry-run` / `--record` 录制 actpred episode(含 imu.npy)/ `--loop` 实时预测闭环(HEF 后端自动异步:推理与执行上一步动作并行) |
| mtc | Python | — | 命令式动作脚本(Python DSL)→ `.mt` 编译器:`exec` 注入命名空间 → 时间轴 IR(非阻塞语义、重叠截断)→ 10ms 栅格化;产物可由 pybot/bot4cj 直接执行,零第三方依赖 | | mtc | Python | — | 命令式动作脚本(Python DSL)→ `.mt` 编译器:`exec` 注入命名空间 → 时间轴 IR(非阻塞语义、重叠截断)→ 10ms 栅格化;产物可由 pybot/bot4cj 直接执行,零第三方依赖 |
| lci | C++ | — (/dev/fb0) | 嵌入式帧缓冲图形界面(无需 X11/Wayland),矢量 GB2312 中文字库、图片渲染、实时数据可视化 | | lci | C++ | — (/dev/fb0) | 嵌入式帧缓冲图形界面(无需 X11/Wayland),矢量 GB2312 中文字库、图片渲染、实时数据可视化 |
| 3d | — (资产) | — | 机器人 / 机械臂 CAD 模型(STEP/STL/SLDPRT),bot4cj 配套硬件资产 | | 3d | — (资产) | — | 机器人 / 机械臂 CAD 模型(STEP/STL/SLDPRT),bot4cj 配套硬件资产 |
@@ -77,7 +77,7 @@
| 模块 | 说明 | | 模块 | 说明 |
|------|------| |------|------|
| tests | 黑盒测试套件(`test_db.py`/`test_gateway.py`/`test_waf.py`/`test_dnfs.py`/`test_captcha.py`/`test_smtp.py`/`test_ai_review.py`/`test_static.py`/`test_core_confs.py`),纯标准库 HTTP/TCP 用户旅程模拟,经 `test_runner.py` 统一调度(详见 [tests/blackbox/README.md](../tests/blackbox/README.md)) | | tests | 黑盒测试套件(`test_db.py`/`test_gateway.py`/`test_waf.py`/`test_dnfs.py`/`test_captcha.py`/`test_smtp.py`/`test_ai_review.py`/`test_static.py`/`test_core_confs.py`),纯标准库 HTTP/TCP 用户旅程模拟,经 `test_runner.py` 统一调度(详见 [tests/blackbox/README.md](../tests/blackbox/README.md)) |
| docs | 文档聚合(security/ 审计、reviews/ 审查、reports/ 分析),70 篇 md | | docs | 文档聚合(security/ 审计、reviews/ 审查、reports/ 分析),76 篇 md |
## 九、根级构建与部署脚本 ## 九、根级构建与部署脚本
@@ -92,5 +92,5 @@
- **日志链路**:各服务 `log_client.py`(异步批量)→ `db2:8005` Logs 表(fail-safe,db2 不可用时静默丢弃) - **日志链路**:各服务 `log_client.py`(异步批量)→ `db2:8005` Logs 表(fail-safe,db2 不可用时静默丢弃)
- **配置链路**:`gateway` → `core` 内嵌 confs:8010(内存缓存 + mtime 重载,仅 gateway.ini);其余服务配置纯 CLI 传递 - **配置链路**:`gateway` → `core` 内嵌 confs:8010(内存缓存 + mtime 重载,仅 gateway.ini);其余服务配置纯 CLI 传递
- **数据/审核/邮件/缓存**:`dnfs2` → `db2:8005`(pymsg 持久化);`dnfs2` → `ai_review:8007`(审核);注册邮件 `ca` → `smtp:8008`;登录验证码 `ca` → `captcha2:8009`;`memdb:8011`(内存缓存,libmsg RPC,暂无业务调用方,预留热点数据/会话缓存) - **数据/审核/邮件/缓存**:`dnfs2` → `db2:8005`(pymsg 持久化);`dnfs2` → `ai_review:8007`(审核);注册邮件 `ca` → `smtp:8008`;登录验证码 `ca` → `captcha2:8009`;`memdb:8011`(内存缓存,libmsg RPC,暂无业务调用方,预留热点数据/会话缓存)
- **机器人数据链**:`mtc` 编译 Python DSL → `.mt`(或手写 `.mt`)→ `pybot` 执行 / `--record` 录制 episode(images + actions.npy + imu.npy)→ `actpred` 两阶段训练 → ONNX/Hailo HEF 部署 → `pybot --loop` 实时预测闭环(bot4cj 语义对齐) - **机器人数据链**:`mtc` 编译 Python DSL → `.mt`(或手写 `.mt`)→ `pybot` 执行 / `--record` 录制 episode(images + actions.npy + imu.npy)→ `actpred` 两阶段训练 → ONNX/Hailo HEF 部署(`predict_async` 异步流水线)→ `pybot --loop` 实时预测闭环(bot4cj 语义对齐,HEF 后端推理与执行并行)
- **端口一览**:80 redirect80 · 443 waf · 8443 ca · 8445 gateway · 5200/8010 core · 9200 fsys · 8006 dnfs2 · 8005 db2 · 8007 ai_review · 8009 captcha2 · 8008 smtp · 8011 memdb · 8765 aibox - **端口一览**:80 redirect80 · 443 waf · 8443 ca · 8445 gateway · 5200/8010 core · 9200 fsys · 8006 dnfs2 · 8005 db2 · 8007 ai_review · 8009 captcha2 · 8008 smtp · 8011 memdb · 8765 aibox
+437
View File
@@ -0,0 +1,437 @@
# aibox 模块安全与质量审计报告
- **日期**:2026-08-17(第一轮)/ 2026-08-18(第二轮复审)/ 2026-08-18(第三轮:遗留复核 + v3.4 专审)/ 2026-08-18(第四轮:3 路并行审读 + Batch H 修复)
- **审计基线**:第一轮 HEAD `ce142fe`(Batch A–D 后)→ 第二轮 HEAD `177a9ac`(Batch E 修复后)→ 第三轮 HEAD `aa15841`(L4a/b/c 落地后)→ 第四轮 HEAD `d1f780e`(Batch G + SOCKS5 验证后)
- **审计范围**:`aibox.py`(~3310 行)+ `llm_client.py` + `tools/`(7 文件)+ `subsystems/`(6 文件)+ `common/`(5 文件)
- **方法**:第一轮逐文件代码审读 + PoC 动态验证;第二轮 Batch E 修复复审 + 全量重审 + 6 处关键发现实证抽查;第三轮遗留项复核 + v3.4 专审;第四轮 3 路并行审读 + 主审逐条复核 + Linux PoC 实证(6/6)
---
## 一、总体结论
**第一轮**(基线 ce142fe):架构设计(分层、参数化 SQL、密钥隔离、消息回滚)整体健康,但**命令执行链存在一条系统性断裂**:实际运行路径绕过了精心设计的执行器,导致 1MB 硬约束、超时、中断三项保护同时失效。
**第二轮**(基线 177a9ac):Batch E 五项修复(H1/H2/H3/M1/M2)中四项确认修复到位(PoC 5/5 验证),**M2 修复不完整**(超时路径跳过工具属性清理,僵尸输出问题仍存)。第二轮全量重审新发现 **1 高危 + 9 中危 + 若干低危**,其中三项与 Batch E 修复直接相关(reader 线程泄漏 / 子代理超时盲区 / M2 残留),其余为存量问题(代理密码明文落盘、drain 线程泄漏、事件循环阻塞等)。详见"八、第二轮复审"。
---
## 二、高危发现(3 项)
### H1:`run_command` 实际路径绕过注入的 executor——三重保护同时失效
证据链:
- `aibox.py:1012` 将 `command_executor` 注入 `CommandLineTool`
- 但 `tools/command_tool.py:86-219` 的 `execute_streaming` 自带一套 **150 行的重复 Popen 实现**,`self.executor` 全程未使用(死注入)
- `tools/registry.py:71-86` 检测到 `execute_streaming` 即走该路径——**生产 100% 命中**
后果:
| 保护 | 设计位置 | 实际状态 |
|---|---|---|
| 1MB 输出上限(硬约束) | `executors.py:69` | **失效**——command_tool 的 `output_lines` 无限累积 |
| `cancel()` 中断 | `executors.py:76-86` 只 kill `_current_process` | **失效**——实际进程引用只在 command_tool 局部变量,三层中断的第三层对 run_command 不生效 |
| 超时/截断统一语义 | executors | 旁路(见 H2) |
附带问题:`msvcrt.kbhit()` 检查的是**当前进程**键盘而非子管道(`command_tool.py:123-125` 无意义代码);循环内重复 `import select`。
### H2:超时机制对无输出命令完全失效(PoC 已证)
`readline()` 是阻塞调用,超时检查只在**两次 readline 之间**执行。无输出命令会让 readline 阻塞到进程自然退出:
```
PoC:timeout=3s,命令 powershell Start-Sleep 15(无 stdout 输出)
结果:readline blocked 15.2s (timeout was 3s)
VERDICT: timeout mechanism ineffective for silent commands: True
```
两处同病:`command_tool.py:106-165` 与 `executors.py:97-113`。`--once` 模式有 `--once-timeout` 兜底,**WebSocket 模式无兜底**。
### H3:`stdin=subprocess.PIPE` 从不写入也不关闭——死锁放大器
`executors.py:156`、`command_tool.py:93`:等待 stdin 的命令(`cat` 无参、交互式 `python`、`ssh`)永不退出 → 与 H2 叠加:**readline 永久阻塞 + 超时检测永不执行 + cancel 杀不到进程** = 工具线程永久挂起。应为 `stdin=subprocess.DEVNULL`。
---
## 三、中危发现(4 项)
| # | 问题 | 位置 | 说明 |
|---|---|---|---|
| M1 | **heredoc 定界符注入** | `basic_tools.py:179` | `content` 含独立行 `AIBOX_EOF` 时 heredoc 提前终止,**后续行成为 shell 命令执行**。AI 写文件内容是不可信输入(可被网页内容/用户消息诱导)。修复:定界符唯一化(检测冲突则换 `AIBOX_EOF_2` 递增) |
| M2 | 工具流式转发循环可永久挂起 | `registry.py:90-107` | `while thread.is_alive() or not empty()` + `get(0.1)`——H3 场景下永不退出;`join(5)` 超时后返回但 daemon 线程继续向输出队列写(僵尸输出混入后续对话流) |
| M3 | web 工具无协议/目标校验 | `basic_tools.py:315-338` | `url` 未限制 scheme,`curl file:///etc/shadow`、内网地址均可达(SSRF)。本地单用户产品风险有限,建议白名单 http/https |
| M4 | shlex.quote POSIX 转义在 Windows 失效 | `basic_tools.py:99-112` 等全部指引命令 | 单引号在 cmd.exe 无效,Windows 下 `head/sed/grep` 指引命令执行异常。Linux 部署(树莓派)不受影响 |
---
## 四、低危发现(3 项)
| # | 问题 | 位置 |
|---|---|---|
| L1 | `get_pending_memos` 的 `LIMIT {limit}` f-string 拼接 | `db.py:417`——当前调用链 limit 均为内部 int,无外部直达;防御性参数化即可 |
| L2 | `check_path_safety` 仅查大小,无路径边界 | `base.py:96-110`——by design(AI 可全盘读写,本地单用户),已确认设计意图 |
| L3 | `executors.execute()` finally kill 后未 `wait()` | `executors.py:205-207`——Windows 句柄短暂滞留 |
**做得好的**:SQL 全面参数化(LIKE 通配符正确转义 `db.py:200-209`)、API key 零日志泄漏、WAL 已启用、消息回滚/tool 配对修复、`_msg_lock`/`_process_lock` 使用正确、工具实例按连接隔离。
---
## 五、架构合规矩阵
| 硬约束 | 状态 |
|---|---|
| WebSocket :8765 | ✅ |
| 依赖单向 `common←subsystems←tools←aibox` | ✅ |
| Worker 仅 7 执行工具 | ✅ |
| 工具输出 ≤1MB + 超时 + 中断 | ❌ **H1/H2/H3 三项全破** |
| prompt 三级排序加载 / sqlite 线程安全 | ✅ |
---
## 六、修复建议(一批搞定 H1/H2/H3,约 ±120 行)
1. **删除 command_tool.py 的重复 Popen 实现**,`execute_streaming` 改为一行委托 `self.executor.execute_streaming(output_queue, ...)` → 1MB、cancel、统一语义全部自动恢复
2. **executors.py 读取循环改线程化**:后台 reader 线程做阻塞 `readline` 入队列,主循环带超时轮询队列——跨平台解决 H2
3. `stdin=subprocess.DEVNULL`(H3)
4. M1 定界符唯一化 + M2 转发循环加总超时,顺手可修
---
## 七、修复落地(Batch E,2026-08-17)
五项修复(H1/H2/H3/M1/M2)已全部实施并通过验证:
| # | 修复 | 位置 | 状态 |
|---|---|---|---|
| H1 | `CommandLineTool.execute_streaming` 改为纯委托 `executor.execute_streaming`,删除 150 行重复 Popen(-165 行) | `tools/command_tool.py` | ✅ |
| H2 | `_read_with_limit` 线程化:后台 reader 线程阻塞 `readline` 入队,主循环 0.1s 轮询 + 超时/1MB 检查;shell 已死但 reader 无 EOF 时 1s 宽限排空(Windows shell=True 孙进程持管道场景) | `subsystems/executors.py` | ✅ |
| H3 | 两处 Popen 均改 `stdin=subprocess.DEVNULL` | `subsystems/executors.py` | ✅ |
| M1 | heredoc 定界符唯一化:content 含独立行与 `AIBOX_EOF` 相同时递增 `_X` 后缀直到唯一 | `tools/basic_tools.py` | ✅ |
| M2 | registry 转发循环加 3600s 总超时兜底,超时后放弃等待并返回错误信息 | `tools/registry.py` | ✅ |
### PoC 回归验证(`aibox/tests/_poc_audit_regression.py`,5/5 PASS)
```
[PASS] PoC-1 无输出命令超时(H2): 3.0s 返回(修复前阻塞15s+),结果含超时: True
[PASS] PoC-2 stdin等待命令不挂起(H3): 0.2s 返回(修复前永久挂起)
[PASS] PoC-3 1MB输出截断: 0.2s,结果 1.00MB,含截断标记: True
[PASS] PoC-4 cancel中断: cancel 后 3.0s 结束(修复前杀不到进程需等满120s)
[PASS] PoC-5 工具层1MB贯通(H1): 0.2s,工具层结果 1.00MB(修复前无限累积)
```
PoC-2 说明:`stdin=DEVNULL` 下等待输入的命令立即收到 EOF 快速退出(0.2s),优于按超时返回;判定标准为"不永久挂起"。
### 单元测试
aibox 全量单测 **264 passed**(零回归)。
### 遗留项(本轮未处理)
- M3(web 工具 scheme/目标校验)、M4(Windows 下 shlex.quote 转义失效)、L1/L3(防御性加固)——低优先级,待后续批次
- 架构合规矩阵"工具输出 ≤1MB + 超时 + 中断"一项修复后为 ✅
---
## 八、第二轮复审(2026-08-18,HEAD `177a9ac`)
方法:Batch E 修复质量复审 + 全量重审(3 路并行审读 + 主审对 6 处关键发现逐一实证抽查)。
### 8.1 Batch E 修复复审结论
| # | 修复 | 复审结论 |
|---|---|---|
| H1 | command_tool 纯委托 executor | ✅ 到位(PoC-5 验证 1MB 贯通) |
| H2 | reader 线程 + 超时轮询 | ✅ 到位(PoC-1 验证),**但有两个残余**:N4 reader 线程泄漏、N5 子代理超时盲区 |
| H3 | stdin=DEVNULL | ✅ 到位(PoC-2 验证) |
| M1 | heredoc 定界符唯一化 | ✅ 到位 |
| M2 | registry 转发总超时 | ⚠️ **不完整**(见 N6) |
### 8.2 第一轮遗留项状态
| # | 问题 | 状态 |
|---|---|---|
| M3 | web 工具无 scheme 校验 | 未修复(实证:`basic_tools.py:319+` url 未限制,`file://` 等可达) |
| M4 | shlex.quote POSIX 转义 Windows 失效 | 未修复(实证:`sed -r`/`grep -oP` 指引,Linux 部署不受影响) |
| L1 | `LIMIT {limit}` f-string | 未修复,且**新发现同型第二处**(`db.py:417` 与 `db.py:459` `get_immediate_memos`) |
| L2 | check_path_safety 无路径边界 | 维持 by design(本地单用户,AI 可全盘读写为设计意图) |
| L3 | execute() finally kill 后未 wait() | 未修复(实证:`executors.py:236-241` 无 wait;`cancel()` 同样无 wait) |
### 8.3 新发现——高危(1 项)
#### N1:SOCKS5 代理密码明文持久化到配置文件
`common/config.py:238-251`(实证):`SetProxyTool.execute` 默认 `save=True` → `set_proxy` 将 `password` 明文写入 `_config["proxy"]["socks5"]` → `_save_config` 用 `json.dump` 全量落盘 `~/.aibox/config.json`。与 api_key 走环境变量的隔离设计形成反差,任何能读该文件的进程可获取代理凭据。
修复建议:密码不落盘(仅内存)或走系统凭据库;必须落盘时对称加密 + `os.chmod(0o600)`;`save` 默认改 `False`。
### 8.4 新发现——中危(9 项)
| # | 问题 | 位置 | 说明 |
|---|---|---|---|
| N2 | drain_thread 每次工具调用泄漏一个永不退出的线程 | `aibox.py:1232-1243`(实证) | 循环条件只查 cancel 事件不查队列空,注释与实现不符;闭包持 output_callback(含 websocket)阻碍 GC;长期运行累积线程/内存 |
| N3 | 同步 threading.RLock 在 async 协程中阻塞事件循环 | `aibox.py:1855-1902, 2275, 2376` | `/new` 的 2s 轮询竞态窗口后仍可能 `with self._msg_lock:` 冻结整个事件循环 → 服务假死 |
| N4 | reader 线程泄漏(H2 残余) | `subsystems/executors.py:130-144` | 宽限 1s break 后 reader 仍阻塞 readline(Windows shell=True 孙进程持管道),stdout 未 close、reader 未 join,常驻服务累积 fd 与线程 |
| N5 | --once 超时对子代理完全无效(H2 盲区) | `aibox.py:3052-3058, 638-649` | Timer 到点只 `manager.cancel()` 父代理;子代理独立 `_cancel_event`/独立 executor/独立 HTTP client,cancel 不传播,进程挂起至子代理自行结束 |
| N6 | registry 超时路径跳过工具属性清理(M2 残余) | `tools/registry.py:99, 116`(实证 L114-120) | 两个超时 return 均跳过 `streaming_output=False`/`output_queue=None`;daemon 线程继续写实例属性 `tool.output_queue`,该属性被后续调用覆盖到新会话队列 → 僵尸输出串流 |
| N7 | FTS5 关键词注入 | `subsystems/db.py:179` | 关键词内 `"` 未转义(应翻倍),可破坏短语边界改变 AND/OR 语义;语法错误时回退 LIKE,影响有限 |
| N8 | 流式输出旁路 TOOL_MAX_RESULT_LENGTH | `tools/db_tools.py:97-98` + `registry.py:104-110` | 超长记忆 content 经流式通道完整推到前端,100000 字符上限仅约束非流式返回值 |
| N9 | prompt 注入存储 | `tools/prompt_tools.py:64, 127, 227` | Agent 可控内容原样写入 `00_IDENTITY.md`/`99_SOUL.md`/`100_STATUS.md` 并在后续会话作为系统提示词加载,形成持久化注入;update_soul 可自我强化 |
| N10 | mark_as_triggered TOCTOU 竞态 | `subsystems/db.py:509-570` | 跨连接先读后写,并发触发 trigger_count 失真;建议 BEGIN IMMEDIATE 同事务读写 |
### 8.5 新发现——低危(7 项)
| # | 问题 | 位置 | 说明 |
|---|---|---|---|
| N11 | debug 日志打印工具敏感参数 | `tools/registry.py:66, 126` | debug=3 时 set_proxy 的 password 明文进 `~/.aibox/ai.log`(日志无 0600) |
| N12 | _load_persona 路径遍历(限 .md) | `aibox.py:527-543` | persona 参数 LLM 可控未净化,可读 SKILLS_DIR 外 .md 文件并注入子代理 |
| N13 | broadcast 裸 except + unregister KeyError 逃逸 | `aibox.py:2076-2082, 1971-1973` | 吞 CancelledError;并发注销时 `remove` 抛 KeyError;建议 `discard` + `except Exception` |
| N14 | _processing_tasks 按 ws id 覆盖,旧任务失联 | `aibox.py:2728-2742` | 连接关闭只能 cancel 最后一个任务;handle_command 不受 is_processing 保护 |
| N15 | llm_client 异常含完整响应体 | `llm_client.py:172-174` | 某些网关 4xx 页回显 Authorization 头 → 密钥进异常栈;建议截断 500 字符 |
| N16 | 历史索引裸 except + 归档文件名撞名覆盖 | `subsystems/conversation_history.py:60-66, 135-137` | Ctrl+C 被吞后空索引覆盖原文件;时间戳粒度不足同秒覆盖 |
| N17 | InstallSkillTool skill_path 无校验 | `tools/skill_tools.py:252-304` | 可引导复制任意敏感文件进技能目录,经 search_skills show_content 预览泄露 |
杂项(低危,随批处理):`executors.py:209/301/338` 裸 except 应收窄为 TimeoutExpired;`db.py` 多处 `with sqlite3.connect` 不显式 close(CPython 下无害,PyPy 下句柄延迟);`config.py:31-34` AI_CONFIG_PATH 可重定向配置加载(本地单用户风险有限);skill_manager 技能目录代码执行面维持 by design(建议文档化可信边界)。
### 8.6 更新后的架构合规矩阵
| 硬约束 | 第一轮 | 第二轮 |
|---|---|---|
| WebSocket :8765 | ✅ | ✅ |
| 依赖单向 `common←subsystems←tools←aibox` | ✅ | ✅ |
| Worker 仅 7 执行工具 | ✅ | ✅ |
| 工具输出 ≤1MB + 超时 + 中断 | ❌ H1/H2/H3 全破 | ⚠️ **executor 链已恢复**;残余:流式 db_tools 旁路长度上限(N8)、子代理超时无效(N5)、reader 泄漏(N4) |
| prompt 三级排序加载 / sqlite 线程安全 | ✅ | ⚠️ sqlite 主流程安全;mark_as_triggered 竞态(N10)、FTS5 注入(N7)待修 |
### 8.7 Batch F 修复建议(按优先级)
1. **F1(高)**:N1 代理密码脱敏——`set_proxy` 落盘前抹除 password 字段 + `save` 默认 False
2. **F2(中)**:N6 registry 超时路径补清理(两个 return 前置 `tool.streaming_output=False; tool.output_queue=None`)+ N4 reader 线程收尾(break 后 `stdout.close()` + `reader.join(2)`)
3. **F3(中)**:N5 子代理 cancel 传播——`run_full_subagent` 传递父 `_cancel_event`,每轮 think 前检查
4. **F4(中)**:N2 drain_thread 加 sentinel 退出 + N3 reset_conversation 移出事件循环(run_in_executor)
5. **F5(中)**:N7 FTS5 转义 + N8 流式截断 + N10 同事务读写 + 顺带修 L1(两处 LIMIT 参数化)+ L3(finally wait)
6. **F6(低)**:N9 prompt 内容过滤/确认门槛 + N11 日志脱敏 + N12 persona 路径校验 + N13/N14/N15/N16/N17 择机处理 + M3/M4 视部署平台决定
### 8.8 Batch F 修复进展
**F1 ✅ 已落地(2026-08-18)**——N1 + N11 一并修复:
- `common/config.py`:`set_proxy` 新增 `password_env` 参数;`_save_config` 落盘前 deepcopy 脱敏(明文密码强制置 None,纵深防御,任何保存路径均拦截);`get_proxy` 按"内存明文优先 → password_env 查环境变量"解析,下游(llm_client / web 工具 `_proxy_curl_args`)无感知;加载时检测旧版明文自动迁移(内存保留供本会话 + 立即重写脱敏文件,一次性清除存量)
- `tools/proxy_tools.py`:`SetProxyTool` 新增 `password_env` 参数,结果显示"密码来源"(环境变量 / 仅本次会话 / 无)
- `tools/registry.py`(N11):debug 日志对 `password/api_key/token/secret/authorization` 参数打码
- 测试:新增 `tests/test_config.py` 3 例(落盘无明文 / password_env 解析 / 旧配置迁移),全量 267 passed
- 行为变化:已有明文密码的配置重启后代理需重设密码或改用环境变量(迁移日志有提示)——清除存量明文的必然代价
**F2 ✅ 已落地(2026-08-18)**——N6 + N4 一并修复:
- N6(registry 超时路径补清理):两个超时 return(deadline 命中、join(5) 超时)统一改为 `timed_out` 标记 + 出口统一执行 `tool.streaming_output=False` / `tool.output_queue=None`;僵尸 daemon 线程的 `send_line` 落到 None 上不再外写,消除僵尸输出串流路径。`stream_tool_timeout` 提为实例属性(默认 3600s,测试可调)
- N4(reader 线程收尾,**含二次修正**):初版按审计建议"break 后 `stdout.close()` + `reader.join(2)`"实现,PoC 回归实证 **close 会阻塞**——CPython 流对象内部有锁,readline 持锁阻塞时 `close()` 一同挂起直到 readline 返回(PoC-1 从 3s 退化到 15.2s,恰为命令 Start-Sleep 时长)。修正后策略:reader 已正常退出(EOF)→ 显式 close 释放管道 fd;reader 仍阻塞 → 只做 `join(timeout=2)` 后放弃(daemon 兜底,孙进程退出收 EOF 后自然结束,fd 随管道消亡回收),主流程及时返回
- **审计建议修正记录**:8.3 节 N4 与 8.7 节 F2 中"close stdout 可中断阻塞 readline(CPython 行为)"的表述**不成立**,已按实测修正——跨线程中断阻塞中的管道 read 无便携方案,正确做法是控制泄漏窗口而非强行中断
- **测试**:新增 `tests/test_f2_fixes.py` 5 例(N6 超时/正常清理 ×2、N4 及时返回 ×2 + EOF 后 close ×1),PoC 回归 5/5(PoC-1/4 恢复 ~5s:超时 3s + join 宽限 2s),全量 272 passed
---
## 九、第三轮审计(2026-08-18,HEAD `aa15841`)
方法:前两轮遗留项逐条实证复核 + v3.4 新增代码(多模型档案 / L4a/b/c 失败转移,`llm_client.py`)全量审读 + 测试基线确认(全量 **355 passed**)。
### 9.1 遗留项修复状态复核
| # | 问题 | 状态 | 实证 |
|---|---|---|---|
| L3 | execute() kill 后未 wait() | ✅ **已修** | `executors.py:222-230`:kill/terminate → `wait(timeout=2)` → 超时再 kill 兜底 |
| N17 | InstallSkillTool 任意文件复制 | ✅ **已消解(设计变更)** | 工具重构为纯指引(只返回安装说明文本,不再执行 shell/复制);`CreateSkillTool` 增加 skill_name 正则校验(`^[A-Za-z0-9_-]+$`) |
| N1/N11 | 代理密码脱敏 / 日志打码 | ✅ 在位 | F1 修复无回归 |
| N4/N6 | reader 收尾 / registry 超时清理 | ✅ 在位 | F2 修复无回归 |
| N2 | drain 线程泄漏 | ❌ 未修 | `aibox.py:1280-1303`:循环条件仍只查 cancel 事件,注释"自动退出"与实现不符,每次工具调用泄漏一个常驻线程 |
| N3 | `/new` 阻塞事件循环 | ❌ 未修 | `aibox.py:2325`:2s 轮询竞态窗口后仍 `with self._msg_lock:`(同步 RLock acquire 于事件循环线程) |
| N5 | 子代理 cancel 不传播 | ❌ 未修 | `aibox.py:643-654`:子代理独立 `_cancel_event`/executor/client;`--once` Timer(3110-3113)只 cancel 父代理 |
| N7 | FTS5 引号未翻倍 | ❌ 未修 | `db.py:178`:`f'"{kw}"'` 直接拼,关键词含 `"` 破坏短语边界 |
| N8 | 流式旁路长度上限 | ❌ 未修 | `registry.py:120-129`:`("line", content)` 无限转发;`aibox.py:1326` 的 `MAX_RESULT_LENGTH` 只管回喂 LLM 的最终 result |
| N9 | prompt 注入存储 | ❌ 未修 | `prompt_tools.py`:confirm 默认 True,Agent 可控内容仍直写 00/99/100 文件 |
| N10 | mark_as_triggered TOCTOU | ❌ 未修 | `db.py:509-525`:跨连接先 `get_memo` 后 UPDATE,无 BEGIN IMMEDIATE |
| N12 | _load_persona 路径遍历 | ❌ 未修 | `aibox.py:529-545`:persona LLM 可控,`os.path.join(SKILLS_DIR, name + ".md")` 无边界校验 |
| N13 | broadcast 裸 except | ❌ 未修 | `aibox.py:2131`:`except:` 吞 CancelledError |
| N14 | _processing_tasks 覆盖 | ❌ 未修 | `aibox.py:2788`:按 ws id 覆盖登记;先完成任务的 done_callback 会误 pop 后续任务的登记 |
| N15 | 异常含完整响应体 | ⚠️ 部分改善 | 瞬时路径已截断 `[:200]`(163/213 行);**非瞬时路径仍全量**(`llm_client.py:215-216`) |
| N16 | 历史索引裸 except / 同秒覆盖 | ❌ 未修 | `conversation_history.py:64` 裸 `except:`;136 行归档文件名秒级时间戳 |
| M3/M4 | web scheme 校验 / shlex Windows | ❌ 未修 | 维持原判(Linux 部署不受影响) |
| L1 | LIMIT f-string | ❌ 未修 | `db.py:417, 459` 两处在位(内部 int 直达,防御性待修) |
### 9.2 v3.4 新代码审计(多模型档案 + L4 失败转移)
**设计评价:整体高质量**。免密端点与密钥隔离(`allow_key_file=False` 防跨厂商凭据泄漏)、用户中断绝不转移(cancel 广播全链 + `_cancelled` 门控)、流式仅首事件前重放(`produced_any` 门控)、fallback 链一层不递归 + 自引用/悬空引用硬性报错、构造失败跳过不阻断主档案——关键决策均正确且有测试锚定(`test_models_profile.py` 约 60 个 L4 用例)。
新发现(P 系列):
| # | 级别 | 问题 | 位置 | 说明 |
|---|---|---|---|---|
| P1 | 中 | 非瞬时 HTTP 错误响应体无截断(N15 残留) | `llm_client.py:215-216` | `RuntimeError(f"HTTP {code}: {response.text}")` 全量响应体进异常栈;部分网关 4xx 页回显 Authorization 头 → 密钥进日志。与瞬时路径 `[:200]` 不一致,建议同样截断 |
| P2 | 中 | Retry-After 无上限 | `llm_client.py:290` | `max(1.0, float(v))` 无上界——异常/恶意服务端 `Retry-After: 999999` 使档案冷却 11.5 天。建议 cap(如 `min(v, max(cooldown_seconds, 3600))`) |
| P3 | 低中 | fallback 档案缺 model 不校验 | `llm_client.py:671-673` | 主档案经 `_require_model` 硬性报错,fallback 档案直接 `fb_profile.get("model")` → model=None → API 400 → 表现为神秘 None 而非清晰配置错误。建议构造链时同样校验 |
| P4 | 低 | FailoverClient 非线程安全 | `llm_client.py:321-334` | `_cancelled`/`_call_started`/`_stats` 无锁。当前调用面无实际并发(compressor 在 `_msg_lock` 内同步调用、子代理独立 client),但 cancel(WebSocket 线程)与 generator 启动时 `_cancelled = False` 复位存在理论竞态窗口。建议注释声明"非并发契约"或加锁 |
| P5 | 低 | `LLMClient._cancelled` 死状态 | `llm_client.py:52, 76` | 只写不读:cancel() 置位后无消费方也从不清除,真正判断靠 FailoverClient 自身标志。建议删除或在底层 client 消费 |
| P6 | 低 | fallback 列表重复项不查重 | `llm_client.py:660-673` | `["kimi","kimi"]` 会在链内构造两个同名 client,冷却键同名冲突、重复撞同端点;影响有限 |
| P7 | 信息 | timeout_budget 仅在尝试间检查 | `llm_client.py:363-366, 404, 443` | 预算不截断单档案进行中的阻塞读(单次仍可超时到 timeout)。与文档"限制整条链尝试的总秒数"有细微出入,可接受,建议文档注明"尝试间预算" |
### 9.3 架构合规矩阵(第三轮)
| 硬约束 | 状态 |
|---|---|
| WebSocket :8765 | ✅ |
| 依赖单向 `common←subsystems←tools←aibox` | ✅ |
| Worker 仅 7 执行工具 | ✅ |
| 工具输出 ≤1MB + 超时 + 中断 | ⚠️ executor 链 ✅;残余:N8 流式旁路、N5 子代理超时、N2 drain 泄漏 |
| prompt 三级排序 / sqlite 线程安全 | ⚠️ N7/N10 待修 |
| 多模型档案凭据隔离(v3.4 新增维度) | ✅ 跨厂商 Key 不互发、免密端点显式声明 |
### 9.4 Batch G 修复建议(按优先级)
1. **G1(中)**:P1 非瞬时路径响应体截断 `[:200]` + P2 Retry-After 上限 cap——两处各一行改动
2. **G2(中)**:P3 fallback 档案补 `_require_model` 校验(缺 model 的 fallback 跳过并告警,与缺 Key 行为对齐)
3. **G3(中)**:N5 子代理 cancel 传播(`run_full_subagent` 传递父 `_cancel_event`,每轮 think 前检查)+ N2 drain 线程加 sentinel/空队列退出
4. **G4(中低)**:N3 `/new` 的 `reset_conversation` 移入 `run_in_executor` + N8 registry 转发行截断 + N12 persona 路径边界校验(`os.path.realpath` 前缀检查)
5. **G5(低)**:N7 FTS 引号翻倍 + N10 BEGIN IMMEDIATE + L1 LIMIT 参数化 + N13 `except Exception`/`discard` + N14 任务登记改按 task id 集合 + N16 收窄 except + 归档文件名加毫秒/序号
6. **G6(低/择机)**:P4 并发契约注释或加锁 + P5 死状态清理 + P6 fallback 去重 + N9 prompt 更新确认门槛 + M3/M4 视部署平台决定
### 9.5 测试基线
全量 **355 passed**(21.8s,Windows 开发机 `py -3`),含 L4 失败转移约 60 用例、Batch E PoC 回归 5/5、Batch F1/F2 修复测试 8 例。零回归。
---
## 十、Batch G 修复落地(2026-08-18,第三轮审计修复)
按 §9.4 建议分 5 波实施,每波全量测试验证后进入下一波。
### 10.1 修复清单
| 波次 | # | 修复内容 | 文件/位置 |
|---|---|---|---|
| W1 | P1 | 非瞬时 HTTP 错误响应体截断 `[:200]`(与瞬时路径对齐,密钥/错误页不进异常栈) | `llm_client.py:216` |
| W1 | P2 | Retry-After 解析上限 `min(max(1.0, v), 3600.0)`——恶意/异常头最多冷却 1h | `llm_client.py:284-296` |
| W1 | P3 | fallback 档案构造前补 `_validate_profile` + `_require_model`,配置缺陷跳过并告警(对齐缺 Key 行为) | `llm_client.py:681-695` |
| W1 | P5 | 删除 `LLMClient._cancelled` 死状态(只写不读;真实机制在 FailoverClient),cancel() 注释说明职责边界 | `llm_client.py:48-49, 71-76` |
| W1 | P6 | fallback 列表 `seen` 去重(自引用硬性报错保持不变,测试锚定) | `llm_client.py:669-679` |
| W1 | P4 | FailoverClient 类文档补并发契约声明(非线程安全 + 当前调用面为何安全) | `llm_client.py:324-327` |
| W2 | N8 | registry 流式转发受 `TOOL_MAX_RESULT_LENGTH` 约束:累计预算 + 超限截断 + 停止转发提示(一次性) | `tools/registry.py:113-145` |
| W2 | N12 | persona 路径边界校验:normcase+abspath 前缀检查,越界/绝对路径拒绝 | `aibox.py:536-544` |
| W3 | N2 | drain 线程重构:批次级单线程 + 独立 stop 事件 + try/finally 统一回收(正常/异常/中断路径均 join≤2s);`set_output_queue(None)` 移入 finally | `aibox.py:1254-1386` |
| W3 | N5 | 子代理取消传播:`run_full_subagent` 新增 `cancel_event` 参数→子代理共享父事件(think 前置/工具间/流式事件间既有检查点联动)+ 0.2s 观察者线程转发深度中断(强关在途 HTTP、kill 子进程),finally 通知退出;`SubAgentTool` 构造注入父 `_cancel_event` | `aibox.py:621-624, 669-684, 773-775, 786-790, 897-902, 1145-1147` |
| W4 | N3 | `/new` 的 `reset_conversation` + `prompt_manager.reload()` 移入 `run_in_executor`(不再同步获取 `_msg_lock` 冻结事件循环) | `aibox.py:2386-2400` |
| W4 | N13 | broadcast 裸 except → `except Exception`(不再吞 CancelledError);unregister `remove` → `discard`(幂等) | `aibox.py:2080-2084, 2185-2192` |
| W4 | N14 | `_processing_tasks` 改按 websocket 键的任务集合登记:done_callback 从集合 discard;连接关闭取消该连接全部在途任务(此前仅最后一个) | `aibox.py:2022, 2847-2870` |
| W5 | N7 | FTS5 关键词内双引号翻倍转义(`"` → `""`),MATCH 短语语法合法 | `subsystems/db.py:178-182` |
| W5 | L1 | 两处 `LIMIT {limit}` f-string → `LIMIT ?` 参数化(`get_pending_memos`/`get_immediate_memos`) | `subsystems/db.py:419-421, 462-465` |
| W5 | N10 | `mark_as_triggered` 读写收进单连接 `BEGIN IMMEDIATE` 事务(消灭 check-then-act 竞态:并发触发不再双计数/双推进 repeat) | `subsystems/db.py:515-590` |
| W5 | N16 | 历史索引加载裸 except → `except Exception` + 告警;归档文件名冲突时加 `_N` 序号(同秒归档不覆盖) | `subsystems/conversation_history.py:58-69, 138-146` |
未实施(按 §9.4 G6 择机项):N9(prompt 更新确认门槛,涉及交互设计)、M3/M4(web scheme 校验 / shlex Windows,Linux 部署不受影响)、P7(timeout_budget 语义为文档注记,已在 FailoverClient 文档说明)。
### 10.2 新增测试
`tests/test_g_fixes.py` **20 例**(全部通过):
- P1/P2 ×4:非瞬时错误体截断、Retry-After cap/正常值/非法值
- P3/P5/P6 ×2:fallback 缺 model 跳过 + 重复去重(链 = [main, dupe, ok2])、死状态删除
- N2 ×2:drain 线程批次结束/工具异常路径均回收
- N5 ×3:子代理共享父取消事件 / 未传时保留自身 / SubAgentTool 注册注入链路
- N7 ×2:含引号关键词不破坏检索、常规检索不回归
- N8 ×1:120000 字符流式输出被截断至预算 + 提示标记
- N10 ×2:缺失 id 事务路径返回 None、一次性任务完成语义
- N12 ×3:`../` 越界/绝对路径拒绝、合法 persona 正常加载
- N16 ×1:同秒两次归档文件名不冲突
N3/N13/N14 为 async WebSocket 路径,依赖事件循环与真实连接对象,由现有 WebSocket 测试面回归(全量通过)。
### 10.3 测试基线
全量 **375 passed**(22.5s,Windows 开发机 `py -3`)= 原 355 + Batch G 新增 20。零回归。
---
## 十一、第四轮审计(2026-08-18,HEAD `d1f780e`)
方法:3 路并行审读(aibox.py 全文 / llm_client+common+tools / subsystems+tests)+ 主审对全部候选发现逐条代码复核,关键项 Linux 动态实证(PoC 6/6)。
### 11.1 新发现与修复(Batch H,13 项全落地)
| # | 级别 | 问题 | 位置 | 修复 |
|---|---|---|---|---|
| H1 | **高危** | **N5 修复引入回归**:子代理共享父取消事件后,首轮 `_think_and_respond_impl` 无条件 `clear()` 吞掉父级已置位的取消信号(用户中断 / --once 超时恰落在子代理生成窗口时被清,观察者线程随之失效)——取消传播被绕过,子代理跑完全程 | `aibox.py:675, 1470` | 共享事件时置 `_shared_cancel_event` 跳过 clear;赋事件后立即检查 is_set 直接返回取消 |
| M1 | 中 | `/reload_prompts` 在事件循环线程同步获取 `_msg_lock` 冻结整个事件循环(N3 同类遗漏,只修了 `/new`) | `aibox.py:2492` | 移入 `run_in_executor`(与 /new 一致) |
| M2 | 中 | SkillManager 懒加载在事件循环线程串行执行 `--help` 子进程(每技能 ≤10s),首条消息/首个任务触发时冻结服务 | `aibox.py:2885-2891` | 启动前预热 WS 与调度器两个共享实例 |
| M3 | 中 | 调度器任务无超时:AI think 卡死 → `await ai_future` 永久阻塞 → 调度循环停摆、后续任务全部丢失(已 mark_as_triggered 不重试) | `aibox.py:333` | `call_later` 任务级超时(`scheduler.task_timeout`,默认 600s)→ `ai.cancel()`,循环继续 |
| M4 | 中 | `get_recent_memories(limit=None)` 无 LIMIT 全表加载(db_tools 分页传 None),记忆库增长后单次调用全量入内存 | `db.py:234` | None → 默认上限(对齐 search_memories 语义) |
| M5 | 中 | `memo delete` 实为 `complete_memo(force)`——只标记完成不删行,重复任务被推进而非移除,stats 计数失真 | `db_tools.py:747` | db 层新增 `delete_memo` 物理删除,工具改调 |
| M6 | 中 | `PromptManager.reload()` 先清空 `_prompts` 再逐文件重读:并发连接构建 system 消息时读到空/半截列表(N3 移线程池后放大) | `prompt_manager.py:298` | 本地构建新列表后原子替换 |
| L1 | 低 | read_file 的 `start_line/end_line` 未强转直拼 sed 单引号模板——LLM 传含引号字符串可闭合引号注入生成的指引命令 | `basic_tools.py:97` | int() 强转 + 范围夹取 |
| L2 | 低 | `_save_config` 吞异常仅 print:磁盘只读时 set_proxy 仍报"已保存",重启后配置丢失无感知 | `config.py:284` | `_save_config` 返回落盘结果,set_proxy 透传,工具如实报告 |
| L3 | 低 | SSE 行 `decode("utf-8")` 遇非 UTF-8 字节抛 UnicodeDecodeError,整条流中断且不触发 failover | `llm_client.py:222` | `errors="replace"` |
| L4 | 低 | persona 路径校验用 abspath 未解析符号链接:skills 目录内软链指向任意文件可绕过前缀检查被读取注入 | `aibox.py:536` | realpath 后再做前缀校验 |
| L5 | 低 | 1MB 截断按字节切后 `errors='replace'` 解码,行尾出现半个字符 � | `executors.py:150` | 改 `errors='ignore'` |
| L6 | 低 | compressor 压缩块全空内容时 `original_tokens=0` → 除零崩溃 | `conversation_compressor.py:517` | `max(original_tokens, 1)` |
| L7 | 低 | InstallSkillTool 指引命令 `ls -l {src}`/`cp {src} {dst}` 未引用:LLM 可控路径含空格/元字符时 shell 拆词/注入 | `skill_tools.py:269-285` | shlex.quote 源与目标路径 |
| L8 | 低 | ask_user 的 required 为空,LLM 不传 question 时 `str + None` 抛 TypeError | `misc_tools.py:42` | required 补 question + 缺参明确报错 |
| L9 | 低 | Linux 孙进程持管道泄漏(N4 残余变体):`(sleep 300 &)` 形态 shell 已死但孙进程持写端,reader 阻塞无 EOF;**实证修复前孙进程残留** | `executors.py` | POSIX `start_new_session` + 进程组信号(组 ID 在 Popen 后立即捕获,shell 死亡后 getpgid 不可用);宽限窗口到期强杀进程组 |
| L10 | 低 | web 工具 url 无 scheme 校验(M3 遗留):`file://` 等经 curl 指引可达(SSRF) | `basic_tools.py:319` | http/https 白名单 |
| L11 | 低 | registry 日志打码精确匹配:apiKey/passwd 等变体漏网 | `registry.py:19` | 子串包含匹配(含 apiKey/passwd/credential) |
| L12 | 低 | FailoverClient 异常消息经 `_log` 落 ai.log:网关错误页若在 200 字符内回显 Authorization 头则密钥进日志 | `llm_client.py` | `_log`/on_failover 前 Bearer 令牌正则脱敏 |
### 11.2 修复实证(Linux PoC 6/6 PASS)
```
[PASS] L-1 无输出命令超时: 3.0s 返回,含超时(原 PoC-1 为 Windows powershell 命令)
[PASS] L-3 1MB截断: 结果 1.00MB(Linux 等价命令)
[PASS] L-4 cancel中断: cancel 后 2.0s 结束
[PASS] L-5 工具层1MB贯通: 结果 1.00MB
[PASS] L-9 孙进程持管道不悬挂: 1.1s 返回,reader 正常收尾
[PASS] L-9 孙进程已被清理: 残留无(修复前 `(sleep 300 &)` 孙进程残留至自然退出)
```
L-9 实证过程说明:首版实现按"break 后 killpg"处理,但 shell 已死时 `os.getpgid(pid)` 抛 ProcessLookupError,组 ID 无法反查,孙进程仍残留(实证 FAIL 抓出)。修正为 **Popen 成功后立即捕获组 ID 并保存**,killpg 用保存值——6/6 通过。
### 11.3 遗留项(维持原判 / 择机)
- **N9**(prompt 注入存储):confirm 默认 True、100_STATUS 直写无门槛——涉及交互设计(用户确认门槛),维持 G6 择机项
- **M4/M3(旧编号)**:shlex Windows 转义(Linux 部署不受影响)、Q9 代理认证 curl 指引(修复需把密码嵌入命令串,反而引入密码进日志/会话史的新泄漏,维持现状)
- **F5** express_emotion 不可中断、**F6** /new 占用默认线程池、**F8** WS 限速/空闲回收、**F9** --once 软超时(H1 修复后取消可传播,残余窗口受 timeout 有界)、**Q7a** stream_chat 取消空窗(受 timeout 有界)、Q4 busy_timeout 重试、Q5 提醒格式异常静默、Q7b load_conversation 无调用面、Q8 对话文件原子写、compressor 裸 print / token 双计、单例 __new__ 无锁、db.py `if limit:` limit=0 边界——均为低危/信息级,下批处理
- **测试环境注记**:`test_executors.py::test_execute_timeout` 与 `test_models_profile.py::test_flat_api_still_reads_key_file` 为 Windows 向用例(ping -n 语法 / 依赖 ~/.cert/deepseek.key),Linux 环境预存失败,与本轮改动无关(git stash 实证)
### 11.4 测试基线(Linux,`aibox/venv`)
全量 **408 passed** = 原 382(Linux 环境)+ Batch H 新增 26。零回归;2 个预存环境性失败同前。PoC 回归脚本为 Windows 命令集,Linux 用等价命令实证(见 11.2)。
---
## 十二、Batch I 修复落地(2026-08-18,遗留项收尾)
按 §11.3 遗留清单分 3 波实施:安全项 → 稳定性项 → 信息级顺手项。
### 12.1 修复清单
| # | 级别 | 修复内容 | 文件/位置 |
|---|---|---|---|
| N9 | 中 | prompt 写入内容长度上限(持久化注入面约束):`PromptManager.MAX_CONTENT_LENGTH=10000`,update_identity/update_soul/update_status 超长拒绝(manager 兜底 + 工具友好提示);save_memory_and_end_conversation 状态内容超长拒绝 | `prompt_manager.py`、`prompt_tools.py` |
| Q7b | 低 | `load_conversation` realpath 前缀校验,`../` 越界/软链逃逸返回 None(当前无 LLM 调用面,防御性加固) | `conversation_history.py:348` |
| F5 | 低 | 情绪子代理取消传播:`run_light_subagent` 新增 `cancel_event`,改走 stream_chat(可被 cancel 强关在途 HTTP)+ 观察者线程;父已取消早退;`ExpressEmotionTool` 构造注入父 `_cancel_event` | `aibox.py:513, 608-660, 1177` |
| Q4 | 低 | `mark_as_triggered` BEGIN IMMEDIATE 前 `PRAGMA busy_timeout = 10000`——多进程同库(CLI+服务)写竞争等待锁释放而非 5s 后直接报 locked | `db.py:527` |
| Q5 | 低 | 提醒时间格式异常任务不再静默丢弃:去掉 SQL 层 `reminder_time <= ?` 预过滤(字符串比较会静默排除格式异常行),时间过滤改由 Python 二次验证权威判断;格式异常按"到期"返回并告警 | `db.py:405-411, 442-444` |
| Q8 | 低 | 对话文件原子写:`_atomic_write_json`(tmp + os.replace)覆盖历史索引/当前对话/归档三处写路径,崩溃不留半截 JSON | `conversation_history.py` |
| F6 | 低 | WS 命令专用线程池:`WebSocketHandler._cmd_executor`(max_workers=2),`/new`、`/reload_prompts` 不再占用默认池(繁忙 AI 时多连接并发 /new 不再耗尽线程池);服务退出时 shutdown | `aibox.py` |
| F9 | 低 | --once 硬超时兜底:软超时 cancel 后若 AI 卡死在不可中断路径(HTTP 空窗、忽略信号的子进程),宽限一个超时周期后 `os._exit(4)` 强杀,保证硬性结束语义 | `aibox.py:3283-3299` |
| — | 信息 | PromptManager 单例 `__new__` 加锁(并发首例化竞态防护) | `prompt_manager.py:49-53` |
| — | 信息 | `get_pending_memos`/`get_immediate_memos` 的 `if limit:` → `if limit is not None`(limit=0 语义修正,None 才不加 LIMIT) | `db.py:420, 464` |
### 12.2 维持原判(未改动)
- **Q7a**(stream_chat 取消空窗):POST 返回前 `_current_response` 未赋值,cancel 无法中断——受 timeout 有界;根治需线程化 POST + 轮询取消,复杂度高,维持记录
- **M4(旧编号)**(shlex Windows 转义):Linux 部署不受影响
- **Q9(代理认证 curl 指引)**:修复会把密码嵌入命令串(进日志/会话史),反而引入新泄漏,维持现状
- **F8**(WS 限速/空闲回收):本地单用户产品设计项
- compressor 裸 print(终端过程展示,保留)、`estimate_tokens` 英文双计(测试锚定既有算法,仅影响压缩阈值)
### 12.3 新增测试(`tests/test_i_fixes.py` 16 例,全部通过)
N9 ×5(manager 兜底/身份/灵魂/状态超长拒绝 + 正常写入)、Q7b ×2(越界拒绝 + 正常加载)、F5 ×3(父已取消早退/流式中断/注册注入链路)、Q5 ×2(格式异常按到期返回 + 正常过滤不回归)、Q8 ×1(无 .tmp 残留 + JSON 合法)、F6 ×1(专用线程池存在与释放)、limit=0 ×2。
### 12.4 测试基线(Linux,`aibox/venv`)
全量 **424 passed** = 原 408 + Batch I 新增 16。零回归;2 个预存环境性失败同前。
+31 -2
View File
@@ -308,9 +308,38 @@ python aibox.py --once --quiet "列出当前目录 py 文件" | grep ".py" | wc
python aibox.py --once --timeout 5 "运行 sleep 60" ; echo "exit=$?" # 期望 4 python aibox.py --once --timeout 5 "运行 sleep 60" ; echo "exit=$?" # 期望 4
``` ```
## 7. 实施状态(2026-08-17 落地记录)
> 本节为实施后回填,与第 3/4 节计划存在少量偏差,以本节为准。
### 7.1 提交与批次
| 批次 | 提交 | 内容 |
|---|---|---|
| Batch A | `ce3fb7f` | `--once` / `--quiet` / `--once-timeout`(300s 默认)+ `handle_once_mode` + SIGINT/超时复用三层取消 |
| Batch B | `9e0d39c` | `--no-save-memory`(`enable_memory` 构造开关干净禁用 MemoryTool)、`--show-reasoning`(思考流默认静默走 stderr)、压缩提示改走 output_callback、退出码 1(orchestrator 层工具异常)+ `test_once_mode.py` 10 项 |
| Batch C | `a5af4bb` | SQLite WAL、`--worker-only`(worker profile + 精简 system prompt 防人格污染)、`--model` / `--temperature`、子代理失败标记透传(`_SUBAGENT_TOOL_FAILED_MARKER` → orchestrator `stats.tool_failed`)+ 5 项测试 |
| Batch D | `d03dbc7` | 多模型档案 `models` 配置节(本档案为实施中新增需求,超出原计划范围)+ `test_models_profile.py` 17 项 + README 更新 |
### 7.2 与计划的偏差
1. **超时参数名**:计划 `--timeout`,落地为 `--once-timeout`(避免与 `api.timeout` 语义混淆)
2. **`models` 默认留空 `{}`**:计划未涉及;实施中发现默认档案硬编码 `DEEPSEEK_API_KEY` / 官方 URL 会覆盖用户自定义配置(本机为 `ARK_API_KEY` + 火山方舟端点,曾导致 401),故默认空 dict,未配置档案时零破坏回退旧 `api` 扁平节
3. **`--worker-only` 需要精简 system prompt**:原计划仅切 tool_profile;实测 orchestrator 的人格/身份提示词会污染 worker 语义(自称"老板"、主动写文件、多轮不收敛),已对齐 `run_full_subagent` 的子代理语义修复
4. **退出码 1 的边界**:仅覆盖 orchestrator 直接感知的工具异常;worker 子代理内的命令失败经 Batch C 的标记透传后同样计入。子命令失败但 AI 正常汇报时退出码为 0(AI 视角任务完成)
### 7.3 验证结果
- 单测 **264 项全过**(once 模式 15 + 多模型档案 17 新增)
- 冒烟:基础问答 exit 0 / 工具调用链(子代理执行 echo)exit 0 / 超时路径 exit 4 / `--no-save-memory --quiet` stdout 干净 / `--show-reasoning` 思考流走 stderr / `--temperature` 透传 / 不存在的模型名 exit 2
### 7.4 明确不做(维持原判)
WebSocket 会话恢复、`--once` 结果缓存、批处理多 query——见第 4 节"明确不做"。
--- ---
## 7. 后续演进方向(不在本期) ## 8. 后续演进方向(不在本期)
1. **Worker 进程级隔离**:`run_full_subagent` 的线程内 `DeepSeekChat` → `subprocess.run(["python", "aibox.py", "--once", "--worker-only", ...])`,子代理崩溃不波及主服务,可用 OS 用户/chroot 做权限隔离 1. **Worker 进程级隔离**:`run_full_subagent` 的线程内 `DeepSeekChat` → `subprocess.run(["python", "aibox.py", "--once", "--worker-only", ...])`,子代理崩溃不波及主服务,可用 OS 用户/chroot 做权限隔离
2. **HTTP 单次接口**:`POST /chat` 无状态接口,与 `--once` 共享内核 2. **HTTP 单次接口**:`POST /chat` 无状态接口,与 `--once` 共享内核
@@ -318,7 +347,7 @@ python aibox.py --once --timeout 5 "运行 sleep 60" ; echo "exit=$?" # 期望
--- ---
## 8. 决策记录 ## 9. 决策记录
| 决策点 | 结论 | 理由 | | 决策点 | 结论 | 理由 |
|--------|------|------| |--------|------|------|
+2 -2
View File
@@ -416,9 +416,9 @@ function renderMarkdown(element, content) {
html: false, html: false,
highlight: function(code, lang) { highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) { if (lang && hljs.getLanguage(lang)) {
return `<pre><code class="hljs language-${lang}">${hljs.highlight(code, {language: lang}).value}</code></pre>` return `<pre><code class="hljs language-${lang}"><div style="font-size: 11px;">${lang}</div>${hljs.highlight(code, {language: lang}).value}</code></pre>`
} else { } else {
return `<pre><code class="hljs">${md.utils.escapeHtml(code)}</code></pre>` return `<pre><code class="hljs"><div style="font-size: 11px;">${lang}</div>\n${md.utils.escapeHtml(code)}</code></pre>`
} }
} }
}); });
+30 -1
View File
@@ -5,6 +5,7 @@
#include <set> #include <set>
#include <optional> #include <optional>
#include <algorithm> #include <algorithm>
#include <utility>
@@ -72,7 +73,35 @@ public:
explicit Memdb() = default; explicit Memdb() = default;
explicit Memdb(int max_time) : MAX_TIME(std::max(0, max_time)) {} explicit Memdb(int max_time) : MAX_TIME(std::max(0, max_time)) {}
Memdb& operator=(const Memdb& to_be_cp_memdb) = default;
// 深拷贝:重建索引,保证 keys/time_touch 的迭代器指向副本自身的 list
Memdb(const Memdb& other)
: __size(other.__size), MAX_TIME(other.MAX_TIME) {
this -> db = other.db;
for (auto it = this -> db.begin(); it != this -> db.end(); ++it) {
Memdb::index_keys new_key;
new_key.item = it;
this -> keys.emplace(it -> key, new_key);
Memdb::index_timestamp new_time_touch;
new_time_touch.item = it;
this -> time_touch.insert(new_time_touch);
}
}
Memdb& operator=(const Memdb& other) {
if (this == &other) {
return *this;
}
Memdb tmp(other);
std::swap(this -> db, tmp.db);
std::swap(this -> keys, tmp.keys);
std::swap(this -> time_touch, tmp.time_touch);
std::swap(this -> __size, tmp.__size);
std::swap(this -> MAX_TIME, tmp.MAX_TIME);
return *this;
}
~Memdb() = default; ~Memdb() = default;
+8 -5
View File
@@ -331,10 +331,11 @@ private:
try { try {
task.message = pmsg_recv(task.fd, _config.recv_timeout); task.message = pmsg_recv(task.fd, _config.recv_timeout);
} catch (const MsgException&) { } catch (const MsgException&) {
// 不在此清除 busy:fd 保持 busy 直到 _reap_failed 关闭连接。
// 旧实现提前清除 busy,EOF 可读性会让事件循环为同一 fd 重复
// 投递任务,迟到的失败标记经 fd 复用可能落在新连接上误杀之。
std::lock_guard<std::mutex> flk(_failed_mtx); std::lock_guard<std::mutex> flk(_failed_mtx);
_failed.insert(task.fd); _failed.insert(task.fd);
std::lock_guard<std::mutex> blk(_busy_mtx);
_busy.erase(task.fd);
continue; continue;
} }
} }
@@ -354,11 +355,12 @@ private:
failed = true; failed = true;
} }
if (failed) { if (failed) {
// 失败连接交由 _reap_failed 统一清除 busy 并关闭,
// 不提前恢复 select 监听(同 recv 失败路径,防 fd 复用竞态)。
std::lock_guard<std::mutex> flk(_failed_mtx); std::lock_guard<std::mutex> flk(_failed_mtx);
_failed.insert(task.fd); _failed.insert(task.fd);
} } else {
// 清除 busy 标记,恢复 select 监听 // 清除 busy 标记,恢复 select 监听
{
std::lock_guard<std::mutex> lock(_busy_mtx); std::lock_guard<std::mutex> lock(_busy_mtx);
_busy.erase(task.fd); _busy.erase(task.fd);
} }
@@ -528,6 +530,7 @@ private:
// 事件循环线程不阻塞接收,防 Slowloris DoS CWE-400) // 事件循环线程不阻塞接收,防 Slowloris DoS CWE-400)
{ {
std::lock_guard<std::mutex> blk(_busy_mtx); std::lock_guard<std::mutex> blk(_busy_mtx);
if (_busy.count(fd)) return; // 已有在途任务,防同一 fd 重复投递(fd 复用竞态)
_busy.insert(fd); _busy.insert(fd);
} }
{ {
+4 -1
View File
@@ -69,4 +69,7 @@ Testing/
.cache/ .cache/
# Databases # Databases
*.db* *.db*
# unit test binary
test_libmemdb
.memdb.pid
+7 -5
View File
@@ -8,6 +8,9 @@ BUILD = build
TARGET = $(BUILD)/memdb TARGET = $(BUILD)/memdb
SRC = src/memdb.cpp SRC = src/memdb.cpp
HOST ?= 127.0.0.1
PORT ?= 8011
all: $(TARGET) all: $(TARGET)
$(BUILD): $(BUILD):
@@ -37,13 +40,12 @@ test-libmemdb: $(TEST_LIBMEMDB)
test-memdb: $(TARGET) test-memdb: $(TARGET)
@echo "Starting memdb server..." @echo "Starting memdb server..."
./$(TARGET) --host $(HOST) --port $(PORT) --workers 2 --max-time 10 & @./$(TARGET) --host $(HOST) --port $(PORT) --workers 2 --max-time 10 >/dev/null 2>&1 & echo $$! > .memdb.pid
@sleep 1 @sleep 1
-python3 test_memdb.py $(HOST) $(PORT); EXIT=$$? @python3 test_memdb.py $(HOST) $(PORT); EXIT=$$?; \
@kill %1 2>/dev/null; wait %1 2>/dev/null kill $$(cat .memdb.pid) 2>/dev/null; rm -f .memdb.pid; exit $$EXIT
@exit $$EXIT
clean: clean:
rm -f $(TARGET) $(TEST_LIBMEMDB) rm -f $(TARGET) $(TEST_LIBMEMDB) .memdb.pid
.PHONY: all clean test test-libmemdb test-memdb .PHONY: all clean test test-libmemdb test-memdb
+2 -1
View File
@@ -21,7 +21,7 @@ Memdb<std::string, std::string> (libmemdb 模板:LRU 链表 + 哈希索
| `update` | `key`, `value`, `refresh_ttl?` | `true/false` | 更新值,可选刷新 TTL | | `update` | `key`, `value`, `refresh_ttl?` | `true/false` | 更新值,可选刷新 TTL |
| `exists` | `key` | `true/false` | 键是否存在 | | `exists` | `key` | `true/false` | 键是否存在 |
| `size` | — | `int` | 当前条目数 | | `size` | — | `int` | 当前条目数 |
| `expire` | `key`, `value` | `true/false` | 更新值并重置 TTL(del + set) | | `expire` | `key`, `value` | `true/false` | 更新已有键的值并重置 TTL(键不存在返回 false) |
| `show` | — | `[{key,value},...]` | 导出所有条目 | | `show` | — | `[{key,value},...]` | 导出所有条目 |
## 配置 ## 配置
@@ -34,6 +34,7 @@ Memdb<std::string, std::string> (libmemdb 模板:LRU 链表 + 哈希索
| `listen_port` | `8011` | 监听端口 | | `listen_port` | `8011` | 监听端口 |
| `workers` | `4` | 线程池大小 | | `workers` | `4` | 线程池大小 |
| `max_time` | `600` | 默认 TTL(秒),10 分钟 | | `max_time` | `600` | 默认 TTL(秒),10 分钟 |
| `backlog` | `128` | listen() backlog(libmsg 默认 10,短连接突刺易丢连接) |
## 构建与运行 ## 构建与运行
+10 -7
View File
@@ -20,6 +20,7 @@ struct ServerConfig {
int listen_port = 8011; int listen_port = 8011;
int workers = 4; int workers = 4;
int max_time = 600; // 默认 TTL 10 分钟 int max_time = 600; // 默认 TTL 10 分钟
int backlog = 128; // 短连接并发突刺时防丢连接(libmsg 默认仅 10)
}; };
static std::atomic<bool> g_running{true}; static std::atomic<bool> g_running{true};
@@ -35,7 +36,7 @@ private:
pmc::net::RpcServer __rpc; pmc::net::RpcServer __rpc;
std::mutex __mtx; std::mutex __mtx;
static const std::string& _require_string(const json::object& req, const std::string& field) { static std::string _require_string(const json::object& req, const std::string& field) {
auto it = req.find(field); auto it = req.find(field);
if (it == req.end()) { if (it == req.end()) {
throw std::runtime_error("missing required field: " + field); throw std::runtime_error("missing required field: " + field);
@@ -43,7 +44,8 @@ private:
if (!it->value().is_string()) { if (!it->value().is_string()) {
throw std::runtime_error("field '" + field + "' must be a string"); throw std::runtime_error("field '" + field + "' must be a string");
} }
return it->value().as_string(); const auto& s = it->value().as_string();
return std::string(s.data(), s.size());
} }
public: public:
@@ -51,7 +53,7 @@ public:
: __cfg(cfg) : __cfg(cfg)
, __db(cfg.max_time) , __db(cfg.max_time)
, __rpc(cfg.listen_host, cfg.listen_port, , __rpc(cfg.listen_host, cfg.listen_port,
{.workers = cfg.workers}) {.workers = cfg.workers, .listen_backlog = cfg.backlog})
{ {
_register_routes(); _register_routes();
} }
@@ -130,13 +132,12 @@ private:
return result; return result;
}); });
// ---- expire: 更新值并重置 TTL(等价于 del + set)---- // ---- expire: 更新值并重置 TTL(仅已有键;等价于 update + refresh_ttl)----
__rpc.add_route("expire", [this](const json::object& req) -> json::value { __rpc.add_route("expire", [this](const json::object& req) -> json::value {
const auto& key = _require_string(req, "key"); const auto& key = _require_string(req, "key");
const auto& val = _require_string(req, "value"); const auto& val = _require_string(req, "value");
std::lock_guard<std::mutex> lock(__mtx); std::lock_guard<std::mutex> lock(__mtx);
__db.remove(key); return __db.update(key, val, true);
return __db.insert(key, val);
}); });
} }
}; };
@@ -156,7 +157,8 @@ int main(int argc, char** argv) {
("host", po::value<std::string>()->default_value("127.0.0.1"), "listen host") ("host", po::value<std::string>()->default_value("127.0.0.1"), "listen host")
("port", po::value<int>()->default_value(8011), "listen port") ("port", po::value<int>()->default_value(8011), "listen port")
("workers", po::value<int>()->default_value(4), "worker threads") ("workers", po::value<int>()->default_value(4), "worker threads")
("max-time", po::value<int>()->default_value(600), "default TTL (seconds)"); ("max-time", po::value<int>()->default_value(600), "default TTL (seconds)")
("backlog", po::value<int>()->default_value(128), "listen() backlog");
po::variables_map vm; po::variables_map vm;
po::store(po::parse_command_line(argc, argv, desc), vm); po::store(po::parse_command_line(argc, argv, desc), vm);
@@ -171,6 +173,7 @@ int main(int argc, char** argv) {
cfg.listen_port = vm["port"].as<int>(); cfg.listen_port = vm["port"].as<int>();
cfg.workers = vm["workers"].as<int>(); cfg.workers = vm["workers"].as<int>();
cfg.max_time = vm["max-time"].as<int>(); cfg.max_time = vm["max-time"].as<int>();
cfg.backlog = vm["backlog"].as<int>();
auto instance = vm["instance"].as<std::string>(); auto instance = vm["instance"].as<std::string>();
auto log_level = vm["log-level"].as<std::string>(); auto log_level = vm["log-level"].as<std::string>();
+50 -9
View File
@@ -10,6 +10,7 @@
#include <cassert> #include <cassert>
#include <chrono> #include <chrono>
#include <iostream> #include <iostream>
#include <optional>
#include <string> #include <string>
#include <thread> #include <thread>
#include <vector> #include <vector>
@@ -266,19 +267,57 @@ static void test_get_time_touch() {
PASS(); PASS();
} }
// ── copy assignment ───────────────────────────────────────────────────── // ── copy semantics ──────────────────────────────────────────────────────
static void test_copy_assignment() { static void test_copy_assignment() {
TEST("copy assignment"); TEST("copy assignment");
Memdb<std::string, std::string> db1;
db1.insert("a", "1");
Memdb<std::string, std::string> db2; Memdb<std::string, std::string> db2;
db2 = db1; {
CHECK(db2.search("a").value() == "1", "copied db should have same data"); Memdb<std::string, std::string> db1;
// verify independence db1.insert("a", "1");
db2.insert("b", "2"); db2 = db1;
CHECK(db1.size() == 1, "original should be unchanged"); CHECK(db2.search("a").value() == "1", "copied db should have same data");
CHECK(db2.size() == 2, "copy should be independent"); // verify independence
db2.insert("b", "2");
CHECK(db1.size() == 1, "original should be unchanged");
CHECK(db2.size() == 2, "copy should be independent");
}
// db1 destroyed — db2 must still work (indices must not dangle)
CHECK(db2.search("a").value() == "1", "copy must survive source destruction");
CHECK(db2.remove("a"), "copy remove should work after source destroyed");
CHECK(db2.size() == 1, "copy size after remove");
PASS();
}
static void test_copy_constructor() {
TEST("copy constructor");
std::optional<Memdb<std::string, std::string>> cpy;
{
Memdb<std::string, std::string> src;
src.insert("a", "1");
src.insert("b", "2");
cpy.emplace(src); // copy constructor
CHECK(cpy->size() == 2, "copy should have 2 entries");
CHECK(src.size() == 2, "source unchanged");
}
CHECK(cpy->search("a").value() == "1", "copy value a after source destroyed");
CHECK(cpy->search("b").value() == "2", "copy value b after source destroyed");
cpy->remove("a");
CHECK(cpy->size() == 1, "copy mutation after source destroyed");
// flush on copy must not touch freed memory
CHECK(cpy->search("b").value() == "2", "flush path safe on copy");
PASS();
}
static void test_update_refresh_extends_ttl() {
TEST("update with refresh extends TTL");
Memdb<std::string, std::string> db(1);
db.insert("a", "1");
std::this_thread::sleep_for(std::chrono::milliseconds(700));
CHECK(db.update("a", "2", true), "update with refresh should succeed");
std::this_thread::sleep_for(std::chrono::milliseconds(600));
CHECK(db.search("a").value() == "2",
"entry alive past original TTL thanks to refresh");
PASS(); PASS();
} }
@@ -344,6 +383,8 @@ int main() {
test_get_keys(); test_get_keys();
test_get_time_touch(); test_get_time_touch();
test_copy_assignment(); test_copy_assignment();
test_copy_constructor();
test_update_refresh_extends_ttl();
test_large_number_of_keys(); test_large_number_of_keys();
test_reinsert_after_remove(); test_reinsert_after_remove();
test_update_without_refresh_keeps_old_ttl(); test_update_without_refresh_keeps_old_ttl();
+6
View File
@@ -197,6 +197,12 @@ def test_expire(c, cnt):
ok, data = c.call("get", key="test:exp") ok, data = c.call("get", key="test:exp")
cnt.check(ok and data == "new", "value after expire", f"got {data}") cnt.check(ok and data == "new", "value after expire", f"got {data}")
ok, data = c.call("expire", key="test:exp:nonexistent", value="x")
cnt.check(ok and data is False, "expire nonexistent key returns false", f"got {data}")
ok, data = c.call("get", key="test:exp:nonexistent")
cnt.check(ok and data is None, "expire nonexistent does not insert", f"got {data}")
def test_missing_params(c, cnt): def test_missing_params(c, cnt):
ok, data = c.call("set", key="only_key") ok, data = c.call("set", key="only_key")
+5
View File
@@ -50,6 +50,11 @@ cat scripts/std.mt | ./_run.sh # 与 bot4cj 相同的管道用法
- checkpoint 后缀 `.hef` → Hailo NPU 后端,否则 → PyTorch 后端 - checkpoint 后缀 `.hef` → Hailo NPU 后端,否则 → PyTorch 后端
- `hz` 须 ≥ 推理 + 执行耗时(ONNX ~11ms / Hailo ~12ms,hz ≤ 50 安全) - `hz` 须 ≥ 推理 + 执行耗时(ONNX ~11ms / Hailo ~12ms,hz ≤ 50 安全)
- **异步推理路径**:predictor 提供 `predict_async`(Hailo NPU 后端,见
actpred `hailo_infer.py`)时自动启用——提交推理后**并行执行上一步动作**
(滞后一拍执行,动作与状态错位一拍,10Hz 下 = 100ms,对微动作步态影响
可忽略),隐藏真实 I2C 执行耗时(实测 HEF 步耗时 ~16ms → ~12ms);
PyTorch/ONNX 同步路径不变
- `@Action` 表头列数须 ≤ `action_dim`(默认 18),闭环中跳过 `spk.delay` 阻塞 - `@Action` 表头列数须 ≤ `action_dim`(默认 18),闭环中跳过 `spk.delay` 阻塞
- **帧源**:闭环从 camera 守护线程队列取最近 F 帧(无阻塞),`@Camera` 的 - **帧源**:闭环从 camera 守护线程队列取最近 F 帧(无阻塞),`@Camera` 的
`framerate` 建议 ≥ 2×`hz` 以保证帧新鲜度;`capacity` 应 ≥ F(帧窗长度)。 `framerate` 建议 ≥ 2×`hz` 以保证帧新鲜度;`capacity` 应 ≥ F(帧窗长度)。
+29 -9
View File
@@ -287,21 +287,41 @@ class RobotStruct:
dt = 1.0 / hz dt = 1.0 / hz
step = 0 step = 0
overrun = 0 overrun = 0
log.info("closed loop start: hz=%.1f F=%d H=%d action_dim=%d", # 异步模式:predictor 声明 predict_async 时启用——推理期间并行执行
hz, F, H, action_dim) # 上一步动作(滞后一拍执行,动作与状态错位一拍,10Hz 下 = 100ms,
# 对微动作步态影响可忽略),省去执行耗时(实测 HEF 步耗时 ~16ms → ~12ms)
use_async = hasattr(predictor, "predict_async")
prev_act = None
log.info("closed loop start: hz=%.1f F=%d H=%d action_dim=%d%s",
hz, F, H, action_dim,
" async" if use_async else "")
try: try:
while max_steps is None or step < max_steps: while max_steps is None or step < max_steps:
t0 = time.monotonic() t0 = time.monotonic()
frames.extend(self._loop_recent_frames(camera_var, F)) # 1 采样 frames.extend(self._loop_recent_frames(camera_var, F)) # 1 采样
imu = self._read_imus(imu_vars) imu = self._read_imus(imu_vars)
if use_imu: # 2 推理 if use_async: # 2 推理(异步)
act = predictor.predict(list(frames), np.array(history), if use_imu:
imu=self._pad_imu( job = predictor.predict_async(
imu, predictor.imu_dim)) list(frames), np.array(history),
imu=self._pad_imu(imu, predictor.imu_dim))
else:
job = predictor.predict_async(
list(frames), np.array(history))
if prev_act is not None:
self._execute_action_vec(prev_act, exec_cols) # 3 执行(∥ NPU)
act = np.asarray(job.result(timeout=10.0),
dtype=np.float32).reshape(-1)
prev_act = act.copy()
else: else:
act = predictor.predict(list(frames), np.array(history)) if use_imu:
act = np.asarray(act, dtype=np.float32).reshape(-1) act = predictor.predict(list(frames), np.array(history),
self._execute_action_vec(act, exec_cols) # 3 执行 imu=self._pad_imu(
imu, predictor.imu_dim))
else:
act = predictor.predict(list(frames), np.array(history))
act = np.asarray(act, dtype=np.float32).reshape(-1)
self._execute_action_vec(act, exec_cols) # 3 执行
history.append(act.copy()) # 4 回写 history.append(act.copy()) # 4 回写
if recorder is not None: if recorder is not None:
recorder.record( recorder.record(
+46
View File
@@ -104,6 +104,14 @@ def _make_robot(mt_text, mock_i2c):
return RobotStruct(io.StringIO(mt_text), dry_run=True) return RobotStruct(io.StringIO(mt_text), dry_run=True)
class AsyncMockPredictor(MockPredictor):
"""带 predict_async 的鸭子类型桩:异步返回与同步相同的可辨识动作"""
def predict_async(self, images, action_history):
act = self.predict(images, action_history)
return type("Job", (), {"result": lambda self, timeout=None: act})()
# ---------------- 冷启动 ---------------- # ---------------- 冷启动 ----------------
def test_cold_start_fills_windows(mock_i2c): def test_cold_start_fills_windows(mock_i2c):
@@ -230,6 +238,44 @@ def test_loop_pacing(mock_i2c):
assert elapsed >= 0.09 # 允许少量计时误差 assert elapsed >= 0.09 # 允许少量计时误差
# ---------------- 异步推理路径 ----------------
def test_loop_async_uses_predict_async(mock_i2c):
"""predictor 声明 predict_async 时闭环走异步路径(执行滞后一拍)"""
robot = _make_robot(LOOP_MT, mock_i2c)
pred = AsyncMockPredictor(frame_len=3, history_len=2, action_dim=4)
robot.active()
robot.run_loop(pred, hz=1000.0, max_steps=3)
robot.deactive()
# 3 步闭环 → 3 次异步推理(calls 由桩内 predict 记录)
assert len(pred.calls) == 3
# history 回写与同步一致:第 3 步历史末步 = 第 2 步预测动作 [2,3,4,5]
_, hist2 = pred.calls[2]
np.testing.assert_allclose(hist2[-1], [2, 3, 4, 5])
def test_loop_async_executes_prev_action(mock_i2c):
"""异步模式执行滞后一拍:第 k 步执行第 k-1 步预测的动作"""
robot = _make_robot(LOOP_MT, mock_i2c)
fd = robot.M["i2c-1"].play({})["fd"]
robot.active()
mock_i2c.journal.clear()
pred = AsyncMockPredictor(frame_len=3, history_len=2, action_dim=4)
robot.run_loop(pred, hz=1000.0, max_steps=3)
robot.deactive()
writes = [(e[2], e[3]) for e in mock_i2c.journal
if e[0] == "write_reg" and e[1] == fd]
# 预测序列:step0→[1,2,3,4], step1→[2,3,4,5], step2→[3,4,5,6]
# 执行序列(滞后一拍):step1 执行 [1,2,3,4],step2 执行 [2,3,4,5]
off = lambda deg: int((0.5 + 2 * deg / 180) * 4096 / 20) # noqa: E731
off_reg = lambda ch: 0x06 + 4 * ch + 2 # noqa: E731 —— LEDx_OFF_L
s1_vals = [v for r, v in writes if r == off_reg(0)] # s1 ch0
s2_vals = [v for r, v in writes if r == off_reg(1)] # s2 ch1
assert s1_vals == [off(1) & 0xFF, off(2) & 0xFF] # step0/step1 预测的 s1
assert s2_vals == [off(2) & 0xFF, off(3) & 0xFF] # step0/step1 预测的 s2
# ---------------- 帧源:守护线程队列 ---------------- # ---------------- 帧源:守护线程队列 ----------------
class _SeqGrabber: class _SeqGrabber: