|
|
|
|
@@ -8,16 +8,16 @@ import { mkdir, stat } from 'node:fs/promises'
|
|
|
|
|
import { join } from 'node:path'
|
|
|
|
|
import type { Context } from 'cordis'
|
|
|
|
|
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import type {
|
|
|
|
|
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus,
|
|
|
|
|
} from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import { errorChain } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
|
|
|
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
|
|
|
|
|
import { SubagentError } from '@deepseek-ai/dsh-subagent'
|
|
|
|
|
import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
|
|
|
|
|
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
|
|
|
|
import {
|
|
|
|
|
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
|
|
|
|
|
@@ -28,7 +28,8 @@ import type {} from '@deepseek-ai/dsh-tools'
|
|
|
|
|
import type {
|
|
|
|
|
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
|
|
|
|
|
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
|
|
|
|
|
SessionSummary, SettingsNamespaceView, ToolEventView, WorkspaceId, WorkspaceView,
|
|
|
|
|
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
|
|
|
|
|
WorkspaceId, WorkspaceView,
|
|
|
|
|
} from './api/index.ts'
|
|
|
|
|
import {
|
|
|
|
|
SESSION_SEARCH_RESULT_LIMIT,
|
|
|
|
|
@@ -270,6 +271,19 @@ function sessionBlank(session: Session): boolean {
|
|
|
|
|
return !session.events.some(event => event.type === 'turn/start')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Shared Session-header projection for list baselines and creation frames. */
|
|
|
|
|
function sessionListFields(header: SessionHeader): {
|
|
|
|
|
parentSessionId?: SessionId
|
|
|
|
|
origin?: 'subagent'
|
|
|
|
|
cwd?: string
|
|
|
|
|
} {
|
|
|
|
|
return {
|
|
|
|
|
...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession },
|
|
|
|
|
...header.origin === undefined ? {} : { origin: header.origin },
|
|
|
|
|
...header.cwd === undefined ? {} : { cwd: header.cwd },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** SessionSummary projection for attached (in-memory) sessions. */
|
|
|
|
|
function summarize(session: Session, running: boolean): SessionSummary {
|
|
|
|
|
return {
|
|
|
|
|
@@ -279,8 +293,7 @@ function summarize(session: Session, running: boolean): SessionSummary {
|
|
|
|
|
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
|
|
|
|
|
running,
|
|
|
|
|
blank: sessionBlank(session),
|
|
|
|
|
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
|
|
|
|
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
|
|
|
|
|
...sessionListFields(session.header),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -315,6 +328,7 @@ async function summarizeCold(
|
|
|
|
|
// cold session is served as not-blank (its log holds its conversation).
|
|
|
|
|
blank: false,
|
|
|
|
|
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
|
|
|
|
|
...meta.origin === undefined ? {} : { origin: meta.origin },
|
|
|
|
|
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
|
|
|
|
|
filters those out (legacy logs are not served); the conditional mirrors
|
|
|
|
|
summarize() shape. */
|
|
|
|
|
@@ -462,6 +476,23 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Render one detached history page through the same presenter path as ordinary history. */
|
|
|
|
|
function historyPage(
|
|
|
|
|
ctx: Context,
|
|
|
|
|
events: readonly SessionEvent[],
|
|
|
|
|
beforeSeq: number | undefined,
|
|
|
|
|
maxMessages: number | undefined,
|
|
|
|
|
): { events: HistoryEntry[]; hasMore: boolean } {
|
|
|
|
|
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
|
|
|
|
|
return {
|
|
|
|
|
events: page.events.map((event) => {
|
|
|
|
|
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
|
|
|
|
|
return { event, ...view === undefined ? {} : { view } }
|
|
|
|
|
}),
|
|
|
|
|
hasMore: page.hasMore,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The projection baseline for one history tail page: the registry's
|
|
|
|
|
* watermark-cache snapshot — one fully synchronous read (no await between the
|
|
|
|
|
@@ -471,10 +502,10 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
|
|
|
|
|
* registry). An absent registry means the deployment has no projection seam:
|
|
|
|
|
* the whole block is absent and clients treat every key as capability-absent.
|
|
|
|
|
*/
|
|
|
|
|
function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | undefined {
|
|
|
|
|
function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined {
|
|
|
|
|
const registry = ctx.get('sessionProjections')
|
|
|
|
|
if (registry === undefined) return undefined
|
|
|
|
|
return registry.snapshot(agent.session)
|
|
|
|
|
return registry.snapshot(session)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@@ -499,12 +530,120 @@ function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Projection baseline for a detached history tail without Agent activation. */
|
|
|
|
|
function detachedProjectionsFor(
|
|
|
|
|
ctx: Context,
|
|
|
|
|
events: readonly SessionEvent[],
|
|
|
|
|
): SessionProjectionsBlock | undefined {
|
|
|
|
|
const registry = ctx.get('sessionProjections')
|
|
|
|
|
if (registry === undefined) return undefined
|
|
|
|
|
return registry.restore({}, events, 0).snapshot
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Map continuation admission failures without exposing provider details. */
|
|
|
|
|
function subagentPromptError(
|
|
|
|
|
request: RpcRequest<{ childSessionId: SessionId }>,
|
|
|
|
|
error: unknown,
|
|
|
|
|
signal: AbortSignal,
|
|
|
|
|
): RpcResponse<never> {
|
|
|
|
|
const childSessionId = request.payload.childSessionId
|
|
|
|
|
if (signal.aborted) {
|
|
|
|
|
return err(request, { code: 'cancelled', message: 'subagent prompt was cancelled', details: {} })
|
|
|
|
|
}
|
|
|
|
|
if (error instanceof SubagentError) {
|
|
|
|
|
switch (error.code) {
|
|
|
|
|
case 'NOT_RESUMABLE':
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'subagent-not-resumable',
|
|
|
|
|
message: 'subagent cannot be resumed',
|
|
|
|
|
details: { childSessionId },
|
|
|
|
|
})
|
|
|
|
|
case 'UNAUTHORIZED':
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'subagent-unauthorized',
|
|
|
|
|
message: 'subagent does not belong to this parent',
|
|
|
|
|
details: { childSessionId },
|
|
|
|
|
})
|
|
|
|
|
case 'DRAINING':
|
|
|
|
|
case 'ACTIVATION_CLOSING':
|
|
|
|
|
case 'CONTINUATION_UNAVAILABLE':
|
|
|
|
|
case 'PERSISTENCE_UNAVAILABLE':
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'subagent-delivery-unavailable',
|
|
|
|
|
message: 'subagent follow-up is temporarily unavailable',
|
|
|
|
|
details: { childSessionId },
|
|
|
|
|
})
|
|
|
|
|
default:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Verify one address and mode against the complete direct-child catalog. */
|
|
|
|
|
async function catalogChild(
|
|
|
|
|
ctx: Context,
|
|
|
|
|
address: SubagentAddress,
|
|
|
|
|
signal?: AbortSignal,
|
|
|
|
|
): Promise<{
|
|
|
|
|
entry?: Extract<CatalogSubagentListEntry, { kind: 'child' }>
|
|
|
|
|
error?: RpcError
|
|
|
|
|
}> {
|
|
|
|
|
const { parentSessionId, childSessionId, mode } = address
|
|
|
|
|
try {
|
|
|
|
|
const entries = await ctx.subagents.listChildren(parentSessionId, signal)
|
|
|
|
|
const entry = entries.find(candidate => candidate.id === childSessionId)
|
|
|
|
|
if (entry === undefined || (entry.kind === 'child' && entry.mode !== mode)) {
|
|
|
|
|
return {
|
|
|
|
|
error: {
|
|
|
|
|
code: 'subagent-not-found',
|
|
|
|
|
message: `session "${childSessionId}" is not a ${mode} direct child of "${parentSessionId}"`,
|
|
|
|
|
details: { parentSessionId, childSessionId },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (entry.kind === 'diagnostic') {
|
|
|
|
|
return {
|
|
|
|
|
error: {
|
|
|
|
|
code: 'subagent-catalog-diagnostic',
|
|
|
|
|
message: `subagent "${childSessionId}" is ${entry.reason}`,
|
|
|
|
|
details: { parentSessionId, childSessionId, reason: entry.reason },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { entry }
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (signal?.aborted
|
|
|
|
|
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|
|
|
|
|
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
|
|
|
|
|
return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } }
|
|
|
|
|
}
|
|
|
|
|
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
|
|
|
|
|
return {
|
|
|
|
|
error: {
|
|
|
|
|
code: 'subagent-not-found',
|
|
|
|
|
message: `parent session "${parentSessionId}" was not found`,
|
|
|
|
|
details: { parentSessionId, childSessionId },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Thrown by the cold-resume path when the id names no servable session
|
|
|
|
|
* (absent from the store, or a pre-project legacy log without a cwd).
|
|
|
|
|
*/
|
|
|
|
|
class SessionNotFound extends Error {}
|
|
|
|
|
|
|
|
|
|
/** Session identity whose lifecycle belongs to subagent routing, not generic Host resume. */
|
|
|
|
|
class SubagentSessionOwnership extends Error {
|
|
|
|
|
constructor(readonly sessionId: SessionId) {
|
|
|
|
|
super(`session "${sessionId}" is a subagent session; use subagent delivery`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Requested identity already belongs to a session with another project cwd. */
|
|
|
|
|
class SessionCwdConflict extends Error {
|
|
|
|
|
constructor(
|
|
|
|
|
@@ -634,6 +773,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/** Project both durable inbox lists, optionally including the splice currently being emitted. */
|
|
|
|
|
const queueItems = (
|
|
|
|
|
agent: Agent,
|
|
|
|
|
splice?: SessionEventMap['agent/inbox/spliced'],
|
|
|
|
|
): QueuedInboxItem[] => {
|
|
|
|
|
const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => {
|
|
|
|
|
const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep
|
|
|
|
|
return splice?.target === target
|
|
|
|
|
? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted)
|
|
|
|
|
: messages
|
|
|
|
|
}
|
|
|
|
|
return [
|
|
|
|
|
...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, message })),
|
|
|
|
|
...project('next-step').map(message => ({ id: message.id, placement: 'steering' as const, message })),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.on('session/event', (session, event) => {
|
|
|
|
|
if (event.type !== 'agent/inbox/spliced') return
|
|
|
|
|
const agent = ctx.agents.get(session.id)
|
|
|
|
|
if (agent?.session !== session) return
|
|
|
|
|
broadcast({ type: 'session/queue', sessionId: session.id, items: queueItems(agent, event.data) })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
|
|
|
|
|
function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
|
|
|
|
|
pendingQuestions.delete(pending.rpcId)
|
|
|
|
|
@@ -768,28 +931,91 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Whether the session's own suffix carries the durable subagent discriminator. */
|
|
|
|
|
function hasSubagentDescriptor(session: Pick<Session, 'events' | 'header'>): boolean {
|
|
|
|
|
const events = session.events
|
|
|
|
|
// Indexed scan from the own-suffix start: slicing copies the whole suffix
|
|
|
|
|
// on every Agent-bound RPC, including each `session.prompt` on long
|
|
|
|
|
// transcripts.
|
|
|
|
|
for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) {
|
|
|
|
|
if (events[index]?.type === 'subagent/descriptor') return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Gate the cold path on the store: an id absent from it, or naming a legacy
|
|
|
|
|
* log without a cwd (pre-release stance: not served, no compatibility), is
|
|
|
|
|
* not-found before any resume is attempted. With the gate passed, a later
|
|
|
|
|
* resume failure is genuinely internal. No persistence configured skips the
|
|
|
|
|
* gate — resume itself then fails loud with its own diagnostic.
|
|
|
|
|
* Generic Host interaction cannot claim a durably classified subagent or an
|
|
|
|
|
* Agent created through its live parent. The runtime-owner arm also covers
|
|
|
|
|
* descriptor-less child publication windows and older stored headers.
|
|
|
|
|
*/
|
|
|
|
|
async function assertServable(sessionId: SessionId): Promise<void> {
|
|
|
|
|
function hasSubagentOwner(
|
|
|
|
|
session: Pick<Session, 'events' | 'header'>,
|
|
|
|
|
agent: Agent | undefined,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (session.header.origin === 'subagent' || hasSubagentDescriptor(session)) return true
|
|
|
|
|
const parentId = session.header.parentSession
|
|
|
|
|
if (parentId === undefined || agent === undefined) return false
|
|
|
|
|
const parent = ctx.agents.get(parentId)
|
|
|
|
|
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Stable generic-Host error for an identity reserved to subagent routing. */
|
|
|
|
|
function subagentOwnershipError(sessionId: SessionId): RpcError {
|
|
|
|
|
return {
|
|
|
|
|
code: 'agent-busy',
|
|
|
|
|
message: `session "${sessionId}" is owned by subagent routing`,
|
|
|
|
|
details: { reason: 'use subagent delivery for this child session' },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Inspect one cold served session without repairing, resuming, or publishing it. */
|
|
|
|
|
async function inspectServable(sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
|
|
|
|
const persistence = ctx.get('sessionPersistence')
|
|
|
|
|
if (persistence === undefined) return
|
|
|
|
|
if (persistence === undefined) {
|
|
|
|
|
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
|
|
|
|
|
}
|
|
|
|
|
const meta = (await persistence.list()).find(m => m.id === sessionId)
|
|
|
|
|
if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
|
|
|
|
|
const inspected = await persistence.inspect(sessionId)
|
|
|
|
|
if (inspected.meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
|
|
|
|
|
return inspected
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve one live registered identity through the subagent-ownership
|
|
|
|
|
* fence: subagent-owned agents answer `agent-busy`, plain agents pass.
|
|
|
|
|
* Fences the live agent's own session rather than trusting a
|
|
|
|
|
* "registered ⇒ attached-store" invariant — a registered subagent whose
|
|
|
|
|
* session is ever absent from the attached store must still not be handed
|
|
|
|
|
* out through generic Host routing. `undefined` means no live agent.
|
|
|
|
|
*/
|
|
|
|
|
function fencedLiveAgent(sessionId: SessionId): { agent: Agent } | { error: RpcError } | undefined {
|
|
|
|
|
const live = ctx.agents.get(sessionId)
|
|
|
|
|
if (live === undefined) return undefined
|
|
|
|
|
if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) }
|
|
|
|
|
return { agent: live }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
|
|
|
|
|
const live = ctx.agents.get(sessionId)
|
|
|
|
|
if (live !== undefined) return { agent: live }
|
|
|
|
|
const fenced = fencedLiveAgent(sessionId)
|
|
|
|
|
if (fenced !== undefined) return fenced
|
|
|
|
|
const attached = ctx.sessions.get(sessionId)
|
|
|
|
|
if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
|
|
|
|
|
return { error: subagentOwnershipError(sessionId) }
|
|
|
|
|
}
|
|
|
|
|
let resume = resumes.get(sessionId)
|
|
|
|
|
if (resume === undefined) {
|
|
|
|
|
resume = (async () => {
|
|
|
|
|
try {
|
|
|
|
|
await assertServable(sessionId)
|
|
|
|
|
const inspected = await inspectServable(sessionId)
|
|
|
|
|
if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
|
|
|
|
|
throw new SubagentSessionOwnership(sessionId)
|
|
|
|
|
}
|
|
|
|
|
const publishedSession = ctx.sessions.get(sessionId)
|
|
|
|
|
const publishedAgent = ctx.agents.get(sessionId)
|
|
|
|
|
if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) {
|
|
|
|
|
throw new SubagentSessionOwnership(sessionId)
|
|
|
|
|
}
|
|
|
|
|
const handle = await ctx.agents.resume({
|
|
|
|
|
resumeSessionId: sessionId,
|
|
|
|
|
agentOptions,
|
|
|
|
|
@@ -808,26 +1034,104 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
if (error instanceof SessionNotFound) {
|
|
|
|
|
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
|
|
|
|
|
}
|
|
|
|
|
if (error instanceof SubagentSessionOwnership) {
|
|
|
|
|
return { error: subagentOwnershipError(error.sessionId) }
|
|
|
|
|
}
|
|
|
|
|
// A concurrent publish can win the identity between the pre-resume
|
|
|
|
|
// re-check and `ctx.agents.resume` publication; the ID-collision
|
|
|
|
|
// rejection falls through here. Mirror ensureSession's `.catch` in
|
|
|
|
|
// full: classify a subagent-owned winner into the stable ownership
|
|
|
|
|
// error, and hand a clean plain-agent winner straight back.
|
|
|
|
|
const fenced = fencedLiveAgent(sessionId)
|
|
|
|
|
if (fenced !== undefined) return fenced
|
|
|
|
|
const attached = ctx.sessions.get(sessionId)
|
|
|
|
|
if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
|
|
|
|
|
return { error: subagentOwnershipError(sessionId) }
|
|
|
|
|
}
|
|
|
|
|
// The internal details slot is contractually {}; the reason rides the message.
|
|
|
|
|
return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SessionReadState = {
|
|
|
|
|
id: SessionId
|
|
|
|
|
header: SessionHeader
|
|
|
|
|
events: SessionEvent[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Read one stable session prefix without acquiring an Agent owner. */
|
|
|
|
|
async function readSessionState(sessionId: SessionId): Promise<SessionReadState> {
|
|
|
|
|
const attached = ctx.sessions.get(sessionId)
|
|
|
|
|
if (attached !== undefined) {
|
|
|
|
|
return {
|
|
|
|
|
id: attached.id,
|
|
|
|
|
header: attached.header,
|
|
|
|
|
events: [...attached.events],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const inspected = await inspectServable(sessionId)
|
|
|
|
|
return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Resolve the Workspace inherited by a fork without making ordinary loose lineage grouped. */
|
|
|
|
|
async function forkWorkspace(source: Pick<Session, 'id' | 'header'>): Promise<Workspace | undefined> {
|
|
|
|
|
const workspaces = ctx.workspace.list()
|
|
|
|
|
const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
|
|
|
|
|
if (direct !== undefined || source.header.origin !== 'subagent') return direct
|
|
|
|
|
|
|
|
|
|
const lineage = await ctx.sessionQuery.traceSession(source.id)
|
|
|
|
|
for (const ancestor of lineage.ancestors) {
|
|
|
|
|
const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id))
|
|
|
|
|
if (workspace !== undefined) return workspace
|
|
|
|
|
}
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */
|
|
|
|
|
async function historyStateFor(
|
|
|
|
|
sessionId: SessionId,
|
|
|
|
|
includeProjections: boolean,
|
|
|
|
|
): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
|
|
|
|
|
const attached = ctx.sessions.get(sessionId)
|
|
|
|
|
if (attached !== undefined) {
|
|
|
|
|
const events = [...attached.events]
|
|
|
|
|
const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
|
|
|
|
|
return { events, ...projections === undefined ? {} : { projections } }
|
|
|
|
|
}
|
|
|
|
|
const inspected = await inspectServable(sessionId)
|
|
|
|
|
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
|
|
|
|
|
return {
|
|
|
|
|
events: inspected.events,
|
|
|
|
|
...projections === undefined ? {} : { projections },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Resolve one requested identity to a live agent, creating or resuming it once. */
|
|
|
|
|
async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
|
|
|
|
|
let creation = sessionCreations.get(sessionId)
|
|
|
|
|
if (creation === undefined) {
|
|
|
|
|
creation = (async () => {
|
|
|
|
|
const attached = ctx.sessions.get(sessionId)
|
|
|
|
|
const live = ctx.agents.get(sessionId)
|
|
|
|
|
if (attached !== undefined && hasSubagentOwner(attached, live)) {
|
|
|
|
|
throw new SubagentSessionOwnership(sessionId)
|
|
|
|
|
}
|
|
|
|
|
if (live !== undefined) return live
|
|
|
|
|
|
|
|
|
|
const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
|
|
|
|
|
const stored = persistence === undefined
|
|
|
|
|
? undefined
|
|
|
|
|
: (await persistence.list()).find(header => header.id === sessionId)
|
|
|
|
|
if (stored !== undefined) {
|
|
|
|
|
if (stored.cwd !== cwd) {
|
|
|
|
|
throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
|
|
|
|
|
if (persistence !== undefined && stored !== undefined) {
|
|
|
|
|
const inspected = await persistence.inspect(sessionId)
|
|
|
|
|
// Ownership first: explicit-id adoption of a session-backed
|
|
|
|
|
// subagent must answer `agent-busy` regardless of the requested
|
|
|
|
|
// cwd (the api/commands.ts contract), not a cwd conflict.
|
|
|
|
|
if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
|
|
|
|
|
throw new SubagentSessionOwnership(sessionId)
|
|
|
|
|
}
|
|
|
|
|
if (inspected.meta.cwd !== cwd) {
|
|
|
|
|
throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
|
|
|
|
|
}
|
|
|
|
|
return (await ctx.agents.resume({
|
|
|
|
|
resumeSessionId: sessionId,
|
|
|
|
|
@@ -851,7 +1155,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
// Another Host entry path may have published the same identity while
|
|
|
|
|
// this operation crossed an asynchronous persistence/filesystem step.
|
|
|
|
|
const live = ctx.agents.get(sessionId)
|
|
|
|
|
if (live !== undefined) return live
|
|
|
|
|
if (live !== undefined) {
|
|
|
|
|
if (hasSubagentOwner(live.session, live)) throw new SubagentSessionOwnership(sessionId)
|
|
|
|
|
return live
|
|
|
|
|
}
|
|
|
|
|
const attached = ctx.sessions.get(sessionId)
|
|
|
|
|
if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
|
|
|
|
|
throw new SubagentSessionOwnership(sessionId)
|
|
|
|
|
}
|
|
|
|
|
throw error
|
|
|
|
|
}).finally(() => {
|
|
|
|
|
sessionCreations.delete(sessionId)
|
|
|
|
|
@@ -859,6 +1170,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
sessionCreations.set(sessionId, creation)
|
|
|
|
|
}
|
|
|
|
|
const agent = await creation
|
|
|
|
|
if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId)
|
|
|
|
|
if (agent.session.header.cwd !== cwd) {
|
|
|
|
|
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
|
|
|
|
|
}
|
|
|
|
|
@@ -1258,6 +1570,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if (error instanceof SubagentSessionOwnership) {
|
|
|
|
|
return err(request, subagentOwnershipError(error.sessionId))
|
|
|
|
|
}
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'internal',
|
|
|
|
|
message: `failed to create session "${sessionId}": ${String(error)}`,
|
|
|
|
|
@@ -1280,25 +1595,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
|
|
|
|
|
async history(request) {
|
|
|
|
|
const { sessionId, beforeSeq, maxMessages } = request.payload
|
|
|
|
|
const found = await agentFor(sessionId)
|
|
|
|
|
if ('error' in found) return err(request, found.error)
|
|
|
|
|
// Everything below the resume above is synchronous: the page slice,
|
|
|
|
|
// the seq read, and the projection walk see one un-torn session state.
|
|
|
|
|
const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
|
|
|
|
|
// Views are computed against the registry at pagination time; result
|
|
|
|
|
// pairing scans within the page only (message-boundary pagination keeps
|
|
|
|
|
// a call and its result on one page — a cross-page miss soft-falls).
|
|
|
|
|
const entries: HistoryEntry[] = page.events.map((event) => {
|
|
|
|
|
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
|
|
|
|
|
return { event, ...view === undefined ? {} : { view } }
|
|
|
|
|
})
|
|
|
|
|
// Baseline rider: tail page only — loadOlder (beforeSeq present) is
|
|
|
|
|
// the one path that never needs a fresh projection baseline.
|
|
|
|
|
const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
|
|
|
|
|
let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock }
|
|
|
|
|
try {
|
|
|
|
|
state = await historyStateFor(sessionId, beforeSeq === undefined)
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (error instanceof SessionNotFound) {
|
|
|
|
|
return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
|
|
|
|
|
}
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'internal',
|
|
|
|
|
message: `history unavailable for session "${sessionId}": ${String(error)}`,
|
|
|
|
|
details: {},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const page = historyPage(ctx, state.events, beforeSeq, maxMessages)
|
|
|
|
|
return ok(request, {
|
|
|
|
|
events: entries,
|
|
|
|
|
events: page.events,
|
|
|
|
|
hasMore: page.hasMore,
|
|
|
|
|
...projections === undefined ? {} : { projections },
|
|
|
|
|
...state.projections === undefined ? {} : { projections: state.projections },
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
@@ -1373,9 +1687,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
|
|
|
|
|
async fork(request) {
|
|
|
|
|
const { sessionId, atSeq } = request.payload
|
|
|
|
|
const found = await agentFor(sessionId)
|
|
|
|
|
if ('error' in found) return err(request, found.error)
|
|
|
|
|
const source = found.agent.session
|
|
|
|
|
let source: SessionReadState
|
|
|
|
|
try {
|
|
|
|
|
source = await readSessionState(sessionId)
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (error instanceof SessionNotFound) {
|
|
|
|
|
return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
|
|
|
|
|
}
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'internal',
|
|
|
|
|
message: `fork source unavailable for session "${sessionId}": ${String(error)}`,
|
|
|
|
|
details: {},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const events = source.events
|
|
|
|
|
// An in-log anchor belongs to the turn containing it and must never
|
|
|
|
|
// clip backward to an earlier completed turn. Omitted and past-end
|
|
|
|
|
@@ -1403,6 +1727,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
// right after the boundary turn.
|
|
|
|
|
let cut = boundary.seq + 1
|
|
|
|
|
while (cut < events.length && events[cut]?.type !== 'turn/start') cut++
|
|
|
|
|
let workspace: Workspace | undefined
|
|
|
|
|
try {
|
|
|
|
|
workspace = await forkWorkspace(source)
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'internal',
|
|
|
|
|
message: `failed to resolve fork workspace for session "${sessionId}": ${String(error)}`,
|
|
|
|
|
details: {},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const childId = `session-${randomUUID()}` as SessionId
|
|
|
|
|
try {
|
|
|
|
|
await ctx.agents.create({
|
|
|
|
|
@@ -1423,9 +1757,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
details: {},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
// Keep the child in the source's Workspace so the list nests it under
|
|
|
|
|
// its parent; the child is already published if the attach fails.
|
|
|
|
|
const workspace = ctx.workspace.list().find(w => w.sessionIds.includes(source.id))
|
|
|
|
|
// An ordinary source keeps its direct Workspace. A subagent source is
|
|
|
|
|
// not listed there, so its ordinary fork joins the nearest owning
|
|
|
|
|
// ancestor instead. The child is already published if attach fails.
|
|
|
|
|
if (workspace !== undefined) {
|
|
|
|
|
try {
|
|
|
|
|
await workspace.attachSession(childId)
|
|
|
|
|
@@ -1461,20 +1795,42 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
updateQueue(request) {
|
|
|
|
|
const { sessionId, itemId, action } = request.payload
|
|
|
|
|
const agent = ctx.agents.get(sessionId)
|
|
|
|
|
const queued = agent?.inbox.nextTurn
|
|
|
|
|
const index = queued?.findIndex(message => message.id === itemId) ?? -1
|
|
|
|
|
const message = queued?.[index]
|
|
|
|
|
if (agent === undefined || message === undefined) {
|
|
|
|
|
if (agent !== undefined && hasSubagentOwner(agent.session, agent)) {
|
|
|
|
|
return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
|
|
|
|
|
}
|
|
|
|
|
if (agent === undefined) {
|
|
|
|
|
return Promise.resolve(err(request, {
|
|
|
|
|
code: 'queue-item-not-found',
|
|
|
|
|
message: 'queued item is no longer pending',
|
|
|
|
|
details: { itemId },
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
const target = agent.inbox.nextTurn.some(message => message.id === itemId)
|
|
|
|
|
? 'next-turn'
|
|
|
|
|
: agent.inbox.nextStep.some(message => message.id === itemId) ? 'next-step' : undefined
|
|
|
|
|
const message = target === undefined
|
|
|
|
|
? undefined
|
|
|
|
|
: (target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep)
|
|
|
|
|
.find(candidate => candidate.id === itemId)
|
|
|
|
|
if (target === undefined || message === undefined) {
|
|
|
|
|
return Promise.resolve(err(request, {
|
|
|
|
|
code: 'queue-item-not-found',
|
|
|
|
|
message: 'queued item is no longer pending',
|
|
|
|
|
details: { itemId },
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
if (action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
|
|
|
|
|
return Promise.resolve(err(request, {
|
|
|
|
|
code: 'steer-unavailable',
|
|
|
|
|
message: 'current turn no longer accepts steering',
|
|
|
|
|
details: { itemId },
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
if (action.kind === 'edit') {
|
|
|
|
|
agent.inbox.splice('next-turn', index, 1, [freezeMessage({ ...message, content: action.content })])
|
|
|
|
|
agent.inbox.update(target, itemId, freezeMessage({ ...message, content: action.content }))
|
|
|
|
|
} else {
|
|
|
|
|
agent.inbox.splice('next-turn', index, 1, [])
|
|
|
|
|
agent.inbox.remove(target, itemId)
|
|
|
|
|
if (action.kind === 'steer') agent.steer(message)
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve(ok(request, { accepted: true as const }))
|
|
|
|
|
},
|
|
|
|
|
@@ -1489,11 +1845,119 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
details: { sessionId },
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
if (hasSubagentOwner(agent.session, agent)) {
|
|
|
|
|
return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
|
|
|
|
|
}
|
|
|
|
|
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
|
|
|
|
return Promise.resolve(ok(request, { accepted: true as const }))
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
subagents: {
|
|
|
|
|
async list(request, signal) {
|
|
|
|
|
try {
|
|
|
|
|
const entries = await ctx.subagents.listChildren(request.payload.parentSessionId, signal)
|
|
|
|
|
return ok(request, {
|
|
|
|
|
entries: entries.map(entry => entry.kind === 'child'
|
|
|
|
|
? {
|
|
|
|
|
...entry,
|
|
|
|
|
activity: ctx.agents.get(entry.id)?.status === 'running' ? 'running' : 'inactive',
|
|
|
|
|
}
|
|
|
|
|
: entry),
|
|
|
|
|
parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
|
|
|
|
|
})
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (signal?.aborted
|
|
|
|
|
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|
|
|
|
|
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'cancelled',
|
|
|
|
|
message: 'subagent catalog read was cancelled',
|
|
|
|
|
details: {},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'internal',
|
|
|
|
|
message: 'subagent catalog read failed',
|
|
|
|
|
details: {},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async history(request, signal) {
|
|
|
|
|
const {
|
|
|
|
|
parentSessionId, childSessionId, mode, beforeSeq, maxMessages,
|
|
|
|
|
} = request.payload
|
|
|
|
|
const verified = await catalogChild(ctx, {
|
|
|
|
|
parentSessionId, childSessionId, mode,
|
|
|
|
|
}, signal)
|
|
|
|
|
if (verified.error !== undefined) return err(request, verified.error)
|
|
|
|
|
try {
|
|
|
|
|
const snapshot = await ctx.sessionQuery.readSession(childSessionId)
|
|
|
|
|
signal?.throwIfAborted()
|
|
|
|
|
if (snapshot.session.parentSession !== parentSessionId) {
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'subagent-unauthorized',
|
|
|
|
|
message: 'subagent parent changed during history read',
|
|
|
|
|
details: { childSessionId },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages)
|
|
|
|
|
const projections = beforeSeq === undefined
|
|
|
|
|
? detachedProjectionsFor(ctx, snapshot.events)
|
|
|
|
|
: undefined
|
|
|
|
|
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (signal?.aborted
|
|
|
|
|
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'cancelled',
|
|
|
|
|
message: 'subagent history read was cancelled',
|
|
|
|
|
details: {},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if (error instanceof SessionQueryError
|
|
|
|
|
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'subagent-not-found',
|
|
|
|
|
message: 'subagent disappeared during history read',
|
|
|
|
|
details: { parentSessionId, childSessionId },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'internal',
|
|
|
|
|
message: 'subagent history read failed',
|
|
|
|
|
details: {},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async prompt(request, signal) {
|
|
|
|
|
const { parentSessionId, childSessionId, content } = request.payload
|
|
|
|
|
const parent = ctx.agents.get(parentSessionId)
|
|
|
|
|
if (parent === undefined) {
|
|
|
|
|
return err(request, {
|
|
|
|
|
code: 'subagent-parent-unavailable',
|
|
|
|
|
message: `parent session "${parentSessionId}" is not live`,
|
|
|
|
|
details: { parentSessionId },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const verified = await catalogChild(ctx, {
|
|
|
|
|
parentSessionId, childSessionId, mode: 'continuable',
|
|
|
|
|
}, signal)
|
|
|
|
|
if (verified.error !== undefined) return err(request, verified.error)
|
|
|
|
|
try {
|
|
|
|
|
const messageId = await ctx.subagents.followup(parent, childSessionId, content, {
|
|
|
|
|
source: { kind: 'user', rpcId: request.rpcId },
|
|
|
|
|
signal,
|
|
|
|
|
})
|
|
|
|
|
return ok(request, { messageId })
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
return subagentPromptError(request, error, signal)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
workspace: {
|
|
|
|
|
list(request) {
|
|
|
|
|
return Promise.resolve(ok(request, {
|
|
|
|
|
@@ -1748,9 +2212,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
commands: {
|
|
|
|
|
// Both methods address one session's agent (agentFor keeps its
|
|
|
|
|
// resume-on-miss: clients only send a sessionId for a published
|
|
|
|
|
// session, and resume restores an existing entity).
|
|
|
|
|
// Both methods address one session's agent. agentFor resumes on miss
|
|
|
|
|
// and fences every subagent-owned identity with `agent-busy`; the
|
|
|
|
|
// api/commands.ts module contract owns that fence's wording, so this
|
|
|
|
|
// comment only notes the routing shape: clients send a sessionId for a
|
|
|
|
|
// published session, and resume restores an existing entity.
|
|
|
|
|
async list(request) {
|
|
|
|
|
// Missing service = the deployment omitted dsh-commands from its
|
|
|
|
|
// composition, not an empty catalog: fail loud instead of serving [].
|
|
|
|
|
@@ -2002,14 +2468,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
// Queue snapshot baseline (pendingQuestions precedent): frames replayed
|
|
|
|
|
// in arrival order per session; a reconnecting client rebuilds its
|
|
|
|
|
// queue view from these alone.
|
|
|
|
|
for (const agent of ctx.agents.list()) {
|
|
|
|
|
const items = agent.inbox.nextTurn
|
|
|
|
|
if (items.length === 0) continue
|
|
|
|
|
queue.push(frame({
|
|
|
|
|
type: 'session/queue',
|
|
|
|
|
sessionId: agent.id,
|
|
|
|
|
items: [...items],
|
|
|
|
|
}))
|
|
|
|
|
for (const session of ctx.sessions.list()) {
|
|
|
|
|
const agent = ctx.agents.get(session.id)
|
|
|
|
|
if (agent?.session === session && agent.inbox.hasPending) {
|
|
|
|
|
queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) }))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Per-session open-call table for result-view pairing. Bounded by the
|
|
|
|
|
// per-turn call count: entries clear on turn/end; a table miss (stream
|
|
|
|
|
@@ -2032,20 +2495,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
const view = viewFor(ctx, event, callId =>
|
|
|
|
|
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
|
|
|
|
|
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
|
|
|
|
|
if (event.type === 'agent/inbox/spliced' && event.data.target === 'next-turn') {
|
|
|
|
|
const agent = ctx.agents.get(session.id)
|
|
|
|
|
if (agent?.session === session) {
|
|
|
|
|
queue.push(frame({
|
|
|
|
|
type: 'session/queue',
|
|
|
|
|
sessionId: session.id,
|
|
|
|
|
items: agent.inbox.nextTurn.toSpliced(
|
|
|
|
|
event.data.start,
|
|
|
|
|
event.data.removedCount ?? 0,
|
|
|
|
|
...event.data.inserted,
|
|
|
|
|
),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
ctx.on('session/created', (session: Session) => {
|
|
|
|
|
subscribeSession(queue, session)
|
|
|
|
|
@@ -2077,9 +2526,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|
|
|
|
// Derived at frame time like summarize(); a just-created session
|
|
|
|
|
// has run no turn yet, so this is constantly true in practice.
|
|
|
|
|
blank: sessionBlank(session),
|
|
|
|
|
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
|
|
|
|
// cwd rides the frame so the client list needs no refresh to group the new session.
|
|
|
|
|
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
|
|
|
|
|
// Including cwd lets the client group the new session without refreshing the list.
|
|
|
|
|
...sessionListFields(session.header),
|
|
|
|
|
}))
|
|
|
|
|
}),
|
|
|
|
|
ctx.on('session/disposed', (session: Session) => {
|
|
|
|
|
|