@sentry/junior 0.203.0 → 0.205.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/dist/agent-D4nu8MQV.js +17521 -0
- package/dist/agent-hooks-DXHpTJ4m.js +4042 -0
- package/dist/agent-runner-C04colea.js +678 -0
- package/dist/api/schema.d.ts +2736 -2639
- package/dist/api/schema.js +4 -169
- package/dist/api.d.ts +23 -17
- package/dist/api.js +3881 -5326
- package/dist/app-Cb776lL8.d.ts +247 -0
- package/dist/app.d.ts +4 -520
- package/dist/app.js +9524 -11875
- package/dist/auth-pause-state-C2P3guS1.js +168 -0
- package/dist/catalog-runtime-CXw3asJD.js +7 -0
- package/dist/checkpoint-CrViJ8Ps.js +2450 -0
- package/dist/cli/chat.d.ts +20 -0
- package/dist/cli/chat.js +229 -336
- package/dist/cli/check.d.ts +8 -0
- package/dist/cli/check.js +513 -806
- package/dist/cli/env.d.ts +13 -0
- package/dist/cli/env.js +63 -12
- package/dist/cli/init.d.ts +3 -0
- package/dist/cli/init.js +90 -159
- package/dist/cli/main.d.ts +6 -0
- package/dist/cli/main.js +43 -64
- package/dist/cli/plugins.d.ts +13 -0
- package/dist/cli/plugins.js +148 -227
- package/dist/cli/run.d.ts +16 -0
- package/dist/cli/run.js +52 -8
- package/dist/cli/snapshot-create.d.ts +4 -0
- package/dist/cli/snapshot-create.js +71 -125
- package/dist/cli/upgrade.d.ts +15 -0
- package/dist/cli/upgrade.js +232 -335
- package/dist/config-BOtJQ7Pc.js +941 -0
- package/dist/context-B45_WqVX.js +298 -0
- package/dist/conversation-privacy-BZRA6Pes.js +182 -0
- package/dist/conversation-work-C6y3T5Hx.js +51 -0
- package/dist/credential-sync-Bep34IB_.js +2661 -0
- package/dist/db-BeyXKJOI.d.ts +5 -0
- package/dist/destinations-BeBiJeEM.js +29 -0
- package/dist/execution-stats-B6lgWxCK.js +373 -0
- package/dist/executor-BlUhFL7_.js +721 -0
- package/dist/experimental-CIXf7Uye.js +46 -0
- package/dist/history-ClE4toxe.d.ts +32 -0
- package/dist/history-CqTuSAdI.js +441 -0
- package/dist/instrumentation.d.ts +3 -3
- package/dist/instrumentation.js +130 -176
- package/dist/job-queue-DLMqIK74.js +63 -0
- package/dist/logging-BMJzdDlP.js +1210 -0
- package/dist/model-profile-CZmVoGdX.js +49 -0
- package/dist/nitro.d.ts +31 -36
- package/dist/nitro.js +275 -456
- package/dist/oauth-callback-server-WwG5_Zgm.js +130 -0
- package/dist/oauth-relay-CdCnu80W.js +62 -0
- package/dist/package-discovery-7a9YqLhE.js +455 -0
- package/dist/pending-auth-CUbXq06G.js +185 -0
- package/dist/plugin-module-BKGOXca2.js +81 -0
- package/dist/plugins-BI9A1vKA.d.ts +95 -0
- package/dist/plugins-egKWjgue.js +99 -0
- package/dist/private-trace-result-BLvBjy_e.js +16 -0
- package/dist/profile-T0tXE4zu.js +861 -0
- package/dist/registry-BA0__8ai.js +1419 -0
- package/dist/reporting-schema-DosTN__Q.js +105 -0
- package/dist/resolve-bvD1ORdv.js +546 -0
- package/dist/rolldown-runtime-B4FMCO8f.js +28 -0
- package/dist/runner-BXYxfIUL.js +392 -0
- package/dist/sandbox-egress-signals-ngEPjSEW.js +46 -0
- package/dist/schema-Rn8-XEaN.js +1140 -0
- package/dist/sentry-CD3SdJl-.js +26 -0
- package/dist/skills-DJOlYdBs.js +316 -0
- package/dist/spawn-h0R1bTEo.js +29 -0
- package/dist/stats-3YZn-Swi.js +2648 -0
- package/dist/store-C5qxF2ac.d.ts +208 -0
- package/dist/task-queue-CoXwd2eq.js +136 -0
- package/dist/task-runner-B2_mJZj0.js +639 -0
- package/dist/thread-state-CqZ1m-es.js +431 -0
- package/dist/turn-execution-fI6PZ0iI.js +47 -0
- package/dist/turn-lifecycle-CcnwrOcp.js +54 -0
- package/dist/usage-B5SfMb3H.js +296 -0
- package/dist/usage-schema-CKaDkH_j.js +20 -0
- package/dist/validation-DfWcvhuS.js +40 -0
- package/dist/vercel-queue-DIkVcfK4.js +167 -0
- package/dist/vercel.d.ts +6 -6
- package/dist/vercel.js +14 -6
- package/dist/version-CnXZ614E.d.ts +5 -0
- package/dist/version-oD-oPrbO.js +8 -0
- package/dist/version.d.ts +2 -4
- package/dist/version.js +2 -6
- package/dist/web-authorization-Crbzlmzo.js +76 -0
- package/dist/web-input-BOuq3oKK.js +494 -0
- package/dist/work-B91LQKHr.js +509 -0
- package/dist/worker-BcRRTe6k.js +625 -0
- package/package.json +9 -9
- package/dist/agent-hooks-GB2VPTNA.js +0 -51
- package/dist/catalog-runtime-UBLIWD3O.js +0 -14
- package/dist/chunk-2LYHWMR4.js +0 -46
- package/dist/chunk-352OWAZH.js +0 -320
- package/dist/chunk-3Y47VMHV.js +0 -6
- package/dist/chunk-3YJSKOWF.js +0 -164
- package/dist/chunk-4KSSKTQ5.js +0 -229
- package/dist/chunk-4UO6FK4G.js +0 -64
- package/dist/chunk-5AHX53NQ.js +0 -717
- package/dist/chunk-5J3E5FWA.js +0 -700
- package/dist/chunk-5PDAG6O3.js +0 -206
- package/dist/chunk-6Y5B5PEX.js +0 -80
- package/dist/chunk-7GWQ5S5R.js +0 -2086
- package/dist/chunk-7H2VZTWN.js +0 -103
- package/dist/chunk-7NL7T6VU.js +0 -406
- package/dist/chunk-A5L27LE4.js +0 -387
- package/dist/chunk-ADIAWL34.js +0 -81
- package/dist/chunk-ASTEMBKJ.js +0 -519
- package/dist/chunk-CEMMLV5L.js +0 -98
- package/dist/chunk-CM5HNV2W.js +0 -131
- package/dist/chunk-CRTRBBRI.js +0 -105
- package/dist/chunk-DA2KGZZI.js +0 -13
- package/dist/chunk-DIB4GX3G.js +0 -8
- package/dist/chunk-DKIJ2RP5.js +0 -68
- package/dist/chunk-DWPN6SCH.js +0 -172
- package/dist/chunk-EDKUFGPL.js +0 -64
- package/dist/chunk-ENY6SKIU.js +0 -1228
- package/dist/chunk-F4MGCZOX.js +0 -2943
- package/dist/chunk-FA6POGLH.js +0 -70
- package/dist/chunk-FSUR7GTG.js +0 -20475
- package/dist/chunk-GKEOHJL5.js +0 -1856
- package/dist/chunk-GMCB6W5Y.js +0 -333
- package/dist/chunk-IE2VPG7O.js +0 -1135
- package/dist/chunk-IZ5B5IHQ.js +0 -3656
- package/dist/chunk-JAL3CH4G.js +0 -27
- package/dist/chunk-KIIEUXD3.js +0 -1764
- package/dist/chunk-KXU4NIL7.js +0 -692
- package/dist/chunk-MTM5IFCY.js +0 -1190
- package/dist/chunk-N7TMSOZH.js +0 -235
- package/dist/chunk-NCEBK6PH.js +0 -36
- package/dist/chunk-NHJU2ZAL.js +0 -56
- package/dist/chunk-P6KTRNVC.js +0 -321
- package/dist/chunk-PDO5BLNM.js +0 -22
- package/dist/chunk-PDRNLLFT.js +0 -3295
- package/dist/chunk-PEQXYSSE.js +0 -45
- package/dist/chunk-PKEX2AQX.js +0 -198
- package/dist/chunk-PSJCGIGO.js +0 -1163
- package/dist/chunk-PZUSUL55.js +0 -506
- package/dist/chunk-R45M4TUS.js +0 -37
- package/dist/chunk-RC745GGZ.js +0 -122
- package/dist/chunk-SP3QKZXJ.js +0 -820
- package/dist/chunk-TXKUKMVO.js +0 -417
- package/dist/chunk-UE4GPWSD.js +0 -931
- package/dist/chunk-UIE3R5XU.js +0 -17
- package/dist/chunk-UXPG6ZIN.js +0 -84
- package/dist/chunk-VFUK3X5B.js +0 -50
- package/dist/chunk-VNMJWREK.js +0 -787
- package/dist/chunk-VZ7CSOX2.js +0 -706
- package/dist/chunk-WCNQJKFT.js +0 -10
- package/dist/chunk-WDZR3QQM.js +0 -135
- package/dist/chunk-WLYVJCHV.js +0 -1433
- package/dist/chunk-WS7NAFTM.js +0 -1520
- package/dist/chunk-X6GDDWQH.js +0 -60
- package/dist/chunk-XPDEYNQI.js +0 -102
- package/dist/chunk-XRW44VGM.js +0 -58
- package/dist/chunk-Y2CM7HXH.js +0 -111
- package/dist/chunk-Y7X25LFY.js +0 -15
- package/dist/conversation-work-VIOQ5ZF3.js +0 -54
- package/dist/db-NGDJV7YH.js +0 -26
- package/dist/discovery-DWVDM7UK.js +0 -44
- package/dist/experimental-P5FZM4CU.js +0 -12
- package/dist/oauth-callback-server-YFY7YEGK.js +0 -243
- package/dist/oauth-relay-5XPVRPYB.js +0 -8
- package/dist/plugins-YTYJMMJF.js +0 -14
- package/dist/runner-ORTNSB2Q.js +0 -522
- package/dist/sandbox-egress-signals-FIL7EGPR.js +0 -73
- package/dist/sentry-MB3NL3IR.js +0 -42
- package/dist/skills-H376WQOH.js +0 -27
- package/dist/spawn-T43WHLOS.js +0 -42
- package/dist/task-runner-LBZWF32V.js +0 -36
- package/dist/validation-REJLRKI6.js +0 -17
- package/dist/work-3KJ3RE76.js +0 -51
- package/dist/worker-QFEBU72E.js +0 -24
|
@@ -0,0 +1,2450 @@
|
|
|
1
|
+
import { D as unwrapCurrentInstruction, O as escapeXml, b as resolveGatewayModel, n as botConfig, v as completeText } from "./config-BOtJQ7Pc.js";
|
|
2
|
+
import { _ as setSpanAttributes, f as logWarn, o as getActiveTraceId, t as buildTurnFailureResponse, u as logException } from "./logging-BMJzdDlP.js";
|
|
3
|
+
import { _ as parseSlackMessageTs, f as toStoredSlackActor } from "./context-B45_WqVX.js";
|
|
4
|
+
import { At as sanitizePostgresJson, Ct as getConversationEventStore, Dt as getSqlExecutor, Nt as bindProviderConversation, Ot as createSqlConversationEventStore, Tt as getConversationStore, kt as withConversationEventLock } from "./agent-hooks-DXHpTJ4m.js";
|
|
5
|
+
import { d as instructionProvenanceFor, l as contextProvenance, n as agentHistoryItemSchema, u as instructionActors } from "./history-CqTuSAdI.js";
|
|
6
|
+
import { c as generateShortTitle, d as agentsInstructionsUpdatedEvent, f as authenticationLinkedEvent, o as ToolInputError, p as authenticationUnlinkedEvent, s as fallbackShortTitle, t as addAgentTurnUsage, u as JUNIOR_NATIVE_EVENT_NAMESPACE } from "./usage-B5SfMb3H.js";
|
|
7
|
+
import { A as sleep, D as fenceLock, M as SANDBOX_REPOS_ROOT, P as SANDBOX_WORKSPACE_ROOT, T as MUTATION_LOCK_TTL_MS, k as withLock, w as getStateAdapter } from "./profile-T0tXE4zu.js";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { isDeepStrictEqual } from "node:util";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { actorSchema, sourceSchema } from "@sentry/junior-plugin-api";
|
|
13
|
+
//#region src/chat/pi/messages.ts
|
|
14
|
+
/** Permissive schema for durable Pi SDK messages whose content shape may evolve. */
|
|
15
|
+
const piMessageSchema = z.object({ role: z.string() }).passthrough().transform((value) => value);
|
|
16
|
+
z.object({
|
|
17
|
+
content: z.array(z.unknown()),
|
|
18
|
+
role: z.string().min(1)
|
|
19
|
+
}).passthrough().transform((value) => value);
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/chat/turn-context-tag.ts
|
|
22
|
+
/**
|
|
23
|
+
* Canonical tag name for the runtime turn context block injected into Pi
|
|
24
|
+
* messages. Shared between prompt assembly and turn context stripping to keep
|
|
25
|
+
* both in sync without requiring the full prompt module.
|
|
26
|
+
*/
|
|
27
|
+
const TURN_CONTEXT_TAG = "runtime-turn-context";
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/chat/pi/transcript.ts
|
|
30
|
+
const RUNTIME_TURN_CONTEXT_START = `<${TURN_CONTEXT_TAG}>`;
|
|
31
|
+
const AGENTS_INSTRUCTIONS_START = "# AGENTS.md instructions";
|
|
32
|
+
function userMessageContent(message) {
|
|
33
|
+
const record = message;
|
|
34
|
+
return record.role === "user" && Array.isArray(record.content) ? record.content : void 0;
|
|
35
|
+
}
|
|
36
|
+
function isRuntimeTurnContextPart(part) {
|
|
37
|
+
return part !== null && typeof part === "object" && part.type === "text" && typeof part.text === "string" && (part.text.startsWith(RUNTIME_TURN_CONTEXT_START) || part.text.startsWith(AGENTS_INSTRUCTIONS_START));
|
|
38
|
+
}
|
|
39
|
+
function hasCurrentInstruction(message) {
|
|
40
|
+
return userMessageContent(message)?.some((part) => part !== null && typeof part === "object" && part.type === "text" && typeof part.text === "string" && part.text.includes("<current-instruction")) ?? false;
|
|
41
|
+
}
|
|
42
|
+
function messageTimestamp$1(message) {
|
|
43
|
+
return message.timestamp;
|
|
44
|
+
}
|
|
45
|
+
function isStandaloneRuntimeContextMessage(messages, index) {
|
|
46
|
+
const message = messages[index];
|
|
47
|
+
const next = messages[index + 1];
|
|
48
|
+
return Boolean(message && next && userMessageContent(message) && userMessageContent(next) && messageTimestamp$1(message) === messageTimestamp$1(next) && !hasCurrentInstruction(message) && hasCurrentInstruction(next));
|
|
49
|
+
}
|
|
50
|
+
const EMBEDDED_THREAD_CONTEXT_PATTERN = new RegExp(`<(${[
|
|
51
|
+
"thread-context",
|
|
52
|
+
"recent-thread-messages",
|
|
53
|
+
"thread-compactions",
|
|
54
|
+
"thread-transcript",
|
|
55
|
+
"thread-background"
|
|
56
|
+
].join("|")})(?:\\s[^>]*)?>[\\s\\S]*</\\1>`, "g");
|
|
57
|
+
/** Type guard for Pi SDK assistant messages. */
|
|
58
|
+
function isAssistantMessage(value) {
|
|
59
|
+
return typeof value === "object" && value !== null && value.role === "assistant";
|
|
60
|
+
}
|
|
61
|
+
/** Type guard for Pi SDK tool result messages. */
|
|
62
|
+
function isToolResultMessage(value) {
|
|
63
|
+
return typeof value === "object" && value !== null && value.role === "toolResult";
|
|
64
|
+
}
|
|
65
|
+
/** Check whether a tool result carries an error flag. */
|
|
66
|
+
function isToolResultError(result) {
|
|
67
|
+
if (!result || typeof result !== "object") return false;
|
|
68
|
+
return Boolean(result.isError);
|
|
69
|
+
}
|
|
70
|
+
/** Extract the tool name from a raw tool result message. */
|
|
71
|
+
function normalizeToolNameFromResult(result) {
|
|
72
|
+
if (!result || typeof result !== "object") return void 0;
|
|
73
|
+
const record = result;
|
|
74
|
+
if (typeof record.toolName === "string" && record.toolName.length > 0) return record.toolName;
|
|
75
|
+
if (typeof record.name === "string" && record.name.length > 0) return record.name;
|
|
76
|
+
}
|
|
77
|
+
/** Extract role string from a raw Pi message. */
|
|
78
|
+
function getPiMessageRole(value) {
|
|
79
|
+
if (!value || typeof value !== "object") return;
|
|
80
|
+
const role = value.role;
|
|
81
|
+
return typeof role === "string" ? role : void 0;
|
|
82
|
+
}
|
|
83
|
+
/** Return whether Pi can continue directly from the current history tail. */
|
|
84
|
+
function isContinuablePiBoundary(messages) {
|
|
85
|
+
const lastRole = getPiMessageRole(messages.at(-1));
|
|
86
|
+
return lastRole === "user" || lastRole === "toolResult";
|
|
87
|
+
}
|
|
88
|
+
/** Concatenate text content parts from an assistant message. */
|
|
89
|
+
function extractAssistantText(message) {
|
|
90
|
+
return (message.content ?? []).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n");
|
|
91
|
+
}
|
|
92
|
+
/** Return assistant messages that belong to the terminal post-tool reply phase. */
|
|
93
|
+
function getTerminalAssistantMessages(messages) {
|
|
94
|
+
let lastNonAssistantIndex = -1;
|
|
95
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) if (!isAssistantMessage(messages[index])) {
|
|
96
|
+
lastNonAssistantIndex = index;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
return messages.slice(lastNonAssistantIndex + 1).filter(isAssistantMessage);
|
|
100
|
+
}
|
|
101
|
+
/** Remove trailing assistant messages before committing a resumable boundary. */
|
|
102
|
+
function trimTrailingAssistantMessages(messages) {
|
|
103
|
+
let end = messages.length;
|
|
104
|
+
while (end > 0 && getPiMessageRole(messages[end - 1]) === "assistant") end -= 1;
|
|
105
|
+
return end === messages.length ? [...messages] : messages.slice(0, end);
|
|
106
|
+
}
|
|
107
|
+
/** Return whether Pi history already carries session bootstrap context. */
|
|
108
|
+
function hasRuntimeTurnContext(messages) {
|
|
109
|
+
return messages.some((message, index) => isStandaloneRuntimeContextMessage(messages, index) || userMessageContent(message)?.some(isRuntimeTurnContextPart));
|
|
110
|
+
}
|
|
111
|
+
/** Keep only volatile bootstrap messages needed by an in-turn context replacement. */
|
|
112
|
+
function retainRuntimeTurnContext(messages) {
|
|
113
|
+
return messages.flatMap((message, index) => {
|
|
114
|
+
if (isStandaloneRuntimeContextMessage(messages, index)) return [message];
|
|
115
|
+
const runtimeContent = userMessageContent(message)?.filter(isRuntimeTurnContextPart) ?? [];
|
|
116
|
+
return runtimeContent.length > 0 ? [{
|
|
117
|
+
...message,
|
|
118
|
+
content: runtimeContent
|
|
119
|
+
}] : [];
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Reduce a runtime user-turn prompt to only the current turn's instruction.
|
|
124
|
+
*
|
|
125
|
+
* Live user prompts embed prior-thread context as `<thread-context
|
|
126
|
+
* authority="evidence-only">` (or legacy transcript/background blocks) beside
|
|
127
|
+
* the `<current-instruction>` block. Those blocks hold other participants'
|
|
128
|
+
* verbatim messages, so completed-run projections consumed by plugins must
|
|
129
|
+
* expose only the instruction authored by this turn's actor — otherwise
|
|
130
|
+
* per-entry provenance can be defeated by reading another user's text out of an
|
|
131
|
+
* instruction-authority entry. Prior thread context is projected separately as
|
|
132
|
+
* per-author context-authority entries, so dropping it here is non-lossy for
|
|
133
|
+
* plugins. This is projection-only; it never touches what the model sees during
|
|
134
|
+
* a live run.
|
|
135
|
+
*/
|
|
136
|
+
function instructionTextForProjection(text) {
|
|
137
|
+
const withoutContext = text.replace(EMBEDDED_THREAD_CONTEXT_PATTERN, "").trim();
|
|
138
|
+
return unwrapCurrentInstruction(withoutContext) ?? withoutContext;
|
|
139
|
+
}
|
|
140
|
+
/** Return the projected instruction text carried by one user message. */
|
|
141
|
+
function getUserMessageInstructionText(message) {
|
|
142
|
+
if (getPiMessageRole(message) !== "user") return "";
|
|
143
|
+
const content = message.content;
|
|
144
|
+
return instructionTextForProjection(typeof content === "string" ? content : Array.isArray(content) ? content.flatMap((part) => part && typeof part === "object" && part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n") : "").trim();
|
|
145
|
+
}
|
|
146
|
+
/** Remove volatile runtime context before reusing messages as history. */
|
|
147
|
+
function stripRuntimeTurnContext(messages) {
|
|
148
|
+
return messages.flatMap((message, index) => {
|
|
149
|
+
if (isStandaloneRuntimeContextMessage(messages, index)) return [];
|
|
150
|
+
const content = userMessageContent(message);
|
|
151
|
+
if (!content) return [message];
|
|
152
|
+
const nextContent = content.filter((part) => !isRuntimeTurnContextPart(part));
|
|
153
|
+
if (nextContent.length === content.length) return [message];
|
|
154
|
+
if (nextContent.length === 0) return [];
|
|
155
|
+
return [{
|
|
156
|
+
...message,
|
|
157
|
+
content: nextContent
|
|
158
|
+
}];
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/chat/pi/conversation-events.ts
|
|
163
|
+
function authorizationObservationMessage(data, createdAtMs) {
|
|
164
|
+
const label = data.kind === "mcp" ? "MCP authorization" : "Authorization";
|
|
165
|
+
return piMessageSchema.parse({
|
|
166
|
+
role: "user",
|
|
167
|
+
content: [{
|
|
168
|
+
type: "text",
|
|
169
|
+
text: `${label} completed for provider "${data.provider}". Continue the blocked request and retry the provider operation if needed.`
|
|
170
|
+
}],
|
|
171
|
+
timestamp: createdAtMs
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function durableMessages(message) {
|
|
175
|
+
return stripRuntimeTurnContext([message]);
|
|
176
|
+
}
|
|
177
|
+
/** Translate one Pi message into Junior's native durable history item. */
|
|
178
|
+
function historyItemFromPiMessage(message, provenance) {
|
|
179
|
+
const { role, ...fields } = piMessageSchema.parse(message);
|
|
180
|
+
if (role === "user") return agentHistoryItemSchema.parse({
|
|
181
|
+
...fields,
|
|
182
|
+
type: "user_message",
|
|
183
|
+
provenance
|
|
184
|
+
});
|
|
185
|
+
if (role === "assistant") return agentHistoryItemSchema.parse({
|
|
186
|
+
...fields,
|
|
187
|
+
type: "assistant_message"
|
|
188
|
+
});
|
|
189
|
+
if (role === "toolResult") return agentHistoryItemSchema.parse({
|
|
190
|
+
...fields,
|
|
191
|
+
type: "tool_result"
|
|
192
|
+
});
|
|
193
|
+
throw new Error(`Unsupported durable agent message role "${role}"`);
|
|
194
|
+
}
|
|
195
|
+
/** Translate one Junior-native history item into the Pi message it represents. */
|
|
196
|
+
function piMessageFromHistoryItem(data) {
|
|
197
|
+
if (data.type === "user_message") {
|
|
198
|
+
const { type: _, provenance: __, ...fields } = data;
|
|
199
|
+
return piMessageSchema.parse({
|
|
200
|
+
...fields,
|
|
201
|
+
role: "user"
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (data.type === "assistant_message") {
|
|
205
|
+
const { type: _, ...fields } = data;
|
|
206
|
+
return piMessageSchema.parse({
|
|
207
|
+
...fields,
|
|
208
|
+
role: "assistant"
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const { type: _, ...fields } = data;
|
|
212
|
+
return piMessageSchema.parse({
|
|
213
|
+
...fields,
|
|
214
|
+
role: "toolResult"
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
function historyItemProvenance(data) {
|
|
218
|
+
return data.type === "user_message" ? data.provenance : contextProvenance;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Project ordered Junior events into Pi context.
|
|
222
|
+
*
|
|
223
|
+
* Compaction and handoff start with replacement history. Later native history
|
|
224
|
+
* events append after it.
|
|
225
|
+
*
|
|
226
|
+
* Host-only events are filtered, completed authorization becomes a synthetic
|
|
227
|
+
* observation, and `maxSeq` reproduces an exact committed boundary.
|
|
228
|
+
*/
|
|
229
|
+
function projectConversationEvents(events, options) {
|
|
230
|
+
const messages = [];
|
|
231
|
+
const provenance = [];
|
|
232
|
+
const seqs = [];
|
|
233
|
+
let modelProfile = options.defaultProfile;
|
|
234
|
+
let replacementSeq;
|
|
235
|
+
for (const event of events) {
|
|
236
|
+
if (options.maxSeq !== void 0 && event.seq > options.maxSeq) break;
|
|
237
|
+
if (event.data.type === "unknown") throw new Error(`Unsupported conversation event "${event.data.originalType}" at seq ${event.seq} (schema version ${event.schemaVersion})`);
|
|
238
|
+
if (event.data.type === "compaction" || event.data.type === "handoff") {
|
|
239
|
+
modelProfile = event.data.modelProfile;
|
|
240
|
+
replacementSeq = event.seq;
|
|
241
|
+
for (const replacement of event.data.replacementHistory) for (const message of durableMessages(piMessageFromHistoryItem(replacement.item))) {
|
|
242
|
+
messages.push(message);
|
|
243
|
+
provenance.push(historyItemProvenance(replacement.item));
|
|
244
|
+
seqs.push(replacement.sourceEventSeq ?? event.seq);
|
|
245
|
+
}
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (event.data.type === "user_message" || event.data.type === "assistant_message" || event.data.type === "tool_result") {
|
|
249
|
+
for (const message of durableMessages(piMessageFromHistoryItem(event.data))) {
|
|
250
|
+
messages.push(message);
|
|
251
|
+
provenance.push(historyItemProvenance(event.data));
|
|
252
|
+
seqs.push(event.seq);
|
|
253
|
+
}
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (event.data.type === "authorization_completed") {
|
|
257
|
+
messages.push(authorizationObservationMessage(event.data, event.createdAtMs));
|
|
258
|
+
provenance.push(contextProvenance);
|
|
259
|
+
seqs.push(event.seq);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
messages,
|
|
264
|
+
provenance,
|
|
265
|
+
seqs,
|
|
266
|
+
modelProfile,
|
|
267
|
+
replacementSeq
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/chat/services/context-budget.ts
|
|
272
|
+
const COMPACTION_TRIGGER_RATIO = .9;
|
|
273
|
+
const CONTEXT_INPUT_LIMIT_RATIO = .95;
|
|
274
|
+
const COMPACTION_TARGET_RATIO = .8;
|
|
275
|
+
const FALLBACK_CONTEXT_WINDOW_TOKENS = 4e5;
|
|
276
|
+
function positiveInteger$1(value, fallback) {
|
|
277
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
278
|
+
}
|
|
279
|
+
/** Estimate text tokens with the shared coarse heuristic used for local budgets. */
|
|
280
|
+
function estimateTextTokens(text) {
|
|
281
|
+
return Math.ceil(text.length / 4);
|
|
282
|
+
}
|
|
283
|
+
/** Derive the automatic compaction threshold from model context capacity. */
|
|
284
|
+
function calculateContextCompactionTriggerTokens(model) {
|
|
285
|
+
const contextWindow = positiveInteger$1(model.contextWindow, FALLBACK_CONTEXT_WINDOW_TOKENS);
|
|
286
|
+
return Math.max(1, Math.floor(contextWindow * COMPACTION_TRIGGER_RATIO));
|
|
287
|
+
}
|
|
288
|
+
/** Derive the maximum estimated input size with room for uncounted request overhead. */
|
|
289
|
+
function calculateContextInputLimitTokens(model) {
|
|
290
|
+
const contextWindow = positiveInteger$1(model.contextWindow, FALLBACK_CONTEXT_WINDOW_TOKENS);
|
|
291
|
+
return Math.max(1, Math.floor(contextWindow * CONTEXT_INPUT_LIMIT_RATIO));
|
|
292
|
+
}
|
|
293
|
+
/** Derive the post-compaction target from the automatic trigger threshold. */
|
|
294
|
+
function calculateContextCompactionTargetTokens(triggerTokens) {
|
|
295
|
+
return Math.max(1, Math.floor(triggerTokens * COMPACTION_TARGET_RATIO));
|
|
296
|
+
}
|
|
297
|
+
/** Cap one model's advertised context capacity with the host bot configuration. */
|
|
298
|
+
function getModelContextBudget(modelId) {
|
|
299
|
+
const advertisedContextWindow = positiveInteger$1(resolveGatewayModel(modelId).contextWindow, FALLBACK_CONTEXT_WINDOW_TOKENS);
|
|
300
|
+
return { contextWindow: Math.min(botConfig.contextWindowTokens, advertisedContextWindow) };
|
|
301
|
+
}
|
|
302
|
+
/** Resolve the automatic compaction threshold for the active agent model. */
|
|
303
|
+
function getAgentContextCompactionTriggerTokens(modelId) {
|
|
304
|
+
return calculateContextCompactionTriggerTokens(getModelContextBudget(modelId));
|
|
305
|
+
}
|
|
306
|
+
/** Resolve the hard input ceiling for the active agent model. */
|
|
307
|
+
function getAgentContextInputLimitTokens(modelId) {
|
|
308
|
+
return calculateContextInputLimitTokens(getModelContextBudget(modelId));
|
|
309
|
+
}
|
|
310
|
+
/** Resolve the visible conversation compaction threshold for the auxiliary model. */
|
|
311
|
+
function getConversationContextCompactionTriggerTokens() {
|
|
312
|
+
return calculateContextCompactionTriggerTokens(getModelContextBudget(botConfig.fastModelId));
|
|
313
|
+
}
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/chat/services/conversation-memory.ts
|
|
316
|
+
const CONTEXT_MIN_LIVE_MESSAGES = 12;
|
|
317
|
+
const CONTEXT_COMPACTION_BATCH_SIZE = 24;
|
|
318
|
+
const CONTEXT_MAX_COMPACTIONS = 16;
|
|
319
|
+
const CONTEXT_MAX_MESSAGE_CHARS = 3200;
|
|
320
|
+
const SLACK_USER_ID_DISPLAY_PATTERN = /^[UW][A-Z0-9]{5,}$/;
|
|
321
|
+
function generateConversationId(prefix) {
|
|
322
|
+
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
323
|
+
}
|
|
324
|
+
/** Normalize message boundaries without changing transcript formatting. */
|
|
325
|
+
function normalizeConversationText(text) {
|
|
326
|
+
return text.replace(/\r\n?/g, "\n").trim();
|
|
327
|
+
}
|
|
328
|
+
function buildImageContextSuffix(message, conversation) {
|
|
329
|
+
const byFileId = conversation?.vision.byFileId;
|
|
330
|
+
const imageFileIds = message.meta?.imageFileIds ?? [];
|
|
331
|
+
if (!byFileId || imageFileIds.length === 0) return "";
|
|
332
|
+
const summaries = imageFileIds.map((fileId) => byFileId[fileId]?.summary?.trim()).filter((summary) => Boolean(summary));
|
|
333
|
+
if (summaries.length === 0) return "";
|
|
334
|
+
return ` [image context: ${escapeXml(summaries.join(" | "))}]`;
|
|
335
|
+
}
|
|
336
|
+
function renderConversationMessageLine(message, conversation) {
|
|
337
|
+
const displayName = escapeXml(conversationAuthorDisplayName(message));
|
|
338
|
+
const text = escapeXml(message.text.replace(/\s+/g, " ").slice(0, CONTEXT_MAX_MESSAGE_CHARS));
|
|
339
|
+
const markers = [];
|
|
340
|
+
if (message.meta?.replied === false) markers.push(escapeXml(`assistant skipped: ${message.meta?.skippedReason ?? "no-reply route"}`));
|
|
341
|
+
if (message.meta?.explicitMention) markers.push("explicit mention");
|
|
342
|
+
const markerSuffix = markers.length > 0 ? ` (${markers.join("; ")})` : "";
|
|
343
|
+
const imageContext = buildImageContextSuffix(message, conversation);
|
|
344
|
+
return `[${message.role}] ${displayName}: ${text}${imageContext}${markerSuffix}`;
|
|
345
|
+
}
|
|
346
|
+
function conversationAuthorDisplayName(message) {
|
|
347
|
+
const author = message.author;
|
|
348
|
+
const fullName = authorDisplayField(author?.fullName, author?.userId);
|
|
349
|
+
const userName = authorDisplayField(author?.userName, author?.userId);
|
|
350
|
+
return fullName ?? userName ?? (message.role === "assistant" ? botConfig.userName : message.role);
|
|
351
|
+
}
|
|
352
|
+
function authorDisplayField(value, userId) {
|
|
353
|
+
if (!value || value === userId || SLACK_USER_ID_DISPLAY_PATTERN.test(value)) return;
|
|
354
|
+
return value;
|
|
355
|
+
}
|
|
356
|
+
/** Estimate the token cost of the current visible conversation context. */
|
|
357
|
+
function estimateConversationContextTokens(conversation) {
|
|
358
|
+
return estimateTextTokens(buildConversationContext(conversation) ?? "");
|
|
359
|
+
}
|
|
360
|
+
function upsertConversationMessage(conversation, message) {
|
|
361
|
+
const existingIndex = conversation.messages.findIndex((entry) => entry.id === message.id);
|
|
362
|
+
if (existingIndex >= 0) {
|
|
363
|
+
conversation.messages[existingIndex] = {
|
|
364
|
+
...conversation.messages[existingIndex],
|
|
365
|
+
...message,
|
|
366
|
+
meta: {
|
|
367
|
+
...conversation.messages[existingIndex]?.meta,
|
|
368
|
+
...message.meta
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
return message.id;
|
|
372
|
+
}
|
|
373
|
+
conversation.messages.push(message);
|
|
374
|
+
return message.id;
|
|
375
|
+
}
|
|
376
|
+
/** Record one assistant message after its destination accepts it. */
|
|
377
|
+
function recordDeliveredAssistantMessage(args) {
|
|
378
|
+
const prefix = `${args.sessionId}:assistant:`;
|
|
379
|
+
const messageId = `${prefix}${args.conversation.messages.filter((message) => message.id.startsWith(prefix)).length + 1}`;
|
|
380
|
+
markConversationMessage(args.conversation, args.userMessageId, {
|
|
381
|
+
replied: true,
|
|
382
|
+
skippedReason: void 0
|
|
383
|
+
});
|
|
384
|
+
upsertConversationMessage(args.conversation, {
|
|
385
|
+
id: messageId,
|
|
386
|
+
role: "assistant",
|
|
387
|
+
text: normalizeConversationText(args.text),
|
|
388
|
+
createdAtMs: Date.now(),
|
|
389
|
+
author: {
|
|
390
|
+
userName: botConfig.userName,
|
|
391
|
+
isBot: true
|
|
392
|
+
},
|
|
393
|
+
meta: {
|
|
394
|
+
replied: true,
|
|
395
|
+
...args.source ? { source: args.source } : void 0
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
return messageId;
|
|
399
|
+
}
|
|
400
|
+
/** Return whether a turn already has a destination-accepted assistant reply. */
|
|
401
|
+
function turnHasReply(conversation, turnId) {
|
|
402
|
+
const assistantPrefix = `${turnId}:assistant:`;
|
|
403
|
+
return conversation.messages.some((message) => message.role === "assistant" && message.id.startsWith(assistantPrefix));
|
|
404
|
+
}
|
|
405
|
+
function markConversationMessage(conversation, messageId, patch) {
|
|
406
|
+
if (!messageId) return;
|
|
407
|
+
const messageIndex = conversation.messages.findIndex((entry) => entry.id === messageId);
|
|
408
|
+
if (messageIndex < 0) return;
|
|
409
|
+
const current = conversation.messages[messageIndex];
|
|
410
|
+
conversation.messages[messageIndex] = {
|
|
411
|
+
...current,
|
|
412
|
+
meta: {
|
|
413
|
+
...current.meta ?? {},
|
|
414
|
+
...patch
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Render thread history as structured XML. Each compaction and message is
|
|
420
|
+
* wrapped with index/ts metadata so the model can reference prior items
|
|
421
|
+
* individually instead of treating the whole block as one flat narrative.
|
|
422
|
+
*/
|
|
423
|
+
function buildConversationContext(conversation, options = {}) {
|
|
424
|
+
const messages = conversation.messages.filter((entry) => entry.id !== options.excludeMessageId);
|
|
425
|
+
if (messages.length === 0 && conversation.compactions.length === 0) return;
|
|
426
|
+
const lines = [];
|
|
427
|
+
if (conversation.compactions.length > 0) {
|
|
428
|
+
lines.push("<thread-compactions>");
|
|
429
|
+
for (const [index, compaction] of conversation.compactions.entries()) lines.push(` <compaction index="${index + 1}" covered_messages="${compaction.coveredMessageCount}" created_at="${new Date(compaction.createdAtMs).toISOString()}">`, compaction.summary, " </compaction>");
|
|
430
|
+
lines.push("</thread-compactions>");
|
|
431
|
+
}
|
|
432
|
+
if (messages.length > 0) {
|
|
433
|
+
if (lines.length > 0) lines.push("");
|
|
434
|
+
lines.push("<thread-context authority=\"evidence-only\">");
|
|
435
|
+
for (const [index, message] of messages.entries()) {
|
|
436
|
+
const author = escapeXml(conversationAuthorDisplayName(message));
|
|
437
|
+
const actorIdAttr = message.author?.userId ? ` actor_id="${escapeXml(message.author.userId)}"` : "";
|
|
438
|
+
const ts = new Date(message.createdAtMs).toISOString();
|
|
439
|
+
const slackTsAttr = message.meta?.slackTs ? ` slack_ts="${escapeXml(message.meta.slackTs)}"` : "";
|
|
440
|
+
lines.push(` <message index="${index + 1}" ts="${ts}" role="${message.role}" author="${author}"${actorIdAttr}${slackTsAttr}>`, renderConversationMessageLine(message, conversation), " </message>");
|
|
441
|
+
}
|
|
442
|
+
lines.push("</thread-context>");
|
|
443
|
+
}
|
|
444
|
+
return lines.join("\n");
|
|
445
|
+
}
|
|
446
|
+
const THREAD_CONTEXT_OPEN = "<thread-context authority=\"evidence-only\">";
|
|
447
|
+
const THREAD_CONTEXT_CLOSE = "</thread-context>";
|
|
448
|
+
/**
|
|
449
|
+
* Keep ambient history as one evidence-only block.
|
|
450
|
+
*
|
|
451
|
+
* When durable context already ends a thread-context envelope, append message
|
|
452
|
+
* lines inside that envelope. Otherwise wrap the new messages in one envelope.
|
|
453
|
+
*/
|
|
454
|
+
function appendThreadContextMessages(baseContext, messageLines) {
|
|
455
|
+
const base = baseContext?.trim();
|
|
456
|
+
if (messageLines.length === 0) return base || void 0;
|
|
457
|
+
const entryBlock = messageLines.join("\n");
|
|
458
|
+
if (!base) return [
|
|
459
|
+
THREAD_CONTEXT_OPEN,
|
|
460
|
+
entryBlock,
|
|
461
|
+
THREAD_CONTEXT_CLOSE
|
|
462
|
+
].join("\n");
|
|
463
|
+
const closeIdx = base.lastIndexOf(THREAD_CONTEXT_CLOSE);
|
|
464
|
+
if (closeIdx >= 0) return `${base.slice(0, closeIdx).replace(/\s*$/, "")}\n${entryBlock}\n${base.slice(closeIdx)}`;
|
|
465
|
+
return [
|
|
466
|
+
base,
|
|
467
|
+
"",
|
|
468
|
+
THREAD_CONTEXT_OPEN,
|
|
469
|
+
entryBlock,
|
|
470
|
+
THREAD_CONTEXT_CLOSE
|
|
471
|
+
].join("\n");
|
|
472
|
+
}
|
|
473
|
+
function pruneCompactions(compactions) {
|
|
474
|
+
if (compactions.length <= CONTEXT_MAX_COMPACTIONS) return compactions;
|
|
475
|
+
const overflowCount = compactions.length - CONTEXT_MAX_COMPACTIONS + 1;
|
|
476
|
+
const merged = compactions.slice(0, overflowCount);
|
|
477
|
+
const mergedSummary = merged.map((entry) => entry.summary).join("\n").slice(0, 3500);
|
|
478
|
+
const coveredMessageCount = merged.reduce((count, entry) => count + entry.coveredMessageCount, 0);
|
|
479
|
+
return [{
|
|
480
|
+
id: generateConversationId("compaction"),
|
|
481
|
+
createdAtMs: Date.now(),
|
|
482
|
+
summary: mergedSummary,
|
|
483
|
+
coveredMessageCount
|
|
484
|
+
}, ...compactions.slice(overflowCount)];
|
|
485
|
+
}
|
|
486
|
+
async function summarizeConversationChunk(messages, conversation, context, deps) {
|
|
487
|
+
const transcript = messages.map((message) => renderConversationMessageLine(message, conversation)).join("\n");
|
|
488
|
+
try {
|
|
489
|
+
const summary = (await deps.completeText({
|
|
490
|
+
modelId: botConfig.fastModelId,
|
|
491
|
+
temperature: 0,
|
|
492
|
+
promptName: "junior.thread_compaction",
|
|
493
|
+
messages: [{
|
|
494
|
+
role: "user",
|
|
495
|
+
content: [
|
|
496
|
+
"Summarize the following older Slack thread transcript segment for future assistant turns.",
|
|
497
|
+
"Keep the summary factual and concise. Do not invent details.",
|
|
498
|
+
"",
|
|
499
|
+
"Output exactly three XML sections in this order:",
|
|
500
|
+
"<active-asks> one bullet per outstanding user ask that has not been narrowed, answered, or replaced by a later turn. Omit the section body if none. </active-asks>",
|
|
501
|
+
"<resolved-or-replaced-asks> one bullet per ask that has been replaced, narrowed, answered, or already acted on in this segment. Include the replacement/outcome inline. Omit the section body if none. </resolved-or-replaced-asks>",
|
|
502
|
+
"<facts> one bullet per durable fact useful regardless of scope: names, ids, URLs, decisions, locations, preferences, constraints that remain true. Omit the section body if none. </facts>",
|
|
503
|
+
"",
|
|
504
|
+
"Do not output any text outside the three sections.",
|
|
505
|
+
"",
|
|
506
|
+
transcript
|
|
507
|
+
].join("\n"),
|
|
508
|
+
timestamp: Date.now()
|
|
509
|
+
}],
|
|
510
|
+
metadata: {
|
|
511
|
+
modelId: botConfig.fastModelId,
|
|
512
|
+
threadId: context.threadId ?? "",
|
|
513
|
+
channelId: context.channelId ?? "",
|
|
514
|
+
actorId: context.actorId ?? "",
|
|
515
|
+
runId: context.runId ?? ""
|
|
516
|
+
}
|
|
517
|
+
})).text.trim();
|
|
518
|
+
if (summary.length > 0) return summary.slice(0, 3500);
|
|
519
|
+
} catch (error) {
|
|
520
|
+
logWarn("conversation.compaction.summary.failed", {
|
|
521
|
+
"exception.message": error instanceof Error ? error.message : String(error),
|
|
522
|
+
"app.compaction_messages_covered": messages.length
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return transcript.slice(0, 2800);
|
|
526
|
+
}
|
|
527
|
+
/** Return the earliest human-authored message known for a thread. */
|
|
528
|
+
function getThreadTitleSourceMessage(conversation) {
|
|
529
|
+
let firstMessage;
|
|
530
|
+
for (const message of conversation.messages) {
|
|
531
|
+
if (!isHumanConversationMessage(message)) continue;
|
|
532
|
+
if (!firstMessage) {
|
|
533
|
+
firstMessage = message;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (message.createdAtMs < firstMessage.createdAtMs) {
|
|
537
|
+
firstMessage = message;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (message.createdAtMs === firstMessage.createdAtMs && message.id < firstMessage.id) firstMessage = message;
|
|
541
|
+
}
|
|
542
|
+
return firstMessage;
|
|
543
|
+
}
|
|
544
|
+
async function compactConversationIfNeededWithDeps(conversation, context, deps) {
|
|
545
|
+
let estimatedTokens = estimateConversationContextTokens(conversation);
|
|
546
|
+
setSpanAttributes({ "app.context_tokens_estimated": estimatedTokens });
|
|
547
|
+
const triggerTokens = getConversationContextCompactionTriggerTokens();
|
|
548
|
+
const targetTokens = calculateContextCompactionTargetTokens(triggerTokens);
|
|
549
|
+
while (estimatedTokens > triggerTokens && conversation.messages.length > CONTEXT_MIN_LIVE_MESSAGES) {
|
|
550
|
+
const compactCount = Math.min(CONTEXT_COMPACTION_BATCH_SIZE, conversation.messages.length - CONTEXT_MIN_LIVE_MESSAGES);
|
|
551
|
+
if (compactCount <= 0) break;
|
|
552
|
+
const compactedChunk = conversation.messages.slice(0, compactCount);
|
|
553
|
+
const summary = await summarizeConversationChunk(compactedChunk, conversation, context, deps);
|
|
554
|
+
conversation.compactions.push({
|
|
555
|
+
id: generateConversationId("compaction"),
|
|
556
|
+
createdAtMs: Date.now(),
|
|
557
|
+
summary,
|
|
558
|
+
coveredMessageCount: compactedChunk.length
|
|
559
|
+
});
|
|
560
|
+
conversation.compactions = pruneCompactions(conversation.compactions);
|
|
561
|
+
conversation.messages = conversation.messages.slice(compactCount);
|
|
562
|
+
estimatedTokens = estimateConversationContextTokens(conversation);
|
|
563
|
+
setSpanAttributes({
|
|
564
|
+
"app.compaction_messages_covered": compactCount,
|
|
565
|
+
"app.compaction.trigger_tokens": triggerTokens,
|
|
566
|
+
"app.compaction.target_tokens": targetTokens,
|
|
567
|
+
"app.context_tokens_estimated": estimatedTokens
|
|
568
|
+
});
|
|
569
|
+
if (estimatedTokens <= targetTokens) break;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/** Build the service that owns durable conversation memory compaction. */
|
|
573
|
+
function createConversationMemoryService(deps) {
|
|
574
|
+
return { compactConversationIfNeeded: async (conversation, context) => await compactConversationIfNeededWithDeps(conversation, context, deps) };
|
|
575
|
+
}
|
|
576
|
+
function isHumanConversationMessage(message) {
|
|
577
|
+
return message.role === "user" && message.author?.isBot !== true;
|
|
578
|
+
}
|
|
579
|
+
/** Return the native Slack timestamp for a persisted conversation message. */
|
|
580
|
+
function getConversationMessageSlackTs(message) {
|
|
581
|
+
return parseSlackMessageTs(message.meta?.slackTs);
|
|
582
|
+
}
|
|
583
|
+
//#endregion
|
|
584
|
+
//#region src/chat/services/conversation-title.ts
|
|
585
|
+
const inFlightTitles = /* @__PURE__ */ new Map();
|
|
586
|
+
/**
|
|
587
|
+
* Set-once conversation title from the earliest human message.
|
|
588
|
+
*
|
|
589
|
+
* One automatic generation path for every surface. Starts only from durable
|
|
590
|
+
* transcript writes via `scheduleConversationTitle`. Returns a newly persisted
|
|
591
|
+
* title, or undefined when the conversation is already titled or generation
|
|
592
|
+
* does not apply.
|
|
593
|
+
*/
|
|
594
|
+
async function ensureConversationTitle(args) {
|
|
595
|
+
const pending = inFlightTitles.get(args.conversationId);
|
|
596
|
+
if (pending) return pending;
|
|
597
|
+
const work = ensureConversationTitleOnce(args).finally(() => {
|
|
598
|
+
inFlightTitles.delete(args.conversationId);
|
|
599
|
+
});
|
|
600
|
+
inFlightTitles.set(args.conversationId, work);
|
|
601
|
+
return work;
|
|
602
|
+
}
|
|
603
|
+
async function ensureConversationTitleOnce(args) {
|
|
604
|
+
if (!args.conversation.messages.some(isHumanConversationMessage)) return;
|
|
605
|
+
const store = args.conversationStore ?? getConversationStore();
|
|
606
|
+
try {
|
|
607
|
+
const stored = await store.get({ conversationId: args.conversationId });
|
|
608
|
+
if (stored?.title?.trim()) return;
|
|
609
|
+
if (stored?.parentConversationId) return;
|
|
610
|
+
const titleSourceMessage = getThreadTitleSourceMessage(args.conversation);
|
|
611
|
+
const sourceText = titleSourceMessage?.text.trim() ?? "";
|
|
612
|
+
if (!titleSourceMessage || !sourceText) return;
|
|
613
|
+
let title;
|
|
614
|
+
try {
|
|
615
|
+
title = await generateShortTitle({
|
|
616
|
+
completeText,
|
|
617
|
+
kind: "conversation",
|
|
618
|
+
sourceText
|
|
619
|
+
}) ?? fallbackShortTitle(sourceText, "Conversation");
|
|
620
|
+
} catch (error) {
|
|
621
|
+
logWarn("conversation.title.generation.failed", { "exception.message": error instanceof Error ? error.message : String(error) });
|
|
622
|
+
title = fallbackShortTitle(sourceText, "Conversation");
|
|
623
|
+
}
|
|
624
|
+
const normalized = title.trim();
|
|
625
|
+
if (!normalized) return;
|
|
626
|
+
const activityAtMs = titleSourceMessage.createdAtMs;
|
|
627
|
+
const nowMs = args.nowMs ?? Date.now();
|
|
628
|
+
try {
|
|
629
|
+
await store.recordActivity({
|
|
630
|
+
activityAtMs,
|
|
631
|
+
conversationId: args.conversationId,
|
|
632
|
+
nowMs,
|
|
633
|
+
title: normalized
|
|
634
|
+
});
|
|
635
|
+
} catch (error) {
|
|
636
|
+
logException(error, "conversation.title.persist.failed");
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
return normalized;
|
|
640
|
+
} catch (error) {
|
|
641
|
+
logException(error, "conversation.title.ensure.failed");
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Fire-and-forget title work after durable human transcript writes.
|
|
647
|
+
* This is the only entry that starts generation.
|
|
648
|
+
*/
|
|
649
|
+
function scheduleConversationTitle(args) {
|
|
650
|
+
if (!args.conversationId) return;
|
|
651
|
+
ensureConversationTitle({
|
|
652
|
+
conversation: args.conversation,
|
|
653
|
+
conversationId: args.conversationId
|
|
654
|
+
}).catch((error) => {
|
|
655
|
+
logException(error, "conversation.title.task.failed");
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Join in-flight generation when present, otherwise read the stored title.
|
|
660
|
+
* Does not start generation; providers use this only to project a title.
|
|
661
|
+
*/
|
|
662
|
+
async function resolveConversationTitle(args) {
|
|
663
|
+
const pending = inFlightTitles.get(args.conversationId);
|
|
664
|
+
if (pending) {
|
|
665
|
+
const generated = await pending;
|
|
666
|
+
if (generated?.trim()) return generated.trim();
|
|
667
|
+
}
|
|
668
|
+
return (await (args.conversationStore ?? getConversationStore()).get({ conversationId: args.conversationId }))?.title?.trim() || void 0;
|
|
669
|
+
}
|
|
670
|
+
//#endregion
|
|
671
|
+
//#region src/chat/conversations/message-summaries.ts
|
|
672
|
+
/** Project the latest source-message summary snapshot from event history. */
|
|
673
|
+
function projectConversationMessageSummaries(events) {
|
|
674
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
675
|
+
const data = events[index]?.data;
|
|
676
|
+
if (data?.type === "messages_summarized") return data.compactions;
|
|
677
|
+
}
|
|
678
|
+
return [];
|
|
679
|
+
}
|
|
680
|
+
/** Persist a changed source-message summary snapshot in event history. */
|
|
681
|
+
async function persistConversationMessageSummaries(args) {
|
|
682
|
+
const eventStore = getConversationEventStore();
|
|
683
|
+
const history = await eventStore.loadMessageHistory(args.conversationId);
|
|
684
|
+
const existing = history.compaction ? projectConversationMessageSummaries([history.compaction]) : [];
|
|
685
|
+
if (isDeepStrictEqual(existing, args.conversation.compactions)) return;
|
|
686
|
+
const liveMessageIds = new Set(args.conversation.messages.map((message) => message.id));
|
|
687
|
+
const nextHistoryFromSeq = history.events.find((event) => event.data.type === "message" && liveMessageIds.has(event.data.messageId))?.seq ?? Math.max(history.historyFromSeq, (history.events.at(-1)?.seq ?? -1) + 1);
|
|
688
|
+
await eventStore.append(args.conversationId, [{
|
|
689
|
+
data: {
|
|
690
|
+
type: "messages_summarized",
|
|
691
|
+
historyFromSeq: nextHistoryFromSeq,
|
|
692
|
+
compactions: args.conversation.compactions
|
|
693
|
+
},
|
|
694
|
+
createdAtMs: args.conversation.compactions.at(-1)?.createdAtMs ?? Date.now()
|
|
695
|
+
}]);
|
|
696
|
+
}
|
|
697
|
+
//#endregion
|
|
698
|
+
//#region src/chat/conversations/message-projection.ts
|
|
699
|
+
function isMessageEvent(event) {
|
|
700
|
+
return event.data.type === "message" || event.data.type === "message_updated" || event.data.type === "message_handled";
|
|
701
|
+
}
|
|
702
|
+
function splitMeta(meta) {
|
|
703
|
+
const rest = { ...meta ?? {} };
|
|
704
|
+
const author = rest.author;
|
|
705
|
+
delete rest.author;
|
|
706
|
+
return {
|
|
707
|
+
...author ? { author } : void 0,
|
|
708
|
+
...Object.keys(rest).length > 0 ? { meta: rest } : void 0
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
function missingBaselineError(event) {
|
|
712
|
+
return /* @__PURE__ */ new Error(`Message event ${event.data.type} at seq ${event.seq} references ${event.data.messageId} before message`);
|
|
713
|
+
}
|
|
714
|
+
/** Reduce a boundary-bearing message-history suffix into destination history. */
|
|
715
|
+
function projectConversationMessages(history) {
|
|
716
|
+
const byId = /* @__PURE__ */ new Map();
|
|
717
|
+
for (const event of history.events) {
|
|
718
|
+
if (!isMessageEvent(event)) continue;
|
|
719
|
+
const data = event.data;
|
|
720
|
+
const current = byId.get(data.messageId);
|
|
721
|
+
if (data.type === "message" || data.type === "message_updated") {
|
|
722
|
+
const message = {
|
|
723
|
+
id: data.messageId,
|
|
724
|
+
role: data.role,
|
|
725
|
+
text: data.text,
|
|
726
|
+
createdAtMs: event.createdAtMs,
|
|
727
|
+
...splitMeta(data.meta)
|
|
728
|
+
};
|
|
729
|
+
if (data.type === "message" && current) throw new Error(`Duplicate message event for ${data.messageId} at seq ${event.seq}`);
|
|
730
|
+
if (data.type === "message_updated" && !current) {
|
|
731
|
+
if (history.historyFromSeq > 0) continue;
|
|
732
|
+
throw missingBaselineError(event);
|
|
733
|
+
}
|
|
734
|
+
byId.set(data.messageId, {
|
|
735
|
+
message: {
|
|
736
|
+
...message,
|
|
737
|
+
createdAtMs: current?.message.createdAtMs ?? message.createdAtMs
|
|
738
|
+
},
|
|
739
|
+
...current?.repliedAtMs === void 0 ? void 0 : { repliedAtMs: current.repliedAtMs }
|
|
740
|
+
});
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (!current) {
|
|
744
|
+
if (history.historyFromSeq > 0) continue;
|
|
745
|
+
throw missingBaselineError(event);
|
|
746
|
+
}
|
|
747
|
+
current.repliedAtMs ??= event.createdAtMs;
|
|
748
|
+
}
|
|
749
|
+
return [...byId.values()].map(({ message, repliedAtMs }) => {
|
|
750
|
+
if (repliedAtMs === void 0) return message;
|
|
751
|
+
return {
|
|
752
|
+
...message,
|
|
753
|
+
meta: {
|
|
754
|
+
...message.meta ?? {},
|
|
755
|
+
replied: true
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
//#endregion
|
|
761
|
+
//#region src/chat/conversations/messages.ts
|
|
762
|
+
/**
|
|
763
|
+
* Conversation-message persistence and hydration.
|
|
764
|
+
*/
|
|
765
|
+
/** Serialize one in-memory message into its event-backed write shape. */
|
|
766
|
+
function toStoredConversationMessage(message) {
|
|
767
|
+
const meta = {};
|
|
768
|
+
if (message.author) meta.author = message.author;
|
|
769
|
+
const { replied, ...restMeta } = message.meta ?? {};
|
|
770
|
+
Object.assign(meta, restMeta);
|
|
771
|
+
if (replied === false) meta.replied = false;
|
|
772
|
+
return {
|
|
773
|
+
messageId: message.id,
|
|
774
|
+
role: message.role,
|
|
775
|
+
text: message.text,
|
|
776
|
+
...Object.keys(meta).length > 0 ? { meta } : void 0,
|
|
777
|
+
createdAtMs: message.createdAtMs
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
function canonicalJson(value) {
|
|
781
|
+
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
782
|
+
if (!value || typeof value !== "object") return value;
|
|
783
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalJson(item)]));
|
|
784
|
+
}
|
|
785
|
+
function messageStateKey(message) {
|
|
786
|
+
return createHash("sha256").update(JSON.stringify(canonicalJson(message))).digest("hex").slice(0, 32);
|
|
787
|
+
}
|
|
788
|
+
/** Hydrate the current message working set from canonical conversation events. */
|
|
789
|
+
async function hydrateConversationMessages(args) {
|
|
790
|
+
if (!args.conversationId) {
|
|
791
|
+
args.conversation.messages = [];
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const history = await getConversationEventStore().loadMessageHistory(args.conversationId);
|
|
795
|
+
args.conversation.compactions = history.compaction ? projectConversationMessageSummaries([history.compaction]) : [];
|
|
796
|
+
args.conversation.messages = projectConversationMessages(history);
|
|
797
|
+
}
|
|
798
|
+
/** Append new messages and handled markers idempotently. */
|
|
799
|
+
async function persistConversationMessages(args) {
|
|
800
|
+
if (!args.conversationId || args.conversation.messages.length === 0) return;
|
|
801
|
+
await appendConversationMessages(getConversationEventStore(), {
|
|
802
|
+
...args,
|
|
803
|
+
conversationId: args.conversationId
|
|
804
|
+
});
|
|
805
|
+
scheduleConversationTitle({
|
|
806
|
+
conversation: args.conversation,
|
|
807
|
+
conversationId: args.conversationId
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
/** Append visible message facts through a caller-owned event-store transaction. */
|
|
811
|
+
async function appendConversationMessages(eventStore, args) {
|
|
812
|
+
if (args.conversation.messages.length === 0) return;
|
|
813
|
+
const history = await eventStore.loadMessageHistory(args.conversationId);
|
|
814
|
+
const existingMessages = new Map(projectConversationMessages(history).map((message) => [message.id, message]));
|
|
815
|
+
const handledAtMs = args.repliedAtMs ?? Date.now();
|
|
816
|
+
const events = args.conversation.messages.flatMap((message) => {
|
|
817
|
+
const stored = toStoredConversationMessage(message);
|
|
818
|
+
const existing = existingMessages.get(stored.messageId);
|
|
819
|
+
const changed = existing !== void 0 && !isDeepStrictEqual(toStoredConversationMessage(existing), stored);
|
|
820
|
+
return [...existing ? changed ? [{
|
|
821
|
+
idempotencyKey: `message:${stored.messageId}:update:${messageStateKey(stored)}`,
|
|
822
|
+
createdAtMs: handledAtMs,
|
|
823
|
+
data: {
|
|
824
|
+
type: "message_updated",
|
|
825
|
+
messageId: stored.messageId,
|
|
826
|
+
role: stored.role,
|
|
827
|
+
text: stored.text,
|
|
828
|
+
...stored.authorIdentityId ? { authorIdentityId: stored.authorIdentityId } : void 0,
|
|
829
|
+
...stored.meta ? { meta: stored.meta } : void 0
|
|
830
|
+
}
|
|
831
|
+
}] : [] : [{
|
|
832
|
+
idempotencyKey: `message:${stored.messageId}`,
|
|
833
|
+
createdAtMs: stored.createdAtMs,
|
|
834
|
+
data: {
|
|
835
|
+
type: "message",
|
|
836
|
+
messageId: stored.messageId,
|
|
837
|
+
role: stored.role,
|
|
838
|
+
text: stored.text,
|
|
839
|
+
...stored.authorIdentityId ? { authorIdentityId: stored.authorIdentityId } : void 0,
|
|
840
|
+
...stored.meta ? { meta: stored.meta } : void 0
|
|
841
|
+
}
|
|
842
|
+
}], ...message.meta?.replied === true ? [{
|
|
843
|
+
idempotencyKey: `message:${stored.messageId}:handled`,
|
|
844
|
+
createdAtMs: handledAtMs,
|
|
845
|
+
data: {
|
|
846
|
+
type: "message_handled",
|
|
847
|
+
messageId: stored.messageId
|
|
848
|
+
}
|
|
849
|
+
}] : []];
|
|
850
|
+
});
|
|
851
|
+
await eventStore.append(args.conversationId, events);
|
|
852
|
+
}
|
|
853
|
+
//#endregion
|
|
854
|
+
//#region src/chat/conversations/projection.ts
|
|
855
|
+
/**
|
|
856
|
+
* Conversation projection orchestration.
|
|
857
|
+
*
|
|
858
|
+
* Materializes Pi agent history and derived run facts such as connected
|
|
859
|
+
* providers. Storage and commit lifecycle stay in the conversations domain;
|
|
860
|
+
* the Pi adapter owns event reduction and opaque-message validation.
|
|
861
|
+
*/
|
|
862
|
+
/** Distinct MCP providers one credential subject connected in the given events, sorted. */
|
|
863
|
+
function connectedMcpProvidersFromEvents(events, credentialSubjectId) {
|
|
864
|
+
const providers = /* @__PURE__ */ new Set();
|
|
865
|
+
for (const event of events) if (event.data.type === "mcp_provider_connected" && event.data.credentialSubjectId === credentialSubjectId) providers.add(event.data.provider);
|
|
866
|
+
return [...providers].sort((left, right) => left.localeCompare(right));
|
|
867
|
+
}
|
|
868
|
+
function isUserMessage(message) {
|
|
869
|
+
return message.role === "user";
|
|
870
|
+
}
|
|
871
|
+
/** A checkpoint tried to replace already committed agent history. */
|
|
872
|
+
var AgentHistoryBranchError = class extends Error {
|
|
873
|
+
constructor(conversationId) {
|
|
874
|
+
super(`Agent history for ${conversationId} changed before its committed boundary`);
|
|
875
|
+
this.name = "AgentHistoryBranchError";
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
/** Match the exact JSONB shape used for prefix comparison and replay. */
|
|
879
|
+
function normalizeDurableMessage(message) {
|
|
880
|
+
return piMessageSchema.parse(JSON.parse(JSON.stringify(sanitizePostgresJson(message))));
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Identity used for append-only prefix checks.
|
|
884
|
+
*
|
|
885
|
+
* Pi may mutate already-committed assistant envelopes in place (usage,
|
|
886
|
+
* stopReason, provider metadata). Append checkpoints must still accept the
|
|
887
|
+
* new suffix when role, timestamp, and durable content match.
|
|
888
|
+
*/
|
|
889
|
+
function durableMessageIdentity(message) {
|
|
890
|
+
const record = message;
|
|
891
|
+
if (record.role === "toolResult") return {
|
|
892
|
+
role: "toolResult",
|
|
893
|
+
timestamp: record.timestamp,
|
|
894
|
+
toolCallId: record.toolCallId,
|
|
895
|
+
toolName: record.toolName,
|
|
896
|
+
isError: record.isError,
|
|
897
|
+
content: record.content
|
|
898
|
+
};
|
|
899
|
+
return {
|
|
900
|
+
role: record.role,
|
|
901
|
+
timestamp: record.timestamp,
|
|
902
|
+
content: record.content
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Count how much of `current` is a durable prefix of `next`.
|
|
907
|
+
*
|
|
908
|
+
* Returns `current.length` only when every committed message still matches by
|
|
909
|
+
* durable identity. A shorter `next` or a content/role/timestamp change is a
|
|
910
|
+
* branch that only compaction/handoff may perform.
|
|
911
|
+
*/
|
|
912
|
+
function countDurablePrefix(current, next) {
|
|
913
|
+
if (next.length < current.length) return countMatchingDeepPrefix(current, next);
|
|
914
|
+
for (let index = 0; index < current.length; index += 1) if (!isDeepStrictEqual(durableMessageIdentity(current[index]), durableMessageIdentity(next[index]))) return index;
|
|
915
|
+
return current.length;
|
|
916
|
+
}
|
|
917
|
+
function countMatchingDeepPrefix(left, right) {
|
|
918
|
+
const limit = Math.min(left.length, right.length);
|
|
919
|
+
for (let index = 0; index < limit; index += 1) if (!isDeepStrictEqual(left[index], right[index])) return index;
|
|
920
|
+
return limit;
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Resolve the aligned provenance to persist for `nextMessages`.
|
|
924
|
+
*
|
|
925
|
+
* Explicit per-message provenance always wins; otherwise the unchanged prefix
|
|
926
|
+
* reuses its committed provenance, new messages default to unauthored context,
|
|
927
|
+
* and any new-user-message default (the turn author's instruction) attaches to
|
|
928
|
+
* the last new user message — the current turn's input.
|
|
929
|
+
*/
|
|
930
|
+
function resolveCommitProvenance(args) {
|
|
931
|
+
if (args.explicitProvenance) {
|
|
932
|
+
if (args.explicitProvenance.length !== args.nextMessages.length) throw new Error("commit provenance must align one-to-one with messages");
|
|
933
|
+
return args.explicitProvenance;
|
|
934
|
+
}
|
|
935
|
+
if (args.existing.provenance.length !== args.existing.messages.length) throw new Error("committed provenance must align one-to-one with messages");
|
|
936
|
+
const matchingPrefix = args.matchingPrefix;
|
|
937
|
+
const provenance = args.nextMessages.map((_, index) => index < matchingPrefix ? args.existing.provenance[index] : contextProvenance);
|
|
938
|
+
if (args.newMessageProvenance) {
|
|
939
|
+
for (let index = args.nextMessages.length - 1; index >= matchingPrefix; index -= 1) if (isUserMessage(args.nextMessages[index])) {
|
|
940
|
+
provenance[index] = args.newMessageProvenance;
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (args.trailingMessageProvenance) {
|
|
945
|
+
if (args.trailingMessageProvenance.length > provenance.length) throw new Error("trailing commit provenance cannot exceed committed messages");
|
|
946
|
+
const newMessageCount = args.nextMessages.length - matchingPrefix;
|
|
947
|
+
if (args.trailingMessageProvenance.length > newMessageCount) throw new Error("trailing commit provenance must align to newly committed messages");
|
|
948
|
+
const start = provenance.length - args.trailingMessageProvenance.length;
|
|
949
|
+
args.trailingMessageProvenance.forEach((entry, offset) => {
|
|
950
|
+
provenance[start + offset] = entry;
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
return provenance;
|
|
954
|
+
}
|
|
955
|
+
/** Load the current Pi agent history for a conversation. */
|
|
956
|
+
async function loadProjection(args) {
|
|
957
|
+
return projectConversationEvents(await getConversationEventStore().loadCurrentHistory(args.conversationId), { defaultProfile: botConfig.defaultProfile }).messages;
|
|
958
|
+
}
|
|
959
|
+
/** Load current Pi context with aligned provenance and source event sequences. */
|
|
960
|
+
async function loadConversationProjection(args) {
|
|
961
|
+
return projectConversationEvents(await getConversationEventStore().loadCurrentHistory(args.conversationId), { defaultProfile: botConfig.defaultProfile });
|
|
962
|
+
}
|
|
963
|
+
/** Load the active Pi projection before a conversation's next request. */
|
|
964
|
+
async function openConversationProjection(args) {
|
|
965
|
+
return projectConversationEvents(await getConversationEventStore().loadCurrentHistory(args.conversationId), { defaultProfile: botConfig.defaultProfile });
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Load a turn's committed Pi projection from the durable event store.
|
|
969
|
+
*
|
|
970
|
+
* The record stays pinned to the history version containing its committed
|
|
971
|
+
* boundary, so later compaction cannot rewrite what a stale record resumes
|
|
972
|
+
* from. Unfinished records (`includeTail`) also see that version's tail so
|
|
973
|
+
* parked input appended after the last safe boundary is model-visible. Terminal records
|
|
974
|
+
* reproduce exactly the boundary they committed by cutting at `committedSeq`.
|
|
975
|
+
* Returns undefined when the committed boundary no longer exists (purged
|
|
976
|
+
* history) so callers fail closed.
|
|
977
|
+
*/
|
|
978
|
+
async function loadTurnProjection(args) {
|
|
979
|
+
const eventStore = getConversationEventStore();
|
|
980
|
+
if (args.committedSeq < 0) return projectConversationEvents(await eventStore.loadCurrentHistory(args.conversationId), { defaultProfile: botConfig.defaultProfile });
|
|
981
|
+
const historyEvents = await eventStore.loadHistoryContaining(args.conversationId, args.committedSeq, args.includeTail ? void 0 : args.committedSeq);
|
|
982
|
+
if (!historyEvents) return;
|
|
983
|
+
return projectConversationEvents(historyEvents, { defaultProfile: botConfig.defaultProfile });
|
|
984
|
+
}
|
|
985
|
+
/** Load MCP providers the credential subject connected in this conversation. */
|
|
986
|
+
async function loadConnectedMcpProviders(args) {
|
|
987
|
+
return connectedMcpProvidersFromEvents(await getConversationEventStore().loadHistory(args.conversationId), args.credentialSubjectId);
|
|
988
|
+
}
|
|
989
|
+
function messageTimestamp(message) {
|
|
990
|
+
const timestamp = message.timestamp;
|
|
991
|
+
return typeof timestamp === "number" ? timestamp : Date.now();
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Append newly stable native history items.
|
|
995
|
+
*
|
|
996
|
+
* Checkpoints are append-only against the durable identity of the committed
|
|
997
|
+
* prefix (role, timestamp, content; tool results also match call id). A shorter
|
|
998
|
+
* history or a true content branch is rejected — only compaction and handoff may
|
|
999
|
+
* intentionally replace active model history. In-place Pi mutations of already
|
|
1000
|
+
* committed assistant envelopes (usage/stopReason) do not block the suffix.
|
|
1001
|
+
*/
|
|
1002
|
+
async function commitMessages(args) {
|
|
1003
|
+
const executor = args.executor ?? getSqlExecutor();
|
|
1004
|
+
return await withConversationEventLock(executor, args.conversationId, async () => executor.transaction(async () => await commitMessagesLocked(args, executor)));
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Commit an accepted agent reply and its visible message in causal order.
|
|
1008
|
+
*
|
|
1009
|
+
* The destination has already accepted the reply. One transaction prevents
|
|
1010
|
+
* reporting from observing the visible reply before the agent message that
|
|
1011
|
+
* produced it.
|
|
1012
|
+
*/
|
|
1013
|
+
async function commitAcceptedReply(args) {
|
|
1014
|
+
const executor = getSqlExecutor();
|
|
1015
|
+
await withConversationEventLock(executor, args.conversationId, async () => executor.transaction(async () => {
|
|
1016
|
+
if (args.agentMessage) {
|
|
1017
|
+
const agentMessage = normalizeDurableMessage(args.agentMessage);
|
|
1018
|
+
await createSqlConversationEventStore(executor).append(args.conversationId, [{
|
|
1019
|
+
idempotencyKey: `message:${args.conversationMessageId}:agent`,
|
|
1020
|
+
data: historyItemFromPiMessage(agentMessage, contextProvenance),
|
|
1021
|
+
createdAtMs: messageTimestamp(agentMessage)
|
|
1022
|
+
}]);
|
|
1023
|
+
}
|
|
1024
|
+
await appendConversationMessages(createSqlConversationEventStore(executor), {
|
|
1025
|
+
conversation: args.conversation,
|
|
1026
|
+
conversationId: args.conversationId,
|
|
1027
|
+
...args.repliedAtMs === void 0 ? void 0 : { repliedAtMs: args.repliedAtMs }
|
|
1028
|
+
});
|
|
1029
|
+
for (const binding of args.providerConversationBindings ?? []) await bindProviderConversation(executor, {
|
|
1030
|
+
conversationId: args.conversationId,
|
|
1031
|
+
...binding
|
|
1032
|
+
});
|
|
1033
|
+
}));
|
|
1034
|
+
}
|
|
1035
|
+
async function commitMessagesLocked(args, executor) {
|
|
1036
|
+
const eventStore = createSqlConversationEventStore(executor);
|
|
1037
|
+
const currentEvents = await eventStore.loadCurrentHistory(args.conversationId);
|
|
1038
|
+
const current = projectConversationEvents(currentEvents, { defaultProfile: botConfig.defaultProfile });
|
|
1039
|
+
const nextLocalMessages = stripRuntimeTurnContext(args.messages).map(normalizeDurableMessage);
|
|
1040
|
+
const matchingPrefix = countDurablePrefix(current.messages, nextLocalMessages);
|
|
1041
|
+
const nextLocalProvenance = resolveCommitProvenance({
|
|
1042
|
+
existing: current,
|
|
1043
|
+
nextMessages: nextLocalMessages,
|
|
1044
|
+
matchingPrefix,
|
|
1045
|
+
...args.provenance ? { explicitProvenance: args.provenance } : void 0,
|
|
1046
|
+
...args.trailingMessageProvenance ? { trailingMessageProvenance: args.trailingMessageProvenance } : void 0,
|
|
1047
|
+
...args.newMessageProvenance ? { newMessageProvenance: args.newMessageProvenance } : void 0
|
|
1048
|
+
});
|
|
1049
|
+
if (matchingPrefix === current.messages.length) {
|
|
1050
|
+
const newMessages = nextLocalMessages.slice(matchingPrefix);
|
|
1051
|
+
const turnContext = args.turnContext;
|
|
1052
|
+
const turnContextEvents = turnContext?.contexts.map((context, index) => ({
|
|
1053
|
+
idempotencyKey: `turn:${turnContext.turnId}:context:${context.pluginName}:${index}`,
|
|
1054
|
+
createdAtMs: context.loadedAtMs,
|
|
1055
|
+
data: {
|
|
1056
|
+
type: "turn_context",
|
|
1057
|
+
turnId: turnContext.turnId,
|
|
1058
|
+
pluginName: context.pluginName,
|
|
1059
|
+
kind: context.kind,
|
|
1060
|
+
version: context.version,
|
|
1061
|
+
content: context.content
|
|
1062
|
+
}
|
|
1063
|
+
})) ?? [];
|
|
1064
|
+
const appendedEvents = await eventStore.append(args.conversationId, [...newMessages.map((message, index) => ({
|
|
1065
|
+
data: historyItemFromPiMessage(message, nextLocalProvenance[matchingPrefix + index]),
|
|
1066
|
+
createdAtMs: messageTimestamp(message)
|
|
1067
|
+
})), ...turnContextEvents]);
|
|
1068
|
+
const cursor = appendedEvents.at(-1) ?? currentEvents.at(-1);
|
|
1069
|
+
return {
|
|
1070
|
+
committedSeq: cursor?.seq ?? -1,
|
|
1071
|
+
historyVersion: cursor?.historyVersion ?? 0,
|
|
1072
|
+
messageSeqs: [...current.seqs, ...appendedEvents.slice(0, newMessages.length).map((event) => event.seq)],
|
|
1073
|
+
messages: nextLocalMessages,
|
|
1074
|
+
provenance: nextLocalProvenance
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
throw new AgentHistoryBranchError(args.conversationId);
|
|
1078
|
+
}
|
|
1079
|
+
/** Record a successful MCP provider connection for one credential subject without duplicating it. */
|
|
1080
|
+
async function recordMcpProviderConnected(args) {
|
|
1081
|
+
const eventStore = getConversationEventStore();
|
|
1082
|
+
if (connectedMcpProvidersFromEvents(await eventStore.loadCurrentHistory(args.conversationId), args.credentialSubjectId).includes(args.provider)) return;
|
|
1083
|
+
await eventStore.append(args.conversationId, [{
|
|
1084
|
+
data: {
|
|
1085
|
+
type: "mcp_provider_connected",
|
|
1086
|
+
provider: args.provider,
|
|
1087
|
+
credentialSubjectId: args.credentialSubjectId
|
|
1088
|
+
},
|
|
1089
|
+
createdAtMs: Date.now()
|
|
1090
|
+
}]);
|
|
1091
|
+
}
|
|
1092
|
+
/** Record that an OAuth/MCP authorization link was delivered or reused. */
|
|
1093
|
+
async function recordAuthorizationRequested(args) {
|
|
1094
|
+
const eventStore = getConversationEventStore();
|
|
1095
|
+
if ((await eventStore.loadCurrentHistory(args.conversationId)).some((event) => event.data.type === "authorization_requested" && event.data.authorizationId === args.authorizationId)) return;
|
|
1096
|
+
await eventStore.append(args.conversationId, [{
|
|
1097
|
+
data: {
|
|
1098
|
+
type: "authorization_requested",
|
|
1099
|
+
kind: args.kind,
|
|
1100
|
+
provider: args.provider,
|
|
1101
|
+
actorId: args.actorId,
|
|
1102
|
+
authorizationId: args.authorizationId,
|
|
1103
|
+
delivery: args.delivery
|
|
1104
|
+
},
|
|
1105
|
+
createdAtMs: Date.now()
|
|
1106
|
+
}]);
|
|
1107
|
+
}
|
|
1108
|
+
/** Record completed authorization as a chronological host observation for Pi. */
|
|
1109
|
+
async function recordAuthorizationCompleted(args) {
|
|
1110
|
+
const eventStore = getConversationEventStore();
|
|
1111
|
+
if ((await eventStore.loadCurrentHistory(args.conversationId)).some((event) => event.data.type === "authorization_completed" && event.data.authorizationId === args.authorizationId)) return;
|
|
1112
|
+
await eventStore.append(args.conversationId, [{
|
|
1113
|
+
data: {
|
|
1114
|
+
type: "authorization_completed",
|
|
1115
|
+
kind: args.kind,
|
|
1116
|
+
provider: args.provider,
|
|
1117
|
+
actorId: args.actorId,
|
|
1118
|
+
authorizationId: args.authorizationId
|
|
1119
|
+
},
|
|
1120
|
+
createdAtMs: Date.now()
|
|
1121
|
+
}]);
|
|
1122
|
+
}
|
|
1123
|
+
function authenticationEventIdempotencyKey(args) {
|
|
1124
|
+
if (args.authorizationId) return `native:${JUNIOR_NATIVE_EVENT_NAMESPACE}:authentication_${args.action}:authorization:${args.authorizationId}`;
|
|
1125
|
+
return `native:${JUNIOR_NATIVE_EVENT_NAMESPACE}:authentication_${args.action}:${args.provider}:actor:${args.actorId}`;
|
|
1126
|
+
}
|
|
1127
|
+
async function recordAuthenticationAccountChange(action, args) {
|
|
1128
|
+
const definition = action === "linked" ? authenticationLinkedEvent : authenticationUnlinkedEvent;
|
|
1129
|
+
const content = definition.parse({
|
|
1130
|
+
actorId: args.actorId,
|
|
1131
|
+
provider: args.provider,
|
|
1132
|
+
...args.accountLabel ? { accountLabel: args.accountLabel } : void 0,
|
|
1133
|
+
...args.authorizationId ? { authorizationId: args.authorizationId } : void 0,
|
|
1134
|
+
...args.providerLabel ? { providerLabel: args.providerLabel } : void 0
|
|
1135
|
+
});
|
|
1136
|
+
await getConversationEventStore().append(args.conversationId, [{
|
|
1137
|
+
createdAtMs: Date.now(),
|
|
1138
|
+
idempotencyKey: authenticationEventIdempotencyKey({
|
|
1139
|
+
action,
|
|
1140
|
+
actorId: args.actorId,
|
|
1141
|
+
authorizationId: args.authorizationId,
|
|
1142
|
+
provider: args.provider
|
|
1143
|
+
}),
|
|
1144
|
+
data: {
|
|
1145
|
+
type: "structured_event",
|
|
1146
|
+
namespace: JUNIOR_NATIVE_EVENT_NAMESPACE,
|
|
1147
|
+
name: definition.eventName,
|
|
1148
|
+
version: definition.version,
|
|
1149
|
+
...args.turnId ? { turnId: args.turnId } : void 0,
|
|
1150
|
+
content
|
|
1151
|
+
}
|
|
1152
|
+
}]);
|
|
1153
|
+
}
|
|
1154
|
+
/** Record a successful conversation-bound account link as host transcript metadata. */
|
|
1155
|
+
async function recordAuthenticationLinked(args) {
|
|
1156
|
+
await recordAuthenticationAccountChange("linked", args);
|
|
1157
|
+
}
|
|
1158
|
+
async function loadAgentsEvent(conversationId) {
|
|
1159
|
+
const event = await getConversationEventStore().loadLatestStructuredEvent(conversationId, JUNIOR_NATIVE_EVENT_NAMESPACE, agentsInstructionsUpdatedEvent.eventName);
|
|
1160
|
+
if (!event || event.data.type !== "structured_event") return void 0;
|
|
1161
|
+
return {
|
|
1162
|
+
content: agentsInstructionsUpdatedEvent.parse(event.data.content),
|
|
1163
|
+
seq: event.seq
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
function agentsInstructionsIdempotencyKey(args) {
|
|
1167
|
+
const previous = args.previousSeq ?? "none";
|
|
1168
|
+
return `native:${JUNIOR_NATIVE_EVENT_NAMESPACE}:agents_instructions_updated:${args.turnId}:${args.action}:${previous}->${args.fingerprint}`;
|
|
1169
|
+
}
|
|
1170
|
+
/** Record one AGENTS.md bootstrap transition as host transcript metadata. */
|
|
1171
|
+
async function recordAgentsInstructionsUpdated(args) {
|
|
1172
|
+
const previous = await loadAgentsEvent(args.conversationId);
|
|
1173
|
+
const instructions = args.instructions;
|
|
1174
|
+
if (previous && (!instructions && previous.content.action === "cleared" || instructions && previous.content.action !== "cleared" && previous.content.fingerprint === instructions.fingerprint && previous.content.directory === instructions.directory)) return;
|
|
1175
|
+
const action = !instructions ? "cleared" : previous && previous.content.action !== "cleared" ? "replaced" : "loaded";
|
|
1176
|
+
const fingerprint = instructions?.fingerprint ?? "cleared";
|
|
1177
|
+
const content = agentsInstructionsUpdatedEvent.parse({
|
|
1178
|
+
action,
|
|
1179
|
+
fingerprint,
|
|
1180
|
+
sources: instructions?.sources ?? [],
|
|
1181
|
+
...instructions ? { directory: instructions.directory } : void 0,
|
|
1182
|
+
...instructions ? { textBytes: Buffer.byteLength(instructions.text, "utf8") } : void 0
|
|
1183
|
+
});
|
|
1184
|
+
await getConversationEventStore().append(args.conversationId, [{
|
|
1185
|
+
createdAtMs: Date.now(),
|
|
1186
|
+
idempotencyKey: agentsInstructionsIdempotencyKey({
|
|
1187
|
+
action,
|
|
1188
|
+
fingerprint,
|
|
1189
|
+
previousSeq: previous?.seq,
|
|
1190
|
+
turnId: args.turnId
|
|
1191
|
+
}),
|
|
1192
|
+
data: {
|
|
1193
|
+
type: "structured_event",
|
|
1194
|
+
namespace: JUNIOR_NATIVE_EVENT_NAMESPACE,
|
|
1195
|
+
name: agentsInstructionsUpdatedEvent.eventName,
|
|
1196
|
+
version: agentsInstructionsUpdatedEvent.version,
|
|
1197
|
+
turnId: args.turnId,
|
|
1198
|
+
content
|
|
1199
|
+
}
|
|
1200
|
+
}]);
|
|
1201
|
+
}
|
|
1202
|
+
/** Record a model-backed subscribed reply route, including no-reply outcomes. */
|
|
1203
|
+
async function recordSubscribedReplyRoute(args) {
|
|
1204
|
+
await getConversationEventStore().append(args.conversationId, [{
|
|
1205
|
+
idempotencyKey: `native:${JUNIOR_NATIVE_EVENT_NAMESPACE}:passive_reply_routed:${args.runId}`,
|
|
1206
|
+
createdAtMs: Date.now(),
|
|
1207
|
+
data: {
|
|
1208
|
+
type: "structured_event",
|
|
1209
|
+
namespace: JUNIOR_NATIVE_EVENT_NAMESPACE,
|
|
1210
|
+
name: "passive_reply_routed",
|
|
1211
|
+
version: 1,
|
|
1212
|
+
content: {
|
|
1213
|
+
costUsd: args.costUsd,
|
|
1214
|
+
reason: args.reason,
|
|
1215
|
+
shouldReply: args.shouldReply,
|
|
1216
|
+
...args.shouldUnsubscribe !== void 0 ? { shouldUnsubscribe: args.shouldUnsubscribe } : void 0
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}]);
|
|
1220
|
+
}
|
|
1221
|
+
/** Load a previously selected model profile for a resumed turn. */
|
|
1222
|
+
async function loadTurnRoute(args) {
|
|
1223
|
+
const event = await getConversationEventStore().loadByIdempotencyKey(args.conversationId, `turn:${args.turnId}:routed`);
|
|
1224
|
+
if (!event) return;
|
|
1225
|
+
if (event.data.type !== "turn_routed" || event.data.turnId !== args.turnId) throw new Error(`Turn route key for "${args.turnId}" has invalid data`);
|
|
1226
|
+
return {
|
|
1227
|
+
...event.data,
|
|
1228
|
+
seq: event.seq
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
/** Record the model profile selected for one turn without changing agent history. */
|
|
1232
|
+
async function recordTurnRoute(args) {
|
|
1233
|
+
await getConversationEventStore().append(args.conversationId, [{
|
|
1234
|
+
idempotencyKey: `turn:${args.turnId}:routed`,
|
|
1235
|
+
createdAtMs: Date.now(),
|
|
1236
|
+
data: {
|
|
1237
|
+
type: "turn_routed",
|
|
1238
|
+
turnId: args.turnId,
|
|
1239
|
+
modelProfile: args.modelProfile,
|
|
1240
|
+
modelId: args.modelId,
|
|
1241
|
+
...args.costUsd !== void 0 ? { costUsd: args.costUsd } : void 0,
|
|
1242
|
+
reasoningLevel: args.reasoningLevel,
|
|
1243
|
+
...args.confidence !== void 0 ? { confidence: args.confidence } : void 0,
|
|
1244
|
+
source: args.source
|
|
1245
|
+
}
|
|
1246
|
+
}]);
|
|
1247
|
+
}
|
|
1248
|
+
/** Record a host-observed parent tool start without adding it to Pi replay. */
|
|
1249
|
+
async function recordToolExecutionStarted(args) {
|
|
1250
|
+
await getConversationEventStore().append(args.conversationId, [{
|
|
1251
|
+
data: {
|
|
1252
|
+
type: "tool_execution_started",
|
|
1253
|
+
toolCallId: args.toolCallId,
|
|
1254
|
+
toolName: args.toolName
|
|
1255
|
+
},
|
|
1256
|
+
createdAtMs: args.createdAtMs ?? Date.now()
|
|
1257
|
+
}]);
|
|
1258
|
+
}
|
|
1259
|
+
/** Stable write key so retries do not mint duplicate delivery rows. */
|
|
1260
|
+
function attachmentsDeliveredIdempotencyKey(args) {
|
|
1261
|
+
if (args.toolCallId) return `attachments_delivered:tool:${args.toolCallId}`;
|
|
1262
|
+
if (args.turnId) return `attachments_delivered:turn:${args.turnId}:` + args.attachments.map((attachment) => attachment.id).sort().join(",");
|
|
1263
|
+
return `attachments_delivered:${args.conversationId}:` + args.attachments.map((attachment) => attachment.id).sort().join(",");
|
|
1264
|
+
}
|
|
1265
|
+
/** Record files delivered for humans without adding them to Pi replay. */
|
|
1266
|
+
async function recordAttachmentsDelivered(args) {
|
|
1267
|
+
if (args.attachments.length === 0) return;
|
|
1268
|
+
await getConversationEventStore().append(args.conversationId, [{
|
|
1269
|
+
idempotencyKey: attachmentsDeliveredIdempotencyKey({
|
|
1270
|
+
attachments: args.attachments,
|
|
1271
|
+
conversationId: args.conversationId,
|
|
1272
|
+
...args.toolCallId ? { toolCallId: args.toolCallId } : void 0,
|
|
1273
|
+
...args.turnId ? { turnId: args.turnId } : void 0
|
|
1274
|
+
}),
|
|
1275
|
+
data: {
|
|
1276
|
+
type: "attachments_delivered",
|
|
1277
|
+
attachments: args.attachments.map((attachment) => ({
|
|
1278
|
+
id: attachment.id,
|
|
1279
|
+
filename: attachment.filename,
|
|
1280
|
+
contentType: attachment.contentType,
|
|
1281
|
+
bytes: attachment.bytes
|
|
1282
|
+
})),
|
|
1283
|
+
...args.toolCallId ? { toolCallId: args.toolCallId } : void 0,
|
|
1284
|
+
...args.turnId ? { turnId: args.turnId } : void 0
|
|
1285
|
+
},
|
|
1286
|
+
createdAtMs: args.createdAtMs ?? Date.now()
|
|
1287
|
+
}]);
|
|
1288
|
+
}
|
|
1289
|
+
/** Record one privacy-safe Guardian decision before the reviewed action continues. */
|
|
1290
|
+
async function recordGuardianActionReviewed(args) {
|
|
1291
|
+
await getConversationEventStore().append(args.conversationId, [{
|
|
1292
|
+
data: {
|
|
1293
|
+
type: "guardian_action_reviewed",
|
|
1294
|
+
turnId: args.turnId,
|
|
1295
|
+
toolCallId: args.toolCallId,
|
|
1296
|
+
toolName: args.toolName,
|
|
1297
|
+
...args.costUsd !== void 0 ? { costUsd: args.costUsd } : void 0,
|
|
1298
|
+
decision: args.decision,
|
|
1299
|
+
riskLevel: args.riskLevel,
|
|
1300
|
+
userAuthorization: args.userAuthorization
|
|
1301
|
+
},
|
|
1302
|
+
createdAtMs: Date.now()
|
|
1303
|
+
}]);
|
|
1304
|
+
}
|
|
1305
|
+
//#endregion
|
|
1306
|
+
//#region src/chat/tools/sandbox/file-utils.ts
|
|
1307
|
+
const MAX_TEXT_CHARS = 6e4;
|
|
1308
|
+
const RIPGREP_EXCLUDED_GLOBS = ["!**/.git/**", "!**/node_modules/**"];
|
|
1309
|
+
const DEFAULT_SEARCH_COMMAND_TIMEOUT_MS = 3e4;
|
|
1310
|
+
const SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([".git", "node_modules"]);
|
|
1311
|
+
/** Normalize model-supplied numeric knobs before they reach filesystem tools. */
|
|
1312
|
+
function positiveInteger(value) {
|
|
1313
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return;
|
|
1314
|
+
const integer = Math.floor(value);
|
|
1315
|
+
return integer > 0 ? integer : void 0;
|
|
1316
|
+
}
|
|
1317
|
+
/** Compare and slice text in one line-ending space while preserving callers' output rules. */
|
|
1318
|
+
function normalizeToLf(value) {
|
|
1319
|
+
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1320
|
+
}
|
|
1321
|
+
/** Keep tool output inside the model-facing budget with an explicit notice. */
|
|
1322
|
+
function truncateText(value, maxChars = MAX_TEXT_CHARS) {
|
|
1323
|
+
if (value.length <= maxChars) return {
|
|
1324
|
+
content: value,
|
|
1325
|
+
truncated: false
|
|
1326
|
+
};
|
|
1327
|
+
const removed = value.length - maxChars;
|
|
1328
|
+
return {
|
|
1329
|
+
content: `${value.slice(0, maxChars)}\n\n[output truncated: ${removed} characters removed]`,
|
|
1330
|
+
truncated: true
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
/** Detect model-facing missing paths without swallowing sandbox lifecycle/API failures. */
|
|
1334
|
+
function isMissingPathError(error) {
|
|
1335
|
+
if (!error || typeof error !== "object") return false;
|
|
1336
|
+
const candidate = error;
|
|
1337
|
+
if (candidate.code === "ENOENT") return true;
|
|
1338
|
+
return typeof candidate.message === "string" && candidate.message.startsWith("File not found:");
|
|
1339
|
+
}
|
|
1340
|
+
/** Reject an expected missing search/list path through the tool-error channel. */
|
|
1341
|
+
function missingPathSearchResult(params) {
|
|
1342
|
+
const textPath = params.displayPath ?? params.missingPath ?? params.path;
|
|
1343
|
+
throw new ToolInputError(`Path not found: ${textPath}`);
|
|
1344
|
+
}
|
|
1345
|
+
function escapeRegExp(value) {
|
|
1346
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1347
|
+
}
|
|
1348
|
+
function globToRegExp(pattern) {
|
|
1349
|
+
let source = "";
|
|
1350
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
1351
|
+
const char = pattern[index];
|
|
1352
|
+
const next = pattern[index + 1];
|
|
1353
|
+
if (char === "*" && next === "*") {
|
|
1354
|
+
if (pattern[index + 2] === "/") {
|
|
1355
|
+
source += "(?:.*/)?";
|
|
1356
|
+
index += 2;
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
source += ".*";
|
|
1360
|
+
index += 1;
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
if (char === "*") {
|
|
1364
|
+
source += "[^/]*";
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
if (char === "?") {
|
|
1368
|
+
source += "[^/]";
|
|
1369
|
+
continue;
|
|
1370
|
+
}
|
|
1371
|
+
source += escapeRegExp(char);
|
|
1372
|
+
}
|
|
1373
|
+
return new RegExp(`^${source}$`);
|
|
1374
|
+
}
|
|
1375
|
+
function matchesGlob(relativePath, pattern) {
|
|
1376
|
+
const matcher = globToRegExp(pattern);
|
|
1377
|
+
if (matcher.test(relativePath)) return true;
|
|
1378
|
+
if (pattern.startsWith("**/") && matchesGlob(relativePath, pattern.slice(3))) return true;
|
|
1379
|
+
return !pattern.includes("/") && matcher.test(path.posix.basename(relativePath));
|
|
1380
|
+
}
|
|
1381
|
+
/** Keep sandbox filesystem tools scoped to the mounted workspace root. */
|
|
1382
|
+
function resolveWorkspacePath(input, fallback = ".") {
|
|
1383
|
+
const requested = (input ?? "").trim() || fallback;
|
|
1384
|
+
const absolute = requested.startsWith("/") ? requested : path.posix.join(SANDBOX_WORKSPACE_ROOT, requested);
|
|
1385
|
+
const normalized = path.posix.normalize(absolute);
|
|
1386
|
+
if (normalized !== SANDBOX_WORKSPACE_ROOT && !normalized.startsWith(`${SANDBOX_WORKSPACE_ROOT}/`)) throw new ToolInputError(`Path must stay within ${SANDBOX_WORKSPACE_ROOT}: ${requested}`);
|
|
1387
|
+
return normalized;
|
|
1388
|
+
}
|
|
1389
|
+
/** Anchor ripgrep paths and globs to the requested search root. */
|
|
1390
|
+
function getRipgrepSearchLocation(root, rootIsDirectory) {
|
|
1391
|
+
if (rootIsDirectory) return {
|
|
1392
|
+
cwd: root,
|
|
1393
|
+
target: "."
|
|
1394
|
+
};
|
|
1395
|
+
return {
|
|
1396
|
+
cwd: path.posix.dirname(root),
|
|
1397
|
+
target: path.posix.basename(root)
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
/** Share bounded workspace traversal across search tools so their skip rules stay aligned. */
|
|
1401
|
+
async function collectFiles(params) {
|
|
1402
|
+
const files = [];
|
|
1403
|
+
let limitReached = false;
|
|
1404
|
+
let missingPath;
|
|
1405
|
+
const visit = async (dirPath) => {
|
|
1406
|
+
if (missingPath) return;
|
|
1407
|
+
let entries;
|
|
1408
|
+
try {
|
|
1409
|
+
entries = (await params.fs.readdir(dirPath)).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
if (isMissingPathError(error)) {
|
|
1412
|
+
missingPath = dirPath;
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
throw error;
|
|
1416
|
+
}
|
|
1417
|
+
for (const entry of entries) {
|
|
1418
|
+
const fullPath = path.posix.join(dirPath, entry);
|
|
1419
|
+
let stat;
|
|
1420
|
+
try {
|
|
1421
|
+
stat = await params.fs.stat(fullPath);
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
if (isMissingPathError(error)) {
|
|
1424
|
+
missingPath = fullPath;
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
throw error;
|
|
1428
|
+
}
|
|
1429
|
+
if (stat.isDirectory()) {
|
|
1430
|
+
if (!SKIPPED_DIRECTORIES.has(entry)) await visit(fullPath);
|
|
1431
|
+
if (limitReached) return;
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
const relativePath = path.posix.relative(params.root, fullPath);
|
|
1435
|
+
if (!params.pattern || matchesGlob(relativePath, params.pattern)) {
|
|
1436
|
+
files.push(fullPath);
|
|
1437
|
+
if (params.limit && files.length >= params.limit) {
|
|
1438
|
+
limitReached = true;
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
};
|
|
1444
|
+
let stat;
|
|
1445
|
+
try {
|
|
1446
|
+
stat = await params.fs.stat(params.root);
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
if (isMissingPathError(error)) return {
|
|
1449
|
+
files,
|
|
1450
|
+
limitReached: false,
|
|
1451
|
+
missingPath: params.root,
|
|
1452
|
+
missingRoot: true
|
|
1453
|
+
};
|
|
1454
|
+
throw error;
|
|
1455
|
+
}
|
|
1456
|
+
if (!stat.isDirectory()) {
|
|
1457
|
+
const relativePath = path.posix.basename(params.root);
|
|
1458
|
+
return {
|
|
1459
|
+
files: !params.pattern || matchesGlob(relativePath, params.pattern) ? [params.root] : [],
|
|
1460
|
+
limitReached: false,
|
|
1461
|
+
missingRoot: false
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
await visit(params.root);
|
|
1465
|
+
return {
|
|
1466
|
+
files,
|
|
1467
|
+
limitReached,
|
|
1468
|
+
missingPath,
|
|
1469
|
+
missingRoot: missingPath === params.root
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
//#endregion
|
|
1473
|
+
//#region src/chat/repository-instructions.ts
|
|
1474
|
+
const AGENTS_FILENAME = "AGENTS.md";
|
|
1475
|
+
const MAX_REPOSITORY_INSTRUCTIONS_BYTES = 32768;
|
|
1476
|
+
const AGENTS_REPLACEMENT_NOTICE = "These AGENTS.md instructions replace all previously provided AGENTS.md instructions.";
|
|
1477
|
+
const AGENTS_REMOVAL_NOTICE = "The previously provided AGENTS.md instructions no longer apply.";
|
|
1478
|
+
async function pathExists(fs, target) {
|
|
1479
|
+
try {
|
|
1480
|
+
await fs.stat(target);
|
|
1481
|
+
return true;
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
if (isMissingPathError(error)) return false;
|
|
1484
|
+
throw error;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
async function directoryExists(fs, target) {
|
|
1488
|
+
try {
|
|
1489
|
+
return (await fs.stat(target)).isDirectory();
|
|
1490
|
+
} catch (error) {
|
|
1491
|
+
if (isMissingPathError(error)) return false;
|
|
1492
|
+
throw error;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
async function findGitRoot(fs, cwd) {
|
|
1496
|
+
let current = cwd;
|
|
1497
|
+
for (;;) {
|
|
1498
|
+
if (await pathExists(fs, path.posix.join(current, ".git"))) return current;
|
|
1499
|
+
if (current === SANDBOX_WORKSPACE_ROOT) return;
|
|
1500
|
+
const parent = path.posix.dirname(current);
|
|
1501
|
+
if (parent === current || parent !== SANDBOX_WORKSPACE_ROOT && !parent.startsWith(`${SANDBOX_WORKSPACE_ROOT}/`)) return;
|
|
1502
|
+
current = parent;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
/** Return Git worktrees under repos/, sorted for stable instruction capture. */
|
|
1506
|
+
async function listRepositoryDirectories(fs) {
|
|
1507
|
+
if (!await directoryExists(fs, SANDBOX_REPOS_ROOT)) return [];
|
|
1508
|
+
const entries = await fs.readdir(SANDBOX_REPOS_ROOT);
|
|
1509
|
+
const repositories = [];
|
|
1510
|
+
for (const entry of entries) {
|
|
1511
|
+
const candidate = path.posix.join(SANDBOX_REPOS_ROOT, entry);
|
|
1512
|
+
if (await directoryExists(fs, candidate) && await pathExists(fs, path.posix.join(candidate, ".git"))) repositories.push(candidate);
|
|
1513
|
+
}
|
|
1514
|
+
return repositories.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
1515
|
+
}
|
|
1516
|
+
async function readInstructionFile(fs, filePath) {
|
|
1517
|
+
try {
|
|
1518
|
+
const content = await fs.readFile(filePath, { encoding: "utf8" });
|
|
1519
|
+
return content.trim() ? content : void 0;
|
|
1520
|
+
} catch (error) {
|
|
1521
|
+
if (isMissingPathError(error)) return;
|
|
1522
|
+
throw error;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
/** Resolve the effective root-to-cwd AGENTS.md bundle for one selected directory. */
|
|
1526
|
+
async function resolveRepositoryInstructions(args) {
|
|
1527
|
+
const gitRoot = await findGitRoot(args.fs, args.cwd);
|
|
1528
|
+
if (!gitRoot) return;
|
|
1529
|
+
const directories = [];
|
|
1530
|
+
let current = args.cwd;
|
|
1531
|
+
for (;;) {
|
|
1532
|
+
directories.push(current);
|
|
1533
|
+
if (current === gitRoot) break;
|
|
1534
|
+
current = path.posix.dirname(current);
|
|
1535
|
+
}
|
|
1536
|
+
directories.reverse();
|
|
1537
|
+
const sources = [];
|
|
1538
|
+
let remaining = Math.max(0, args.maxBytes ?? MAX_REPOSITORY_INSTRUCTIONS_BYTES);
|
|
1539
|
+
for (const directory of directories) {
|
|
1540
|
+
if (remaining <= 0) break;
|
|
1541
|
+
const filePath = path.posix.join(directory, AGENTS_FILENAME);
|
|
1542
|
+
const content = await readInstructionFile(args.fs, filePath);
|
|
1543
|
+
if (!content) continue;
|
|
1544
|
+
const bytes = Buffer.from(content, "utf8");
|
|
1545
|
+
const bounded = bytes.length <= remaining ? content : bytes.subarray(0, remaining).toString("utf8");
|
|
1546
|
+
sources.push({
|
|
1547
|
+
content: bounded,
|
|
1548
|
+
path: filePath
|
|
1549
|
+
});
|
|
1550
|
+
remaining -= Buffer.byteLength(bounded, "utf8");
|
|
1551
|
+
}
|
|
1552
|
+
if (sources.length === 0) return;
|
|
1553
|
+
const text = formatInstructionSources(sources);
|
|
1554
|
+
const fingerprint = createHash("sha256").update(JSON.stringify({
|
|
1555
|
+
directory: args.cwd,
|
|
1556
|
+
sources
|
|
1557
|
+
})).digest("hex");
|
|
1558
|
+
return {
|
|
1559
|
+
directory: args.cwd,
|
|
1560
|
+
fingerprint,
|
|
1561
|
+
sources,
|
|
1562
|
+
text
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
function formatInstructionSources(sources) {
|
|
1566
|
+
if (sources.length === 1) return sources[0].content;
|
|
1567
|
+
return sources.map((source) => `## ${path.posix.dirname(source.path)}\n\n${source.content}`).join("\n\n");
|
|
1568
|
+
}
|
|
1569
|
+
/** Combine AGENTS.md bundles from one or more repository directories. */
|
|
1570
|
+
function mergeRepositoryInstructions(bundles) {
|
|
1571
|
+
if (bundles.length === 0) return;
|
|
1572
|
+
if (bundles.length === 1) return bundles[0];
|
|
1573
|
+
const sources = bundles.flatMap((bundle) => bundle.sources);
|
|
1574
|
+
const text = formatInstructionSources(sources);
|
|
1575
|
+
return {
|
|
1576
|
+
fingerprint: createHash("sha256").update(JSON.stringify({
|
|
1577
|
+
directories: bundles.map((bundle) => bundle.directory ?? null),
|
|
1578
|
+
sources
|
|
1579
|
+
})).digest("hex"),
|
|
1580
|
+
sources,
|
|
1581
|
+
text
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
/** Resolve AGENTS.md instructions for each selected repository directory. */
|
|
1585
|
+
async function resolveRepositoryInstructionsForDirectories(args) {
|
|
1586
|
+
const uniqueDirectories = [...new Set(args.directories.filter((directory) => directory === SANDBOX_WORKSPACE_ROOT || directory.startsWith(`${SANDBOX_WORKSPACE_ROOT}/`)))].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
1587
|
+
let remaining = MAX_REPOSITORY_INSTRUCTIONS_BYTES;
|
|
1588
|
+
const bundles = [];
|
|
1589
|
+
for (const directory of uniqueDirectories) {
|
|
1590
|
+
if (remaining <= 0) break;
|
|
1591
|
+
const instructions = await resolveRepositoryInstructions({
|
|
1592
|
+
cwd: directory,
|
|
1593
|
+
fs: args.fs,
|
|
1594
|
+
maxBytes: remaining
|
|
1595
|
+
});
|
|
1596
|
+
if (!instructions) continue;
|
|
1597
|
+
bundles.push(instructions);
|
|
1598
|
+
remaining -= instructions.sources.reduce((total, source) => total + Buffer.byteLength(source.content, "utf8"), 0);
|
|
1599
|
+
}
|
|
1600
|
+
return mergeRepositoryInstructions(bundles);
|
|
1601
|
+
}
|
|
1602
|
+
/** Render repository instructions with Codex's exact model-visible wrapper. */
|
|
1603
|
+
function renderAgentsInstructions(args) {
|
|
1604
|
+
return `# AGENTS.md instructions${args.directory ? ` for ${args.directory}` : ""}\n\n<INSTRUCTIONS>\n${args.text}\n</INSTRUCTIONS>`;
|
|
1605
|
+
}
|
|
1606
|
+
/** Build one user-role repository-instructions context message. */
|
|
1607
|
+
function buildAgentsInstructionsMessage(args) {
|
|
1608
|
+
return {
|
|
1609
|
+
role: "user",
|
|
1610
|
+
content: [{
|
|
1611
|
+
type: "text",
|
|
1612
|
+
text: renderAgentsInstructions(args)
|
|
1613
|
+
}],
|
|
1614
|
+
timestamp: args.timestamp ?? Date.now()
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
/** Read the latest effective AGENTS.md state from model-visible history. */
|
|
1618
|
+
function findVisibleAgentsInstructions(messages) {
|
|
1619
|
+
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
|
1620
|
+
const message = messages[messageIndex];
|
|
1621
|
+
if (message.role !== "user" || !Array.isArray(message.content)) continue;
|
|
1622
|
+
for (let partIndex = message.content.length - 1; partIndex >= 0; partIndex -= 1) {
|
|
1623
|
+
const part = message.content[partIndex];
|
|
1624
|
+
if (part.type !== "text" || typeof part.text !== "string" || !part.text.startsWith("# AGENTS.md instructions")) continue;
|
|
1625
|
+
const match = part.text.match(/^# AGENTS\.md instructions(?: for ([^\n]+))?\n\n<INSTRUCTIONS>\n([\s\S]*)\n<\/INSTRUCTIONS>$/);
|
|
1626
|
+
if (!match) continue;
|
|
1627
|
+
let text = match[2];
|
|
1628
|
+
if (text === "The previously provided AGENTS.md instructions no longer apply.") return { active: false };
|
|
1629
|
+
if (text.startsWith(`These AGENTS.md instructions replace all previously provided AGENTS.md instructions.\n\n`)) text = text.slice(86);
|
|
1630
|
+
return {
|
|
1631
|
+
active: true,
|
|
1632
|
+
...match[1] ? { directory: match[1] } : void 0,
|
|
1633
|
+
text
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
/** Whether one message is a generated AGENTS.md context update. */
|
|
1639
|
+
function isAgentsInstructionsMessage(message) {
|
|
1640
|
+
return Boolean(findVisibleAgentsInstructions([message]));
|
|
1641
|
+
}
|
|
1642
|
+
//#endregion
|
|
1643
|
+
//#region src/chat/services/turn-limit.ts
|
|
1644
|
+
/** Terminal failure when one turn uses too many execution slices. */
|
|
1645
|
+
var TurnSliceLimitExceededError = class extends Error {
|
|
1646
|
+
constructor(maxSlices) {
|
|
1647
|
+
super(`Agent turn exceeded execution limit (${maxSlices} slices)`);
|
|
1648
|
+
this.name = "TurnSliceLimitExceededError";
|
|
1649
|
+
}
|
|
1650
|
+
};
|
|
1651
|
+
/** Terminal failure when one turn uses too many tool calls. */
|
|
1652
|
+
var TurnToolCallLimitExceededError = class extends Error {
|
|
1653
|
+
constructor(maxToolCalls) {
|
|
1654
|
+
super(`Agent turn exceeded execution limit (${maxToolCalls} tool calls)`);
|
|
1655
|
+
this.name = "TurnToolCallLimitExceededError";
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
function isDirectTurnExecutionLimitError(error) {
|
|
1659
|
+
return error instanceof TurnSliceLimitExceededError || error instanceof TurnToolCallLimitExceededError;
|
|
1660
|
+
}
|
|
1661
|
+
/** True when a turn hit a hard execution limit, including one Error.cause wrap. */
|
|
1662
|
+
function isTurnExecutionLimitExceededError(error) {
|
|
1663
|
+
if (isDirectTurnExecutionLimitError(error)) return true;
|
|
1664
|
+
return error instanceof Error && isDirectTurnExecutionLimitError(error.cause);
|
|
1665
|
+
}
|
|
1666
|
+
/** Stop the turn when its tool-call count is past the limit. */
|
|
1667
|
+
function assertTurnToolCallLimit(toolCallCount, maxToolCalls) {
|
|
1668
|
+
if (toolCallCount <= maxToolCalls) return;
|
|
1669
|
+
throw new TurnToolCallLimitExceededError(maxToolCalls);
|
|
1670
|
+
}
|
|
1671
|
+
/** Explain a terminal turn execution limit with actionable recovery guidance. */
|
|
1672
|
+
function buildTurnLimitResponse(eventId) {
|
|
1673
|
+
return `I couldn't finish this request because this turn reached its execution limit. Please try again with a smaller or more specific request. Reference: \`event_id=${eventId}\`.`;
|
|
1674
|
+
}
|
|
1675
|
+
/** User-facing reply for a thrown turn error. */
|
|
1676
|
+
function buildTurnErrorResponse(error, eventId) {
|
|
1677
|
+
return isTurnExecutionLimitExceededError(error) ? buildTurnLimitResponse(eventId) : buildTurnFailureResponse(eventId);
|
|
1678
|
+
}
|
|
1679
|
+
//#endregion
|
|
1680
|
+
//#region src/chat/services/persist-retry.ts
|
|
1681
|
+
/**
|
|
1682
|
+
* Shared post-delivery persist retry.
|
|
1683
|
+
*
|
|
1684
|
+
* Delivered outcomes the user already saw must not be lost to a transient
|
|
1685
|
+
* state-write failure, so both Slack turns and dispatch turns retry these
|
|
1686
|
+
* persists with the same short linear backoff.
|
|
1687
|
+
*/
|
|
1688
|
+
const PERSIST_ATTEMPTS = 3;
|
|
1689
|
+
/** Retry a delivered-state persist briefly before surfacing the failure. */
|
|
1690
|
+
async function persistWithRetry(persist) {
|
|
1691
|
+
let lastError;
|
|
1692
|
+
for (let attempt = 1; attempt <= PERSIST_ATTEMPTS; attempt += 1) try {
|
|
1693
|
+
await persist();
|
|
1694
|
+
return;
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
lastError = error;
|
|
1697
|
+
if (attempt < PERSIST_ATTEMPTS) await sleep(attempt * 100);
|
|
1698
|
+
}
|
|
1699
|
+
throw lastError;
|
|
1700
|
+
}
|
|
1701
|
+
//#endregion
|
|
1702
|
+
//#region src/chat/task-execution/turn-cursor-keys.ts
|
|
1703
|
+
/** Redis namespace for the current turn cursor format. */
|
|
1704
|
+
const TURN_CURSOR_PREFIX = "junior:turn_cursor:v2";
|
|
1705
|
+
/** Return the Redis key for one turn cursor. */
|
|
1706
|
+
function turnCursorKey(conversationId, turnId) {
|
|
1707
|
+
return `${TURN_CURSOR_PREFIX}:${conversationId}:${turnId}`;
|
|
1708
|
+
}
|
|
1709
|
+
/** Return the Redis index key for one conversation's turns. */
|
|
1710
|
+
function turnCursorIndexKey(conversationId) {
|
|
1711
|
+
return `${TURN_CURSOR_PREFIX}:conversation:${conversationId}:index`;
|
|
1712
|
+
}
|
|
1713
|
+
/** Return the lock key that serializes writes to one turn cursor. */
|
|
1714
|
+
function turnCursorMutationKey(conversationId, turnId) {
|
|
1715
|
+
return `${TURN_CURSOR_PREFIX}:mutation:${conversationId}:${turnId}`;
|
|
1716
|
+
}
|
|
1717
|
+
//#endregion
|
|
1718
|
+
//#region src/chat/task-execution/turn-cursor.ts
|
|
1719
|
+
/**
|
|
1720
|
+
* Internal turn cursor storage for `checkpoint.ts`.
|
|
1721
|
+
*
|
|
1722
|
+
* Callers outside `task-execution/` should use `checkpoint.ts` only.
|
|
1723
|
+
* These records track one turn across auth pauses, timeout slices, and
|
|
1724
|
+
* completion. Full Pi messages live in SQL conversation events; this store
|
|
1725
|
+
* keeps resume metadata and a committed `seq` cursor into
|
|
1726
|
+
* `junior_conversation_events`.
|
|
1727
|
+
*/
|
|
1728
|
+
const TURN_CURSOR_SCHEMA_VERSION = 2;
|
|
1729
|
+
const TURN_CURSOR_RESUME_TTL_MS = 864e5;
|
|
1730
|
+
const TURN_CURSOR_TERMINAL_TTL_MS = 36e5;
|
|
1731
|
+
const TURN_CURSOR_MUTATION_LOCK_WAIT_MS = 1e4;
|
|
1732
|
+
async function withTurnCursorMutation(conversationId, turnId, run) {
|
|
1733
|
+
const state = getStateAdapter();
|
|
1734
|
+
await state.connect();
|
|
1735
|
+
const result = await withLock(state, turnCursorMutationKey(conversationId, turnId), async (lock) => await run(async () => await fenceLock(state, lock)), {
|
|
1736
|
+
keepAlive: true,
|
|
1737
|
+
ttlMs: MUTATION_LOCK_TTL_MS,
|
|
1738
|
+
waitMs: TURN_CURSOR_MUTATION_LOCK_WAIT_MS
|
|
1739
|
+
});
|
|
1740
|
+
if (!result.acquired) throw new Error(`Could not acquire turn cursor lock for ${turnId}`);
|
|
1741
|
+
return result.value;
|
|
1742
|
+
}
|
|
1743
|
+
function executionMetricsForTurn(conversation, turnId) {
|
|
1744
|
+
return conversation?.executionMetrics?.runId === turnId ? conversation.executionMetrics : void 0;
|
|
1745
|
+
}
|
|
1746
|
+
/** Keep only keys whose value is defined. */
|
|
1747
|
+
function definedProps$1(values) {
|
|
1748
|
+
return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== void 0));
|
|
1749
|
+
}
|
|
1750
|
+
const turnStatusSchema = z.enum([
|
|
1751
|
+
"running",
|
|
1752
|
+
"paused",
|
|
1753
|
+
"completed",
|
|
1754
|
+
"failed",
|
|
1755
|
+
"abandoned"
|
|
1756
|
+
]);
|
|
1757
|
+
const turnSurfaceSchema = z.enum([
|
|
1758
|
+
"slack",
|
|
1759
|
+
"api",
|
|
1760
|
+
"scheduler",
|
|
1761
|
+
"internal"
|
|
1762
|
+
]);
|
|
1763
|
+
const turnPauseReasonSchema = z.enum([
|
|
1764
|
+
"timeout",
|
|
1765
|
+
"auth",
|
|
1766
|
+
"yield",
|
|
1767
|
+
"retry"
|
|
1768
|
+
]);
|
|
1769
|
+
const nonNegativeNumberSchema = z.number().finite().nonnegative();
|
|
1770
|
+
const seqCursorSchema = z.number().int().min(-1);
|
|
1771
|
+
/** Thin recovery projection stored per conversation. Reporting reads SQL. */
|
|
1772
|
+
const storedTurnSummarySchema = z.object({
|
|
1773
|
+
schemaVersion: z.literal(TURN_CURSOR_SCHEMA_VERSION),
|
|
1774
|
+
version: z.number().int().nonnegative(),
|
|
1775
|
+
conversationId: z.string().min(1),
|
|
1776
|
+
dispatchId: z.string().min(1).optional(),
|
|
1777
|
+
dispatchOutcome: z.enum([
|
|
1778
|
+
"blocked",
|
|
1779
|
+
"completed",
|
|
1780
|
+
"failed"
|
|
1781
|
+
]).optional(),
|
|
1782
|
+
resultMessageId: z.string().min(1).optional(),
|
|
1783
|
+
resumeReason: turnPauseReasonSchema.optional(),
|
|
1784
|
+
turnId: z.string().min(1),
|
|
1785
|
+
sliceId: z.number().int().nonnegative(),
|
|
1786
|
+
state: turnStatusSchema,
|
|
1787
|
+
surface: turnSurfaceSchema.optional(),
|
|
1788
|
+
updatedAtMs: nonNegativeNumberSchema
|
|
1789
|
+
}).strip();
|
|
1790
|
+
/** Full resume cursor stored for one turn. */
|
|
1791
|
+
const storedTurnRecordSchema = z.object({
|
|
1792
|
+
actor: actorSchema.optional(),
|
|
1793
|
+
schemaVersion: z.literal(TURN_CURSOR_SCHEMA_VERSION),
|
|
1794
|
+
version: z.number().int().nonnegative(),
|
|
1795
|
+
conversationId: z.string().min(1),
|
|
1796
|
+
dispatchId: z.string().min(1).optional(),
|
|
1797
|
+
dispatchOutcome: z.enum([
|
|
1798
|
+
"blocked",
|
|
1799
|
+
"completed",
|
|
1800
|
+
"failed"
|
|
1801
|
+
]).optional(),
|
|
1802
|
+
resultMessageId: z.string().min(1).optional(),
|
|
1803
|
+
lastProgressAtMs: nonNegativeNumberSchema,
|
|
1804
|
+
resumeReason: turnPauseReasonSchema.optional(),
|
|
1805
|
+
publishExternally: z.boolean().optional(),
|
|
1806
|
+
source: sourceSchema.optional(),
|
|
1807
|
+
resumedFromSliceId: z.number().int().nonnegative().optional(),
|
|
1808
|
+
turnId: z.string().min(1),
|
|
1809
|
+
sliceId: z.number().int().nonnegative(),
|
|
1810
|
+
startedAtMs: nonNegativeNumberSchema,
|
|
1811
|
+
state: turnStatusSchema,
|
|
1812
|
+
surface: turnSurfaceSchema.optional(),
|
|
1813
|
+
traceId: z.string().optional(),
|
|
1814
|
+
updatedAtMs: nonNegativeNumberSchema,
|
|
1815
|
+
committedSeq: seqCursorSchema,
|
|
1816
|
+
historyVersion: z.number().int().nonnegative().optional(),
|
|
1817
|
+
errorMessage: z.string().optional(),
|
|
1818
|
+
cumulativeToolCallCount: z.number().int().nonnegative().optional(),
|
|
1819
|
+
turnStartSeq: seqCursorSchema.optional(),
|
|
1820
|
+
runtimeContext: z.array(piMessageSchema).optional()
|
|
1821
|
+
}).strip();
|
|
1822
|
+
function conversationExecutionFromSummary(summary) {
|
|
1823
|
+
return {
|
|
1824
|
+
status: summary.state === "completed" || summary.state === "abandoned" ? "idle" : summary.state,
|
|
1825
|
+
runId: summary.turnId,
|
|
1826
|
+
updatedAtMs: summary.updatedAtMs
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
function sessionLogActor(actor) {
|
|
1830
|
+
return actor?.platform === "slack" ? toStoredSlackActor(actor) : void 0;
|
|
1831
|
+
}
|
|
1832
|
+
function parseTurnRecord(value) {
|
|
1833
|
+
return storedTurnRecordSchema.parse(value);
|
|
1834
|
+
}
|
|
1835
|
+
function parseTurnSummary(value) {
|
|
1836
|
+
return storedTurnSummarySchema.parse(value);
|
|
1837
|
+
}
|
|
1838
|
+
function storeTurnSummary(summary) {
|
|
1839
|
+
return storedTurnSummarySchema.parse(summary);
|
|
1840
|
+
}
|
|
1841
|
+
function turnCursorTtlMs(state, ttlMs) {
|
|
1842
|
+
if (ttlMs !== void 0) return Math.max(1, ttlMs);
|
|
1843
|
+
return state === "running" || state === "paused" ? TURN_CURSOR_RESUME_TTL_MS : TURN_CURSOR_TERMINAL_TTL_MS;
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* The per-conversation recovery index holds many turns. Keep it on the resume
|
|
1847
|
+
* window so a terminal write cannot expire unfinished siblings still under a
|
|
1848
|
+
* resume lease. Explicit ttl overrides (tests) still win.
|
|
1849
|
+
*/
|
|
1850
|
+
function turnCursorIndexTtlMs(ttlMs) {
|
|
1851
|
+
return Math.max(1, ttlMs ?? TURN_CURSOR_RESUME_TTL_MS);
|
|
1852
|
+
}
|
|
1853
|
+
function projectTurnSummary(record) {
|
|
1854
|
+
return parseTurnSummary(record);
|
|
1855
|
+
}
|
|
1856
|
+
async function appendTurnSummary(summary, ttlMs) {
|
|
1857
|
+
await getStateAdapter().appendToList(turnCursorIndexKey(summary.conversationId), storeTurnSummary(summary), { ttlMs });
|
|
1858
|
+
}
|
|
1859
|
+
/** Store run summary metadata in the configured conversation store. */
|
|
1860
|
+
async function recordConversationActivityMetadata(args) {
|
|
1861
|
+
const conversationStore = args.conversationStore ?? getConversationStore();
|
|
1862
|
+
const conversation = await conversationStore.get({ conversationId: args.summary.conversationId });
|
|
1863
|
+
const hasParent = Boolean(conversation?.parentConversationId);
|
|
1864
|
+
const destination = hasParent ? void 0 : args.destination;
|
|
1865
|
+
if (!hasParent && !destination && !conversation) return;
|
|
1866
|
+
const activitySource = hasParent ? "internal" : args.source?.kind === "web" ? "web" : destination?.platform === "local" ? "local" : destination ? args.summary.surface : void 0;
|
|
1867
|
+
await conversationStore.recordActivity({
|
|
1868
|
+
activityAtMs: args.summary.updatedAtMs,
|
|
1869
|
+
channelName: args.summary.channelName,
|
|
1870
|
+
conversationId: args.summary.conversationId,
|
|
1871
|
+
destination,
|
|
1872
|
+
nowMs: args.nowMs,
|
|
1873
|
+
actor: sessionLogActor(args.actor),
|
|
1874
|
+
...definedProps$1({ source: activitySource }),
|
|
1875
|
+
...args.source ? { sessionSource: args.source } : void 0,
|
|
1876
|
+
visibility: hasParent ? void 0 : args.destinationVisibility
|
|
1877
|
+
});
|
|
1878
|
+
await conversationStore.recordExecution({
|
|
1879
|
+
channelName: args.summary.channelName,
|
|
1880
|
+
conversationId: args.summary.conversationId,
|
|
1881
|
+
createdAtMs: args.summary.startedAtMs,
|
|
1882
|
+
destination,
|
|
1883
|
+
execution: conversationExecutionFromSummary(args.summary),
|
|
1884
|
+
lastActivityAtMs: args.summary.updatedAtMs,
|
|
1885
|
+
metrics: {
|
|
1886
|
+
durationMs: args.summary.cumulativeDurationMs,
|
|
1887
|
+
...args.summary.cumulativeUsage ? { usage: args.summary.cumulativeUsage } : void 0
|
|
1888
|
+
},
|
|
1889
|
+
actor: sessionLogActor(args.actor),
|
|
1890
|
+
...definedProps$1({ source: activitySource }),
|
|
1891
|
+
updatedAtMs: args.nowMs,
|
|
1892
|
+
visibility: hasParent ? void 0 : args.destinationVisibility
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
function materializeTurnRecord(stored, piProjection, turnStartMessageIndex, restoreVolatileContext = true, runtimeMetrics) {
|
|
1896
|
+
const restoredProjection = restoreVolatileContext && (stored.state === "running" || stored.state === "paused") ? restoreRuntimeContext(piProjection, stored.runtimeContext) : piProjection;
|
|
1897
|
+
return {
|
|
1898
|
+
...definedProps$1({ actor: stored.actor }),
|
|
1899
|
+
schemaVersion: stored.schemaVersion,
|
|
1900
|
+
version: stored.version,
|
|
1901
|
+
conversationId: stored.conversationId,
|
|
1902
|
+
turnId: stored.turnId,
|
|
1903
|
+
sliceId: stored.sliceId,
|
|
1904
|
+
state: stored.state,
|
|
1905
|
+
startedAtMs: stored.startedAtMs,
|
|
1906
|
+
lastProgressAtMs: stored.lastProgressAtMs,
|
|
1907
|
+
updatedAtMs: stored.updatedAtMs,
|
|
1908
|
+
piMessages: restoredProjection.messages,
|
|
1909
|
+
piMessageProvenance: restoredProjection.provenance,
|
|
1910
|
+
actors: instructionActors(piProjection.provenance),
|
|
1911
|
+
cumulativeDurationMs: runtimeMetrics?.cumulativeDurationMs ?? 0,
|
|
1912
|
+
...definedProps$1({
|
|
1913
|
+
channelName: runtimeMetrics?.channelName,
|
|
1914
|
+
cumulativeToolCallCount: stored.cumulativeToolCallCount,
|
|
1915
|
+
cumulativeUsage: runtimeMetrics?.cumulativeUsage,
|
|
1916
|
+
dispatchId: stored.dispatchId,
|
|
1917
|
+
dispatchOutcome: stored.dispatchOutcome,
|
|
1918
|
+
errorMessage: stored.errorMessage,
|
|
1919
|
+
resumeReason: stored.resumeReason,
|
|
1920
|
+
source: stored.source,
|
|
1921
|
+
resultMessageId: stored.resultMessageId,
|
|
1922
|
+
resumedFromSliceId: stored.resumedFromSliceId,
|
|
1923
|
+
surface: stored.surface,
|
|
1924
|
+
traceId: stored.traceId,
|
|
1925
|
+
turnStartMessageIndex
|
|
1926
|
+
})
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
1929
|
+
function restoreRuntimeContext(projection, runtimeContext) {
|
|
1930
|
+
if (!runtimeContext || runtimeContext.length === 0) return projection;
|
|
1931
|
+
const restoredMessages = [...projection.messages];
|
|
1932
|
+
const restoredProvenance = [...projection.provenance];
|
|
1933
|
+
const unmatchedRuntimeContext = [];
|
|
1934
|
+
for (const runtimeMessage of runtimeContext) {
|
|
1935
|
+
const runtime = runtimeMessage;
|
|
1936
|
+
const targetIndex = restoredMessages.findIndex((message) => {
|
|
1937
|
+
const candidate = message;
|
|
1938
|
+
return candidate.role === "user" && candidate.timestamp === runtime.timestamp;
|
|
1939
|
+
});
|
|
1940
|
+
if (targetIndex < 0) {
|
|
1941
|
+
if (isAgentsInstructionsMessage(runtimeMessage)) {
|
|
1942
|
+
const followingIndex = restoredMessages.findIndex((message) => {
|
|
1943
|
+
const timestamp = message.timestamp;
|
|
1944
|
+
return typeof runtime.timestamp === "number" && typeof timestamp === "number" && timestamp > runtime.timestamp;
|
|
1945
|
+
});
|
|
1946
|
+
const insertionIndex = followingIndex < 0 ? restoredMessages.length : followingIndex;
|
|
1947
|
+
restoredMessages.splice(insertionIndex, 0, runtimeMessage);
|
|
1948
|
+
restoredProvenance.splice(insertionIndex, 0, contextProvenance);
|
|
1949
|
+
} else unmatchedRuntimeContext.push(runtimeMessage);
|
|
1950
|
+
continue;
|
|
1951
|
+
}
|
|
1952
|
+
restoredMessages.splice(targetIndex, 0, runtimeMessage);
|
|
1953
|
+
restoredProvenance.splice(targetIndex, 0, contextProvenance);
|
|
1954
|
+
}
|
|
1955
|
+
return {
|
|
1956
|
+
messages: [...unmatchedRuntimeContext, ...restoredMessages],
|
|
1957
|
+
provenance: [...unmatchedRuntimeContext.map(() => contextProvenance), ...restoredProvenance]
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
/** Read only the stored metadata record without materializing transcript logs. */
|
|
1961
|
+
async function getStoredTurnRecord(conversationId, turnId) {
|
|
1962
|
+
const stateAdapter = getStateAdapter();
|
|
1963
|
+
await stateAdapter.connect();
|
|
1964
|
+
const value = await stateAdapter.get(turnCursorKey(conversationId, turnId));
|
|
1965
|
+
return value == null ? void 0 : parseTurnRecord(value);
|
|
1966
|
+
}
|
|
1967
|
+
/** Read a materialized turn cursor for resume and history loading. */
|
|
1968
|
+
async function materializeStoredTurnRecord(conversationId, turnId, followCurrentReplacement) {
|
|
1969
|
+
const parsed = await getStoredTurnRecord(conversationId, turnId);
|
|
1970
|
+
if (!parsed) return;
|
|
1971
|
+
const pinnedProjection = await loadTurnProjection({
|
|
1972
|
+
conversationId,
|
|
1973
|
+
committedSeq: parsed.committedSeq,
|
|
1974
|
+
includeTail: parsed.state === "running" || parsed.state === "paused"
|
|
1975
|
+
});
|
|
1976
|
+
if (!pinnedProjection) return;
|
|
1977
|
+
const eventStore = getConversationEventStore();
|
|
1978
|
+
const currentHistoryVersion = await eventStore.loadCurrentHistoryVersion(conversationId);
|
|
1979
|
+
const followsReplacement = followCurrentReplacement && (parsed.state === "running" || parsed.state === "paused") && parsed.historyVersion !== void 0 && parsed.historyVersion !== currentHistoryVersion;
|
|
1980
|
+
const piProjection = followsReplacement ? projectConversationEvents(await eventStore.loadCurrentHistory(conversationId), { defaultProfile: botConfig.defaultProfile }) : pinnedProjection;
|
|
1981
|
+
const turnStartMessageIndex = followsReplacement ? 0 : parsed.turnStartSeq === void 0 ? void 0 : piProjection.seqs.filter((seq) => seq <= parsed.turnStartSeq).length;
|
|
1982
|
+
const conversation = await getConversationStore().get({ conversationId });
|
|
1983
|
+
const executionMetrics = executionMetricsForTurn(conversation, turnId);
|
|
1984
|
+
return materializeTurnRecord(parsed, piProjection, turnStartMessageIndex, !followsReplacement && (parsed.historyVersion === void 0 || parsed.historyVersion === currentHistoryVersion), {
|
|
1985
|
+
channelName: conversation?.channelName,
|
|
1986
|
+
cumulativeDurationMs: executionMetrics?.durationMs,
|
|
1987
|
+
cumulativeUsage: executionMetrics?.usage
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
/** Read a turn record pinned to the history version containing its checkpoint. */
|
|
1991
|
+
async function getTurnRecord(conversationId, turnId) {
|
|
1992
|
+
return await materializeStoredTurnRecord(conversationId, turnId, false);
|
|
1993
|
+
}
|
|
1994
|
+
/** Read a turn cursor for resume, following a newer committed replacement while unfinished. */
|
|
1995
|
+
async function getTurnRecordForResume(conversationId, turnId) {
|
|
1996
|
+
return await materializeStoredTurnRecord(conversationId, turnId, true);
|
|
1997
|
+
}
|
|
1998
|
+
/** Build the storage record that advances optimistic resume versioning. */
|
|
1999
|
+
function buildStoredRecord(args) {
|
|
2000
|
+
const nowMs = Date.now();
|
|
2001
|
+
return {
|
|
2002
|
+
schemaVersion: TURN_CURSOR_SCHEMA_VERSION,
|
|
2003
|
+
version: (args.previousVersion ?? 0) + 1,
|
|
2004
|
+
conversationId: args.conversationId,
|
|
2005
|
+
turnId: args.turnId,
|
|
2006
|
+
sliceId: args.sliceId,
|
|
2007
|
+
state: args.state,
|
|
2008
|
+
startedAtMs: args.startedAtMs ?? nowMs,
|
|
2009
|
+
lastProgressAtMs: args.lastProgressAtMs ?? nowMs,
|
|
2010
|
+
updatedAtMs: nowMs,
|
|
2011
|
+
committedSeq: args.committedSeq,
|
|
2012
|
+
...definedProps$1({
|
|
2013
|
+
actor: args.actor,
|
|
2014
|
+
cumulativeToolCallCount: args.cumulativeToolCallCount,
|
|
2015
|
+
dispatchId: args.dispatchId,
|
|
2016
|
+
dispatchOutcome: args.dispatchOutcome,
|
|
2017
|
+
errorMessage: args.errorMessage,
|
|
2018
|
+
historyVersion: args.historyVersion,
|
|
2019
|
+
resumeReason: args.resumeReason,
|
|
2020
|
+
publishExternally: args.publishExternally,
|
|
2021
|
+
source: args.source,
|
|
2022
|
+
resultMessageId: args.resultMessageId,
|
|
2023
|
+
resumedFromSliceId: args.resumedFromSliceId,
|
|
2024
|
+
runtimeContext: args.runtimeContext && args.runtimeContext.length > 0 ? args.runtimeContext : void 0,
|
|
2025
|
+
surface: args.surface,
|
|
2026
|
+
traceId: args.traceId,
|
|
2027
|
+
turnStartSeq: args.turnStartSeq
|
|
2028
|
+
})
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
async function setStoredRecord(args) {
|
|
2032
|
+
const stateAdapter = getStateAdapter();
|
|
2033
|
+
await stateAdapter.connect();
|
|
2034
|
+
const storedRecord = storedTurnRecordSchema.parse(args.record);
|
|
2035
|
+
await args.fence();
|
|
2036
|
+
await recordConversationActivityMetadata({
|
|
2037
|
+
actor: args.actor,
|
|
2038
|
+
conversationStore: args.conversationStore,
|
|
2039
|
+
destination: args.destination,
|
|
2040
|
+
destinationVisibility: args.destinationVisibility,
|
|
2041
|
+
nowMs: Date.now(),
|
|
2042
|
+
source: args.source,
|
|
2043
|
+
summary: {
|
|
2044
|
+
...projectTurnSummary(storedRecord),
|
|
2045
|
+
startedAtMs: storedRecord.startedAtMs,
|
|
2046
|
+
...args.runtimeMetrics
|
|
2047
|
+
}
|
|
2048
|
+
});
|
|
2049
|
+
await args.fence();
|
|
2050
|
+
await stateAdapter.set(turnCursorKey(storedRecord.conversationId, storedRecord.turnId), storedRecord, args.ttlMs);
|
|
2051
|
+
await args.fence();
|
|
2052
|
+
await appendTurnSummary(projectTurnSummary(storedRecord), turnCursorIndexTtlMs(args.indexTtlMs));
|
|
2053
|
+
return materializeTurnRecord(storedRecord, {
|
|
2054
|
+
messages: [...args.piMessages],
|
|
2055
|
+
provenance: [...args.piMessageProvenance]
|
|
2056
|
+
}, args.turnStartMessageIndex, true, args.runtimeMetrics);
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Transition an unfinished turn only if the caller still holds the
|
|
2060
|
+
* version it loaded, preventing stale resume callbacks from winning.
|
|
2061
|
+
*/
|
|
2062
|
+
async function updateTurnState(args) {
|
|
2063
|
+
const parsed = await getStoredTurnRecord(args.existing.conversationId, args.existing.turnId);
|
|
2064
|
+
if (!parsed || parsed.version !== args.existing.version) return;
|
|
2065
|
+
return await setStoredRecord({
|
|
2066
|
+
actor: args.existing.actor,
|
|
2067
|
+
fence: args.fence,
|
|
2068
|
+
piMessages: args.existing.piMessages,
|
|
2069
|
+
piMessageProvenance: args.existing.piMessageProvenance,
|
|
2070
|
+
ttlMs: turnCursorTtlMs(args.state),
|
|
2071
|
+
runtimeMetrics: {
|
|
2072
|
+
cumulativeDurationMs: args.existing.cumulativeDurationMs,
|
|
2073
|
+
...definedProps$1({
|
|
2074
|
+
channelName: args.existing.channelName,
|
|
2075
|
+
cumulativeUsage: args.existing.cumulativeUsage
|
|
2076
|
+
})
|
|
2077
|
+
},
|
|
2078
|
+
...definedProps$1({ turnStartMessageIndex: args.existing.turnStartMessageIndex }),
|
|
2079
|
+
record: buildStoredRecord({
|
|
2080
|
+
actor: args.existing.actor,
|
|
2081
|
+
conversationId: args.existing.conversationId,
|
|
2082
|
+
turnId: args.existing.turnId,
|
|
2083
|
+
sliceId: args.existing.sliceId,
|
|
2084
|
+
state: args.state,
|
|
2085
|
+
committedSeq: parsed.committedSeq,
|
|
2086
|
+
startedAtMs: parsed.startedAtMs,
|
|
2087
|
+
lastProgressAtMs: parsed.lastProgressAtMs,
|
|
2088
|
+
previousVersion: parsed.version,
|
|
2089
|
+
...definedProps$1({
|
|
2090
|
+
cumulativeToolCallCount: args.existing.cumulativeToolCallCount,
|
|
2091
|
+
dispatchId: args.existing.dispatchId,
|
|
2092
|
+
dispatchOutcome: args.existing.dispatchOutcome,
|
|
2093
|
+
errorMessage: args.errorMessage ?? args.existing.errorMessage,
|
|
2094
|
+
historyVersion: parsed.historyVersion,
|
|
2095
|
+
resumeReason: args.existing.resumeReason,
|
|
2096
|
+
publishExternally: parsed.publishExternally,
|
|
2097
|
+
source: args.existing.source,
|
|
2098
|
+
resultMessageId: args.resultMessageId ?? args.existing.resultMessageId,
|
|
2099
|
+
resumedFromSliceId: args.existing.resumedFromSliceId,
|
|
2100
|
+
surface: args.existing.surface,
|
|
2101
|
+
traceId: args.existing.traceId,
|
|
2102
|
+
turnStartSeq: parsed.turnStartSeq
|
|
2103
|
+
})
|
|
2104
|
+
})
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
/** Commit stable Pi state and advance the turn cursor. */
|
|
2108
|
+
async function upsertTurnRecord(args) {
|
|
2109
|
+
return await withTurnCursorMutation(args.conversationId, args.turnId, async (fence) => await upsertTurnRecordLocked(args, fence));
|
|
2110
|
+
}
|
|
2111
|
+
async function upsertTurnRecordLocked(args, fence) {
|
|
2112
|
+
const existingRecord = await getStoredTurnRecord(args.conversationId, args.turnId);
|
|
2113
|
+
const conversation = await (args.conversationStore ?? getConversationStore()).get({ conversationId: args.conversationId });
|
|
2114
|
+
const executionMetrics = executionMetricsForTurn(conversation, args.turnId);
|
|
2115
|
+
const existingDispatchId = existingRecord?.dispatchId ?? (await listTurnSummaries(args.conversationId)).find((summary) => summary.turnId === args.turnId)?.dispatchId;
|
|
2116
|
+
if (existingDispatchId && args.dispatchId && existingDispatchId !== args.dispatchId) throw new Error(`Turn ${args.turnId} dispatchId cannot be changed`);
|
|
2117
|
+
const ttlMs = turnCursorTtlMs(args.state, args.ttlMs);
|
|
2118
|
+
const instructionActor = args.actor;
|
|
2119
|
+
const commit = await commitMessages({
|
|
2120
|
+
conversationId: args.conversationId,
|
|
2121
|
+
messages: args.piMessages,
|
|
2122
|
+
...instructionActor ? { newMessageProvenance: instructionProvenanceFor(instructionActor) } : void 0,
|
|
2123
|
+
...args.trailingMessageProvenance ? { trailingMessageProvenance: args.trailingMessageProvenance } : void 0,
|
|
2124
|
+
...args.turnContexts && args.turnContexts.length > 0 ? { turnContext: {
|
|
2125
|
+
contexts: args.turnContexts,
|
|
2126
|
+
turnId: args.turnId
|
|
2127
|
+
} } : void 0
|
|
2128
|
+
});
|
|
2129
|
+
const durableTurnStartMessageIndex = args.turnStartMessageIndex === void 0 ? void 0 : stripRuntimeTurnContext(args.piMessages.slice(0, args.turnStartMessageIndex)).length;
|
|
2130
|
+
const runtimeContext = retainRuntimeTurnContext(args.piMessages);
|
|
2131
|
+
const retainedRuntimeContext = runtimeContext.length > 0 ? runtimeContext : existingRecord?.historyVersion === commit.historyVersion ? existingRecord.runtimeContext : void 0;
|
|
2132
|
+
const turnStartSeq = durableTurnStartMessageIndex === void 0 ? existingRecord?.turnStartSeq : durableTurnStartMessageIndex <= 0 ? -1 : commit.messageSeqs[durableTurnStartMessageIndex - 1] ?? commit.committedSeq;
|
|
2133
|
+
const turnStartMessageIndex = durableTurnStartMessageIndex ?? (turnStartSeq === void 0 ? void 0 : commit.messageSeqs.filter((seq) => seq <= turnStartSeq).length);
|
|
2134
|
+
const turnSource = args.source ?? existingRecord?.source;
|
|
2135
|
+
const dispatchId = args.dispatchId ?? existingRecord?.dispatchId;
|
|
2136
|
+
const hasProviderLocation = Boolean(conversation?.location) || args.destination?.platform === "slack";
|
|
2137
|
+
const publishExternally = dispatchId || turnSource ? hasProviderLocation && (Boolean(dispatchId) || turnSource?.kind === "slack" || turnSource?.kind === "resource_event") : existingRecord?.publishExternally ?? false;
|
|
2138
|
+
return await setStoredRecord({
|
|
2139
|
+
actor: existingRecord?.actor ?? args.actor,
|
|
2140
|
+
fence,
|
|
2141
|
+
conversationStore: args.conversationStore,
|
|
2142
|
+
destination: args.destination,
|
|
2143
|
+
destinationVisibility: args.destinationVisibility,
|
|
2144
|
+
source: args.source,
|
|
2145
|
+
piMessages: commit.messages,
|
|
2146
|
+
piMessageProvenance: commit.provenance,
|
|
2147
|
+
ttlMs,
|
|
2148
|
+
runtimeMetrics: {
|
|
2149
|
+
channelName: args.channelName ?? conversation?.channelName,
|
|
2150
|
+
cumulativeDurationMs: args.cumulativeDurationMs ?? executionMetrics?.durationMs ?? 0,
|
|
2151
|
+
cumulativeUsage: args.cumulativeUsage ?? executionMetrics?.usage
|
|
2152
|
+
},
|
|
2153
|
+
...definedProps$1({ indexTtlMs: args.ttlMs }),
|
|
2154
|
+
turnStartMessageIndex,
|
|
2155
|
+
record: buildStoredRecord({
|
|
2156
|
+
actor: existingRecord?.actor ?? args.actor,
|
|
2157
|
+
conversationId: args.conversationId,
|
|
2158
|
+
turnId: args.turnId,
|
|
2159
|
+
sliceId: args.sliceId,
|
|
2160
|
+
state: args.state,
|
|
2161
|
+
committedSeq: commit.committedSeq,
|
|
2162
|
+
historyVersion: commit.historyVersion,
|
|
2163
|
+
previousVersion: existingRecord?.version,
|
|
2164
|
+
...definedProps$1({
|
|
2165
|
+
cumulativeToolCallCount: args.cumulativeToolCallCount ?? existingRecord?.cumulativeToolCallCount,
|
|
2166
|
+
dispatchId,
|
|
2167
|
+
dispatchOutcome: args.dispatchOutcome ?? existingRecord?.dispatchOutcome,
|
|
2168
|
+
errorMessage: args.errorMessage,
|
|
2169
|
+
lastProgressAtMs: args.lastProgressAtMs,
|
|
2170
|
+
resumeReason: args.resumeReason,
|
|
2171
|
+
publishExternally,
|
|
2172
|
+
source: args.source ?? existingRecord?.source,
|
|
2173
|
+
resultMessageId: args.resultMessageId ?? existingRecord?.resultMessageId,
|
|
2174
|
+
resumedFromSliceId: args.resumedFromSliceId,
|
|
2175
|
+
runtimeContext: args.state === "running" || args.state === "paused" ? retainedRuntimeContext : void 0,
|
|
2176
|
+
startedAtMs: existingRecord?.startedAtMs,
|
|
2177
|
+
surface: args.surface ?? existingRecord?.surface,
|
|
2178
|
+
traceId: args.traceId ?? existingRecord?.traceId,
|
|
2179
|
+
turnStartSeq
|
|
2180
|
+
})
|
|
2181
|
+
})
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
/** Record turn metadata without storing conversation messages. */
|
|
2185
|
+
async function recordTurnSummary(args) {
|
|
2186
|
+
await withTurnCursorMutation(args.conversationId, args.turnId, async (fence) => await recordTurnSummaryOwned(args, fence));
|
|
2187
|
+
}
|
|
2188
|
+
async function recordTurnSummaryOwned(args, fence) {
|
|
2189
|
+
const stored = await getStoredTurnRecord(args.conversationId, args.turnId);
|
|
2190
|
+
const conversationStore = args.conversationStore ?? getConversationStore();
|
|
2191
|
+
const conversation = await conversationStore.get({ conversationId: args.conversationId });
|
|
2192
|
+
const executionMetrics = executionMetricsForTurn(conversation, args.turnId);
|
|
2193
|
+
const priorSummary = (await listTurnSummaries(args.conversationId)).find((summary) => summary.turnId === args.turnId);
|
|
2194
|
+
const terminalSummary = priorSummary && (priorSummary.state === "completed" || priorSummary.state === "failed" || priorSummary.state === "abandoned") ? priorSummary : void 0;
|
|
2195
|
+
const terminalRecord = stored && (stored.state === "completed" || stored.state === "failed" || stored.state === "abandoned") ? stored : void 0;
|
|
2196
|
+
const existing = terminalSummary ?? terminalRecord ?? stored ?? priorSummary;
|
|
2197
|
+
if ((terminalSummary || terminalRecord) && (args.state === "running" || args.state === "paused")) return;
|
|
2198
|
+
const existingDispatchId = existing?.dispatchId;
|
|
2199
|
+
const existingDispatchOutcome = priorSummary?.dispatchOutcome ?? stored?.dispatchOutcome;
|
|
2200
|
+
const existingResultMessageId = priorSummary?.resultMessageId ?? stored?.resultMessageId;
|
|
2201
|
+
if (existingDispatchId && args.dispatchId && existingDispatchId !== args.dispatchId) throw new Error(`Turn ${args.turnId} dispatchId cannot be changed`);
|
|
2202
|
+
const nowMs = Date.now();
|
|
2203
|
+
const ttlMs = turnCursorIndexTtlMs(args.ttlMs);
|
|
2204
|
+
const summary = {
|
|
2205
|
+
schemaVersion: TURN_CURSOR_SCHEMA_VERSION,
|
|
2206
|
+
version: existing?.version ?? 0,
|
|
2207
|
+
conversationId: args.conversationId,
|
|
2208
|
+
turnId: args.turnId,
|
|
2209
|
+
sliceId: args.sliceId,
|
|
2210
|
+
state: args.state,
|
|
2211
|
+
updatedAtMs: nowMs,
|
|
2212
|
+
...definedProps$1({
|
|
2213
|
+
dispatchId: args.dispatchId ?? existingDispatchId,
|
|
2214
|
+
dispatchOutcome: args.dispatchOutcome ?? existingDispatchOutcome,
|
|
2215
|
+
resumeReason: args.resumeReason,
|
|
2216
|
+
resultMessageId: args.resultMessageId ?? existingResultMessageId,
|
|
2217
|
+
surface: args.surface ?? existing?.surface
|
|
2218
|
+
})
|
|
2219
|
+
};
|
|
2220
|
+
await fence();
|
|
2221
|
+
await recordConversationActivityMetadata({
|
|
2222
|
+
actor: args.actor,
|
|
2223
|
+
conversationStore,
|
|
2224
|
+
destination: args.destination,
|
|
2225
|
+
destinationVisibility: args.destinationVisibility,
|
|
2226
|
+
nowMs,
|
|
2227
|
+
source: args.source,
|
|
2228
|
+
summary: {
|
|
2229
|
+
...summary,
|
|
2230
|
+
channelName: args.channelName ?? conversation?.channelName,
|
|
2231
|
+
cumulativeDurationMs: args.cumulativeDurationMs ?? executionMetrics?.durationMs ?? 0,
|
|
2232
|
+
cumulativeUsage: args.cumulativeUsage ?? executionMetrics?.usage,
|
|
2233
|
+
startedAtMs: stored?.startedAtMs ?? args.startedAtMs ?? nowMs
|
|
2234
|
+
}
|
|
2235
|
+
});
|
|
2236
|
+
await fence();
|
|
2237
|
+
await appendTurnSummary(summary, ttlMs);
|
|
2238
|
+
}
|
|
2239
|
+
async function readTurnSummaries(key) {
|
|
2240
|
+
const stateAdapter = getStateAdapter();
|
|
2241
|
+
await stateAdapter.connect();
|
|
2242
|
+
const values = await stateAdapter.getList(key);
|
|
2243
|
+
const summaries = /* @__PURE__ */ new Map();
|
|
2244
|
+
for (const value of [...values].reverse()) {
|
|
2245
|
+
let summary;
|
|
2246
|
+
try {
|
|
2247
|
+
summary = parseTurnSummary(value);
|
|
2248
|
+
} catch {
|
|
2249
|
+
continue;
|
|
2250
|
+
}
|
|
2251
|
+
const summaryKey = `${summary.conversationId}:${summary.turnId}`;
|
|
2252
|
+
if (!summaries.has(summaryKey)) summaries.set(summaryKey, summary);
|
|
2253
|
+
}
|
|
2254
|
+
return [...summaries.values()].sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
|
2255
|
+
}
|
|
2256
|
+
/** List retained turn recovery projections for one conversation. */
|
|
2257
|
+
async function listTurnSummaries(conversationId) {
|
|
2258
|
+
return readTurnSummaries(turnCursorIndexKey(conversationId));
|
|
2259
|
+
}
|
|
2260
|
+
/** Mark an unfinished turn cursor as abandoned when a newer turn wins. */
|
|
2261
|
+
async function abandonTurnRecord(args) {
|
|
2262
|
+
return await withTurnCursorMutation(args.conversationId, args.turnId, async (fence) => {
|
|
2263
|
+
const existing = await getTurnRecord(args.conversationId, args.turnId);
|
|
2264
|
+
if (!existing || existing.state === "completed" || existing.state === "failed" || existing.state === "abandoned") return;
|
|
2265
|
+
return await updateTurnState({
|
|
2266
|
+
existing,
|
|
2267
|
+
fence,
|
|
2268
|
+
state: "abandoned",
|
|
2269
|
+
errorMessage: args.errorMessage ?? existing.errorMessage
|
|
2270
|
+
});
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
/** Mark an unfinished turn cursor as completed after delivery was accepted. */
|
|
2274
|
+
async function completeTurnRecord(args) {
|
|
2275
|
+
return await withTurnCursorMutation(args.conversationId, args.turnId, async (fence) => {
|
|
2276
|
+
const existing = await getTurnRecord(args.conversationId, args.turnId);
|
|
2277
|
+
if (!existing || existing.state === "completed" || existing.state === "failed" || existing.state === "abandoned" || existing.version !== args.expectedVersion) return;
|
|
2278
|
+
return await updateTurnState({
|
|
2279
|
+
existing,
|
|
2280
|
+
fence,
|
|
2281
|
+
resultMessageId: args.resultMessageId,
|
|
2282
|
+
state: "completed"
|
|
2283
|
+
});
|
|
2284
|
+
});
|
|
2285
|
+
}
|
|
2286
|
+
/** Mark an unfinished turn cursor as failed so it cannot resume. */
|
|
2287
|
+
async function failTurnRecord(args) {
|
|
2288
|
+
return await withTurnCursorMutation(args.conversationId, args.turnId, async (fence) => {
|
|
2289
|
+
const existing = await getTurnRecord(args.conversationId, args.turnId);
|
|
2290
|
+
if (!existing || existing.state === "completed" || existing.state === "failed" || existing.state === "abandoned" || existing.version !== args.expectedVersion) return;
|
|
2291
|
+
return await updateTurnState({
|
|
2292
|
+
existing,
|
|
2293
|
+
fence,
|
|
2294
|
+
state: "failed",
|
|
2295
|
+
errorMessage: args.errorMessage ?? existing.errorMessage
|
|
2296
|
+
});
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
//#endregion
|
|
2300
|
+
//#region src/chat/task-execution/checkpoint.ts
|
|
2301
|
+
function definedProps(values) {
|
|
2302
|
+
return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== void 0));
|
|
2303
|
+
}
|
|
2304
|
+
function addDurationMs(prior, current) {
|
|
2305
|
+
return [prior, current].reduce((sum, value) => {
|
|
2306
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return sum;
|
|
2307
|
+
return (sum ?? 0) + Math.max(0, Math.floor(value));
|
|
2308
|
+
}, void 0);
|
|
2309
|
+
}
|
|
2310
|
+
/** Boundary safe to continue after auth/timeout (trim mid-assistant). */
|
|
2311
|
+
function continuableMessages(messages, fallback) {
|
|
2312
|
+
const current = trimTrailingAssistantMessages(messages);
|
|
2313
|
+
if (current.length > 0 && isContinuablePiBoundary(current)) return current;
|
|
2314
|
+
return trimTrailingAssistantMessages(fallback ?? []);
|
|
2315
|
+
}
|
|
2316
|
+
/** Load resume cursor for one turn. */
|
|
2317
|
+
async function loadTurnCheckpoint(args) {
|
|
2318
|
+
const record = await getTurnRecordForResume(args.conversationId, args.turnId);
|
|
2319
|
+
if (record?.state === "paused") return {
|
|
2320
|
+
resumed: true,
|
|
2321
|
+
sliceId: record.sliceId,
|
|
2322
|
+
record
|
|
2323
|
+
};
|
|
2324
|
+
return {
|
|
2325
|
+
resumed: false,
|
|
2326
|
+
sliceId: 1,
|
|
2327
|
+
record
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
async function saveTurnCheckpoint(args) {
|
|
2331
|
+
if (args.mode === "running") return await saveRunning(args);
|
|
2332
|
+
if (args.mode === "paused") return await savePaused(args);
|
|
2333
|
+
await saveDone(args);
|
|
2334
|
+
}
|
|
2335
|
+
function sharedWrite(args, latest) {
|
|
2336
|
+
return {
|
|
2337
|
+
conversationId: args.conversationId,
|
|
2338
|
+
turnId: args.turnId,
|
|
2339
|
+
...definedProps({
|
|
2340
|
+
actor: args.actor,
|
|
2341
|
+
channelName: args.channelName ?? latest?.channelName,
|
|
2342
|
+
destination: args.destination,
|
|
2343
|
+
destinationVisibility: args.destinationVisibility,
|
|
2344
|
+
dispatchId: args.dispatchId ?? latest?.dispatchId,
|
|
2345
|
+
source: args.source,
|
|
2346
|
+
surface: args.surface ?? latest?.surface,
|
|
2347
|
+
traceId: getActiveTraceId() ?? latest?.traceId,
|
|
2348
|
+
turnStartMessageIndex: args.turnStartMessageIndex ?? latest?.turnStartMessageIndex,
|
|
2349
|
+
cumulativeToolCallCount: args.cumulativeToolCallCount ?? latest?.cumulativeToolCallCount
|
|
2350
|
+
})
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
async function saveRunning(args) {
|
|
2354
|
+
if (args.messages.length === 0 || !isContinuablePiBoundary(args.messages)) return;
|
|
2355
|
+
try {
|
|
2356
|
+
const latest = await getTurnRecord(args.conversationId, args.turnId);
|
|
2357
|
+
return await upsertTurnRecord({
|
|
2358
|
+
...sharedWrite(args, latest),
|
|
2359
|
+
...definedProps({
|
|
2360
|
+
trailingMessageProvenance: args.trailingMessageProvenance,
|
|
2361
|
+
turnContexts: args.turnContexts
|
|
2362
|
+
}),
|
|
2363
|
+
cumulativeDurationMs: latest?.cumulativeDurationMs,
|
|
2364
|
+
cumulativeUsage: latest?.cumulativeUsage,
|
|
2365
|
+
piMessages: args.messages,
|
|
2366
|
+
sliceId: args.sliceId,
|
|
2367
|
+
state: "running"
|
|
2368
|
+
});
|
|
2369
|
+
} catch (error) {
|
|
2370
|
+
if (!(error instanceof AgentHistoryBranchError)) logException(error, "agent.turn.checkpoint.running.failed", {
|
|
2371
|
+
"app.ai.resume_conversation_id": args.conversationId,
|
|
2372
|
+
"app.ai.resume_session_id": args.turnId,
|
|
2373
|
+
"app.ai.resume_slice_id": args.sliceId
|
|
2374
|
+
});
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
async function savePaused(args) {
|
|
2379
|
+
const keepSlice = args.reason === "yield";
|
|
2380
|
+
const nextSliceId = keepSlice ? args.sliceId : args.sliceId + 1;
|
|
2381
|
+
try {
|
|
2382
|
+
const latest = await getTurnRecord(args.conversationId, args.turnId);
|
|
2383
|
+
const messages = args.reason === "yield" ? [...args.messages] : continuableMessages(args.messages, latest?.piMessages);
|
|
2384
|
+
if (args.reason === "auth") {
|
|
2385
|
+
if (messages.length > 0 && !isContinuablePiBoundary(messages)) return;
|
|
2386
|
+
} else if (messages.length === 0 || !isContinuablePiBoundary(messages)) return;
|
|
2387
|
+
const base = {
|
|
2388
|
+
...sharedWrite(args, latest),
|
|
2389
|
+
...definedProps({
|
|
2390
|
+
cumulativeDurationMs: addDurationMs(latest?.cumulativeDurationMs, args.durationMs),
|
|
2391
|
+
cumulativeUsage: addAgentTurnUsage(latest?.cumulativeUsage, args.usage),
|
|
2392
|
+
errorMessage: args.errorMessage
|
|
2393
|
+
}),
|
|
2394
|
+
piMessages: messages,
|
|
2395
|
+
resumeReason: args.reason
|
|
2396
|
+
};
|
|
2397
|
+
if (!keepSlice && nextSliceId > botConfig.maxSlicesPerTurn) {
|
|
2398
|
+
const error = new TurnSliceLimitExceededError(botConfig.maxSlicesPerTurn);
|
|
2399
|
+
await upsertTurnRecord({
|
|
2400
|
+
...base,
|
|
2401
|
+
...definedProps({ resumedFromSliceId: latest?.resumedFromSliceId }),
|
|
2402
|
+
errorMessage: error.message,
|
|
2403
|
+
sliceId: args.sliceId,
|
|
2404
|
+
state: "failed"
|
|
2405
|
+
});
|
|
2406
|
+
throw error;
|
|
2407
|
+
}
|
|
2408
|
+
return await upsertTurnRecord({
|
|
2409
|
+
...base,
|
|
2410
|
+
...definedProps({ resumedFromSliceId: keepSlice ? latest?.resumedFromSliceId : args.sliceId }),
|
|
2411
|
+
sliceId: nextSliceId,
|
|
2412
|
+
state: "paused"
|
|
2413
|
+
});
|
|
2414
|
+
} catch (error) {
|
|
2415
|
+
if (error instanceof TurnSliceLimitExceededError) throw error;
|
|
2416
|
+
logException(error, "agent.turn.checkpoint.paused.failed", {
|
|
2417
|
+
"app.ai.resume_conversation_id": args.conversationId,
|
|
2418
|
+
"app.ai.resume_session_id": args.turnId,
|
|
2419
|
+
"app.ai.resume_from_slice_id": args.sliceId,
|
|
2420
|
+
"app.ai.resume_next_slice_id": nextSliceId,
|
|
2421
|
+
"app.ai.resume_reason": args.reason
|
|
2422
|
+
});
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
async function saveDone(args) {
|
|
2427
|
+
let latest;
|
|
2428
|
+
await persistWithRetry(async () => {
|
|
2429
|
+
latest = await getTurnRecord(args.conversationId, args.turnId);
|
|
2430
|
+
});
|
|
2431
|
+
const sliceId = args.sliceId ?? latest?.sliceId;
|
|
2432
|
+
if (sliceId === void 0) throw new Error("Completed turn checkpoint requires a slice id from the caller or the latest stored record");
|
|
2433
|
+
await persistWithRetry(async () => {
|
|
2434
|
+
await upsertTurnRecord({
|
|
2435
|
+
...sharedWrite(args, latest),
|
|
2436
|
+
...definedProps({
|
|
2437
|
+
cumulativeDurationMs: addDurationMs(latest?.cumulativeDurationMs, args.durationMs),
|
|
2438
|
+
cumulativeUsage: addAgentTurnUsage(latest?.cumulativeUsage, args.usage),
|
|
2439
|
+
dispatchOutcome: args.dispatchOutcome ?? latest?.dispatchOutcome,
|
|
2440
|
+
errorMessage: args.errorMessage,
|
|
2441
|
+
resultMessageId: args.resultMessageId ?? latest?.resultMessageId
|
|
2442
|
+
}),
|
|
2443
|
+
piMessages: args.messages,
|
|
2444
|
+
sliceId,
|
|
2445
|
+
state: args.mode
|
|
2446
|
+
});
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
//#endregion
|
|
2450
|
+
export { persistConversationMessages as $, normalizeToLf as A, retainRuntimeTurnContext as At, openConversationProjection as B, DEFAULT_SEARCH_COMMAND_TIMEOUT_MS as C, hasRuntimeTurnContext as Ct, getRipgrepSearchLocation as D, isToolResultError as Dt, collectFiles as E, isContinuablePiBoundary as Et, commitMessages as F, recordAuthorizationRequested as G, recordAttachmentsDelivered as H, loadConnectedMcpProviders as I, recordSubscribedReplyRoute as J, recordGuardianActionReviewed as K, loadConversationProjection as L, resolveWorkspacePath as M, trimTrailingAssistantMessages as Mt, truncateText as N, TURN_CONTEXT_TAG as Nt, isMissingPathError as O, isToolResultMessage as Ot, commitAcceptedReply as P, hydrateConversationMessages as Q, loadProjection as R, resolveRepositoryInstructionsForDirectories as S, getUserMessageInstructionText as St, RIPGREP_EXCLUDED_GLOBS as T, isAssistantMessage as Tt, recordAuthenticationLinked as U, recordAgentsInstructionsUpdated as V, recordAuthorizationCompleted as W, recordTurnRoute as X, recordToolExecutionStarted as Y, appendConversationMessages as Z, AGENTS_REPLACEMENT_NOTICE as _, historyItemFromPiMessage as _t, completeTurnRecord as a, createConversationMemoryService as at, listRepositoryDirectories as b, getPiMessageRole as bt, getTurnRecordForResume as c, isHumanConversationMessage as ct, persistWithRetry as d, recordDeliveredAssistantMessage as dt, projectConversationMessages as et, TurnSliceLimitExceededError as f, turnHasReply as ft, AGENTS_REMOVAL_NOTICE as g, getAgentContextInputLimitTokens as gt, isTurnExecutionLimitExceededError as h, getAgentContextCompactionTriggerTokens as ht, abandonTurnRecord as i, buildConversationContext as it, positiveInteger as j, stripRuntimeTurnContext as jt, missingPathSearchResult as k, normalizeToolNameFromResult as kt, listTurnSummaries as l, markConversationMessage as lt, buildTurnErrorResponse as m, estimateTextTokens as mt, loadTurnCheckpoint as n, resolveConversationTitle as nt, failTurnRecord as o, estimateConversationContextTokens as ot, assertTurnToolCallLimit as p, upsertConversationMessage as pt, recordMcpProviderConnected as q, saveTurnCheckpoint as r, appendThreadContextMessages as rt, getTurnRecord as s, getConversationMessageSlackTs as st, continuableMessages as t, persistConversationMessageSummaries as tt, recordTurnSummary as u, normalizeConversationText as ut, buildAgentsInstructionsMessage as v, piMessageFromHistoryItem as vt, MAX_TEXT_CHARS as w, instructionTextForProjection as wt, renderAgentsInstructions as x, getTerminalAssistantMessages as xt, findVisibleAgentsInstructions as y, extractAssistantText as yt, loadTurnRoute as z };
|