@fitchmultz/pi-subagents 0.46.1 → 0.46.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/CHANGELOG.md +16 -0
- package/dist/agents/agent-management.js +8 -2
- package/dist/agents/agents.js +8 -8
- package/dist/extension/build-info.js +1 -1
- package/dist/extension/index.js +11 -5
- package/dist/extension/result-snapshot.js +0 -18
- package/dist/history/ingest.js +40 -31
- package/dist/history/prune.js +1 -1
- package/dist/history/run-projection.js +1 -2
- package/dist/history/search-queries.js +8 -2
- package/dist/history/store.js +20 -4
- package/dist/history/text-chunks.js +1 -2
- package/dist/history/transcript-queries.js +12 -6
- package/dist/intercom/result-intercom.js +0 -74
- package/dist/pi-intercom/broker/broker.js +33 -16
- package/dist/pi-intercom/broker/framing.js +67 -17
- package/dist/pi-intercom/broker/snapshot-reader.js +29 -8
- package/dist/pi-intercom/broker/snapshots.js +144 -41
- package/dist/pi-intercom/session-targets.js +0 -1
- package/dist/runs/background/async-run-discovery.js +4 -6
- package/dist/runs/background/completion-dedupe.js +4 -55
- package/dist/runs/background/notify.js +1 -9
- package/dist/runs/background/result-watcher.js +1 -1
- package/dist/runs/background/runner-child-observer.js +12 -7
- package/dist/runs/background/runner-monitor.js +2 -0
- package/dist/runs/background/runner-parallel.js +2 -3
- package/dist/runs/shared/child-observation-spool.js +8 -3
- package/dist/runs/shared/run-restoration.js +4 -5
- package/dist/runs/shared/supervisor-questions.js +7 -4
- package/dist/shared/journal-frames.js +1 -1
- package/dist/slash/command-discovery.js +13 -12
- package/dist/tui/agent-history.js +0 -14
- package/dist/tui/agent-view.js +0 -1
- package/dist/tui/conversation-history.js +10 -1
- package/dist/tui/history-cards.js +8 -22
- package/docs/intercom.md +5 -1
- package/docs/performance.md +72 -3
- package/package.json +1 -1
- package/src/agents/agent-management.ts +8 -4
- package/src/agents/agents.ts +17 -12
- package/src/extension/index.ts +12 -5
- package/src/extension/result-snapshot.ts +1 -27
- package/src/history/ingest.ts +57 -49
- package/src/history/prune.ts +1 -1
- package/src/history/run-projection.ts +2 -4
- package/src/history/search-queries.ts +9 -2
- package/src/history/store.ts +21 -5
- package/src/history/text-chunks.ts +7 -2
- package/src/history/transcript-queries.ts +16 -10
- package/src/intercom/result-intercom.ts +0 -103
- package/src/pi-intercom/broker/broker.ts +34 -20
- package/src/pi-intercom/broker/framing.ts +71 -20
- package/src/pi-intercom/broker/snapshot-reader.ts +47 -9
- package/src/pi-intercom/broker/snapshots.ts +184 -51
- package/src/pi-intercom/session-targets.ts +0 -2
- package/src/runs/background/async-run-discovery.ts +4 -10
- package/src/runs/background/completion-dedupe.ts +6 -69
- package/src/runs/background/notify.ts +2 -12
- package/src/runs/background/result-watcher.ts +1 -1
- package/src/runs/background/runner-child-observer.ts +19 -11
- package/src/runs/background/runner-monitor.ts +2 -0
- package/src/runs/background/runner-parallel.ts +2 -3
- package/src/runs/shared/child-observation-spool.ts +8 -3
- package/src/runs/shared/run-restoration.ts +4 -4
- package/src/runs/shared/supervisor-questions.ts +10 -10
- package/src/shared/journal-frames.ts +1 -1
- package/src/slash/command-discovery.ts +13 -12
- package/src/tui/agent-history.ts +1 -18
- package/src/tui/agent-view.ts +0 -1
- package/src/tui/conversation-history.ts +15 -1
- package/src/tui/history-cards.ts +8 -26
package/src/history/ingest.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { AnswerTexts } from "./answer-texts.ts";
|
|
|
9
9
|
import { object } from "./values.ts";
|
|
10
10
|
import { identity, stamp, openSource, requireSource } from "./source-file.ts";
|
|
11
11
|
export { identity, stamp } from "./source-file.ts";
|
|
12
|
-
import { number, text } from "./rows.ts";
|
|
12
|
+
import { number, text, type Row } from "./rows.ts";
|
|
13
13
|
import { stageEntry, publishTools, type IngestRecord } from "./entry-ingestion.ts";
|
|
14
14
|
export { previewProjection, previewLimits, compactEntry } from "./preview.ts";
|
|
15
15
|
|
|
@@ -173,7 +173,6 @@ export class SourceIngest {
|
|
|
173
173
|
});
|
|
174
174
|
return;
|
|
175
175
|
}
|
|
176
|
-
this.stagedEntry = stageEntry(this.store, this.source, record);
|
|
177
176
|
this.textCursor = 0;
|
|
178
177
|
this.phase = "text";
|
|
179
178
|
}
|
|
@@ -235,64 +234,73 @@ export class SourceIngest {
|
|
|
235
234
|
}
|
|
236
235
|
private indexText(): void {
|
|
237
236
|
const record = this.record;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
throw new HistoryIndexError("CORRUPT", "Missing staged entry.");
|
|
237
|
+
if (!record) {
|
|
238
|
+
throw new HistoryIndexError("CORRUPT", "Missing ingestion record.");
|
|
241
239
|
}
|
|
242
240
|
const chunks = this.store.all(
|
|
243
|
-
"SELECT rowid,* FROM pending_text WHERE rowid>? ORDER BY rowid LIMIT
|
|
241
|
+
"SELECT rowid,* FROM pending_text WHERE rowid>? ORDER BY rowid LIMIT 33",
|
|
244
242
|
this.textCursor,
|
|
245
243
|
);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
"INSERT INTO documents(entry_rowid,field,text_start,text_end,preview) VALUES (?,?,?,?,?)",
|
|
255
|
-
rowid,
|
|
256
|
-
text(chunk, "field"),
|
|
257
|
-
number(chunk, "position"),
|
|
258
|
-
number(chunk, "end_position"),
|
|
259
|
-
safeText(body, 512),
|
|
260
|
-
).lastInsertRowid;
|
|
261
|
-
this.store.run("INSERT INTO corpus(rowid,text) VALUES (?,?)", id, body);
|
|
262
|
-
}
|
|
263
|
-
});
|
|
264
|
-
const last = chunks.at(-1);
|
|
265
|
-
if (last) {
|
|
266
|
-
this.textCursor = number(last, "rowid");
|
|
244
|
+
const complete = chunks.length <= 32;
|
|
245
|
+
const batch = chunks.slice(0, 32);
|
|
246
|
+
let rowid: number | undefined;
|
|
247
|
+
const write = () => {
|
|
248
|
+
rowid = this.stagedEntry ?? stageEntry(this.store, this.source, record);
|
|
249
|
+
this.indexChunks(rowid, record, batch);
|
|
250
|
+
if (complete) {
|
|
251
|
+
this.publishEntry(rowid, record);
|
|
267
252
|
}
|
|
268
|
-
|
|
253
|
+
};
|
|
254
|
+
if (complete) {
|
|
255
|
+
this.published(record.end, write);
|
|
256
|
+
this.stagedEntry = undefined;
|
|
257
|
+
this.phase = "read";
|
|
258
|
+
} else {
|
|
259
|
+
this.store.transaction(write);
|
|
260
|
+
this.stagedEntry = rowid;
|
|
261
|
+
this.textCursor = number(batch[31], "rowid");
|
|
269
262
|
}
|
|
263
|
+
}
|
|
264
|
+
private indexChunks(rowid: number, record: IngestRecord, chunks: readonly Row[]): void {
|
|
265
|
+
for (const chunk of chunks) {
|
|
266
|
+
if (!allowed(record.value, text(chunk, "field"))) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const body = safeText(text(chunk, "text"), 1_048_576);
|
|
270
|
+
const id = this.store.run(
|
|
271
|
+
"INSERT INTO documents(entry_rowid,field,text_start,text_end,preview) VALUES (?,?,?,?,?)",
|
|
272
|
+
rowid,
|
|
273
|
+
text(chunk, "field"),
|
|
274
|
+
number(chunk, "position"),
|
|
275
|
+
number(chunk, "end_position"),
|
|
276
|
+
safeText(body, 512),
|
|
277
|
+
).lastInsertRowid;
|
|
278
|
+
this.store.run("INSERT INTO corpus(rowid,text) VALUES (?,?)", id, body);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
private publishEntry(rowid: number, record: IngestRecord): void {
|
|
270
282
|
const value = record.value;
|
|
271
283
|
const answers = this.answerTexts.fingerprints(
|
|
272
284
|
value,
|
|
273
285
|
typeof value.id === "string" ? value.id : `legacy-${record.start}`,
|
|
274
286
|
);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
publishTools(this.store, this.source, rowid, value);
|
|
293
|
-
});
|
|
294
|
-
this.stagedEntry = undefined;
|
|
295
|
-
this.phase = "read";
|
|
287
|
+
if (
|
|
288
|
+
this.store.run(
|
|
289
|
+
"UPDATE entries SET published=1 WHERE rowid=? AND source_id=? AND generation=? AND published=0",
|
|
290
|
+
rowid,
|
|
291
|
+
this.id,
|
|
292
|
+
this.source.generation,
|
|
293
|
+
).changes !== 1
|
|
294
|
+
) {
|
|
295
|
+
throw new HistoryIndexError(
|
|
296
|
+
"SOURCE_CHANGED",
|
|
297
|
+
"Staged entry disappeared before publication; reconcile before advancing the source.",
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
for (const [digest, item] of answers) {
|
|
301
|
+
this.store.run("INSERT OR IGNORE INTO answers VALUES (?,?,?)", rowid, digest, item);
|
|
302
|
+
}
|
|
303
|
+
publishTools(this.store, this.source, rowid, value);
|
|
296
304
|
}
|
|
297
305
|
/** Each pump reads <=64 KiB or indexes <=32 chunks, then returns to the IPC event loop. */
|
|
298
306
|
step(): boolean {
|
package/src/history/prune.ts
CHANGED
|
@@ -30,7 +30,7 @@ export function pruneHistory(
|
|
|
30
30
|
return store.transaction(() => {
|
|
31
31
|
let removed = previousSources === undefined && pruneRuns(store, runs);
|
|
32
32
|
for (const source of store.all(
|
|
33
|
-
`SELECT id,path FROM sources WHERE
|
|
33
|
+
`SELECT id,path FROM sources WHERE NOT EXISTS (SELECT 1 FROM children WHERE children.source_id=sources.id)${previousSources ? ` AND id IN (${previousSources.map(() => "?").join(",")})` : ""}`,
|
|
34
34
|
...(previousSources ?? []),
|
|
35
35
|
)) {
|
|
36
36
|
const id = text(source, "id");
|
|
@@ -147,10 +147,8 @@ export function projectRun(
|
|
|
147
147
|
foreground: Readonly<ReadonlyMap<string, ReadonlyForegroundResumeRun>>,
|
|
148
148
|
): HistoryRunRow {
|
|
149
149
|
const state: OwnedRunReadState = { ownedRuns: runs, foregroundRuns: foreground };
|
|
150
|
-
const questions = listRunQuestions(getRunMetadataDir(run.runId)).filter(
|
|
151
|
-
(question) =>
|
|
152
|
-
question.ownerSessionId === ownerSessionId &&
|
|
153
|
-
["awaiting_input", "answer_pending"].includes(question.state),
|
|
150
|
+
const questions = listRunQuestions(getRunMetadataDir(run.runId), ownerSessionId).filter(
|
|
151
|
+
(question) => ["awaiting_input", "answer_pending"].includes(question.state),
|
|
154
152
|
);
|
|
155
153
|
return {
|
|
156
154
|
...ownedRunView(run, state, {
|
|
@@ -93,7 +93,8 @@ function plan(
|
|
|
93
93
|
clauses.push("c.agent=?");
|
|
94
94
|
params.push(input.agent);
|
|
95
95
|
}
|
|
96
|
-
//
|
|
96
|
+
// Materialized IDs reject common out-of-scope postings without per-hit entry lookups.
|
|
97
|
+
// Each source is scoped once; bm25 still uses the whole corpus.
|
|
97
98
|
const eligible =
|
|
98
99
|
hasText(input.runId) || hasText(input.agent)
|
|
99
100
|
? `eligible AS MATERIALIZED (SELECT e.rowid FROM entries e JOIN sources s ON s.id=e.source_id WHERE e.published=1 AND e.generation=s.generation AND e.source_id IN (SELECT c.source_id FROM children c WHERE ${clauses.slice(4).join(" AND ")})),`
|
|
@@ -166,6 +167,12 @@ export class SearchQueries {
|
|
|
166
167
|
limit: number,
|
|
167
168
|
): readonly Row[] {
|
|
168
169
|
// Match every token across a record's visible windows; a quoted phrase cannot span fields.
|
|
170
|
+
// Relevance benefits from hit-driven attribution. Newest keeps the native join order:
|
|
171
|
+
// forcing hit order regresses newest sorting across many shared attempts.
|
|
172
|
+
const joined =
|
|
173
|
+
score === "matched.score"
|
|
174
|
+
? "FROM matched CROSS JOIN entries e ON e.rowid=matched.entry_rowid CROSS JOIN sources s ON s.id=e.source_id CROSS JOIN children c ON c.source_id=s.id JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id"
|
|
175
|
+
: "FROM matched JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id JOIN entries e ON e.rowid=matched.entry_rowid JOIN sources s ON s.id=e.source_id JOIN children c ON c.source_id=s.id";
|
|
169
176
|
// Ranking/filters precede LIMIT. The parent owns the hard deadline for a costly FTS scan.
|
|
170
177
|
return this.scope.store.all(
|
|
171
178
|
`WITH ${filter.eligible} hits AS MATERIALIZED (
|
|
@@ -173,7 +180,7 @@ export class SearchQueries {
|
|
|
173
180
|
), terms AS (SELECT entry_rowid,term,MIN(score) AS score FROM hits GROUP BY entry_rowid,term),
|
|
174
181
|
matched AS (SELECT entry_rowid,SUM(score) AS score FROM terms GROUP BY entry_rowid HAVING COUNT(*)=${expression.length}),
|
|
175
182
|
excerpts AS (SELECT id,entry_rowid,ROW_NUMBER() OVER (PARTITION BY entry_rowid ORDER BY score,id) AS choice FROM hits)
|
|
176
|
-
SELECT d.*,${score} AS score,e.*,d.id AS document_id,s.path,s.session_id,c.run_id,c.child_index,c.agent,d.preview AS document_preview
|
|
183
|
+
SELECT d.*,${score} AS score,e.*,d.id AS document_id,s.path,s.session_id,c.run_id,c.child_index,c.agent,d.preview AS document_preview ${joined} LEFT JOIN entries terminal ON terminal.source_id=s.id AND terminal.generation=s.generation AND terminal.id=c.terminal_entry_id AND terminal.published=1 WHERE ${filter.clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`,
|
|
177
184
|
...filter.eligibleParams,
|
|
178
185
|
...filter.params,
|
|
179
186
|
limit + 1,
|
package/src/history/store.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import { openGeneration } from "./database-generation.ts";
|
|
3
3
|
import { OutputDigests } from "./output-digests.ts";
|
|
4
|
-
import type { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import type { DatabaseSync, StatementSync } from "node:sqlite";
|
|
5
5
|
import { historyTransaction, openHistoryDatabase } from "./database.ts";
|
|
6
6
|
import { hash, safeText, hasText } from "./text.ts";
|
|
7
7
|
export { hash, exactTextDigest, safeText, integer, pageLimit } from "./text.ts";
|
|
@@ -45,6 +45,7 @@ export class HistoryStore {
|
|
|
45
45
|
this.counters[operation] += amount;
|
|
46
46
|
}
|
|
47
47
|
private ingestLock?: DatabaseSync;
|
|
48
|
+
private readonly statements = new Map<string, StatementSync>();
|
|
48
49
|
private readonly outputDigests = new OutputDigests();
|
|
49
50
|
constructor(agentDir: string, owner: string) {
|
|
50
51
|
this.owner = owner;
|
|
@@ -72,12 +73,11 @@ export class HistoryStore {
|
|
|
72
73
|
return row;
|
|
73
74
|
}
|
|
74
75
|
get(sql: string, ...params: readonly IndexInput[]): Row | undefined {
|
|
75
|
-
const row = this.
|
|
76
|
+
const row = this.statement(sql).get(...params);
|
|
76
77
|
return row === undefined ? undefined : indexRow(row);
|
|
77
78
|
}
|
|
78
79
|
all(sql: string, ...params: readonly IndexInput[]): readonly Row[] {
|
|
79
|
-
return this.
|
|
80
|
-
.prepare(sql)
|
|
80
|
+
return this.statement(sql)
|
|
81
81
|
.all(...params)
|
|
82
82
|
.map(indexRow);
|
|
83
83
|
}
|
|
@@ -85,7 +85,22 @@ export class HistoryStore {
|
|
|
85
85
|
sql: string,
|
|
86
86
|
...params: readonly IndexInput[]
|
|
87
87
|
): ReturnType<ReturnType<DatabaseSync["prepare"]>["run"]> {
|
|
88
|
-
return this.
|
|
88
|
+
return this.statement(sql).run(...params);
|
|
89
|
+
}
|
|
90
|
+
private statement(sql: string): StatementSync {
|
|
91
|
+
let statement = this.statements.get(sql);
|
|
92
|
+
if (!statement) {
|
|
93
|
+
statement = this.db.prepare(sql);
|
|
94
|
+
// Dynamic browse/filter shapes cannot grow this connection's cache indefinitely.
|
|
95
|
+
if (this.statements.size >= 128) {
|
|
96
|
+
const oldest = this.statements.keys().next().value;
|
|
97
|
+
if (oldest !== undefined) {
|
|
98
|
+
this.statements.delete(oldest);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
this.statements.set(sql, statement);
|
|
102
|
+
}
|
|
103
|
+
return statement;
|
|
89
104
|
}
|
|
90
105
|
deleteDocuments(sourceId: string, unpublishedOnly = false): void {
|
|
91
106
|
const where = `entry_rowid IN (SELECT rowid FROM entries WHERE source_id=?${unpublishedOnly ? " AND published=0" : ""})`;
|
|
@@ -279,6 +294,7 @@ export class HistoryStore {
|
|
|
279
294
|
}
|
|
280
295
|
}
|
|
281
296
|
close(): void {
|
|
297
|
+
this.statements.clear();
|
|
282
298
|
this.ingestLock?.close();
|
|
283
299
|
this.db.close();
|
|
284
300
|
}
|
|
@@ -171,9 +171,14 @@ export class TextChunks {
|
|
|
171
171
|
this.fields.clear();
|
|
172
172
|
}
|
|
173
173
|
if (this.staged.length > 0) {
|
|
174
|
-
const insert = store.db.prepare("INSERT INTO pending_text VALUES (?,?,?,?)");
|
|
175
174
|
for (const item of this.staged) {
|
|
176
|
-
|
|
175
|
+
store.run(
|
|
176
|
+
"INSERT INTO pending_text VALUES (?,?,?,?)",
|
|
177
|
+
item.field,
|
|
178
|
+
item.start,
|
|
179
|
+
item.end,
|
|
180
|
+
item.text,
|
|
181
|
+
);
|
|
177
182
|
}
|
|
178
183
|
this.staged = [];
|
|
179
184
|
}
|
|
@@ -58,7 +58,7 @@ export class TranscriptQueries {
|
|
|
58
58
|
boundary: Boundary,
|
|
59
59
|
ids: readonly string[],
|
|
60
60
|
): readonly (readonly [string, boolean])[] {
|
|
61
|
-
const
|
|
61
|
+
const humans: (readonly [string, number])[] = [];
|
|
62
62
|
const where = boundary.clauses.join(" AND ");
|
|
63
63
|
for (const id of new Set(ids)) {
|
|
64
64
|
const human = this.scope.store.get(
|
|
@@ -67,17 +67,23 @@ export class TranscriptQueries {
|
|
|
67
67
|
id,
|
|
68
68
|
);
|
|
69
69
|
if (human) {
|
|
70
|
-
|
|
71
|
-
id,
|
|
72
|
-
this.scope.store.get(
|
|
73
|
-
`SELECT rowid FROM entries WHERE ${where} AND start>? AND assistant_text=1 LIMIT 1`,
|
|
74
|
-
...boundary.params,
|
|
75
|
-
number(human, "start"),
|
|
76
|
-
) !== undefined,
|
|
77
|
-
]);
|
|
70
|
+
humans.push([id, number(human, "start")]);
|
|
78
71
|
}
|
|
79
72
|
}
|
|
80
|
-
|
|
73
|
+
if (humans.length === 0) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
// One tail lookup answers every ID, without scanning before the earliest requested human.
|
|
77
|
+
const assistant = this.scope.store.get(
|
|
78
|
+
`SELECT start FROM entries WHERE ${where} AND start>? AND assistant_text=1 ORDER BY start DESC LIMIT 1`,
|
|
79
|
+
...boundary.params,
|
|
80
|
+
Math.min(...humans.map(([, start]) => start)),
|
|
81
|
+
);
|
|
82
|
+
const assistantStart = assistant ? number(assistant, "start") : undefined;
|
|
83
|
+
return humans.map(([id, start]): readonly [string, boolean] => [
|
|
84
|
+
id,
|
|
85
|
+
assistantStart !== undefined && start < assistantStart,
|
|
86
|
+
]);
|
|
81
87
|
}
|
|
82
88
|
private readMetadata(
|
|
83
89
|
boundary: Boundary,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ReadonlyInput } from "../shared/types/inputs.ts";
|
|
2
|
-
import { copyDetails, copyResult } from "../extension/result-snapshot.ts";
|
|
3
2
|
import { compactNestedResultChildren, formatNestedResultLines } from "./nested-result-tree.ts";
|
|
4
3
|
export {
|
|
5
4
|
compactNestedResultChildren,
|
|
@@ -13,7 +12,6 @@ import * as fs from "node:fs";
|
|
|
13
12
|
import { formatRunAction } from "../shared/status-format.ts";
|
|
14
13
|
import { SUBAGENT_CHILD_ENV, SUBAGENT_FANOUT_CHILD_ENV } from "../runs/shared/pi-args.ts";
|
|
15
14
|
import type {
|
|
16
|
-
Details,
|
|
17
15
|
SingleResult,
|
|
18
16
|
SubagentResultIntercomChild,
|
|
19
17
|
SubagentResultIntercomPayload,
|
|
@@ -308,104 +306,3 @@ export function buildSubagentResultIntercomPayload(
|
|
|
308
306
|
};
|
|
309
307
|
return { ...payload, message: formatSubagentResultIntercomMessage(payload) };
|
|
310
308
|
}
|
|
311
|
-
|
|
312
|
-
function stripSingleResultOutputs(result: ReadonlyInput<SingleResult>): SingleResult {
|
|
313
|
-
return {
|
|
314
|
-
...copyResult(result),
|
|
315
|
-
messages: undefined,
|
|
316
|
-
finalOutput: undefined,
|
|
317
|
-
truncation: undefined,
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export function stripDetailsOutputsForIntercomReceipt(
|
|
322
|
-
details: ReadonlyInput<Details>,
|
|
323
|
-
delivery?: Details["intercomDelivery"],
|
|
324
|
-
): Details {
|
|
325
|
-
return {
|
|
326
|
-
...copyDetails(details),
|
|
327
|
-
results: details.results.map(stripSingleResultOutputs),
|
|
328
|
-
...(delivery ? { intercomDelivery: delivery } : {}),
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function compactReceiptSummary(summary: string): string {
|
|
333
|
-
const withoutOutput = summary.split(/\n\nOutput:\n|\sOutput:\s/)[0] ?? summary;
|
|
334
|
-
const normalized = withoutOutput.replace(/\s+/g, " ").trim();
|
|
335
|
-
return normalized.length > 240 ? `${normalized.slice(0, 239)}…` : normalized;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function formatChildReferences(
|
|
339
|
-
children: readonly SubagentResultIntercomChild[],
|
|
340
|
-
field: "artifactPath" | "intercomTarget" | "sessionPath",
|
|
341
|
-
heading: string,
|
|
342
|
-
): string[] {
|
|
343
|
-
const lines: string[] = [];
|
|
344
|
-
for (const child of children) {
|
|
345
|
-
const value = child[field];
|
|
346
|
-
if (typeof value === "string") {
|
|
347
|
-
lines.push(`- ${child.agent} [${child.status}]: ${value}`);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
return lines.length > 0 ? [heading, ...lines] : [];
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function formatReceiptReferences(payload: SubagentResultIntercomPayload): string[] {
|
|
354
|
-
const lines: string[] = [];
|
|
355
|
-
if (hasText(payload.resultPath)) {
|
|
356
|
-
lines.push(`Saved result (acceptance details when configured): ${payload.resultPath}`);
|
|
357
|
-
}
|
|
358
|
-
for (const child of payload.children) {
|
|
359
|
-
if (hasText(child.metadataPath)) {
|
|
360
|
-
lines.push(
|
|
361
|
-
`Result metadata (${child.agent}; acceptance details when configured): ${child.metadataPath}${fs.existsSync(child.metadataPath) ? "" : " (missing)"}`,
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
lines.push(
|
|
366
|
-
...formatChildReferences(payload.children, "artifactPath", "Artifacts:"),
|
|
367
|
-
...formatChildReferences(
|
|
368
|
-
payload.children,
|
|
369
|
-
"intercomTarget",
|
|
370
|
-
"Run intercom targets (may be inactive after completion):",
|
|
371
|
-
),
|
|
372
|
-
...formatChildReferences(payload.children, "sessionPath", "Sessions:"),
|
|
373
|
-
);
|
|
374
|
-
const nonCompleted = payload.children.filter((child) => child.status !== "completed");
|
|
375
|
-
if (nonCompleted.length > 0) {
|
|
376
|
-
lines.push("Non-completed children:");
|
|
377
|
-
for (const child of nonCompleted) {
|
|
378
|
-
lines.push(`- ${child.agent} [${child.status}]: ${compactReceiptSummary(child.summary)}`);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
return lines;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
export function formatSubagentResultReceipt(input: {
|
|
385
|
-
readonly mode: SubagentRunMode;
|
|
386
|
-
readonly runId: string;
|
|
387
|
-
readonly payload: SubagentResultIntercomPayload;
|
|
388
|
-
}): string {
|
|
389
|
-
const counts = countStatuses(input.payload.children);
|
|
390
|
-
const modeLabel = {
|
|
391
|
-
single: "single subagent result",
|
|
392
|
-
parallel: "parallel subagent results",
|
|
393
|
-
chain: "chain subagent results",
|
|
394
|
-
}[input.mode];
|
|
395
|
-
const lines = [
|
|
396
|
-
`Delivered ${modeLabel} via intercom.`,
|
|
397
|
-
"Delivery: succeeded",
|
|
398
|
-
`Run: ${input.runId}`,
|
|
399
|
-
`Child outcome: ${input.payload.status}`,
|
|
400
|
-
`Children: ${formatStatusCounts(counts)}`,
|
|
401
|
-
...(hasText(input.payload.error)
|
|
402
|
-
? [
|
|
403
|
-
`Workflow ${input.payload.status === "paused" ? "paused" : "error"}: ${input.payload.error}`,
|
|
404
|
-
]
|
|
405
|
-
: []),
|
|
406
|
-
];
|
|
407
|
-
|
|
408
|
-
lines.push(...formatReceiptReferences(input.payload));
|
|
409
|
-
lines.push("Full grouped output was sent over intercom.");
|
|
410
|
-
return lines.join("\n");
|
|
411
|
-
}
|
|
@@ -23,7 +23,7 @@ import { errorMessage, isRecord, type UnknownRecord } from "../../shared/unknown
|
|
|
23
23
|
import {
|
|
24
24
|
messageSender,
|
|
25
25
|
snapshotFrames,
|
|
26
|
-
|
|
26
|
+
TopicSnapshot,
|
|
27
27
|
publicationMessage,
|
|
28
28
|
deliveryAcknowledgement,
|
|
29
29
|
shouldReplaceDelivery,
|
|
@@ -36,6 +36,7 @@ const REPLACE_DELIVERY_DELAY_MS = 1500;
|
|
|
36
36
|
interface ConnectedSession {
|
|
37
37
|
readonly socket: net.Socket;
|
|
38
38
|
info: SessionInfo;
|
|
39
|
+
topics: TopicSnapshot;
|
|
39
40
|
readonly topicFrames: boolean;
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -178,7 +179,12 @@ class IntercomBroker {
|
|
|
178
179
|
const info: SessionInfo = { ...msg.session, id, lastSeen: msg.session.lastSeen ?? Date.now() };
|
|
179
180
|
snapshotFrames(randomUUID(), [info], true);
|
|
180
181
|
const topicFrames = msg.topicFrames === true;
|
|
181
|
-
this.sessions.set(id, {
|
|
182
|
+
this.sessions.set(id, {
|
|
183
|
+
socket,
|
|
184
|
+
info: messageSender(info),
|
|
185
|
+
topics: new TopicSnapshot(info),
|
|
186
|
+
topicFrames,
|
|
187
|
+
});
|
|
182
188
|
setId(id);
|
|
183
189
|
if (this.shutdownTimer) {
|
|
184
190
|
clearTimeout(this.shutdownTimer);
|
|
@@ -208,9 +214,7 @@ class IntercomBroker {
|
|
|
208
214
|
return deliveries;
|
|
209
215
|
}
|
|
210
216
|
for (const target of this.sessions.values()) {
|
|
211
|
-
const subscription = target.
|
|
212
|
-
(entry) => entry.topic === publication.topic,
|
|
213
|
-
);
|
|
217
|
+
const subscription = target.topics.subscription(publication.topic);
|
|
214
218
|
if (target.info.id === fromId || !subscription) {
|
|
215
219
|
continue;
|
|
216
220
|
}
|
|
@@ -234,24 +238,28 @@ class IntercomBroker {
|
|
|
234
238
|
if (!session) {
|
|
235
239
|
throw new Error("Sender session not found");
|
|
236
240
|
}
|
|
237
|
-
const prepared =
|
|
238
|
-
const nextInfo = prepared.
|
|
241
|
+
const prepared = session.topics.prepare(msg.change, session.topicFrames);
|
|
242
|
+
const nextInfo = prepared.restore
|
|
243
|
+
? session.info
|
|
244
|
+
: session.topics.snapshot(session.info, prepared);
|
|
239
245
|
const from = messageSender(nextInfo);
|
|
240
246
|
const deliveries = this.planPublications(currentId, from, prepared.publication);
|
|
241
|
-
const restore = prepared.restore;
|
|
242
|
-
const infos = [...this.sessions.values()].map((entry) =>
|
|
243
|
-
entry === session ? nextInfo : entry.info,
|
|
244
|
-
);
|
|
245
247
|
// Validate every frame before committing state or delivering publications.
|
|
246
|
-
if (restore) {
|
|
247
|
-
|
|
248
|
+
if (prepared.restore) {
|
|
249
|
+
session.topics.validateRestore(session.info, prepared, requestId);
|
|
248
250
|
}
|
|
249
|
-
const
|
|
251
|
+
const infos = prepared.restore
|
|
252
|
+
? []
|
|
253
|
+
: [...this.sessions.values()].map((entry) => {
|
|
254
|
+
if (entry === session) {
|
|
255
|
+
return nextInfo;
|
|
256
|
+
}
|
|
257
|
+
return msg.stream === true ? entry.topics.snapshot(entry.info) : entry.info;
|
|
258
|
+
});
|
|
259
|
+
const frames = snapshotFrames(requestId, infos, msg.stream === true);
|
|
250
260
|
const last = frames.pop();
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
session.info = nextInfo;
|
|
261
|
+
session.topics.commit(prepared);
|
|
262
|
+
session.info = messageSender(nextInfo);
|
|
255
263
|
const receipts: Array<SessionSnapshot["receipts"][number]> = [];
|
|
256
264
|
for (const { to, message } of deliveries) {
|
|
257
265
|
receipts.push({ to, ...this.sendToSession(currentId, to, from, message) });
|
|
@@ -326,7 +334,10 @@ class IntercomBroker {
|
|
|
326
334
|
if (!session) {
|
|
327
335
|
return;
|
|
328
336
|
}
|
|
329
|
-
const update: Record<string, unknown> = {
|
|
337
|
+
const update: Record<string, unknown> = {
|
|
338
|
+
...session.topics.snapshot(session.info),
|
|
339
|
+
lastSeen: Date.now(),
|
|
340
|
+
};
|
|
330
341
|
for (const key of [
|
|
331
342
|
"name",
|
|
332
343
|
"status",
|
|
@@ -346,7 +357,10 @@ class IntercomBroker {
|
|
|
346
357
|
throw new Error("Invalid presence update");
|
|
347
358
|
}
|
|
348
359
|
snapshotFrames(randomUUID(), [nextInfo], true);
|
|
349
|
-
|
|
360
|
+
if (msg.topics !== undefined || msg.subscriptions !== undefined) {
|
|
361
|
+
session.topics = new TopicSnapshot(nextInfo);
|
|
362
|
+
}
|
|
363
|
+
session.info = messageSender(nextInfo);
|
|
350
364
|
}
|
|
351
365
|
|
|
352
366
|
private sendToSession(
|
|
@@ -19,17 +19,67 @@ export function validateIntercomMessageSize(msg: unknown): Error | null {
|
|
|
19
19
|
* Format: 4-byte big-endian length + JSON payload
|
|
20
20
|
*/
|
|
21
21
|
export function writeMessage(socket: Socket, msg: unknown): void {
|
|
22
|
-
const tooLarge = validateIntercomMessageSize(msg);
|
|
23
|
-
if (tooLarge) {
|
|
24
|
-
throw tooLarge;
|
|
25
|
-
}
|
|
26
22
|
const json = JSON.stringify(msg);
|
|
23
|
+
const size = Buffer.byteLength(json, "utf-8");
|
|
24
|
+
if (size > MAX_FRAME_SIZE_BYTES) {
|
|
25
|
+
throw new Error(`Intercom message too large (${size} bytes; max ${MAX_FRAME_SIZE_BYTES})`);
|
|
26
|
+
}
|
|
27
27
|
const payload = Buffer.from(json, "utf-8");
|
|
28
28
|
const header = Buffer.alloc(4);
|
|
29
29
|
header.writeUInt32BE(payload.length, 0);
|
|
30
30
|
socket.write(Buffer.concat([header, payload]));
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Retain fragments until a header or payload is complete; copy each fragmented value once. */
|
|
34
|
+
class MessageBuffer {
|
|
35
|
+
private chunks: Buffer[] = [];
|
|
36
|
+
private index = 0;
|
|
37
|
+
length = 0;
|
|
38
|
+
|
|
39
|
+
append(data: Buffer): void {
|
|
40
|
+
if (data.length > 0) {
|
|
41
|
+
this.chunks.push(data);
|
|
42
|
+
this.length += data.length;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
clear(): void {
|
|
47
|
+
this.chunks = [];
|
|
48
|
+
this.index = 0;
|
|
49
|
+
this.length = 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
read(size: number): Buffer {
|
|
53
|
+
const first = this.chunks[this.index];
|
|
54
|
+
if (size === 0) {
|
|
55
|
+
return Buffer.alloc(0);
|
|
56
|
+
}
|
|
57
|
+
const result = first.length >= size ? first.subarray(0, size) : Buffer.allocUnsafe(size);
|
|
58
|
+
let written = 0;
|
|
59
|
+
while (written < size) {
|
|
60
|
+
const chunk = this.chunks[this.index];
|
|
61
|
+
const count = Math.min(chunk.length, size - written);
|
|
62
|
+
if (first.length < size) {
|
|
63
|
+
chunk.copy(result, written, 0, count);
|
|
64
|
+
}
|
|
65
|
+
written += count;
|
|
66
|
+
this.length -= count;
|
|
67
|
+
if (count === chunk.length) {
|
|
68
|
+
this.index++;
|
|
69
|
+
} else {
|
|
70
|
+
this.chunks[this.index] = chunk.subarray(count);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (this.index === this.chunks.length) {
|
|
74
|
+
this.clear();
|
|
75
|
+
} else if (this.index * 2 >= this.chunks.length) {
|
|
76
|
+
this.chunks = this.chunks.slice(this.index);
|
|
77
|
+
this.index = 0;
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
33
83
|
/**
|
|
34
84
|
* Create a message reader that handles partial reads.
|
|
35
85
|
* Calls onMessage for each complete message received.
|
|
@@ -39,27 +89,28 @@ export function createMessageReader(
|
|
|
39
89
|
onMessage: (msg: unknown) => void,
|
|
40
90
|
onError: (error: Readonly<Error>) => void,
|
|
41
91
|
): (data: Buffer) => void {
|
|
42
|
-
|
|
92
|
+
const buffer = new MessageBuffer();
|
|
93
|
+
let length: number | undefined;
|
|
43
94
|
|
|
44
95
|
return (data: Buffer) => {
|
|
45
|
-
buffer
|
|
46
|
-
|
|
47
|
-
while (buffer.length >= 4) {
|
|
48
|
-
const length = buffer.readUInt32BE(0);
|
|
49
|
-
if (length > MAX_FRAME_SIZE_BYTES) {
|
|
50
|
-
onError(
|
|
51
|
-
new Error(`Intercom frame too large (${length} bytes; max ${MAX_FRAME_SIZE_BYTES})`),
|
|
52
|
-
);
|
|
53
|
-
buffer = Buffer.alloc(0);
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
96
|
+
buffer.append(data);
|
|
56
97
|
|
|
57
|
-
|
|
58
|
-
|
|
98
|
+
while (buffer.length >= (length ?? 4)) {
|
|
99
|
+
if (length === undefined) {
|
|
100
|
+
length = buffer.read(4).readUInt32BE(0);
|
|
101
|
+
if (length > MAX_FRAME_SIZE_BYTES) {
|
|
102
|
+
onError(
|
|
103
|
+
new Error(`Intercom frame too large (${length} bytes; max ${MAX_FRAME_SIZE_BYTES})`),
|
|
104
|
+
);
|
|
105
|
+
buffer.clear();
|
|
106
|
+
length = undefined;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
59
110
|
}
|
|
60
111
|
|
|
61
|
-
const payload = buffer.
|
|
62
|
-
|
|
112
|
+
const payload = buffer.read(length);
|
|
113
|
+
length = undefined;
|
|
63
114
|
|
|
64
115
|
let msg: unknown;
|
|
65
116
|
try {
|