switchroom 0.19.41 → 0.19.43
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/dist/agent-scheduler/index.js +23 -3
- package/dist/auth-broker/index.js +54 -9
- package/dist/cli/notion-write-pretool.mjs +23 -3
- package/dist/cli/switchroom.js +1956 -900
- package/dist/host-control/main.js +227 -30
- package/dist/vault/approvals/kernel-server.js +54 -9
- package/dist/vault/broker/server.js +54 -9
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +82 -4
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1318 -693
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +56 -58
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +62 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/CHANGELOG.md +28 -0
- package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
- package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +18 -3
- package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
- package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
- package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reply that was rate-limited and then DELIVERED on retry must not raise a
|
|
3
|
+
* fleet-health delivery-failure alert (#3931).
|
|
4
|
+
*
|
|
5
|
+
* ── The bug ──────────────────────────────────────────────────────────────
|
|
6
|
+
* `installTgPostLogger` is a grammy API transformer, so it runs INSIDE the
|
|
7
|
+
* function the retry policy calls — it observes one POST ATTEMPT, never the
|
|
8
|
+
* logical send's outcome. A 429 that `createRetryApiCall` slept and retried
|
|
9
|
+
* successfully therefore left a `tg-post method=sendRichMessage … status=err`
|
|
10
|
+
* line in the gateway log, and fleet-health's `reply-delivery-failure`
|
|
11
|
+
* signature (`src/fleet-health/detect.ts`) matches per LINE. Result: a
|
|
12
|
+
* severity-3 "the answer never reached the principal" escalation for an answer
|
|
13
|
+
* the operator had already read. Under flood pressure (the class of event that
|
|
14
|
+
* produces 429s in the first place) that is the alarm that fires most.
|
|
15
|
+
*
|
|
16
|
+
* ── The fix these tests pin ──────────────────────────────────────────────
|
|
17
|
+
* The retry policy publishes its attempt context; the transformer labels an
|
|
18
|
+
* attempt the policy is about to repeat `status=retry`. `status=err` now means
|
|
19
|
+
* the logical send is OVER and nothing landed. Nothing is suppressed — the
|
|
20
|
+
* retry lines are still written, they just no longer masquerade as outcomes.
|
|
21
|
+
*
|
|
22
|
+
* These tests drive a REAL grammy `Bot` (a mock-`api` harness sits above the
|
|
23
|
+
* transformer layer, so it would be a false guard) through the REAL retry
|
|
24
|
+
* policy, and feed the REAL emitted lines to the REAL detector.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
27
|
+
import { Bot } from 'grammy'
|
|
28
|
+
import { installTgPostLogger } from '../shared/bot-runtime.js'
|
|
29
|
+
import { createRetryApiCall, willRetryTelegramFailure } from '../retry-api-call.js'
|
|
30
|
+
import { detectGatewayFindings, scanAgent } from '../../src/fleet-health/detect.js'
|
|
31
|
+
|
|
32
|
+
/** A real Bot whose transport answers with `envelopes[i]` for the i-th call. */
|
|
33
|
+
function makeBot(envelopes: Array<Record<string, unknown>>): Bot {
|
|
34
|
+
let i = 0
|
|
35
|
+
const fakeFetch = (async () => {
|
|
36
|
+
const envelope = envelopes[Math.min(i, envelopes.length - 1)]
|
|
37
|
+
i++
|
|
38
|
+
// A Bot API rejection is an HTTP 200 carrying `ok:false` — which is
|
|
39
|
+
// precisely why it RESOLVES the transformer chain (see #3927).
|
|
40
|
+
return { ok: true, status: 200, json: async () => envelope } as unknown as Response
|
|
41
|
+
}) as unknown as typeof fetch
|
|
42
|
+
|
|
43
|
+
const bot = new Bot('123456:TEST_TOKEN', {
|
|
44
|
+
botInfo: {
|
|
45
|
+
id: 123456,
|
|
46
|
+
is_bot: true,
|
|
47
|
+
first_name: 'Test',
|
|
48
|
+
username: 'test_bot',
|
|
49
|
+
can_join_groups: false,
|
|
50
|
+
can_read_all_group_messages: false,
|
|
51
|
+
supports_inline_queries: false,
|
|
52
|
+
can_connect_to_business: false,
|
|
53
|
+
has_main_web_app: false,
|
|
54
|
+
},
|
|
55
|
+
client: { fetch: fakeFetch },
|
|
56
|
+
})
|
|
57
|
+
installTgPostLogger(bot)
|
|
58
|
+
return bot
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const OK_ENVELOPE = {
|
|
62
|
+
ok: true,
|
|
63
|
+
result: { message_id: 7, date: 0, chat: { id: 4242, type: 'private' } },
|
|
64
|
+
}
|
|
65
|
+
const FLOOD_429 = {
|
|
66
|
+
ok: false,
|
|
67
|
+
error_code: 429,
|
|
68
|
+
description: 'Too Many Requests: retry after 3',
|
|
69
|
+
parameters: { retry_after: 3 },
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Send one user-facing reply through the production composition
|
|
74
|
+
* (retry policy → transformer → wire) and return the gateway log lines it
|
|
75
|
+
* produced, shaped exactly as `gateway-supervisor.log` carries them.
|
|
76
|
+
*/
|
|
77
|
+
async function gatewayLinesForReply(
|
|
78
|
+
envelopes: Array<Record<string, unknown>>,
|
|
79
|
+
): Promise<{ lines: string[]; threw: boolean }> {
|
|
80
|
+
const written: string[] = []
|
|
81
|
+
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
|
|
82
|
+
written.push(String(chunk))
|
|
83
|
+
return true
|
|
84
|
+
}) as unknown as typeof process.stderr.write)
|
|
85
|
+
let threw = false
|
|
86
|
+
try {
|
|
87
|
+
const bot = makeBot(envelopes)
|
|
88
|
+
// The real policy, with sleeping stubbed out so a 3s flood-wait doesn't
|
|
89
|
+
// cost the suite 3 seconds. Everything else is production behaviour.
|
|
90
|
+
const robustApiCall = createRetryApiCall({ sleep: async () => {} })
|
|
91
|
+
await robustApiCall(() =>
|
|
92
|
+
bot.api.sendRichMessage(4242, { markdown: 'the answer' }),
|
|
93
|
+
).catch(() => {
|
|
94
|
+
threw = true
|
|
95
|
+
})
|
|
96
|
+
} finally {
|
|
97
|
+
spy.mockRestore()
|
|
98
|
+
}
|
|
99
|
+
const lines = written
|
|
100
|
+
.filter((l) => l.startsWith('tg-post '))
|
|
101
|
+
.map((l) => `2026-08-01T12:00:00Z gateway: ${l.trimEnd()}`)
|
|
102
|
+
return { lines, threw }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
vi.restoreAllMocks()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('#3931 — a retried-and-delivered reply raises no delivery-failure alert', () => {
|
|
110
|
+
it('429-then-ok: fleet-health sees zero reply-delivery-failures and does not escalate', async () => {
|
|
111
|
+
const { lines, threw } = await gatewayLinesForReply([FLOOD_429, OK_ENVELOPE])
|
|
112
|
+
|
|
113
|
+
// Ground truth: the reply WAS delivered. If this ever fails, the fixture
|
|
114
|
+
// stopped modelling the scenario and the assertions below mean nothing.
|
|
115
|
+
expect(threw).toBe(false)
|
|
116
|
+
expect(lines).toHaveLength(2)
|
|
117
|
+
expect(lines[1]).toContain('status=ok')
|
|
118
|
+
|
|
119
|
+
// THE outcome: the detector raises nothing, and the agent is not escalated.
|
|
120
|
+
const { gw_hits, findings } = detectGatewayFindings('alpha', lines.join('\n'))
|
|
121
|
+
expect(gw_hits['reply-delivery-failure']).toBe(0)
|
|
122
|
+
expect(findings.filter((f) => f.signal === 'reply-delivery-failure')).toHaveLength(0)
|
|
123
|
+
expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(false)
|
|
124
|
+
|
|
125
|
+
// The evidence is not hidden — the rate-limit attempt is still on the log,
|
|
126
|
+
// honestly labelled as an attempt rather than an outcome.
|
|
127
|
+
expect(lines[0]).toContain('status=retry')
|
|
128
|
+
expect(lines[0]).toContain('code=429')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('an ok:true reply is untouched — no retry tier leaks onto the happy path', async () => {
|
|
132
|
+
const { lines, threw } = await gatewayLinesForReply([OK_ENVELOPE])
|
|
133
|
+
expect(threw).toBe(false)
|
|
134
|
+
expect(lines).toHaveLength(1)
|
|
135
|
+
expect(lines[0]).toContain('status=ok')
|
|
136
|
+
expect(lines[0]).toContain('err=- code=- desc=-')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('#3931 — a genuinely undelivered reply still escalates', () => {
|
|
141
|
+
it('a terminal 403 escalates', async () => {
|
|
142
|
+
const { lines, threw } = await gatewayLinesForReply([
|
|
143
|
+
{ ok: false, error_code: 403, description: 'Forbidden: bot was blocked by the user' },
|
|
144
|
+
])
|
|
145
|
+
expect(threw).toBe(true)
|
|
146
|
+
expect(lines).toHaveLength(1)
|
|
147
|
+
expect(lines[0]).toContain('status=err')
|
|
148
|
+
|
|
149
|
+
expect(detectGatewayFindings('alpha', lines.join('\n')).gw_hits['reply-delivery-failure']).toBe(1)
|
|
150
|
+
expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('a 429 on EVERY attempt (retries exhausted, nothing landed) escalates exactly once', async () => {
|
|
154
|
+
const { lines, threw } = await gatewayLinesForReply([FLOOD_429])
|
|
155
|
+
expect(threw).toBe(true)
|
|
156
|
+
// Three attempts: two survivable, the last one terminal.
|
|
157
|
+
expect(lines).toHaveLength(3)
|
|
158
|
+
expect(lines.filter((l) => l.includes('status=retry'))).toHaveLength(2)
|
|
159
|
+
expect(lines.filter((l) => l.includes('status=err'))).toHaveLength(1)
|
|
160
|
+
|
|
161
|
+
// One alert for one lost answer — not one per attempt.
|
|
162
|
+
expect(detectGatewayFindings('alpha', lines.join('\n')).gw_hits['reply-delivery-failure']).toBe(1)
|
|
163
|
+
expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(true)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('willRetryTelegramFailure — the predicate mirrors the retry loop', () => {
|
|
168
|
+
const ctx = { attempt: 0, maxRetries: 3, maxFloodSleepMs: 120_000 }
|
|
169
|
+
|
|
170
|
+
it('is false with no enclosing retry policy (a raw bot.api.* call is its own outcome)', () => {
|
|
171
|
+
expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, undefined)).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('is false on the last attempt — the loop falls through to give-up', () => {
|
|
175
|
+
expect(
|
|
176
|
+
willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, { ...ctx, attempt: 2 }),
|
|
177
|
+
).toBe(false)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('is false for a 429 over the in-process sleep ceiling (FLOOD_WAIT_ACTIVE, not retried)', () => {
|
|
181
|
+
expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 15908 }, ctx)).toBe(false)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('is true for a 429 under the ceiling and for a transient transport error', () => {
|
|
185
|
+
expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, ctx)).toBe(true)
|
|
186
|
+
expect(willRetryTelegramFailure({ message: 'ECONNRESET' }, ctx)).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('is false for the error classes the loop rethrows immediately', () => {
|
|
190
|
+
expect(willRetryTelegramFailure({ errorCode: 400, message: 'Bad Request' }, ctx)).toBe(false)
|
|
191
|
+
expect(willRetryTelegramFailure({ errorCode: 403 }, ctx)).toBe(false)
|
|
192
|
+
// Local disk exhaustion must NOT look retryable — retrying it is what
|
|
193
|
+
// trips the flood ban in the first place (#2923).
|
|
194
|
+
expect(willRetryTelegramFailure({ message: 'ENOSPC: no space left on device' }, ctx)).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -14,7 +14,13 @@ import {
|
|
|
14
14
|
STEP_TIMER_MIN_MS,
|
|
15
15
|
type SessionActivityHeader,
|
|
16
16
|
} from "../tool-activity-summary.js";
|
|
17
|
-
import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from "../status-no-truncate.js";
|
|
17
|
+
import { STATUS_ROLLING_LINES, STATUS_LINE_MAX, NESTED_PREFIX } from "../status-no-truncate.js";
|
|
18
|
+
|
|
19
|
+
// The nested (foreground sub-agent) child-block indent. Asserted through the
|
|
20
|
+
// constant, never as a literal: #3668 replaced its ASCII spaces with U+2800
|
|
21
|
+
// (Telegram left-trims a leading ASCII/Zs run off a content line), and a test
|
|
22
|
+
// that hardcodes the bytes pins the defect instead of the behaviour.
|
|
23
|
+
const NP = NESTED_PREFIX;
|
|
18
24
|
import { COLLAPSE_SAFE_SEPARATOR } from "../card-format.js";
|
|
19
25
|
|
|
20
26
|
/** The pinned-card collapse separator (#3666) every status-card line now
|
|
@@ -352,29 +358,29 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
352
358
|
expect(out).toContain("~~_✓ Delegating: review the migration_~~");
|
|
353
359
|
expect(out).not.toContain("**→ Delegating");
|
|
354
360
|
// The live → step is the newest nested child line; earlier child = italic.
|
|
355
|
-
expect(out).toContain("
|
|
356
|
-
expect(out).toContain("
|
|
361
|
+
expect(out).toContain(NP + "~~_Reading schema.ts_~~");
|
|
362
|
+
expect(out).toContain(NP + "**→ Looking for foreign keys**");
|
|
357
363
|
});
|
|
358
364
|
|
|
359
365
|
it("windows the nested block to STATUS_ROLLING_LINES with a '↳ +N earlier…' header", () => {
|
|
360
366
|
const total = STATUS_ROLLING_LINES + 3;
|
|
361
367
|
const child = Array.from({ length: total }, (_, i) => `step ${i + 1}`);
|
|
362
368
|
const out = renderActivityFeedWithNested(["Delegating: x"], child)!;
|
|
363
|
-
expect(out).toContain(
|
|
369
|
+
expect(out).toContain(`${NP}_+${total - STATUS_ROLLING_LINES} earlier…_`);
|
|
364
370
|
// newest nested line is the live → step
|
|
365
|
-
expect(out).toContain(
|
|
371
|
+
expect(out).toContain(`${NP}**→ step ${total}**`);
|
|
366
372
|
// the oldest (collapsed) lines are not rendered verbatim
|
|
367
373
|
expect(out).not.toContain("step 1<");
|
|
368
374
|
});
|
|
369
375
|
|
|
370
376
|
it("renders the child block even when the parent feed is empty", () => {
|
|
371
377
|
const out = renderActivityFeedWithNested([], ["Reading a.ts"]);
|
|
372
|
-
expect(out).toBe("
|
|
378
|
+
expect(out).toBe(NP + "**→ Reading a.ts**");
|
|
373
379
|
});
|
|
374
380
|
|
|
375
381
|
it("markdown-escapes nested child text (emphasis specials only)", () => {
|
|
376
382
|
const out = renderActivityFeedWithNested(["Delegating: x"], ["touch a_b & 2 * 3"])!;
|
|
377
|
-
expect(out).toContain("
|
|
383
|
+
expect(out).toContain(NP + "**→ touch a\\_b & 2 \\* 3**");
|
|
378
384
|
});
|
|
379
385
|
|
|
380
386
|
it("final=true: the nested newest step renders done (✓), not in-progress (→)", () => {
|
|
@@ -383,7 +389,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
383
389
|
["Reading schema.ts", "Looking for foreign keys"],
|
|
384
390
|
true,
|
|
385
391
|
)!;
|
|
386
|
-
expect(out).toContain("
|
|
392
|
+
expect(out).toContain(NP + "~~_Looking for foreign keys_~~"); // newest now struck italic done
|
|
387
393
|
expect(out).not.toContain("→"); // no in-progress arrow in the finalized feed
|
|
388
394
|
});
|
|
389
395
|
|
|
@@ -400,7 +406,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
400
406
|
false,
|
|
401
407
|
" · 22s",
|
|
402
408
|
)!;
|
|
403
|
-
expect(out).toContain("
|
|
409
|
+
expect(out).toContain(NP + "**→ Looking for foreign keys · 22s**");
|
|
404
410
|
expect(out).not.toContain("Reading schema.ts · "); // only the newest line ticks
|
|
405
411
|
});
|
|
406
412
|
|
|
@@ -567,7 +573,7 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
|
|
|
567
573
|
expect(out).not.toContain(`Parent ${i}`);
|
|
568
574
|
}
|
|
569
575
|
expect(out).toContain(`_✓ +${totalParent - STATUS_ROLLING_LINES} earlier…_`);
|
|
570
|
-
expect(out).toContain("
|
|
576
|
+
expect(out).toContain(NP + "**→ Child step B**");
|
|
571
577
|
});
|
|
572
578
|
|
|
573
579
|
it("many child lines → only the last STATUS_ROLLING_LINES child lines render with a ↳ +N earlier header", () => {
|
|
@@ -582,8 +588,8 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
|
|
|
582
588
|
for (let i = 1; i < firstVisible; i++) {
|
|
583
589
|
expect(out).not.toContain(`Child ${i}`);
|
|
584
590
|
}
|
|
585
|
-
expect(out).toContain(
|
|
586
|
-
expect(out).toContain(
|
|
591
|
+
expect(out).toContain(`${NP}_+${totalChild - STATUS_ROLLING_LINES} earlier…_`);
|
|
592
|
+
expect(out).toContain(`${NP}**→ Child ${totalChild}**`);
|
|
587
593
|
});
|
|
588
594
|
|
|
589
595
|
it("STATUS_LINE_MAX=200: a 250-char child line is clipped to 200 (both surfaces)", () => {
|
|
@@ -600,7 +606,7 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
|
|
|
600
606
|
const child = Array.from({ length: STATUS_ROLLING_LINES }, () => bigLine);
|
|
601
607
|
const out = renderActivityFeedWithNested(parent, child)!;
|
|
602
608
|
expect(out.length).toBeLessThanOrEqual(4096);
|
|
603
|
-
expect(out).toContain(
|
|
609
|
+
expect(out).toContain(NP);
|
|
604
610
|
});
|
|
605
611
|
});
|
|
606
612
|
|
|
@@ -678,11 +684,11 @@ describe("extreme-edge: single oversized line with markdown specials & && _ *",
|
|
|
678
684
|
expect(out.length).toBeLessThanOrEqual(4000);
|
|
679
685
|
expect(isValidMarkdown(out)).toBe(true);
|
|
680
686
|
// Must contain the nested prefix.
|
|
681
|
-
expect(out).toContain(
|
|
687
|
+
expect(out).toContain(NP);
|
|
682
688
|
// Regression guard: an operator-precedence bug made wrapperOverhead a string
|
|
683
689
|
// (NESTED_PREFIX + n) → NaN budget → slice(0, NaN) === "" → the child content
|
|
684
|
-
// was silently discarded, leaving only
|
|
685
|
-
// output still satisfies the toContain(
|
|
690
|
+
// was silently discarded, leaving only `${NESTED_PREFIX}**→ **`. The empty-wrapper
|
|
691
|
+
// output still satisfies the toContain(NP) check above, so assert that
|
|
686
692
|
// real child content actually survives.
|
|
687
693
|
expect(out).toContain("Child step");
|
|
688
694
|
expect(out.length).toBeGreaterThan(100);
|
|
@@ -1025,7 +1031,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
|
|
|
1025
1031
|
// Parent step is done-styled (child is the live step).
|
|
1026
1032
|
expect(out).toContain("~~_✓ Delegating: review_~~");
|
|
1027
1033
|
// Child step is the in-progress step.
|
|
1028
|
-
expect(out).toContain("
|
|
1034
|
+
expect(out).toContain(NP + "**→ Reading schema.ts**");
|
|
1029
1035
|
});
|
|
1030
1036
|
});
|
|
1031
1037
|
|
|
@@ -389,13 +389,14 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
389
389
|
* an already-escaped markdown escape is never sliced.
|
|
390
390
|
*/
|
|
391
391
|
const deepest = Math.max(1, escapedBody.length)
|
|
392
|
+
/** The collapsed parent trail, kept at every shrink level below level 0. */
|
|
393
|
+
const markerSection: CardSection = {
|
|
394
|
+
steps: [],
|
|
395
|
+
window: 1,
|
|
396
|
+
placeholder: parentMarker ?? undefined,
|
|
397
|
+
}
|
|
392
398
|
return fitCardToBudget((level) => {
|
|
393
399
|
if (level === 0) return { spec: fullSpec() }
|
|
394
|
-
const markerSection: CardSection = {
|
|
395
|
-
steps: [],
|
|
396
|
-
window: 1,
|
|
397
|
-
placeholder: parentMarker ?? undefined,
|
|
398
|
-
}
|
|
399
400
|
if (level < escapedBody.length) {
|
|
400
401
|
return {
|
|
401
402
|
spec: {
|
|
@@ -427,39 +428,66 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
427
428
|
}
|
|
428
429
|
}, deepest)
|
|
429
430
|
|
|
431
|
+
/** The deepest shrink level's spec, for a given already-wrapped body line. */
|
|
432
|
+
function deepestSpec(bodyLine: string): CardSpec {
|
|
433
|
+
return {
|
|
434
|
+
chrome,
|
|
435
|
+
sections: [markerSection, { steps: [], window: 1, placeholder: bodyLine }],
|
|
436
|
+
footer,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Wrap an ALREADY-ESCAPED newest step in its bullet chrome. */
|
|
441
|
+
function wrapNewest(escaped: string): string {
|
|
442
|
+
return final
|
|
443
|
+
? `${bodyIndent}_✓ ${escaped}_`
|
|
444
|
+
: `${bodyIndent}**→ ${escaped}${liveSuffix}**`
|
|
445
|
+
}
|
|
446
|
+
|
|
430
447
|
/**
|
|
431
|
-
* The deepest shrink level's single bullet
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
448
|
+
* The deepest shrink level's single bullet: clip the RAW newest text to
|
|
449
|
+
* whatever the fixed chrome/footer leaves of `STATUS_CARD_CHAR_BUDGET`,
|
|
450
|
+
* escape, then re-measure — escaping can expand the string (`&` → `&`).
|
|
451
|
+
*
|
|
452
|
+
* Everything is measured by RENDERING the level's real spec (#3833). The
|
|
453
|
+
* previous arithmetic estimate charged `[...chrome, ...footer].join('\n')`,
|
|
454
|
+
* but the renderer is `stackCard`, which emits a collapse separator plus a
|
|
455
|
+
* two-space GFM hard break at every line boundary — 4 chars, not 1 — and it
|
|
456
|
+
* also charges the marker line and the section boundaries the estimate only
|
|
457
|
+
* partly counted. That under-count let a card render OVER the wire budget.
|
|
458
|
+
* Measuring with the renderer cannot drift from it by construction.
|
|
435
459
|
*/
|
|
436
460
|
function truncatedNewestLine(): string {
|
|
437
|
-
const fixedCost = [...chrome, ...footer].join('\n').length
|
|
438
461
|
const rawNewest = rawBody.length > 0 ? cleanStepLine(rawBody[rawBody.length - 1]) : ''
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
let
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
raw.length
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
newest = escapeMarkdown(raw)
|
|
462
|
+
const empty = wrapNewest('')
|
|
463
|
+
const fixedCost = renderCardSpec(deepestSpec(empty)).length
|
|
464
|
+
const budget = STATUS_CARD_CHAR_BUDGET - fixedCost
|
|
465
|
+
// Chrome alone is over budget (a 40k-char worker description, #3682). No
|
|
466
|
+
// amount of body clipping helps; `fitCardToBudget`'s hard-truncate backstop
|
|
467
|
+
// is what keeps the card under the wire limit in that case.
|
|
468
|
+
if (budget <= 0) return empty
|
|
469
|
+
let raw = clipRaw(rawNewest, budget)
|
|
470
|
+
let line = wrapNewest(escapeMarkdown(raw))
|
|
471
|
+
for (;;) {
|
|
472
|
+
const excess = renderCardSpec(deepestSpec(line)).length - STATUS_CARD_CHAR_BUDGET
|
|
473
|
+
if (excess <= 0 || raw.length === 0) return line
|
|
474
|
+
// Dropping k RAW chars drops at least k escaped chars (escaping never
|
|
475
|
+
// shrinks), so this strictly converges.
|
|
476
|
+
raw = clipRaw(raw, Math.max(0, raw.length - excess))
|
|
477
|
+
line = wrapNewest(escapeMarkdown(raw))
|
|
456
478
|
}
|
|
457
|
-
return final
|
|
458
|
-
? `${bodyIndent}_✓ ${newest}_`
|
|
459
|
-
: `${bodyIndent}**→ ${newest}${liveSuffix}**`
|
|
460
479
|
}
|
|
461
480
|
}
|
|
462
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Slice raw (pre-escape) text to `n` chars without leaving a dangling lone
|
|
484
|
+
* high surrogate — an unpaired surrogate is invalid UTF-16 on the wire.
|
|
485
|
+
*/
|
|
486
|
+
function clipRaw(text: string, n: number): string {
|
|
487
|
+
const out = text.slice(0, Math.max(0, n))
|
|
488
|
+
return /[\uD800-\uDBFF]$/.test(out) ? out.slice(0, -1) : out
|
|
489
|
+
}
|
|
490
|
+
|
|
463
491
|
/** Subtle horizontal rule between the running feed and the finished result. */
|
|
464
492
|
const WORKER_RESULT_RULE = '─────'
|
|
465
493
|
/** Hard cap on the terminal result paragraph. */
|
|
@@ -51,8 +51,9 @@ const FG_DISPATCH_PROMPT =
|
|
|
51
51
|
`one-line summary." Wait for the foreground sub-agent to finish, then send ` +
|
|
52
52
|
`me a brief reply telling me it's done.`;
|
|
53
53
|
|
|
54
|
-
// The nested foreground block renders each child line prefixed with
|
|
55
|
-
//
|
|
54
|
+
// The nested foreground block renders each child line prefixed with a three
|
|
55
|
+
// U+2800 indent then "↳" (NESTED_PREFIX; #3668 replaced the old ASCII spaces,
|
|
56
|
+
// which Telegram left-trims), newest as a bold "→ …" current step. Telegram
|
|
56
57
|
// strips the bold but keeps the literal ↳ / → glyphs in message text.
|
|
57
58
|
const NESTED_RE = /↳/;
|
|
58
59
|
|
|
@@ -54,6 +54,34 @@
|
|
|
54
54
|
|
|
55
55
|
### Changed (switchroom divergence)
|
|
56
56
|
|
|
57
|
+
- **Recall/retain hygiene guard-rail batch** (`scripts/recall.py`,
|
|
58
|
+
`scripts/subagent_retain.py`, `scripts/lib/content.py`, `scripts/tests/**`).
|
|
59
|
+
Closes guard-rail debt in the fork's hook scripts before extending them:
|
|
60
|
+
- **#3766** — `shape_recall_query` no longer drops a single-char SUBJECT
|
|
61
|
+
('C', 'R', a single-digit version) before the stopword fallback runs. The
|
|
62
|
+
`len(t) > 1` guard moved into the fallback, so a single-char content word
|
|
63
|
+
survives while single-char stopwords are still removed.
|
|
64
|
+
- **#3578** — `_overlap_tokens` (the #3369 transcript-fallback tokenizer) now
|
|
65
|
+
accumulates alphanumeric runs (`isalnum`), so issue numbers, ports and
|
|
66
|
+
versions ('3993', ':9077', 'v0') carry signal and match symmetrically on
|
|
67
|
+
both the query and transcript sides. A lone digit is still dropped as noise.
|
|
68
|
+
- **#3994** — the sidechain volume gate counts ONLY the chars the text-only
|
|
69
|
+
retain path keeps (`retained_text_char_count` → `_extract_text_content`),
|
|
70
|
+
not the `tool_use` inputs the payload drops. Gate and payload now measure
|
|
71
|
+
the same char set, so a tool-heavy / prose-light fork that used to clear the
|
|
72
|
+
gate and retain a near-empty document is now correctly skipped. The
|
|
73
|
+
2,000-char floor was re-measured against the corrected metric —
|
|
74
|
+
`docs/measurements/subagent-volume-gate-3994.md`, replay harness
|
|
75
|
+
`scripts/tests/data/replay_volume_gate_3994.py`.
|
|
76
|
+
- **#4001** — the "window formatted to empty despite clearing the gate" skip
|
|
77
|
+
now emits an unconditional stderr line, not a debug-only log that was silent
|
|
78
|
+
in shipped settings.
|
|
79
|
+
- **#3999 / #3777** — rewrote the vacuous sidechain config-mutation test to
|
|
80
|
+
assert on the config object the code actually receives (RED when the copy
|
|
81
|
+
fix is deleted), and restored direct characterisation of `_overlap_tokens`
|
|
82
|
+
(`scripts/tests/test_overlap_tokens.py`) that was deleted with the removed
|
|
83
|
+
lexical gate while the tokenizer stayed load-bearing.
|
|
84
|
+
|
|
57
85
|
- **`MAX_DIRECTIVES` 15 → 30, and truncation is no longer SILENT**
|
|
58
86
|
(`scripts/lib/directives.py`). Live fleet active-directive counts were 24
|
|
59
87
|
(assistant), 17 (klanker), 15 (carrie) against a client-side cap of 15, so the
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Sub-agent volume-gate recalibration — #3994
|
|
2
|
+
|
|
3
|
+
## What changed
|
|
4
|
+
|
|
5
|
+
`subagent_retain.py`'s volume gate decides whether a terminating sub-agent's
|
|
6
|
+
sidechain window is worth an LLM-backed retain. Its char floor
|
|
7
|
+
(`MIN_NON_TOOL_RESULT_CHARS = 2000`) used to be measured by a metric
|
|
8
|
+
(`non_tool_result_char_count`) that summed assistant/user **text** PLUS every
|
|
9
|
+
`tool_use` block's `name` + serialized `input`.
|
|
10
|
+
|
|
11
|
+
But the sidechain retain runs on the **text-only** path (`retainToolCalls =
|
|
12
|
+
False`), so `tool_use` inputs are **not** in the stored payload. The gate and
|
|
13
|
+
the payload measured different things: a tool-heavy / prose-light fork could
|
|
14
|
+
clear the gate on tool-command volume and then retain a near-empty document.
|
|
15
|
+
|
|
16
|
+
The gate now counts exactly what the text-only path keeps, via
|
|
17
|
+
`retained_text_char_count` → `lib.content._extract_text_content` (assistant
|
|
18
|
+
`text` blocks, channel-message tool_use text, plain-string user turns). Gate and
|
|
19
|
+
payload now measure the identical char set.
|
|
20
|
+
|
|
21
|
+
## Re-measuring the 2,000-char floor
|
|
22
|
+
|
|
23
|
+
Because the metric shrank (it no longer counts tool chars), the floor had to be
|
|
24
|
+
re-measured against the corrected count — not carried over blind.
|
|
25
|
+
|
|
26
|
+
**Replay harness:** `scripts/tests/data/replay_volume_gate_3994.py`
|
|
27
|
+
(re-runnable; `--dir <path>` replays real `*.jsonl` sidechain transcripts,
|
|
28
|
+
default is a deterministic synthetic corpus parameterized by the empirical
|
|
29
|
+
distributions already measured and cited in the code).
|
|
30
|
+
|
|
31
|
+
**Data source note.** Fleet sidechain transcripts are private agent memory and
|
|
32
|
+
are not committed to this repo, so the committed run uses the
|
|
33
|
+
distribution-parameterized synthetic corpus (seed 1994): 120 real workers drawn
|
|
34
|
+
across the measured klanker text-only size range (p10 5,035 / p50 10,058 / p90
|
|
35
|
+
26,040; 0 empty / 0 < 500 over 200 real transcripts — see
|
|
36
|
+
`test_subagent_retain_learnings.py::TextOnlyPathIsNotEmpty`), 60 trivial 10-second
|
|
37
|
+
forks, and 40 tool-heavy / prose-light forks (the exact shape this change exists
|
|
38
|
+
to reject). An operator re-runs it against ground truth with `--dir`.
|
|
39
|
+
|
|
40
|
+
## Result (committed run, `python3 scripts/tests/data/replay_volume_gate_3994.py --json`)
|
|
41
|
+
|
|
42
|
+
| metric | value |
|
|
43
|
+
|---|---|
|
|
44
|
+
| corpus | 220 transcripts (120 real / 60 trivial / 40 tool-heavy-prose-light) |
|
|
45
|
+
| old gate passed but actual payload < 500 chars | **40** |
|
|
46
|
+
| new gate passed but actual payload < 500 chars | **0** |
|
|
47
|
+
| gate-passers' payload size (new, floor 2000) | min 5,591 / p10 8,293 / p50 13,968 / p90 24,672 |
|
|
48
|
+
|
|
49
|
+
Floor sweep (real workers newly skipped vs. the old 2000 gate ; empty-ish forks
|
|
50
|
+
now correctly skipped):
|
|
51
|
+
|
|
52
|
+
| floor | real workers newly skipped | empty-ish forks now skipped |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| 500 | 0 | 40 |
|
|
55
|
+
| 1000 | 0 | 40 |
|
|
56
|
+
| 1500 | 0 | 40 |
|
|
57
|
+
| **2000** | **0** | **40** |
|
|
58
|
+
| 3000 | 0 | 40 |
|
|
59
|
+
| 5000 | **2** | 40 |
|
|
60
|
+
|
|
61
|
+
## Decision: keep the floor at 2,000
|
|
62
|
+
|
|
63
|
+
The two populations separate cleanly under the corrected metric. Every
|
|
64
|
+
gate-passer has a text-only payload of **≥ 5,591 chars** (p10 8,293) — real
|
|
65
|
+
workers cluster an order of magnitude above the floor — while the entire
|
|
66
|
+
trivial / tool-heavy-prose-light fork population falls below it. Any floor in
|
|
67
|
+
`[500, 3000]` yields the same decision on this corpus, so 2,000 is not a knife
|
|
68
|
+
edge: it sits in the wide flat middle. Pushing to 5,000 begins clipping genuine
|
|
69
|
+
short-but-real workers (2 lost), so 2,000 stays as the conservative,
|
|
70
|
+
well-separated choice.
|
|
71
|
+
|
|
72
|
+
The headline win: **40 forks the old gate passed to a near-empty (< 500 char)
|
|
73
|
+
retain are now correctly skipped, and zero real workers are lost.**
|
|
74
|
+
|
|
75
|
+
## Guarding tests
|
|
76
|
+
|
|
77
|
+
- `test_subagent_retain.py::VolumeGate::test_tool_heavy_prose_light_fork_now_skips`
|
|
78
|
+
— a tool-heavy / prose-light fork that PASSED the old gate now FAILS (RED if
|
|
79
|
+
the metric reverts to counting `tool_use` inputs).
|
|
80
|
+
- `test_subagent_retain.py::VolumeGate::test_char_count_is_text_only`
|
|
81
|
+
— `retained_text_char_count` counts only `_extract_text_content` chars.
|
|
82
|
+
- `test_subagent_retain_learnings.py::ProseFreeSidechainIsSkipped`
|
|
83
|
+
— the prose-free degenerate case is now SKIPPED by the gate (the retain path's
|
|
84
|
+
own guarantee that gate-passers always carry retainable prose).
|