agentus 0.3.4 → 0.3.6
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/package.json +5 -1
- package/packages/server/src/acp/registry.ts +28 -1
- package/packages/server/src/acp/session-manager.ts +161 -40
- package/packages/server/src/index.ts +4 -1
- package/packages/server/src/settings.ts +72 -0
- package/packages/shared/src/index.ts +56 -0
- package/packages/web/dist/assets/index-C2dY8mT1.css +32 -0
- package/packages/web/dist/assets/{index-N0FnNa4-.js → index-_UMrD23P.js} +49 -46
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-D-RQzauE.css +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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": {
|
|
@@ -52,6 +52,10 @@
|
|
|
52
52
|
"workspace-smoke": "node scripts/workspace-smoke.mjs",
|
|
53
53
|
"voice-smoke": "node scripts/voice-smoke.mjs",
|
|
54
54
|
"sentence-units": "tsx scripts/qa/sentence-units.mts",
|
|
55
|
+
"token-format": "tsx scripts/qa/token-format.mts",
|
|
56
|
+
"terminal-palette": "tsx scripts/qa/terminal-palette.mts",
|
|
57
|
+
"android-trust-merge": "node scripts/qa/android-trust-merge.mjs",
|
|
58
|
+
"plan-hint-sweep": "node scripts/qa/plan-hint-sweep.mjs",
|
|
55
59
|
"plan-service-sweep": "node scripts/qa/plan-service-sweep.mjs",
|
|
56
60
|
"plan-mcp-sweep": "node scripts/qa/plan-mcp-sweep.mjs",
|
|
57
61
|
"plan-v2-sweep": "tsx scripts/qa/plan-v2-sweep.mts",
|
|
@@ -204,7 +204,17 @@ export interface BackendHandshake {
|
|
|
204
204
|
/** agentCapabilities.sessionCapabilities.fork */
|
|
205
205
|
fork: boolean | null;
|
|
206
206
|
modes: { currentModeId: string | null; available: string[]; names?: Record<string, string> } | null;
|
|
207
|
-
|
|
207
|
+
/** The ACP `select`s, kept WHOLE rather than reduced to id/name/currentValue: the new-session
|
|
208
|
+
* start page has no session to read configOptions from, so the controls it draws come from
|
|
209
|
+
* here — and a control without its `options` is a button that cannot be turned. */
|
|
210
|
+
configOptions: {
|
|
211
|
+
id: string;
|
|
212
|
+
name: string | null;
|
|
213
|
+
currentValue: string | null;
|
|
214
|
+
type?: string | null;
|
|
215
|
+
category?: string | null;
|
|
216
|
+
options?: { value: string; name: string | null; description?: string | null }[] | null;
|
|
217
|
+
}[];
|
|
208
218
|
models: { currentModelId: string | null; available: string[] } | null;
|
|
209
219
|
/** slash commands the agent announced (session/update or newSession) */
|
|
210
220
|
commands: string[];
|
|
@@ -621,6 +631,23 @@ export function handshakeFrom(
|
|
|
621
631
|
id: o.id,
|
|
622
632
|
name: o.name ?? null,
|
|
623
633
|
currentValue: o.currentValue === undefined || o.currentValue === null ? null : String(o.currentValue),
|
|
634
|
+
// Passed through defensively like everything else here: the SDK types cover only part of what
|
|
635
|
+
// the wire carries, and a session's real control set differs per agent.
|
|
636
|
+
type: typeof (o as { type?: unknown }).type === "string" ? String((o as { type?: string }).type) : null,
|
|
637
|
+
category:
|
|
638
|
+
typeof (o as { category?: unknown }).category === "string"
|
|
639
|
+
? String((o as { category?: string }).category)
|
|
640
|
+
: null,
|
|
641
|
+
options: Array.isArray((o as { options?: unknown }).options)
|
|
642
|
+
? ((o as unknown as { options: { value?: unknown; name?: unknown; description?: unknown }[] }).options ?? []).map(
|
|
643
|
+
(opt) => ({
|
|
644
|
+
value: String(opt.value ?? ""),
|
|
645
|
+
name: opt.name === undefined || opt.name === null ? null : String(opt.name),
|
|
646
|
+
description:
|
|
647
|
+
opt.description === undefined || opt.description === null ? null : String(opt.description),
|
|
648
|
+
}),
|
|
649
|
+
)
|
|
650
|
+
: null,
|
|
624
651
|
})),
|
|
625
652
|
models,
|
|
626
653
|
commands: (s.availableCommands ?? []).map((c) => c.name),
|
|
@@ -36,6 +36,8 @@ import type {
|
|
|
36
36
|
TurnTrace,
|
|
37
37
|
} from "../../../shared/src/index.js";
|
|
38
38
|
import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
|
|
39
|
+
import { PLAN_GUIDANCE_ACTIVE, PLAN_GUIDANCE_INTRO, PLAN_GUIDANCE_NATIVE } from "../../../shared/src/index.js";
|
|
40
|
+
import { PREFS_DEFAULT, type PlanHintSettings } from "../settings.js";
|
|
39
41
|
|
|
40
42
|
// Permission prompts must not hang a session forever (design.md §8-4).
|
|
41
43
|
// Env-tunable so QA can exercise the timeout path in seconds instead of minutes.
|
|
@@ -99,6 +101,11 @@ function boundedDiff(path: string, oldText: string, newText: string): Permission
|
|
|
99
101
|
};
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
// The built-in wording for the plan hint's GUIDANCE half lives in `shared` (PLAN_GUIDANCE_*), not
|
|
105
|
+
// here: the settings page has to SHOW an operator exactly what "leave the box empty" sends, and a
|
|
106
|
+
// second prose copy on the web side is a copy that drifts. Read the doc comment there for which
|
|
107
|
+
// variant covers which case.
|
|
108
|
+
|
|
102
109
|
// A title fork is a real model turn over a copy of the conversation: give it room, but never
|
|
103
110
|
// let a stuck one hang the operator's click — the caller falls back to the derived name on
|
|
104
111
|
// timeout. Env-tunable so a sweep can exercise the timeout path in seconds.
|
|
@@ -108,6 +115,14 @@ const TITLE_FORK_TIMEOUT_MS = Number(process.env.AGENTUS_TITLE_TIMEOUT_MS || 90_
|
|
|
108
115
|
// (an agent that ignores session/cancel must not hang the operator's next sentence forever).
|
|
109
116
|
const TURN_SETTLE_TIMEOUT_MS = Number(process.env.AGENTUS_SETTLE_TIMEOUT_MS || 15_000);
|
|
110
117
|
|
|
118
|
+
/** How long a close/archive waits for a slot's in-flight handshake before killing it anyway.
|
|
119
|
+
*
|
|
120
|
+
* `#stop` waits because the agent-side session id only exists once the handshake lands, and a row
|
|
121
|
+
* closed without it can never be resumed (see `#stop`). The wait is bounded so a spawn that never
|
|
122
|
+
* answers cannot hold the operator's click open forever: past this, the slot closes with whatever it
|
|
123
|
+
* has — a broken agent is not made worse by a row we cannot wake. */
|
|
124
|
+
const CLOSE_STARTUP_WAIT_MS = Number(process.env.AGENTUS_CLOSE_WAIT_MS || 30_000);
|
|
125
|
+
|
|
111
126
|
/** A cold slot the agent cannot adopt, and why — the only two answers there are.
|
|
112
127
|
*
|
|
113
128
|
* Resuming has to *ask* the agent: `session/load` is "do you still have this session?", and the
|
|
@@ -159,6 +174,14 @@ interface LiveSession {
|
|
|
159
174
|
* so a second interrupting utterance that already started its own turn is not mistaken for
|
|
160
175
|
* "the one I cancelled is still running". */
|
|
161
176
|
turnSeq: number;
|
|
177
|
+
/** The spawn-time handshake, while it is still running.
|
|
178
|
+
*
|
|
179
|
+
* `create()` no longer waits for it (that wait is what made 「新建会话」 feel broken), so the slot
|
|
180
|
+
* exists — and can be closed, archived or reaped — before the agent has answered. This handle is
|
|
181
|
+
* how the teardown waits for the one thing only the handshake can tell us: the agent-side session
|
|
182
|
+
* id. Killing the child before it arrives leaves a row with `acpSessionId: null` that `resume()`
|
|
183
|
+
* refuses forever (「was never handed to an agent」) — see `#stop`. */
|
|
184
|
+
startup?: Promise<void>;
|
|
162
185
|
/** When the operator last USED this slot — he opened it, or his page reported it as the one on
|
|
163
186
|
* screen. In memory on purpose: this is the half of the idle rule that says "he was just here",
|
|
164
187
|
* and the store's message clock (which survives restarts) is the other half. Reading only the
|
|
@@ -203,12 +226,22 @@ export class SessionManager {
|
|
|
203
226
|
#sessions = new Map<string, LiveSession>();
|
|
204
227
|
#store: Store;
|
|
205
228
|
#emit: Emitter;
|
|
229
|
+
/** How the operator wants the plan-tool hint injected into a prompt — read on EVERY prompt, like
|
|
230
|
+
* the idle thresholds, because the requirement is that a change in 设置 applies to the very next
|
|
231
|
+
* turn with no restart and no session to hibernate. */
|
|
232
|
+
#planHint: () => PlanHintSettings = () => ({ ...PREFS_DEFAULT.planHint });
|
|
206
233
|
|
|
207
234
|
constructor(store: Store, emit: Emitter) {
|
|
208
235
|
this.#store = store;
|
|
209
236
|
this.#emit = emit;
|
|
210
237
|
}
|
|
211
238
|
|
|
239
|
+
/** Wire the cockpit's plan-hint setting in. Optional on purpose: with no source wired the
|
|
240
|
+
* manager behaves exactly as it did before the setting existed (on, 仅首条). */
|
|
241
|
+
setPlanHintSource(read: () => PlanHintSettings): void {
|
|
242
|
+
this.#planHint = read;
|
|
243
|
+
}
|
|
244
|
+
|
|
212
245
|
list(): SessionInfo[] {
|
|
213
246
|
// Most recently CHATTED first — the cockpit's left rail is a launch pad, not a log file, and
|
|
214
247
|
// creation order is the wrong key for that: the session you talked to a minute ago is the
|
|
@@ -417,6 +450,33 @@ export class SessionManager {
|
|
|
417
450
|
});
|
|
418
451
|
child.on("exit", (code, sig) => this.#onChildExit(live, code, sig));
|
|
419
452
|
|
|
453
|
+
// The row is complete and already persisted with `status: "starting"` — hand it back to the
|
|
454
|
+
// caller NOW and let the handshake finish OFF the request. See `#startup`.
|
|
455
|
+
// Kept on the slot (not dropped with `void`) so a close during the window can wait for the
|
|
456
|
+
// agent-side id instead of orphaning the session — see `#stop`.
|
|
457
|
+
live.startup = this.#startup(live, backend, spec, cwd, spawnStarted);
|
|
458
|
+
void live.startup.catch(() => {}); // `#startup` reports failure on the slot; nobody is awaiting
|
|
459
|
+
return live.info;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The spawn-time handshake — `initialize` + `newSession` + the row's default permission mode.
|
|
463
|
+
*
|
|
464
|
+
* Deliberately NOT awaited by `create()`. Holding the HTTP response open for the whole handshake
|
|
465
|
+
* is what made 「新建会话」 feel broken: hermes boots a python process and answers in 10–40 s, the
|
|
466
|
+
* page sat on a busy dialog with no progress to show and a 120 s timeout behind it (measured; the
|
|
467
|
+
* old `state.ts` comment said so in as many words). The row is born `starting` — the rail can
|
|
468
|
+
* draw it, the page can open it and file the first message — and when this resolves,
|
|
469
|
+
* `#updateSession` broadcasts `ready`, which is also the signal the client drains that queued
|
|
470
|
+
* first message on (`state.ts`, `case "session"`).
|
|
471
|
+
*
|
|
472
|
+
* Failure is reported INSIDE the session, never to a caller that has long since answered: the
|
|
473
|
+
* status goes `error` with `lastError` (the reason + the agent's stderr tail, delivered while the
|
|
474
|
+
* slot is still in the live map so the frame carries it), then the slot is dropped and the row is
|
|
475
|
+
* closed. That is what makes 「启动失败 → 因为什么 → 再试一次」 possible at all. */
|
|
476
|
+
async #startup(live: LiveSession, backend: BackendId, spec: BackendSpec, cwd: string, spawnStarted: number): Promise<void> {
|
|
477
|
+
const id = live.info.id;
|
|
478
|
+
const child = live.child;
|
|
479
|
+
if (!child) return; // the slot was torn down between `create()` returning and this line
|
|
420
480
|
try {
|
|
421
481
|
const conn = new ClientSideConnection(
|
|
422
482
|
() => ({
|
|
@@ -458,18 +518,19 @@ export class SessionManager {
|
|
|
458
518
|
// this method — that is exactly what keeps a per-session pick from being silently undone.
|
|
459
519
|
await this.#applyDefaultMode(live);
|
|
460
520
|
this.#updateSession(live);
|
|
461
|
-
return live.info;
|
|
462
521
|
} catch (err) {
|
|
463
522
|
child.kill("SIGKILL");
|
|
464
523
|
live.info.status = "error";
|
|
465
524
|
live.info.lastError = errMessage(err) + stderrTail(live);
|
|
466
|
-
|
|
525
|
+
// Announce the failure WHILE the slot is still live: `{t:"session"}` is the only frame that
|
|
526
|
+
// carries `lastError`, and the page needs it to say why nothing came up.
|
|
527
|
+
this.#updateSession(live);
|
|
528
|
+
this.#sessions.delete(id); // failed handshake => don't keep a zombie slot
|
|
467
529
|
this.#store.upsertSession({ ...sessionRow(live.info), closedAt: Date.now() });
|
|
468
530
|
this.#noteBackendCheck(String(backend), {
|
|
469
531
|
ok: false, error: live.info.lastError, errorCode: classifyError(err),
|
|
470
532
|
latencyMs: Date.now() - spawnStarted,
|
|
471
533
|
});
|
|
472
|
-
throw err instanceof Error ? err : new Error(String(err));
|
|
473
534
|
}
|
|
474
535
|
}
|
|
475
536
|
|
|
@@ -841,10 +902,24 @@ export class SessionManager {
|
|
|
841
902
|
if (!blocks.length) throw new Error("empty prompt");
|
|
842
903
|
// The plan hand-over, owed once per agent process (§7): a restarted agent never knew about the
|
|
843
904
|
// plan, and nothing else on this wire would tell it (ACP has no server->agent state channel).
|
|
844
|
-
|
|
905
|
+
//
|
|
906
|
+
// It carries a second thing the agent cannot learn on its own: that this session HAS a plan
|
|
907
|
+
// tool. Its own description says "prefer this over any todo tool", but a runtime that keeps MCP
|
|
908
|
+
// tools in a lazy-loaded catalog (Hermes) never shows that text to the model unless the model
|
|
909
|
+
// goes looking — so the plan card stayed empty while the agent kept a todo list only its own
|
|
910
|
+
// process could see. The nudge therefore has to come from the wire; Studio does the same
|
|
911
|
+
// (its `task-plan-runs.ts` appends a template to the latest input every turn).
|
|
912
|
+
//
|
|
913
|
+
// Read per prompt, and only CONSUME the hand-over when it is actually injected: an operator who
|
|
914
|
+
// turns the hint off and later back on gets the plan hand-over then, rather than losing it.
|
|
915
|
+
const hint = this.#planHint();
|
|
916
|
+
if (hint.on) {
|
|
917
|
+
const owed = Boolean(s.planReminderPending);
|
|
845
918
|
s.planReminderPending = false;
|
|
846
|
-
|
|
847
|
-
|
|
919
|
+
if (owed || hint.everyTurn) {
|
|
920
|
+
const reminder = this.#planReminder(s);
|
|
921
|
+
if (reminder) blocks.push(reminder);
|
|
922
|
+
}
|
|
848
923
|
}
|
|
849
924
|
s.turnSeq += 1;
|
|
850
925
|
s.busy = true;
|
|
@@ -1181,46 +1256,68 @@ export class SessionManager {
|
|
|
1181
1256
|
/** The plan, handed to an agent that just (re)started, as an ACP `resource` content block: a
|
|
1182
1257
|
* stable URI plus the steps still owed. The card is fed by the plan OBJECT, but the AGENT's own
|
|
1183
1258
|
* copy died with its process — this block is the only thing that tells it the plan exists
|
|
1184
|
-
* (design-plan-service.md §7).
|
|
1185
|
-
*
|
|
1186
|
-
*
|
|
1259
|
+
* (design-plan-service.md §7).
|
|
1260
|
+
*
|
|
1261
|
+
* It is also the only thing that tells an agent the tool exists AT ALL — `update_plan`'s own
|
|
1262
|
+
* description says "prefer this over any todo tool", but a runtime that keeps MCP tools in a
|
|
1263
|
+
* lazy-loaded catalog (Hermes) never shows the model that text unless it goes looking first.
|
|
1264
|
+
* Everything here goes out on the wire, every turn when 每回合 is on, exactly like Studio's
|
|
1265
|
+
* `task-plan-runs.ts` (which appends its block to the latest input unconditionally).
|
|
1266
|
+
*
|
|
1267
|
+
* The ONE case that says nothing: a plan that exists with every step closed. There is no
|
|
1268
|
+
* introduction worth repeating and no work to hand over — "you are done" costs context and buys
|
|
1269
|
+
* nothing. Note that "no plan yet" is NOT that case: it is the moment the nudge matters most,
|
|
1270
|
+
* because an agent that has not been told the tool exists will reach for its own todo list. */
|
|
1187
1271
|
#planReminder(s: LiveSession): unknown | null {
|
|
1272
|
+
// The operator's own wording, when they wrote one (设置 → 计划提示注入). Read per call, like the
|
|
1273
|
+
// switches beside it, so an edit lands on the next turn with no restart. Emptiness is decided
|
|
1274
|
+
// HERE rather than only at save time: a value written by an older build (or hand-edited into
|
|
1275
|
+
// the DB) must not be able to inject a line of whitespace as if it were guidance.
|
|
1276
|
+
const custom = this.#planHint().instruction.trim() || null;
|
|
1188
1277
|
const plan = this.#store.getPlan(s.info.id);
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1278
|
+
const open = plan
|
|
1279
|
+
? plan.items.filter((i) => i.status !== "completed" && i.status !== "cancelled")
|
|
1280
|
+
: [];
|
|
1281
|
+
if (plan && !open.length) return null;
|
|
1282
|
+
|
|
1193
1283
|
// How the agent keeps it current depends on which channel it actually has: an agent whose row
|
|
1194
1284
|
// opted into native frames must use its own todo tool (those frames are what feed the card),
|
|
1195
1285
|
// while an MCP-driven agent — which is every backend by default, Hermes included — was handed
|
|
1196
1286
|
// `update_plan` in this very handshake, and its own todo tool does NOT reach the card.
|
|
1197
1287
|
const mcpDriven = nativePlanSourceOf(this.#specFor(s.info.backend)) === "none";
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
]
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1288
|
+
const body: string[] = [];
|
|
1289
|
+
|
|
1290
|
+
if (open.length) {
|
|
1291
|
+
body.push(
|
|
1292
|
+
"This session has an unfinished plan. It is kept by the cockpit and survives your",
|
|
1293
|
+
"restarts; your own in-memory task list does not.",
|
|
1294
|
+
"",
|
|
1295
|
+
...open.map((i) => `- [${i.status}] ${i.content}`),
|
|
1296
|
+
"",
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (mcpDriven && open.length) {
|
|
1301
|
+
body.push(custom ?? PLAN_GUIDANCE_ACTIVE);
|
|
1302
|
+
} else if (mcpDriven) {
|
|
1303
|
+
// No plan yet. This is the case the cockpit used to say NOTHING about — which is exactly why
|
|
1304
|
+
// an agent reached for its own todo list and the card stayed empty: `update_plan`'s own
|
|
1305
|
+
// description ("prefer this over any todo tool") lives on a tool a lazy-loaded MCP catalog
|
|
1306
|
+
// never puts in front of the model. Keep it short; it rides on every first prompt.
|
|
1307
|
+
body.push(custom ?? PLAN_GUIDANCE_INTRO);
|
|
1308
|
+
} else if (open.length) {
|
|
1309
|
+
body.push(custom ?? PLAN_GUIDANCE_NATIVE);
|
|
1310
|
+
} else {
|
|
1311
|
+
// A native-frames backend with nothing to hand over: nothing worth saying.
|
|
1312
|
+
return null;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1211
1315
|
return {
|
|
1212
1316
|
type: "resource",
|
|
1213
1317
|
resource: {
|
|
1214
1318
|
uri: `agentus://plan/${s.info.id}`,
|
|
1215
1319
|
mimeType: "text/plain",
|
|
1216
|
-
text:
|
|
1217
|
-
"This session has an unfinished plan. It is kept by the cockpit and survives your",
|
|
1218
|
-
"restarts; your own in-memory task list does not.",
|
|
1219
|
-
"",
|
|
1220
|
-
lines,
|
|
1221
|
-
"",
|
|
1222
|
-
...closing,
|
|
1223
|
-
].join("\n"),
|
|
1320
|
+
text: body.join("\n"),
|
|
1224
1321
|
},
|
|
1225
1322
|
// ACP reserves `_meta` for the client: tagging our own blocks is what lets us tell them from
|
|
1226
1323
|
// the operator's words later (and strip them from anything we echo back).
|
|
@@ -1230,7 +1327,10 @@ export class SessionManager {
|
|
|
1230
1327
|
|
|
1231
1328
|
async closeSession(sessionId: string): Promise<void> {
|
|
1232
1329
|
const s = this.#need(sessionId);
|
|
1233
|
-
|
|
1330
|
+
// Awaited, not fired and forgotten: the row has to be settled (and carry the agent-side id) by the
|
|
1331
|
+
// time the DELETE that asked for this answers, or the page loads a slot the store still calls
|
|
1332
|
+
// `starting`.
|
|
1333
|
+
await this.#stop(s, "closed");
|
|
1234
1334
|
this.#emitSessions();
|
|
1235
1335
|
}
|
|
1236
1336
|
|
|
@@ -1245,7 +1345,7 @@ export class SessionManager {
|
|
|
1245
1345
|
const live = this.#sessions.get(id);
|
|
1246
1346
|
const row = this.#store.getSession(id);
|
|
1247
1347
|
if (!row) throw new Error(`no such session: ${id}`);
|
|
1248
|
-
if (on && live) this.#stop(live, "archived");
|
|
1348
|
+
if (on && live) await this.#stop(live, "archived");
|
|
1249
1349
|
this.#store.setArchived(id, on ? Date.now() : null);
|
|
1250
1350
|
this.#emitSessions();
|
|
1251
1351
|
return this.#sessions.get(id)?.info ?? this.#coldInfo(id);
|
|
@@ -1257,11 +1357,30 @@ export class SessionManager {
|
|
|
1257
1357
|
* `reaped` is its own status on purpose — 「它怎么变冷了」 has three different answers (I closed
|
|
1258
1358
|
* it / it crashed / the cockpit reclaimed it for being idle) and a rail that shows one word for
|
|
1259
1359
|
* all three teaches the operator to distrust the column. */
|
|
1260
|
-
#stop(live: LiveSession, why: "closed" | "archived" | "reaped"): void {
|
|
1360
|
+
async #stop(live: LiveSession, why: "closed" | "archived" | "reaped"): Promise<void> {
|
|
1261
1361
|
this.#dropPending(live);
|
|
1262
|
-
|
|
1263
|
-
|
|
1362
|
+
// A slot can be closed while its handshake is still in flight — `create()` no longer waits for it
|
|
1363
|
+
// (see there). Killing the child on the spot was WRONG in a way the operator would eventually hit:
|
|
1364
|
+
// the agent had already created a session, but the row was written with `acpSessionId: null`, and
|
|
1365
|
+
// `resume()` refuses such a row outright (「was never handed to an agent」) — the slot became a
|
|
1366
|
+
// transcript nobody could ever wake again. So let the startup land first: it is the only thing that
|
|
1367
|
+
// knows the agent-side id, and the child it spawned is the one really there to kill.
|
|
1368
|
+
//
|
|
1369
|
+
// The slot stays in the live map for the duration, on purpose: the child IS alive and about to be
|
|
1370
|
+
// `ready`, and calling it cold while it boots is the mirror-image lie (a cold row that wakes up by
|
|
1371
|
+
// itself). The wait is bounded — see `CLOSE_STARTUP_WAIT_MS`.
|
|
1372
|
+
if (live.startup && live.info.status === "starting") {
|
|
1373
|
+
await Promise.race([
|
|
1374
|
+
live.startup.catch(() => {}),
|
|
1375
|
+
new Promise<void>((r) => { setTimeout(r, CLOSE_STARTUP_WAIT_MS).unref?.(); }),
|
|
1376
|
+
]);
|
|
1377
|
+
}
|
|
1378
|
+
// Applied LAST, and only over a non-terminal status: `#startup` writes `ready` when the handshake
|
|
1379
|
+
// lands and `error` (with `lastError`) when it fails — and a handshake that failed on its own has a
|
|
1380
|
+
// better answer than 「closed」, so it keeps it.
|
|
1381
|
+
if (live.info.status !== "error") live.info.status = why === "reaped" ? "reaped" : "closed";
|
|
1264
1382
|
live.info.pid = null;
|
|
1383
|
+
live.child?.kill("SIGTERM");
|
|
1265
1384
|
const row = this.#store.getSession(live.info.id);
|
|
1266
1385
|
if (row) {
|
|
1267
1386
|
this.#store.upsertSession({
|
|
@@ -1382,7 +1501,9 @@ export class SessionManager {
|
|
|
1382
1501
|
`[agentus] idle reap: ${s.info.id} (${Math.round(age / 60_000)} min idle, limit ${Math.round(idleMs / 60_000)} min)`,
|
|
1383
1502
|
);
|
|
1384
1503
|
}
|
|
1385
|
-
|
|
1504
|
+
// Fire and forget: the reaper skips `starting` slots, so there is no in-flight handshake to
|
|
1505
|
+
// wait for here and nothing downstream needs the row settled in this tick.
|
|
1506
|
+
void this.#stop(s, "reaped");
|
|
1386
1507
|
reaped += 1;
|
|
1387
1508
|
}
|
|
1388
1509
|
// Only speak when something changed: a frame every tick would be noise on the socket (and on
|
|
@@ -19,7 +19,7 @@ import { terms } from "./term.js";
|
|
|
19
19
|
import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
|
|
20
20
|
import { hotwordsFor, vocabularyOf } from "./hotwords.js";
|
|
21
21
|
import { openDashscopeStream } from "./dashscope.js";
|
|
22
|
-
import { agentSettings, initSettings, publicSettings, saveAgent, saveCall, savePrefs, saveSettings, saveTheme } from "./settings.js";
|
|
22
|
+
import { agentSettings, initSettings, planHintSettings, publicSettings, saveAgent, saveCall, savePrefs, saveSettings, saveTheme } from "./settings.js";
|
|
23
23
|
import * as auth from "./auth.js";
|
|
24
24
|
import { NotifyCenter } from "./notify/center.js";
|
|
25
25
|
import type { BackendId, ClientCommand, PermissionDecision, PromptAttachment, ServerEvent } from "../../shared/src/index.js";
|
|
@@ -298,6 +298,9 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
|
|
|
298
298
|
envWarmTtlMs != null ? envWarmTtlMs / 3_600_000 : agentSettings().warmTtlHours
|
|
299
299
|
} h untouched (${envWarm != null || envWarmTtlMs != null ? "AGENTUS_WARM_*" : "settings.agent"}), 0 = off / never`,
|
|
300
300
|
);
|
|
301
|
+
// The plan-tool hint: read per prompt, so the 设置 switch applies to the next turn with nothing
|
|
302
|
+
// to restart (the session's own plan card is what it fills).
|
|
303
|
+
mgr.setPlanHintSource(planHintSettings);
|
|
301
304
|
mgr.startIdleReaper({
|
|
302
305
|
idleMs: () => envIdleMs ?? agentSettings().idleKillMin * 60_000,
|
|
303
306
|
warmSlots: () => envWarm ?? agentSettings().warmSlots,
|
|
@@ -102,10 +102,43 @@ export interface PrefsSettings {
|
|
|
102
102
|
serverTts: boolean;
|
|
103
103
|
/** which recogniser to use, when the caller does not force one */
|
|
104
104
|
stt: "auto" | "browser" | "stream" | "server";
|
|
105
|
+
/** Whether — and how often — the cockpit tells the agent about the plan MCP it was handed.
|
|
106
|
+
* The tool's own description says "prefer this over any todo tool", but on a runtime that
|
|
107
|
+
* keeps MCP tools in a lazy-loaded catalog the agent never reads it unless it goes looking,
|
|
108
|
+
* so the session's plan card stays empty while the agent keeps a todo list nobody can see.
|
|
109
|
+
* A per-turn nudge in the prompt is what closes that last mile; this is its switch. */
|
|
110
|
+
planHint: PlanHintSettings;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Longest operator-authored plan instruction we store and inject. Sized for a few sentences of
|
|
114
|
+
* house rules, not a document — the block rides on the end of the operator's message every turn,
|
|
115
|
+
* so its cost is paid per turn, not once. */
|
|
116
|
+
export const PLAN_HINT_INSTRUCTION_MAX = 2000;
|
|
117
|
+
|
|
118
|
+
export interface PlanHintSettings {
|
|
119
|
+
/** Inject the plan-tool hint at all. Off = the prompt carries the operator's words and
|
|
120
|
+
* nothing else (some operators do not want anything appended to their message). */
|
|
121
|
+
on: boolean;
|
|
122
|
+
/** true = every turn (the agent is re-told as the plan moves); false = only the first prompt
|
|
123
|
+
* of each agent process. Default true: this mirrors Studio, which appends its block to the
|
|
124
|
+
* latest input on EVERY turn (services/task-plan-runs.ts) — the per-turn re-statement is the
|
|
125
|
+
* part that makes an agent actually keep the card current, and an agent process can be
|
|
126
|
+
* long-lived enough that "first prompt only" means "once this morning". */
|
|
127
|
+
everyTurn: boolean;
|
|
128
|
+
/** The operator's own wording for the appended instruction. Empty = the built-in text (which
|
|
129
|
+
* names the tool, says why it beats a native todo, and how to find it when the runtime defers
|
|
130
|
+
* MCP tools). This replaces only the GUIDANCE half — the per-turn plan STATUS (which steps are
|
|
131
|
+
* still open) is generated from the card and stays authoritative, so an edit here can never
|
|
132
|
+
* make the prompt disagree with what the operator sees. */
|
|
133
|
+
instruction: string;
|
|
105
134
|
}
|
|
106
135
|
|
|
107
136
|
export const PREFS_DEFAULT: PrefsSettings = {
|
|
108
137
|
autoRead: false, voiceURI: "", rate: 1, lang: "", serverTts: false, stt: "auto",
|
|
138
|
+
// On + every turn by default: the feature exists because the tool was there and nobody used it,
|
|
139
|
+
// and 对齐 Studio means the block rides on every turn (see everyTurn's note). Both are visible
|
|
140
|
+
// and switchable in 设置 — this is a default, not a lock.
|
|
141
|
+
planHint: { on: true, everyTurn: true, instruction: "" },
|
|
109
142
|
};
|
|
110
143
|
|
|
111
144
|
export const CALL_DEFAULT: CallSettings = {
|
|
@@ -332,6 +365,15 @@ function pickPrefs(raw: unknown): PrefsSettings {
|
|
|
332
365
|
const rate = Number(r.rate);
|
|
333
366
|
if (Number.isFinite(rate) && rate >= 0.5 && rate <= 2) out.rate = rate;
|
|
334
367
|
if (r.stt === "auto" || r.stt === "browser" || r.stt === "stream" || r.stt === "server") out.stt = r.stt;
|
|
368
|
+
const h = r.planHint;
|
|
369
|
+
if (h && typeof h === "object") {
|
|
370
|
+
const hr = h as Record<string, unknown>;
|
|
371
|
+
if (typeof hr.on === "boolean") out.planHint.on = hr.on;
|
|
372
|
+
if (typeof hr.everyTurn === "boolean") out.planHint.everyTurn = hr.everyTurn;
|
|
373
|
+
if (typeof hr.instruction === "string" && hr.instruction.length <= PLAN_HINT_INSTRUCTION_MAX) {
|
|
374
|
+
out.planHint.instruction = hr.instruction;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
335
377
|
}
|
|
336
378
|
return out;
|
|
337
379
|
}
|
|
@@ -512,6 +554,30 @@ export function savePrefs(patch: Record<string, unknown>): Settings {
|
|
|
512
554
|
if (!["auto", "browser", "stream", "server"].includes(v)) throw new Error(`invalid stt: ${JSON.stringify(patch.stt)}`);
|
|
513
555
|
next.stt = v as PrefsSettings["stt"];
|
|
514
556
|
}
|
|
557
|
+
if ("planHint" in patch) {
|
|
558
|
+
const h = patch.planHint;
|
|
559
|
+
if (!h || typeof h !== "object") throw new Error("invalid planHint: expected an object");
|
|
560
|
+
const hr = h as Record<string, unknown>;
|
|
561
|
+
const nextHint = { ...next.planHint };
|
|
562
|
+
if ("on" in hr) {
|
|
563
|
+
if (typeof hr.on !== "boolean") throw new Error("invalid planHint.on: expected true/false");
|
|
564
|
+
nextHint.on = hr.on;
|
|
565
|
+
}
|
|
566
|
+
if ("everyTurn" in hr) {
|
|
567
|
+
if (typeof hr.everyTurn !== "boolean") throw new Error("invalid planHint.everyTurn: expected true/false");
|
|
568
|
+
nextHint.everyTurn = hr.everyTurn;
|
|
569
|
+
}
|
|
570
|
+
if ("instruction" in hr) {
|
|
571
|
+
if (typeof hr.instruction !== "string") throw new Error("invalid planHint.instruction: expected a string");
|
|
572
|
+
// Trimmed on the way in: whitespace-only means "use the built-in text", and storing it as the
|
|
573
|
+
// empty string keeps that one case one case.
|
|
574
|
+
if (hr.instruction.length > PLAN_HINT_INSTRUCTION_MAX) {
|
|
575
|
+
throw new Error(`invalid planHint.instruction: longer than ${PLAN_HINT_INSTRUCTION_MAX} characters`);
|
|
576
|
+
}
|
|
577
|
+
nextHint.instruction = hr.instruction.trim() ? hr.instruction : "";
|
|
578
|
+
}
|
|
579
|
+
next.planHint = nextHint;
|
|
580
|
+
}
|
|
515
581
|
cache = { ...cache, prefs: next, updatedAt: Date.now() };
|
|
516
582
|
persist();
|
|
517
583
|
return cache;
|
|
@@ -547,6 +613,12 @@ export function agentSettings(): AgentSettings {
|
|
|
547
613
|
return cache.agent;
|
|
548
614
|
}
|
|
549
615
|
|
|
616
|
+
/** The plan-hint knobs. Read on EVERY prompt (not cached here), so the switch in 设置 takes effect
|
|
617
|
+
* on the operator's very next turn — no restart, no session to hibernate. */
|
|
618
|
+
export function planHintSettings(): PlanHintSettings {
|
|
619
|
+
return cache.prefs.planHint;
|
|
620
|
+
}
|
|
621
|
+
|
|
550
622
|
/** Normalise the two URL shapes operators paste: MaaS root or .../compatible-mode/v1. */
|
|
551
623
|
export function dashscopeUrls(baseUrl: string): { root: string; compat: string; ws: string } {
|
|
552
624
|
const trimmed = baseUrl.replace(/\/+$/, "");
|
|
@@ -273,6 +273,62 @@ export interface PlanSnapshot {
|
|
|
273
273
|
updatedAt: number;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
// ---- the built-in plan-hint wording ----
|
|
277
|
+
//
|
|
278
|
+
// It lives in `shared` because TWO sides need the same bytes: the server injects it
|
|
279
|
+
// (`session-manager.ts#planReminder`) and the settings page has to be able to SHOW the operator
|
|
280
|
+
// what "leave the box empty" actually sends. Keeping a prose copy on the web side would drift,
|
|
281
|
+
// and a settings screen that paraphrases the thing it configures is a control that lies.
|
|
282
|
+
//
|
|
283
|
+
// Three variants, because what is worth saying depends on the channel the agent really has:
|
|
284
|
+
//
|
|
285
|
+
// - `INTRO` — no plan on the session yet. The moment the nudge matters most: an agent that has
|
|
286
|
+
// not been told `update_plan` exists will reach for its own todo tool, which never reaches the
|
|
287
|
+
// cockpit's card. Explicitly teaches the deferred-tool lookup, because the tool's own
|
|
288
|
+
// description ("prefer this over any todo tool") is invisible until the model finds it.
|
|
289
|
+
// - `ACTIVE` — a plan exists with steps still open, on an MCP-driven backend.
|
|
290
|
+
// - `NATIVE` — a backend whose row opted into native plan frames: those frames are what feed the
|
|
291
|
+
// card, so its own todo tool IS the right instrument and `update_plan` is not mentioned.
|
|
292
|
+
//
|
|
293
|
+
// The per-turn plan STATUS (which steps are still open) is NOT part of these strings — the server
|
|
294
|
+
// generates it from the card on every call, so an operator editing the hint text cannot make the
|
|
295
|
+
// prompt disagree with what the cockpit displays.
|
|
296
|
+
|
|
297
|
+
/** Injected when the session has no plan yet and the backend is MCP-driven. */
|
|
298
|
+
export const PLAN_GUIDANCE_INTRO = [
|
|
299
|
+
"This session has an `update_plan` tool mounted on it (MCP server `agentus_plan`), and the",
|
|
300
|
+
"plan it holds is the one the operator sees — a todo list of your own is invisible there and",
|
|
301
|
+
"dies with your process. For anything with more than one step, publish the steps with",
|
|
302
|
+
"`update_plan` and update it as you go: mark a step completed only once its work is actually",
|
|
303
|
+
"verified, and drop or cancel what you are no longer doing. If `update_plan` is not in your",
|
|
304
|
+
"tool list it is deferred — search for `agentus_plan update_plan` to find it, then call it by",
|
|
305
|
+
"that exact name. A one-step request needs no plan.",
|
|
306
|
+
].join("\n");
|
|
307
|
+
|
|
308
|
+
/** Injected when the session HAS an open plan and the backend is MCP-driven. */
|
|
309
|
+
export const PLAN_GUIDANCE_ACTIVE = [
|
|
310
|
+
"Carry on with it, and keep it updated with the `update_plan` tool as you go — that list is",
|
|
311
|
+
"the ONLY one the cockpit shows the operator, so a built-in todo/checklist tool of yours will",
|
|
312
|
+
"not be seen there. Mark a step completed once its work is actually verified, and drop or",
|
|
313
|
+
"cancel what you are no longer doing. If you disagree with an item, change it rather than",
|
|
314
|
+
"quietly skipping it.",
|
|
315
|
+
].join("\n");
|
|
316
|
+
|
|
317
|
+
/** Injected when the backend's row feeds the card from its OWN native plan frames. */
|
|
318
|
+
export const PLAN_GUIDANCE_NATIVE = [
|
|
319
|
+
"Carry on with it, and keep it updated with your own plan/todo tool as you go: mark a step",
|
|
320
|
+
"completed only once its work is actually verified, and drop or cancel what you are no",
|
|
321
|
+
"longer doing. If you disagree with an item, change it rather than quietly skipping it.",
|
|
322
|
+
].join("\n");
|
|
323
|
+
|
|
324
|
+
/** The three built-ins with the case each one covers — so a surface can list them without
|
|
325
|
+
* inventing its own labels (and without a second copy of the prose). */
|
|
326
|
+
export const PLAN_GUIDANCE_VARIANTS: ReadonlyArray<{ id: string; when: string; text: string }> = [
|
|
327
|
+
{ id: "intro", when: "该会话还没有计划(最要紧的一刻:不告诉它,它就会用自己的 todo)", text: PLAN_GUIDANCE_INTRO },
|
|
328
|
+
{ id: "active", when: "已有未完成的计划,且 agent 是 MCP 驱动", text: PLAN_GUIDANCE_ACTIVE },
|
|
329
|
+
{ id: "native", when: "该后端的计划卡由它自己的原生计划帧喂(不用 update_plan)", text: PLAN_GUIDANCE_NATIVE },
|
|
330
|
+
];
|
|
331
|
+
|
|
276
332
|
// ---- WS envelope (browser -> server) ----
|
|
277
333
|
/** Media the operator attaches to a prompt. Images ride ACP as base64 blocks (that
|
|
278
334
|
* is the wire shape the protocol defines), text files are inlined as text with a
|