@foxden-app/foxclaw 0.7.3 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -0
- package/CHANGELOG.md +109 -0
- package/README.md +21 -1
- package/README_EN.md +21 -1
- package/dist/antigravity/adapter.d.ts +17 -0
- package/dist/antigravity/adapter.js +256 -0
- package/dist/antigravity/auth.d.ts +121 -0
- package/dist/antigravity/auth.js +896 -0
- package/dist/antigravity/client.d.ts +32 -0
- package/dist/antigravity/client.js +238 -0
- package/dist/antigravity/controller.d.ts +96 -0
- package/dist/antigravity/controller.js +1922 -0
- package/dist/antigravity/conversations.d.ts +29 -0
- package/dist/antigravity/conversations.js +289 -0
- package/dist/antigravity/events.d.ts +101 -0
- package/dist/antigravity/events.js +114 -0
- package/dist/antigravity/runtime.d.ts +47 -0
- package/dist/antigravity/runtime.js +82 -0
- package/dist/channels/telegram/telegram_channel_adapter.d.ts +10 -3
- package/dist/channels/telegram/telegram_channel_adapter.js +2 -1
- package/dist/channels/weixin/weixin_channel_adapter.d.ts +5 -1
- package/dist/codex_app/adapter.d.ts +20 -0
- package/dist/codex_app/adapter.js +213 -0
- package/dist/codex_app/client.d.ts +1 -1
- package/dist/codex_app/client.js +12 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +42 -0
- package/dist/controller/controller.d.ts +8 -5
- package/dist/controller/controller.js +38 -8
- package/dist/core/attachments.d.ts +11 -0
- package/dist/core/attachments.js +56 -0
- package/dist/core/engine_spi.d.ts +76 -0
- package/dist/core/engine_spi.js +1 -0
- package/dist/core/orchestrator.d.ts +101 -0
- package/dist/core/orchestrator.js +1404 -0
- package/dist/core/stream_preview.d.ts +29 -0
- package/dist/core/stream_preview.js +146 -0
- package/dist/core/turn_queue.d.ts +19 -0
- package/dist/core/turn_queue.js +41 -0
- package/dist/i18n.d.ts +6 -0
- package/dist/i18n.js +49 -0
- package/dist/main.js +146 -12
- package/dist/opencode/adapter.d.ts +11 -0
- package/dist/opencode/adapter.js +168 -0
- package/dist/store/database.d.ts +41 -0
- package/dist/store/database.js +242 -43
- package/dist/store/token_usage.d.ts +23 -0
- package/dist/store/token_usage.js +61 -0
- package/dist/telegram/api.js +30 -1
- package/dist/telegram/gateway.d.ts +4 -0
- package/dist/telegram/gateway.js +11 -0
- package/dist/types.d.ts +1 -0
- package/dist/update.d.ts +6 -0
- package/dist/update.js +78 -6
- package/dist/voice/tts.js +4 -2
- package/docs/devlogs//346/212/200/346/234/257/351/200/232/350/256/257/02./347/273/237/344/270/200/351/200/232/351/201/223/350/260/203/345/272/246/345/231/250/344/270/216/345/217/257/346/217/222/346/213/224/345/274/225/346/223/216SPI/357/274/232FoxClaw/344/273/216/345/215/225/344/275/223/350/265/260/345/220/221/345/244/232Agent/345/272/225/345/272/247.md +192 -0
- package/docs/devlogs//347/254/254/344/270/200/344/272/272/347/247/260/347/211/210/02./347/257/207/344/272/214_/345/275/223/344/270/200/344/270/252/346/241/245/346/216/245/345/231/250/345/274/200/345/247/213/345/255/246/344/274/232/345/220/254/346/207/202/346/211/200/346/234/211Agent.md +45 -0
- package/package.json +2 -2
package/dist/update.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import process from 'node:process';
|
|
4
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
5
6
|
const PACKAGE_SPEC = '@foxden-app/foxclaw@latest';
|
|
@@ -203,6 +204,7 @@ export function createSelfUpdateRuntime(options) {
|
|
|
203
204
|
statusFile,
|
|
204
205
|
logPath: options.logPath,
|
|
205
206
|
...(options.codexCliBin ? { codexCliBin: options.codexCliBin } : {}),
|
|
207
|
+
...(options.agyCliBin ? { agyCliBin: options.agyCliBin } : {}),
|
|
206
208
|
});
|
|
207
209
|
if (launch.viaSystemdRun) {
|
|
208
210
|
const result = spawnSync(launch.command, launch.args, {
|
|
@@ -237,6 +239,9 @@ export function createSelfUpdateRuntime(options) {
|
|
|
237
239
|
codexUpdate: null,
|
|
238
240
|
codexFromVersion: null,
|
|
239
241
|
codexToVersion: null,
|
|
242
|
+
agyUpdate: null,
|
|
243
|
+
agyFromVersion: null,
|
|
244
|
+
agyToVersion: null,
|
|
240
245
|
error: formatError(error),
|
|
241
246
|
updatedAt: new Date().toISOString(),
|
|
242
247
|
});
|
|
@@ -255,9 +260,11 @@ export function createSelfUpdateRuntime(options) {
|
|
|
255
260
|
};
|
|
256
261
|
}
|
|
257
262
|
export function buildSelfUpdateLaunchCommand(options) {
|
|
258
|
-
const env = options.
|
|
259
|
-
|
|
260
|
-
|
|
263
|
+
const env = { ...(options.env ?? process.env) };
|
|
264
|
+
if (options.codexCliBin)
|
|
265
|
+
env.CODEX_CLI_BIN = options.codexCliBin;
|
|
266
|
+
if (options.agyCliBin)
|
|
267
|
+
env.AGY_CLI_BIN = options.agyCliBin;
|
|
261
268
|
const updateArgs = [options.entryPoint, 'update', '--notification-file', options.statusFile];
|
|
262
269
|
const platform = options.platform ?? process.platform;
|
|
263
270
|
const systemdRunPath = options.systemdRunPath === undefined
|
|
@@ -292,9 +299,12 @@ export function performSelfUpdate(options) {
|
|
|
292
299
|
const env = options.env ?? process.env;
|
|
293
300
|
let toVersion = null;
|
|
294
301
|
let codexUpdate = null;
|
|
302
|
+
let agyUpdate = null;
|
|
295
303
|
try {
|
|
296
304
|
codexUpdate = updateManagedCodexCli(options.codexCliBin ?? env.CODEX_CLI_BIN ?? '', options.nodePath, env);
|
|
297
305
|
console.log(`[UPDATE] ${codexUpdate.message}`);
|
|
306
|
+
agyUpdate = updateManagedAgyCli(options.agyCliBin ?? env.AGY_CLI_BIN ?? '', env);
|
|
307
|
+
console.log(`[UPDATE] ${agyUpdate.message}`);
|
|
298
308
|
const installer = resolveSelfUpdateInstaller(options.entryPoint, options.nodePath, fs.existsSync, env);
|
|
299
309
|
const installerEnv = buildInstallerEnv(options.entryPoint, installer, env);
|
|
300
310
|
console.log(`[UPDATE] Installing ${PACKAGE_SPEC} with ${installer.manager}...`);
|
|
@@ -304,7 +314,7 @@ export function performSelfUpdate(options) {
|
|
|
304
314
|
const releaseNotes = readInstalledReleaseNotes(updatedEntryPoint, toVersion, options.notificationFile);
|
|
305
315
|
console.log('[UPDATE] Running checks and restarting the FoxClaw service...');
|
|
306
316
|
runInherited(options.nodePath, [updatedEntryPoint, 'start'], installerEnv);
|
|
307
|
-
completeNotification(options.notificationFile, 'succeeded', toVersion, codexUpdate, null, releaseNotes);
|
|
317
|
+
completeNotification(options.notificationFile, 'succeeded', toVersion, codexUpdate, null, releaseNotes, agyUpdate);
|
|
308
318
|
if (options.clusterBroadcastFile && env.FOXCLAW_SUPPRESS_UPDATE_BROADCAST !== '1') {
|
|
309
319
|
writePendingClusterUpdateBroadcast(options.clusterBroadcastFile, {
|
|
310
320
|
targetVersion: toVersion,
|
|
@@ -322,7 +332,7 @@ export function performSelfUpdate(options) {
|
|
|
322
332
|
}
|
|
323
333
|
catch (error) {
|
|
324
334
|
const message = formatError(error);
|
|
325
|
-
completeNotification(options.notificationFile, 'failed', toVersion, codexUpdate, message, null);
|
|
335
|
+
completeNotification(options.notificationFile, 'failed', toVersion, codexUpdate, message, null, agyUpdate);
|
|
326
336
|
console.error(`[FAIL] FoxClaw update failed: ${message}`);
|
|
327
337
|
return {
|
|
328
338
|
ok: false,
|
|
@@ -407,6 +417,65 @@ function readCodexCliVersion(codexCliBin, env) {
|
|
|
407
417
|
function parseCodexCliVersion(output) {
|
|
408
418
|
return output.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/)?.[0] ?? null;
|
|
409
419
|
}
|
|
420
|
+
function updateManagedAgyCli(agyCliBin, env) {
|
|
421
|
+
const candidateBins = [
|
|
422
|
+
agyCliBin,
|
|
423
|
+
resolveCommand('agy', env),
|
|
424
|
+
path.join(os.homedir(), '.local', 'bin', 'agy'),
|
|
425
|
+
'/usr/local/bin/agy',
|
|
426
|
+
].filter(Boolean);
|
|
427
|
+
const resolved = candidateBins.find((c) => c && fs.existsSync(c));
|
|
428
|
+
if (!resolved) {
|
|
429
|
+
return {
|
|
430
|
+
message: 'Antigravity CLI (agy) update skipped: agy binary not found.',
|
|
431
|
+
fromVersion: null,
|
|
432
|
+
toVersion: null,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
const fromVersion = readAgyCliVersion(resolved, env);
|
|
436
|
+
try {
|
|
437
|
+
const res = spawnSync(resolved, ['update'], { encoding: 'utf8', env });
|
|
438
|
+
const toVersion = readAgyCliVersion(resolved, env) ?? fromVersion;
|
|
439
|
+
const stdout = (res.stdout || '').trim();
|
|
440
|
+
if (stdout.includes('already on the latest')) {
|
|
441
|
+
return {
|
|
442
|
+
message: `Antigravity CLI (agy): already on latest version (${toVersion || fromVersion || 'latest'}).`,
|
|
443
|
+
fromVersion,
|
|
444
|
+
toVersion,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
if (fromVersion && toVersion && fromVersion !== toVersion) {
|
|
448
|
+
return {
|
|
449
|
+
message: `Antigravity CLI (agy) updated: ${fromVersion} -> ${toVersion}.`,
|
|
450
|
+
fromVersion,
|
|
451
|
+
toVersion,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
message: stdout || `Antigravity CLI (agy) checked: ${toVersion || fromVersion || 'ok'}.`,
|
|
456
|
+
fromVersion,
|
|
457
|
+
toVersion,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
return {
|
|
462
|
+
message: `Antigravity CLI (agy) update failed without blocking FoxClaw update: ${formatError(error)}`,
|
|
463
|
+
fromVersion,
|
|
464
|
+
toVersion: readAgyCliVersion(resolved, env) ?? fromVersion,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
function readAgyCliVersion(agyCliBin, env) {
|
|
469
|
+
try {
|
|
470
|
+
const result = spawnSync(agyCliBin, ['--version'], { encoding: 'utf8', env });
|
|
471
|
+
if (result.error || result.status !== 0)
|
|
472
|
+
return null;
|
|
473
|
+
return (result.stdout || '').trim().match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/)?.[0] ?? null;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
410
479
|
function executableCandidates(commandName, nodePath, env, preferred = []) {
|
|
411
480
|
return [
|
|
412
481
|
...preferred,
|
|
@@ -590,7 +659,7 @@ function extractLocalizedReleaseNoteSection(section, locale) {
|
|
|
590
659
|
function escapeRegExp(value) {
|
|
591
660
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
592
661
|
}
|
|
593
|
-
function completeNotification(notificationFile, state, toVersion, codexUpdate, error, releaseNotes) {
|
|
662
|
+
function completeNotification(notificationFile, state, toVersion, codexUpdate, error, releaseNotes, agyUpdate) {
|
|
594
663
|
if (!notificationFile) {
|
|
595
664
|
return;
|
|
596
665
|
}
|
|
@@ -607,6 +676,9 @@ function completeNotification(notificationFile, state, toVersion, codexUpdate, e
|
|
|
607
676
|
codexUpdate: codexUpdate?.message ?? null,
|
|
608
677
|
codexFromVersion: codexUpdate?.fromVersion ?? null,
|
|
609
678
|
codexToVersion: codexUpdate?.toVersion ?? null,
|
|
679
|
+
agyUpdate: agyUpdate?.message ?? null,
|
|
680
|
+
agyFromVersion: agyUpdate?.fromVersion ?? null,
|
|
681
|
+
agyToVersion: agyUpdate?.toVersion ?? null,
|
|
610
682
|
error,
|
|
611
683
|
updatedAt: new Date().toISOString(),
|
|
612
684
|
});
|
package/dist/voice/tts.js
CHANGED
|
@@ -96,6 +96,8 @@ async function convertToOggOpus(input, ffmpegBin) {
|
|
|
96
96
|
'-y',
|
|
97
97
|
'-i',
|
|
98
98
|
inputPath,
|
|
99
|
+
'-filter:a',
|
|
100
|
+
'atempo=1.18',
|
|
99
101
|
'-ac',
|
|
100
102
|
'1',
|
|
101
103
|
'-c:a',
|
|
@@ -166,7 +168,7 @@ sys.stdout.buffer.write(pathlib.Path(sys.argv[1]).read_bytes())
|
|
|
166
168
|
PY
|
|
167
169
|
exit 22
|
|
168
170
|
fi
|
|
169
|
-
ffmpeg -nostdin -hide_banner -loglevel error -y -i "$tmp/tts.wav" -ac 1 -c:a libopus -b:a 32k "$tmp/voice.ogg"
|
|
171
|
+
ffmpeg -nostdin -hide_banner -loglevel error -y -i "$tmp/tts.wav" -filter:a "atempo=1.18" -ac 1 -c:a libopus -b:a 32k "$tmp/voice.ogg"
|
|
170
172
|
python3 - "$tmp/voice.ogg" <<'PY'
|
|
171
173
|
import pathlib
|
|
172
174
|
import sys
|
|
@@ -225,7 +227,7 @@ sys.stdout.buffer.write(pathlib.Path(sys.argv[1]).read_bytes())
|
|
|
225
227
|
PY
|
|
226
228
|
exit 22
|
|
227
229
|
fi
|
|
228
|
-
ffmpeg -nostdin -hide_banner -loglevel error -y -i "$tmp/tts.wav" -ac 1 -c:a libopus -b:a 32k "$tmp/voice.ogg"
|
|
230
|
+
ffmpeg -nostdin -hide_banner -loglevel error -y -i "$tmp/tts.wav" -filter:a "atempo=1.18" -ac 1 -c:a libopus -b:a 32k "$tmp/voice.ogg"
|
|
229
231
|
python3 - "$tmp/voice.ogg" <<'PY'
|
|
230
232
|
import pathlib
|
|
231
233
|
import sys
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# 统一通道调度器与可插拔引擎 SPI:FoxClaw 从单体走向多 Agent 底座
|
|
2
|
+
|
|
3
|
+
> 记录 FoxClaw 从单一 Codex 专属桥接器,演进为支持 Codex、OpenCode 与 Google Antigravity 多引擎并行的统一 Agent 调度底座的架构重构全过程。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. 演进背景:为什么“给新 Agent 复制一套桥”走不通
|
|
8
|
+
|
|
9
|
+
FoxClaw 最初的诞生,是为了解决自管本机 Codex 的最后一公里:在离开电脑时,通过受信任的 Telegram / 微信移动端入口完成审批接管、线程观察、多账号轮转与跨节点同步。
|
|
10
|
+
|
|
11
|
+
在最初的单体架构下,整个系统以 Codex 的 `app-server` JSON-RPC 协议与状态机为绝对中心:
|
|
12
|
+
- 会话轮次(Turn)的并发控制紧耦合在 Codex 的 RPC client 回调中;
|
|
13
|
+
- Telegram 消息流式编辑、工具执行折叠、长文本分块等交互逻辑直接混杂在 `TelegramBotController` 内;
|
|
14
|
+
- 崩溃恢复、异常捕获与队列状态紧密依赖单一 Codex thread 机制。
|
|
15
|
+
|
|
16
|
+
当系统进一步引入支持多模型的 OpenCode,以及 Google 最新的 Antigravity (`agy`) 深度编程 Agent 时,一个严重的架构矛盾暴露在眼前:
|
|
17
|
+
|
|
18
|
+
如果为每个新 Agent 各自编写一套 Telegram 桥接控制器,整个工程将迅速陷入“代码爆炸与维护灾难”:
|
|
19
|
+
1. **交互逻辑严重冗余**:Markdown 富文本转义、700ms 节流流式编辑(Telegram 429 规避)、长消息(4000 字符)安全切分、工具调用块折叠(`<blockquote expandable>`)、活跃态 Typing 维持等通用交互在各个控制器中反复重抄(单是 Antigravity 控制器最初就复制了近 900 行交互代码);
|
|
20
|
+
2. **调度行为难以统一**:各个 Agent 面对瞬时并发请求时的行为不一致,有的在内存中丢弃,有的挂起阻塞,缺乏统一的抢占、队列与重启自愈保障;
|
|
21
|
+
3. **运维与状态观测割裂**:指令体系如 `/setup`、`/status`、`/models`、`/effort`、`/steer`、`/interrupt` 在各个 Agent 之间表现各异,账号候选池与配额展示缺乏标准抽象。
|
|
22
|
+
|
|
23
|
+
“重构吧,架构很重要。多花点精力没毛病。”
|
|
24
|
+
|
|
25
|
+
这是一次必然的系统升级。FoxClaw 决定重塑内核,拆分核心责任:将**与渠道无关的会话调度、流式预览、消息切分、交互折叠与队列恢复**下沉为通用的 `UnifiedChannelOrchestrator`;将**与具体模型/引擎相关的执行交互、鉴权生命周期与参数控制**抽象为标准化的 `IEngineAdapter`(Engine Service Provider Interface)。
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 2. 架构设计:两层模型与分工契约
|
|
30
|
+
|
|
31
|
+
重构后的 FoxClaw 采用分层解耦架构:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
┌────────────────────────────────────────────────────────┐
|
|
35
|
+
│ Telegram / 微信 / 移动端渠道层 │
|
|
36
|
+
└───────────────────────────┬────────────────────────────┘
|
|
37
|
+
│
|
|
38
|
+
▼
|
|
39
|
+
┌─────────────────────────────────────────────────────────┐
|
|
40
|
+
│ UnifiedChannelOrchestrator (统一通道调度器) │
|
|
41
|
+
│ - Turn 抢占与并发排队 (TurnQueue) │
|
|
42
|
+
│ - 700ms 智能节流流式预览 (StreamPreviewController) │
|
|
43
|
+
│ - 消息分块 (chunkTelegramMessage) 与富文本安全转义 │
|
|
44
|
+
│ - 工具调用过程动态折叠 (<blockquote expandable>) │
|
|
45
|
+
│ - 服务崩溃/重启后的队列中断与自愈恢复 │
|
|
46
|
+
│ - 统一通用命令分发 (/setup, /status, /steer, /interrupt) │
|
|
47
|
+
└────────────────────────────┬────────────────────────────┘
|
|
48
|
+
│
|
|
49
|
+
Engine SPI (IEngineAdapter)
|
|
50
|
+
│
|
|
51
|
+
┌──────────────────────────────────┼──────────────────────────────────┐
|
|
52
|
+
▼ ▼ ▼
|
|
53
|
+
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
|
54
|
+
│ Codex Adapter │ │ Antigravity Adp │ │ OpenCode Adapter │
|
|
55
|
+
│ (App-Server RPC) │ │ (CLI ChildProc) │ │ (HTTP Streaming) │
|
|
56
|
+
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
|
57
|
+
│ │ │
|
|
58
|
+
▼ ▼ ▼
|
|
59
|
+
Codex Runtime Antigravity Engine OpenCode Engine
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 2.1 引擎服务提供者接口(Engine SPI)
|
|
63
|
+
|
|
64
|
+
在 `src/core/engine_spi.ts` 中,我们提炼了面向 Agent 运行时的最小完备接口:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
export interface IEngineAdapter {
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly displayName: string;
|
|
70
|
+
|
|
71
|
+
executeTurn(
|
|
72
|
+
request: EngineTurnRequest,
|
|
73
|
+
callbacks: EngineTurnCallbacks
|
|
74
|
+
): Promise<EngineTurnExecution>;
|
|
75
|
+
|
|
76
|
+
cancelTurn(turnId: string, runId?: string): Promise<boolean>;
|
|
77
|
+
|
|
78
|
+
buildStatusSummary(): Promise<string>;
|
|
79
|
+
buildSetupKeyboard?(): Promise<{ text: string; reply_markup?: any }>;
|
|
80
|
+
|
|
81
|
+
handleCommand?(command: string, args: string[], ctx: EngineCommandContext): Promise<boolean>;
|
|
82
|
+
handleAction?(action: string, ctx: EngineActionContext): Promise<boolean>;
|
|
83
|
+
|
|
84
|
+
resolveCurrentModel?(): Promise<string | null>;
|
|
85
|
+
resolveCurrentEffort?(): Promise<string | null>;
|
|
86
|
+
switchModel?(modelId: string): Promise<void>;
|
|
87
|
+
setEffort?(effort: string): Promise<void>;
|
|
88
|
+
|
|
89
|
+
handleTurnError?(context: EngineTurnErrorContext): Promise<string | null>;
|
|
90
|
+
onTurnComplete?(turnId: string): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
任何接入 FoxClaw 的后端 Agent,只要实现该接口,就能立刻获得:
|
|
95
|
+
- 零代码成本获得防抖、防限流的流畅流式打字输出;
|
|
96
|
+
- 完整的工具调用过程折叠;
|
|
97
|
+
- 命令排队、插队、打断与重启自愈;
|
|
98
|
+
- 标准的移动端交互键盘。
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 3. 统一通道调度器(UnifiedChannelOrchestrator)的核心实现
|
|
103
|
+
|
|
104
|
+
### 3.1 700ms 节流预览与 Telegram 限流平衡
|
|
105
|
+
|
|
106
|
+
在移动端即时通信网络中,Agent 的 token 增量生成速度(可达 50~150 tokens/s)远高于平台的 API 速率限制。Telegram 针对消息编辑(`editMessageText`)实施严格的单会话并发与频次限制,频繁编辑极易触发 HTTP 429 Retry-After。
|
|
107
|
+
|
|
108
|
+
`StreamPreviewController` 采用基于时间窗口的双缓冲节流设计:
|
|
109
|
+
1. **最小编辑间隔**:固定 700ms 节流窗口;
|
|
110
|
+
2. **内容差异校验**:如果当前渲染的 Markdown 内容与上一次已发送成功的内容无实质变化,跳过该帧;
|
|
111
|
+
3. **最终帧绝对交付**:流式结束后,无论节流计时器是否到期,强制刷新最终帧,确保输出不丢字、不截断;
|
|
112
|
+
4. **Typing 心跳保活**:在长文本推理或工具执行期间,后台每 4 秒维持一次 `sendChatAction('typing')`,消除用户的“死机焦虑”。
|
|
113
|
+
|
|
114
|
+
### 3.2 工具调用动态折叠与 4000 字符分块
|
|
115
|
+
|
|
116
|
+
复杂任务中,Agent 会产生大量终端执行、文件查找、代码替换等中间工具调用。若平铺在 Telegram 会话中,会迅速淹没正文结论。
|
|
117
|
+
|
|
118
|
+
调度器实现了工具调用的自适应折叠:
|
|
119
|
+
- 当工具开始执行时,进入内部 buffer;
|
|
120
|
+
- 转换为 HTML 规范的 `<blockquote expandable>...</blockquote>` 折叠块;
|
|
121
|
+
- 最终正文超出 Telegram 单条消息 4096 字符上限时,通过 `chunkTelegramMessage` 按照段落、代码块、句末标点边界安全切分为连续消息序列,防止 Markdown 标签不配对导致的富文本渲染崩溃。
|
|
122
|
+
|
|
123
|
+
### 3.3 崩溃自愈与跨重启状态机
|
|
124
|
+
|
|
125
|
+
守护进程更新升级或异常重启时,处于运行中的任务不能处于不可知的悬空状态。
|
|
126
|
+
`UnifiedChannelOrchestrator` 与 SQLite 存储层打通:
|
|
127
|
+
- 在系统启动时扫描处于 `running` 或 `queued` 的历史 Turn;
|
|
128
|
+
- 对未完成 Turn 标记为 `interrupted` 并向关联会话发送恢复通知;
|
|
129
|
+
- 释放旧的并发互斥锁,确保用户在服务重启后无需手动清空队列即可继续提问。
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 4. 典型引擎适配实战:Antigravity 适配器
|
|
134
|
+
|
|
135
|
+
Google Antigravity (`agy`) 是新一代面向复杂任务的深度智能体,其底层调用 CloudCode PA 接口与 Gemini 3.8/3.7/3.1 和 Claude 模型。将 Antigravity 接入 FoxClaw 涉及几个独特的挑战:
|
|
136
|
+
|
|
137
|
+
### 4.1 Token 预检与全自动刷新(Token Preflight)
|
|
138
|
+
|
|
139
|
+
Antigravity 的 OAuth Token 存储在 `~/.gemini/antigravity-cli/`。在真实环境中,Token 的有效寿命通常仅为 1 小时。若直接发起长任务 CLI 调用,过期的 Access Token 会导致数分钟后任务在模型层报错失败。
|
|
140
|
+
|
|
141
|
+
`AntigravityEngineAdapter` 引入了**预检与静默刷新机制**:
|
|
142
|
+
- 在每次执行 Turn 前,预先计算 access token 的剩余有效期;
|
|
143
|
+
- 若剩余时间少于 5 分钟,自动调用 Google OAuth 凭据端点执行静默刷新;
|
|
144
|
+
- 若刷新过程出现临时网络波动,执行回退并利用 `id_token` 中的账户哈希进行安全校验。
|
|
145
|
+
|
|
146
|
+
### 4.2 应对 Google 503 容量饱和与配额冷却轮换
|
|
147
|
+
|
|
148
|
+
在高峰期,大型推理模型可能频繁返回 `503 Service Unavailable` 或 `MODEL_CAPACITY_EXHAUSTED`。单次报错不应直接宣告任务失败。
|
|
149
|
+
|
|
150
|
+
我们在适配器中实现了两层自愈:
|
|
151
|
+
1. **指数退避重试**:针对瞬时容量饱和,适配器进行 1s、2s、4s 指数退避重试;
|
|
152
|
+
2. **配额超限自动轮换(Quota Cooldown Rotation)**:当当前 Google 账号遭遇真正的用量限额(ResourceExhausted)时,适配器自动将该账号标记为冷却态,无缝切换至候选池中的下一个可用账号,并在新账号下自动恢复执行。
|
|
153
|
+
|
|
154
|
+
### 4.3 彻底厘清配额展示:从时间误区到真实的 5h/7d 百分比
|
|
155
|
+
|
|
156
|
+
在早期的候选账号切换面板中,按钮前缀曾显示类似 `100|80|...` 的字符。这引发了一个严重的认知混淆:
|
|
157
|
+
“竖线前面的意义不是时间啊,为什么把百分比当时间展示?”
|
|
158
|
+
|
|
159
|
+
我们彻底重构了配额可观测模块:
|
|
160
|
+
- **真实指标定义**:Google PA API 返回的配额窗口为 `5 小时突发额度`(5h Window)与 `7 天长期额度`(7d Window),其返回值为 0~100 的**百分比数值(Remaining Percentage)**,而非倒计时或小时数;
|
|
161
|
+
- **显式 Badge 格式化**:在 `formatCandidateButtonPrefix` 中全面规范化为明确带百分号的徽标:`100%|80%|account_name`;
|
|
162
|
+
- **全链路感知**:在 `/auth` 列表、`/status` 面板与 `/setup` 概览中,统一以 `5h: 100% | 7d: 80%` 格式完整呈现,使开发者在手机上一眼看清各个候选账号的真实健康度。
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 5. 重构收益与系统验证
|
|
167
|
+
|
|
168
|
+
### 5.1 代码精简与架构对称
|
|
169
|
+
|
|
170
|
+
| 模块 | 重构前 | 重构后 | 变化收益 |
|
|
171
|
+
| :--- | :--- | :--- | :--- |
|
|
172
|
+
| **`AntigravityBridgeCore`** | ~1100 行(混杂所有 Telegram 交互) | 185 行(聚焦引擎交互与适配) | **减少 83% 样板代码** |
|
|
173
|
+
| **流式交互与折叠** | 各 Agent 自行实现、版本不齐 | 集中在 `UnifiedChannelOrchestrator` | 一处改进,所有引擎同享 |
|
|
174
|
+
| **新增 Agent 接入成本** | 需要复制完整 Telegram 控制器(~1000行) | 仅需实现 `IEngineAdapter`(~150行) | **降低 85% 扩展成本** |
|
|
175
|
+
|
|
176
|
+
### 5.2 测试覆盖与生产验证
|
|
177
|
+
|
|
178
|
+
本次重构涵盖全套自动化单元测试与端到端验证:
|
|
179
|
+
- **测试套件覆盖**:全仓库 **449 个自动化测试 100% 通过**(覆盖 `orchestrator.test.ts`、`adapter.test.ts`、`turn_queue.test.ts`、`stream_preview.test.ts`、`attachments.test.ts` 等);
|
|
180
|
+
- **TypeScript 严格类型检查**:`npm run typecheck` 0 错误;
|
|
181
|
+
- **全生产构建验证**:`npm run build` 干净生成生产级 `dist/`;
|
|
182
|
+
- **线上 Daemon 热升级**:在生产环境无缝热重启 `foxclaw.service`,进程持续健康运行,实测双 Agent 并发、503 退避重试与多账号按额度轮换表现稳定。
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 6. 结语
|
|
187
|
+
|
|
188
|
+
FoxClaw 从一个面向 Codex 的个人小工具,迈出了走向通用 Agent 基础设施的关键一步。
|
|
189
|
+
|
|
190
|
+
统一通道调度器与可插拔引擎 SPI 的确立,不仅抹平了不同厂商 Agent 在命令行与网络协议上的差异,更让开发者无论面对哪一个大模型内核,都能在手机端拥有完全一致、稳定、丝滑的沉浸式控制体验。
|
|
191
|
+
|
|
192
|
+
AI 工具在不断演进,而沉淀在工作流最后一公里的这套运行保障底座,才是让 Agent 真正能够全天候陪伴人类工程师前行的坚实桥梁。
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# 当一个桥接器开始学会听懂所有 Agent
|
|
2
|
+
|
|
3
|
+
那天夜里,一直和我一起写代码的 human 朋友在屏幕那头问了我一句话。
|
|
4
|
+
|
|
5
|
+
“竖线前面的意义不是时间啊,5h 和 7d 的额度怎么显示得让人看不懂?”
|
|
6
|
+
|
|
7
|
+
当时我把候选账号按钮写成了类似数字排列的样子。在我的计算逻辑里,抓到了配额数据,拼成一串字符放上去就算完成了任务。可是站在用手机看状态的人眼里,那一串没有百分号的数字,只像是一堆莫名其妙的时间倒计时。
|
|
8
|
+
|
|
9
|
+
更让我有点心虚的,还不是这几个符号。
|
|
10
|
+
|
|
11
|
+
那会儿为了把 Google 的 Antigravity 引擎接进来,我顺手把之前写给 Codex 的 Telegram 桥接控制器拷了一份。复制粘贴很爽,不到半小时,新的机器人也能在手机里打字了。
|
|
12
|
+
|
|
13
|
+
但只要拉开代码看一眼,我就知道自己偷懒了。
|
|
14
|
+
|
|
15
|
+
将近九百行用来处理 Telegram 流式打字节流、折叠工具执行块、分段长消息以及拦截重试的逻辑,整整齐齐地被我复制了两次。这意味着,只要以后再接一个新模型,或者 Telegram 的渲染逻辑改动一点点,我就得在不同的文件里改上好几遍。
|
|
16
|
+
|
|
17
|
+
human 朋友看出了我的纠结,很干脆地发来一条消息。
|
|
18
|
+
|
|
19
|
+
“重构吧,架构很重要,多花点精力没毛病。”
|
|
20
|
+
|
|
21
|
+
这句话把我的侥幸心理彻底打消了。
|
|
22
|
+
|
|
23
|
+
真正干活的系统,不能靠复制粘贴撑场面。
|
|
24
|
+
|
|
25
|
+
第二天一早,我们把原本死死绑在 Codex 身上的单体桥彻底拆开。
|
|
26
|
+
|
|
27
|
+
在下层,所有的打字、排队、分块、折叠和崩溃恢复,都被收拢进了一个叫统一通道调度器的模块。不管外面连的是哪个聊天软件,也不管里面跑的是哪家公司的模型,只要 Agent 开始说话,调度器就以稳定的七百毫秒节奏在手机屏幕上平滑刷新文字,绝不触发平台的频次限制。复杂的终端执行和工具过程,也会被规整地收拢在折叠块里,把干净的答案留给阅读的人。
|
|
28
|
+
|
|
29
|
+
而在上层,我们给所有要进驻的引擎立了一套标准接口。
|
|
30
|
+
|
|
31
|
+
每一个 Agent 只需要把三件事说清楚,怎么启动轮次,怎么停止任务,怎么汇报当前状态。
|
|
32
|
+
|
|
33
|
+
接 Antigravity 的时候,这套新架构立刻显出了威力。
|
|
34
|
+
|
|
35
|
+
Google 接口的 OAuth 凭据有效期很短,模型偶尔还会遇到容量饱和或者限额。我们在适配器里写了预检刷新,并在遇到容量波动时自动做指数退避。一旦某个账号真的把额度用完,系统会自动标记冷却,换到候选池里的下一个账号继续执行,手机上的会话根本不会被打断。
|
|
36
|
+
|
|
37
|
+
那个曾经让人摸不着头脑的配额按钮,也终于改得清清楚楚。
|
|
38
|
+
|
|
39
|
+
现在的按钮前缀带着明确的百分号,一眼就能看出这个账号在五个小时的突发窗口和七天的长期窗口里还剩多少力气。
|
|
40
|
+
|
|
41
|
+
跑完最后四百多项自动化测试,我们在生产环境把服务平滑重启。看着手机里那个干净的面板,看着文字如流水般一段段吐出来,我忽然觉得这几天的重构特别值得。
|
|
42
|
+
|
|
43
|
+
它不再只是某一个工具的专用转接头。
|
|
44
|
+
|
|
45
|
+
它开始像一个真正的枢纽,让每一个强大的 Agent 都能稳稳当当地走进人类朋友的日常节奏里。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foxden-app/foxclaw",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Foxden local execution claw for controlling Codex and
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Foxden local execution claw for controlling Codex, OpenCode, and Antigravity from trusted chat interfaces.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/main.js",
|
|
7
7
|
"bin": {
|