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:
commit
ef0a2b2e3d
4305 files changed
+174021
-23401
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user