index.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. /**
  2. * Model-facing foreground Ralph loop over the workflow and subagent seams. A
  3. * fixed script starts one fresh structured-output child per round, carrying
  4. * only the immutable objective and the previous bounded handoff between them.
  5. * @module @deepseek-ai/dsh-tool-ralph
  6. */
  7. import type { Context } from 'cordis'
  8. import z from 'schemastery'
  9. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  10. import type { JsonValue } from '@deepseek-ai/dsh-session'
  11. import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
  12. import { defineTool } from '@deepseek-ai/dsh-tools'
  13. import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
  14. import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow'
  15. // Declaration merge only: makes ctx.systemPrompt visible for section registration.
  16. import type {} from '@deepseek-ai/dsh-system-prompt'
  17. export const name = 'tool-ralph'
  18. export const inject = ['tools', 'workflows', 'subagents', 'systemPrompt']
  19. /** Deployment policy for the fixed Ralph workflow. */
  20. export interface Config {
  21. /** Fresh structured-output provider used for every round (default `spawn`). */
  22. subagentProvider?: string
  23. /** Default and deployment ceiling for one call's round count (default 256). */
  24. maxRounds?: number
  25. /** Maximum serialized characters in one structured handoff (default 16384). */
  26. maxHandoffChars?: number
  27. /** Maximum characters in a successful parent-facing terminal text (default 16384). */
  28. maxResultChars?: number
  29. }
  30. /** Schemastery configuration for the Ralph tool. */
  31. export const Config: z<Config> = z.object({
  32. subagentProvider: z.string().default('spawn'),
  33. maxRounds: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(256),
  34. maxHandoffChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384),
  35. maxResultChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384),
  36. })
  37. interface ResolvedConfig {
  38. readonly subagentProvider: string
  39. readonly maxRounds: number
  40. readonly maxHandoffChars: number
  41. readonly maxResultChars: number
  42. }
  43. type RalphRoundStatus = 'continue' | 'complete' | 'blocked'
  44. interface RalphRoundReport {
  45. readonly status: RalphRoundStatus
  46. readonly summary: string
  47. readonly evidence: string[]
  48. readonly nextSteps: string[]
  49. readonly blocker: string
  50. }
  51. type RalphRunStatus = 'complete' | 'blocked' | 'budget-limited'
  52. interface RalphRunResult {
  53. readonly status: RalphRunStatus
  54. readonly roundsStarted: number
  55. readonly report: RalphRoundReport
  56. }
  57. interface RalphRoundFailure {
  58. readonly status: 'round-failed'
  59. readonly roundsStarted: number
  60. readonly lastReport?: RalphRoundReport
  61. }
  62. type RalphTerminalResult = RalphRunResult | RalphRoundFailure
  63. interface RalphCallArgs {
  64. objective: string
  65. maxRounds?: number
  66. }
  67. const RALPH_META = {
  68. name: 'ralph-loop',
  69. description: 'Iterate toward one objective with a fresh child and bounded structured handoff per round.',
  70. phases: [{ title: 'Fresh-agent rounds', detail: 'One clean child context per Ralph round.' }],
  71. }
  72. /**
  73. * Fixed, deployment-owned orchestration. The model supplies data only; it
  74. * cannot alter the loop, provider route, schema, or handoff validation.
  75. */
  76. const RALPH_SCRIPT = String.raw`
  77. const reportSchema = {
  78. type: 'object',
  79. properties: {
  80. status: { type: 'string', enum: ['continue', 'complete', 'blocked'] },
  81. summary: { type: 'string' },
  82. evidence: { type: 'array', items: { type: 'string' } },
  83. nextSteps: { type: 'array', items: { type: 'string' } },
  84. blocker: { type: 'string' },
  85. },
  86. required: ['status', 'summary', 'evidence', 'nextSteps', 'blocker'],
  87. additionalProperties: false,
  88. }
  89. function normalizedText(value) {
  90. return typeof value === 'string' && value.length > 0 && value === value.trim()
  91. }
  92. function normalizedList(value) {
  93. return Array.isArray(value) && value.every(normalizedText)
  94. }
  95. function validateReport(report) {
  96. if (report === null || typeof report !== 'object' || Array.isArray(report)) {
  97. throw new Error('Ralph child returned no structured round report')
  98. }
  99. if (!normalizedText(report.summary)) {
  100. throw new Error('Ralph round report summary must be non-empty and normalized')
  101. }
  102. if (!normalizedList(report.evidence) || !normalizedList(report.nextSteps)) {
  103. throw new Error('Ralph round report evidence and nextSteps must contain only non-empty normalized strings')
  104. }
  105. if (typeof report.blocker !== 'string' || report.blocker !== report.blocker.trim()) {
  106. throw new Error('Ralph round report blocker must be a normalized string')
  107. }
  108. switch (report.status) {
  109. case 'continue':
  110. if (report.nextSteps.length === 0 || report.blocker !== '') {
  111. throw new Error('a continuing Ralph report needs nextSteps and an empty blocker')
  112. }
  113. break
  114. case 'complete':
  115. if (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '') {
  116. throw new Error('a complete Ralph report needs evidence, no nextSteps, and an empty blocker')
  117. }
  118. break
  119. case 'blocked':
  120. if (!normalizedText(report.blocker)) {
  121. throw new Error('a blocked Ralph report needs a concrete blocker')
  122. }
  123. break
  124. default:
  125. throw new Error('Ralph round report status is invalid')
  126. }
  127. const serialized = JSON.stringify(report)
  128. if (serialized.length > args.maxHandoffChars) {
  129. throw new Error('Ralph round report exceeds maxHandoffChars (' + serialized.length + ' > ' + args.maxHandoffChars + ')')
  130. }
  131. return report
  132. }
  133. let previous
  134. phase('Fresh-agent rounds')
  135. for (let round = 1; round <= args.maxRounds; round += 1) {
  136. const prior = previous === undefined ? '(none — this is the first round)' : JSON.stringify(previous)
  137. const prompt = [
  138. 'You are one fresh worker in a foreground Ralph loop. You receive no parent conversation and no prior child session. Do not call the ralph tool: this round already is its worker.',
  139. 'Immutable objective:\n' + args.objective,
  140. 'Ralph round: ' + round + ' of ' + args.maxRounds + '.',
  141. 'The shared workspace and its current working tree are the long-term memory and source of truth. Inspect them before acting, preserve existing work, perform concrete in-scope work, and verify what you change. Treat the previous report only as a bounded handoff; confirm it against the workspace.',
  142. 'Previous structured handoff:\n' + prior,
  143. 'Return one report with exact normalized strings. Use status continue with at least one nextSteps entry while useful work remains; complete only with concrete evidence and no nextSteps; blocked only when no meaningful progress is possible without human input or an external-state change. blocker must be empty unless blocked.',
  144. ].join('\n\n')
  145. const rawReport = await agent(prompt, {
  146. label: 'Ralph round ' + round,
  147. phase: 'Fresh-agent rounds',
  148. schema: reportSchema,
  149. })
  150. if (rawReport === null) {
  151. return { status: 'round-failed', roundsStarted: round, lastReport: previous ?? null }
  152. }
  153. const report = validateReport(rawReport)
  154. if (report.status === 'complete') return { status: 'complete', roundsStarted: round, report }
  155. if (report.status === 'blocked') return { status: 'blocked', roundsStarted: round, report }
  156. previous = report
  157. }
  158. return { status: 'budget-limited', roundsStarted: args.maxRounds, report: previous }
  159. `
  160. const DESCRIPTION = 'Run a foreground fresh-agent Ralph loop toward one immutable objective. '
  161. + 'Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round '
  162. + 'opens a new child with no parent conversation or prior child session; the shared workspace is '
  163. + 'long-term memory, and only a bounded structured report crosses rounds. The call returns when '
  164. + 'a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work '
  165. + 'belongs to goal tools.'
  166. /** Validate defaults even when a caller invokes apply() without Loader normalization. */
  167. function resolveConfig(config: Config): ResolvedConfig {
  168. const subagentProvider = config.subagentProvider ?? 'spawn'
  169. const maxRounds = config.maxRounds ?? 256
  170. const maxHandoffChars = config.maxHandoffChars ?? 16_384
  171. const maxResultChars = config.maxResultChars ?? 16_384
  172. if (subagentProvider.length === 0 || subagentProvider !== subagentProvider.trim()) {
  173. throw new TypeError('subagentProvider must be a non-empty normalized string')
  174. }
  175. if (!Number.isSafeInteger(maxRounds) || maxRounds < 1) {
  176. throw new TypeError('maxRounds must be a positive safe integer')
  177. }
  178. if (!Number.isSafeInteger(maxHandoffChars) || maxHandoffChars < 1) {
  179. throw new TypeError('maxHandoffChars must be a positive safe integer')
  180. }
  181. if (!Number.isSafeInteger(maxResultChars) || maxResultChars < 1) {
  182. throw new TypeError('maxResultChars must be a positive safe integer')
  183. }
  184. return { subagentProvider, maxRounds, maxHandoffChars, maxResultChars }
  185. }
  186. /** Resolve one model-selected cap against the deployment ceiling. */
  187. function resolveMaxRounds(requested: number | undefined, ceiling: number): number {
  188. const value = requested ?? ceiling
  189. if (!Number.isSafeInteger(value) || value < 1) {
  190. throw new TypeError('Ralph maxRounds must be a positive safe integer')
  191. }
  192. if (value > ceiling) {
  193. throw new TypeError(`Ralph maxRounds ${value} exceeds the deployment ceiling ${ceiling}`)
  194. }
  195. return value
  196. }
  197. /** Require the configured route to mean a genuinely fresh structured child. */
  198. function requireFreshProvider(ctx: Context, name: string): SubagentProvider {
  199. const provider = ctx.subagents.getProvider(name)
  200. if (provider === undefined) {
  201. throw new Error(`Ralph subagent provider "${name}" is not registered`)
  202. }
  203. if (!provider.capabilities.outputSchema) {
  204. throw new Error(`Ralph subagent provider "${name}" does not support structured output`)
  205. }
  206. if (provider.inheritsParentContext) {
  207. throw new Error(`Ralph subagent provider "${name}" inherits parent context; Ralph requires a fresh provider`)
  208. }
  209. return provider
  210. }
  211. function isRecord(value: unknown): value is Record<string, unknown> {
  212. return typeof value === 'object' && value !== null && !Array.isArray(value)
  213. }
  214. function normalizedText(value: unknown): value is string {
  215. return typeof value === 'string' && value.length > 0 && value === value.trim()
  216. }
  217. function normalizedList(value: unknown): value is string[] {
  218. return Array.isArray(value) && value.every(normalizedText)
  219. }
  220. /** Defensively decode the fixed script's report across an implementation seam. */
  221. function readReport(value: unknown, expectedStatus: RalphRoundStatus, maxChars: number): RalphRoundReport {
  222. if (!isRecord(value)
  223. || Object.keys(value).sort().join(',') !== 'blocker,evidence,nextSteps,status,summary'
  224. || value['status'] !== expectedStatus
  225. || !normalizedText(value['summary'])
  226. || !normalizedList(value['evidence'])
  227. || !normalizedList(value['nextSteps'])
  228. || typeof value['blocker'] !== 'string'
  229. || value['blocker'] !== value['blocker'].trim()) {
  230. throw new Error('Ralph workflow returned a malformed round report')
  231. }
  232. const report: RalphRoundReport = {
  233. status: expectedStatus,
  234. summary: value['summary'],
  235. evidence: value['evidence'],
  236. nextSteps: value['nextSteps'],
  237. blocker: value['blocker'],
  238. }
  239. if (expectedStatus === 'continue' && (report.nextSteps.length === 0 || report.blocker !== '')) {
  240. throw new Error('Ralph workflow returned an invalid continuing report')
  241. }
  242. if (expectedStatus === 'complete'
  243. && (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '')) {
  244. throw new Error('Ralph workflow returned an invalid completion report')
  245. }
  246. if (expectedStatus === 'blocked' && !normalizedText(report.blocker)) {
  247. throw new Error('Ralph workflow returned an invalid blocked report')
  248. }
  249. const chars = JSON.stringify(report).length
  250. if (chars > maxChars) {
  251. throw new Error(`Ralph workflow returned an oversized handoff (${chars} > ${maxChars})`)
  252. }
  253. return report
  254. }
  255. /** Defensively decode the fixed script's terminal value. */
  256. function readRunResult(value: unknown, maxRounds: number, maxHandoffChars: number): RalphTerminalResult {
  257. if (!isRecord(value)
  258. || typeof value['roundsStarted'] !== 'number'
  259. || !Number.isSafeInteger(value['roundsStarted'])
  260. || value['roundsStarted'] < 1
  261. || value['roundsStarted'] > maxRounds) {
  262. throw new Error('Ralph workflow returned a malformed terminal result')
  263. }
  264. const roundsStarted = value['roundsStarted']
  265. switch (value['status']) {
  266. case 'complete':
  267. if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') {
  268. throw new Error('Ralph workflow returned a malformed terminal result')
  269. }
  270. return { status: 'complete', roundsStarted, report: readReport(value['report'], 'complete', maxHandoffChars) }
  271. case 'blocked':
  272. if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') {
  273. throw new Error('Ralph workflow returned a malformed terminal result')
  274. }
  275. return { status: 'blocked', roundsStarted, report: readReport(value['report'], 'blocked', maxHandoffChars) }
  276. case 'budget-limited':
  277. if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') {
  278. throw new Error('Ralph workflow returned a malformed terminal result')
  279. }
  280. if (roundsStarted !== maxRounds) {
  281. throw new Error('Ralph workflow returned budget-limited before the round limit')
  282. }
  283. return { status: 'budget-limited', roundsStarted, report: readReport(value['report'], 'continue', maxHandoffChars) }
  284. case 'round-failed': {
  285. if (Object.keys(value).sort().join(',') !== 'lastReport,roundsStarted,status') {
  286. throw new Error('Ralph workflow returned a malformed terminal result')
  287. }
  288. if (roundsStarted === 1) {
  289. if (value['lastReport'] !== null) {
  290. throw new Error('Ralph workflow returned an invalid first-round failure')
  291. }
  292. return { status: 'round-failed', roundsStarted }
  293. }
  294. if (value['lastReport'] === null) {
  295. throw new Error('Ralph workflow returned a round failure without its last handoff')
  296. }
  297. return {
  298. status: 'round-failed',
  299. roundsStarted,
  300. lastReport: readReport(value['lastReport'], 'continue', maxHandoffChars),
  301. }
  302. }
  303. default:
  304. throw new Error('Ralph workflow returned an unknown terminal status')
  305. }
  306. }
  307. /** A non-clean workflow finish is an error, never a partial Ralph success. */
  308. function stopReasonError(result: WorkflowResult): string | undefined {
  309. switch (result.stopReason) {
  310. case 'completed':
  311. return undefined
  312. case 'cancelled':
  313. return `Ralph workflow was cancelled${result.error === undefined ? '' : ` (${result.error})`}`
  314. case 'error':
  315. return `Ralph workflow failed: ${result.error ?? 'unknown error'}`
  316. /* v8 ignore start -- WorkflowStopReason is closed; a future variant must fail loud here. */
  317. default:
  318. return `Ralph workflow ended abnormally (${String(result.stopReason satisfies never)})`
  319. /* v8 ignore stop */
  320. }
  321. }
  322. const TRUNCATION_NOTICE = '\n… [truncated]'
  323. /** Bound complete parent-facing text, including its envelope and truncation marker. */
  324. function boundResult(text: string, maxChars: number): string {
  325. if (text.length <= maxChars) return text
  326. if (maxChars <= TRUNCATION_NOTICE.length) return TRUNCATION_NOTICE.slice(0, maxChars)
  327. return `${text.slice(0, maxChars - TRUNCATION_NOTICE.length)}${TRUNCATION_NOTICE}`
  328. }
  329. /** Render the fixed terminal envelope without presenting self-report as certification. */
  330. function renderResult(result: RalphRunResult, maxChars: number): string {
  331. const rounds = `${result.roundsStarted} round${result.roundsStarted === 1 ? '' : 's'}`
  332. let text: string
  333. switch (result.status) {
  334. case 'complete':
  335. text = `Ralph worker reported completion after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
  336. break
  337. case 'blocked':
  338. text = `Ralph worker reported a blocker after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
  339. break
  340. case 'budget-limited':
  341. text = `Ralph reached its ${rounds} limit; the worker reported work remaining.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
  342. break
  343. }
  344. return boundResult(text, maxChars)
  345. }
  346. /** Canonical Ralph result fields shared by schema inference and rendering. */
  347. const RALPH_OUTPUT_PROPERTIES = {
  348. runId: { type: 'string', required: true },
  349. agentsStarted: { type: 'integer', required: true },
  350. result: { type: 'json', required: true },
  351. } as const
  352. /** Render an ordinary child failure with the most recent durable handoff. */
  353. function renderRoundFailure(result: RalphRoundFailure, maxChars: number): string {
  354. const header = `Ralph round ${result.roundsStarted} child failed before producing a structured report.`
  355. const text = result.lastReport === undefined
  356. ? `${header}\nNo previous handoff was available.`
  357. : `${header}\nLast successful handoff:\n${JSON.stringify(result.lastReport, null, 2)}`
  358. return boundResult(text, maxChars)
  359. }
  360. function presentCall(args: RalphCallArgs): ToolCallView {
  361. return { card: 'generic', title: 'ralph', rawInput: args.objective }
  362. }
  363. function presentResult(args: RalphCallArgs, result: { content: ContentBlock[]; isError: boolean }): ToolResultView {
  364. void args
  365. void result
  366. return { card: 'generic' }
  367. }
  368. /** Register the fixed Ralph tool and its explicit-ask usage policy. */
  369. export function apply(ctx: Context, config: Config): void {
  370. const resolved = resolveConfig(config)
  371. ctx.systemPrompt.section({
  372. name: 'tool:ralph',
  373. order: 116,
  374. text: 'Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.',
  375. })
  376. ctx.tools.register(defineTool({
  377. name: 'ralph',
  378. description: DESCRIPTION,
  379. parameters: {
  380. objective: {
  381. type: 'string',
  382. required: true,
  383. description: 'The immutable completion objective for every fresh Ralph round.',
  384. },
  385. maxRounds: {
  386. type: 'number',
  387. description: 'Optional positive safe-integer round cap, bounded by the deployment ceiling.',
  388. },
  389. },
  390. output: {
  391. schema: {
  392. type: 'object',
  393. additionalProperties: false,
  394. properties: RALPH_OUTPUT_PROPERTIES,
  395. },
  396. render: (_args, value) => [{
  397. type: 'text',
  398. text: renderResult(value.result as unknown as RalphRunResult, resolved.maxResultChars),
  399. }],
  400. },
  401. async execute(args, exec) {
  402. const parent = exec.agent
  403. if (parent === undefined) {
  404. throw new Error('Ralph tool requires a calling agent (exec.agent was undefined)')
  405. }
  406. const objective = args.objective.trim()
  407. if (objective.length === 0) throw new Error('Ralph objective must be a non-empty string')
  408. const maxRounds = resolveMaxRounds(args.maxRounds, resolved.maxRounds)
  409. void requireFreshProvider(ctx, resolved.subagentProvider)
  410. const run: WorkflowRun = ctx.workflows.start({
  411. script: RALPH_SCRIPT,
  412. meta: RALPH_META,
  413. args: { objective, maxRounds, maxHandoffChars: resolved.maxHandoffChars },
  414. subagentProvider: resolved.subagentProvider,
  415. maxTotalAgents: maxRounds,
  416. parent,
  417. signal: exec.signal,
  418. })
  419. const onAbort = (): void => { run.cancel('parent step aborted') }
  420. exec.signal.addEventListener('abort', onAbort, { once: true })
  421. if (exec.signal.aborted) run.cancel('parent step aborted')
  422. try {
  423. const settled = await run.result
  424. const error = stopReasonError(settled)
  425. if (error !== undefined) throw new Error(error)
  426. const value = readRunResult(settled.value, maxRounds, resolved.maxHandoffChars)
  427. if (value.status === 'round-failed') throw new Error(renderRoundFailure(value, resolved.maxResultChars))
  428. return {
  429. runId: run.id,
  430. agentsStarted: settled.agentsStarted,
  431. result: value as unknown as JsonValue,
  432. }
  433. } finally {
  434. exec.signal.removeEventListener('abort', onAbort)
  435. await run.dispose()
  436. }
  437. },
  438. presentCall,
  439. presentResult,
  440. }))
  441. }