@oh-my-pi/pi-agent-core 18.15.0 → 18.17.0
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 +22 -0
- package/dist/types/agent-loop.d.ts +2 -0
- package/dist/types/agent.d.ts +11 -1
- package/dist/types/append-only-context.d.ts +9 -0
- package/dist/types/compaction/branch-summarization.d.ts +2 -3
- package/dist/types/compaction/compaction.d.ts +2 -2
- package/dist/types/compaction/utils.d.ts +6 -22
- package/dist/types/telemetry.d.ts +3 -1
- package/dist/types/tokenizer.d.ts +9 -9
- package/dist/types/types.d.ts +19 -0
- package/package.json +11 -11
- package/src/agent-loop.ts +75 -23
- package/src/agent.ts +83 -10
- package/src/append-only-context.ts +49 -18
- package/src/compaction/branch-summarization.ts +4 -10
- package/src/compaction/compaction-v2-streaming.ts +2 -3
- package/src/compaction/compaction.ts +6 -10
- package/src/compaction/openai.ts +0 -1
- package/src/compaction/utils.ts +19 -98
- package/src/telemetry.ts +1 -1
- package/src/tokenizer.ts +53 -24
- package/src/types.ts +21 -0
- package/src/compaction/prompts/file-operations.md +0 -5
package/src/agent.ts
CHANGED
|
@@ -62,6 +62,7 @@ import type {
|
|
|
62
62
|
AsideBoundary,
|
|
63
63
|
AsideMessage,
|
|
64
64
|
PrepareQueuedMessages,
|
|
65
|
+
QueuedMessageGate,
|
|
65
66
|
QueuedMessageQueue,
|
|
66
67
|
SpeculativeToolExecutionConfig,
|
|
67
68
|
StreamFn,
|
|
@@ -279,6 +280,8 @@ export interface AgentOptions {
|
|
|
279
280
|
|
|
280
281
|
/** Enable intent tracing schema injection/stripping in the harness. */
|
|
281
282
|
intentTracing?: boolean;
|
|
283
|
+
/** Schema description of the injected intent field; `PI_INTENT_DESC` overrides it. Default `concise intent`. */
|
|
284
|
+
intentDescription?: string;
|
|
282
285
|
/**
|
|
283
286
|
* Strip tool descriptions from provider-bound tool specs (top-level + nested
|
|
284
287
|
* schema annotations). Use when the full catalog is rendered into the system
|
|
@@ -493,6 +496,7 @@ export class Agent {
|
|
|
493
496
|
#resolveFallbackTool?: (name: string, advertised: readonly AgentTool<any>[]) => AgentTool<any> | undefined;
|
|
494
497
|
#suggestFallbackToolNames?: () => Iterable<string>;
|
|
495
498
|
#intentTracing: boolean;
|
|
499
|
+
#intentDescription?: string;
|
|
496
500
|
#pruneToolDescriptions: boolean;
|
|
497
501
|
#dialect?: Dialect;
|
|
498
502
|
#dialectResolver?: (model: Model) => Dialect | undefined;
|
|
@@ -525,6 +529,12 @@ export class Agent {
|
|
|
525
529
|
getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
|
|
526
530
|
/** Prepare actual queue deliveries after dequeue gates; commit runs only while ownership remains valid. */
|
|
527
531
|
prepareQueuedMessages?: PrepareQueuedMessages;
|
|
532
|
+
/**
|
|
533
|
+
* Consulted synchronously whenever queued messages are removed for delivery and
|
|
534
|
+
* told about claimed messages that went back undelivered (see {@link QueuedMessageGate}).
|
|
535
|
+
* Unset, batches are taken as they are.
|
|
536
|
+
*/
|
|
537
|
+
queuedMessageGate?: QueuedMessageGate;
|
|
528
538
|
/**
|
|
529
539
|
* Hook invoked after tool arguments are validated and before execution.
|
|
530
540
|
* Reassign at any time to swap the implementation (e.g. on extension reload).
|
|
@@ -602,6 +612,7 @@ export class Agent {
|
|
|
602
612
|
this.#resolveFallbackTool = opts.resolveFallbackTool;
|
|
603
613
|
this.#suggestFallbackToolNames = opts.suggestFallbackToolNames;
|
|
604
614
|
this.#intentTracing = opts.intentTracing === true;
|
|
615
|
+
this.#intentDescription = opts.intentDescription;
|
|
605
616
|
this.#pruneToolDescriptions = opts.pruneToolDescriptions === true;
|
|
606
617
|
this.#dialect = opts.dialect;
|
|
607
618
|
this.#dialectResolver = opts.dialectResolver;
|
|
@@ -864,6 +875,11 @@ export class Agent {
|
|
|
864
875
|
this.#intentTracing = value;
|
|
865
876
|
}
|
|
866
877
|
|
|
878
|
+
/** The intent field's schema description this agent was built with (`PI_INTENT_DESC` still overrides it). */
|
|
879
|
+
get intentDescription(): string | undefined {
|
|
880
|
+
return this.#intentDescription;
|
|
881
|
+
}
|
|
882
|
+
|
|
867
883
|
/** Abort the provider request on a fabricated tool result; applies from the next prompt run. */
|
|
868
884
|
get abortOnFabricatedToolResult(): boolean | undefined {
|
|
869
885
|
return this.#abortOnFabricatedToolResult;
|
|
@@ -970,6 +986,7 @@ export class Agent {
|
|
|
970
986
|
? []
|
|
971
987
|
: (normalizeTools(this.#toolsForModel(model), {
|
|
972
988
|
injectIntent: this.#intentTracing,
|
|
989
|
+
intentDescription: this.#intentDescription,
|
|
973
990
|
pruneDescriptions: this.#pruneToolDescriptions,
|
|
974
991
|
}) ?? []);
|
|
975
992
|
let context: Context = { systemPrompt, messages, tools };
|
|
@@ -1042,12 +1059,14 @@ export class Agent {
|
|
|
1042
1059
|
const runController = this.#abortController;
|
|
1043
1060
|
if (!prepare) {
|
|
1044
1061
|
this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0, additional: [] });
|
|
1062
|
+
this.#emitQueueChanged();
|
|
1045
1063
|
return messages;
|
|
1046
1064
|
}
|
|
1047
1065
|
|
|
1048
1066
|
const claim: QueuedMessageClaim = { messages, controller: new AbortController() };
|
|
1049
1067
|
this.#queuedMessageClaims[queue] = claim;
|
|
1050
1068
|
const preparationSignal = AbortSignal.any([signal, claim.controller.signal]);
|
|
1069
|
+
this.#emitQueueChanged();
|
|
1051
1070
|
try {
|
|
1052
1071
|
const preparation = await prepare(messages, preparationSignal, queue);
|
|
1053
1072
|
signal.throwIfAborted();
|
|
@@ -1067,6 +1086,7 @@ export class Agent {
|
|
|
1067
1086
|
next: 0,
|
|
1068
1087
|
additional: additional ?? [],
|
|
1069
1088
|
});
|
|
1089
|
+
this.#emitQueueChanged();
|
|
1070
1090
|
return additional?.length ? [...messages, ...additional] : messages;
|
|
1071
1091
|
} catch (error) {
|
|
1072
1092
|
if (signal.aborted) throw error;
|
|
@@ -1086,6 +1106,7 @@ export class Agent {
|
|
|
1086
1106
|
const claim = this.#queuedMessageClaims[queue];
|
|
1087
1107
|
if (!claim) return;
|
|
1088
1108
|
delete this.#queuedMessageClaims[queue];
|
|
1109
|
+
this.queuedMessageGate?.release(claim.messages);
|
|
1089
1110
|
if (restore) {
|
|
1090
1111
|
if (queue === "steering") {
|
|
1091
1112
|
this.#steeringQueue = [...claim.messages, ...this.#steeringQueue];
|
|
@@ -1104,6 +1125,7 @@ export class Agent {
|
|
|
1104
1125
|
// Live steering was taken before anything the run still holds undelivered.
|
|
1105
1126
|
this.#liveSteered = this.#liveSteered.filter(entry => {
|
|
1106
1127
|
if (entry.controller !== controller) return true;
|
|
1128
|
+
if (entry.message.role === "user") delete entry.message.liveSteered;
|
|
1107
1129
|
restored.steering.push(entry.message);
|
|
1108
1130
|
return false;
|
|
1109
1131
|
});
|
|
@@ -1114,12 +1136,14 @@ export class Agent {
|
|
|
1114
1136
|
restored[delivery.queue].push(delivery.messages[i]);
|
|
1115
1137
|
}
|
|
1116
1138
|
}
|
|
1139
|
+
if (restored.steering.length === 0 && restored.followUp.length === 0) return;
|
|
1140
|
+
this.queuedMessageGate?.release([...restored.steering, ...restored.followUp]);
|
|
1117
1141
|
if (restored.steering.length > 0) {
|
|
1118
1142
|
this.#steeringQueue = [...restored.steering, ...this.#steeringQueue];
|
|
1119
1143
|
this.#notifySteeringWaiters();
|
|
1120
1144
|
}
|
|
1121
1145
|
if (restored.followUp.length > 0) this.#followUpQueue = [...restored.followUp, ...this.#followUpQueue];
|
|
1122
|
-
|
|
1146
|
+
this.#emitQueueChanged();
|
|
1123
1147
|
}
|
|
1124
1148
|
|
|
1125
1149
|
/** Move steering live steering took out of the queue-delivery records into {@link #liveSteered}. */
|
|
@@ -1177,6 +1201,9 @@ export class Agent {
|
|
|
1177
1201
|
for (const message of [...pending, ...delivery.additional]) suppress(message, delivery.controller);
|
|
1178
1202
|
}
|
|
1179
1203
|
this.#queuedMessageDeliveries.clear();
|
|
1204
|
+
if (withdrawn.steering.length > 0 || withdrawn.followUp.length > 0) {
|
|
1205
|
+
this.queuedMessageGate?.release([...withdrawn.steering, ...withdrawn.followUp]);
|
|
1206
|
+
}
|
|
1180
1207
|
if (hadLiveSteered) this.#emitQueueChanged();
|
|
1181
1208
|
return withdrawn;
|
|
1182
1209
|
}
|
|
@@ -1336,11 +1363,9 @@ export class Agent {
|
|
|
1336
1363
|
this.#state.messages = ms.slice();
|
|
1337
1364
|
}
|
|
1338
1365
|
|
|
1339
|
-
/** Signal
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
1342
|
-
* enqueue, dequeue-on-delivery, clear, and restore without emits scattered
|
|
1343
|
-
* across every call site that queues or dequeues a message. */
|
|
1366
|
+
/** Signal after a queue transition has settled its pending, claim, and
|
|
1367
|
+
* delivery state, so AgentSession's `queue_update` coalescing observes
|
|
1368
|
+
* a consistent snapshot across enqueue, preparation, delivery, and restore. */
|
|
1344
1369
|
#emitQueueChanged(): void {
|
|
1345
1370
|
for (const listener of this.#queueListeners) {
|
|
1346
1371
|
try {
|
|
@@ -1507,6 +1532,19 @@ export class Agent {
|
|
|
1507
1532
|
return claim ? [...claim.messages, ...this.#steeringQueue] : this.#steeringQueue;
|
|
1508
1533
|
}
|
|
1509
1534
|
|
|
1535
|
+
#isUndeliveredQueuedMessage(message: AgentMessage): boolean {
|
|
1536
|
+
for (const entry of this.#liveSteered) {
|
|
1537
|
+
if (entry.message === message || entry.additional?.includes(message)) return true;
|
|
1538
|
+
}
|
|
1539
|
+
for (const delivery of this.#queuedMessageDeliveries) {
|
|
1540
|
+
if (delivery.additional.includes(message)) return true;
|
|
1541
|
+
for (let i = delivery.next; i < delivery.messages.length; i++) {
|
|
1542
|
+
if (delivery.messages[i] === message) return true;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
return false;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1510
1548
|
/** Dequeued messages not yet in the transcript, e.g. steering a provider
|
|
1511
1549
|
* took into its in-flight response via live steering. Aborting the run
|
|
1512
1550
|
* requeues them, so the session's empty-submit interrupt counts them as
|
|
@@ -1553,17 +1591,50 @@ export class Agent {
|
|
|
1553
1591
|
return queue.slice(0, count);
|
|
1554
1592
|
}
|
|
1555
1593
|
|
|
1594
|
+
/**
|
|
1595
|
+
* The next delivery batch of `queue`. With a {@link queuedMessageGate}, the
|
|
1596
|
+
* gate sees each candidate batch first; what it vetoes leaves the queue and
|
|
1597
|
+
* the batch is recomputed, and candidates it claimed that the final batch no
|
|
1598
|
+
* longer holds are released again.
|
|
1599
|
+
*/
|
|
1600
|
+
#nextDeliveryBatch(queue: QueuedMessageQueue): AgentMessage[] {
|
|
1601
|
+
const mode = queue === "steering" ? this.#steeringMode : this.#followUpMode;
|
|
1602
|
+
let batch = this.#dequeueMessages(queue === "steering" ? this.#steeringQueue : this.#followUpQueue, mode);
|
|
1603
|
+
const gate = this.queuedMessageGate;
|
|
1604
|
+
if (!gate || batch.length === 0) return batch;
|
|
1605
|
+
let claimedEarlier: AgentMessage[] | undefined;
|
|
1606
|
+
let dropped = false;
|
|
1607
|
+
while (batch.length > 0) {
|
|
1608
|
+
const veto = gate.claim(queue, batch);
|
|
1609
|
+
if (veto.length === 0) break;
|
|
1610
|
+
const drop = new Set(veto);
|
|
1611
|
+
const pending = queue === "steering" ? this.#steeringQueue : this.#followUpQueue;
|
|
1612
|
+
const kept = pending.filter(message => !drop.has(message));
|
|
1613
|
+
if (kept.length === pending.length) break;
|
|
1614
|
+
for (const message of batch) if (!drop.has(message)) (claimedEarlier ??= []).push(message);
|
|
1615
|
+
if (queue === "steering") this.#steeringQueue = kept;
|
|
1616
|
+
else this.#followUpQueue = kept;
|
|
1617
|
+
dropped = true;
|
|
1618
|
+
batch = this.#dequeueMessages(kept, mode);
|
|
1619
|
+
}
|
|
1620
|
+
if (claimedEarlier) {
|
|
1621
|
+
const final = new Set(batch);
|
|
1622
|
+
const stale = claimedEarlier.filter(message => !final.has(message));
|
|
1623
|
+
if (stale.length > 0) gate.release(stale);
|
|
1624
|
+
}
|
|
1625
|
+
if (dropped && batch.length === 0) this.#emitQueueChanged();
|
|
1626
|
+
return batch;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1556
1629
|
#dequeueSteeringMessages(): AgentMessage[] {
|
|
1557
|
-
const messages = this.#
|
|
1630
|
+
const messages = this.#nextDeliveryBatch("steering");
|
|
1558
1631
|
this.#steeringQueue = this.#steeringQueue.slice(messages.length);
|
|
1559
|
-
if (messages.length > 0) this.#emitQueueChanged();
|
|
1560
1632
|
return messages;
|
|
1561
1633
|
}
|
|
1562
1634
|
|
|
1563
1635
|
#dequeueFollowUpMessages(): AgentMessage[] {
|
|
1564
|
-
const messages = this.#
|
|
1636
|
+
const messages = this.#nextDeliveryBatch("followUp");
|
|
1565
1637
|
this.#followUpQueue = this.#followUpQueue.slice(messages.length);
|
|
1566
|
-
if (messages.length > 0) this.#emitQueueChanged();
|
|
1567
1638
|
return messages;
|
|
1568
1639
|
}
|
|
1569
1640
|
|
|
@@ -1991,6 +2062,7 @@ export class Agent {
|
|
|
1991
2062
|
resolveFallbackTool: this.#resolveFallbackTool,
|
|
1992
2063
|
suggestFallbackToolNames: this.#suggestFallbackToolNames,
|
|
1993
2064
|
intentTracing: this.#intentTracing,
|
|
2065
|
+
intentDescription: this.#intentDescription,
|
|
1994
2066
|
pruneToolDescriptions: this.#pruneToolDescriptions,
|
|
1995
2067
|
dialect: this.#dialect,
|
|
1996
2068
|
getDialect: this.#dialectResolver,
|
|
@@ -2024,6 +2096,7 @@ export class Agent {
|
|
|
2024
2096
|
}
|
|
2025
2097
|
return this.#dequeueSteeringMessagesAfterHooks(signal ?? loopSignal);
|
|
2026
2098
|
},
|
|
2099
|
+
isUndeliveredQueuedMessage: message => this.#isUndeliveredQueuedMessage(message),
|
|
2027
2100
|
hasSteeringMessages: () =>
|
|
2028
2101
|
steeringQueueState(
|
|
2029
2102
|
this.#steeringQueue,
|
|
@@ -82,6 +82,8 @@ export interface StablePrefixSnapshot {
|
|
|
82
82
|
export interface BuildOptions {
|
|
83
83
|
/** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
|
|
84
84
|
intentTracing: boolean;
|
|
85
|
+
/** Schema description of the injected `i` field (must match agent-loop's normalizeTools). */
|
|
86
|
+
intentDescription?: string;
|
|
85
87
|
/** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
|
|
86
88
|
pruneToolDescriptions?: boolean;
|
|
87
89
|
}
|
|
@@ -136,6 +138,7 @@ export class StablePrefix {
|
|
|
136
138
|
// but changes any of these must miss the fast path.
|
|
137
139
|
#lastToolKey: readonly string[] | undefined;
|
|
138
140
|
#lastIntentTracing: boolean | undefined;
|
|
141
|
+
#lastIntentDescription: string | undefined;
|
|
139
142
|
#lastPruneToolDescriptions: boolean | undefined;
|
|
140
143
|
|
|
141
144
|
build(context: AgentContext, options: BuildOptions): boolean {
|
|
@@ -166,6 +169,7 @@ export class StablePrefix {
|
|
|
166
169
|
#fastPathHit(context: AgentContext, options: BuildOptions, toolKeys: readonly string[]): boolean {
|
|
167
170
|
if (
|
|
168
171
|
this.#lastIntentTracing !== options.intentTracing ||
|
|
172
|
+
this.#lastIntentDescription !== options.intentDescription ||
|
|
169
173
|
this.#lastPruneToolDescriptions !== options.pruneToolDescriptions
|
|
170
174
|
) {
|
|
171
175
|
return false;
|
|
@@ -196,6 +200,7 @@ export class StablePrefix {
|
|
|
196
200
|
#recordFastPathKey(context: AgentContext, options: BuildOptions, renderedToolKeys: readonly string[]): void {
|
|
197
201
|
this.#lastPromptSegments = [...context.systemPrompt];
|
|
198
202
|
this.#lastIntentTracing = options.intentTracing;
|
|
203
|
+
this.#lastIntentDescription = options.intentDescription;
|
|
199
204
|
this.#lastPruneToolDescriptions = options.pruneToolDescriptions;
|
|
200
205
|
this.#lastToolKey = renderedToolKeys;
|
|
201
206
|
}
|
|
@@ -470,11 +475,7 @@ export class AppendOnlyContextManager {
|
|
|
470
475
|
|
|
471
476
|
function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
|
|
472
477
|
const systemPrompt = [...context.systemPrompt];
|
|
473
|
-
const tools =
|
|
474
|
-
normalizeTools(context.tools, {
|
|
475
|
-
injectIntent: options.intentTracing,
|
|
476
|
-
pruneDescriptions: options.pruneToolDescriptions,
|
|
477
|
-
}) ?? [];
|
|
478
|
+
const tools = normalizeWireTools(context.tools, options);
|
|
478
479
|
return {
|
|
479
480
|
systemPrompt,
|
|
480
481
|
tools,
|
|
@@ -482,20 +483,50 @@ function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefi
|
|
|
482
483
|
};
|
|
483
484
|
}
|
|
484
485
|
|
|
486
|
+
function normalizeWireTools(tools: AgentContext["tools"], options: BuildOptions): Tool[] {
|
|
487
|
+
return (
|
|
488
|
+
normalizeTools(tools, {
|
|
489
|
+
injectIntent: options.intentTracing,
|
|
490
|
+
intentDescription: options.intentDescription,
|
|
491
|
+
pruneDescriptions: options.pruneToolDescriptions,
|
|
492
|
+
}) ?? []
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The fields of a provider-bound tool spec the wire carries; `normalizeTools` has rendered `examples` into the description. */
|
|
497
|
+
function wireToolFields(t: Tool) {
|
|
498
|
+
return {
|
|
499
|
+
n: t.name,
|
|
500
|
+
d: t.description,
|
|
501
|
+
p: t.parameters,
|
|
502
|
+
s: t.strict,
|
|
503
|
+
cf: t.customFormat,
|
|
504
|
+
cw: t.customWireName,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Fingerprint of the tool specs a request built with `options` sends: the
|
|
510
|
+
* tools half of the prefix fingerprint, for a caller that compares tool sets
|
|
511
|
+
* across requests. A same-name tool whose description, schema, examples,
|
|
512
|
+
* `strict`, `customFormat` or `customWireName` changed makes another one.
|
|
513
|
+
*/
|
|
514
|
+
export function wireToolsFingerprint(tools: AgentContext["tools"], options: BuildOptions): string {
|
|
515
|
+
return hashPayload(JSON.stringify(normalizeWireTools(tools, options).map(wireToolFields)));
|
|
516
|
+
}
|
|
517
|
+
|
|
485
518
|
function computeFingerprint(systemPrompt: string[], tools: Tool[], options: BuildOptions): string {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
pd: options.pruneToolDescriptions,
|
|
498
|
-
});
|
|
519
|
+
return hashPayload(
|
|
520
|
+
JSON.stringify({
|
|
521
|
+
s: systemPrompt,
|
|
522
|
+
t: tools.map(wireToolFields),
|
|
523
|
+
i: options.intentTracing,
|
|
524
|
+
pd: options.pruneToolDescriptions,
|
|
525
|
+
}),
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function hashPayload(payload: string): string {
|
|
499
530
|
let hash = 0;
|
|
500
531
|
for (let i = 0; i < payload.length; i++) {
|
|
501
532
|
hash = ((hash << 5) - hash + payload.charCodeAt(i)) | 0;
|
|
@@ -24,16 +24,12 @@ import {
|
|
|
24
24
|
import branchSummaryPrompt from "./prompts/branch-summary.md" with { type: "text" };
|
|
25
25
|
import branchSummaryPreamble from "./prompts/branch-summary-preamble.md" with { type: "text" };
|
|
26
26
|
import {
|
|
27
|
-
computeFileLists,
|
|
28
|
-
createFileOps,
|
|
29
27
|
extractFileOpsFromMessage,
|
|
30
|
-
type FileOperations,
|
|
31
28
|
incompleteOutputError,
|
|
32
29
|
SUMMARIZATION_SYSTEM_PROMPT,
|
|
33
30
|
serializeConversationForSummary,
|
|
34
31
|
stripReadSelector,
|
|
35
32
|
truncateToolResultForSummary,
|
|
36
|
-
upsertFileOperations,
|
|
37
33
|
} from "./utils";
|
|
38
34
|
|
|
39
35
|
// ============================================================================
|
|
@@ -54,13 +50,11 @@ export interface BranchSummaryDetails {
|
|
|
54
50
|
modifiedFiles: string[];
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
export type { FileOperations } from "./utils";
|
|
58
|
-
|
|
59
53
|
export interface BranchPreparation {
|
|
60
54
|
/** Messages extracted for summarization, in chronological order */
|
|
61
55
|
messages: AgentMessage[];
|
|
62
56
|
/** File operations extracted from tool calls */
|
|
63
|
-
fileOps: FileOperations;
|
|
57
|
+
fileOps: snapcompact.FileOperations;
|
|
64
58
|
/** Total estimated tokens in messages */
|
|
65
59
|
totalTokens: number;
|
|
66
60
|
}
|
|
@@ -252,7 +246,7 @@ export function prepareBranchEntries(
|
|
|
252
246
|
tokenBudget: number = 0,
|
|
253
247
|
): BranchPreparation {
|
|
254
248
|
const messages: AgentMessage[] = [];
|
|
255
|
-
const fileOps = createFileOps();
|
|
249
|
+
const fileOps = snapcompact.createFileOps();
|
|
256
250
|
let totalTokens = 0;
|
|
257
251
|
|
|
258
252
|
// First pass: collect file ops from ALL entries (even if they don't fit in token budget)
|
|
@@ -400,8 +394,8 @@ export async function generateBranchSummary(
|
|
|
400
394
|
summary = BRANCH_SUMMARY_PREAMBLE + summary;
|
|
401
395
|
|
|
402
396
|
// Compute file lists and append to summary
|
|
403
|
-
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
404
|
-
summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
|
|
397
|
+
const { readFiles, modifiedFiles } = snapcompact.computeFileLists(fileOps);
|
|
398
|
+
summary = snapcompact.upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
|
|
405
399
|
|
|
406
400
|
return {
|
|
407
401
|
summary: summary || "No summary generated",
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
OPENAI_HEADER_VALUES,
|
|
33
33
|
OPENAI_HEADERS,
|
|
34
34
|
} from "@oh-my-pi/pi-catalog/wire/codex";
|
|
35
|
-
import { $env, isUnexpectedSocketCloseMessage, logger, ptree, stringifyJson } from "@oh-my-pi/pi-utils";
|
|
35
|
+
import { $env, isUnexpectedSocketCloseMessage, logger, ptree, stringifyJson, toError } from "@oh-my-pi/pi-utils";
|
|
36
36
|
import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
|
|
37
37
|
import { prepareBedrockCompactionRequest } from "./bedrock";
|
|
38
38
|
|
|
@@ -320,7 +320,7 @@ export async function requestCompactionV2Streaming(
|
|
|
320
320
|
codexAccessPrograms: options?.codexAccessPrograms,
|
|
321
321
|
});
|
|
322
322
|
} catch (err) {
|
|
323
|
-
const error =
|
|
323
|
+
const error = toError(err);
|
|
324
324
|
if (signal?.aborted) throw error;
|
|
325
325
|
|
|
326
326
|
if (isRetryableCompactionError(error) && attempt < V2_COMPACTION_MAX_RETRIES) {
|
|
@@ -472,7 +472,6 @@ function buildCompactionV2Headers(
|
|
|
472
472
|
headers[OPENAI_HEADERS.SESSION_ID] = routingSessionId;
|
|
473
473
|
headers["x-client-request-id"] = routingSessionId;
|
|
474
474
|
}
|
|
475
|
-
headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
|
|
476
475
|
headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
|
|
477
476
|
headers[OPENAI_HEADERS.CODEX_BETA_FEATURES] = OPENAI_HEADER_VALUES.REMOTE_COMPACTION_V2;
|
|
478
477
|
headers[OPENAI_HEADERS.ROUTING_HINT] = codexRoutingHint(request.body.model, undefined);
|
|
@@ -86,16 +86,12 @@ import handoffDocumentPrompt from "./prompts/handoff-document.md" with { type: "
|
|
|
86
86
|
import snapcompactArchiveContextPrompt from "./prompts/snapcompact-archive-context.md" with { type: "text" };
|
|
87
87
|
|
|
88
88
|
import {
|
|
89
|
-
computeFileLists,
|
|
90
|
-
createFileOps,
|
|
91
89
|
escapeSummaryBoundaryTags,
|
|
92
90
|
extractFileOpsFromMessage,
|
|
93
|
-
type FileOperations,
|
|
94
91
|
incompleteOutputError,
|
|
95
92
|
SUMMARIZATION_SYSTEM_PROMPT,
|
|
96
93
|
serializeConversationForSummary,
|
|
97
94
|
stripReadSelector,
|
|
98
|
-
upsertFileOperations,
|
|
99
95
|
} from "./utils";
|
|
100
96
|
|
|
101
97
|
// ============================================================================
|
|
@@ -115,8 +111,8 @@ function extractFileOperations(
|
|
|
115
111
|
messages: AgentMessage[],
|
|
116
112
|
entries: SessionEntry[],
|
|
117
113
|
prevCompactionIndex: number,
|
|
118
|
-
): FileOperations {
|
|
119
|
-
const fileOps = createFileOps();
|
|
114
|
+
): snapcompact.FileOperations {
|
|
115
|
+
const fileOps = snapcompact.createFileOps();
|
|
120
116
|
|
|
121
117
|
// Collect from previous compaction's details (if pi-generated)
|
|
122
118
|
if (prevCompactionIndex >= 0) {
|
|
@@ -1384,7 +1380,7 @@ export interface CompactionPreparation {
|
|
|
1384
1380
|
/** Preserved opaque compaction payload from the previous compaction, if any. */
|
|
1385
1381
|
previousPreserveData?: Record<string, unknown>;
|
|
1386
1382
|
/** File operations extracted from messagesToSummarize */
|
|
1387
|
-
fileOps: FileOperations;
|
|
1383
|
+
fileOps: snapcompact.FileOperations;
|
|
1388
1384
|
/** Compaction settions from settings.jsonl */
|
|
1389
1385
|
settings: CompactionSettings;
|
|
1390
1386
|
}
|
|
@@ -2343,8 +2339,8 @@ export async function compact(
|
|
|
2343
2339
|
});
|
|
2344
2340
|
|
|
2345
2341
|
// Compute file lists and append to summary
|
|
2346
|
-
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
2347
|
-
summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
|
|
2342
|
+
const { readFiles, modifiedFiles } = snapcompact.computeFileLists(fileOps);
|
|
2343
|
+
summary = snapcompact.upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
|
|
2348
2344
|
if (nativeSummary !== undefined) {
|
|
2349
2345
|
// The replayed block (summary and any `tool_changes`) stays
|
|
2350
2346
|
// byte-identical to the API's output so it matches its signature. The
|
|
@@ -2352,7 +2348,7 @@ export async function compact(
|
|
|
2352
2348
|
// summary message with the block and skips its text, so they would
|
|
2353
2349
|
// otherwise be invisible to this provider. Every other provider keeps
|
|
2354
2350
|
// reading the entry text.
|
|
2355
|
-
const filesText = upsertFileOperations("", readFiles, modifiedFiles, fileOps.read) || undefined;
|
|
2351
|
+
const filesText = snapcompact.upsertFileOperations("", readFiles, modifiedFiles, fileOps.read) || undefined;
|
|
2356
2352
|
preserveData = withAnthropicCompactionPreserveData(preserveData, {
|
|
2357
2353
|
provider: model.provider,
|
|
2358
2354
|
content: nativeSummary,
|
package/src/compaction/openai.ts
CHANGED
|
@@ -965,7 +965,6 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
965
965
|
if (attestation) {
|
|
966
966
|
headers[OPENAI_HEADERS.ATTESTATION] = attestation;
|
|
967
967
|
}
|
|
968
|
-
headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
|
|
969
968
|
headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
|
|
970
969
|
// This compaction request sends no `service_tier`, so the hint is model-only.
|
|
971
970
|
headers[OPENAI_HEADERS.ROUTING_HINT] = codexRoutingHint(request.model, undefined);
|
package/src/compaction/utils.ts
CHANGED
|
@@ -5,27 +5,27 @@
|
|
|
5
5
|
import type { Message, ToolCall } from "@oh-my-pi/pi-ai";
|
|
6
6
|
import { type Dialect, getDialectDefinition } from "@oh-my-pi/pi-ai/dialect";
|
|
7
7
|
import { escapeHarmonyControlTokens } from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
8
|
-
import {
|
|
8
|
+
import { prompt, stringifyJson } from "@oh-my-pi/pi-utils";
|
|
9
|
+
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
9
10
|
import type { AgentMessage } from "../types";
|
|
10
|
-
import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
|
|
11
11
|
import summarizationSystemPrompt from "./prompts/summarization-system.md" with { type: "text" };
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
/** @deprecated Import these from `@oh-my-pi/snapcompact`. */
|
|
14
|
+
export {
|
|
15
|
+
computeFileLists,
|
|
16
|
+
createFileOps,
|
|
17
|
+
type FileOperations,
|
|
18
|
+
isUrlSchemePath,
|
|
19
|
+
upsertFileOperations,
|
|
20
|
+
} from "@oh-my-pi/snapcompact";
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return {
|
|
25
|
-
read: new Set(),
|
|
26
|
-
written: new Set(),
|
|
27
|
-
edited: new Set(),
|
|
28
|
-
};
|
|
22
|
+
/** @deprecated Use `upsertFileOperations("", readFiles, modifiedFiles, readSet)` from `@oh-my-pi/snapcompact`. */
|
|
23
|
+
export function formatFileOperations(
|
|
24
|
+
readFiles: string[],
|
|
25
|
+
modifiedFiles: string[],
|
|
26
|
+
readSet?: ReadonlySet<string>,
|
|
27
|
+
): string {
|
|
28
|
+
return snapcompact.upsertFileOperations("", readFiles, modifiedFiles, readSet);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** Error text for a summarization call that hit its output limit (`stopReason: "length"`): partial text is never used. */
|
|
@@ -91,27 +91,10 @@ export function isAlternateFormReadSelector(sel: string): boolean {
|
|
|
91
91
|
return sel.split(":").some(part => READ_ALTERNATE_FORM_RE.test(part));
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
/**
|
|
95
|
-
* A real filesystem path never contains a `scheme://` URL. Tool-call paths that
|
|
96
|
-
* do — `conflict://1`, `artifact://3`, `local://ctx.md`, `history://…`,
|
|
97
|
-
* `issue://12`, `https://…`, and the tolerated `file.ts:conflict://1` prefix
|
|
98
|
-
* form — are session-scoped or remote resources, not files the post-compaction
|
|
99
|
-
* agent can re-ground on. Keep them out of the `<files>` summary.
|
|
100
|
-
*/
|
|
101
|
-
const URL_SCHEME_RE = /[a-z][a-z0-9+.-]*:\/\//i;
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Whether `path` references a `scheme://` URL (internal URI or web URL) rather
|
|
105
|
-
* than a filesystem path that belongs in the compaction `<files>` summary.
|
|
106
|
-
*/
|
|
107
|
-
export function isUrlSchemePath(path: string): boolean {
|
|
108
|
-
return URL_SCHEME_RE.test(path);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
94
|
/**
|
|
112
95
|
* Extract file operations from tool calls in an assistant message.
|
|
113
96
|
*/
|
|
114
|
-
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
|
|
97
|
+
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: snapcompact.FileOperations): void {
|
|
115
98
|
if (message.role !== "assistant") return;
|
|
116
99
|
if (!("content" in message) || !Array.isArray(message.content)) return;
|
|
117
100
|
|
|
@@ -128,7 +111,7 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
|
|
|
128
111
|
|
|
129
112
|
// Internal URIs (conflict://, artifact://, local://, history://, …) and
|
|
130
113
|
// web URLs are not re-groundable files — keep them out of `<files>`.
|
|
131
|
-
if (isUrlSchemePath(path)) continue;
|
|
114
|
+
if (snapcompact.isUrlSchemePath(path)) continue;
|
|
132
115
|
|
|
133
116
|
switch (block.name) {
|
|
134
117
|
case "read":
|
|
@@ -144,68 +127,6 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
|
|
|
144
127
|
}
|
|
145
128
|
}
|
|
146
129
|
|
|
147
|
-
/**
|
|
148
|
-
* Compute final file lists from file operations.
|
|
149
|
-
* Returns readFiles (files only read, not modified) and modifiedFiles.
|
|
150
|
-
*/
|
|
151
|
-
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
|
|
152
|
-
// Drop any `scheme://` URLs (e.g. legacy `conflict://`/`artifact://` entries
|
|
153
|
-
// rehydrated straight into `fileOps` from a pre-fix compaction summary) — only
|
|
154
|
-
// real files belong in `<files>`. New tool-call scans are already filtered.
|
|
155
|
-
const modified = new Set([...fileOps.edited, ...fileOps.written].filter(f => !isUrlSchemePath(f)));
|
|
156
|
-
const readOnly = [...fileOps.read].filter(f => !isUrlSchemePath(f) && !modified.has(f)).sort();
|
|
157
|
-
const modifiedFiles = [...modified].sort();
|
|
158
|
-
return { readFiles: readOnly, modifiedFiles };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Format file operations as one `<files>` tag: a grouped, prefix-folded
|
|
163
|
-
* directory tree (find-tool shape — `# dir/` headers, bare basenames) with a
|
|
164
|
-
* ` (Read)` / ` (Write)` / ` (RW)` marker per file instead of separate
|
|
165
|
-
* read/modified lists. `readSet` is the cumulative read set (`fileOps.read`),
|
|
166
|
-
* used to tell modified files that were also read (RW) from blind writes.
|
|
167
|
-
*/
|
|
168
|
-
const FILE_OPERATION_SUMMARY_LIMIT = 20;
|
|
169
|
-
|
|
170
|
-
function stripFileOperationTags(summary: string): string {
|
|
171
|
-
// Legacy <read-files>/<modified-files> tags are still stripped so summaries
|
|
172
|
-
// written before the combined <files> tag self-heal on the next compaction.
|
|
173
|
-
return summary
|
|
174
|
-
.replace(/<files>[\s\S]*?<\/files>\s*/g, "")
|
|
175
|
-
.replace(/<read-files>[\s\S]*?<\/read-files>\s*/g, "")
|
|
176
|
-
.replace(/<modified-files>[\s\S]*?<\/modified-files>\s*/g, "")
|
|
177
|
-
.trimEnd();
|
|
178
|
-
}
|
|
179
|
-
export function formatFileOperations(
|
|
180
|
-
readFiles: string[],
|
|
181
|
-
modifiedFiles: string[],
|
|
182
|
-
readSet?: ReadonlySet<string>,
|
|
183
|
-
): string {
|
|
184
|
-
if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
|
|
185
|
-
const mode = new Map<string, "Read" | "Write" | "RW">();
|
|
186
|
-
for (const file of readFiles) mode.set(file, "Read");
|
|
187
|
-
for (const file of modifiedFiles) mode.set(file, readSet?.has(file) ? "RW" : "Write");
|
|
188
|
-
const all = [...mode.keys()].sort();
|
|
189
|
-
let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
|
|
190
|
-
if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
|
|
191
|
-
files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`;
|
|
192
|
-
}
|
|
193
|
-
return prompt.render(fileOperationsTemplate, { files });
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
export function upsertFileOperations(
|
|
197
|
-
summary: string,
|
|
198
|
-
readFiles: string[],
|
|
199
|
-
modifiedFiles: string[],
|
|
200
|
-
readSet?: ReadonlySet<string>,
|
|
201
|
-
): string {
|
|
202
|
-
const baseSummary = stripFileOperationTags(summary);
|
|
203
|
-
const fileOperations = formatFileOperations(readFiles, modifiedFiles, readSet);
|
|
204
|
-
if (!fileOperations) return baseSummary;
|
|
205
|
-
if (!baseSummary) return fileOperations;
|
|
206
|
-
return `${baseSummary}\n\n${fileOperations}`;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
130
|
// ============================================================================
|
|
210
131
|
// Message Serialization
|
|
211
132
|
// ============================================================================
|
package/src/telemetry.ts
CHANGED
|
@@ -1836,7 +1836,7 @@ export interface InstrumentedChatSpanOptions {
|
|
|
1836
1836
|
export async function instrumentedCompleteSimple<TApi extends Api>(
|
|
1837
1837
|
model: Model<TApi>,
|
|
1838
1838
|
ctx: Context,
|
|
1839
|
-
options: SimpleStreamOptions,
|
|
1839
|
+
options: SimpleStreamOptions & { onAttempt?: (message: AssistantMessage) => void },
|
|
1840
1840
|
span: InstrumentedChatSpanOptions,
|
|
1841
1841
|
): Promise<AssistantMessage> {
|
|
1842
1842
|
const { telemetry, parent, oneshotKind } = span;
|