Files
orca/src/shared/structured-agent-session-message-projection.ts
T

232 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
AgentJournalCursor,
AgentJournalMessageItem,
AgentJournalRenderItem,
AgentJournalSubmission
} from './agent-session-journal-types'
import { agentJournalSubmissionKey } from './agent-session-journal-item-key'
import { selectAgentSessionCurrentRequests } from './agent-session-current-requests'
import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission'
import { collapseProviderRetryRuns } from './native-chat/native-chat-provider-retry-runs'
import {
keepStoppedSendsInSendOrder,
placeStoppedSends,
withStopRowsAfterStoppedSends
} from './native-chat/native-chat-stopped-before-start'
import { compareNativeChatTranscriptMessages } from './native-chat-transcript-projection'
import type { NativeChatMessage } from './native-chat-types'
import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection'
import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection'
import { isActionableStructuredAgentSessionPrompt } from './structured-agent-session-live-turn'
import {
agentSessionContextSequenceFor,
isAgentSessionContextClear
} from './agent-session-context-clear'
/** Client echo until host publication; an acknowledged read past recordedCursor supersedes it. */
export type StructuredAgentSessionOptimisticMessage = {
clientMessageId: string
body: AgentJournalMessageItem
queuedAt: number
recordedCursor?: AgentJournalCursor
/** Sent while the chat read Stopping: drawn after the turn being stopped. */
sentWhileStopping?: true
}
export function structuredAgentSessionCommandItemIds(
items: readonly AgentJournalRenderItem[]
): Set<string> {
return new Set(
items
.filter((item) => item.body.kind === 'message' && item.body.command)
.map((item) => item.itemId)
)
}
/** Rejected sends shown as unsent in submission order; withdrawals use stop notices, queued handoffs/cards keep their cards,
* and commands carry rejection in their own reply. */
export function structuredAgentSessionRejectedShownInPlace(
submissions: readonly AgentJournalSubmission[],
commandItemIds: ReadonlySet<string>,
clearSequences: readonly number[] = []
): Set<string> {
const latest = new Map<string, number>()
const later = Array.from<number | undefined>({ length: submissions.length })
const scopeKey = (submission: AgentJournalSubmission) =>
JSON.stringify([
agentSessionContextSequenceFor(submission.acceptedSequence ?? 0, clearSequences),
submission.payloadFingerprint
])
for (let index = submissions.length - 1; index >= 0; index--) {
const submission = submissions[index]
const key = scopeKey(submission)
later[index] = latest.get(key)
if (!dispatchWasWithdrawn(submission)) {
latest.set(key, Math.max(latest.get(key) ?? -Infinity, submission.submittedAt))
}
}
const shown = new Set<string>()
for (const [index, submission] of submissions.entries()) {
const { resolvedAt } = submission
if (
submission.dispatchState !== 'rejected' ||
dispatchWasWithdrawn(submission) ||
submission.queuedMessageId !== undefined ||
// Persist card ownership on the send so editing/deleting the card cannot resurrect its row.
submission.keptAsQueuedMessageId !== undefined ||
commandItemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) ||
// Collapses resends of a rejected message: past Retries resent it under a new id, and the
// host re-delivers its own messages under new ids. Only a later copy sent once the rejection
// was known counts, so a repeat sent before it is kept.
(resolvedAt !== null && later[index] !== undefined && later[index] >= resolvedAt)
) {
continue
}
shown.add(agentJournalSubmissionKey(submission.clientMessageId))
}
return shown
}
export function projectStructuredAgentSessionMessages(
items: readonly AgentJournalRenderItem[],
optimistic: readonly StructuredAgentSessionOptimisticMessage[],
submissions: readonly AgentJournalSubmission[],
projectItems = projectStructuredItemsToNativeChat
): NativeChatMessage[] {
// Keep stopped-before-start sends in conversation history; queued handoffs stay in their cards.
const stoppedBeforeStart = new Map(
submissions
.filter(
(submission) => dispatchWasWithdrawn(submission) && submission.queuedMessageId === undefined
)
.map((submission) => [agentJournalSubmissionKey(submission.clientMessageId), submission])
)
// Other rejections are ledger-only unless explicitly shown in place as unsent.
const rejected = new Set(
submissions
.filter((submission) => submission.dispatchState === 'rejected')
.map((submission) => agentJournalSubmissionKey(submission.clientMessageId))
.filter((itemId) => !stoppedBeforeStart.has(itemId))
)
// Avoid placement work on every stream commit when no stopped sends exist.
const placement =
stoppedBeforeStart.size > 0 ? placeStoppedSends(items, submissions, stoppedBeforeStart) : null
const inPlace = structuredAgentSessionRejectedShownInPlace(
submissions,
structuredAgentSessionCommandItemIds(items),
items.filter((item) => isAgentSessionContextClear(item.body)).map((item) => item.sequence)
)
const visibleItems: AgentJournalRenderItem[] = []
const unsentItems: AgentJournalRenderItem[] = []
for (const item of items) {
if (inPlace.has(item.itemId)) {
unsentItems.push(item)
} else if (!rejected.has(item.itemId)) {
visibleItems.push(item)
}
}
const journalled = new Set(visibleItems.map((item) => item.itemId))
// Until handover, a queued send has no conversation position, even relative to commands it waits behind.
const queued = new Set(
submissions
.filter(isQueuedAgentJournalSubmission)
.map((submission) => agentJournalSubmissionKey(submission.clientMessageId))
)
const delivered: NativeChatMessage[] = []
const held: NativeChatMessage[] = []
const shownStopped = new Set<string>()
let moved = false
for (const message of projectItems(visibleItems)) {
if (queued.has(message.id)) {
held.push({ ...message, queued: true })
} else if (!placement || !stoppedBeforeStart.has(message.id)) {
delivered.push(message)
} else {
const { opensTurn, position } = placement(message.id)
moved ||= position !== undefined
// An opener’s interrupted turn already reports its stop; do not add another notice.
if (!opensTurn) {
shownStopped.add(message.id)
}
delivered.push({
...message,
stoppedBeforeStart: true,
...(position ? { journalPosition: position } : {})
})
}
}
if (shownStopped.size > 0) {
moved = keepStoppedSendsInSendOrder(delivered, stoppedBeforeStart, shownStopped) || moved
}
const ordered = moved
? Array.from(collapseProviderRetryRuns(delivered)).sort(compareNativeChatTranscriptMessages)
: collapseProviderRetryRuns(delivered)
const conversation =
shownStopped.size > 0 ? withStopRowsAfterStoppedSends(ordered, shownStopped) : ordered
return [
// Merge unsent rows at journal positions after stop grouping so each run retains one stop notice.
...(unsentItems.length > 0
? withRowsAtJournalPlaces(
conversation,
projectItems(unsentItems).map((message) => ({ ...message, unsent: true as const }))
)
: conversation),
// Held cards follow the conversation outside its runs.
...held,
// Host rows supersede client echoes under either client or provider IDs.
...optimistic
.filter(
(entry) =>
!journalled.has(agentJournalSubmissionKey(entry.clientMessageId)) &&
!submissions.some((submission) => submission.clientMessageId === entry.clientMessageId)
)
.map((entry): NativeChatMessage => ({
id: agentJournalSubmissionKey(entry.clientMessageId),
role: 'user',
source: 'transcript',
timestamp: entry.queuedAt,
blocks: entry.body.blocks,
...(entry.sentWhileStopping ? { sentWhileStopping: true as const } : {})
}))
]
}
/** Merge placed rows in journal order so clients rendering the list directly need no sort. */
function withRowsAtJournalPlaces(
rows: readonly NativeChatMessage[],
placed: NativeChatMessage[]
): NativeChatMessage[] {
placed.sort(compareNativeChatTranscriptMessages)
const merged: NativeChatMessage[] = []
let next = 0
for (const row of rows) {
while (next < placed.length && compareNativeChatTranscriptMessages(placed[next], row) < 0) {
merged.push(placed[next++])
}
merged.push(row)
}
return merged.concat(placed.slice(next))
}
export type StructuredPromptItem = AgentJournalRenderItem & {
body: Extract<AgentJournalRenderItem['body'], { kind: 'approval' | 'question' }>
}
/** Offer pending prompts, respecting the host’s actionable-wait declaration when available. */
export function pendingStructuredSessionPrompts({
journalItems,
currentRequests,
actionablePromptIds
}: {
journalItems: readonly AgentJournalRenderItem[]
currentRequests?: readonly AgentJournalRenderItem[]
actionablePromptIds?: readonly string[]
}): StructuredPromptItem[] {
return selectAgentSessionCurrentRequests({ items: journalItems, currentRequests }).filter(
(item): item is StructuredPromptItem =>
(item.body.kind === 'approval' || item.body.kind === 'question') &&
item.body.resolution.state === 'pending' &&
isActionableStructuredAgentSessionPrompt(item.itemId, actionablePromptIds)
)
}