mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
363 lines
15 KiB
TypeScript
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}`
|
|
}
|