agentus 0.3.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.3.5",
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": {
@@ -36,7 +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 type { PlanHintSettings } from "../settings.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";
40
41
 
41
42
  // Permission prompts must not hang a session forever (design.md §8-4).
42
43
  // Env-tunable so QA can exercise the timeout path in seconds instead of minutes.
@@ -100,6 +101,11 @@ function boundedDiff(path: string, oldText: string, newText: string): Permission
100
101
  };
101
102
  }
102
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
+
103
109
  // A title fork is a real model turn over a copy of the conversation: give it room, but never
104
110
  // let a stuck one hang the operator's click — the caller falls back to the derived name on
105
111
  // timeout. Env-tunable so a sweep can exercise the timeout path in seconds.
@@ -223,7 +229,7 @@ export class SessionManager {
223
229
  /** How the operator wants the plan-tool hint injected into a prompt — read on EVERY prompt, like
224
230
  * the idle thresholds, because the requirement is that a change in 设置 applies to the very next
225
231
  * turn with no restart and no session to hibernate. */
226
- #planHint: () => PlanHintSettings = () => ({ on: true, everyTurn: false });
232
+ #planHint: () => PlanHintSettings = () => ({ ...PREFS_DEFAULT.planHint });
227
233
 
228
234
  constructor(store: Store, emit: Emitter) {
229
235
  this.#store = store;
@@ -911,7 +917,7 @@ export class SessionManager {
911
917
  const owed = Boolean(s.planReminderPending);
912
918
  s.planReminderPending = false;
913
919
  if (owed || hint.everyTurn) {
914
- const reminder = this.#planReminder(s, !owed);
920
+ const reminder = this.#planReminder(s);
915
921
  if (reminder) blocks.push(reminder);
916
922
  }
917
923
  }
@@ -1252,19 +1258,27 @@ export class SessionManager {
1252
1258
  * copy died with its process — this block is the only thing that tells it the plan exists
1253
1259
  * (design-plan-service.md §7).
1254
1260
  *
1255
- * `perTurn` = this is a mid-conversation nudge (the 每回合 setting), not the once-per-process
1256
- * hand-over. It changes only what is worth SAYING: with no plan on the card there is nothing to
1257
- * hand over and repeating the introduction every turn would be the noise the operator asked to
1258
- * avoid, so a per-turn call with nothing to show returns null. Returns null too when a plan
1259
- * exists but every step is closed — spending context to say "you are done" is worse than saying
1260
- * nothing. */
1261
- #planReminder(s: LiveSession, perTurn = false): unknown | null {
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. */
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;
1262
1277
  const plan = this.#store.getPlan(s.info.id);
1263
1278
  const open = plan
1264
1279
  ? plan.items.filter((i) => i.status !== "completed" && i.status !== "cancelled")
1265
1280
  : [];
1266
1281
  if (plan && !open.length) return null;
1267
- if (!open.length && perTurn) return null;
1268
1282
 
1269
1283
  // How the agent keeps it current depends on which channel it actually has: an agent whose row
1270
1284
  // opted into native frames must use its own todo tool (those frames are what feed the card),
@@ -1284,33 +1298,15 @@ export class SessionManager {
1284
1298
  }
1285
1299
 
1286
1300
  if (mcpDriven && open.length) {
1287
- body.push(
1288
- "Carry on with it, and keep it updated with the `update_plan` tool as you go — that list is",
1289
- "the ONLY one the cockpit shows the operator, so a built-in todo/checklist tool of yours will",
1290
- "not be seen there. Mark a step completed once its work is actually verified, and drop or",
1291
- "cancel what you are no longer doing. If you disagree with an item, change it rather than",
1292
- "quietly skipping it.",
1293
- );
1301
+ body.push(custom ?? PLAN_GUIDANCE_ACTIVE);
1294
1302
  } else if (mcpDriven) {
1295
1303
  // No plan yet. This is the case the cockpit used to say NOTHING about — which is exactly why
1296
1304
  // an agent reached for its own todo list and the card stayed empty: `update_plan`'s own
1297
1305
  // description ("prefer this over any todo tool") lives on a tool a lazy-loaded MCP catalog
1298
1306
  // never puts in front of the model. Keep it short; it rides on every first prompt.
1299
- body.push(
1300
- "This session has an `update_plan` tool mounted on it (MCP server `agentus_plan`), and the",
1301
- "plan it holds is the one the operator sees — a todo list of your own is invisible there and",
1302
- "dies with your process. For anything with more than one step, publish the steps with",
1303
- "`update_plan` and update it as you go: mark a step completed only once its work is actually",
1304
- "verified, and drop or cancel what you are no longer doing. If `update_plan` is not in your",
1305
- "tool list it is deferred — search for `agentus_plan update_plan` to find it, then call it by",
1306
- "that exact name. A one-step request needs no plan.",
1307
- );
1307
+ body.push(custom ?? PLAN_GUIDANCE_INTRO);
1308
1308
  } else if (open.length) {
1309
- body.push(
1310
- "Carry on with it, and keep it updated with your own plan/todo tool as you go: mark a step",
1311
- "completed only once its work is actually verified, and drop or cancel what you are no",
1312
- "longer doing. If you disagree with an item, change it rather than quietly skipping it.",
1313
- );
1309
+ body.push(custom ?? PLAN_GUIDANCE_NATIVE);
1314
1310
  } else {
1315
1311
  // A native-frames backend with nothing to hand over: nothing worth saying.
1316
1312
  return null;
@@ -110,21 +110,35 @@ export interface PrefsSettings {
110
110
  planHint: PlanHintSettings;
111
111
  }
112
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
+
113
118
  export interface PlanHintSettings {
114
119
  /** Inject the plan-tool hint at all. Off = the prompt carries the operator's words and
115
120
  * nothing else (some operators do not want anything appended to their message). */
116
121
  on: boolean;
117
122
  /** true = every turn (the agent is re-told as the plan moves); false = only the first prompt
118
- * of each agent process (a fresh process has no memory of the plan; a running one maintains
119
- * it itself, and re-sending it every turn costs context and invites re-deriving finished work). */
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". */
120
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;
121
134
  }
122
135
 
123
136
  export const PREFS_DEFAULT: PrefsSettings = {
124
137
  autoRead: false, voiceURI: "", rate: 1, lang: "", serverTts: false, stt: "auto",
125
- // On by default: the feature exists because the tool was there and nobody used it. 仅首条 is
126
- // the conservative default — it is also what the cockpit did before this was configurable.
127
- planHint: { on: true, everyTurn: false },
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: "" },
128
142
  };
129
143
 
130
144
  export const CALL_DEFAULT: CallSettings = {
@@ -356,6 +370,9 @@ function pickPrefs(raw: unknown): PrefsSettings {
356
370
  const hr = h as Record<string, unknown>;
357
371
  if (typeof hr.on === "boolean") out.planHint.on = hr.on;
358
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
+ }
359
376
  }
360
377
  }
361
378
  return out;
@@ -550,6 +567,15 @@ export function savePrefs(patch: Record<string, unknown>): Settings {
550
567
  if (typeof hr.everyTurn !== "boolean") throw new Error("invalid planHint.everyTurn: expected true/false");
551
568
  nextHint.everyTurn = hr.everyTurn;
552
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
+ }
553
579
  next.planHint = nextHint;
554
580
  }
555
581
  cache = { ...cache, prefs: next, updatedAt: Date.now() };
@@ -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