| ... | @@ -28,6 +28,10 @@ const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; | ... | @@ -28,6 +28,10 @@ const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; |
| 28 | const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); | 28 | const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); |
| 29 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; | 29 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; |
| 30 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); | 30 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); |
| | 31 | const BUFFERED_ASSISTANT_MESSAGE_SPILLED_CACHE_CAPACITY = 20_000; |
| | 32 | const BUFFERED_ASSISTANT_MESSAGE_SPILLED_TTL = Duration.minutes(120); |
| | 33 | const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; |
| | 34 | const BUFFERED_ASSISTANT_KEEP_CHARS = 8_000; |
| 31 | | 35 | |
| 32 | type TurnStartRequestedDomainEvent = Extract< | 36 | type TurnStartRequestedDomainEvent = Extract< |
| 33 | OrchestrationEvent, | 37 | OrchestrationEvent, |
| ... | @@ -169,6 +173,11 @@ const make = Effect.gen(function* () { | ... | @@ -169,6 +173,11 @@ const make = Effect.gen(function* () { |
| 169 | timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, | 173 | timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, |
| 170 | lookup: () => Effect.succeed(""), | 174 | lookup: () => Effect.succeed(""), |
| 171 | }); | 175 | }); |
| | 176 | const bufferedAssistantMessageSpilledByMessageId = yield* Cache.make<MessageId, true>({ |
| | 177 | capacity: BUFFERED_ASSISTANT_MESSAGE_SPILLED_CACHE_CAPACITY, |
| | 178 | timeToLive: BUFFERED_ASSISTANT_MESSAGE_SPILLED_TTL, |
| | 179 | lookup: () => Effect.succeed(true), |
| | 180 | }); |
| 172 | | 181 | |
| 173 | const rememberAssistantMessageId = ( | 182 | const rememberAssistantMessageId = ( |
| 174 | sessionId: ProviderSessionId, | 183 | sessionId: ProviderSessionId, |
| ... | @@ -226,14 +235,23 @@ const make = Effect.gen(function* () { | ... | @@ -226,14 +235,23 @@ const make = Effect.gen(function* () { |
| 226 | const appendBufferedAssistantText = (messageId: MessageId, delta: string) => | 235 | const appendBufferedAssistantText = (messageId: MessageId, delta: string) => |
| 227 | Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( | 236 | Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( |
| 228 | Effect.flatMap((existingText) => | 237 | Effect.flatMap((existingText) => |
| 229 | Cache.set( | 238 | Effect.gen(function* () { |
| 230 | bufferedAssistantTextByMessageId, | 239 | const nextText = Option.match(existingText, { |
| 231 | messageId, | | |
| 232 | Option.match(existingText, { | | |
| 233 | onNone: () => delta, | 240 | onNone: () => delta, |
| 234 | onSome: (text) => `${text}${delta}`, | 241 | onSome: (text) => `${text}${delta}`, |
| 235 | }), | 242 | }); |
| 236 | ), | 243 | if (nextText.length <= MAX_BUFFERED_ASSISTANT_CHARS) { |
| | 244 | yield* Cache.set(bufferedAssistantTextByMessageId, messageId, nextText); |
| | 245 | return ""; |
| | 246 | } |
| | 247 | |
| | 248 | // Safety valve: spill oldest text as a persisted assistant delta to cap memory. |
| | 249 | const spillChars = Math.max(1, nextText.length - BUFFERED_ASSISTANT_KEEP_CHARS); |
| | 250 | const spillChunk = nextText.slice(0, spillChars); |
| | 251 | const retainedTail = nextText.slice(spillChars); |
| | 252 | yield* Cache.set(bufferedAssistantTextByMessageId, messageId, retainedTail); |
| | 253 | return spillChunk; |
| | 254 | }), |
| 237 | ), | 255 | ), |
| 238 | ); | 256 | ); |
| 239 | | 257 | |
| ... | @@ -249,18 +267,51 @@ const make = Effect.gen(function* () { | ... | @@ -249,18 +267,51 @@ const make = Effect.gen(function* () { |
| 249 | const clearBufferedAssistantText = (messageId: MessageId) => | 267 | const clearBufferedAssistantText = (messageId: MessageId) => |
| 250 | Cache.invalidate(bufferedAssistantTextByMessageId, messageId); | 268 | Cache.invalidate(bufferedAssistantTextByMessageId, messageId); |
| 251 | | 269 | |
| 252 | const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId); | 270 | const markBufferedAssistantMessageSpilled = (messageId: MessageId) => |
| | 271 | Cache.set(bufferedAssistantMessageSpilledByMessageId, messageId, true); |
| | 272 | |
| | 273 | const takeBufferedAssistantMessageSpilled = (messageId: MessageId) => |
| | 274 | Cache.getOption(bufferedAssistantMessageSpilledByMessageId, messageId).pipe( |
| | 275 | Effect.flatMap((spilled) => |
| | 276 | Cache.invalidate(bufferedAssistantMessageSpilledByMessageId, messageId).pipe( |
| | 277 | Effect.as(Option.isSome(spilled)), |
| | 278 | ), |
| | 279 | ), |
| | 280 | ); |
| | 281 | |
| | 282 | const clearBufferedAssistantMessageSpilled = (messageId: MessageId) => |
| | 283 | Cache.invalidate(bufferedAssistantMessageSpilledByMessageId, messageId); |
| | 284 | |
| | 285 | const clearAssistantMessageState = (messageId: MessageId) => |
| | 286 | Effect.all( |
| | 287 | [clearBufferedAssistantText(messageId), clearBufferedAssistantMessageSpilled(messageId)], |
| | 288 | { concurrency: 1 }, |
| | 289 | ).pipe(Effect.asVoid); |
| 253 | | 290 | |
| 254 | const dispatchAssistantCompletion = (input: { | 291 | const finalizeAssistantMessage = (input: { |
| 255 | event: ProviderRuntimeEvent; | 292 | event: ProviderRuntimeEvent; |
| 256 | threadId: ThreadId; | 293 | threadId: ThreadId; |
| 257 | messageId: MessageId; | 294 | messageId: MessageId; |
| 258 | turnId?: TurnId; | 295 | turnId?: TurnId; |
| 259 | createdAt: string; | 296 | createdAt: string; |
| 260 | commandTag: string; | 297 | commandTag: string; |
| | 298 | spillRemainderCommandTag: string; |
| 261 | }) => | 299 | }) => |
| 262 | Effect.gen(function* () { | 300 | Effect.gen(function* () { |
| 263 | const text = yield* takeBufferedAssistantText(input.messageId); | 301 | const text = yield* takeBufferedAssistantText(input.messageId); |
| | 302 | const spilled = yield* takeBufferedAssistantMessageSpilled(input.messageId); |
| | 303 | |
| | 304 | if (spilled && text.length > 0) { |
| | 305 | yield* orchestrationEngine.dispatch({ |
| | 306 | type: "thread.message.assistant.delta", |
| | 307 | commandId: providerCommandId(input.event, input.spillRemainderCommandTag), |
| | 308 | threadId: input.threadId, |
| | 309 | messageId: input.messageId, |
| | 310 | delta: text, |
| | 311 | ...(input.turnId ? { turnId: input.turnId } : {}), |
| | 312 | createdAt: input.createdAt, |
| | 313 | }); |
| | 314 | } |
| 264 | | 315 | |
| 265 | yield* orchestrationEngine.dispatch({ | 316 | yield* orchestrationEngine.dispatch({ |
| 266 | type: "thread.message.assistant.complete", | 317 | type: "thread.message.assistant.complete", |
| ... | @@ -268,7 +319,7 @@ const make = Effect.gen(function* () { | ... | @@ -268,7 +319,7 @@ const make = Effect.gen(function* () { |
| 268 | threadId: input.threadId, | 319 | threadId: input.threadId, |
| 269 | messageId: input.messageId, | 320 | messageId: input.messageId, |
| 270 | ...(input.turnId ? { turnId: input.turnId } : {}), | 321 | ...(input.turnId ? { turnId: input.turnId } : {}), |
| 271 | text, | 322 | text: spilled ? "" : text, |
| 272 | createdAt: input.createdAt, | 323 | createdAt: input.createdAt, |
| 273 | }); | 324 | }); |
| 274 | yield* clearAssistantMessageState(input.messageId); | 325 | yield* clearAssistantMessageState(input.messageId); |
| ... | @@ -369,7 +420,19 @@ const make = Effect.gen(function* () { | ... | @@ -369,7 +420,19 @@ const make = Effect.gen(function* () { |
| 369 | | 420 | |
| 370 | const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); | 421 | const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); |
| 371 | if (assistantDeliveryMode === "buffered") { | 422 | if (assistantDeliveryMode === "buffered") { |
| 372 | yield* appendBufferedAssistantText(assistantMessageId, event.delta); | 423 | const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, event.delta); |
| | 424 | if (spillChunk.length > 0) { |
| | 425 | yield* markBufferedAssistantMessageSpilled(assistantMessageId); |
| | 426 | yield* orchestrationEngine.dispatch({ |
| | 427 | type: "thread.message.assistant.delta", |
| | 428 | commandId: providerCommandId(event, "assistant-delta-buffer-spill"), |
| | 429 | threadId: thread.id, |
| | 430 | messageId: assistantMessageId, |
| | 431 | delta: spillChunk, |
| | 432 | ...(turnId ? { turnId } : {}), |
| | 433 | createdAt: now, |
| | 434 | }); |
| | 435 | } |
| 373 | } else { | 436 | } else { |
| 374 | yield* orchestrationEngine.dispatch({ | 437 | yield* orchestrationEngine.dispatch({ |
| 375 | type: "thread.message.assistant.delta", | 438 | type: "thread.message.assistant.delta", |
| ... | @@ -390,13 +453,14 @@ const make = Effect.gen(function* () { | ... | @@ -390,13 +453,14 @@ const make = Effect.gen(function* () { |
| 390 | yield* rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId); | 453 | yield* rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId); |
| 391 | } | 454 | } |
| 392 | | 455 | |
| 393 | yield* dispatchAssistantCompletion({ | 456 | yield* finalizeAssistantMessage({ |
| 394 | event, | 457 | event, |
| 395 | threadId: thread.id, | 458 | threadId: thread.id, |
| 396 | messageId: assistantMessageId, | 459 | messageId: assistantMessageId, |
| 397 | ...(turnId ? { turnId } : {}), | 460 | ...(turnId ? { turnId } : {}), |
| 398 | createdAt: now, | 461 | createdAt: now, |
| 399 | commandTag: "assistant-complete", | 462 | commandTag: "assistant-complete", |
| | 463 | spillRemainderCommandTag: "assistant-delta-buffer-remainder", |
| 400 | }); | 464 | }); |
| 401 | | 465 | |
| 402 | if (turnId) { | 466 | if (turnId) { |
| ... | @@ -412,13 +476,14 @@ const make = Effect.gen(function* () { | ... | @@ -412,13 +476,14 @@ const make = Effect.gen(function* () { |
| 412 | assistantMessageIds, | 476 | assistantMessageIds, |
| 413 | (assistantMessageId) => | 477 | (assistantMessageId) => |
| 414 | Effect.gen(function* () { | 478 | Effect.gen(function* () { |
| 415 | yield* dispatchAssistantCompletion({ | 479 | yield* finalizeAssistantMessage({ |
| 416 | event, | 480 | event, |
| 417 | threadId: thread.id, | 481 | threadId: thread.id, |
| 418 | messageId: assistantMessageId, | 482 | messageId: assistantMessageId, |
| 419 | turnId, | 483 | turnId, |
| 420 | createdAt: now, | 484 | createdAt: now, |
| 421 | commandTag: "assistant-complete-finalize", | 485 | commandTag: "assistant-complete-finalize", |
| | 486 | spillRemainderCommandTag: "assistant-delta-buffer-remainder-finalize", |
| 422 | }); | 487 | }); |
| 423 | }), | 488 | }), |
| 424 | { concurrency: 1 }, | 489 | { concurrency: 1 }, |