@songsid/agend 2.1.4-beta.8 → 2.1.4-beta.9

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.
@@ -106,6 +106,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
106
106
  readonly replyDeduper: ReplyDeduper;
107
107
  /** instanceName → what it is doing right now, when the backend can tell us. */
108
108
  private instanceActivity;
109
+ /** instanceName → this turn's tool list (multi-line), for the bubble. */
110
+ private instanceProgress;
109
111
  /** instanceName → tail of deliveries waiting for its IPC to come back. */
110
112
  private ipcWaitTails;
111
113
  /** instanceName → restart currently executing; concurrent callers join it. */
@@ -644,6 +646,26 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
644
646
  * never used to decide anything — purely what the user is shown.
645
647
  */
646
648
  private cacheInstanceActivity;
649
+ /**
650
+ * Cache the turn's tool-progress list and push it into the instance's live
651
+ * bubble, coalesced so a burst of tool events cannot flood the Bot API
652
+ * (Telegram's flood limit is per-chat across ALL forum topics, so edits are
653
+ * rate-limited per bubble AND ride behind the daemon-side 3s coalescer).
654
+ */
655
+ private cacheInstanceProgress;
656
+ /** At most one progress-driven edit per bubble per TOOL_PROGRESS_EDIT_MIN_MS. */
657
+ private scheduleProgressEdit;
658
+ /**
659
+ * The ONE composer for the bubble text. Both writers — the elapsed-time
660
+ * ticker and the tool-progress push — go through here; two independent
661
+ * renderers editing the same message is how the progress list used to get
662
+ * wiped by the next elapsed tick (#528 trap 2).
663
+ */
664
+ private composeBubbleText;
665
+ /** Pure composition of header + tool list, exposed for tests. */
666
+ static bubbleText(elapsedMs: number, activity: string | undefined, minElapsedMs: number, progress: string | undefined): string;
667
+ /** Recompose and edit the bubble in place; skips when nothing changed. */
668
+ private refreshBubble;
647
669
  /**
648
670
  * Refresh the button's text in place while the instance keeps working.
649
671
  *
@@ -135,6 +135,8 @@ const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
135
135
  * exactly one progress message per turn, and it is the cancel button itself.
136
136
  */
137
137
  const PROGRESS_UPDATE_INTERVAL_MS = 60_000;
138
+ /** Floor between tool-progress-driven bubble edits (Telegram flood safety). */
139
+ const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
138
140
  /** Elapsed time is only shown once work has clearly outlasted a quick answer. */
139
141
  /**
140
142
  * Default delay before the button starts showing elapsed time. Configurable via
@@ -253,6 +255,8 @@ export class FleetManager {
253
255
  replyDeduper = new ReplyDeduper();
254
256
  /** instanceName → what it is doing right now, when the backend can tell us. */
255
257
  instanceActivity = new Map();
258
+ /** instanceName → this turn's tool list (multi-line), for the bubble. */
259
+ instanceProgress = new Map();
256
260
  /** instanceName → tail of deliveries waiting for its IPC to come back. */
257
261
  ipcWaitTails = new Map();
258
262
  /** instanceName → restart currently executing; concurrent callers join it. */
@@ -2235,6 +2239,26 @@ export class FleetManager {
2235
2239
  const result = await this.topicCommands.sendCompact(name);
2236
2240
  await data.respond(result);
2237
2241
  }
2242
+ else if (data.command === "steer") {
2243
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2244
+ if (!name) {
2245
+ await data.respond(t("classic.no_agent"));
2246
+ return;
2247
+ }
2248
+ const steerText = String(data.options?.message ?? "").trim();
2249
+ if (!steerText) {
2250
+ await data.respond(t("steer.usage"));
2251
+ return;
2252
+ }
2253
+ // chat_id/message_id stay empty: a slash interaction has no channel
2254
+ // message to react to, and an empty chat_id keeps updateLastChat from
2255
+ // rerouting the instance's replies to the slash context.
2256
+ const result = this.topicCommands.sendSteer(name, steerText, {
2257
+ chatId: "", messageId: "", username: data.username ?? "user",
2258
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2259
+ });
2260
+ await data.respond(result);
2261
+ }
2238
2262
  else if (data.command === "clear") {
2239
2263
  await this.handleClearSlash(data, adapterId);
2240
2264
  }
@@ -2359,6 +2383,26 @@ export class FleetManager {
2359
2383
  const result = await this.topicCommands.sendCompact(name);
2360
2384
  await data.respond(result);
2361
2385
  }
2386
+ else if (data.command === "steer") {
2387
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2388
+ if (!name) {
2389
+ await data.respond(t("classic.no_agent"));
2390
+ return;
2391
+ }
2392
+ const steerText = String(data.options?.message ?? "").trim();
2393
+ if (!steerText) {
2394
+ await data.respond(t("steer.usage"));
2395
+ return;
2396
+ }
2397
+ // chat_id/message_id stay empty: a slash interaction has no channel
2398
+ // message to react to, and an empty chat_id keeps updateLastChat from
2399
+ // rerouting the instance's replies to the slash context.
2400
+ const result = this.topicCommands.sendSteer(name, steerText, {
2401
+ chatId: "", messageId: "", username: data.username ?? "user",
2402
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2403
+ });
2404
+ await data.respond(result);
2405
+ }
2362
2406
  }, this.logger, "adapter.slash_command"));
2363
2407
  await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
2364
2408
  // Background-probe each backend's CLI env (version/models) → cli-env cache.
@@ -2641,6 +2685,26 @@ export class FleetManager {
2641
2685
  const result = await this.topicCommands.sendCompact(name);
2642
2686
  await data.respond(result);
2643
2687
  }
2688
+ else if (data.command === "steer") {
2689
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2690
+ if (!name) {
2691
+ await data.respond(t("classic.no_agent"));
2692
+ return;
2693
+ }
2694
+ const steerText = String(data.options?.message ?? "").trim();
2695
+ if (!steerText) {
2696
+ await data.respond(t("steer.usage"));
2697
+ return;
2698
+ }
2699
+ // chat_id/message_id stay empty: a slash interaction has no channel
2700
+ // message to react to, and an empty chat_id keeps updateLastChat from
2701
+ // rerouting the instance's replies to the slash context.
2702
+ const result = this.topicCommands.sendSteer(name, steerText, {
2703
+ chatId: "", messageId: "", username: data.username ?? "user",
2704
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2705
+ });
2706
+ await data.respond(result);
2707
+ }
2644
2708
  }, this.logger, `adapter[${adapterId}].slash_command`));
2645
2709
  await adapter.start();
2646
2710
  if (channelConfig.group_id) {
@@ -2758,6 +2822,9 @@ export class FleetManager {
2758
2822
  else if (msg.type === "instance_activity") {
2759
2823
  this.cacheInstanceActivity(name, msg.activity);
2760
2824
  }
2825
+ else if (msg.type === "instance_progress") {
2826
+ this.cacheInstanceProgress(name, msg.progress || null);
2827
+ }
2761
2828
  else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
2762
2829
  this.cacheInstanceExecutionState(name, msg);
2763
2830
  if (msg.type === "instance_state_response") {
@@ -3205,6 +3272,24 @@ export class FleetManager {
3205
3272
  await msgAdapter?.sendText(chatId, result);
3206
3273
  return;
3207
3274
  }
3275
+ // /steer — interject into the running turn. Not admin-gated: anyone who
3276
+ // can talk to this agent can send it a message; steer only changes when
3277
+ // it lands, and it keeps the full [user:] formatting (unlike /raw).
3278
+ if (text === "/steer" || text.startsWith("/steer ") || text.startsWith("/steer@")) {
3279
+ const steerName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
3280
+ if (!steerName) {
3281
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3282
+ return;
3283
+ }
3284
+ const steerContent = text.replace(/^\/steer(@\S+)?/, "").trim();
3285
+ if (!steerContent) {
3286
+ await msgAdapter?.sendText(chatId, t("steer.usage"));
3287
+ return;
3288
+ }
3289
+ const result = this.topicCommands.sendSteer(steerName, steerContent, msg);
3290
+ await msgAdapter?.sendText(chatId, result);
3291
+ return;
3292
+ }
3208
3293
  // Handle /clear command (admin only) — unlike /compact this starts a
3209
3294
  // fresh conversation and intentionally discards the current history.
3210
3295
  if (text === "/clear" || text.startsWith("/clear@")) {
@@ -5130,6 +5215,80 @@ export class FleetManager {
5130
5215
  else
5131
5216
  this.instanceActivity.delete(name);
5132
5217
  }
5218
+ /**
5219
+ * Cache the turn's tool-progress list and push it into the instance's live
5220
+ * bubble, coalesced so a burst of tool events cannot flood the Bot API
5221
+ * (Telegram's flood limit is per-chat across ALL forum topics, so edits are
5222
+ * rate-limited per bubble AND ride behind the daemon-side 3s coalescer).
5223
+ */
5224
+ cacheInstanceProgress(name, progress) {
5225
+ if (progress)
5226
+ this.instanceProgress.set(name, progress);
5227
+ else
5228
+ this.instanceProgress.delete(name);
5229
+ for (const entry of this.cancelButtons.values()) {
5230
+ if (entry.instanceName === name)
5231
+ this.scheduleProgressEdit(entry);
5232
+ }
5233
+ }
5234
+ /** At most one progress-driven edit per bubble per TOOL_PROGRESS_EDIT_MIN_MS. */
5235
+ scheduleProgressEdit(entry) {
5236
+ const since = Date.now() - (entry.lastProgressEditAt ?? 0);
5237
+ if (since >= TOOL_PROGRESS_EDIT_MIN_MS) {
5238
+ this.refreshBubble(entry);
5239
+ return;
5240
+ }
5241
+ if (entry.progressEditTimer)
5242
+ return; // trailing edit already scheduled
5243
+ entry.progressEditTimer = setTimeout(() => {
5244
+ entry.progressEditTimer = undefined;
5245
+ this.refreshBubble(entry);
5246
+ }, TOOL_PROGRESS_EDIT_MIN_MS - since);
5247
+ entry.progressEditTimer.unref?.();
5248
+ }
5249
+ /**
5250
+ * The ONE composer for the bubble text. Both writers — the elapsed-time
5251
+ * ticker and the tool-progress push — go through here; two independent
5252
+ * renderers editing the same message is how the progress list used to get
5253
+ * wiped by the next elapsed tick (#528 trap 2).
5254
+ */
5255
+ composeBubbleText(entry) {
5256
+ return FleetManager.bubbleText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), this.instanceProgress.get(entry.instanceName));
5257
+ }
5258
+ /** Pure composition of header + tool list, exposed for tests. */
5259
+ static bubbleText(elapsedMs, activity, minElapsedMs, progress) {
5260
+ const header = FleetManager.progressText(elapsedMs,
5261
+ // The single-line activity detail is redundant once a tool list exists.
5262
+ progress ? undefined : activity, minElapsedMs);
5263
+ return progress ? `${header}\n${progress}` : header;
5264
+ }
5265
+ /** Recompose and edit the bubble in place; skips when nothing changed. */
5266
+ refreshBubble(entry) {
5267
+ if (!this.cancelButtons.has(entry.messageId)) {
5268
+ clearInterval(entry.progressTimer);
5269
+ return;
5270
+ }
5271
+ const text = this.composeBubbleText(entry);
5272
+ if (text === entry.lastProgressText)
5273
+ return; // nothing changed — skip the API call
5274
+ const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
5275
+ if (!adapter?.editAlert)
5276
+ return;
5277
+ entry.lastProgressText = text;
5278
+ entry.lastProgressEditAt = Date.now();
5279
+ adapter.editAlert(entry.chatId, entry.messageId, {
5280
+ type: "cancel",
5281
+ instanceName: entry.instanceName,
5282
+ message: text,
5283
+ choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
5284
+ }, entry.threadId ? { threadId: entry.threadId } : undefined)
5285
+ .catch(err => {
5286
+ // A failed progress edit must never escalate: the button still works and
5287
+ // the next tick retries. Common causes are a deleted message or a
5288
+ // rate limit.
5289
+ this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
5290
+ });
5291
+ }
5133
5292
  /**
5134
5293
  * Refresh the button's text in place while the instance keeps working.
5135
5294
  *
@@ -5147,31 +5306,7 @@ export class FleetManager {
5147
5306
  return PROGRESS_MIN_ELAPSED_MS;
5148
5307
  }
5149
5308
  startProgressTicker(entry) {
5150
- const tick = () => {
5151
- if (!this.cancelButtons.has(entry.messageId)) {
5152
- clearInterval(entry.progressTimer);
5153
- return;
5154
- }
5155
- const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs());
5156
- if (text === entry.lastProgressText)
5157
- return; // nothing changed — skip the API call
5158
- const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
5159
- if (!adapter?.editAlert)
5160
- return;
5161
- entry.lastProgressText = text;
5162
- adapter.editAlert(entry.chatId, entry.messageId, {
5163
- type: "cancel",
5164
- instanceName: entry.instanceName,
5165
- message: text,
5166
- choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
5167
- }, entry.threadId ? { threadId: entry.threadId } : undefined)
5168
- .catch(err => {
5169
- // A failed progress edit must never escalate: the button still works and
5170
- // the next tick retries. Common causes are a deleted message or a
5171
- // rate limit.
5172
- this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
5173
- });
5174
- };
5309
+ const tick = () => this.refreshBubble(entry);
5175
5310
  entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
5176
5311
  entry.progressTimer.unref?.();
5177
5312
  // One extra tick right when the threshold passes, so a 30s threshold shows
@@ -5239,6 +5374,8 @@ export class FleetManager {
5239
5374
  clearInterval(entry.idleCheckTimer);
5240
5375
  if (entry.progressTimer)
5241
5376
  clearInterval(entry.progressTimer);
5377
+ if (entry.progressEditTimer)
5378
+ clearTimeout(entry.progressEditTimer);
5242
5379
  if (entry.replyGraceTimer)
5243
5380
  clearTimeout(entry.replyGraceTimer);
5244
5381
  this.cancelButtons.delete(entry.messageId);