agentus 0.11.0 → 0.11.2
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/installation.md +1 -1
- package/docs/installation.zh-CN.md +1 -1
- package/package.json +1 -1
- package/packages/server/src/acp/session-manager.ts +131 -26
- package/packages/server/src/acp/voice-mode.ts +28 -9
- package/packages/web/dist/assets/{index-D34Kt-uv.js → index-DKXkp6hP.js} +37 -37
- package/packages/web/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ background process comes back as a turn of its own), an **ACP session that write
|
|
|
57
57
|
cockpit-side failure is diagnosable afterwards), and a **DeepSeek web backend** (search +
|
|
58
58
|
extract on the model key) — come from the fork's integration build instead. That is a five-line install:
|
|
59
59
|
[§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
|
|
60
|
-
`agentus/v0.22.0/merge-20261023-
|
|
60
|
+
`agentus/v0.22.0/merge-20261023-0556`, on Hermes `v0.22.0`.
|
|
61
61
|
|
|
62
62
|
The first slot you start will use the Hermes home **your own agent already uses**
|
|
63
63
|
(`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
|
package/README.zh-CN.md
CHANGED
|
@@ -51,7 +51,7 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
|
|
|
51
51
|
以及 **DeepSeek 的 web 后端**(搜索 + 正文抓取,共用模型 key)—— 来自 fork 的集成构建(tag)。
|
|
52
52
|
装法就五行:
|
|
53
53
|
[§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
|
|
54
|
-
`agentus/v0.22.0/merge-20261023-
|
|
54
|
+
`agentus/v0.22.0/merge-20261023-0556`,基座 Hermes `v0.22.0`。
|
|
55
55
|
|
|
56
56
|
你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
|
|
57
57
|
指定了另一个 —— 见下面的「HERMES_HOME」。
|
package/docs/installation.md
CHANGED
|
@@ -60,7 +60,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
|
|
|
60
60
|
ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
|
|
61
61
|
last segment sorts, so "newest" is a `sort -V`.
|
|
62
62
|
|
|
63
|
-
**Current integration build: `agentus/v0.22.0/merge-20261023-
|
|
63
|
+
**Current integration build: `agentus/v0.22.0/merge-20261023-0556`** — the newest tag on the fork,
|
|
64
64
|
built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
|
|
65
65
|
`TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
|
|
66
66
|
ever looks stale.
|
|
@@ -52,7 +52,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
|
|
|
52
52
|
tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
|
|
53
53
|
可排序,所以「最新」就是一次 `sort -V`。
|
|
54
54
|
|
|
55
|
-
**当前集成构建:`agentus/v0.22.0/merge-20261023-
|
|
55
|
+
**当前集成构建:`agentus/v0.22.0/merge-20261023-0556`** —— fork 上最新的那个 tag,基座 Hermes
|
|
56
56
|
`v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
|
|
57
57
|
以它为准。
|
|
58
58
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.2",
|
|
4
4
|
"description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
} from "@agentclientprotocol/sdk";
|
|
17
17
|
import { BACKENDS, buildSpawnEnv, nativePlanSourceOf, type BackendSpec } from "./backends.js";
|
|
18
18
|
import { forgetAgentOwner, recordAgentOwner, verifyAgentOwner } from "./agent-owner.js";
|
|
19
|
-
import { thinkingOptionOf, voiceModeBlock } from "./voice-mode.js";
|
|
19
|
+
import { thinkingOptionOf, voiceAppliesToPrompt, voiceModeBlock } from "./voice-mode.js";
|
|
20
20
|
import {
|
|
21
21
|
byActivity,
|
|
22
22
|
coldPage as pageCold,
|
|
@@ -318,6 +318,8 @@ export class SessionManager {
|
|
|
318
318
|
* answer in this process, and a second source would drift. Optional: with nothing wired the
|
|
319
319
|
* manager never injects anything, which is the pre-#48 behaviour. */
|
|
320
320
|
#callLive: () => string | null = () => null;
|
|
321
|
+
/** Serialises call-state transitions (see syncCallState). */
|
|
322
|
+
#callSync: Promise<void> = Promise.resolve();
|
|
321
323
|
setCallLiveSource(read: () => string | null): void {
|
|
322
324
|
this.#callLive = read;
|
|
323
325
|
}
|
|
@@ -449,6 +451,17 @@ export class SessionManager {
|
|
|
449
451
|
* answer 「好的,我了解了」 into the microphone. The block rides the next real prompt.
|
|
450
452
|
*/
|
|
451
453
|
async syncCallState(): Promise<void> {
|
|
454
|
+
// Serialised: presence arrives on a 30 s beat AND on every visibility/enter/leave event, and two
|
|
455
|
+
// overlapping runs used to interleave the dial switch — a hang-up that restored the operator's
|
|
456
|
+
// level could be overtaken by the lowering of the call that had just ended, leaving the session
|
|
457
|
+
// stuck on the lowest level with its snapshot already cleared (review B2).
|
|
458
|
+
this.#callSync = this.#callSync
|
|
459
|
+
.then(() => this.#syncCallStateOnce())
|
|
460
|
+
.catch(() => { /* presence is a hint: never break the caller */ });
|
|
461
|
+
return this.#callSync;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async #syncCallStateOnce(): Promise<void> {
|
|
452
465
|
const live = this.#callLive();
|
|
453
466
|
for (const v of this.#store.listInCall()) {
|
|
454
467
|
if (v.sessionId !== live) await this.#endCall(v.sessionId);
|
|
@@ -457,49 +470,132 @@ export class SessionManager {
|
|
|
457
470
|
}
|
|
458
471
|
|
|
459
472
|
async #beginCall(id: string): Promise<void> {
|
|
473
|
+
const prev = this.#store.getVoiceMode(id);
|
|
474
|
+
if (prev.inCall) return; // idempotent: a second presence beat for the same call changes nothing
|
|
460
475
|
if (!this.#store.getSession(id)) return; // a call with a session this store has never seen
|
|
461
476
|
const s = this.#sessions.get(id);
|
|
462
477
|
const choice = s ? thinkingOptionOf(s.info.configOptions) : null;
|
|
478
|
+
// A snapshot left behind by an earlier call (the restore could not run: slot was cold, or the
|
|
479
|
+
// process died) is still the operator's real level — never overwrite it with the pressed-down
|
|
480
|
+
// value we may have persisted onto the session row (review B1).
|
|
481
|
+
const before = prev.thoughtBefore ?? choice?.current ?? null;
|
|
463
482
|
this.#store.upsertVoiceMode(id, {
|
|
464
483
|
inCall: true, boundary: "enter", turns: 0,
|
|
465
|
-
thoughtBefore:
|
|
484
|
+
thoughtBefore: before,
|
|
485
|
+
// A new call starts untouched even if the PREVIOUS call ended with the operator's own pick
|
|
486
|
+
// still recorded: that pick was already honoured (by not restoring it), and inheriting the
|
|
487
|
+
// flag here would skip the lowering for this whole call (review round 2, A-m2).
|
|
488
|
+
thoughtTouched: false,
|
|
466
489
|
});
|
|
467
490
|
// A call is full-duplex: waiting 20 s for a first token reads as a dead line to the operator.
|
|
468
|
-
// Lower the dial for the call and put the operator's own choice back on hang-up (see #
|
|
469
|
-
if (s && choice && choice.lowest &&
|
|
491
|
+
// Lower the dial for the call and put the operator's own choice back on hang-up (see #restoreThought).
|
|
492
|
+
if (s && choice && choice.lowest && before !== choice.lowest && !this.#store.getVoiceMode(id).thoughtTouched) {
|
|
470
493
|
await this.setConfig(id, choice.id, choice.lowest, { internal: true }).catch(() => { /* the block still goes out */ });
|
|
494
|
+
// A hang-up can land while the switch is in flight: if the call is already over, put it back
|
|
495
|
+
// now rather than leaving the session on the pressed-down level.
|
|
496
|
+
if (!this.#store.getVoiceMode(id).inCall) await this.#restoreThought(id);
|
|
471
497
|
}
|
|
472
498
|
}
|
|
473
499
|
|
|
474
500
|
async #endCall(id: string): Promise<void> {
|
|
475
501
|
const v = this.#store.getVoiceMode(id);
|
|
476
|
-
|
|
502
|
+
// Nothing was actually said (no prompt ever rode this call): owe nothing. An exit block here
|
|
503
|
+
// would tell the agent "the conversation above was spoken", which is false — and it is cheap to
|
|
504
|
+
// trigger (opening the call overlay and closing it, a denied microphone) (review B3).
|
|
505
|
+
const owed = v.turns > 0;
|
|
506
|
+
this.#store.upsertVoiceMode(id, { inCall: false, boundary: owed ? "exit" : null, ...(owed ? {} : { turns: 0 }) });
|
|
507
|
+
await this.#restoreThought(id);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Put the operator's thinking level back after a call. Clears the snapshot ONLY when the level
|
|
511
|
+
* really went back — a slot that is no longer in memory (idle reap, restart) or a rejected value
|
|
512
|
+
* keeps it, so the next resume can finish the job instead of silently staying on the call level
|
|
513
|
+
* (review M2). */
|
|
514
|
+
async #restoreThought(id: string): Promise<boolean> {
|
|
515
|
+
const v = this.#store.getVoiceMode(id);
|
|
516
|
+
if (!v.thoughtBefore) return true; // nothing to put back
|
|
517
|
+
if (v.thoughtTouched) {
|
|
518
|
+
// The operator picked a level during the call: theirs wins, the snapshot is moot.
|
|
519
|
+
this.#store.upsertVoiceMode(id, { thoughtBefore: null, thoughtTouched: false });
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
477
522
|
const s = this.#sessions.get(id);
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
523
|
+
if (!s) return false;
|
|
524
|
+
const choice = thinkingOptionOf(s.info.configOptions);
|
|
525
|
+
if (!choice || !choice.lowest) return false;
|
|
526
|
+
try {
|
|
527
|
+
await this.setConfig(id, choice.id, v.thoughtBefore, { internal: true });
|
|
528
|
+
} catch {
|
|
529
|
+
return false;
|
|
482
530
|
}
|
|
531
|
+
// A new call may have started while this round-trip was in flight: it needs the snapshot, so
|
|
532
|
+
// leave it alone (the new call will restore it on ITS hang-up).
|
|
533
|
+
if (this.#store.getVoiceMode(id).inCall) return false;
|
|
534
|
+
// `setConfig` reports a rejected value by emitting, not by throwing: verify against what the
|
|
535
|
+
// agent actually advertises before declaring the level restored (review round 2, A-m1).
|
|
536
|
+
const now = thinkingOptionOf(s.info.configOptions);
|
|
537
|
+
if (now && now.current && now.current !== v.thoughtBefore) return false;
|
|
483
538
|
this.#store.upsertVoiceMode(id, { thoughtBefore: null, thoughtTouched: false });
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Run a restore on the call-state chain instead of behind its back. */
|
|
543
|
+
#queueRestore(id: string): void {
|
|
544
|
+
this.#callSync = this.#callSync
|
|
545
|
+
.then(() => this.#restoreThought(id))
|
|
546
|
+
.then(() => undefined, () => undefined);
|
|
484
547
|
}
|
|
485
548
|
|
|
486
549
|
/** Attach this prompt's voice block, and settle what it settles. Synchronous on purpose: it runs
|
|
487
|
-
* inside the send path, and a slow store write must not delay the operator's words.
|
|
488
|
-
|
|
550
|
+
* inside the send path, and a slow store write must not delay the operator's words.
|
|
551
|
+
*
|
|
552
|
+
* Returns a settle() to call once the turn is actually delivered: a prompt that threw before the
|
|
553
|
+
* agent saw it must not consume a boundary or count as a spoken turn (review m1). */
|
|
554
|
+
#applyVoiceMode(s: LiveSession, blocks: unknown[], opts: { operator: boolean }): (delivered: boolean) => void {
|
|
555
|
+
// A background-completion wake is not a spoken turn: letting it consume the boundary injected
|
|
556
|
+
// "the call is over" into a synthetic turn nobody reads, so the operator's next real message
|
|
557
|
+
// never learned it, and each wake inflated the turn count (review M1).
|
|
558
|
+
if (!opts.operator) return () => { /* nothing owed to a synthetic turn */ };
|
|
489
559
|
const live = this.#callLive();
|
|
490
560
|
let v = this.#store.getVoiceMode(s.info.id);
|
|
491
561
|
// Converge on the ONE authority (presence) in both directions: a page that closed mid-call never
|
|
492
562
|
// reports a hang-up, so the exit boundary must be derived here or the model stays on a call for
|
|
493
563
|
// ever; and a call that started while this slot was cold has no enter boundary recorded.
|
|
494
564
|
if (v.inCall && live !== s.info.id) {
|
|
495
|
-
|
|
565
|
+
const owed = v.turns > 0;
|
|
566
|
+
v = this.#store.upsertVoiceMode(s.info.id, { inCall: false, boundary: owed ? "exit" : null, ...(owed ? {} : { turns: 0 }) });
|
|
567
|
+
// On the SAME chain as every other transition: a fire-and-forget restore could finish after a
|
|
568
|
+
// new call had already pressed the level down, clear the snapshot, and strand the session on
|
|
569
|
+
// the call level (review round 2, A-M2).
|
|
570
|
+
this.#queueRestore(s.info.id);
|
|
496
571
|
} else if (!v.inCall && live === s.info.id) {
|
|
497
572
|
v = this.#store.upsertVoiceMode(s.info.id, { inCall: true, boundary: v.boundary ?? "enter", turns: 0 });
|
|
498
573
|
}
|
|
499
574
|
const block = voiceModeBlock(s.info.id, { inCall: v.inCall, boundary: v.boundary, turns: v.turns });
|
|
500
575
|
if (block) blocks.push(block);
|
|
501
|
-
|
|
502
|
-
|
|
576
|
+
const seenBoundary = v.boundary;
|
|
577
|
+
const counted = v.inCall;
|
|
578
|
+
if (!seenBoundary && !counted) return () => { /* steady state, nothing to settle */ };
|
|
579
|
+
// The turn is counted NOW, not when the prompt returns: hanging up while the agent is still
|
|
580
|
+
// answering is the ordinary way a call ends, and `#endCall` must already see that something was
|
|
581
|
+
// said or it owes no exit boundary (review round 2, A-M1). A prompt that never reaches the agent
|
|
582
|
+
// rolls the count back.
|
|
583
|
+
if (counted) this.#store.upsertVoiceMode(s.info.id, { turns: v.turns + 1 });
|
|
584
|
+
return (delivered: boolean) => {
|
|
585
|
+
if (!delivered) {
|
|
586
|
+
if (counted) {
|
|
587
|
+
const now = this.#store.getVoiceMode(s.info.id);
|
|
588
|
+
if (now.inCall && now.turns > 0) this.#store.upsertVoiceMode(s.info.id, { turns: now.turns - 1 });
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
// Clear only the boundary this turn actually carried: a hang-up during the turn may have
|
|
593
|
+
// replaced it with an exit boundary the operator still needs to hear (review r2 A-M1).
|
|
594
|
+
if (seenBoundary) {
|
|
595
|
+
const cur = this.#store.getVoiceMode(s.info.id);
|
|
596
|
+
if (cur.boundary === seenBoundary) this.#store.upsertVoiceMode(s.info.id, { boundary: null });
|
|
597
|
+
}
|
|
598
|
+
};
|
|
503
599
|
}
|
|
504
600
|
|
|
505
601
|
/** Bind a freshly spawned agent to (this data dir, this session, this server run). Bookkeeping only:
|
|
@@ -978,7 +1074,8 @@ export class SessionManager {
|
|
|
978
1074
|
}
|
|
979
1075
|
}
|
|
980
1076
|
// 通话中 resume(页面刷新、空闲回收后再唤起):把通话档位重新压下去,否则「恢复的会话」会带着
|
|
981
|
-
// 用户平时的高思考档沉默十几秒 —— 正是 issue #48
|
|
1077
|
+
// 用户平时的高思考档沉默十几秒 —— 正是 issue #48 要消除的那种卡死手感。反过来,通话已经结束却
|
|
1078
|
+
// 留着快照(挂断时会话不在内存,还原没跑成)就要在这里补还原,否则会话永远停在通话档(review M2)。
|
|
982
1079
|
{
|
|
983
1080
|
const vm = this.#store.getVoiceMode(id);
|
|
984
1081
|
if (vm.inCall) {
|
|
@@ -986,6 +1083,8 @@ export class SessionManager {
|
|
|
986
1083
|
if (knob && knob.current !== knob.lowest) {
|
|
987
1084
|
await this.setConfig(id, knob.id, knob.lowest, { internal: true }).catch(() => { /* keep going */ });
|
|
988
1085
|
}
|
|
1086
|
+
} else if (vm.thoughtBefore && !vm.thoughtTouched) {
|
|
1087
|
+
await this.#restoreThought(id);
|
|
989
1088
|
}
|
|
990
1089
|
}
|
|
991
1090
|
live.info.status = "ready";
|
|
@@ -1228,8 +1327,9 @@ export class SessionManager {
|
|
|
1228
1327
|
// 语音通话模式(issue #48): entering, staying in, and leaving a call each owe the agent one
|
|
1229
1328
|
// sentence it cannot infer — that its output is being SPOKEN, or that the speaking is over.
|
|
1230
1329
|
// Rides the same `_meta`-tagged resource channel as the plan reminder; the operator's own words
|
|
1231
|
-
// are untouched (the transcript stores `text`, never the blocks).
|
|
1232
|
-
|
|
1330
|
+
// are untouched (the transcript stores `text`, never the blocks). A background wake (`origin`)
|
|
1331
|
+
// is not a spoken turn and must not consume a boundary.
|
|
1332
|
+
const settleVoice = this.#applyVoiceMode(s, blocks, { operator: voiceAppliesToPrompt(opts) });
|
|
1233
1333
|
s.turnSeq += 1;
|
|
1234
1334
|
s.busy = true;
|
|
1235
1335
|
// The operator has spoken: from here anything the agent says is an ANSWER, not its own history
|
|
@@ -1267,6 +1367,9 @@ export class SessionManager {
|
|
|
1267
1367
|
prompt: blocks as any,
|
|
1268
1368
|
});
|
|
1269
1369
|
const terminalState = res?.stopReason === "cancelled" ? "interrupted" : "ended";
|
|
1370
|
+
// The agent has the block (a cancelled turn still received it): NOW the boundary is spent and
|
|
1371
|
+
// the turn counts.
|
|
1372
|
+
settleVoice(true);
|
|
1270
1373
|
this.#finalizePlan(sessionId, terminalState);
|
|
1271
1374
|
this.#emit({ t: "turn-end", sessionId, stopReason: res?.stopReason, durationMs: Date.now() - turnStartedAt });
|
|
1272
1375
|
} catch (err) {
|
|
@@ -1404,15 +1507,6 @@ export class SessionManager {
|
|
|
1404
1507
|
const s = this.#need(sessionId);
|
|
1405
1508
|
if (!s.conn || !s.info.acpSessionId) throw new Error("session not ready");
|
|
1406
1509
|
const requested = String(value);
|
|
1407
|
-
// 通话中操作者自己调了思考档位 → 以他为准,挂断时不再还原(issue #48)。`internal` marks the
|
|
1408
|
-
// cockpit's own lowering/restoring, which must NOT count as the operator's choice.
|
|
1409
|
-
if (!opts.internal) {
|
|
1410
|
-
const vm = this.#store.getVoiceMode(sessionId);
|
|
1411
|
-
if (vm.inCall) {
|
|
1412
|
-
const knob = thinkingOptionOf(s.info.configOptions);
|
|
1413
|
-
if (knob && knob.id === configId) this.#store.upsertVoiceMode(sessionId, { thoughtTouched: true });
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
1510
|
let actual = requested;
|
|
1417
1511
|
let rejected = false;
|
|
1418
1512
|
try {
|
|
@@ -1453,6 +1547,17 @@ export class SessionManager {
|
|
|
1453
1547
|
this.#settleRow(s, cur.seq, payload);
|
|
1454
1548
|
}
|
|
1455
1549
|
}
|
|
1550
|
+
// 通话中操作者自己调了思考档位 → 以他为准,挂断时不再还原(issue #48)。`internal` marks the
|
|
1551
|
+
// cockpit's own lowering/restoring, which must NOT count as the operator's choice. Only a pick the
|
|
1552
|
+
// AGENT actually accepted counts: a rejected value would otherwise make the hang-up skip the
|
|
1553
|
+
// restore and strand the session on the call's level (review m4).
|
|
1554
|
+
if (!opts.internal && !rejected && actual === requested) {
|
|
1555
|
+
const vm = this.#store.getVoiceMode(sessionId);
|
|
1556
|
+
if (vm.inCall) {
|
|
1557
|
+
const knob = thinkingOptionOf(s.info.configOptions);
|
|
1558
|
+
if (knob && knob.id === configId) this.#store.upsertVoiceMode(sessionId, { thoughtTouched: true });
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1456
1561
|
if (rejected) {
|
|
1457
1562
|
this.#emit({
|
|
1458
1563
|
t: "config-rejected",
|
|
@@ -77,10 +77,16 @@ export function voiceModeBlock(sessionId: string, st: VoiceState): unknown | nul
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Does this prompt get the call treatment at all?
|
|
82
|
+
*
|
|
83
|
+
* Only an OPERATOR prompt does. A background-completion wake rides the same `prompt()` with an
|
|
84
|
+
* `origin`, and it is a synthetic turn nobody reads: letting it carry the block burned the pending
|
|
85
|
+
* exit boundary into it (so the operator's next real message never learned the call had ended) and
|
|
86
|
+
* inflated the turn count that the exit block quotes.
|
|
87
|
+
*/
|
|
88
|
+
export function voiceAppliesToPrompt(opts: { origin?: unknown }): boolean {
|
|
89
|
+
return !opts.origin;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
export interface ThinkingChoice {
|
|
@@ -98,6 +104,11 @@ export interface ThinkingChoice {
|
|
|
98
104
|
* signals that survive across backends (Hermes, the mock, and the adapters we have seen) are the
|
|
99
105
|
* semantic `category: "thought_level"` and an id/name containing think/reason/effort. If neither is
|
|
100
106
|
* present the session simply has no such knob and call mode must not invent one.
|
|
107
|
+
*
|
|
108
|
+
* The LOWEST level must also be recognised. ACP puts no ordering on the value list, so falling back
|
|
109
|
+
* to `options[0]` is a coin flip that can pick "3 档" or `true` — i.e. a call could make the agent
|
|
110
|
+
* SLOWER, which is the exact failure mode call mode exists to remove (review M3). If no value looks
|
|
111
|
+
* cheapest, report no knob: worse to press an unknown dial than to leave it alone.
|
|
101
112
|
*/
|
|
102
113
|
export function thinkingOptionOf(options: unknown): ThinkingChoice | null {
|
|
103
114
|
if (!Array.isArray(options)) return null;
|
|
@@ -112,15 +123,23 @@ export function thinkingOptionOf(options: unknown): ThinkingChoice | null {
|
|
|
112
123
|
const values = (Array.isArray(found.options) ? found.options : [])
|
|
113
124
|
.map((o) => {
|
|
114
125
|
const v = o as { value?: unknown; name?: unknown };
|
|
115
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
value: typeof v.value === "string" ? v.value : typeof v.value === "number" ? String(v.value) : "",
|
|
128
|
+
name: typeof v.name === "string" ? v.name : "",
|
|
129
|
+
};
|
|
116
130
|
})
|
|
117
131
|
.filter((o) => o.value);
|
|
118
132
|
if (values.length < 2) return null;
|
|
119
|
-
const
|
|
120
|
-
|
|
133
|
+
const lowValue = /^(off|none|disabled|min|minimal|low|lowest|quick|fast|1)$/i;
|
|
134
|
+
const lowName = /^(off|none|disabled|low|lowest|quick|fast|最低|低|关闭|关|快速|最快|1\s*档?)$/i;
|
|
135
|
+
const pick = values.find((v) => lowValue.test(v.value.trim()) || lowName.test(v.name.trim()));
|
|
136
|
+
if (!pick) return null;
|
|
137
|
+
const currentRaw = found.currentValue;
|
|
121
138
|
return {
|
|
122
139
|
id: found.id as string,
|
|
123
|
-
current: typeof
|
|
124
|
-
|
|
140
|
+
current: typeof currentRaw === "string"
|
|
141
|
+
? currentRaw
|
|
142
|
+
: typeof currentRaw === "number" || typeof currentRaw === "boolean" ? String(currentRaw) : "",
|
|
143
|
+
lowest: pick.value,
|
|
125
144
|
};
|
|
126
145
|
}
|