diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0724012e77..836c54e2ab 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -76,6 +76,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^" }, @@ -97,6 +98,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^" } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 6956336198..14be075879 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -9,6 +9,7 @@ import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' +import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -22,6 +23,8 @@ export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' export type {} from '@deepseek-ai/dsh-session-reference/remote' +export type {} from '@deepseek-ai/dsh-subagent/remote' +export type * from '@deepseek-ai/dsh-subagent/client' export type {} from '@deepseek-ai/dsh-api-session-controller/remote' export type * from '@deepseek-ai/dsh-api-session-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' @@ -54,7 +57,6 @@ export type { MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, - SubagentAddress, SubagentCatalog, } from '@deepseek-ai/dsh-client-connection/client' export type {} from '@deepseek-ai/dsh-api-gateway/client' export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote' @@ -110,6 +112,7 @@ export type ClientFailure = | import('@deepseek-ai/dsh-client-connection/client').RpcError | import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError | import('@deepseek-ai/dsh-api-session-controller/types').SessionError + | import('@deepseek-ai/dsh-subagent/client').SubagentControlError | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError /** Success or failure returned by Client operations spanning both API families. */ @@ -138,7 +141,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, - sessionRemote, workspaceRemote, + subagentsRemote, sessionRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index eb98174378..9d738aa3f8 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -54,6 +54,9 @@ { "path": "../../settings/settings" }, + { + "path": "../../subagent/subagent" + }, { "path": "../../interaction/user-approval" }, diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 959b35ed9f..36f0ae707c 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -61,7 +61,6 @@ "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "fflate": "^0.8.2", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 857172bcc2..3c3f8210d6 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -9,7 +9,6 @@ import type { Context } from '@deepseek-ai/cordis' import type { ModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-presets/types' import type { SessionId } from '@deepseek-ai/dsh-session' -import { SubagentError } from '@deepseek-ai/dsh-subagent' import { isUserInvocable } from '@deepseek-ai/dsh-skill' import { InvalidPresetIdError, PresetExistsError, @@ -19,7 +18,6 @@ import type { ApiProxy, ConfigurableProviderView, CredentialView, SettingsNamespaceView, } from './api/index.ts' -import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types' import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller' import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import { @@ -46,25 +44,6 @@ import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts' -/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ -const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ - -/** Validate and canonicalize one browser-supplied IANA zone at the wire boundary. */ -function canonicalClientTimeZone(value: string): string | undefined { - if (value.length === 0 || value.trim() !== value - || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined - try { - const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }) - .resolvedOptions().timeZone - /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */ - if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined - return canonical - } catch { - // Intl rejects unsupported zone names; the RPC maps that parser rejection below. - return undefined - } -} - /** Read live abort state across awaits without treating it as synchronously immutable. */ function isAborted(signal: AbortSignal): boolean { return signal.aborted @@ -110,55 +89,6 @@ export interface ApiProxyDefaults { canOpenPath?: () => boolean } -/** Map continuation admission failures without exposing provider details. */ -function subagentPromptError( - request: RpcRequest<{ childSessionId: SessionId }>, - error: unknown, - signal: AbortSignal, -): RpcResponse { - 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: {} }) -} - -/** Stable RPC face of the missing projections capability, shared by every catalog read path. */ -function projectionsUnavailableError(): RpcError { - return { - code: 'internal', - message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', - details: {}, - } -} - /** The roster is absent: this deployment composes no agent presets at all. */ function noRoster(agentPreset: string): RpcError { return { @@ -341,99 +271,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return { - 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')) { - return err(request, { - code: 'cancelled', - message: 'subagent catalog read was cancelled', - details: {}, - }) - } - if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { - return err(request, projectionsUnavailableError()) - } - return err(request, { - code: 'internal', - message: 'subagent catalog read failed', - details: {}, - }) - } - }, - - async prompt(request, signal) { - const { parentSessionId, childSessionId, content, clientTimeZone } = request.payload - const canonicalTimeZone = clientTimeZone === undefined - ? undefined - : canonicalClientTimeZone(clientTimeZone) - if (clientTimeZone !== undefined && canonicalTimeZone === undefined) { - return err(request, { - code: 'invalid-time-zone', - message: 'clientTimeZone must be UTC or a valid IANA Area/Location name', - details: { value: clientTimeZone }, - }) - } - 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 }, - }) - } - try { - const messageId = await ctx.subagents.followup(parent, childSessionId, content, { - source: { - kind: 'user', - rpcId: request.rpcId as unknown as SessionRequestId, - ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), - }, - signal, - }) - return ok(request, { messageId }) - } catch (error: unknown) { - return subagentPromptError(request, error, signal) - } - }, - - // Deliberately no catalog, history, persistence, or parent Agent lookup: - // the core primitive alone authorizes the durable address against the - // live Activation, which is what keeps a live child interruptible while - // its parent Agent is offline. Absent targets are accepted no-ops there. - interrupt(request) { - const { parentSessionId, childSessionId } = request.payload - try { - ctx.subagents.interrupt(childSessionId, { kind: 'user', parentSessionId }) - } catch (error: unknown) { - if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') { - return Promise.resolve(err(request, { - code: 'subagent-unauthorized', - message: 'subagent does not belong to this parent', - details: { childSessionId }, - })) - } - return Promise.resolve(err(request, { - code: 'internal', - message: 'subagent interrupt failed', - details: {}, - })) - } - return Promise.resolve(ok(request, { accepted: true as const })) - }, - }, - host: { describe(request) { // TODO(apiproxy-version): read the version from apps/cli/package.json. diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index be660ec53b..434c362e1a 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -7,7 +7,6 @@ import type { HostApi } from './host.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' -import type { SubagentsApi } from './subagents.ts' import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' @@ -15,7 +14,6 @@ import type { DownloadsApi } from './downloads.ts' /** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */ export interface ApiProxy { - subagents: SubagentsApi host: HostApi skills: SkillsApi agentPresets: AgentPresetsApi @@ -32,10 +30,6 @@ export type { ModelReasoningEffort, ModelSelection, } from '@deepseek-ai/dsh-api-session-controller/types' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' -export type { - SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry, - SubagentPromptReceipt, SubagentsApi, -} from './subagents.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { AgentPresetsApi } from './agent-presets.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index ad37ade1b8..d636963e82 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -9,7 +9,6 @@ import type { SkillsApi } from './skills.ts' import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' -import type { SubagentsApi } from './subagents.ts' import type { RpcResponse } from './rpc.ts' /** @@ -18,9 +17,6 @@ import type { RpcResponse } from './rpc.ts' * request; the carrier passes its request signal, never a wire field. */ export interface RpcMethodMap { - 'subagent.list': SubagentsApi['list'] - 'subagent.prompt': SubagentsApi['prompt'] - 'subagent.interrupt': SubagentsApi['interrupt'] 'host.describe': HostApi['describe'] 'host.pickDirectory': HostApi['pickDirectory'] 'host.listDirectory': HostApi['listDirectory'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 9dacda7237..498c7c3a1f 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -49,16 +49,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }), - z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }), - z.object({ code: z.literal('subagent-not-found'), message: z.string(), details: z.object({ parentSessionId: z.string(), childSessionId: z.string() }) }), - z.object({ code: z.literal('subagent-catalog-diagnostic'), message: z.string(), details: z.object({ - parentSessionId: z.string(), - childSessionId: z.string(), - reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]), - }) }), - z.object({ code: z.literal('subagent-not-resumable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }), - z.object({ code: z.literal('subagent-unauthorized'), message: z.string(), details: z.object({ childSessionId: z.string() }) }), - z.object({ code: z.literal('subagent-delivery-unavailable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index fbbdbe63d0..b154a5e14a 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -62,16 +62,6 @@ export interface RpcErrorDetailsMap { * details name the endpoint asked, never the credential offered. */ 'model-discovery-failed': { settingsNs: string; baseURL?: string } - 'subagent-parent-unavailable': { parentSessionId: SessionId } - 'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId } - 'subagent-catalog-diagnostic': { - parentSessionId: SessionId - childSessionId: SessionId - reason: 'corrupt' | 'unsupported' | 'unavailable' - } - 'subagent-not-resumable': { childSessionId: SessionId } - 'subagent-unauthorized': { childSessionId: SessionId } - 'subagent-delivery-unavailable': { childSessionId: SessionId } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/subagents.schema.ts b/packages/host/apiproxy/src/api/subagents.schema.ts deleted file mode 100644 index 0943c6c79f..0000000000 --- a/packages/host/apiproxy/src/api/subagents.schema.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** Zod schemas for the browser-safe subagent domain. */ - -import { z } from 'zod' -import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './ids.schema.ts' -import type { SubagentListEntry } from './subagents.ts' - -const contentBlockSchema = z.looseObject({ type: z.string() }) - -/** Healthy and diagnostic durable catalog rows. */ -export const subagentListEntrySchema = z.union([ - z.object({ - kind: z.literal('child'), - id: sessionIdSchema, - mode: z.literal('one-shot'), - activity: z.union([z.literal('running'), z.literal('inactive')]), - hasChildren: z.boolean(), - label: z.string().optional(), - }), - z.object({ - kind: z.literal('child'), - id: sessionIdSchema, - mode: z.literal('continuable'), - activity: z.union([z.literal('running'), z.literal('inactive')]), - hasChildren: z.boolean(), - label: z.string(), - }), - z.object({ - kind: z.literal('diagnostic'), - id: sessionIdSchema, - reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]), - }), -]) satisfies z.ZodType> - -/** subagent.list request payload. */ -export const subagentListRequestSchema = z.object({ - parentSessionId: sessionIdSchema, -}) satisfies z.ZodType>> - -/** subagent.list response value. */ -export const subagentListValueSchema = z.object({ - entries: z.array(subagentListEntrySchema), - parentAvailable: z.boolean(), -}) satisfies z.ZodType>> - -/** subagent.prompt request payload. */ -export const subagentPromptRequestSchema = z.object({ - parentSessionId: sessionIdSchema, - childSessionId: sessionIdSchema, - mode: z.literal('continuable'), - content: z.array(contentBlockSchema), - clientTimeZone: z.string().optional(), -}) as unknown as z.ZodType> - -/** subagent.interrupt request payload. */ -export const subagentInterruptRequestSchema = z.object({ - parentSessionId: sessionIdSchema, - childSessionId: sessionIdSchema, - mode: z.literal('continuable'), -}) satisfies z.ZodType>> - -/** subagent.interrupt response value. */ -export const subagentInterruptValueSchema = z.object({ - accepted: z.literal(true), -}) satisfies z.ZodType>> - -const messageIdSchema = z.string() as unknown as z.ZodType - -/** subagent.prompt response value. */ -export const subagentPromptValueSchema = z.object({ - messageId: messageIdSchema, -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/subagents.ts b/packages/host/apiproxy/src/api/subagents.ts deleted file mode 100644 index 2ca814066b..0000000000 --- a/packages/host/apiproxy/src/api/subagents.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** Browser-safe subagent catalog, continuation, and interrupt contract. */ - -import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** Complete durable direct-child catalog row. */ -export type SubagentListEntry = - | { - kind: 'child' - id: SessionId - /** Whether the child Agent driver is running at the Host sampling boundary. */ - activity: 'running' | 'inactive' - /** Whether a direct descendant has durable `origin: 'subagent'`. */ - hasChildren: boolean - } & ( - | { - mode: 'one-shot' - label?: string - } - | { - mode: 'continuable' - label: string - } - ) - | { - kind: 'diagnostic' - id: SessionId - reason: 'corrupt' | 'unsupported' | 'unavailable' - } - -/** Inbox identity returned once the continuation accepts one human message. */ -export interface SubagentPromptReceipt { - messageId: MessageId -} - -/** Uniform acknowledgement that one interrupt request was admitted. */ -export interface SubagentInterruptReceipt { - accepted: true -} - -/** Durable parent/child address that selects subagent transport in the client. */ -export type SubagentAddress = - & { - parentSessionId: SessionId - childSessionId: SessionId - } - & ( - | { mode: 'one-shot' } - | { mode: 'continuable' } - ) - -/** Complete direct-child catalog plus the delivery-time parent availability hint. */ -export interface SubagentCatalog { - entries: SubagentListEntry[] - parentAvailable: boolean -} - -/** Subagent-domain unary methods. */ -export interface SubagentsApi { - /** - * Lists direct session-backed children without loading either side. Parent - * availability is a hint; continuable prompt performs the authoritative - * check. - */ - list( - request: RpcRequest<{ parentSessionId: SessionId }>, - signal?: AbortSignal, - ): Promise> - - /** - * Delivers human content to a continuable child through the exact live - * parent's continuation owner. Success identifies the message accepted by - * the child's FIFO inbox; later execution is independent of this request. - * Optional browser-zone provenance is validated and logged on that message. - */ - prompt( - request: RpcRequest< - Extract & { - content: ContentBlock[] - /** Optional browser zone sampled for this exact human prompt. */ - clientTimeZone?: string - } - >, - signal: AbortSignal, - ): Promise> - - /** - * Interrupts a live continuable child's current turn under the address's - * durable direct-parent authority, without requiring a live parent Agent, - * consulting the catalog, or resuming anything. Fire-and-return: `accepted` - * acknowledges the admitted cancel signal, not target quiescence, so the - * child may remain visibly running briefly. Unclaimed queued follow-ups are - * kept and parked; an absent, idle, or already-completed target is likewise - * `accepted`. - */ - interrupt( - request: RpcRequest>, - ): Promise> -} diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 21a89483dc..f65d5fafff 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -28,11 +28,6 @@ import { credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema, } from '../api/credentials.schema.ts' import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' -import { - subagentInterruptValueSchema, - subagentListValueSchema, - subagentPromptValueSchema, -} from '../api/subagents.schema.ts' /** * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary @@ -47,11 +42,6 @@ import { * Derived per method key from RpcMethodMap so a map row addition updates this mechanically. */ export interface IApiClient { - subagents: { - list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise>> - prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise>> - interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise>> - } host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise>> @@ -89,9 +79,6 @@ export interface IApiClient { * mirror of the handler's request table; key coverage compiler-enforced against RpcMethodMap). */ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType>> } = { - 'subagent.list': subagentListValueSchema, - 'subagent.prompt': subagentPromptValueSchema, - 'subagent.interrupt': subagentInterruptValueSchema, 'host.describe': hostDescribeValueSchema, 'host.pickDirectory': hostPickDirectoryValueSchema, 'host.listDirectory': hostListDirectoryValueSchema, @@ -240,12 +227,6 @@ export abstract class AbstractApiClient implements IApiClient { // ---- IApiClient API (arrow properties so destructured/passed references stay bound) ---- - readonly subagents: IApiClient['subagents'] = { - list: (payload, signal) => this.callUnary('subagent.list', payload, signal), - prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal), - interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal), - } - readonly host: IApiClient['host'] = { describe: (payload, signal) => this.callUnary('host.describe', payload, signal), // A native system dialog is user-paced and may legitimately stay open diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 1451741fc2..6b3a7d3b13 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -31,11 +31,6 @@ import { credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema, } from '../api/credentials.schema.ts' import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' -import { - subagentInterruptRequestSchema, - subagentListRequestSchema, - subagentPromptRequestSchema, -} from '../api/subagents.schema.ts' /** * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a @@ -54,9 +49,6 @@ type UnaryRoutes = { } const UNARY_ROUTES: UnaryRoutes = { - 'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) }, - 'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) }, - 'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 98ee7eca20..aab8e952d2 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -62,7 +62,7 @@ export interface Config { */ export class ApiProxyService extends Service implements ApiProxy { static inject = [ - 'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery', + 'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'sessionQuery', 'sessionController', ] @@ -72,7 +72,6 @@ export class ApiProxyService extends Service implements ApiProxy { .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z, }) - readonly subagents: ApiProxy['subagents'] readonly host: ApiProxy['host'] readonly skills: ApiProxy['skills'] readonly agentPresets: ApiProxy['agentPresets'] @@ -91,7 +90,6 @@ export class ApiProxyService extends Service implements ApiProxy { ? {} : { sessionExportCompressionLevel: config.sessionExportCompressionLevel }), }) - this.subagents = api.subagents this.host = api.host this.skills = api.skills this.agentPresets = api.agentPresets diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 87fb2a1b11..1e1d3e37df 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -53,9 +53,6 @@ { "path": "../../session-query/session-query" }, - { - "path": "../../subagent/subagent" - }, { "path": "../../skill/skill" }, diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 5cdb75b630..c89f11eee7 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -26,6 +26,14 @@ "types": "./lib/types/client.d.ts", "default": "./lib/types/client.js" }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -33,7 +41,11 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" ], "license": "MIT", "dependencies": { @@ -56,6 +68,7 @@ "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -108,6 +121,7 @@ "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/subagent/subagent/src/client.ts b/packages/subagent/subagent/src/client.ts index 602dcd8793..8a643d6284 100644 --- a/packages/subagent/subagent/src/client.ts +++ b/packages/subagent/subagent/src/client.ts @@ -1,7 +1,8 @@ /** - * Browser-safe subagent projection vocabulary. + * Browser-safe subagent projection and control vocabulary. * * @module @deepseek-ai/dsh-subagent/client */ +export type * from './control-types.ts' export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts new file mode 100644 index 0000000000..ea02674413 --- /dev/null +++ b/packages/subagent/subagent/src/control-types.ts @@ -0,0 +1,147 @@ +/** + * Client-safe subagent catalog and control vocabulary: the durable direct-child + * row both the listing and the browser catalog answer with, plus the + * browser-facing control surface's prompt, receipts, and failures. + * + * @module @deepseek-ai/dsh-subagent/control-types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { z as zCore } from 'zod' + +/** + * Client-minted identity of one browser prompt, persisted on the exact accepted + * message. It carries the Session Controller's `session-request-id` brand so a + * subagent prompt and an ordinary Session prompt share one identity + * vocabulary; that package depends on this one, so the brand is spelled here + * rather than imported. + */ +export type SubagentPromptRequestId = Branded<'session-request-id'> + +/** + * One durable direct-child row, ordered by header `createdAt` with ties broken + * on id. Only a candidate whose durable header has `origin: 'subagent'` is + * interpreted. A served `subagent` projection value produces a `child`; a + * settled candidate whose fold served no identity produces a `diagnostic`; a + * running candidate without one is omitted — its descriptor may not be + * appended yet (the creation window). Diagnostics relay the projection fold's + * outcome or a failed read, never a per-child event scan, and never expose + * model-hidden descriptor content. + */ +export type SubagentListEntry = + | { + readonly kind: 'child' + /** The durable child session id, stable across Activations. */ + readonly id: SessionId + /** + * Whether the child is live at the moment its reader sampled it: the + * durable listing reads the Session store (`running` means the logical + * record is resident, `inactive` that it exists only in persistence), + * while the browser catalog re-samples the child's Agent driver. Neither + * encodes a durable outcome, and a continuable child may still reject + * delivery as an ownership conflict. + */ + readonly activity: 'running' | 'inactive' + /** Whether a direct descendant has durable `origin: 'subagent'`. */ + readonly hasChildren: boolean + } & ( + | { + /** A terminal one-shot child. */ + readonly mode: 'one-shot' + /** Optional durable creation label from the child's descriptor. */ + readonly label?: string + } + | { + /** A resumable conversation. */ + readonly mode: 'continuable' + /** Durable creation label from the child's descriptor. */ + readonly label: string + } + ) + | { + readonly kind: 'diagnostic' + /** The candidate's session id. */ + readonly id: SessionId + /** + * Why the candidate has no `child` row: `corrupt` for a settled candidate + * whose projection fold served no identity (a missing, malformed, or + * unrecognized-version descriptor — deliberately undistinguished), and + * for any candidate whose log makes a registered unit's fold or schema + * throw (deterministic data damage, contained per child); `unavailable` + * when the candidate's Session observation was absent or transiently + * unreadable (retried on the next listing). `unsupported` is never produced; it remains in the + * union for consumers that route on it. + */ + readonly reason: 'corrupt' | 'unsupported' | 'unavailable' + } + +/** Complete direct-child catalog plus the delivery-time parent availability hint. */ +export interface SubagentCatalog { + readonly entries: readonly SubagentListEntry[] + readonly parentAvailable: boolean +} + +/** Durable parent/child address that selects subagent transport in the client. */ +export type SubagentAddress = + & { + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + } + & ( + | { readonly mode: 'one-shot' } + | { readonly mode: 'continuable' } + ) + +/** One human message addressed to a continuable direct child. */ +export interface SubagentPromptRequest { + /** Identity persisted on the accepted message, minted before the call. */ + readonly requestId: SubagentPromptRequestId + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + /** Required discriminator retained from the browser control address. */ + readonly mode: 'continuable' + /** Content delivered as the child's user message. */ + readonly content: ContentBlock[] + /** Optional browser zone sampled for this exact human prompt. */ + readonly clientTimeZone?: string +} + +/** Inbox identity returned once the continuation accepts one human message. */ +export interface SubagentPromptReceipt { + readonly messageId: MessageId +} + +/** Uniform acknowledgement that one interrupt request was admitted. */ +export interface SubagentInterruptReceipt { + readonly accepted: true +} + +/** + * Failure details the control surface answers with. The catalog read, the + * prompt, and the interrupt produce these codes; a Client fabricates + * `subagent-not-resumable` and `subagent-delivery-unavailable` for a one-shot + * address it refuses before the call, so both planes read one vocabulary. + */ +export interface SubagentControlErrorDetailsMap { + 'bad-request': { readonly issues: zCore.core.$ZodIssue[] } + cancelled: Record + 'invalid-time-zone': { readonly value: string } + 'subagent-parent-unavailable': { readonly parentSessionId: SessionId } + 'subagent-not-resumable': { readonly childSessionId: SessionId } + 'subagent-unauthorized': { readonly childSessionId: SessionId } + 'subagent-delivery-unavailable': { readonly childSessionId: SessionId } + 'subagent-projections-unavailable': Record + internal: Record +} + +/** One subagent control failure, returned without a carrier error. */ +export type SubagentControlError = { + [Code in keyof SubagentControlErrorDetailsMap]: { + readonly code: Code + readonly message: string + readonly details: SubagentControlErrorDetailsMap[Code] + } +}[keyof SubagentControlErrorDetailsMap] diff --git a/packages/subagent/subagent/src/control.ts b/packages/subagent/subagent/src/control.ts new file mode 100644 index 0000000000..661a43153a --- /dev/null +++ b/packages/subagent/subagent/src/control.ts @@ -0,0 +1,180 @@ +/** + * Browser-facing subagent control assembly: the catalog view sampled against + * the live Agent registry, one browser zone's validation, and the stable + * failure codes the Remote surface answers with. + * + * @module @deepseek-ai/dsh-subagent + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { z } from 'zod' +import type { + SubagentCatalog, SubagentControlErrorDetailsMap, SubagentListEntry, +} from './control-types.ts' +import { SubagentError } from './error.ts' + +/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ +const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ + +const SESSION_ID_SCHEMA = z.string().min(1) +const CONTROL_ID_SCHEMAS = { + 'subagent.list': z.object({ parentSessionId: SESSION_ID_SCHEMA }), + 'subagent.prompt': z.object({ + parentSessionId: SESSION_ID_SCHEMA, + childSessionId: SESSION_ID_SCHEMA, + mode: z.literal('continuable'), + }), + 'subagent.interrupt': z.object({ + parentSessionId: SESSION_ID_SCHEMA, + childSessionId: SESSION_ID_SCHEMA, + mode: z.literal('continuable'), + }), +} as const + +/** + * Validate and canonicalize one browser-supplied IANA zone at the wire boundary. + * @param value - the browser's reported zone name. + * @returns the canonical zone, or `undefined` when the name is unusable. + */ +export function canonicalClientTimeZone(value: string): string | undefined { + if (value.length === 0 || value.trim() !== value + || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined + try { + const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }) + .resolvedOptions().timeZone + /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */ + if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined + return canonical + } catch { + // Intl rejects unsupported zone names; the caller maps that parser rejection. + return undefined + } +} + +/** + * Refuse one Remote call with a stable business failure the carrier preserves. + * @param code - declared caller-facing code. + * @param message - human-readable refusal. + * @param details - that code's declared detail payload. + * @returns Never — the failure is thrown. + * @throws {TypertRemoteFailure} always. + */ +export function rejectControl( + code: Code, + message: string, + details: SubagentControlErrorDetailsMap[Code], +): never { + throw new TypertRemoteFailure({ code, message, details }) +} + +/** + * Apply the subagent payload checks that are stricter than generated + * branded-string codecs. + * @param method - method name carried in the failure message. + * @param payload - decoded control fields to validate. + * @throws {TypertRemoteFailure} `bad-request` with the original Zod issues. + */ +export function validateControlRequest( + method: keyof typeof CONTROL_ID_SCHEMAS, + payload: unknown, +): void { + const parsed = CONTROL_ID_SCHEMAS[method].safeParse(payload) + if (!parsed.success) { + return rejectControl('bad-request', `invalid payload for ${method}`, { + issues: parsed.error.issues, + }) + } +} + +/** + * Project one durable listing onto the catalog view, replacing each row's + * store-derived activity with the live Agent driver's status and reporting + * whether the exact parent Agent is live. Without an Agent registry no driver + * runs at all, so every row is inactive and the parent is unavailable. + * @param ctx - Host context that may carry the Agent registry. + * @param parentSessionId - the listed parent. + * @param entries - the durable direct-child listing. + * @returns the catalog view answered to one browser. + */ +export function catalogView( + ctx: Context, + parentSessionId: SessionId, + entries: readonly SubagentListEntry[], +): SubagentCatalog { + const agents = ctx.get('agents') + return { + entries: entries.map((entry): SubagentListEntry => entry.kind === 'child' + ? { ...entry, activity: agents?.get(entry.id)?.status === 'running' ? 'running' : 'inactive' } + : entry), + parentAvailable: agents?.get(parentSessionId) !== undefined, + } +} + +/** + * Refuse one catalog read while preserving cancellation and a missing + * projections registry as distinct failures. + * @param error - the thrown value. + * @param signal - the caller's cancellation. + * @returns Never — the refusal is thrown. + * @throws {TypertRemoteFailure} always. + */ +export function rejectCatalogRead(error: unknown, signal: AbortSignal): never { + if (isCancellation(error, signal)) { + return rejectControl('cancelled', 'subagent catalog read was cancelled', {}) + } + if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { + return rejectControl( + 'subagent-projections-unavailable', + 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', + {}, + ) + } + return rejectControl('internal', 'subagent catalog read failed', {}) +} + +/** + * Refuse one continuation prompt without exposing provider detail: admission + * failures the caller can act on keep their own code, everything else is + * internal. + * @param error - the thrown value. + * @param childSessionId - the addressed child. + * @param signal - the caller's cancellation. + * @returns Never — the refusal is thrown. + * @throws {TypertRemoteFailure} always. + */ +export function rejectPrompt(error: unknown, childSessionId: SessionId, signal: AbortSignal): never { + if (isCancellation(error, signal)) { + return rejectControl('cancelled', 'subagent prompt was cancelled', {}) + } + if (error instanceof SubagentError) { + switch (error.code) { + case 'NOT_RESUMABLE': + return rejectControl('subagent-not-resumable', 'subagent cannot be resumed', { childSessionId }) + case 'UNAUTHORIZED': + return rejectControl( + 'subagent-unauthorized', + 'subagent does not belong to this parent', + { childSessionId }, + ) + case 'DRAINING': + case 'ACTIVATION_CLOSING': + case 'CONTINUATION_UNAVAILABLE': + case 'PERSISTENCE_UNAVAILABLE': + return rejectControl( + 'subagent-delivery-unavailable', + 'subagent follow-up is temporarily unavailable', + { childSessionId }, + ) + // A code outside the admission vocabulary is not the caller's move to make. + default: + break + } + } + return rejectControl('internal', 'subagent prompt failed', {}) +} + +function isCancellation(error: unknown, signal: AbortSignal): boolean { + return signal.aborted || (error instanceof SubagentError && error.code === 'CANCELLED') +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0701ff7b90..444f4ee857 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -29,13 +29,25 @@ * @module @deepseek-ai/dsh-subagent */ -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { + canonicalClientTimeZone, catalogView, rejectCatalogRead, rejectControl, rejectPrompt, + validateControlRequest, +} from './control.ts' +import type { + SubagentCatalog, + SubagentInterruptReceipt, + SubagentPromptReceipt, + SubagentPromptRequest, + SubagentPromptRequestId, +} from './control-types.ts' import type { ContinuableCreateRequest, ContinuableCreateSpec, @@ -121,7 +133,8 @@ export type { SubagentSettledMessageSource, } from './continuation.ts' export type { ContinuableSetupContribution } from './activation-setup-registry.ts' -export type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts' +export type * from './control-types.ts' +export type { SubagentDescendantListEntry } from './list-children.ts' export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts' export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' @@ -166,8 +179,21 @@ declare module '@deepseek-ai/cordis' { } } +/** + * Durable attribution of one browser-authored follow-up. The Session + * Controller declares this `user-rpc` message source and depends on this + * package, so the fields are spelled here: `MessageSource`'s `user` member + * accepts the record and the correlation id rides the durable message the + * Client reconciles its optimistic prompt against. + */ +interface BrowserPromptSource { + readonly kind: 'user' + readonly rpcId: SubagentPromptRequestId + readonly clientTimeZone?: string +} + /** Named provider registry with one-shot runs, durable discovery, and continuable-child operations. */ -export class SubagentRuntime extends Service { +export class SubagentRuntime extends TypertRemoteService { private providers = new Map() private continuations: SubagentContinuationManager | undefined /** Deployment contributions composed into unpublished continuable children. */ @@ -363,6 +389,114 @@ export class SubagentRuntime extends Service { return listSubagentDescendants(this.ctx, rootSessionId, signal) } + /** + * Remote face of {@link listChildren} for one browser: the durable listing + * plus live Agent activity and the delivery-time parent availability hint. + * Parent availability is a hint; {@link prompt} performs the authoritative + * check. Named apart from the provider-name {@link list}, which owns the + * member. + * @param parentSessionId - parent session whose direct children are listed. + * @param signal - carrier cancellation forwarded to Session queries. + * @returns the catalog view for that parent. + * @throws {TypertRemoteFailure} `bad-request` for an empty parent id, + * `cancelled` for an aborted read, `subagent-projections-unavailable` when + * the deployment has no projection registry, otherwise `internal`. + */ + @Remote('list') + async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise { + validateControlRequest('subagent.list', { parentSessionId }) + try { + return catalogView(this.ctx, parentSessionId, await this.listChildren(parentSessionId, signal)) + } catch (error: unknown) { + return rejectCatalogRead(error, signal) + } + } + + /** + * Deliver one browser-authored message to a continuable child through the + * exact live direct parent, retaining the caller-minted request identity and + * validated browser zone on the accepted message. Success identifies the + * message the child's FIFO inbox accepted; later execution is independent of + * this call. + * @param request - durable address, minted identity, content, and optional browser zone. + * @param signal - carrier cancellation, owning the call until inbox acceptance. + * @returns the accepted message's inbox identity. + * @throws {TypertRemoteFailure} `bad-request`, `invalid-time-zone`, + * `subagent-parent-unavailable`, `subagent-not-resumable`, + * `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or + * `internal`. + */ + @Remote('prompt') + async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise { + const { parentSessionId, childSessionId, clientTimeZone } = request + validateControlRequest('subagent.prompt', request) + const canonicalTimeZone = clientTimeZone === undefined + ? undefined + : canonicalClientTimeZone(clientTimeZone) + if (clientTimeZone !== undefined && canonicalTimeZone === undefined) { + return rejectControl( + 'invalid-time-zone', + 'clientTimeZone must be UTC or a valid IANA Area/Location name', + { value: clientTimeZone }, + ) + } + const parent = this.ctx.get('agents')?.get(parentSessionId) + if (parent === undefined) { + return rejectControl( + 'subagent-parent-unavailable', + `parent session "${parentSessionId}" is not live`, + { parentSessionId }, + ) + } + const source: BrowserPromptSource = { + kind: 'user', + rpcId: request.requestId, + ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), + } + const content: ContentBlock[] = [...request.content] + try { + return { messageId: await this.followup(parent, childSessionId, content, { source, signal }) } + } catch (error: unknown) { + return rejectPrompt(error, childSessionId, signal) + } + } + + /** + * Remote face of {@link interrupt} under one durable parent address. No + * catalog, history, persistence, or parent Agent lookup runs: the core + * primitive alone authorizes the address against the live Activation, which + * is what keeps a live child interruptible while its parent Agent is offline. + * Absent, idle, and already-completed targets are accepted no-ops there. + * @param childSessionId - durable child session id to interrupt. + * @param parentSessionId - durable direct parent whose authority is claimed. + * @param mode - required continuable-address discriminator. + * @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent. + * @throws {TypertRemoteFailure} `bad-request` for an empty id, + * `subagent-unauthorized` when the address does not own the live target, + * otherwise `internal`. + */ + @Remote('interruptByParent') + interruptByParent( + childSessionId: SessionId, + parentSessionId: SessionId, + mode: 'continuable', + ): SubagentInterruptReceipt { + validateControlRequest('subagent.interrupt', { childSessionId, parentSessionId, mode }) + try { + this.interrupt(childSessionId, { kind: 'user', parentSessionId }) + } catch (error: unknown) { + if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') { + return rejectControl( + 'subagent-unauthorized', + 'subagent does not belong to this parent', + { childSessionId }, + ) + } + return rejectControl('internal', 'subagent interrupt failed', {}) + } + return { accepted: true } + } + /** * Register a provider under its name. Registration is effect-scoped and HMR * safe; removing a provider blocks new starts but does not revoke runs that diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index ffefa624b8..b04289d8bf 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -21,9 +21,12 @@ import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache' import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' +import type { SubagentListEntry } from './control-types.ts' import { SubagentError } from './error.ts' import type { SubagentIdentityProjection } from './projection-types.ts' +export type { SubagentListEntry } from './control-types.ts' + /** * Concurrent cold observations per explicit catalog listing. Current Session * persistence providers are local; a networked provider must promote this to @@ -31,61 +34,6 @@ import type { SubagentIdentityProjection } from './projection-types.ts' */ const COLD_READ_CONCURRENCY = 4 -/** - * One entry of a {@link listChildren} result, ordered by header `createdAt` - * with ties broken on id. Only a candidate whose durable header has - * `origin: 'subagent'` is interpreted. A served `subagent` projection value - * produces a `child`; a settled candidate whose fold served no identity - * produces a `diagnostic`; a running candidate without one is omitted — its - * descriptor may not be appended yet (the creation window). Diagnostics - * relay the projection fold's outcome or a failed read, never a per-child - * event scan, and never expose model-hidden descriptor content. - */ -export type SubagentListEntry = - | { - readonly kind: 'child' - /** The durable child session id, stable across Activations. */ - readonly id: SessionId - /** - * Store snapshot activity: `running` means the logical record is live in - * `ctx.sessions`; `inactive` means it exists only in persistence. Neither - * encodes a durable outcome, and a continuable child may still reject - * delivery as an ownership conflict. - */ - readonly activity: 'running' | 'inactive' - /** Whether a direct descendant has durable `origin: 'subagent'`. */ - readonly hasChildren: boolean - } & ( - | { - /** A terminal one-shot child. */ - readonly mode: 'one-shot' - /** Optional durable creation label from the child's descriptor. */ - readonly label?: string - } - | { - /** A resumable conversation. */ - readonly mode: 'continuable' - /** Durable creation label from the child's descriptor. */ - readonly label: string - } - ) - | { - readonly kind: 'diagnostic' - /** The candidate's session id. */ - readonly id: SessionId - /** - * Why the candidate has no `child` row: `corrupt` for a settled candidate - * whose projection fold served no identity (a missing, malformed, or - * unrecognized-version descriptor — deliberately undistinguished), and - * for any candidate whose log makes a registered unit's fold or schema - * throw (deterministic data damage, contained per child); `unavailable` - * when the candidate's Session observation was absent or transiently - * unreadable (retried on the next listing). `unsupported` is never produced; it remains in the - * union for consumers that route on it. - */ - readonly reason: 'corrupt' | 'unsupported' | 'unavailable' - } - /** * One entry of a descendant listing: the interpreted subagent facts plus its * position in the complete session tree. `parentId` is the durable direct diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index ae39b9a78b..4c86dd8250 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -56,6 +56,9 @@ { "path": "../../jobs/jobs" }, + { + "path": "../../typert/protocol" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2ddd689f7..eccfb28d25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -722,6 +722,9 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval @@ -5715,9 +5718,6 @@ importers: '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill - '@deepseek-ai/dsh-subagent': - specifier: workspace:^ - version: link:../../subagent/subagent '@deepseek-ai/dsh-util-crypto': specifier: workspace:^ version: link:../../util/crypto @@ -8241,6 +8241,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval @@ -9049,6 +9052,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent '@types/react': specifier: ~18.3.1 version: 18.3.31