index.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. /**
  2. * Out-of-process SDK subagent backend. Each child is a complete DeepSeek
  3. * Harness runtime in its own process — own `cordis.yml`-decided composition,
  4. * session, model route, and tools — driven over stdio JSON-RPC through the
  5. * TypeScript SDK client, so it shares no Cordis context and advertises no
  6. * parent-enforced start capabilities; the ONE thing it reads off
  7. * `request.parent` is the session's workspace cwd. This plugin uses named
  8. * exports only; a default would hide its loader metadata (see
  9. * `docs/postmortem/0001-acp-default-export-drops-inject.md`).
  10. * @module @deepseek-ai/dsh-subagent-dsh-sdk
  11. */
  12. import type { Context } from 'cordis'
  13. import z from 'schemastery'
  14. import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
  15. import { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent'
  16. import {
  17. DEFAULT_DISPOSE_EOF_GRACE_MS,
  18. DEFAULT_DISPOSE_GRACE_MS,
  19. DEFAULT_SHUTDOWN_TIMEOUT_MS,
  20. startSdkRun,
  21. type SdkRunSpec,
  22. } from './run.ts'
  23. export const name = 'subagent-dsh-sdk'
  24. export const inject = ['subagents']
  25. /** Config: how to spawn and drive the child SDK runtime process. */
  26. export interface Config {
  27. /** Provider name on `ctx.subagents` (default `dsh-sdk`). */
  28. providerName: string
  29. /** The executable to spawn for each run (the child runtime bin or packaged exe). */
  30. command: string
  31. /** Arguments passed to {@link command} (typically the child's `cordis.yml` path). */
  32. args: string[]
  33. /**
  34. * Working directory override for the child process and its SDK session
  35. * workspace. Must be non-empty; a relative path resolves against the
  36. * harness launch directory at load, and the result must be an existing
  37. * directory. When omitted, each child inherits its delegating parent
  38. * session's cwd — and starting one from a parent session that has no cwd
  39. * fails.
  40. */
  41. cwd?: string
  42. /** Provider route the child runtime initializes with (default `deepseek-official`). */
  43. provider: string
  44. /** Model the child runtime initializes with (default `deepseek-v4-flash`). */
  45. model: string
  46. /** Optional per-request output-token cap for the child runtime. */
  47. maxTokens?: number
  48. /**
  49. * Extra environment variables for the child process — e.g. the child
  50. * runtime's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG` naming its
  51. * config. Forwarded on top of a credential-scrubbed copy of the parent
  52. * env, so an explicit key here reaches the child while ambient secrets do
  53. * not leak implicitly.
  54. */
  55. env: Record<string, string>
  56. /** Bound (ms) on the protocol `shutdown` exchange during dispose. */
  57. shutdownTimeoutMs?: number
  58. /**
  59. * Grace period (ms) for the child's EOF-driven quiesce on dispose — its
  60. * window to flush persistence and tear down its own nested subprocesses
  61. * before the parent escalates to a signal.
  62. */
  63. disposeEofGraceMs?: number
  64. /** Termination confirmation window (ms), including forced exit on every platform. */
  65. disposeGraceMs?: number
  66. }
  67. export const Config: z<Config> = z.object({
  68. providerName: z.string().default('dsh-sdk'),
  69. command: z.string().required(),
  70. args: z.array(z.string()).default([]),
  71. cwd: z.string(),
  72. provider: z.string().default('deepseek-official'),
  73. model: z.string().default('deepseek-v4-flash'),
  74. maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
  75. env: z.dict(z.string()).default({}),
  76. shutdownTimeoutMs: z.number().default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
  77. disposeEofGraceMs: z.number().default(DEFAULT_DISPOSE_EOF_GRACE_MS),
  78. disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
  79. })
  80. /** The shape after schemastery applied the defaults (`cwd` and `maxTokens` have none). */
  81. type ResolvedConfig = Required<Omit<Config, 'cwd' | 'maxTokens'>> & Pick<Config, 'cwd' | 'maxTokens'>
  82. /**
  83. * The SDK provider. Advertises NO start-time capabilities: an out-of-process
  84. * child cannot honor `outputSchema`/`maxDepth`/`toolFilter`/`persona` (the
  85. * service rejects a request needing any of them before `start` runs).
  86. */
  87. class SdkProvider implements SubagentProvider {
  88. readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
  89. // Context contract: an out-of-process SDK child starts fresh — no parent conversation crosses the process boundary.
  90. readonly inheritsParentContext = false
  91. constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {}
  92. start(request: SubagentStartRequest) {
  93. const spec: SdkRunSpec = {
  94. command: this.config.command,
  95. args: this.config.args,
  96. cwd: resolveChildCwd('subagent-dsh-sdk', this.config.cwd, request.parent.session.header.cwd),
  97. provider: this.config.provider,
  98. model: this.config.model,
  99. ...this.config.maxTokens === undefined ? {} : { maxTokens: this.config.maxTokens },
  100. env: this.config.env,
  101. shutdownTimeoutMs: this.config.shutdownTimeoutMs,
  102. disposeEofGraceMs: this.config.disposeEofGraceMs,
  103. disposeGraceMs: this.config.disposeGraceMs,
  104. onError: (error, stopReason) => {
  105. // The seam forbids `result` rejecting, so a child-level failure is
  106. // flattened to a stop reason — preserve it here rather than losing it.
  107. this.ctx.logger.warn(`subagent-dsh-sdk "${this.name}": child run failed (${stopReason}): ${error.message}`)
  108. },
  109. }
  110. return startSdkRun(request, spec)
  111. }
  112. }
  113. export function apply(ctx: Context, config: Config): void {
  114. // schemastery (Config) has already filled every defaulted field.
  115. const resolved = config as ResolvedConfig
  116. assertPositiveFinite('subagent-dsh-sdk', 'shutdownTimeoutMs', resolved.shutdownTimeoutMs)
  117. assertPositiveFinite('subagent-dsh-sdk', 'disposeEofGraceMs', resolved.disposeEofGraceMs)
  118. assertPositiveFinite('subagent-dsh-sdk', 'disposeGraceMs', resolved.disposeGraceMs)
  119. if (resolved.maxTokens !== undefined && (!Number.isSafeInteger(resolved.maxTokens) || resolved.maxTokens <= 0)) {
  120. throw new TypeError('subagent-dsh-sdk maxTokens must be a positive safe integer')
  121. }
  122. // Interpret a relative configured cwd against the harness launch directory
  123. // ONCE, at load, and fail a misconfigured directory here — not per start.
  124. const configuredCwd = validateConfiguredCwd('subagent-dsh-sdk', resolved.cwd)
  125. const validated: ResolvedConfig = configuredCwd === undefined
  126. ? resolved
  127. : { ...resolved, cwd: configuredCwd }
  128. ctx.subagents.registerProvider(new SdkProvider(validated.providerName, ctx, validated))
  129. }