1
/**2
* Model-facing foreground Ralph loop over the workflow and subagent seams. A3
* fixed script starts one fresh structured-output child per round, carrying4
* only the immutable objective and the previous bounded handoff between them.5
* @module @deepseek-ai/dsh-tool-ralph6
*/8
import type { Context } from '@deepseek-ai/cordis'9
import z from '@deepseek-ai/schemastery'10
import type { ContentBlock } from '@deepseek-ai/dsh-llm'11
import type { JsonValue } from '@deepseek-ai/dsh-util-values'12
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'13
import { defineTool } from '@deepseek-ai/dsh-tools'14
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'15
import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow'17
export const name = 'tool-ralph'18
export const inject = ['tools', 'workflowEngine', 'subagents', 'systemPrompt']20
/** Deployment policy for the fixed Ralph workflow. */21
export interface Config {22
/** Fresh structured-output provider used for every round (default `spawn`). */23
subagentProvider?: string24
/** Default and deployment ceiling for one call's round count (default 256). */25
maxRounds?: number26
/** Maximum serialized characters in one structured handoff (default 16384). */27
maxHandoffChars?: number28
/** Maximum characters in a successful parent-facing terminal text (default 16384). */29
maxResultChars?: number30
}32
/** Schemastery configuration for the Ralph tool. */33
export const Config: z<Config> = z.object({34
subagentProvider: z.string().default('spawn'),35
maxRounds: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(256),36
maxHandoffChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384),37
maxResultChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384),38
})40
interface ResolvedConfig {41
readonly subagentProvider: string42
readonly maxRounds: number43
readonly maxHandoffChars: number44
readonly maxResultChars: number45
}47
type RalphRoundStatus = 'continue' | 'complete' | 'blocked'49
interface RalphRoundReport {50
readonly status: RalphRoundStatus51
readonly summary: string52
readonly evidence: string[]53
readonly nextSteps: string[]54
readonly blocker: string55
}57
type RalphRunStatus = 'complete' | 'blocked' | 'budget-limited'59
interface RalphRunResult {60
readonly status: RalphRunStatus61
readonly roundsStarted: number62
readonly report: RalphRoundReport63
}65
interface RalphRoundFailure {66
readonly status: 'round-failed'67
readonly roundsStarted: number68
readonly lastReport?: RalphRoundReport69
}71
type RalphTerminalResult = RalphRunResult | RalphRoundFailure73
interface RalphCallArgs {74
objective: string75
maxRounds?: number76
}78
const RALPH_META = {79
name: 'ralph-loop',80
description: 'Iterate toward one objective with a fresh child and bounded structured handoff per round.',81
phases: [{ title: 'Fresh-agent rounds', detail: 'One clean child context per Ralph round.' }],82
}84
/**85
* Fixed, deployment-owned orchestration. The model supplies data only; it86
* cannot alter the loop, provider route, schema, or handoff validation.87
*/88
const RALPH_SCRIPT = String.raw`89
const reportSchema = {90
type: 'object',91
properties: {92
status: { type: 'string', enum: ['continue', 'complete', 'blocked'] },93
summary: { type: 'string' },94
evidence: { type: 'array', items: { type: 'string' } },95
nextSteps: { type: 'array', items: { type: 'string' } },96
blocker: { type: 'string' },97
},98
required: ['status', 'summary', 'evidence', 'nextSteps', 'blocker'],99
additionalProperties: false,100
}102
function normalizedText(value) {103
return typeof value === 'string' && value.length > 0 && value === value.trim()104
}106
function normalizedList(value) {107
return Array.isArray(value) && value.every(normalizedText)108
}110
function validateReport(report) {111
if (report === null || typeof report !== 'object' || Array.isArray(report)) {112
throw new Error('Ralph child returned no structured round report')113
}114
if (!normalizedText(report.summary)) {115
throw new Error('Ralph round report summary must be non-empty and normalized')116
}117
if (!normalizedList(report.evidence) || !normalizedList(report.nextSteps)) {118
throw new Error('Ralph round report evidence and nextSteps must contain only non-empty normalized strings')119
}120
if (typeof report.blocker !== 'string' || report.blocker !== report.blocker.trim()) {121
throw new Error('Ralph round report blocker must be a normalized string')122
}123
switch (report.status) {124
case 'continue':125
if (report.nextSteps.length === 0 || report.blocker !== '') {126
throw new Error('a continuing Ralph report needs nextSteps and an empty blocker')127
}128
break129
case 'complete':130
if (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '') {131
throw new Error('a complete Ralph report needs evidence, no nextSteps, and an empty blocker')132
}133
break134
case 'blocked':135
if (!normalizedText(report.blocker)) {136
throw new Error('a blocked Ralph report needs a concrete blocker')137
}138
break139
default:140
throw new Error('Ralph round report status is invalid')141
}142
const serialized = JSON.stringify(report)143
if (serialized.length > args.maxHandoffChars) {144
throw new Error('Ralph round report exceeds maxHandoffChars (' + serialized.length + ' > ' + args.maxHandoffChars + ')')145
}146
return report147
}149
let previous150
phase('Fresh-agent rounds')151
for (let round = 1; round <= args.maxRounds; round += 1) {152
const prior = previous === undefined ? '(none — this is the first round)' : JSON.stringify(previous)153
const prompt = [154
'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.',155
'Immutable objective:\n' + args.objective,156
'Ralph round: ' + round + ' of ' + args.maxRounds + '.',157
'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.',158
'Previous structured handoff:\n' + prior,159
'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.',160
].join('\n\n')161
const rawReport = await agent(prompt, {162
label: 'Ralph round ' + round,163
phase: 'Fresh-agent rounds',164
schema: reportSchema,165
})166
if (rawReport === null) {167
return { status: 'round-failed', roundsStarted: round, lastReport: previous ?? null }168
}169
const report = validateReport(rawReport)170
if (report.status === 'complete') return { status: 'complete', roundsStarted: round, report }171
if (report.status === 'blocked') return { status: 'blocked', roundsStarted: round, report }172
previous = report173
}174
return { status: 'budget-limited', roundsStarted: args.maxRounds, report: previous }175
`177
const DESCRIPTION = 'Run a foreground fresh-agent Ralph loop toward one immutable objective. '178
+ 'Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round '179
+ 'opens a new child with no parent conversation or prior child session; the shared workspace is '180
+ 'long-term memory, and only a bounded structured report crosses rounds. The call returns when '181
+ 'a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work '182
+ 'belongs to goal tools.'184
/** Validate defaults even when a caller invokes apply() without Loader normalization. */185
function resolveConfig(config: Config): ResolvedConfig {186
const subagentProvider = config.subagentProvider ?? 'spawn'187
const maxRounds = config.maxRounds ?? 256188
const maxHandoffChars = config.maxHandoffChars ?? 16_384189
const maxResultChars = config.maxResultChars ?? 16_384190
if (subagentProvider.length === 0 || subagentProvider !== subagentProvider.trim()) {191
throw new TypeError('subagentProvider must be a non-empty normalized string')192
}193
if (!Number.isSafeInteger(maxRounds) || maxRounds < 1) {194
throw new TypeError('maxRounds must be a positive safe integer')195
}196
if (!Number.isSafeInteger(maxHandoffChars) || maxHandoffChars < 1) {197
throw new TypeError('maxHandoffChars must be a positive safe integer')198
}199
if (!Number.isSafeInteger(maxResultChars) || maxResultChars < 1) {200
throw new TypeError('maxResultChars must be a positive safe integer')201
}202
return { subagentProvider, maxRounds, maxHandoffChars, maxResultChars }203
}205
/** Resolve one model-selected cap against the deployment ceiling. */206
function resolveMaxRounds(requested: number | undefined, ceiling: number): number {207
const value = requested ?? ceiling208
if (!Number.isSafeInteger(value) || value < 1) {209
throw new TypeError('Ralph maxRounds must be a positive safe integer')210
}211
if (value > ceiling) {212
throw new TypeError(`Ralph maxRounds ${value} exceeds the deployment ceiling ${ceiling}`)213
}214
return value215
}217
/** Require the configured route to mean a genuinely fresh structured child. */218
function requireFreshProvider(ctx: Context, name: string): SubagentProvider {219
const provider = ctx.subagents.getProvider(name)220
if (provider === undefined) {221
throw new Error(`Ralph subagent provider "${name}" is not registered`)222
}223
if (!provider.capabilities.outputSchema) {224
throw new Error(`Ralph subagent provider "${name}" does not support structured output`)225
}226
if (provider.inheritsParentContext) {227
throw new Error(`Ralph subagent provider "${name}" inherits parent context; Ralph requires a fresh provider`)228
}229
return provider230
}232
function isRecord(value: unknown): value is Record<string, unknown> {233
return typeof value === 'object' && value !== null && !Array.isArray(value)234
}236
function normalizedText(value: unknown): value is string {237
return typeof value === 'string' && value.length > 0 && value === value.trim()238
}240
function normalizedList(value: unknown): value is string[] {241
return Array.isArray(value) && value.every(normalizedText)242
}244
/** Defensively decode the fixed script's report across a provider boundary. */245
function readReport(value: unknown, expectedStatus: RalphRoundStatus, maxChars: number): RalphRoundReport {246
if (!isRecord(value)247
|| Object.keys(value).sort().join(',') !== 'blocker,evidence,nextSteps,status,summary'248
|| value['status'] !== expectedStatus249
|| !normalizedText(value['summary'])250
|| !normalizedList(value['evidence'])251
|| !normalizedList(value['nextSteps'])252
|| typeof value['blocker'] !== 'string'253
|| value['blocker'] !== value['blocker'].trim()) {254
throw new Error('Ralph workflow returned a malformed round report')255
}256
const report: RalphRoundReport = {257
status: expectedStatus,258
summary: value['summary'],259
evidence: value['evidence'],260
nextSteps: value['nextSteps'],261
blocker: value['blocker'],262
}263
if (expectedStatus === 'continue' && (report.nextSteps.length === 0 || report.blocker !== '')) {264
throw new Error('Ralph workflow returned an invalid continuing report')265
}266
if (expectedStatus === 'complete'267
&& (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '')) {268
throw new Error('Ralph workflow returned an invalid completion report')269
}270
if (expectedStatus === 'blocked' && !normalizedText(report.blocker)) {271
throw new Error('Ralph workflow returned an invalid blocked report')272
}273
const chars = JSON.stringify(report).length274
if (chars > maxChars) {275
throw new Error(`Ralph workflow returned an oversized handoff (${chars} > ${maxChars})`)276
}277
return report278
}280
/** Defensively decode the fixed script's terminal value. */281
function readRunResult(value: unknown, maxRounds: number, maxHandoffChars: number): RalphTerminalResult {282
if (!isRecord(value)283
|| typeof value['roundsStarted'] !== 'number'284
|| !Number.isSafeInteger(value['roundsStarted'])285
|| value['roundsStarted'] < 1286
|| value['roundsStarted'] > maxRounds) {287
throw new Error('Ralph workflow returned a malformed terminal result')288
}289
const roundsStarted = value['roundsStarted']290
switch (value['status']) {291
case 'complete':292
if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') {293
throw new Error('Ralph workflow returned a malformed terminal result')294
}295
return { status: 'complete', roundsStarted, report: readReport(value['report'], 'complete', maxHandoffChars) }296
case 'blocked':297
if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') {298
throw new Error('Ralph workflow returned a malformed terminal result')299
}300
return { status: 'blocked', roundsStarted, report: readReport(value['report'], 'blocked', maxHandoffChars) }301
case 'budget-limited':302
if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') {303
throw new Error('Ralph workflow returned a malformed terminal result')304
}305
if (roundsStarted !== maxRounds) {306
throw new Error('Ralph workflow returned budget-limited before the round limit')307
}308
return { status: 'budget-limited', roundsStarted, report: readReport(value['report'], 'continue', maxHandoffChars) }309
case 'round-failed': {310
if (Object.keys(value).sort().join(',') !== 'lastReport,roundsStarted,status') {311
throw new Error('Ralph workflow returned a malformed terminal result')312
}313
if (roundsStarted === 1) {314
if (value['lastReport'] !== null) {315
throw new Error('Ralph workflow returned an invalid first-round failure')316
}317
return { status: 'round-failed', roundsStarted }318
}319
if (value['lastReport'] === null) {320
throw new Error('Ralph workflow returned a round failure without its last handoff')321
}322
return {323
status: 'round-failed',324
roundsStarted,325
lastReport: readReport(value['lastReport'], 'continue', maxHandoffChars),326
}327
}328
default:329
throw new Error('Ralph workflow returned an unknown terminal status')330
}331
}333
/** A non-clean workflow finish is an error, never a partial Ralph success. */334
function stopReasonError(result: WorkflowResult): string | undefined {335
switch (result.stopReason) {336
case 'completed':337
return undefined338
case 'cancelled':339
return `Ralph workflow was cancelled${result.error === undefined ? '' : ` (${result.error})`}`340
case 'error':341
return `Ralph workflow failed: ${result.error ?? 'unknown error'}`342
/* v8 ignore start -- WorkflowStopReason is closed; a future variant must fail loud here. */343
default:344
return `Ralph workflow ended abnormally (${String(result.stopReason satisfies never)})`345
/* v8 ignore stop */346
}347
}349
const TRUNCATION_NOTICE = '\n… [truncated]'351
/** Bound complete parent-facing text, including its envelope and truncation marker. */352
function boundResult(text: string, maxChars: number): string {353
if (text.length <= maxChars) return text354
if (maxChars <= TRUNCATION_NOTICE.length) return TRUNCATION_NOTICE.slice(0, maxChars)355
return `${text.slice(0, maxChars - TRUNCATION_NOTICE.length)}${TRUNCATION_NOTICE}`356
}358
/** Render the fixed terminal envelope without presenting self-report as certification. */359
function renderResult(result: RalphRunResult, maxChars: number): string {360
const rounds = `${result.roundsStarted} round${result.roundsStarted === 1 ? '' : 's'}`361
let text: string362
switch (result.status) {363
case 'complete':364
text = `Ralph worker reported completion after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`365
break366
case 'blocked':367
text = `Ralph worker reported a blocker after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`368
break369
case 'budget-limited':370
text = `Ralph reached its ${rounds} limit; the worker reported work remaining.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`371
break372
}373
return boundResult(text, maxChars)374
}376
/** Canonical Ralph result fields shared by schema inference and rendering. */377
const RALPH_OUTPUT_PROPERTIES = {378
runId: { type: 'string', required: true },379
agentsStarted: { type: 'integer', required: true },380
result: { type: 'json', required: true },381
} as const383
/** Render an ordinary child failure with the most recent durable handoff. */384
function renderRoundFailure(result: RalphRoundFailure, maxChars: number): string {385
const header = `Ralph round ${result.roundsStarted} child failed before producing a structured report.`386
const text = result.lastReport === undefined387
? `${header}\nNo previous handoff was available.`388
: `${header}\nLast successful handoff:\n${JSON.stringify(result.lastReport, null, 2)}`389
return boundResult(text, maxChars)390
}392
function presentCall(args: RalphCallArgs): ToolCallView {393
return { card: 'generic', title: 'ralph', rawInput: args.objective }394
}396
function presentResult(args: RalphCallArgs, result: { content: ContentBlock[]; isError: boolean }): ToolResultView {397
void args398
void result399
return { card: 'generic' }400
}402
/** Register the fixed Ralph tool and its explicit-ask usage policy. */403
export function apply(ctx: Context, config: Config): void {404
const resolved = resolveConfig(config)405
ctx.systemPrompt.section({406
name: 'tool:ralph',407
order: ctx.systemPrompt.getSectionOrder('TOOL_RALPH'),408
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.',409
})410
ctx.tools.register(defineTool({411
name: 'ralph',412
description: DESCRIPTION,413
parameters: {414
objective: {415
type: 'string',416
required: true,417
description: 'The immutable completion objective for every fresh Ralph round.',418
},419
maxRounds: {420
type: 'number',421
description: 'Optional positive safe-integer round cap, bounded by the deployment ceiling.',422
},423
},424
output: {425
schema: {426
type: 'object',427
additionalProperties: false,428
properties: RALPH_OUTPUT_PROPERTIES,429
},430
render: (_args, value) => [{431
type: 'text',432
text: renderResult(value.result as unknown as RalphRunResult, resolved.maxResultChars),433
}],434
},435
async execute(args, exec) {436
const parent = exec.agent437
if (parent === undefined) {438
throw new Error('Ralph tool requires a calling agent (exec.agent was undefined)')439
}440
const objective = args.objective.trim()441
if (objective.length === 0) throw new Error('Ralph objective must be a non-empty string')442
const maxRounds = resolveMaxRounds(args.maxRounds, resolved.maxRounds)443
void requireFreshProvider(ctx, resolved.subagentProvider)445
const run: WorkflowRun = ctx.workflowEngine.start({446
script: RALPH_SCRIPT,447
meta: RALPH_META,448
args: { objective, maxRounds, maxHandoffChars: resolved.maxHandoffChars },449
subagentProvider: resolved.subagentProvider,450
maxTotalAgents: maxRounds,451
parent,452
signal: exec.signal,453
})454
const onAbort = (): void => { run.cancel('parent step aborted') }455
exec.signal.addEventListener('abort', onAbort, { once: true })456
if (exec.signal.aborted) run.cancel('parent step aborted')458
try {459
const settled = await run.result460
const error = stopReasonError(settled)461
if (error !== undefined) throw new Error(error)462
const value = readRunResult(settled.value, maxRounds, resolved.maxHandoffChars)463
if (value.status === 'round-failed') throw new Error(renderRoundFailure(value, resolved.maxResultChars))464
return {465
runId: run.id,466
agentsStarted: settled.agentsStarted,467
result: value as unknown as JsonValue,468
}469
} finally {470
exec.signal.removeEventListener('abort', onAbort)471
await run.dispose()472
}473
},474
presentCall,475
presentResult,476
}))477
}