agentus 0.11.1 → 0.11.3
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 +65 -14
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-0607`, 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-0607`,基座 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-0607`** — 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-0607`** —— 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.3",
|
|
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": {
|
|
@@ -320,6 +320,8 @@ export class SessionManager {
|
|
|
320
320
|
#callLive: () => string | null = () => null;
|
|
321
321
|
/** Serialises call-state transitions (see syncCallState). */
|
|
322
322
|
#callSync: Promise<void> = Promise.resolve();
|
|
323
|
+
/** Per-session call generation (see #callGen). */
|
|
324
|
+
#callGens = new Map<string, number>();
|
|
323
325
|
setCallLiveSource(read: () => string | null): void {
|
|
324
326
|
this.#callLive = read;
|
|
325
327
|
}
|
|
@@ -470,6 +472,7 @@ export class SessionManager {
|
|
|
470
472
|
}
|
|
471
473
|
|
|
472
474
|
async #beginCall(id: string): Promise<void> {
|
|
475
|
+
this.#bumpCallGen(id);
|
|
473
476
|
const prev = this.#store.getVoiceMode(id);
|
|
474
477
|
if (prev.inCall) return; // idempotent: a second presence beat for the same call changes nothing
|
|
475
478
|
if (!this.#store.getSession(id)) return; // a call with a session this store has never seen
|
|
@@ -482,7 +485,10 @@ export class SessionManager {
|
|
|
482
485
|
this.#store.upsertVoiceMode(id, {
|
|
483
486
|
inCall: true, boundary: "enter", turns: 0,
|
|
484
487
|
thoughtBefore: before,
|
|
485
|
-
|
|
488
|
+
// A new call starts untouched even if the PREVIOUS call ended with the operator's own pick
|
|
489
|
+
// still recorded: that pick was already honoured (by not restoring it), and inheriting the
|
|
490
|
+
// flag here would skip the lowering for this whole call (review round 2, A-m2).
|
|
491
|
+
thoughtTouched: false,
|
|
486
492
|
});
|
|
487
493
|
// A call is full-duplex: waiting 20 s for a first token reads as a dead line to the operator.
|
|
488
494
|
// Lower the dial for the call and put the operator's own choice back on hang-up (see #restoreThought).
|
|
@@ -495,6 +501,7 @@ export class SessionManager {
|
|
|
495
501
|
}
|
|
496
502
|
|
|
497
503
|
async #endCall(id: string): Promise<void> {
|
|
504
|
+
this.#bumpCallGen(id);
|
|
498
505
|
const v = this.#store.getVoiceMode(id);
|
|
499
506
|
// Nothing was actually said (no prompt ever rode this call): owe nothing. An exit block here
|
|
500
507
|
// would tell the agent "the conversation above was spoken", which is false — and it is cheap to
|
|
@@ -525,16 +532,44 @@ export class SessionManager {
|
|
|
525
532
|
} catch {
|
|
526
533
|
return false;
|
|
527
534
|
}
|
|
535
|
+
// A new call may have started while this round-trip was in flight: it needs the snapshot, so
|
|
536
|
+
// leave it alone (the new call will restore it on ITS hang-up).
|
|
537
|
+
if (this.#store.getVoiceMode(id).inCall) return false;
|
|
538
|
+
// `setConfig` reports a rejected value by emitting, not by throwing: verify against what the
|
|
539
|
+
// agent actually advertises before declaring the level restored (review round 2, A-m1). The
|
|
540
|
+
// knob must still be the SAME one (a model/mode switch changes the list) and an answer without
|
|
541
|
+
// `currentValue` proves nothing — in both cases keep the snapshot rather than silently staying
|
|
542
|
+
// on the call level (review r3 A-③).
|
|
543
|
+
const now = thinkingOptionOf(s.info.configOptions);
|
|
544
|
+
if (!now || now.id !== choice.id || !now.current) return false;
|
|
545
|
+
if (now.current !== v.thoughtBefore) return false;
|
|
528
546
|
this.#store.upsertVoiceMode(id, { thoughtBefore: null, thoughtTouched: false });
|
|
529
547
|
return true;
|
|
530
548
|
}
|
|
531
549
|
|
|
550
|
+
/** Monotonic per-session call generation: every begin/end is a new one. Used to make sure a
|
|
551
|
+
* settle() only touches the call it was created in (review r3 A-②). */
|
|
552
|
+
#callGen(id: string): number {
|
|
553
|
+
return this.#callGens.get(id) ?? 0;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
#bumpCallGen(id: string): void {
|
|
557
|
+
this.#callGens.set(id, (this.#callGens.get(id) ?? 0) + 1);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Run a restore on the call-state chain instead of behind its back. */
|
|
561
|
+
#queueRestore(id: string): void {
|
|
562
|
+
this.#callSync = this.#callSync
|
|
563
|
+
.then(() => this.#restoreThought(id))
|
|
564
|
+
.then(() => undefined, () => undefined);
|
|
565
|
+
}
|
|
566
|
+
|
|
532
567
|
/** Attach this prompt's voice block, and settle what it settles. Synchronous on purpose: it runs
|
|
533
568
|
* inside the send path, and a slow store write must not delay the operator's words.
|
|
534
569
|
*
|
|
535
|
-
* Returns a settle() to call once the turn
|
|
536
|
-
*
|
|
537
|
-
#applyVoiceMode(s: LiveSession, blocks: unknown[], opts: { operator: boolean }): (
|
|
570
|
+
* Returns a settle() to call once the turn has been dispatched: before that point the boundary is
|
|
571
|
+
* still owed, so a failed send does not burn it (review m1). */
|
|
572
|
+
#applyVoiceMode(s: LiveSession, blocks: unknown[], opts: { operator: boolean }): () => void {
|
|
538
573
|
// A background-completion wake is not a spoken turn: letting it consume the boundary injected
|
|
539
574
|
// "the call is over" into a synthetic turn nobody reads, so the operator's next real message
|
|
540
575
|
// never learned it, and each wake inflated the turn count (review M1).
|
|
@@ -547,19 +582,36 @@ export class SessionManager {
|
|
|
547
582
|
if (v.inCall && live !== s.info.id) {
|
|
548
583
|
const owed = v.turns > 0;
|
|
549
584
|
v = this.#store.upsertVoiceMode(s.info.id, { inCall: false, boundary: owed ? "exit" : null, ...(owed ? {} : { turns: 0 }) });
|
|
550
|
-
|
|
585
|
+
// On the SAME chain as every other transition: a fire-and-forget restore could finish after a
|
|
586
|
+
// new call had already pressed the level down, clear the snapshot, and strand the session on
|
|
587
|
+
// the call level (review round 2, A-M2).
|
|
588
|
+
this.#queueRestore(s.info.id);
|
|
551
589
|
} else if (!v.inCall && live === s.info.id) {
|
|
552
590
|
v = this.#store.upsertVoiceMode(s.info.id, { inCall: true, boundary: v.boundary ?? "enter", turns: 0 });
|
|
553
591
|
}
|
|
554
592
|
const block = voiceModeBlock(s.info.id, { inCall: v.inCall, boundary: v.boundary, turns: v.turns });
|
|
555
593
|
if (block) blocks.push(block);
|
|
556
|
-
const
|
|
594
|
+
const seenBoundary = v.boundary;
|
|
557
595
|
const counted = v.inCall;
|
|
558
|
-
if (!
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
596
|
+
if (!seenBoundary && !counted) return () => { /* steady state, nothing to settle */ };
|
|
597
|
+
// The turn is counted NOW, not when the prompt returns: hanging up while the agent is still
|
|
598
|
+
// answering is the ordinary way a call ends, and `#endCall` must already see that something was
|
|
599
|
+
// said or it owes no exit boundary (review round 2, A-M1). A prompt that never reaches the agent
|
|
600
|
+
// rolls the count back.
|
|
601
|
+
if (counted) this.#store.upsertVoiceMode(s.info.id, { turns: v.turns + 1 });
|
|
602
|
+
// No rollback on a thrown prompt, deliberately: "the frame went out and the answer never came
|
|
603
|
+
// back" cannot be told apart from "the frame never went out", and the two mistakes are not
|
|
604
|
+
// symmetric — over-counting costs one extra sentence in the exit block, under-counting means the
|
|
605
|
+
// exit boundary is never owed at all and the agent keeps answering text in spoken-word style
|
|
606
|
+
// (review r3 A-①).
|
|
607
|
+
// The generation guards against a same-shaped boundary that arrived AFTER this turn started:
|
|
608
|
+
// consume the enter boundary, hang up and dial again → the new call's enter must survive.
|
|
609
|
+
const gen = this.#callGen(s.info.id);
|
|
610
|
+
return () => {
|
|
611
|
+
if (seenBoundary && this.#callGen(s.info.id) === gen) {
|
|
612
|
+
const cur = this.#store.getVoiceMode(s.info.id);
|
|
613
|
+
if (cur.boundary === seenBoundary) this.#store.upsertVoiceMode(s.info.id, { boundary: null });
|
|
614
|
+
}
|
|
563
615
|
};
|
|
564
616
|
}
|
|
565
617
|
|
|
@@ -1332,9 +1384,8 @@ export class SessionManager {
|
|
|
1332
1384
|
prompt: blocks as any,
|
|
1333
1385
|
});
|
|
1334
1386
|
const terminalState = res?.stopReason === "cancelled" ? "interrupted" : "ended";
|
|
1335
|
-
// The agent has the block (a cancelled turn still received it): NOW the boundary is spent
|
|
1336
|
-
|
|
1337
|
-
settleVoice(true);
|
|
1387
|
+
// The agent has the block (a cancelled turn still received it): NOW the boundary is spent.
|
|
1388
|
+
settleVoice();
|
|
1338
1389
|
this.#finalizePlan(sessionId, terminalState);
|
|
1339
1390
|
this.#emit({ t: "turn-end", sessionId, stopReason: res?.stopReason, durationMs: Date.now() - turnStartedAt });
|
|
1340
1391
|
} catch (err) {
|