Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`, `verify-translation-pairing --write` for the touched bilingual pairs, `gen-doc-graphs`, and one typert snapshot whose ids embed character offsets. `pnpm run rescope-vendor --check` verifies the result. Renames nine vendored packages (cordis, cosmokit, schemastery and the six @cordisjs plugins) and every reference that resolves them: manifest names and dependency keys, module specifiers including declare-module merges, cordis.yml plugin names, tsconfig paths, every Markdown fence, and `docs/` prose. Directory names, upstream versions, and dependency ranges are unchanged, so vendor/README.md still reads as an upstream snapshot; its manifest table gains an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed at each fork's origin. The tutorial tier follows the rename end to end: its yaml fences named plugins the Loader can no longer resolve, its `ts ignore-check` fences disagreed with the compiled fences beside them, and its prose quoted both. The contracts that told readers to keep upstream names — the root convention and the vendoring cookbook's tree comment and manifest invariant — now say to rescope instead. Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle purity gate now names the vendored libraries a browser bundle inlines, and the files where a bare `cordis` is an agent-preset id keep that product data.
212 lines
9.0 KiB
TypeScript
212 lines
9.0 KiB
TypeScript
/** Host BFF policy for resolving Remote Agent and Session identities. */
|
|
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent'
|
|
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
|
import type {} from '@deepseek-ai/dsh-session-persistence'
|
|
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
|
import type {} from '@deepseek-ai/dsh-typert-registry'
|
|
|
|
/** Caller-facing failures preserved by the Gateway's RPC adapter. */
|
|
export type ApiRemoteLookupError =
|
|
| { readonly code: 'agent-busy'; readonly message: string; readonly details: { readonly reason: string } }
|
|
| { readonly code: 'session-not-found'; readonly message: string; readonly details: { readonly sessionId: SessionId } }
|
|
| { readonly code: 'internal'; readonly message: string; readonly details: Record<never, never> }
|
|
|
|
/** Result of resolving one session identity to its live Agent. */
|
|
export type ApiRemoteAgentResult =
|
|
| { readonly agent: Agent }
|
|
| { readonly error: ApiRemoteLookupError }
|
|
|
|
/** Resume configuration supplied by the owning Host composition. */
|
|
export interface ApiRemoteAgentOptions {
|
|
/** Read the per-Agent defaults when a cold identity must resume. */
|
|
readonly agentOptions?: () => AgentOptions
|
|
/**
|
|
* Build the Host-specific Agent-scope composition completed before
|
|
* publication. Keyed by the resumed session itself because what a Host
|
|
* installs may depend on what that session recorded: an agent preset fixes
|
|
* the tools its history was produced under, so rebuilding it under another
|
|
* composition would replay tool calls the agent can no longer make. The
|
|
* events come along because a session's own record of such a choice may be
|
|
* an event rather than a header field.
|
|
* @param session - the resumed session's persisted header and event log.
|
|
* @returns the Agent-scope setup to run before publication.
|
|
*/
|
|
readonly setup?: (
|
|
session: { meta: SessionHeader; events: readonly SessionEvent[] },
|
|
) => AgentSetup | Promise<AgentSetup>
|
|
}
|
|
|
|
/** Cold identity absent from the durable session store. */
|
|
export class ApiRemoteSessionNotFound extends Error {}
|
|
|
|
/** Session identity whose lifecycle belongs to subagent routing. */
|
|
export class ApiRemoteSubagentSessionOwnership extends Error {
|
|
/**
|
|
* Construct the ownership fence.
|
|
* @param sessionId - identity reserved to subagent routing.
|
|
*/
|
|
constructor(readonly sessionId: SessionId) {
|
|
super(`session "${sessionId}" is a subagent session; use subagent delivery`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test whether generic Host routing must leave an identity to subagent routing.
|
|
* @param ctx - Host Context carrying the live Agent registry.
|
|
* @param session - attached or live Session metadata.
|
|
* @param agent - live Agent when one is registered.
|
|
* @returns whether generic Remote and legacy API calls must reject the identity.
|
|
*/
|
|
export function hasApiRemoteSubagentOwner(
|
|
ctx: Context,
|
|
session: Pick<Session, 'header'>,
|
|
agent: Agent | undefined,
|
|
): boolean {
|
|
if (session.header.origin === 'subagent') 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)
|
|
}
|
|
|
|
/**
|
|
* Build the stable caller-facing ownership rejection.
|
|
* @param sessionId - identity reserved to subagent routing.
|
|
* @returns the existing `agent-busy` RPC shape.
|
|
*/
|
|
export function apiRemoteSubagentOwnershipError(sessionId: SessionId): ApiRemoteLookupError {
|
|
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.
|
|
* @param ctx - Host Context carrying the optional persistence provider.
|
|
* @param sessionId - durable identity to inspect.
|
|
* @returns detached metadata and events for a servable session.
|
|
* @throws {@link ApiRemoteSessionNotFound} when the identity has no project-backed session.
|
|
*/
|
|
export async function inspectApiRemoteSession(
|
|
ctx: Context,
|
|
sessionId: SessionId,
|
|
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
|
const persistence = ctx.get('sessionPersistence')
|
|
if (persistence === undefined) {
|
|
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
|
|
}
|
|
const meta = (await persistence.list()).find(candidate => candidate.id === sessionId)
|
|
if (meta === undefined || meta.cwd === undefined) {
|
|
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
|
|
}
|
|
const inspected = await persistence.inspect(sessionId)
|
|
if (inspected.meta.cwd === undefined) {
|
|
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
|
|
}
|
|
return { meta: inspected.meta, events: [...inspected.events] }
|
|
}
|
|
|
|
/**
|
|
* Create the Host's shared Agent resolver and configure Agent/Session TypeRT lookups.
|
|
* Live Agents are reused, ordinary cold sessions resume once per identity, and
|
|
* subagent-owned identities retain the legacy `agent-busy` fence.
|
|
* @param ctx - owning Host Context.
|
|
* @param options - defaults and Agent-scope setup used only for cold resume.
|
|
* @returns resolver shared by legacy API Proxy methods and TypeRT lookups.
|
|
*/
|
|
export function createApiRemoteAgentResolver(
|
|
ctx: Context,
|
|
options: ApiRemoteAgentOptions,
|
|
): (sessionId: SessionId) => Promise<ApiRemoteAgentResult> {
|
|
const resumes = new Map<SessionId, Promise<Agent>>()
|
|
|
|
const fencedLiveAgent = (sessionId: SessionId): ApiRemoteAgentResult | undefined => {
|
|
const live = ctx.agents.get(sessionId)
|
|
if (live === undefined) return undefined
|
|
if (hasApiRemoteSubagentOwner(ctx, live.session, live)) {
|
|
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
|
}
|
|
return { agent: live }
|
|
}
|
|
|
|
const agentFor = async (sessionId: SessionId): Promise<ApiRemoteAgentResult> => {
|
|
const fenced = fencedLiveAgent(sessionId)
|
|
if (fenced !== undefined) return fenced
|
|
const attached = ctx.sessions.get(sessionId)
|
|
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
|
|
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
|
}
|
|
let resume = resumes.get(sessionId)
|
|
if (resume === undefined) {
|
|
resume = (async () => {
|
|
try {
|
|
const inspected = await inspectApiRemoteSession(ctx, sessionId)
|
|
if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) {
|
|
throw new ApiRemoteSubagentSessionOwnership(sessionId)
|
|
}
|
|
// Built from the inspected session before the published re-checks
|
|
// below, so those stay adjacent to `resume` and a Host setup that
|
|
// awaits (composing a preset, say) does not widen the collision
|
|
// window.
|
|
const setup = options.setup === undefined ? undefined : await options.setup(inspected)
|
|
const publishedSession = ctx.sessions.get(sessionId)
|
|
const publishedAgent = ctx.agents.get(sessionId)
|
|
if (publishedSession !== undefined
|
|
&& hasApiRemoteSubagentOwner(ctx, publishedSession, publishedAgent)) {
|
|
throw new ApiRemoteSubagentSessionOwnership(sessionId)
|
|
}
|
|
const handle = await ctx.agents.resume({
|
|
resumeSessionId: sessionId,
|
|
...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() },
|
|
...setup === undefined ? {} : { setup },
|
|
})
|
|
return handle.agent
|
|
} finally {
|
|
resumes.delete(sessionId)
|
|
}
|
|
})()
|
|
resumes.set(sessionId, resume)
|
|
}
|
|
try {
|
|
return { agent: await resume }
|
|
} catch (error: unknown) {
|
|
if (error instanceof ApiRemoteSessionNotFound) {
|
|
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
|
|
}
|
|
if (error instanceof ApiRemoteSubagentSessionOwnership) {
|
|
return { error: apiRemoteSubagentOwnershipError(error.sessionId) }
|
|
}
|
|
const fenced = fencedLiveAgent(sessionId)
|
|
if (fenced !== undefined) return fenced
|
|
const attached = ctx.sessions.get(sessionId)
|
|
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
|
|
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
|
}
|
|
return {
|
|
error: {
|
|
code: 'internal',
|
|
message: `resume failed for session "${sessionId}": ${String(error)}`,
|
|
details: {},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.inject(['typert'], (typeCtx) => {
|
|
const resolveAgent = async (sessionId: SessionId): Promise<Agent> => {
|
|
const found = await agentFor(sessionId)
|
|
if ('error' in found) throw new TypeRTLookupFailure(found.error)
|
|
return found.agent
|
|
}
|
|
typeCtx.typert.lookups.configure('agent', resolveAgent)
|
|
typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session)
|
|
typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx)
|
|
})
|
|
|
|
return agentFor
|
|
}
|