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

363 lines
15 KiB
TypeScript

import {
AGENT_STATUS_MAX_FIELD_LENGTH,
normalizeOptionalField,
normalizePromptField
} from './agent-status-field-normalization'
import {
AGENT_JOURNAL_MESSAGE_SEND_MODES,
AGENT_JOURNAL_MESSAGE_STATES,
type AgentJournalMessageItem,
type AgentJournalMessageSendMode,
type AgentJournalRenderItem,
type AgentJournalSubmission
} from './agent-session-journal-types'
import { agentTurnVerdict, type AgentTurnOutcome } from './agent-turn-outcome'
import { agentJournalToolCallLifecycle } from './agent-journal-tool-call-lifecycle'
import { agentJournalLinkageFields } from './agent-session-journal-producer'
import { structuredAgentSessionStatusBlock } from './structured-agent-session-status-block'
import { agentJournalItemRowOrigin } from './agent-session-journal-position'
import {
AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
AGENT_STATUS_TOOL_NAME_MAX_LENGTH
} from './agent-status-types'
import { describeToolInput } from './native-chat-tool-summary'
import { statusStructuredAgentSessionToolCall } from './structured-agent-session-live-turn'
import {
hasStructuredAgentSessionRequest,
latestStructuredAgentSessionAssistantMessage,
latestStructuredAgentSessionPrompt,
latestStructuredAgentSessionRequest,
type StructuredAgentSessionLatestRequest
} from './structured-agent-session-latest-request'
import {
isStructuredAgentSessionToolAction,
structuredAgentSessionToolCallBlock
} from './structured-agent-session-tool-call-block'
import type { NativeChatBlock, NativeChatMessage } from './native-chat-types'
import { sha256 } from './sha256'
import { readAgentMessageSource } from './agent-session-message-source'
import { structuredAgentSessionStatusStartedAt } from './structured-agent-session-status-started-at'
import { owesStructuredAgentSessionWork } from './structured-agent-session-owed-work'
import type { StructuredAgentSessionWorkScope } from './structured-agent-session-main-agent-working'
import { agentSessionCurrentContextRows } from './agent-session-context-clear'
// Re-exported so the live-turn readers' and the unanswered-send rule's existing consumers keep one
// import site.
export {
activeStructuredAgentSessionTurnId,
newestStructuredAgentSessionTurn
} from './structured-agent-session-live-turn'
export { hasUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch'
/** A tool's output as its result block. Only a cut the sent copy made has more behind it; a head an
* older build saved has none, so only that cut names the item to fetch. */
function toolResult(
item: AgentJournalRenderItem,
payload: { head: string; truncated: boolean; byteLength: number },
callId: string | undefined
) {
return {
type: 'tool-result' as const,
output: payload.truncated ? `${payload.head}\n… (${payload.byteLength} bytes)` : payload.head,
callId,
...(item.shortened && payload.truncated ? { fullOutputItemId: item.itemId } : {})
}
}
/** The markers a clipped payload carries in its own text, anchored to the end
* so nothing that merely looks like one inside the body can match. */
const BOUNDED_TEXT_MARKERS = [
/\n… \(\d+ bytes\)$/,
/\n\[Orca: output truncated — \d+ bytes total, digest [0-9a-f]+\]$/
]
/** Recovers the clipped body from a bounded payload's text, and says whether a
* marker was there. A reader that treats the text as content renders the
* marker as a line of it — with a line number, which reads as a real position
* in the file — and reports the body as complete. */
export function stripBoundedTextMarker(text: string): { text: string; truncated: boolean } {
const stripped = BOUNDED_TEXT_MARKERS.reduce((value, marker) => value.replace(marker, ''), text)
return { text: stripped, truncated: stripped.length !== text.length }
}
function itemBlocks(item: AgentJournalRenderItem): {
role: NativeChatMessage['role']
blocks: NativeChatBlock[]
} | null {
const body = item.body
if (body.kind === 'message') {
return { role: body.role, blocks: body.blocks }
}
if (isStructuredAgentSessionToolAction(body)) {
const call = structuredAgentSessionToolCallBlock(body, item.itemId)
// The call and its output are one journal row, so the result names its call.
const { callId } = call
if (body.kind === 'diff') {
return { role: 'assistant', blocks: [call, toolResult(item, body.patch, callId)] }
}
return {
role: 'assistant',
blocks: [
call,
...(body.output
? [
{
...toolResult(item, body.output, callId),
// Output a call left when it was cut short is not an error it reported.
isError: agentJournalToolCallLifecycle(body) === 'failed'
}
]
: [])
]
}
}
if (body.kind === 'approval') {
if (body.resolution.state === 'pending') {
return null
}
return {
role: 'system',
blocks: [
{
type: 'text',
text: `${body.title}\n${body.detail ?? ''}\n${body.resolution.state}`.trim()
}
]
}
}
if (body.kind === 'question') {
if (body.resolution.state === 'pending') {
return null
}
const choices = body.options.map((option) => option.label).join(' · ')
return {
role: 'system',
blocks: [{ type: 'text', text: `${body.question}\n${choices}`.trim() }]
}
}
// A turn record is timing, not content; a kind this build does not know is
// never painted as text either, so a newer host can add kinds freely.
if (body.kind !== 'status' || body.turnLifecycle) {
return null
}
return {
role: 'system',
blocks: [structuredAgentSessionStatusBlock(body, item.turnScope, item.itemId)]
}
}
function isAgentJournalMessageSendMode(value: string): value is AgentJournalMessageSendMode {
return AGENT_JOURNAL_MESSAGE_SEND_MODES.some((mode) => mode === value)
}
/** A state this build cannot name reads as completed: a newer host's row is never live here. */
function messageLifecycle(
body: AgentJournalMessageItem
): Pick<NativeChatMessage, 'state' | 'completedAt'> {
const state: string | undefined = body.state
if (state === undefined) {
return {}
}
return {
state: AGENT_JOURNAL_MESSAGE_STATES.find((known) => known === state) ?? 'completed',
...(body.completedAt !== undefined ? { completedAt: body.completedAt } : {})
}
}
const projectedItems = new WeakMap<AgentJournalRenderItem, NativeChatMessage | null>()
/** Deliberately NOT scoped by producer: every agent's rows are projected, and
* each message keeps its row's linkage so the transcript can keep a subagent's
* rows with that subagent. Every "what is this agent doing right now" scan
* renders only the session's own agent's. */
export function projectStructuredItemsToNativeChat(
items: readonly AgentJournalRenderItem[]
): NativeChatMessage[] {
const messages: NativeChatMessage[] = []
items.forEach((item) => {
const projected = projectStructuredItemToNativeChat(item)
if (projected) {
messages.push(projected)
}
})
return messages
}
export function projectStructuredItemToNativeChat(
item: AgentJournalRenderItem
): NativeChatMessage | null {
const cached = projectedItems.get(item)
if (cached !== undefined) {
return cached
}
// Reducer updates replace journal items, so unchanged rows keep their render caches.
const projected = itemBlocks(item)
const sentAs = item.body.kind === 'message' ? item.body.sentAs : undefined
const lifecycle = item.body.kind === 'message' ? messageLifecycle(item.body) : {}
const command = item.body.kind === 'message' ? item.body.command : undefined
// Read here too: a client's journal comes off the wire, from a host of any version.
const from = item.body.kind === 'message' ? readAgentMessageSource(item.body.from) : undefined
const message: NativeChatMessage | null = projected
? {
...agentJournalItemRowOrigin(item),
...agentJournalLinkageFields(item),
role: projected.role,
blocks: projected.blocks,
// A send mode this build cannot name renders as an ordinary message.
...(sentAs !== undefined && isAgentJournalMessageSendMode(sentAs) ? { sentAs } : {}),
...lifecycle,
...(command ? { command } : {}),
...(from && projected.role === 'user' ? { from } : {}),
...(item.shortened && item.body.kind === 'message' ? { shortened: true } : {})
}
: null
projectedItems.set(item, message)
return message
}
export type StructuredAgentSessionProjectedStatus = 'working' | 'attention' | 'idle'
export function structuredAgentSessionTabId(sessionId: string): string {
return `structured-agent-session-${sessionId}`
}
/** A prompt waiting on the user, unless the host's scope says an ended generation raised it:
* nothing can take its answer. */
function pendingStructuredAgentSessionPrompt(
scope?: StructuredAgentSessionWorkScope
): (item: AgentJournalRenderItem) => boolean {
return (item) =>
(item.body.kind === 'approval' || item.body.kind === 'question') &&
item.body.resolution.state === 'pending' &&
(!scope || scope.isCurrentItem(item.itemId))
}
/** `scope`: a host's (`StructuredAgentSessionWorkScope`), so work an ended generation left neither
* holds nor reads as Working. */
export function projectStructuredAgentSessionStatus(
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[] = [],
currentFence?: number | null,
scope?: StructuredAgentSessionWorkScope
): StructuredAgentSessionProjectedStatus {
;({ items, submissions } = agentSessionCurrentContextRows(items, submissions))
if (items.some(pendingStructuredAgentSessionPrompt(scope))) {
return 'attention'
}
return owesStructuredAgentSessionWork(items, submissions, currentFence, scope)
? 'working'
: 'idle'
}
/** The activity fields a sidebar row shows beside the prompt, named as the agent-status
* entry names them so the client can hand them straight to a row. */
export type StructuredAgentSessionStatusProjection = {
status: StructuredAgentSessionProjectedStatus | null
latestPrompt: string
/** Present only while a turn is running — see showsAgentToolPreview, which reads
* these on any state that carries them. */
toolName?: string
toolInput?: string
lastAssistantMessage?: string
/** The latest request's verdict: its turn's, or `failure` for a send the agent or its start
* refused. A turn the provider gave none reads as its host-observed end. Present only while
* `status` is idle. */
turnOutcome?: AgentTurnOutcome
statusStartedAt?: number
}
/** One projection shared by host and client: null status means "no turn yet", not idle.
* Every text field is bounded to the same preview an agent-status row carries — a send
* admits 256 KB, and one status frame carries every retained session at once. The
* assistant line is bounded harder than the hook field it stands in for (a preview, not
* the 8 KB body): a streamed reply re-projects on every journal checkpoint, so the frame
* has to stay small even though the row only ever renders one line of it. */
export function projectStructuredAgentSessionStatusSummary(
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[] = [],
currentFence?: number | null
): StructuredAgentSessionStatusProjection {
return projectStructuredAgentSessionStatusState(items, submissions, currentFence).summary
}
/** The summary plus the latest request it was read from, whatever the status, so the host's
* completion feed follows the same request the row reports without scanning again. */
export function projectStructuredAgentSessionStatusState(
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[] = [],
currentFence?: number | null,
scope?: StructuredAgentSessionWorkScope
): {
summary: StructuredAgentSessionStatusProjection
latestRequest: StructuredAgentSessionLatestRequest | null
/** Whether a running turn or an unanswered send is still owed, even beneath a pending prompt. */
owesWork: boolean
/** Item ids of the approvals and questions waiting on the user: what makes the status `attention`. */
pendingPromptIds: string[]
} {
const latestPrompt = latestStructuredAgentSessionPrompt(items)
if (!hasStructuredAgentSessionRequest(items, submissions, currentFence)) {
return {
summary: { status: null, latestPrompt: '' },
latestRequest: null,
owesWork: false,
pendingPromptIds: []
}
}
;({ items, submissions } = agentSessionCurrentContextRows(items, submissions))
const status = projectStructuredAgentSessionStatus(items, submissions, currentFence, scope)
const statusToolCall = status === 'working' ? statusStructuredAgentSessionToolCall(items) : null
const toolName = statusToolCall
? normalizeOptionalField(statusToolCall.name, AGENT_STATUS_TOOL_NAME_MAX_LENGTH)
: undefined
const toolInput = statusToolCall
? normalizeOptionalField(
describeToolInput(statusToolCall.input),
AGENT_STATUS_TOOL_INPUT_MAX_LENGTH
)
: undefined
const lastAssistantMessage = normalizeOptionalField(
latestStructuredAgentSessionAssistantMessage(items),
AGENT_STATUS_MAX_FIELD_LENGTH
)
const latestRequest = latestStructuredAgentSessionRequest(items, submissions)
// A verdict is a fact about a finished request: only an idle session has one to report.
const request = status === 'idle' ? latestRequest : null
const turnOutcome = request
? agentTurnVerdict({ state: request.turnState, outcome: request.outcome })
: null
const statusStartedAt = structuredAgentSessionStatusStartedAt(
status,
items,
submissions,
currentFence,
request
)
return {
latestRequest,
owesWork:
status !== 'idle' && owesStructuredAgentSessionWork(items, submissions, currentFence, scope),
pendingPromptIds:
status === 'attention'
? items.filter(pendingStructuredAgentSessionPrompt(scope)).map((item) => item.itemId)
: [],
summary: {
status,
latestPrompt: normalizePromptField(latestPrompt),
...(toolName ? { toolName } : {}),
...(toolInput ? { toolInput } : {}),
...(lastAssistantMessage ? { lastAssistantMessage } : {}),
...(turnOutcome ? { turnOutcome } : {}),
...(statusStartedAt !== undefined ? { statusStartedAt } : {})
}
}
}
export function structuredAgentSessionPaneKey(tabId: string, sessionId: string): string {
const bytes = sha256(new TextEncoder().encode(sessionId))
const hex = Array.from(bytes.slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join('')
const leaf = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`
return `${tabId}:${leaf}`
}