@cursor/july 0.1.6 → 0.1.8
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/bin/agent-serve.js +73 -19
- package/dist/channels/slack/channel-watch.d.ts +6 -1
- package/dist/channels/slack/channel-watch.d.ts.map +1 -1
- package/dist/channels/slack/channel-watch.js +22 -1
- package/dist/channels/slack/cursor-account.d.ts +4 -0
- package/dist/channels/slack/cursor-account.d.ts.map +1 -1
- package/dist/channels/slack/cursor-account.js +12 -2
- package/dist/channels/slack/defaults.d.ts +10 -5
- package/dist/channels/slack/defaults.d.ts.map +1 -1
- package/dist/channels/slack/defaults.js +39 -17
- package/dist/channels/slack/dispatch.d.ts +18 -0
- package/dist/channels/slack/dispatch.d.ts.map +1 -1
- package/dist/channels/slack/dispatch.js +141 -38
- package/dist/channels/slack/inbound.d.ts +13 -3
- package/dist/channels/slack/inbound.d.ts.map +1 -1
- package/dist/channels/slack/inbound.js +17 -9
- package/dist/channels/slack/index.d.ts +4 -0
- package/dist/channels/slack/index.d.ts.map +1 -1
- package/dist/channels/slack/index.js +4 -0
- package/dist/channels/slack/live-delivery.d.ts.map +1 -1
- package/dist/channels/slack/live-delivery.js +1 -0
- package/dist/channels/slack/manifest.d.ts.map +1 -1
- package/dist/channels/slack/manifest.js +3 -0
- package/dist/channels/slack/nudge-carrier.d.ts +19 -0
- package/dist/channels/slack/nudge-carrier.d.ts.map +1 -0
- package/dist/channels/slack/nudge-carrier.js +166 -0
- package/dist/channels/slack/nudge-store.d.ts +61 -0
- package/dist/channels/slack/nudge-store.d.ts.map +1 -0
- package/dist/channels/slack/nudge-store.js +266 -0
- package/dist/channels/slack/slack-channel.d.ts.map +1 -1
- package/dist/channels/slack/slack-channel.js +74 -25
- package/dist/channels/slack/stop.d.ts +14 -0
- package/dist/channels/slack/stop.d.ts.map +1 -0
- package/dist/channels/slack/stop.js +22 -0
- package/dist/channels/slack/thread-context.d.ts +81 -0
- package/dist/channels/slack/thread-context.d.ts.map +1 -0
- package/dist/channels/slack/thread-context.js +470 -0
- package/dist/channels/slack/types.d.ts +27 -0
- package/dist/channels/slack/types.d.ts.map +1 -1
- package/dist/docs/404.html +2 -2
- package/dist/docs/ab.html +4 -4
- package/dist/docs/assets/{app.DMboGKUD.js → app.BP8mAVMW.js} +1 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.DevOabKJ.js +1 -0
- package/dist/docs/assets/chunks/{VPLocalSearchBox.DkEqEnJU.js → VPLocalSearchBox.1FXIZbz8.js} +1 -1
- package/dist/docs/assets/chunks/{theme.ASugdMNm.js → theme.Dm1R02v9.js} +2 -2
- package/dist/docs/assets/example-agents_index.md.CwtWu5ix.js +2 -0
- package/dist/docs/assets/{example-agents_index.md.CRqJlnIf.lean.js → example-agents_index.md.CwtWu5ix.lean.js} +1 -1
- package/dist/docs/assets/example-agents_oncall.md.BG_sUMly.js +10 -0
- package/dist/docs/assets/example-agents_oncall.md.BG_sUMly.lean.js +1 -0
- package/dist/docs/assets/{guides_slack.md.CCwqHvSV.js → guides_slack.md.FWale6Ip.js} +4 -3
- package/dist/docs/assets/{guides_slack.md.CCwqHvSV.lean.js → guides_slack.md.FWale6Ip.lean.js} +1 -1
- package/dist/docs/assets/{reference_sessions.md.DdjWabnN.js → reference_sessions.md.DLd6mvbv.js} +2 -2
- package/dist/docs/assets/{reference_sessions.md.DdjWabnN.lean.js → reference_sessions.md.DLd6mvbv.lean.js} +1 -1
- package/dist/docs/assets/{storage.md.CVnInNiN.js → storage.md.Cu3MwM2a.js} +3 -2
- package/dist/docs/assets/{storage.md.CVnInNiN.lean.js → storage.md.Cu3MwM2a.lean.js} +1 -1
- package/dist/docs/building-with-agents.html +4 -4
- package/dist/docs/concepts.html +4 -4
- package/dist/docs/deployment.html +4 -4
- package/dist/docs/evals.html +4 -4
- package/dist/docs/example-agents/approval-buddy.html +4 -4
- package/dist/docs/example-agents/benny.html +5 -5
- package/dist/docs/example-agents/bugbot.html +5 -5
- package/dist/docs/example-agents/codebase-wiki.html +4 -4
- package/dist/docs/example-agents/codeowners-review.html +4 -4
- package/dist/docs/example-agents/concierge.html +4 -4
- package/dist/docs/example-agents/fsd.html +4 -4
- package/dist/docs/example-agents/index.html +6 -6
- package/dist/docs/example-agents/knowledge-base.html +4 -4
- package/dist/docs/example-agents/oncall.html +34 -0
- package/dist/docs/example-agents/security-reviewer.html +4 -4
- package/dist/docs/example-agents/slack-agent.html +4 -4
- package/dist/docs/example-agents/weather-agent.html +4 -4
- package/dist/docs/guides/agent-to-agent.html +4 -4
- package/dist/docs/guides/cloud-runtime.html +4 -4
- package/dist/docs/guides/github.html +4 -4
- package/dist/docs/guides/human-in-the-loop.html +4 -4
- package/dist/docs/guides/slack.html +7 -6
- package/dist/docs/guides/webhooks.html +4 -4
- package/dist/docs/hashmap.json +1 -1
- package/dist/docs/hillclimbing.html +4 -4
- package/dist/docs/index.html +4 -4
- package/dist/docs/quickstart.html +4 -4
- package/dist/docs/reference/agent-config.html +4 -4
- package/dist/docs/reference/channels.html +4 -4
- package/dist/docs/reference/cli.html +4 -4
- package/dist/docs/reference/connections.html +4 -4
- package/dist/docs/reference/hooks.html +4 -4
- package/dist/docs/reference/http-api.html +4 -4
- package/dist/docs/reference/instructions.html +4 -4
- package/dist/docs/reference/playground.html +4 -4
- package/dist/docs/reference/project-layout.html +4 -4
- package/dist/docs/reference/schedules.html +4 -4
- package/dist/docs/reference/sessions.html +5 -5
- package/dist/docs/reference/skills.html +4 -4
- package/dist/docs/reference/subagents.html +4 -4
- package/dist/docs/reference/tools.html +4 -4
- package/dist/docs/scaffolding-agents.html +4 -4
- package/dist/docs/storage.html +7 -6
- package/dist/docs/troubleshooting.html +4 -4
- package/dist/evals.d.ts +2 -2
- package/dist/internal/cli-ax.d.ts +23 -10
- package/dist/internal/cli-ax.d.ts.map +1 -1
- package/dist/internal/cli-ax.js +358 -55
- package/dist/internal/cli-mcp.d.ts +64 -0
- package/dist/internal/cli-mcp.d.ts.map +1 -0
- package/dist/internal/cli-mcp.js +570 -0
- package/dist/internal/distribution.d.ts.map +1 -1
- package/dist/internal/distribution.js +2 -0
- package/dist/internal/eval-run-store.d.ts +2 -0
- package/dist/internal/eval-run-store.d.ts.map +1 -1
- package/dist/internal/eval-run-store.js +4 -4
- package/dist/internal/evals-client.d.ts +96 -0
- package/dist/internal/evals-client.d.ts.map +1 -0
- package/dist/internal/evals-client.js +262 -0
- package/dist/internal/host-kv.d.ts +17 -0
- package/dist/internal/host-kv.d.ts.map +1 -0
- package/dist/internal/host-kv.js +77 -0
- package/dist/internal/init-project.js +3 -3
- package/dist/internal/server.d.ts.map +1 -1
- package/dist/internal/server.js +19 -2
- package/dist/internal/session-engine.d.ts +42 -7
- package/dist/internal/session-engine.d.ts.map +1 -1
- package/dist/internal/session-engine.js +247 -51
- package/dist/internal/storage-coordinator.d.ts +18 -1
- package/dist/internal/storage-coordinator.d.ts.map +1 -1
- package/dist/internal/storage-coordinator.js +43 -0
- package/dist/playground/assets/index-Bpr4Th4M.js +319 -0
- package/dist/playground/index.html +1 -1
- package/dist/storage-backends/file-kv.d.ts +42 -0
- package/dist/storage-backends/file-kv.d.ts.map +1 -0
- package/dist/storage-backends/file-kv.js +190 -0
- package/dist/storage-backends/postgres-kv.d.ts +77 -0
- package/dist/storage-backends/postgres-kv.d.ts.map +1 -0
- package/dist/storage-backends/postgres-kv.js +180 -0
- package/dist/storage.d.ts +10 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +10 -1
- package/dist/types.d.ts +79 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/example-agents/index.md +14 -9
- package/docs/example-agents/oncall.md +216 -0
- package/docs/guides/slack.md +11 -2
- package/docs/reference/sessions.md +13 -4
- package/docs/storage.md +27 -0
- package/package.json +15 -1
- package/skills/framework-map/SKILL.md +6 -4
- package/src/bin/agent-serve.ts +79 -18
- package/src/channels/slack/channel-watch.ts +28 -1
- package/src/channels/slack/cursor-account.ts +20 -0
- package/src/channels/slack/defaults.ts +51 -20
- package/src/channels/slack/dispatch.ts +215 -40
- package/src/channels/slack/inbound.ts +30 -10
- package/src/channels/slack/index.ts +4 -0
- package/src/channels/slack/live-delivery.ts +1 -0
- package/src/channels/slack/manifest.ts +3 -0
- package/src/channels/slack/nudge-carrier.ts +182 -0
- package/src/channels/slack/nudge-store.ts +308 -0
- package/src/channels/slack/slack-channel.ts +106 -31
- package/src/channels/slack/stop.ts +25 -0
- package/src/channels/slack/thread-context.ts +572 -0
- package/src/channels/slack/types.ts +27 -0
- package/src/evals.ts +2 -2
- package/src/internal/cli-ax.ts +466 -75
- package/src/internal/cli-mcp.ts +708 -0
- package/src/internal/distribution.ts +2 -0
- package/src/internal/eval-run-store.ts +5 -0
- package/src/internal/evals-client.ts +431 -0
- package/src/internal/host-kv.ts +85 -0
- package/src/internal/init-project.ts +3 -3
- package/src/internal/server.ts +43 -0
- package/src/internal/session-engine.ts +351 -50
- package/src/internal/storage-coordinator.ts +41 -0
- package/src/storage-backends/file-kv.ts +221 -0
- package/src/storage-backends/postgres-kv.ts +248 -0
- package/src/storage.ts +11 -1
- package/src/types.ts +83 -0
- package/dist/docs/assets/chunks/@localSearchIndexroot.O6eP7QZr.js +0 -1
- package/dist/docs/assets/example-agents_index.md.CRqJlnIf.js +0 -2
- package/dist/playground/assets/index-mux9aj5P.js +0 -319
|
@@ -17,6 +17,10 @@ import {
|
|
|
17
17
|
resolveABSplit,
|
|
18
18
|
} from "../ab.js";
|
|
19
19
|
import { slackThreadPermalink } from "../channels/slack/links.js";
|
|
20
|
+
import {
|
|
21
|
+
SlackAskDedupeStore,
|
|
22
|
+
SlackNudgeStore,
|
|
23
|
+
} from "../channels/slack/nudge-store.js";
|
|
20
24
|
import {
|
|
21
25
|
type AgentProject,
|
|
22
26
|
type ApprovalDecision,
|
|
@@ -43,6 +47,7 @@ import {
|
|
|
43
47
|
type SessionInfo,
|
|
44
48
|
type SessionRecord,
|
|
45
49
|
type SessionSummary,
|
|
50
|
+
type SlackNudgeHostApi,
|
|
46
51
|
type ToolCallOutcome,
|
|
47
52
|
type ToolContext,
|
|
48
53
|
type ToolNeedsApproval,
|
|
@@ -57,6 +62,7 @@ import { mergeCloudOptions, resolveSessionRuntime } from "./cloud-merge.js";
|
|
|
57
62
|
import { isDevMode } from "./dev-mode.js";
|
|
58
63
|
import { EventLogStore } from "./event-log.js";
|
|
59
64
|
import { TurnEventMapper } from "./event-mapper.js";
|
|
65
|
+
import { createHostKvApi } from "./host-kv.js";
|
|
60
66
|
import { createHostPlatforms, type HostPlatforms } from "./host-platforms.js";
|
|
61
67
|
import {
|
|
62
68
|
formatContinuationToken,
|
|
@@ -196,10 +202,29 @@ export interface EngineSendOptions extends SendMessageOptions {
|
|
|
196
202
|
mode?: "chat" | "task";
|
|
197
203
|
}
|
|
198
204
|
|
|
205
|
+
interface CoalescedQueuedTurn {
|
|
206
|
+
message: string;
|
|
207
|
+
sourceTs?: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface AdmitTurnOptions {
|
|
211
|
+
admission?: "preempt" | "coalesce";
|
|
212
|
+
sourceTs?: string;
|
|
213
|
+
/**
|
|
214
|
+
* When true, skip `clearCoalesced` on the preempt admit path. Used by
|
|
215
|
+
* coalesce drain so asks that arrived during drain are not wiped.
|
|
216
|
+
*/
|
|
217
|
+
retainCoalesced?: boolean;
|
|
218
|
+
}
|
|
219
|
+
|
|
199
220
|
export class SessionEngine {
|
|
200
221
|
readonly project: AgentProject;
|
|
201
222
|
readonly stateRoot: string;
|
|
202
223
|
readonly sessions: SessionStore;
|
|
224
|
+
/** Durable Slack mid-turn nudges under `<stateRoot>/slack-nudges`. */
|
|
225
|
+
readonly nudgeStore: SlackNudgeStore;
|
|
226
|
+
/** Durable `channelId:ts` ask claims under `<stateRoot>/slack-asks`. */
|
|
227
|
+
readonly askDedupe: SlackAskDedupeStore;
|
|
203
228
|
/**
|
|
204
229
|
* Runtime for `agent/storage.ts` (`defineStorage`), when
|
|
205
230
|
* authored. All durable state changes funnel through it (session
|
|
@@ -226,6 +251,16 @@ export class SessionEngine {
|
|
|
226
251
|
private readonly turnAborts = new Map<string, AbortController>();
|
|
227
252
|
/** Serializes turn admission per session (latest follow-up wins). */
|
|
228
253
|
private readonly admissionChains = new Map<string, Promise<unknown>>();
|
|
254
|
+
/**
|
|
255
|
+
* Per-session coalesce queue for `admission: "coalesce"` (Slack). Drained
|
|
256
|
+
* into one follow-up turn when the active turn finishes without interrupt.
|
|
257
|
+
*/
|
|
258
|
+
private readonly coalesceQueues = new Map<string, CoalescedQueuedTurn[]>();
|
|
259
|
+
/**
|
|
260
|
+
* Sessions whose turn lifecycle is draining coalesced follow-ups. Counts as
|
|
261
|
+
* busy so inbound coalesce admits enqueue instead of racing `clearCoalesced`.
|
|
262
|
+
*/
|
|
263
|
+
private readonly coalesceDrains = new Map<string, Promise<void>>();
|
|
229
264
|
private readonly lastOutcomes = new Map<string, TurnOutcome>();
|
|
230
265
|
private readonly updateChains = new Map<string, Promise<unknown>>();
|
|
231
266
|
private readonly dispatchChains = new Map<string, Promise<void>>();
|
|
@@ -235,6 +270,7 @@ export class SessionEngine {
|
|
|
235
270
|
private readonly approvals = new ApprovalGate();
|
|
236
271
|
private readonly abCollector: ABCollector;
|
|
237
272
|
private reminderApi: HostContext["reminders"];
|
|
273
|
+
private readonly kvApi: HostContext["kv"];
|
|
238
274
|
/** Serve-time resolved connections (see {@link setResolvedConnections}). */
|
|
239
275
|
private resolvedConnections: EngineResolvedConnection[] = [];
|
|
240
276
|
|
|
@@ -244,6 +280,12 @@ export class SessionEngine {
|
|
|
244
280
|
this.runner = options.runner;
|
|
245
281
|
this.logger = options.logger ?? (() => {});
|
|
246
282
|
this.sessions = new SessionStore(join(options.stateRoot, "sessions"));
|
|
283
|
+
this.nudgeStore = new SlackNudgeStore(
|
|
284
|
+
join(options.stateRoot, "slack-nudges")
|
|
285
|
+
);
|
|
286
|
+
this.askDedupe = new SlackAskDedupeStore(
|
|
287
|
+
join(options.stateRoot, "slack-asks")
|
|
288
|
+
);
|
|
247
289
|
this.logs = new EventLogStore(
|
|
248
290
|
(sessionId) => this.sessions.eventFilePath(sessionId),
|
|
249
291
|
(sessionId, error) =>
|
|
@@ -260,6 +302,11 @@ export class SessionEngine {
|
|
|
260
302
|
projectRoot: options.project.rootDir,
|
|
261
303
|
logger: this.logger,
|
|
262
304
|
});
|
|
305
|
+
this.kvApi = createHostKvApi({
|
|
306
|
+
agentName: options.project.name,
|
|
307
|
+
stateRoot: options.stateRoot,
|
|
308
|
+
...(this.storage === undefined ? {} : { storage: this.storage }),
|
|
309
|
+
});
|
|
263
310
|
this.abCollector = new ABCollector(
|
|
264
311
|
options.project.abs,
|
|
265
312
|
this.logger,
|
|
@@ -302,13 +349,32 @@ export class SessionEngine {
|
|
|
302
349
|
}
|
|
303
350
|
}
|
|
304
351
|
|
|
305
|
-
/** Shared host services (MCP / GitHub / Slack / reminders). */
|
|
352
|
+
/** Shared host services (MCP / GitHub / Slack / reminders / kv / Slack nudges). */
|
|
306
353
|
get host(): HostContext {
|
|
307
354
|
return {
|
|
308
355
|
mcp: this.mcpHost,
|
|
309
356
|
github: this.platforms.github,
|
|
310
357
|
slack: this.platforms.slack,
|
|
311
358
|
reminders: this.reminderApi,
|
|
359
|
+
kv: this.kvApi,
|
|
360
|
+
slackNudges: this.slackNudgeHostApi(),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private slackNudgeHostApi(): SlackNudgeHostApi {
|
|
365
|
+
return {
|
|
366
|
+
append: (input) => this.nudgeStore.append(input),
|
|
367
|
+
drainFormatted: (continuationKey) =>
|
|
368
|
+
this.nudgeStore.drainFormatted(continuationKey),
|
|
369
|
+
clear: (continuationKey) => this.nudgeStore.clear(continuationKey),
|
|
370
|
+
hasUndelivered: (continuationKey) =>
|
|
371
|
+
this.nudgeStore.hasUndelivered(continuationKey),
|
|
372
|
+
isDelivered: (continuationKey, sourceTs) =>
|
|
373
|
+
this.nudgeStore.isDelivered(continuationKey, sourceTs),
|
|
374
|
+
tryClaimAsk: (channelId, messageTs) =>
|
|
375
|
+
this.askDedupe.tryClaim(channelId, messageTs),
|
|
376
|
+
releaseAsk: (channelId, messageTs) =>
|
|
377
|
+
this.askDedupe.release(channelId, messageTs),
|
|
312
378
|
};
|
|
313
379
|
}
|
|
314
380
|
|
|
@@ -371,8 +437,13 @@ export class SessionEngine {
|
|
|
371
437
|
pendingWorkspaceFiles: options.workspaceFiles,
|
|
372
438
|
}));
|
|
373
439
|
}
|
|
374
|
-
await this.startTurn(record.sessionId, message
|
|
375
|
-
|
|
440
|
+
const coalesced = await this.startTurn(record.sessionId, message, {
|
|
441
|
+
admission: options.admission ?? "preempt",
|
|
442
|
+
...(options.coalesceSourceTs === undefined
|
|
443
|
+
? {}
|
|
444
|
+
: { sourceTs: options.coalesceSourceTs }),
|
|
445
|
+
});
|
|
446
|
+
return this.channelSession(record.sessionId, record, false, coalesced);
|
|
376
447
|
}
|
|
377
448
|
|
|
378
449
|
const now = new Date().toISOString();
|
|
@@ -402,8 +473,31 @@ export class SessionEngine {
|
|
|
402
473
|
data: { channelId },
|
|
403
474
|
});
|
|
404
475
|
record = await this.enrollABs(record);
|
|
405
|
-
await this.startTurn(record.sessionId, message
|
|
406
|
-
|
|
476
|
+
await this.startTurn(record.sessionId, message, {
|
|
477
|
+
admission: options.admission ?? "preempt",
|
|
478
|
+
...(options.coalesceSourceTs === undefined
|
|
479
|
+
? {}
|
|
480
|
+
: { sourceTs: options.coalesceSourceTs }),
|
|
481
|
+
});
|
|
482
|
+
return this.channelSession(record.sessionId, record, true, false);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Whether a continuation token already maps to a session on this channel. */
|
|
486
|
+
async hasContinuationSession(
|
|
487
|
+
channelId: string,
|
|
488
|
+
continuationToken: string
|
|
489
|
+
): Promise<boolean> {
|
|
490
|
+
let existing = await this.sessions.findByContinuation(
|
|
491
|
+
channelId,
|
|
492
|
+
continuationToken
|
|
493
|
+
);
|
|
494
|
+
if (existing === undefined) {
|
|
495
|
+
existing = await this.restoreSessionByContinuation(
|
|
496
|
+
channelId,
|
|
497
|
+
continuationToken
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
return existing !== undefined;
|
|
407
501
|
}
|
|
408
502
|
|
|
409
503
|
/**
|
|
@@ -559,17 +653,25 @@ export class SessionEngine {
|
|
|
559
653
|
// ==========================================================================
|
|
560
654
|
|
|
561
655
|
/**
|
|
562
|
-
* Admit a turn for `sessionId`.
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
656
|
+
* Admit a turn for `sessionId`.
|
|
657
|
+
*
|
|
658
|
+
* Default (`preempt`): a follow-up cancels the running turn and starts
|
|
659
|
+
* immediately (HTTP / MCP). With `admission: "coalesce"` (Slack), a busy
|
|
660
|
+
* session enqueues the message and returns without interrupting; the queue
|
|
661
|
+
* drains into one follow-up when the active turn finishes.
|
|
662
|
+
*
|
|
663
|
+
* Resolves to `true` when the message was coalesced (enqueued), `false`
|
|
664
|
+
* when a turn was started.
|
|
567
665
|
*/
|
|
568
|
-
private startTurn(
|
|
666
|
+
private startTurn(
|
|
667
|
+
sessionId: string,
|
|
668
|
+
message: string,
|
|
669
|
+
options: AdmitTurnOptions = {}
|
|
670
|
+
): Promise<boolean> {
|
|
569
671
|
const prior = this.admissionChains.get(sessionId) ?? Promise.resolve();
|
|
570
672
|
const admitted = prior
|
|
571
673
|
.catch(() => {})
|
|
572
|
-
.then(() => this.admitTurn(sessionId, message));
|
|
674
|
+
.then(() => this.admitTurn(sessionId, message, options));
|
|
573
675
|
this.admissionChains.set(
|
|
574
676
|
sessionId,
|
|
575
677
|
admitted.catch(() => {})
|
|
@@ -577,11 +679,88 @@ export class SessionEngine {
|
|
|
577
679
|
return admitted;
|
|
578
680
|
}
|
|
579
681
|
|
|
580
|
-
private
|
|
682
|
+
private enqueueCoalesced(sessionId: string, item: CoalescedQueuedTurn): void {
|
|
683
|
+
const queue = this.coalesceQueues.get(sessionId) ?? [];
|
|
684
|
+
if (
|
|
685
|
+
item.sourceTs !== undefined &&
|
|
686
|
+
queue.some((entry) => entry.sourceTs === item.sourceTs)
|
|
687
|
+
) {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
queue.push(item);
|
|
691
|
+
this.coalesceQueues.set(sessionId, queue);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private clearCoalesced(sessionId: string): void {
|
|
695
|
+
this.coalesceQueues.delete(sessionId);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private formatCoalescedFollowups(
|
|
699
|
+
items: readonly CoalescedQueuedTurn[]
|
|
700
|
+
): string {
|
|
701
|
+
if (items.length === 1) {
|
|
702
|
+
return items[0]!.message;
|
|
703
|
+
}
|
|
704
|
+
const parts = [
|
|
705
|
+
"<slack_followups>",
|
|
706
|
+
`ANSWER-EACH: ${items.length} asks arrived while you were busy. Address each in order; keep replies compact.`,
|
|
707
|
+
"",
|
|
708
|
+
];
|
|
709
|
+
for (let i = 0; i < items.length; i++) {
|
|
710
|
+
parts.push(`### Ask ${i + 1}`);
|
|
711
|
+
parts.push(items[i]!.message);
|
|
712
|
+
parts.push("");
|
|
713
|
+
}
|
|
714
|
+
parts.push("</slack_followups>");
|
|
715
|
+
return parts.join("\n").trimEnd();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
private async drainCoalescedFollowups(sessionId: string): Promise<void> {
|
|
719
|
+
const queued = this.coalesceQueues.get(sessionId) ?? [];
|
|
720
|
+
if (queued.length === 0) {
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
this.coalesceQueues.delete(sessionId);
|
|
724
|
+
const message = this.formatCoalescedFollowups(queued);
|
|
725
|
+
this.logger(
|
|
726
|
+
`[session] coalesce drain starting follow-up (${queued.length}) ${sessionId}`
|
|
727
|
+
);
|
|
728
|
+
await this.startTurn(sessionId, message, {
|
|
729
|
+
admission: "preempt",
|
|
730
|
+
retainCoalesced: true,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private async admitTurn(
|
|
735
|
+
sessionId: string,
|
|
736
|
+
message: string,
|
|
737
|
+
options: AdmitTurnOptions = {}
|
|
738
|
+
): Promise<boolean> {
|
|
739
|
+
const admission = options.admission ?? "preempt";
|
|
740
|
+
if (admission === "coalesce" && this.isTurnRunning(sessionId)) {
|
|
741
|
+
this.enqueueCoalesced(sessionId, {
|
|
742
|
+
message,
|
|
743
|
+
...(options.sourceTs === undefined
|
|
744
|
+
? {}
|
|
745
|
+
: { sourceTs: options.sourceTs }),
|
|
746
|
+
});
|
|
747
|
+
this.logger(`[session] coalesce enqueue ${sessionId}`);
|
|
748
|
+
return true;
|
|
749
|
+
}
|
|
750
|
+
|
|
581
751
|
// Interrupt any turn in flight and wait for it to settle so the SDK run is
|
|
582
752
|
// terminal before this turn's send (the follow-up also force-expires as a
|
|
583
|
-
// belt-and-suspenders — see buildTurnRequest).
|
|
584
|
-
|
|
753
|
+
// belt-and-suspenders — see buildTurnRequest). Drain-resume skips interrupt:
|
|
754
|
+
// the parent lifecycle already cleared activeTurns, and awaiting the in-
|
|
755
|
+
// flight coalesce drain here would deadlock on itself.
|
|
756
|
+
if (options.retainCoalesced !== true) {
|
|
757
|
+
await this.interruptActiveTurn(sessionId);
|
|
758
|
+
}
|
|
759
|
+
// Preempt owns the conversation — drop stale coalesced asks. Coalesce
|
|
760
|
+
// admits and drain-resume keep the queue (retainCoalesced / coalesce path).
|
|
761
|
+
if (admission === "preempt" && options.retainCoalesced !== true) {
|
|
762
|
+
this.clearCoalesced(sessionId);
|
|
763
|
+
}
|
|
585
764
|
this.pendingTurns.add(sessionId);
|
|
586
765
|
|
|
587
766
|
try {
|
|
@@ -607,31 +786,58 @@ export class SessionEngine {
|
|
|
607
786
|
|
|
608
787
|
const abort = new AbortController();
|
|
609
788
|
this.turnAborts.set(sessionId, abort);
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
789
|
+
// Lifecycle promise includes post-turn coalesce drain so waiters and
|
|
790
|
+
// interrupt settle after follow-ups are admitted (not only executeTurn).
|
|
791
|
+
// Assigned before the async body runs so self-identity checks typecheck.
|
|
792
|
+
let lifecycle!: Promise<TurnOutcome>;
|
|
793
|
+
lifecycle = (async (): Promise<TurnOutcome> => {
|
|
794
|
+
let outcome: TurnOutcome;
|
|
795
|
+
try {
|
|
796
|
+
outcome = await this.executeTurn(
|
|
797
|
+
record,
|
|
798
|
+
turnId,
|
|
799
|
+
message,
|
|
800
|
+
isFirstTurn,
|
|
801
|
+
abort.signal
|
|
802
|
+
);
|
|
803
|
+
} catch (error) {
|
|
804
|
+
outcome = {
|
|
805
|
+
status: "error",
|
|
806
|
+
errorMessage: describeError(error),
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
this.lastOutcomes.set(sessionId, outcome);
|
|
810
|
+
const wasCurrent = this.activeTurns.get(sessionId) === lifecycle;
|
|
811
|
+
if (wasCurrent) {
|
|
812
|
+
this.activeTurns.delete(sessionId);
|
|
813
|
+
}
|
|
814
|
+
if (this.turnAborts.get(sessionId) === abort) {
|
|
815
|
+
this.turnAborts.delete(sessionId);
|
|
816
|
+
}
|
|
817
|
+
if (wasCurrent && !abort.signal.aborted) {
|
|
818
|
+
const drain = this.drainCoalescedFollowups(sessionId).catch(
|
|
819
|
+
(error) => {
|
|
820
|
+
this.logger(
|
|
821
|
+
`[session] coalesce drain failed for ${sessionId}: ${describeError(error)}`
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
);
|
|
825
|
+
this.coalesceDrains.set(sessionId, drain);
|
|
826
|
+
try {
|
|
827
|
+
await drain;
|
|
828
|
+
} finally {
|
|
829
|
+
if (this.coalesceDrains.get(sessionId) === drain) {
|
|
830
|
+
this.coalesceDrains.delete(sessionId);
|
|
831
|
+
}
|
|
630
832
|
}
|
|
631
|
-
}
|
|
833
|
+
}
|
|
834
|
+
return outcome;
|
|
835
|
+
})();
|
|
836
|
+
this.activeTurns.set(sessionId, lifecycle);
|
|
632
837
|
} finally {
|
|
633
838
|
this.pendingTurns.delete(sessionId);
|
|
634
839
|
}
|
|
840
|
+
return false;
|
|
635
841
|
}
|
|
636
842
|
|
|
637
843
|
/**
|
|
@@ -645,19 +851,102 @@ export class SessionEngine {
|
|
|
645
851
|
if (active !== undefined) {
|
|
646
852
|
await active.catch(() => {});
|
|
647
853
|
}
|
|
854
|
+
const drain = this.coalesceDrains.get(sessionId);
|
|
855
|
+
if (drain !== undefined) {
|
|
856
|
+
await drain.catch(() => {});
|
|
857
|
+
}
|
|
648
858
|
}
|
|
649
859
|
|
|
650
860
|
/**
|
|
651
861
|
* Abort the in-flight turn without starting a follow-up. Returns whether
|
|
652
|
-
* a turn was active (or pending abort) when called.
|
|
862
|
+
* a turn was active (or pending abort) when called. Also clears any
|
|
863
|
+
* coalesced follow-ups queued behind the session.
|
|
653
864
|
*/
|
|
654
865
|
async interrupt(sessionId: string): Promise<boolean> {
|
|
655
866
|
const hadWork =
|
|
656
|
-
this.turnAborts.has(sessionId) ||
|
|
867
|
+
this.turnAborts.has(sessionId) ||
|
|
868
|
+
this.activeTurns.has(sessionId) ||
|
|
869
|
+
this.coalesceDrains.has(sessionId) ||
|
|
870
|
+
(this.coalesceQueues.get(sessionId)?.length ?? 0) > 0;
|
|
871
|
+
this.clearCoalesced(sessionId);
|
|
872
|
+
const record = await this.sessions.get(sessionId);
|
|
873
|
+
const continuationKey = record?.continuationKey;
|
|
874
|
+
if (continuationKey !== undefined && continuationKey !== null) {
|
|
875
|
+
await this.nudgeStore.clear(continuationKey);
|
|
876
|
+
}
|
|
657
877
|
await this.interruptActiveTurn(sessionId);
|
|
658
878
|
return hadWork;
|
|
659
879
|
}
|
|
660
880
|
|
|
881
|
+
async isContinuationBusy(
|
|
882
|
+
channelId: string,
|
|
883
|
+
continuationToken: string
|
|
884
|
+
): Promise<boolean> {
|
|
885
|
+
let existing = await this.sessions.findByContinuation(
|
|
886
|
+
channelId,
|
|
887
|
+
continuationToken
|
|
888
|
+
);
|
|
889
|
+
if (existing === undefined) {
|
|
890
|
+
existing = await this.restoreSessionByContinuation(
|
|
891
|
+
channelId,
|
|
892
|
+
continuationToken
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
if (existing === undefined) {
|
|
896
|
+
return false;
|
|
897
|
+
}
|
|
898
|
+
return this.isTurnRunning(existing.sessionId);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Last agent-posted Slack message ts for warm `<slack_thread_delta>`, from
|
|
903
|
+
* the session's channel state when present.
|
|
904
|
+
*/
|
|
905
|
+
async getContinuationLastBotMessageTs(
|
|
906
|
+
channelId: string,
|
|
907
|
+
continuationToken: string
|
|
908
|
+
): Promise<string | undefined> {
|
|
909
|
+
let existing = await this.sessions.findByContinuation(
|
|
910
|
+
channelId,
|
|
911
|
+
continuationToken
|
|
912
|
+
);
|
|
913
|
+
if (existing === undefined) {
|
|
914
|
+
existing = await this.restoreSessionByContinuation(
|
|
915
|
+
channelId,
|
|
916
|
+
continuationToken
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
if (existing === undefined) {
|
|
920
|
+
return undefined;
|
|
921
|
+
}
|
|
922
|
+
const state = existing.channelState;
|
|
923
|
+
if (state === null || state === undefined || typeof state !== "object") {
|
|
924
|
+
return undefined;
|
|
925
|
+
}
|
|
926
|
+
const ts = (state as { lastBotMessageTs?: unknown }).lastBotMessageTs;
|
|
927
|
+
return typeof ts === "string" && ts.trim() !== "" ? ts.trim() : undefined;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async interruptContinuation(
|
|
931
|
+
channelId: string,
|
|
932
|
+
continuationToken: string
|
|
933
|
+
): Promise<boolean> {
|
|
934
|
+
let existing = await this.sessions.findByContinuation(
|
|
935
|
+
channelId,
|
|
936
|
+
continuationToken
|
|
937
|
+
);
|
|
938
|
+
if (existing === undefined) {
|
|
939
|
+
existing = await this.restoreSessionByContinuation(
|
|
940
|
+
channelId,
|
|
941
|
+
continuationToken
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
if (existing === undefined) {
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
947
|
+
return this.interrupt(existing.sessionId);
|
|
948
|
+
}
|
|
949
|
+
|
|
661
950
|
/**
|
|
662
951
|
* Local harness cwd for a session: per-send override, else
|
|
663
952
|
* `<agent.local.cwd>/<sessionId>`, else the durable session workspace.
|
|
@@ -1498,15 +1787,20 @@ export class SessionEngine {
|
|
|
1498
1787
|
async waitForCompletion(sessionId: string): Promise<TurnOutcome> {
|
|
1499
1788
|
// Drain admission first so pendingTurns → activeTurns is visible before we
|
|
1500
1789
|
// decide the session is idle (reminders use this for queueBehindTurn).
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1790
|
+
// The active lifecycle promise also awaits coalesce drain + the drained
|
|
1791
|
+
// follow-up's admission, so Slack coalesced waiters see the resume turn.
|
|
1792
|
+
for (;;) {
|
|
1793
|
+
const chain = this.admissionChains.get(sessionId);
|
|
1794
|
+
if (chain !== undefined) {
|
|
1795
|
+
await chain.catch(() => {});
|
|
1796
|
+
}
|
|
1797
|
+
const active = this.activeTurns.get(sessionId);
|
|
1798
|
+
if (active !== undefined) {
|
|
1799
|
+
await active;
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1802
|
+
return this.lastOutcomes.get(sessionId) ?? { status: "finished" };
|
|
1508
1803
|
}
|
|
1509
|
-
return this.lastOutcomes.get(sessionId) ?? { status: "finished" };
|
|
1510
1804
|
}
|
|
1511
1805
|
|
|
1512
1806
|
/**
|
|
@@ -1674,9 +1968,14 @@ export class SessionEngine {
|
|
|
1674
1968
|
}
|
|
1675
1969
|
|
|
1676
1970
|
isTurnRunning(sessionId: string): boolean {
|
|
1677
|
-
// Include the admit window (pendingTurns)
|
|
1678
|
-
// turn that has
|
|
1679
|
-
|
|
1971
|
+
// Include the admit window (pendingTurns) and coalesce drain so busy
|
|
1972
|
+
// policies do not treat a turn that has finished executeTurn but not yet
|
|
1973
|
+
// resumed queued follow-ups as idle (avoids clearCoalesced races).
|
|
1974
|
+
return (
|
|
1975
|
+
this.activeTurns.has(sessionId) ||
|
|
1976
|
+
this.pendingTurns.has(sessionId) ||
|
|
1977
|
+
this.coalesceDrains.has(sessionId)
|
|
1978
|
+
);
|
|
1680
1979
|
}
|
|
1681
1980
|
|
|
1682
1981
|
sessionInfo(record: SessionRecord): SessionInfo {
|
|
@@ -1698,12 +1997,14 @@ export class SessionEngine {
|
|
|
1698
1997
|
private channelSession(
|
|
1699
1998
|
sessionId: string,
|
|
1700
1999
|
record: SessionRecord,
|
|
1701
|
-
isNew: boolean
|
|
2000
|
+
isNew: boolean,
|
|
2001
|
+
coalesced = false
|
|
1702
2002
|
): ChannelSession {
|
|
1703
2003
|
return {
|
|
1704
2004
|
id: sessionId,
|
|
1705
2005
|
continuationToken: record.continuationKey,
|
|
1706
2006
|
isNew,
|
|
2007
|
+
...(coalesced ? { coalesced: true } : {}),
|
|
1707
2008
|
info: this.sessionInfo(record),
|
|
1708
2009
|
getEventStream: (options?: { startIndex?: number }) =>
|
|
1709
2010
|
this.eventStream(sessionId, options?.startIndex ?? 0),
|
|
@@ -392,6 +392,47 @@ export class StorageCoordinator {
|
|
|
392
392
|
);
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
+
// ==========================================================================
|
|
396
|
+
// Author KV ({@link HostContext.kv})
|
|
397
|
+
// ==========================================================================
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Whether the sink can serve awaited author reads/writes (`get` is
|
|
401
|
+
* required — `put` is always present on {@link StorageConfig}).
|
|
402
|
+
*/
|
|
403
|
+
get supportsAuthorKv(): boolean {
|
|
404
|
+
return this.definition.get !== undefined;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Awaited author KV read. Unlike framework session mirrors, this awaits
|
|
409
|
+
* the sink and propagates errors — handlers need read-after-write.
|
|
410
|
+
*/
|
|
411
|
+
async kvGet(authorKey: string): Promise<JsonValue | undefined> {
|
|
412
|
+
const get = this.definition.get;
|
|
413
|
+
if (get === undefined) {
|
|
414
|
+
throw new Error("storage get is not configured");
|
|
415
|
+
}
|
|
416
|
+
const key = storageKeys.kv(this.agentName, authorKey);
|
|
417
|
+
// Preserve stored JSON `null` (distinct from a missing key).
|
|
418
|
+
return await get(key, this.context("restore"));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Awaited author KV write. Goes straight to the sink (not the
|
|
423
|
+
* at-most-once delivery queue) so callers observe durable success/failure.
|
|
424
|
+
*/
|
|
425
|
+
async kvPut(authorKey: string, value: JsonValue): Promise<void> {
|
|
426
|
+
const key = storageKeys.kv(this.agentName, authorKey);
|
|
427
|
+
await this.definition.put(key, value, this.context("policy"));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Awaited author KV delete. No-op when the sink omits `delete`. */
|
|
431
|
+
async kvDelete(authorKey: string): Promise<void> {
|
|
432
|
+
const key = storageKeys.kv(this.agentName, authorKey);
|
|
433
|
+
await this.definition.delete?.(key, this.context("policy"));
|
|
434
|
+
}
|
|
435
|
+
|
|
395
436
|
// ==========================================================================
|
|
396
437
|
// Evals (dedicated `evals` table)
|
|
397
438
|
// ==========================================================================
|