Merge remote-tracking branch 'origin/master' into turtle/image-cache-stale-0a243f674e12

# Conflicts:
#	packages/client/ui-primitives/README.i18n.yaml
#	packages/client/ui-primitives/README.md
#	packages/client/ui-primitives/README.zh.md
This commit is contained in:
Turtle committed 2026-10-18 14:49:15 +08:00
commit ef0a2b2e3d
4305 files changed
+174021 -23401

No files matched your search

+168 -94
View File
@@ -1,5 +1,6 @@
/** Agent activation, composition, and model-selection policy owned by API Session. */
import { randomUUID } from 'node:crypto'
import { mkdir } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
@@ -8,11 +9,12 @@ import type {
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
import { brandString } from '@deepseek-ai/dsh-brand'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-typert-registry'
import type { ModelSelection } from './types.ts'
@@ -42,21 +44,49 @@ export class ApiSessionCwdConflict extends Error {
}
}
/** Explicit-id creation attempted to adopt a Session under another preset. */
/** Reuse of a Session named an Agent preset while the deployment composes no preset roster. */
export class ApiSessionPresetConflict extends Error {
constructor(
readonly sessionId: SessionId,
readonly requestedPreset: string,
readonly existingPreset: string | undefined,
) {
super(
existingPreset === undefined
? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"`
: `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`,
)
super(`the deployment composes no agent preset roster, so session "${sessionId}" cannot run preset "${requestedPreset}"`)
}
}
/** A blank Session chosen for reuse started a turn before this Host acquired its writer. */
class ApiSessionStartedSinceObserved extends Error {}
/** Placement and Agent preset for one Session created for a new conversation. */
export interface ApiSessionCreateRequest {
/** Blank Session to reuse, or the identity of a new Session when none is stored under it. */
readonly sessionId?: SessionId
/** Directory the Session owns. */
readonly cwd: string
/** Preset the Session runs; the configured default when omitted. */
readonly agentPreset?: string
/**
* Placement rule the caller owns, such as Workspace membership and archive state.
* @param sessionId - existing Session named by the request.
* @returns whether that Session may be reused.
*/
readonly reusable: (sessionId: SessionId) => boolean
}
/** The Session one creation request created or reused. */
export interface ApiSessionCreated {
/** Live Agent of the Session. */
readonly agent: Agent
/** Preset the Session ran when the request finished, before a later request for the same identity; absent without a roster. */
readonly agentPreset: string | undefined
}
/** Resolved preset and pre-publication Agent setup for one create or resume. */
interface ApiSessionComposition {
readonly agentPreset?: string
readonly setup: AgentSetup
}
/** Failures produced while resolving one ordinary Session identity to its live Agent. */
export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'session/writer-held' | 'gateway/internal'>
@@ -139,7 +169,7 @@ export async function inspectApiSession(
/** Owns every operation that may create, resume, or configure a Web Agent. */
export class ApiSessionAgentController {
private readonly resumes = new Map<SessionId, Promise<Agent>>()
private readonly creations = new Map<SessionId, Promise<Agent>>()
private readonly claims = new Map<SessionId, Promise<void>>()
private readonly selections = new WeakMap<Agent, InstalledSelection>()
private readonly imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
@@ -229,50 +259,26 @@ export class ApiSessionAgentController {
}
/**
* Resolve one requested identity, creating or resuming it once.
* @param sessionId - requested Session identity.
* @param cwd - directory the Session must own.
* @param checkPersistedIdentity - whether to inspect a cold identity before creation.
* @param presetId - optional Agent preset the Session must own.
* @returns the matching live ordinary Agent.
* Create a Session for a new conversation, reusing the named blank Session
* while it is still reusable. A reused Session runs the requested preset, or
* the configured default when the request names none; the preset it recorded
* is not resolved, and a change is logged as `agent-preset/selected` before
* the Agent is published. A named Session that has started a turn, fails the
* caller's placement rule, or is written by another owner stays unchanged,
* and a new Session is created instead. Requests naming one identity run in
* arrival order, including their preset resolution, and check the named
* Session's directory and ownership before resolving the preset.
* @param request - optional Session to reuse, directory, preset, and placement rule.
* @returns the reused or created Session and the preset it ran when this request finished.
* @throws ApiSessionCwdConflict when the named Session belongs to another directory.
* @throws ApiSessionSubagentOwnership when subagent routing owns the named Session.
* @throws RemoteError `agent-preset/not-found` or `agent-preset/invalid` for the requested or default preset.
* @throws ApiSessionPresetConflict when no roster is composed and the request names a preset for a reused Session.
*/
async ensureSession(
sessionId: SessionId,
cwd: string,
checkPersistedIdentity: boolean,
presetId?: string,
): Promise<Agent> {
let creation = this.creations.get(sessionId)
if (creation === undefined) {
creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId)
.catch((error: unknown) => {
const live = this.ctx.agents.get(sessionId)
if (live !== undefined) {
if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
return live
}
const attached = this.ctx.sessions.get(sessionId)
if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
throw error
})
.finally(() => { this.creations.delete(sessionId) })
this.creations.set(sessionId, creation)
}
const agent = await creation
if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
if (presetId !== undefined) {
this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session))
}
if (agent.session.header.cwd !== cwd) {
throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
}
return agent
async createSession(request: ApiSessionCreateRequest): Promise<ApiSessionCreated> {
const named = request.sessionId
if (named === undefined) return this.created(await this.createMinted(request.cwd, await this.composeAgent(request.agentPreset)))
return this.inOrder(named, async () => this.created(await this.claim(named, request)))
}
/**
@@ -378,10 +384,7 @@ export class ApiSessionAgentController {
* @param presetId - requested preset or the configured default when omitted.
* @returns the resolved preset identity and Agent setup callback.
*/
async composeAgent(presetId: string | undefined): Promise<{
readonly agentPreset?: string
readonly setup: AgentSetup
}> {
async composeAgent(presetId: string | undefined): Promise<ApiSessionComposition> {
const presets = this.ctx.get('agentPresets')
if (presets === undefined) {
return { setup: (_agentCtx, agent) => { this.installSelection(agent) } }
@@ -441,48 +444,128 @@ export class ApiSessionAgentController {
})).agent
}
private async createOrAdopt(
sessionId: SessionId,
cwd: string,
checkPersistedIdentity: boolean,
presetId: string | undefined,
): Promise<Agent> {
private async inOrder<Value>(sessionId: SessionId, operation: () => Promise<Value>): Promise<Value> {
const result = (this.claims.get(sessionId) ?? Promise.resolve()).then(operation)
const settled = result.then(() => undefined, () => undefined)
this.claims.set(sessionId, settled)
try {
return await result
} finally {
if (this.claims.get(sessionId) === settled) this.claims.delete(sessionId)
}
}
/** @returns the reused Session, a new Session under `sessionId` when none is stored, or a new Session under a minted identity. */
private async claim(sessionId: SessionId, request: ApiSessionCreateRequest): Promise<Agent> {
const attached = this.ctx.sessions.get(sessionId)
const live = this.ctx.agents.get(sessionId)
if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
if (live !== undefined) return live
if (checkPersistedIdentity) {
try {
using observation = await this.ctx.sessionQuery.observeSession(sessionId)
if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
if (observation.header.cwd !== cwd) {
throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd)
}
const storedPreset = this.presetForObservation(observation)
this.assertPresetUnchanged(sessionId, presetId, storedPreset)
const composition = await this.composeAgent(storedPreset)
return (await this.ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: this.agentOptions(),
setup: composition.setup,
})).agent
} catch (error: unknown) {
if (!(error instanceof SessionQueryError)
|| error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
if (live !== undefined) {
if (live.session.header.cwd !== request.cwd) {
throw new ApiSessionCwdConflict(live.id, request.cwd, live.session.header.cwd)
}
const composition = await this.composeAgent(request.agentPreset)
return await this.reuseLive(live, request, composition) ?? this.createMinted(request.cwd, composition)
}
let observation: SessionObservation
try {
observation = await this.ctx.sessionQuery.observeSession(sessionId)
} catch (error: unknown) {
if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
return this.createAgent(sessionId, request.cwd, await this.composeAgent(request.agentPreset))
}
let reusable: boolean
{
using observed = observation
if (hasApiSessionSubagentOwner(this.ctx, { header: observed.header }, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
if (observed.header.cwd !== request.cwd) {
throw new ApiSessionCwdConflict(sessionId, request.cwd, observed.header.cwd)
}
reusable = request.reusable(sessionId) && !observed.events.some(event => event.type === 'turn/start')
}
const composition = await this.composeAgent(request.agentPreset)
if (!reusable) return this.createMinted(request.cwd, composition)
this.assertPresetApplicable(sessionId, request.agentPreset)
try {
return (await this.ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: this.agentOptions(),
setup: this.reuseSetup(composition),
})).agent
} catch (error: unknown) {
if (error instanceof ApiSessionStartedSinceObserved) return this.createMinted(request.cwd, composition)
if (!(error instanceof Error) || error.name !== 'SessionAlreadyOwnedError') throw error
// Another resume in this process may hold the writer for the Agent it published.
return this.ctx.agents.get(sessionId) === undefined
? this.createMinted(request.cwd, composition)
: this.claim(sessionId, request)
}
}
/** @returns the live blank Session under the requested preset, or undefined when it cannot be reused. */
private async reuseLive(
live: Agent,
request: ApiSessionCreateRequest,
composition: ApiSessionComposition,
): Promise<Agent | undefined> {
if (!request.reusable(live.id) || !this.isBlank(live.session)) return undefined
this.assertPresetApplicable(live.id, request.agentPreset)
const current = this.presetForSession(live.session)
const presets = this.ctx.get('agentPresets')
const target = composition.agentPreset
if (presets === undefined || target === undefined || target === current) return live
try {
await presets.select(live, target)
} catch (error: unknown) {
if (remoteErrorOf(error)?.code !== 'agent-preset/locked') throw error
return undefined
}
return live
}
/** Setup that re-checks blankness under the acquired writer, then records a preset change before publication. */
private reuseSetup(composition: ApiSessionComposition): AgentSetup {
return async (agentCtx, agent) => {
if (!this.isBlank(agent.session)) throw new ApiSessionStartedSinceObserved()
const commit = await composition.setup(agentCtx, agent)
const target = composition.agentPreset
if (target !== undefined && this.presetForSession(agent.session) !== target) {
agent.session.append('agent-preset/selected', { agentPreset: target })
}
return commit
}
}
/** Whether no turn has started, by the predicate the preset registry's `select` enforces. */
private isBlank(session: Session): boolean {
const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')
return boundary === undefined || (boundary.openTurnStartSeq === null && boundary.lastTurn === 0)
}
/** Without a roster, reuse cannot apply a named preset. */
private assertPresetApplicable(sessionId: SessionId, requested: string | undefined): void {
if (requested === undefined || this.ctx.get('agentPresets') !== undefined) return
throw new ApiSessionPresetConflict(sessionId, requested)
}
private created(agent: Agent): ApiSessionCreated {
return { agent, agentPreset: this.presetForSession(agent.session) }
}
private createMinted(cwd: string, composition: ApiSessionComposition): Promise<Agent> {
return this.createAgent(brandString<SessionId>(`session-${randomUUID()}`), cwd, composition)
}
private async createAgent(sessionId: SessionId, cwd: string, composition: ApiSessionComposition): Promise<Agent> {
try {
await mkdir(cwd, { recursive: true })
} catch (error: unknown) {
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
}
const composition = await this.composeAgent(presetId)
return (await this.ctx.agents.create({
sessionId,
agentOptions: this.agentOptions(),
@@ -514,15 +597,6 @@ export class ApiSessionAgentController {
}
return observation.projections.values.agentPreset ?? undefined
}
private assertPresetUnchanged(
sessionId: SessionId,
requested: string | undefined,
existing: string | undefined,
): void {
if (requested === undefined || requested === existing) return
throw new ApiSessionPresetConflict(sessionId, requested, existing)
}
}
function agentModelSelection(selection: ModelSelection): AgentModelSelection {
@@ -79,14 +79,17 @@ export interface ISessions {
*/
readonly searchResultLimit: number
/**
* Create or adopt a Session on the Host.
* @param opts - target workspace, directory, and optional preallocated identity.
* @returns the catalogued identity; retain it before borrowing its binding.
* Create a Session for a new conversation on the Host. The Host reuses the
* named blank Session while it is reusable and otherwise creates another; the
* Session runs the named preset, or the Host default when none is named.
* @param opts - target workspace or directory, an optional blank Session to reuse, and an optional Agent preset.
* @returns the catalogued identity the Host created or reused; retain it before borrowing its binding.
*/
create(opts?: {
workspaceId?: WorkspaceId
cwd?: string
sessionId?: SessionId
agentPreset?: string
}): Promise<SessionId>
/**
* Resolve an already discovered direct-parent address without opening it.
@@ -502,17 +502,21 @@ export class SessionManager {
* Contract session.create; on success merge into summaries immediately (no
* wait for the next refresh). A created session is blank by definition
* (entity birth precedes the first message).
* @param opts - target workspace or working directory, plus an optional caller-owned id.
* @returns the create result.
* @param opts - target workspace or working directory, an optional blank Session to reuse, and an optional Agent preset.
* @returns the create result, naming the Session the Host created or reused.
*/
async create(
opts: {
workspaceId?: WorkspaceId
cwd?: string
sessionId?: SessionId
agentPreset?: string
} = {},
): Promise<RemoteResult<{ sessionId: SessionId }>> {
const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
const shared = {
...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }),
...(opts.agentPreset === undefined ? {} : { agentPreset: opts.agentPreset }),
}
const payload = opts.workspaceId !== undefined
? { workspaceId: opts.workspaceId, ...shared }
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
@@ -427,11 +427,16 @@ export class ClientSessions implements ISessions {
/**
* Create a Host Session and publish its catalog row before resolving.
* Callers retain the returned identity before borrowing its binding.
* @param opts - target workspace or directory and an optional preallocated id.
* @returns the new session id.
* @param opts - target workspace or directory, an optional blank Session to reuse, and an optional Agent preset.
* @returns the created or reused session id.
* @throws {SessionCreateError} with the requested id.
*/
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
async create(opts: {
workspaceId?: WorkspaceId
cwd?: string
sessionId?: SessionId
agentPreset?: string
} = {}): Promise<SessionId> {
const result = await this.manager.create(opts)
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
this.projectList()
@@ -289,7 +289,7 @@ export class Session implements SessionFace {
// wire type is used; this array is not filtered or reordered.
const routedContent = content as Exclude<PromptContentPart, { readonly type: 'file' }>[]
const routed = await this.remote.subagents.prompt({
requestId: randomUUID() as SessionRequestId,
requestId: requestId ?? randomUUID() as SessionRequestId,
parentSessionId: this.address.parentSessionId,
childSessionId: this.address.childSessionId,
mode: 'continuable',
+28 -46
View File
@@ -20,7 +20,6 @@ import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
@@ -33,6 +32,7 @@ import {
apiSessionSubagentOwnershipError,
hasApiSessionSubagentOwner,
inspectApiSession,
type ApiSessionCreated,
} from './agent.ts'
import type {
SessionAttachmentRequest,
@@ -64,7 +64,12 @@ type PromptContentCandidate =
| SessionPromptRequest['content'][number]
| Extract<SessionUpdateQueueRequest['action'], { readonly kind: 'edit' }>['content'][number]
function hasPromptContent(content: readonly PromptContentCandidate[]): boolean {
/**
* Whether prompt content carries non-whitespace text or an attachment.
* @param content - submitted or edited prompt parts.
* @returns false for content the Agent must not receive.
*/
export function hasPromptContent(content: readonly PromptContentCandidate[]): boolean {
return content.some(part => part.type !== 'text' || part.text.trim().length > 0)
}
@@ -98,15 +103,15 @@ export class SessionCommandController {
) {}
/**
* Create or idempotently adopt one ordinary Session.
* @param request - requested identity, location, and Agent preset.
* @returns the Session identity and resolved preset when configured.
* Create one ordinary Session for a new conversation, reusing the named
* blank Session while it is an unarchived member of the requested Workspace.
* @param request - optional blank Session to reuse, location, and Agent preset.
* @returns the created or reused Session identity and its preset when configured.
*/
async create(request: SessionCreateRequest): Promise<SessionCreateValue> {
if (request.workspaceId !== undefined && request.cwd !== undefined) {
throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {})
}
const sessionId = request.sessionId ?? brandString<SessionId>(`session-${randomUUID()}`)
let workspace: Workspace | undefined
if (request.workspaceId !== undefined) {
workspace = this.ctx.workspaceRegistry.get(request.workspaceId)
@@ -117,17 +122,19 @@ export class SessionCommandController {
}
}
const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd
let adopted: Agent
let created: ApiSessionCreated
try {
adopted = await this.agents.ensureSession(
sessionId,
created = await this.agents.createSession({
...(request.sessionId === undefined ? {} : { sessionId: request.sessionId }),
cwd,
request.sessionId !== undefined,
request.agentPreset,
)
...(request.agentPreset === undefined ? {} : { agentPreset: request.agentPreset }),
reusable: sessionId => !this.ctx.workspaceRegistry.archivedSessionIds.includes(sessionId)
&& (workspace === undefined || workspace.sessionIds.includes(sessionId)),
})
} catch (error) {
this.rejectCreation(sessionId, error)
this.rejectCreation(error)
}
const sessionId = created.agent.id
if (workspace !== undefined) {
try {
await workspace.attachSession(sessionId)
@@ -139,8 +146,7 @@ export class SessionCommandController {
)
}
}
const agentPreset = this.agents.presetForSession(adopted.session)
return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) }
return { sessionId, ...(created.agentPreset === undefined ? {} : { agentPreset: created.agentPreset }) }
}
/**
@@ -304,35 +310,15 @@ export class SessionCommandController {
}
/**
* Reject empty content, then admit one prompt after Agent and attachment validation.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* Admit one prompt after Agent and attachment validation; the Remote method
* has already validated the content and built the durable message source.
* @param request - Session identity, prompt content, and delivery mode.
* @param source - the `user-rpc` source recorded on the accepted message.
* @returns acknowledgement that the Agent accepted the prompt.
*/
async prompt(request: SessionPromptRequest): Promise<SessionPromptValue> {
if (!hasPromptContent(request.content)) {
throw new RemoteError(
'gateway/bad-request',
'prompt content must include non-whitespace text or an attachment',
{},
)
}
const clientTimeZone = request.clientTimeZone === undefined
? undefined
: canonicalClientTimeZone(request.clientTimeZone)
if (request.clientTimeZone !== undefined && clientTimeZone === undefined) {
throw new RemoteError(
'session/invalid-time-zone',
'clientTimeZone must be UTC or a valid IANA Area/Location name',
{ value: request.clientTimeZone },
)
}
async prompt(request: SessionPromptRequest, source: MessageSource): Promise<SessionPromptValue> {
const agent = await this.resolveAgent(request.sessionId)
if (hasPromptRequest(agent, request.requestId)) return { accepted: true }
const source: MessageSource = {
kind: 'user',
rpcId: request.requestId,
...(clientTimeZone === undefined ? {} : { clientTimeZone }),
}
const hasImage = request.content.some(part => part.type === 'image')
const admit = async (): Promise<SessionPromptValue> => {
try {
@@ -530,16 +516,12 @@ export class SessionCommandController {
return found.agent
}
private rejectCreation(sessionId: SessionId, error: unknown): never {
private rejectCreation(error: unknown): never {
if (remoteErrorOf(error) !== undefined) throw error
if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') {
throw new RemoteError('session/writer-held', error.message, { sessionId })
}
if (error instanceof ApiSessionPresetConflict) {
throw new RemoteError('agent-preset/conflict', error.message, {
sessionId: error.sessionId,
requestedPreset: error.requestedPreset,
...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }),
})
}
if (error instanceof ApiSessionCwdConflict) {
@@ -552,7 +534,7 @@ export class SessionCommandController {
if (error instanceof ApiSessionSubagentOwnership) {
throw apiSessionSubagentOwnershipError(error.sessionId)
}
throw new RemoteError('gateway/internal', `failed to create session "${sessionId}": ${String(error)}`, {})
throw new RemoteError('gateway/internal', `failed to create session: ${String(error)}`, {})
}
private async readSessionState(sessionId: SessionId): Promise<SessionReadState> {
+72 -16
View File
@@ -6,20 +6,23 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-fs'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain, ReasoningEffortId, type MessageSource } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-client-file-upload'
import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
import type {} from '@deepseek-ai/dsh-session-projection-cache'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { Remote, RemoteError, TypertRemoteService, principalOf } from '@deepseek-ai/dsh-typert-protocol'
import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import type {} from '@deepseek-ai/dsh-api-remote-access'
import {
ApiSessionAgentController,
inspectApiSession,
type ApiSessionAgentResult,
} from './agent.ts'
import { SessionCommandController } from './commands.ts'
import { SessionCommandController, hasPromptContent } from './commands.ts'
import { SessionControlController } from './control.ts'
import { SessionHistoryController } from './history.ts'
import { SessionFileReferences } from './file-references.ts'
@@ -32,6 +35,7 @@ import { ArchivedSessionGate } from './archived-session-gate.ts'
import type {
ModelCatalog,
SessionWorkspacePathApplication,
SessionAddress,
SessionAttachmentRequest,
SessionAttachmentValue,
SessionCancelRequest,
@@ -76,6 +80,16 @@ declare module '@deepseek-ai/cordis' {
}
}
/**
* Read the Session resource a history address names: the Session itself, or
* the child Session whose transcript a subagent address reads.
* @param request - history request carrying the durable address.
* @returns the Session whose access the read requires.
*/
function addressedSession(request: { readonly address: SessionAddress }): SessionId {
return request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId
}
/** Session Controller deployment policy. */
export interface Config {
/** Override platform desktop-opener detection. */
@@ -110,6 +124,7 @@ export class SessionController extends TypertRemoteService {
'fileUploads',
'fs',
'llm',
'remoteAccess',
'sessions',
'sessionProjections',
'sessionQuery',
@@ -141,6 +156,7 @@ export class SessionController extends TypertRemoteService {
*/
constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) {
super(ctx, 'sessionController', { namespace: 'session' })
ctx.typert.resources.register('session', { field: 'sessionId' })
const resolved = SessionController.Config(config)
installModelSelectionProjection(ctx)
this.agents = new ApiSessionAgentController(ctx)
@@ -258,24 +274,28 @@ export class SessionController extends TypertRemoteService {
*/
@Remote('list')
async list(_request: SessionListRequest, signal: AbortSignal): Promise<SessionListValue> {
return { items: await this.listState.list(signal) }
const items = await this.listState.list(signal)
return { items: items.filter(item => this.ctx.remoteAccess.allows({ kind: 'session', id: item.sessionId })) }
}
/**
* Search visible Session content without resuming an Agent.
* Search visible Session content without resuming an Agent. A non-owner
* receives only the hits whose Session `allows()` admits; `hasMore`
* describes the bounded result before that filtering.
* @param request - literal message-content query.
* @param signal - cancellation for list and search reads.
* @returns authorized bounded Session search results.
*/
@Remote('search')
search(request: SessionSearchRequest, signal: AbortSignal): Promise<SessionSearchValue> {
return this.listState.search(request.query, signal)
async search(request: SessionSearchRequest, signal: AbortSignal): Promise<SessionSearchValue> {
const result = await this.listState.search(request.query, signal)
return { ...result, items: result.items.filter(item => this.ctx.remoteAccess.allows({ kind: 'session', id: item.sessionId })) }
}
/**
* Create or idempotently adopt one ordinary Session.
* @param request - requested identity, location, and Agent preset.
* @returns the Session identity and resolved preset when configured.
* Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable.
* @param request - optional blank Session to reuse, location, and Agent preset.
* @returns the created or reused Session identity and its preset when configured.
*/
@Remote('create')
create(request: SessionCreateRequest): Promise<SessionCreateValue> {
@@ -439,9 +459,30 @@ export class SessionController extends TypertRemoteService {
* @returns acknowledgement that the Agent accepted the prompt.
*/
@Remote('prompt')
prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
signal.throwIfAborted()
return this.commands.prompt(request)
if (!hasPromptContent(request.content)) {
throw new RemoteError('gateway/bad-request', 'prompt content must include non-whitespace text or an attachment', {})
}
const clientTimeZone = request.clientTimeZone === undefined
? undefined
: canonicalClientTimeZone(request.clientTimeZone)
if (request.clientTimeZone !== undefined && clientTimeZone === undefined) {
throw new RemoteError(
'session/invalid-time-zone',
'clientTimeZone must be UTC or a valid IANA Area/Location name',
{ value: request.clientTimeZone },
)
}
// The caller is recorded only when it is not the owner, so owner messages keep their exact source.
const principal = principalOf(this.ctx.invocation)
const source: MessageSource = {
kind: 'user',
rpcId: request.requestId,
...(clientTimeZone === undefined ? {} : { clientTimeZone }),
...(principal === undefined ? {} : { principal }),
}
return this.commands.prompt(request, source)
}
/**
@@ -480,7 +521,7 @@ export class SessionController extends TypertRemoteService {
* @param signal - cancellation for persistence reads.
* @returns one chronological page.
*/
@Remote('page')
@Remote({ name: 'page', resources: { session: addressedSession } })
page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
return this.history.page(request, signal)
}
@@ -492,7 +533,7 @@ export class SessionController extends TypertRemoteService {
* @returns a complete opening snapshot followed by gap-free durable event
* frames and optional cursorless assistant-stream frames.
*/
@Remote({ mode: 'stream' })
@Remote({ mode: 'stream', resources: { session: addressedSession } })
follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
return this.history.follow(request, signal)
}
@@ -555,8 +596,23 @@ export class SessionController extends TypertRemoteService {
* @returns one complete baseline followed by live replacement frames.
*/
@Remote({ mode: 'stream' })
control(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
return this.controlState.control(signal)
async *control(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
for await (const frame of this.controlState.control(signal)) {
switch (frame.type) {
case 'baseline': {
const projections = Object.fromEntries(Object.entries(frame.value.projections)
.filter(([sessionId]) => this.ctx.remoteAccess.allows({ kind: 'session', id: sessionId as SessionId })))
yield { type: 'baseline', value: { ...frame.value, projections } }
break
}
case 'projection':
if (this.ctx.remoteAccess.allows({ kind: 'session', id: frame.sessionId })) yield frame
break
/* v8 ignore next -- closed control-frame union. */
default:
assertNever(frame)
}
}
}
+14 -4
View File
@@ -9,6 +9,7 @@ import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { TextBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { Principal } from '@deepseek-ai/dsh-typert-protocol'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
@@ -219,7 +220,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
'agent-preset/conflict': {
readonly sessionId: SessionId
readonly requestedPreset: string
readonly existingPreset?: string
}
'session/attachment-invalid': { readonly reason: string }
'session/queue-item-not-found': { readonly itemId: MessageId }
@@ -284,17 +284,23 @@ export interface SessionSearchValue {
readonly hasMore: boolean
}
/** Session creation or explicit-id adoption request. */
/** Request for a Session for a new conversation. */
export interface SessionCreateRequest {
/** Workspace the Session joins; exclusive with `cwd`. */
readonly workspaceId?: WorkspaceId
/** Directory the Session owns when no Workspace is named. */
readonly cwd?: string
/** Blank Session to reuse while it is reusable, or the identity of a new Session when none is stored under it. */
readonly sessionId?: SessionId
/** Preset the Session runs, replacing a reused Session's recorded preset; the configured default when omitted. */
readonly agentPreset?: string
}
/** Session creation response value. */
export interface SessionCreateValue {
/** The created or reused Session, which may differ from the requested `sessionId`. */
readonly sessionId: SessionId
/** Preset the Session runs, when the deployment composes a preset roster. */
readonly agentPreset?: string
}
@@ -404,8 +410,12 @@ export type SessionRequestId = Branded<'session-request-id'>
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
/** Browser prompt correlation and optional Host-validated time zone. */
'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string }
/**
* Browser prompt correlation, optional Host-validated time zone, and the
* caller that submitted the prompt. `principal` is written only for a
* caller other than the owner, so an absent `principal` means the owner.
*/
'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string; principal?: Principal }
}
}