@dorokuma/herdsman-pi 0.14.0 → 0.14.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/package.json +1 -1
- package/src/index.ts +166 -225
- package/src/wake.ts +20 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -7,7 +7,6 @@ export { logHerdsmanPi };
|
|
|
7
7
|
export type { HerdsmanPiLogLevel } from "./logger.js";
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
type AgentContextListItem,
|
|
11
10
|
type AgentEventWireRecord,
|
|
12
11
|
type AgentOrchestratorChanged,
|
|
13
12
|
type AgentOrchestratorWireState,
|
|
@@ -105,7 +104,6 @@ type HerdsmanState = {
|
|
|
105
104
|
launchIdentity: LaunchIdentity | undefined;
|
|
106
105
|
latestContext: AgentWorkspaceContextSnapshot | undefined;
|
|
107
106
|
pendingEvents: AgentEventWireRecord[];
|
|
108
|
-
pinnedContext: AgentWorkspaceContextSnapshot | undefined;
|
|
109
107
|
presentedEventIds: Set<number>;
|
|
110
108
|
reconnectingFromOn: boolean;
|
|
111
109
|
registrationInFlight: Promise<void> | undefined;
|
|
@@ -141,8 +139,12 @@ type HerdsmanState = {
|
|
|
141
139
|
* delivery from being written off, and it carries three guarantees at once:
|
|
142
140
|
*
|
|
143
141
|
* - never lost: while it is non-empty the settlement drives a continuation
|
|
144
|
-
* (bounded by MAX_WAKE_CONTINUATION_ATTEMPTS) so a later run drains
|
|
145
|
-
* and carries the update out
|
|
142
|
+
* (bounded by MAX_WAKE_CONTINUATION_ATTEMPTS drives) so a later run drains
|
|
143
|
+
* the queue and carries the update out. The bound is a real ceiling, not a
|
|
144
|
+
* suggestion: once it is spent the ids are released from here and
|
|
145
|
+
* acknowledged (`writeOffStrandedWakeDelivery`), because an update that no
|
|
146
|
+
* turn will ever carry out must not keep a run loop — and the daemon's
|
|
147
|
+
* redelivery of it — alive forever;
|
|
146
148
|
* - never acknowledged unseen: these ids are excluded from the acknowledgement
|
|
147
149
|
* path, so the daemon keeps them pending and redelivers them when this
|
|
148
150
|
* session never consumes them (the only way a delivery Pi itself dropped —
|
|
@@ -203,18 +205,24 @@ type HerdsmanState = {
|
|
|
203
205
|
*/
|
|
204
206
|
wakeSkipLogReasons: Map<number, string>;
|
|
205
207
|
/**
|
|
206
|
-
* Continuation drives already spent
|
|
208
|
+
* Continuation drives already spent, keyed by event id.
|
|
207
209
|
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
210
|
+
* The bound is per event, never per delivery and never per session: an
|
|
211
|
+
* unrelated event being injected must not hand a stuck one a fresh budget. A
|
|
212
|
+
* session that keeps receiving new updates used to reset a shared counter at
|
|
213
|
+
* every injection, so the one id no run ever drained drove on unboundedly and
|
|
214
|
+
* `MAX_WAKE_CONTINUATION_ATTEMPTS` never fired — the loop that flooded the
|
|
215
|
+
* orchestrator session with empty turns.
|
|
216
|
+
*
|
|
217
|
+
* `MAX_WAKE_CONTINUATION_ATTEMPTS` is therefore a hard ceiling per id: the
|
|
218
|
+
* drive that reaches it writes that id off
|
|
219
|
+
* (`writeOffStrandedWakeDelivery`), and an id that still has drives left keeps
|
|
220
|
+
* both its copy and its count even when a sibling is written off next to it.
|
|
221
|
+
*
|
|
222
|
+
* An entry leaves the map with the id itself: acknowledged, dead-lettered,
|
|
223
|
+
* consumed (its copy reached the transcript), or a role/scope reset.
|
|
216
224
|
*/
|
|
217
|
-
|
|
225
|
+
wakeContinuationDrives: Map<number, number>;
|
|
218
226
|
wakeRequested: boolean;
|
|
219
227
|
wakeRequestedThroughEventId: number;
|
|
220
228
|
wakeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -281,17 +289,31 @@ export const WAKE_BUSY_SPIN_MS = 100;
|
|
|
281
289
|
*/
|
|
282
290
|
export const WAKE_DEFERRED_TIMEOUT_MS = 5_000;
|
|
283
291
|
/**
|
|
284
|
-
* Upper bound on the continuation drives spent on one unconsumed
|
|
292
|
+
* Upper bound on the continuation drives spent on **one** unconsumed wake
|
|
293
|
+
* event id.
|
|
294
|
+
*
|
|
295
|
+
* Every drive costs a full agent run, so the budget is deliberately small: three
|
|
296
|
+
* attempts already cover the drive that follows the settlement which missed Pi's
|
|
297
|
+
* follow-up queue plus two retries after intervening runs that also ended before
|
|
298
|
+
* their stop point, and five adds margin for a run that spent its stop point on
|
|
299
|
+
* a tool call. Beyond that the cause is systemic — the runs keep ending early, so
|
|
300
|
+
* starting another one could only produce another empty turn — and that id is
|
|
301
|
+
* written off instead (`writeOffStrandedWakeDelivery`): it leaves
|
|
302
|
+
* `wakeAwaitingConsumption` and is acknowledged, which stops both the
|
|
303
|
+
* continuation loop and the daemon's redelivery of the same event.
|
|
304
|
+
*
|
|
305
|
+
* The bound is per event id (`wakeContinuationDrives`) and is never reset by a
|
|
306
|
+
* later injection: a session that keeps receiving new updates must not hand an
|
|
307
|
+
* already-stuck id a fresh ceiling, or the loop it was meant to stop continues
|
|
308
|
+
* for exactly the events that are stuck.
|
|
285
309
|
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
* delivery stays unacknowledged, hence pending on the daemon, which redelivers
|
|
292
|
-
* it to a later run or session.
|
|
310
|
+
* The trade-off is deliberate and asymmetric: losing one agent update is
|
|
311
|
+
* recoverable (the agent is still there and its transcript can be read
|
|
312
|
+
* directly), while an unbounded continuation loop floods the orchestrator
|
|
313
|
+
* session with empty turns and makes it unusable. A missed update is therefore
|
|
314
|
+
* strictly better than an endless one.
|
|
293
315
|
*/
|
|
294
|
-
export const MAX_WAKE_CONTINUATION_ATTEMPTS =
|
|
316
|
+
export const MAX_WAKE_CONTINUATION_ATTEMPTS = 5;
|
|
295
317
|
/** `customType` of the hidden wake context this extension injects. */
|
|
296
318
|
const WAKE_CONTEXT_CUSTOM_TYPE = "herdsman-wake-context";
|
|
297
319
|
/** `customType` of the hidden marker that drives a missed wake continuation. */
|
|
@@ -378,7 +400,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
378
400
|
launchIdentity: undefined,
|
|
379
401
|
latestContext: undefined,
|
|
380
402
|
pendingEvents: [],
|
|
381
|
-
pinnedContext: undefined,
|
|
382
403
|
presentedEventIds: new Set(),
|
|
383
404
|
reconnectingFromOn: false,
|
|
384
405
|
registrationInFlight: undefined,
|
|
@@ -394,8 +415,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
394
415
|
wakeConsumptionObserved: new Set(),
|
|
395
416
|
wakeSuppressedEventIds: new Set(),
|
|
396
417
|
wakeSkipLogReasons: new Map(),
|
|
397
|
-
|
|
398
|
-
wakeContext: undefined,
|
|
418
|
+
wakeContinuationDrives: new Map(),
|
|
399
419
|
wakeRequested: false,
|
|
400
420
|
wakeRequestedThroughEventId: 0,
|
|
401
421
|
wakeTimer: undefined,
|
|
@@ -446,15 +466,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
446
466
|
cancelWakeTimer();
|
|
447
467
|
state.wakeRequested = false;
|
|
448
468
|
state.wakeRequestedThroughEventId = 0;
|
|
449
|
-
// A wake queued for a busy orchestrator belongs to the role/scope that
|
|
450
|
-
// queued it: dropping it here keeps a stale event body out of the context
|
|
451
|
-
// of whatever session takes over next.
|
|
452
|
-
state.wakeContext = undefined;
|
|
453
469
|
};
|
|
454
470
|
|
|
455
471
|
const clearAgentContext = () => {
|
|
456
472
|
state.latestContext = undefined;
|
|
457
|
-
state.pinnedContext = undefined;
|
|
458
473
|
state.runActive = false;
|
|
459
474
|
};
|
|
460
475
|
|
|
@@ -504,7 +519,8 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
504
519
|
state.wakeAwaitingConsumption.delete(eventId);
|
|
505
520
|
state.wakeConsumptionObserved.delete(eventId);
|
|
506
521
|
state.wakeSuppressedEventIds.delete(eventId);
|
|
507
|
-
|
|
522
|
+
// The id can no longer owe a drive, so its own budget leaves with it.
|
|
523
|
+
state.wakeContinuationDrives.delete(eventId);
|
|
508
524
|
};
|
|
509
525
|
|
|
510
526
|
/**
|
|
@@ -544,7 +560,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
544
560
|
state.wakeAwaitingConsumption.clear();
|
|
545
561
|
state.wakeConsumptionObserved.clear();
|
|
546
562
|
state.wakeSkipLogReasons.clear();
|
|
547
|
-
state.
|
|
563
|
+
state.wakeContinuationDrives.clear();
|
|
548
564
|
};
|
|
549
565
|
|
|
550
566
|
const pruneAcknowledgedEvents = (ackedEventId: number | undefined) => {
|
|
@@ -935,6 +951,44 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
935
951
|
return { deliveryQueue, confirmablePrefix, blockedByMissingTurn, stillAwaitingConsumption };
|
|
936
952
|
};
|
|
937
953
|
|
|
954
|
+
const writeOffStrandedWakeDelivery = (eventIds: number[], ctx: PiContext): void => {
|
|
955
|
+
// Release first, acknowledge second: the release is what breaks the loop
|
|
956
|
+
// synchronously, while the acknowledgement is an RPC that may fail.
|
|
957
|
+
for (const eventId of eventIds) {
|
|
958
|
+
state.wakeAwaitingConsumption.delete(eventId);
|
|
959
|
+
// This set only ever holds the id of a delivery that was already handed
|
|
960
|
+
// to Pi, so releasing it is not permission to present it again: the
|
|
961
|
+
// session-wide presented guard stays behind and keeps a daemon replay
|
|
962
|
+
// of the same id out of the transcript.
|
|
963
|
+
state.presentedEventIds.add(eventId);
|
|
964
|
+
}
|
|
965
|
+
// The written-off ids can no longer owe a drive, so their budgets leave
|
|
966
|
+
// with them. Every id that is still awaiting consumption keeps its own
|
|
967
|
+
// budget: it has not spent its own MAX_WAKE_CONTINUATION_ATTEMPTS drives
|
|
968
|
+
// yet, and resetting it here is what used to let a sibling's fresh budget
|
|
969
|
+
// cover for an unrelated stuck id.
|
|
970
|
+
for (const eventId of eventIds) state.wakeContinuationDrives.delete(eventId);
|
|
971
|
+
const stranded = eventIds
|
|
972
|
+
.map((eventId) => state.unackedDelivered.get(eventId))
|
|
973
|
+
.filter((event): event is AgentEventWireRecord => event !== undefined)
|
|
974
|
+
.sort((left, right) => left.id - right.id);
|
|
975
|
+
logHerdsmanPi(
|
|
976
|
+
"warn",
|
|
977
|
+
`[herdsman-pi] wake continuation gave up eventIds=${eventIds.join(",")} drives=${MAX_WAKE_CONTINUATION_ATTEMPTS} · released and acknowledged as consumed so the daemon stops redelivering them`,
|
|
978
|
+
);
|
|
979
|
+
ctx.ui.notify?.(
|
|
980
|
+
`Herdsman · ${eventIds.length} agent update${eventIds.length === 1 ? "" : "s"} could not be delivered by a wake turn · given up on (possibly never seen): read the agent directly for the details, or hand this workspace to another terminal so the daemon delivers it there`,
|
|
981
|
+
"warning",
|
|
982
|
+
);
|
|
983
|
+
if (stranded.length === 0) return;
|
|
984
|
+
void acknowledgeEventIds(stranded, { notify: false }, ctx).catch((error: unknown) => {
|
|
985
|
+
logHerdsmanPi(
|
|
986
|
+
"warn",
|
|
987
|
+
`[herdsman-pi] wake write-off acknowledgement failed eventIds=${stranded.map((event) => event.id).join(",")} · ${String(error)}`,
|
|
988
|
+
);
|
|
989
|
+
});
|
|
990
|
+
};
|
|
991
|
+
|
|
938
992
|
/**
|
|
939
993
|
* Drives one continuation that carries out a wake Pi has not drained.
|
|
940
994
|
*
|
|
@@ -950,17 +1004,25 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
950
1004
|
*
|
|
951
1005
|
* Only consumption evidence writes a delivery off, so an intervening run that
|
|
952
1006
|
* ends before its stop point (error, user abort, a refused tool) leaves the
|
|
953
|
-
* next settlement driving again. That is
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
1007
|
+
* next settlement driving again. That bound is per event id and nothing
|
|
1008
|
+
* resets it: starting runs cannot fix a cause that is not about the queue, so
|
|
1009
|
+
* once an id's own budget is spent the id is written off (released and
|
|
1010
|
+
* acknowledged) instead of looping forever — see
|
|
1011
|
+
* `writeOffStrandedWakeDelivery` for the trade-off.
|
|
957
1012
|
*/
|
|
958
1013
|
const driveWakeContinuation = (ctx: PiContext) => {
|
|
959
1014
|
if (state.wakeAwaitingConsumption.size === 0) return;
|
|
960
|
-
if (state.wakeContinuationAttempts >= MAX_WAKE_CONTINUATION_ATTEMPTS) return;
|
|
961
1015
|
if (!pi.sendMessage) return;
|
|
962
1016
|
const eventIds = [...state.wakeAwaitingConsumption].sort((left, right) => left - right);
|
|
963
|
-
|
|
1017
|
+
// Every id that is still waiting spends one drive of its own budget. The
|
|
1018
|
+
// counters are keyed by event id and are never reset by an injection, so a
|
|
1019
|
+
// delivery handed over later cannot buy an already-stuck id a fresh budget.
|
|
1020
|
+
const spent: number[] = [];
|
|
1021
|
+
for (const eventId of eventIds) {
|
|
1022
|
+
const drives = (state.wakeContinuationDrives.get(eventId) ?? 0) + 1;
|
|
1023
|
+
state.wakeContinuationDrives.set(eventId, drives);
|
|
1024
|
+
spent.push(drives);
|
|
1025
|
+
}
|
|
964
1026
|
try {
|
|
965
1027
|
pi.sendMessage(
|
|
966
1028
|
{
|
|
@@ -972,29 +1034,27 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
972
1034
|
);
|
|
973
1035
|
logHerdsmanPi(
|
|
974
1036
|
"info",
|
|
975
|
-
`[herdsman-pi] wake continuation driven eventIds=${eventIds[0] ?? 0}-${eventIds.at(-1) ?? 0} count=${eventIds.length}
|
|
1037
|
+
`[herdsman-pi] wake continuation driven eventIds=${eventIds[0] ?? 0}-${eventIds.at(-1) ?? 0} count=${eventIds.length} drives=${spent.join(",")}`,
|
|
976
1038
|
);
|
|
977
1039
|
} catch {
|
|
978
1040
|
logHerdsmanPi("warn", "[herdsman-pi] wake continuation refused by pi");
|
|
979
1041
|
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
)
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
"warning",
|
|
997
|
-
);
|
|
1042
|
+
// Only the ids whose own budget this drive spent are written off. A sibling
|
|
1043
|
+
// that still has drives left keeps its copy in the queue and its count, so
|
|
1044
|
+
// it is neither lost nor granted a fresh ceiling.
|
|
1045
|
+
const exhausted = eventIds.filter(
|
|
1046
|
+
(_eventId, index) => (spent[index] ?? 0) >= MAX_WAKE_CONTINUATION_ATTEMPTS,
|
|
1047
|
+
);
|
|
1048
|
+
if (exhausted.length > 0) {
|
|
1049
|
+
// This was the last drive the id's own budget allows, and the queued copy
|
|
1050
|
+
// is still undrained: the id is written off instead of being left
|
|
1051
|
+
// pending forever. The ids are released from
|
|
1052
|
+
// `wakeAwaitingConsumption` (no further run is started for them) and
|
|
1053
|
+
// acknowledged, which is what stops the daemon from redelivering them
|
|
1054
|
+
// every freshness window — the loop that used to flood the orchestrator
|
|
1055
|
+
// session until manual sqlite surgery. Losing one update is the accepted
|
|
1056
|
+
// cost; see `writeOffStrandedWakeDelivery`.
|
|
1057
|
+
writeOffStrandedWakeDelivery(exhausted, ctx);
|
|
998
1058
|
}
|
|
999
1059
|
};
|
|
1000
1060
|
|
|
@@ -1193,11 +1253,17 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1193
1253
|
state.wakeTimer = undefined;
|
|
1194
1254
|
state.wakeRequested = true;
|
|
1195
1255
|
state.wakeRequestedThroughEventId = current.at(-1)?.eventId ?? 0;
|
|
1196
|
-
//
|
|
1197
|
-
// follow-up
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1256
|
+
// Single-track injection: the wake content is handed to Pi exactly
|
|
1257
|
+
// once, as the follow-up message below. An idle orchestrator gets a
|
|
1258
|
+
// triggered follow-up turn (immediate delivery); a busy one is not
|
|
1259
|
+
// interrupted and receives the same content as a queued,
|
|
1260
|
+
// non-triggering follow-up that a later run drains out of the
|
|
1261
|
+
// follow-up queue. There is deliberately no second copy on the
|
|
1262
|
+
// `context` hook: a run that is busy cannot act on an early copy
|
|
1263
|
+
// anyway, while a second copy is exactly what puts the same update
|
|
1264
|
+
// into the session twice. So the transcript holds one entry per event
|
|
1265
|
+
// id, and the continuation drive (see `driveWakeContinuation`) is what
|
|
1266
|
+
// carries a queued copy that no run drained into the transcript.
|
|
1201
1267
|
const orchestratorBusy = ctx.isIdle?.() === false;
|
|
1202
1268
|
const wakeContent = formatAgentOutcomeUpdates(batchOutcomes);
|
|
1203
1269
|
// Single line, injection path only: the decision that produced this
|
|
@@ -1238,17 +1304,17 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1238
1304
|
// `driveWakeContinuation`), and until the content is seen in the
|
|
1239
1305
|
// transcript the delivery is neither acknowledged nor injected again.
|
|
1240
1306
|
if (orchestratorBusy) {
|
|
1307
|
+
// A freshly queued id starts its own continuation budget; an id that
|
|
1308
|
+
// was already awaiting consumption (merged into this batch from an
|
|
1309
|
+
// earlier injection) keeps the drives it already spent. Only a new
|
|
1310
|
+
// id may get a fresh ceiling — never an unrelated one.
|
|
1241
1311
|
for (const outcome of batchOutcomes) {
|
|
1242
1312
|
state.wakeAwaitingConsumption.add(outcome.eventId);
|
|
1313
|
+
if (!state.wakeContinuationDrives.has(outcome.eventId)) {
|
|
1314
|
+
state.wakeContinuationDrives.set(outcome.eventId, 0);
|
|
1315
|
+
}
|
|
1243
1316
|
}
|
|
1244
|
-
state.wakeContinuationAttempts = 0;
|
|
1245
1317
|
}
|
|
1246
|
-
// A queued (non-triggering) delivery keeps the content available to
|
|
1247
|
-
// the current turn through the context hook until it is settled or
|
|
1248
|
-
// superseded by the next injection.
|
|
1249
|
-
state.wakeContext = orchestratorBusy
|
|
1250
|
-
? { content: wakeContent, eventIds: batchOutcomes.map((outcome) => outcome.eventId) }
|
|
1251
|
-
: undefined;
|
|
1252
1318
|
state.wakeForcedRelease = false;
|
|
1253
1319
|
state.wakeDeferredSince = undefined;
|
|
1254
1320
|
// Only expose the batch after the hidden context was accepted by pi. This
|
|
@@ -1488,7 +1554,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1488
1554
|
// already acked events are covered by the server cursor
|
|
1489
1555
|
// (pruneAcknowledgedEvents below).
|
|
1490
1556
|
state.deliveredBatch = undefined;
|
|
1491
|
-
state.wakeContext = undefined;
|
|
1492
1557
|
}
|
|
1493
1558
|
// Otherwise the batch's wake turn is still in flight: keep it so the
|
|
1494
1559
|
// settlement acknowledges it and the events are not re-presented.
|
|
@@ -1576,23 +1641,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1576
1641
|
state.currentScope.workspaceId === message.params.workspaceId
|
|
1577
1642
|
) {
|
|
1578
1643
|
const next = message.params.context ?? undefined;
|
|
1579
|
-
const retain = (snapshot: AgentWorkspaceContextSnapshot | undefined) =>
|
|
1580
|
-
snapshot
|
|
1581
|
-
? {
|
|
1582
|
-
...snapshot,
|
|
1583
|
-
agents: snapshot.agents.flatMap((agent) => {
|
|
1584
|
-
const nextAgents =
|
|
1585
|
-
next?.agents.filter((candidate) => candidate.paneId === agent.paneId) ?? [];
|
|
1586
|
-
if (nextAgents.length === 0) return [];
|
|
1587
|
-
const nextAgent = nextAgents.find(
|
|
1588
|
-
(candidate) => !agent.id || !candidate.id || agent.id === candidate.id,
|
|
1589
|
-
);
|
|
1590
|
-
return nextAgent ? [nextAgent] : [];
|
|
1591
|
-
}),
|
|
1592
|
-
}
|
|
1593
|
-
: undefined;
|
|
1594
1644
|
state.latestContext = next;
|
|
1595
|
-
state.pinnedContext = retain(state.pinnedContext);
|
|
1596
1645
|
}
|
|
1597
1646
|
return;
|
|
1598
1647
|
}
|
|
@@ -1796,6 +1845,9 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1796
1845
|
}
|
|
1797
1846
|
for (const eventId of consumedEventIds) {
|
|
1798
1847
|
state.wakeAwaitingConsumption.delete(eventId);
|
|
1848
|
+
// Its copy reached the transcript, so the id owes no drive any more and
|
|
1849
|
+
// its budget leaves with it.
|
|
1850
|
+
state.wakeContinuationDrives.delete(eventId);
|
|
1799
1851
|
// The content reached the transcript, so this id counts as presented from
|
|
1800
1852
|
// now on: a daemon redelivery of it (it is still unacknowledged whenever
|
|
1801
1853
|
// its batch was already dropped) must not inject the same update again.
|
|
@@ -1807,7 +1859,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1807
1859
|
// Only an id the daemon can still be told about is worth remembering.
|
|
1808
1860
|
if (state.unackedDelivered.has(eventId)) state.wakeConsumptionObserved.add(eventId);
|
|
1809
1861
|
}
|
|
1810
|
-
if (state.wakeAwaitingConsumption.size === 0) state.wakeContinuationAttempts = 0;
|
|
1811
1862
|
}
|
|
1812
1863
|
if (message.role !== "assistant") return;
|
|
1813
1864
|
const stopReason = stringValue(message.stopReason);
|
|
@@ -1846,63 +1897,25 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1846
1897
|
// the next user message.
|
|
1847
1898
|
if (state.runActive) return;
|
|
1848
1899
|
state.runActive = true;
|
|
1849
|
-
state.pinnedContext =
|
|
1850
|
-
state.isOrchestrator && !state.deliveredBatch?.herdsmanTriggered
|
|
1851
|
-
? state.latestContext
|
|
1852
|
-
: undefined;
|
|
1853
1900
|
});
|
|
1854
1901
|
|
|
1855
1902
|
pi.on("context", (event: { messages: PiAgentMessage[] }) => {
|
|
1903
|
+
// The wake content rides the single follow-up track and is never pinned
|
|
1904
|
+
// into the context here, so this hook injects nothing at all. What it
|
|
1905
|
+
// still does is drop herdsman's own context/wake entries from the
|
|
1906
|
+
// incoming list: a session replayed from disk can carry an old
|
|
1907
|
+
// `[HERDSMAN AGENT CONTEXT]` preview (its truncated one-line reports
|
|
1908
|
+
// repeatedly read as cut-off reports, and live board state is queried
|
|
1909
|
+
// through herdr), so those entries are filtered out instead of being
|
|
1910
|
+
// re-served to the model. The wake message itself
|
|
1911
|
+
// (`herdsman-wake-context`, the full report text) is not filtered - it is
|
|
1912
|
+
// the evidence the turn acts on.
|
|
1856
1913
|
const messages = event.messages.filter((message) => !isNormalHerdsmanContext(message));
|
|
1857
|
-
|
|
1858
|
-
const snapshot = state.pinnedContext;
|
|
1859
|
-
if (snapshot && snapshot.agents.length > 0) {
|
|
1860
|
-
additions.push({
|
|
1861
|
-
content: formatHiddenAgentContext({
|
|
1862
|
-
agents: snapshot.agents,
|
|
1863
|
-
workspaceId: snapshot.workspaceId,
|
|
1864
|
-
}),
|
|
1865
|
-
customType: "herdsman-agent-context",
|
|
1866
|
-
display: false,
|
|
1867
|
-
role: "custom",
|
|
1868
|
-
timestamp: Date.now(),
|
|
1869
|
-
});
|
|
1870
|
-
}
|
|
1871
|
-
// A wake queued for a busy orchestrator is not allowed to interrupt the
|
|
1872
|
-
// running tool chain, so its content is additionally pinned to the current
|
|
1873
|
-
// context: the orchestrator sees the child-agent outcome in this turn
|
|
1874
|
-
// without a triggered follow-up. The entry is dropped again by
|
|
1875
|
-
// isNormalHerdsmanContext, so at most one copy is present per call.
|
|
1876
|
-
//
|
|
1877
|
-
// `eventIds` mirrors what this turn actually presents (the freshly injected
|
|
1878
|
-
// outcomes): events carried over in the delivery queue were already shown
|
|
1879
|
-
// to the orchestrator in the turn that presented them, so they are not
|
|
1880
|
-
// re-listed here. The queued follow-up message itself keeps the wider
|
|
1881
|
-
// `details.eventIds` set (everything still unconfirmed).
|
|
1882
|
-
//
|
|
1883
|
-
// The pin is not a second delivery of the update: it makes the same queued
|
|
1884
|
-
// copy visible early to the turn that is running, it is not written to the
|
|
1885
|
-
// transcript, it is never acknowledged on its own, and it is not consumption
|
|
1886
|
-
// evidence — only the hidden wake message's `message_end` is. So it may not be
|
|
1887
|
-
// dropped in the name of "one copy only" either: it is the only way this turn
|
|
1888
|
-
// ever sees the update.
|
|
1889
|
-
const queuedWake = state.wakeContext;
|
|
1890
|
-
if (queuedWake) {
|
|
1891
|
-
additions.push({
|
|
1892
|
-
content: queuedWake.content,
|
|
1893
|
-
customType: "herdsman-wake-queued",
|
|
1894
|
-
details: { eventIds: queuedWake.eventIds },
|
|
1895
|
-
display: false,
|
|
1896
|
-
role: "custom",
|
|
1897
|
-
timestamp: Date.now(),
|
|
1898
|
-
});
|
|
1899
|
-
}
|
|
1900
|
-
return additions.length === 0 ? { messages } : { messages: [...messages, ...additions] };
|
|
1914
|
+
return { messages };
|
|
1901
1915
|
});
|
|
1902
1916
|
|
|
1903
1917
|
pi.on("agent_settled", async (_event: unknown, ctx: PiContext) => {
|
|
1904
1918
|
state.runActive = false;
|
|
1905
|
-
state.pinnedContext = undefined;
|
|
1906
1919
|
// A wake delivered as a queued follow-up is drained only when a run reaches
|
|
1907
1920
|
// its stop point. If the run that received it settled without draining it,
|
|
1908
1921
|
// no further run exists to carry the update out: drive a continuation (up to
|
|
@@ -1920,7 +1933,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1920
1933
|
state.wakeDeferredUntilSettled = false;
|
|
1921
1934
|
state.wakeDeferredSince = undefined;
|
|
1922
1935
|
state.wakeForcedRelease = false;
|
|
1923
|
-
state.wakeContext = undefined;
|
|
1924
1936
|
setHerdsmanUi(ctx);
|
|
1925
1937
|
scheduleWake(ctx);
|
|
1926
1938
|
};
|
|
@@ -1988,62 +2000,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
|
|
|
1988
2000
|
|
|
1989
2001
|
export default createHerdsmanPiExtension();
|
|
1990
2002
|
|
|
1991
|
-
export function formatHiddenAgentContext(input: {
|
|
1992
|
-
agents: AgentContextListItem[];
|
|
1993
|
-
workspaceId: string;
|
|
1994
|
-
}): string {
|
|
1995
|
-
return [
|
|
1996
|
-
"[HERDSMAN AGENT CONTEXT]",
|
|
1997
|
-
`Current Herdr workspace: ${input.workspaceId}`,
|
|
1998
|
-
...input.agents.map((agent) => {
|
|
1999
|
-
const history = agent.history ?? {};
|
|
2000
|
-
const identity = agentIdentityLabel({
|
|
2001
|
-
agent: agent.agent ?? "unknown",
|
|
2002
|
-
name: agent.name,
|
|
2003
|
-
});
|
|
2004
|
-
const paneId = agent.paneId ?? "unknown";
|
|
2005
|
-
const status = agent.agentStatus ?? "unknown";
|
|
2006
|
-
const prefix = `- ${identity} ${paneId} ${status}`;
|
|
2007
|
-
|
|
2008
|
-
const rawAgent = record(agent);
|
|
2009
|
-
const tabTitleCandidate =
|
|
2010
|
-
stringValue(rawAgent.terminalTitle) ?? stringValue(rawAgent.label);
|
|
2011
|
-
const tabTitleCleaned =
|
|
2012
|
-
tabTitleCandidate !== null ? sanitizeAndCleanContextText(tabTitleCandidate) : "";
|
|
2013
|
-
const tabTitle =
|
|
2014
|
-
tabTitleCleaned.length > 0 ? truncateSummary(tabTitleCleaned, 60) : null;
|
|
2015
|
-
|
|
2016
|
-
const rawHistory = record(history);
|
|
2017
|
-
const lastAssistantRecord = record(history.lastAssistantMessage);
|
|
2018
|
-
const timeCandidate =
|
|
2019
|
-
lastAssistantRecord.timestamp ??
|
|
2020
|
-
history.updatedAt ??
|
|
2021
|
-
rawHistory.updatedAt ??
|
|
2022
|
-
rawAgent.updatedAt ??
|
|
2023
|
-
rawAgent.time;
|
|
2024
|
-
const formattedTime = formatTimestamp(timeCandidate);
|
|
2025
|
-
|
|
2026
|
-
const assistantRaw = history.lastAssistantMessage?.text;
|
|
2027
|
-
const assistantCleaned =
|
|
2028
|
-
assistantRaw !== undefined && assistantRaw !== null
|
|
2029
|
-
? sanitizeAndCleanContextText(assistantRaw)
|
|
2030
|
-
: "";
|
|
2031
|
-
const assistantSummary =
|
|
2032
|
-
assistantCleaned.length > 0 ? truncateSummary(assistantCleaned, 100) : null;
|
|
2033
|
-
|
|
2034
|
-
const segments = [
|
|
2035
|
-
prefix,
|
|
2036
|
-
tabTitle,
|
|
2037
|
-
formattedTime,
|
|
2038
|
-
assistantSummary,
|
|
2039
|
-
].filter((segment): segment is string => segment !== null && segment.length > 0);
|
|
2040
|
-
|
|
2041
|
-
return segments.join(" · ");
|
|
2042
|
-
}),
|
|
2043
|
-
"Use herdsman agent get/read if details are needed.",
|
|
2044
|
-
].join("\n");
|
|
2045
|
-
}
|
|
2046
|
-
|
|
2047
2003
|
export function formatHiddenAgentUpdates(events: AgentEventWireRecord[]): string {
|
|
2048
2004
|
return [
|
|
2049
2005
|
"[HERDSMAN AGENT UPDATES]",
|
|
@@ -2066,6 +2022,10 @@ export function formatHiddenAgentUpdates(events: AgentEventWireRecord[]): string
|
|
|
2066
2022
|
function isNormalHerdsmanContext(message: PiAgentMessage): boolean {
|
|
2067
2023
|
return (
|
|
2068
2024
|
message.customType === "herdsman-agent-context" ||
|
|
2025
|
+
// Defensive for session replay: older versions pinned a queued wake to the
|
|
2026
|
+
// context hook under this customType. A replayed history may still carry
|
|
2027
|
+
// those entries, and they are herdsman's own, so they are dropped from the
|
|
2028
|
+
// incoming list like the agent-context ones (see the context hook).
|
|
2069
2029
|
message.customType === "herdsman-wake-queued" ||
|
|
2070
2030
|
contentIncludesMarker(message.content, "[HERDSMAN AGENT CONTEXT]")
|
|
2071
2031
|
);
|
|
@@ -2119,12 +2079,22 @@ function record(value: unknown): Record<string, unknown> {
|
|
|
2119
2079
|
}
|
|
2120
2080
|
|
|
2121
2081
|
function cleanContextText(value: string): string {
|
|
2082
|
+
// Same newline-preserving scheme as `normalizeExcerpt` in wake.ts: which byte a
|
|
2083
|
+
// line break is (CRLF, CR, U+2028/U+2029, NEL) is normalised to LF, trailing
|
|
2084
|
+
// whitespace is dropped per line, 3+ newlines collapse to a blank line, and
|
|
2085
|
+
// leading indentation plus inline runs of whitespace are kept as they are.
|
|
2086
|
+
// NEL (U+0085) is excluded from the control-character clearing regex below,
|
|
2087
|
+
// which would otherwise delete it as an unprintable byte; the rest of that
|
|
2088
|
+
// regex is left intact.
|
|
2122
2089
|
return stripVTControlCharacters(value)
|
|
2123
2090
|
.replace(
|
|
2124
|
-
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
|
|
2091
|
+
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u0084\u0086-\u009f\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
|
|
2125
2092
|
"",
|
|
2126
2093
|
)
|
|
2127
|
-
.replace(/\
|
|
2094
|
+
.replace(/\r\n?/g, "\n")
|
|
2095
|
+
.replace(/[\u2028\u2029\u0085]/g, "\n")
|
|
2096
|
+
.replace(/[^\S\n]+$/gm, "")
|
|
2097
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
2128
2098
|
.trim();
|
|
2129
2099
|
}
|
|
2130
2100
|
|
|
@@ -2135,32 +2105,3 @@ function sanitizeAndCleanContextText(value: string): string {
|
|
|
2135
2105
|
return cleanContextText(sanitized);
|
|
2136
2106
|
}
|
|
2137
2107
|
|
|
2138
|
-
function truncateSummary(value: string, limit = 100): string {
|
|
2139
|
-
if (value.length <= limit) return value;
|
|
2140
|
-
const maxSearch = Math.min(value.length, limit + 8);
|
|
2141
|
-
const searchSlice = value.slice(0, maxSearch);
|
|
2142
|
-
const lastSpaceIndex = searchSlice.search(/\s[^\s]*$/);
|
|
2143
|
-
if (lastSpaceIndex >= limit - 20) {
|
|
2144
|
-
return `${value.slice(0, lastSpaceIndex)}…`;
|
|
2145
|
-
}
|
|
2146
|
-
return `${value.slice(0, limit)}…`;
|
|
2147
|
-
}
|
|
2148
|
-
|
|
2149
|
-
function formatTimestamp(value: unknown): string | null {
|
|
2150
|
-
if (typeof value === "string") {
|
|
2151
|
-
const trimmed = value.trim();
|
|
2152
|
-
if (/^\d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
|
|
2153
|
-
if (/^\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed;
|
|
2154
|
-
}
|
|
2155
|
-
if (!value) return null;
|
|
2156
|
-
const date = value instanceof Date ? value : new Date(value as string | number);
|
|
2157
|
-
if (Number.isNaN(date.getTime())) return null;
|
|
2158
|
-
const pad = (n: number) => String(n).padStart(2, "0");
|
|
2159
|
-
const month = pad(date.getMonth() + 1);
|
|
2160
|
-
const day = pad(date.getDate());
|
|
2161
|
-
const hours = pad(date.getHours());
|
|
2162
|
-
const minutes = pad(date.getMinutes());
|
|
2163
|
-
const seconds = pad(date.getSeconds());
|
|
2164
|
-
return `${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
2165
|
-
}
|
|
2166
|
-
|
package/src/wake.ts
CHANGED
|
@@ -34,7 +34,26 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|
|
34
34
|
function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; }
|
|
35
35
|
function normalizeExcerpt(value: unknown): string {
|
|
36
36
|
const raw = stringValue(value) ?? "";
|
|
37
|
-
|
|
37
|
+
// Line structure is evidence, not noise: a code block or a markdown heading
|
|
38
|
+
// only reads as one while its newlines survive, so this chain normalises
|
|
39
|
+
// *which byte* a line break is and where whitespace sits inside a line, and
|
|
40
|
+
// never folds a newline into a space:
|
|
41
|
+
// - CRLF/CR and the Unicode line separators (U+2028, U+2029, NEL) become LF,
|
|
42
|
+
// so "a line" means one thing everywhere downstream (NEL is excluded from
|
|
43
|
+
// the control-character regex, which would otherwise delete it);
|
|
44
|
+
// - trailing whitespace is dropped per line, so a "blank" line padded with
|
|
45
|
+
// spaces still collapses (it would otherwise defeat the 3+ newline rule);
|
|
46
|
+
// - three or more newlines collapse to a single blank line;
|
|
47
|
+
// - leading indentation and inline runs of whitespace are kept as they are,
|
|
48
|
+
// because they are what keeps a code block readable.
|
|
49
|
+
// No length cap is applied here.
|
|
50
|
+
return stripVTControlCharacters(raw)
|
|
51
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u0084\u0086-\u009f]/g, "")
|
|
52
|
+
.replace(/\r\n?/g, "\n")
|
|
53
|
+
.replace(/[\u2028\u2029\u0085]/g, "\n")
|
|
54
|
+
.replace(/[^\S\n]+$/gm, "")
|
|
55
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
56
|
+
.trim();
|
|
38
57
|
}
|
|
39
58
|
function outcomeKind(event: AgentEventWireRecord): AgentOutcome["kind"] | undefined {
|
|
40
59
|
if (!event.terminalId) return undefined;
|