返回源码地图

packages/workflow/tool-ralph/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 11 / 11;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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
8import type { Context } from '@deepseek-ai/cordis'
9import z from '@deepseek-ai/schemastery'
10import type { ContentBlock } from '@deepseek-ai/dsh-llm'
11import type { JsonValue } from '@deepseek-ai/dsh-util-values'
12import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
13import { defineTool } from '@deepseek-ai/dsh-tools'
14import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
15import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow'
16
17export const name = 'tool-ralph'
18export const inject = ['tools', 'workflowEngine', 'subagents', 'systemPrompt']
19
20/** Deployment policy for the fixed Ralph workflow. */
21export interface Config {
22 /** Fresh structured-output provider used for every round (default `spawn`). */
23 subagentProvider?: string
24 /** Default and deployment ceiling for one call's round count (default 256). */
25 maxRounds?: number
26 /** Maximum serialized characters in one structured handoff (default 16384). */
27 maxHandoffChars?: number
28 /** Maximum characters in a successful parent-facing terminal text (default 16384). */
29 maxResultChars?: number
30}
31
32/** Schemastery configuration for the Ralph tool. */
33export 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})
39
40interface ResolvedConfig {
41 readonly subagentProvider: string
42 readonly maxRounds: number
43 readonly maxHandoffChars: number
44 readonly maxResultChars: number
45}
46
47type RalphRoundStatus = 'continue' | 'complete' | 'blocked'
48
49interface RalphRoundReport {
50 readonly status: RalphRoundStatus
51 readonly summary: string
52 readonly evidence: string[]
53 readonly nextSteps: string[]
54 readonly blocker: string
55}
56
57type RalphRunStatus = 'complete' | 'blocked' | 'budget-limited'
58
59interface RalphRunResult {
60 readonly status: RalphRunStatus
61 readonly roundsStarted: number
62 readonly report: RalphRoundReport
63}
64
65interface RalphRoundFailure {
66 readonly status: 'round-failed'
67 readonly roundsStarted: number
68 readonly lastReport?: RalphRoundReport
69}
70
71type RalphTerminalResult = RalphRunResult | RalphRoundFailure
72
73interface RalphCallArgs {
74 objective: string
75 maxRounds?: number
76}
77
78const 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}
83
84/**
85 * Fixed, deployment-owned orchestration. The model supplies data only; it
86 * cannot alter the loop, provider route, schema, or handoff validation.
87 */
88const RALPH_SCRIPT = String.raw`
89const 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}
101
102function normalizedText(value) {
103 return typeof value === 'string' && value.length > 0 && value === value.trim()
104}
105
106function normalizedList(value) {
107 return Array.isArray(value) && value.every(normalizedText)
108}
109
110function 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 break
129 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 break
134 case 'blocked':
135 if (!normalizedText(report.blocker)) {
136 throw new Error('a blocked Ralph report needs a concrete blocker')
137 }
138 break
139 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 report
147}
148
149let previous
150phase('Fresh-agent rounds')
151for (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 = report
173}
174return { status: 'budget-limited', roundsStarted: args.maxRounds, report: previous }
175`
176
177const 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.'
183
184/** Validate defaults even when a caller invokes apply() without Loader normalization. */
185function resolveConfig(config: Config): ResolvedConfig {
186 const subagentProvider = config.subagentProvider ?? 'spawn'
187 const maxRounds = config.maxRounds ?? 256
188 const maxHandoffChars = config.maxHandoffChars ?? 16_384
189 const maxResultChars = config.maxResultChars ?? 16_384
190 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}
204
205/** Resolve one model-selected cap against the deployment ceiling. */
206function resolveMaxRounds(requested: number | undefined, ceiling: number): number {
207 const value = requested ?? ceiling
208 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 value
215}
216
217/** Require the configured route to mean a genuinely fresh structured child. */
218function 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 provider
230}
231
232function isRecord(value: unknown): value is Record<string, unknown> {
233 return typeof value === 'object' && value !== null && !Array.isArray(value)
234}
235
236function normalizedText(value: unknown): value is string {
237 return typeof value === 'string' && value.length > 0 && value === value.trim()
238}
239
240function normalizedList(value: unknown): value is string[] {
241 return Array.isArray(value) && value.every(normalizedText)
242}
243
244/** Defensively decode the fixed script's report across a provider boundary. */
245function 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'] !== expectedStatus
249 || !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).length
274 if (chars > maxChars) {
275 throw new Error(`Ralph workflow returned an oversized handoff (${chars} > ${maxChars})`)
276 }
277 return report
278}
279
280/** Defensively decode the fixed script's terminal value. */
281function 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'] < 1
286 || 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}
332
333/** A non-clean workflow finish is an error, never a partial Ralph success. */
334function stopReasonError(result: WorkflowResult): string | undefined {
335 switch (result.stopReason) {
336 case 'completed':
337 return undefined
338 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}
348
349const TRUNCATION_NOTICE = '\n… [truncated]'
350
351/** Bound complete parent-facing text, including its envelope and truncation marker. */
352function boundResult(text: string, maxChars: number): string {
353 if (text.length <= maxChars) return text
354 if (maxChars <= TRUNCATION_NOTICE.length) return TRUNCATION_NOTICE.slice(0, maxChars)
355 return `${text.slice(0, maxChars - TRUNCATION_NOTICE.length)}${TRUNCATION_NOTICE}`
356}
357
358/** Render the fixed terminal envelope without presenting self-report as certification. */
359function renderResult(result: RalphRunResult, maxChars: number): string {
360 const rounds = `${result.roundsStarted} round${result.roundsStarted === 1 ? '' : 's'}`
361 let text: string
362 switch (result.status) {
363 case 'complete':
364 text = `Ralph worker reported completion after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
365 break
366 case 'blocked':
367 text = `Ralph worker reported a blocker after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`
368 break
369 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 break
372 }
373 return boundResult(text, maxChars)
374}
375
376/** Canonical Ralph result fields shared by schema inference and rendering. */
377const RALPH_OUTPUT_PROPERTIES = {
378 runId: { type: 'string', required: true },
379 agentsStarted: { type: 'integer', required: true },
380 result: { type: 'json', required: true },
381} as const
382
383/** Render an ordinary child failure with the most recent durable handoff. */
384function 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 === undefined
387 ? `${header}\nNo previous handoff was available.`
388 : `${header}\nLast successful handoff:\n${JSON.stringify(result.lastReport, null, 2)}`
389 return boundResult(text, maxChars)
390}
391
392function presentCall(args: RalphCallArgs): ToolCallView {
393 return { card: 'generic', title: 'ralph', rawInput: args.objective }
394}
395
396function presentResult(args: RalphCallArgs, result: { content: ContentBlock[]; isError: boolean }): ToolResultView {
397 void args
398 void result
399 return { card: 'generic' }
400}
401
402/** Register the fixed Ralph tool and its explicit-ask usage policy. */
403export 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.agent
437 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)
444
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')
457
458 try {
459 const settled = await run.result
460 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}