@hydraharness/harness-tool-ralph 0.1.1-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/lib/index.js +371 -0
- package/lib/invariant.js +23 -0
- package/lib/types/index.d.ts +26 -0
- package/lib/types/invariant.d.ts +16 -0
- package/package.json +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# @hydraharness/harness-tool-ralph
|
|
2
|
+
|
|
3
|
+
The model-facing `ralph` tool runs a fixed foreground workflow that gives one immutable objective to a sequence of fresh child agents. It demonstrates a specialized orchestration policy as an ordinary plugin over [`ctx.workflowEngine`](../workflow/README.md) and [`ctx.subagents`](../../subagent/subagent/README.md): no Ralph mode or fresh-agent loop is added to `agent-loop`, and the same-session [goal domain](../../goal/goal/README.md) remains independent. The [Ralph Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md) owns the policy and deferred work.
|
|
4
|
+
|
|
5
|
+
## Contract
|
|
6
|
+
|
|
7
|
+
`ralph({ objective, maxRounds? })` waits for the entire run. The deployment config's `maxRounds` is both the default and a ceiling on a call override. Every Ralph round starts one child through `subagentProvider`; that provider must exist, support structured output, and report `inheritsParentContext: false`. The configured provider is carried as `WorkflowStartRequest.subagentProvider`, so the fixed script cannot inspect or change routing and the ordinary model-written `workflow` tool gains no provider selector. The resolved round cap is also carried as `WorkflowStartRequest.maxTotalAgents`, coordinating the fixed loop with the engine's total-child backstop; the engine rejects a Ralph cap above its deployment ceiling before publishing a run.
|
|
8
|
+
|
|
9
|
+
Each child receives only the immutable objective, its current Ralph round and cap, a shared-workspace-as-authority instruction, and the previous structured handoff. The workspace is long-term memory; parent conversation and prior child sessions are not seeded. Reports have `status: continue | complete | blocked`, a non-empty summary, evidence, next steps, and blocker text. Status-specific semantics and the serialized `maxHandoffChars` ceiling are validated inside the fixed workflow and again at the consumer boundary. Invalid, missing, or oversized reports fail the workflow instead of being truncated or mistaken for cap exhaustion.
|
|
10
|
+
|
|
11
|
+
The successful terminal tool result is `complete`, `blocked`, or `budget-limited`, with the last bounded report and number of rounds started. The canonical envelope is `{ runId, agentsStarted, result }`; completion and blocker labels in its Native renderer explicitly say that a worker reported the outcome, not independent certification. `maxResultChars` bounds only that rendered text including its truncation marker, without altering the validated report in the canonical value or the cross-round handoff.
|
|
12
|
+
|
|
13
|
+
An ordinary child failure produces an error naming the failed round and retaining the last successful handoff when one exists. Ralph does not retry that round. Fatal provider-start, transport, worker, or workflow failures remain workflow errors and may settle before the fixed script can return a handoff. Cancellation is also an error; partial output is never success.
|
|
14
|
+
|
|
15
|
+
## Lifecycle and cancellation
|
|
16
|
+
|
|
17
|
+
The caller's agent is the parent of every fresh child, preserving cwd and lineage without copying its conversation. `exec.signal` enters the workflow engine and is also bridged to `run.cancel()` for implementation independence. The tool awaits `run.result` and calls `run.dispose()` in `finally`, so a cancelled parent step waits for the engine's bounded termination and child quiescence before returning.
|
|
18
|
+
|
|
19
|
+
## Render intent
|
|
20
|
+
|
|
21
|
+
The pending call is a `generic` card titled `ralph`; the immutable objective is its `rawInput`. The result keeps the generic card. Both presentation functions depend only on tool arguments and the settled tool envelope.
|
|
22
|
+
|
|
23
|
+
## Config
|
|
24
|
+
|
|
25
|
+
| Key | Default | Meaning |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `subagentProvider` | `spawn` | Fresh structured-output provider used for every round. |
|
|
28
|
+
| `maxRounds` | `256` | Default and deployment ceiling for one Ralph run. |
|
|
29
|
+
| `maxHandoffChars` | `16384` | Maximum serialized characters in one round report. |
|
|
30
|
+
| `maxResultChars` | `16384` | Maximum characters in the complete successful parent result. |
|
|
31
|
+
|
|
32
|
+
All config values are normalized and validated when the plugin applies, including direct application outside Loader schema normalization. Provider capabilities are resolved immediately before each call because provider registration can change under plugin lifecycle and HMR.
|
|
33
|
+
|
|
34
|
+
## Model Experience
|
|
35
|
+
|
|
36
|
+
### System prompt
|
|
37
|
+
|
|
38
|
+
#### What the model sees
|
|
39
|
+
|
|
40
|
+
Every parent request in this plugin's registration scope receives the fixed routing guidance below.
|
|
41
|
+
|
|
42
|
+
##### Ralph guidance
|
|
43
|
+
|
|
44
|
+
```markdown
|
|
45
|
+
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 workflowEngine for bounded delegation and fan-out.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
#### Token effect
|
|
49
|
+
|
|
50
|
+
Small fixed guidance cost per request while the plugin is active.
|
|
51
|
+
|
|
52
|
+
#### KV Cache effect
|
|
53
|
+
|
|
54
|
+
Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section.
|
|
55
|
+
|
|
56
|
+
### Tool schema
|
|
57
|
+
|
|
58
|
+
#### What the model sees
|
|
59
|
+
|
|
60
|
+
The generated [`ralph` schema](../../../docs/tool-catalog.md#hydraharness-tool-ralph) exposes one required `objective` string and one optional `maxRounds` number. Provider choice, handoff size, report schema, workflow script, and orchestration behavior are deployment-owned and absent from the call schema.
|
|
61
|
+
|
|
62
|
+
#### Token effect
|
|
63
|
+
|
|
64
|
+
Small fixed schema cost on each request where the tool is visible.
|
|
65
|
+
|
|
66
|
+
#### KV Cache effect
|
|
67
|
+
|
|
68
|
+
Prefix-stable while the definition and visibility are unchanged.
|
|
69
|
+
|
|
70
|
+
### Child requests and parent result
|
|
71
|
+
|
|
72
|
+
#### What the model sees
|
|
73
|
+
|
|
74
|
+
Each child sees the standalone fixed round prompt plus the structured-output capture contract. The parent sees only the original call and one terminal result containing a worker-reported status, round count, and pretty-printed final report; intermediate child messages and reports do not enter the parent conversation. A failed ordinary child instead yields an error with its round number and, after round one, the last successful handoff.
|
|
75
|
+
|
|
76
|
+
#### Token effect
|
|
77
|
+
|
|
78
|
+
Every round pays for a fresh child context. `maxHandoffChars` bounds cross-round state and `maxResultChars` independently bounds the complete successful parent text; child work remains outside the parent context.
|
|
79
|
+
|
|
80
|
+
#### KV Cache effect
|
|
81
|
+
|
|
82
|
+
Each fresh child has an independent request cache. The parent result appends after the reusable request prefix.
|
|
83
|
+
|
|
84
|
+
## Known Limitations and Deferred Work
|
|
85
|
+
|
|
86
|
+
- **Completion is worker self-declaration** — there is no independent evaluator or verifier deciding whether the objective is actually complete; evaluator policy and evaluator-driven continuation are deferred.
|
|
87
|
+
- **Foreground only** — there is no job id, background collection, process-resume checkpoint, scheduler, or wall-clock start policy.
|
|
88
|
+
- **The workspace is the only cross-round long-term memory** — one bounded report is the explicit handoff, and uncommitted conversational reasoning disappears with each child.
|
|
89
|
+
- **One round is one fresh child** — there is no within-round fan-out, model/provider switching, fork context, or model-call-selected provider.
|
|
90
|
+
- **Ordinary child failure is terminal for the run** — the fixed script reports the failed round and last successful handoff but does not retry; fatal workflow infrastructure failures can end before that state is returned.
|
|
91
|
+
- **Only round count bounds aggregate effort** — token, price, and elapsed-time budgets are deferred.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import z from "@hydraharness/schemastery";
|
|
2
|
+
import { defineTool } from "@hydraharness/harness-tools";
|
|
3
|
+
//#region lib/types/index.js
|
|
4
|
+
/**
|
|
5
|
+
* Model-facing foreground Ralph loop over the workflow and subagent seams. A
|
|
6
|
+
* fixed script starts one fresh structured-output child per round, carrying
|
|
7
|
+
* only the immutable objective and the previous bounded handoff between them.
|
|
8
|
+
* @module @hydraharness/harness-tool-ralph
|
|
9
|
+
*/
|
|
10
|
+
const name = "tool-ralph";
|
|
11
|
+
const inject = [
|
|
12
|
+
"tools",
|
|
13
|
+
"workflowEngine",
|
|
14
|
+
"subagents",
|
|
15
|
+
"systemPrompt"
|
|
16
|
+
];
|
|
17
|
+
/** Schemastery configuration for the Ralph tool. */
|
|
18
|
+
const Config = z.object({
|
|
19
|
+
subagentProvider: z.string().default("spawn"),
|
|
20
|
+
maxRounds: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(256),
|
|
21
|
+
maxHandoffChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16384),
|
|
22
|
+
maxResultChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16384)
|
|
23
|
+
});
|
|
24
|
+
const RALPH_META = {
|
|
25
|
+
name: "ralph-loop",
|
|
26
|
+
description: "Iterate toward one objective with a fresh child and bounded structured handoff per round.",
|
|
27
|
+
phases: [{
|
|
28
|
+
title: "Fresh-agent rounds",
|
|
29
|
+
detail: "One clean child context per Ralph round."
|
|
30
|
+
}]
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Fixed, deployment-owned orchestration. The model supplies data only; it
|
|
34
|
+
* cannot alter the loop, provider route, schema, or handoff validation.
|
|
35
|
+
*/
|
|
36
|
+
const RALPH_SCRIPT = String.raw`
|
|
37
|
+
const reportSchema = {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
status: { type: 'string', enum: ['continue', 'complete', 'blocked'] },
|
|
41
|
+
summary: { type: 'string' },
|
|
42
|
+
evidence: { type: 'array', items: { type: 'string' } },
|
|
43
|
+
nextSteps: { type: 'array', items: { type: 'string' } },
|
|
44
|
+
blocker: { type: 'string' },
|
|
45
|
+
},
|
|
46
|
+
required: ['status', 'summary', 'evidence', 'nextSteps', 'blocker'],
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizedText(value) {
|
|
51
|
+
return typeof value === 'string' && value.length > 0 && value === value.trim()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizedList(value) {
|
|
55
|
+
return Array.isArray(value) && value.every(normalizedText)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validateReport(report) {
|
|
59
|
+
if (report === null || typeof report !== 'object' || Array.isArray(report)) {
|
|
60
|
+
throw new Error('Ralph child returned no structured round report')
|
|
61
|
+
}
|
|
62
|
+
if (!normalizedText(report.summary)) {
|
|
63
|
+
throw new Error('Ralph round report summary must be non-empty and normalized')
|
|
64
|
+
}
|
|
65
|
+
if (!normalizedList(report.evidence) || !normalizedList(report.nextSteps)) {
|
|
66
|
+
throw new Error('Ralph round report evidence and nextSteps must contain only non-empty normalized strings')
|
|
67
|
+
}
|
|
68
|
+
if (typeof report.blocker !== 'string' || report.blocker !== report.blocker.trim()) {
|
|
69
|
+
throw new Error('Ralph round report blocker must be a normalized string')
|
|
70
|
+
}
|
|
71
|
+
switch (report.status) {
|
|
72
|
+
case 'continue':
|
|
73
|
+
if (report.nextSteps.length === 0 || report.blocker !== '') {
|
|
74
|
+
throw new Error('a continuing Ralph report needs nextSteps and an empty blocker')
|
|
75
|
+
}
|
|
76
|
+
break
|
|
77
|
+
case 'complete':
|
|
78
|
+
if (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '') {
|
|
79
|
+
throw new Error('a complete Ralph report needs evidence, no nextSteps, and an empty blocker')
|
|
80
|
+
}
|
|
81
|
+
break
|
|
82
|
+
case 'blocked':
|
|
83
|
+
if (!normalizedText(report.blocker)) {
|
|
84
|
+
throw new Error('a blocked Ralph report needs a concrete blocker')
|
|
85
|
+
}
|
|
86
|
+
break
|
|
87
|
+
default:
|
|
88
|
+
throw new Error('Ralph round report status is invalid')
|
|
89
|
+
}
|
|
90
|
+
const serialized = JSON.stringify(report)
|
|
91
|
+
if (serialized.length > args.maxHandoffChars) {
|
|
92
|
+
throw new Error('Ralph round report exceeds maxHandoffChars (' + serialized.length + ' > ' + args.maxHandoffChars + ')')
|
|
93
|
+
}
|
|
94
|
+
return report
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let previous
|
|
98
|
+
phase('Fresh-agent rounds')
|
|
99
|
+
for (let round = 1; round <= args.maxRounds; round += 1) {
|
|
100
|
+
const prior = previous === undefined ? '(none — this is the first round)' : JSON.stringify(previous)
|
|
101
|
+
const prompt = [
|
|
102
|
+
'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.',
|
|
103
|
+
'Immutable objective:\n' + args.objective,
|
|
104
|
+
'Ralph round: ' + round + ' of ' + args.maxRounds + '.',
|
|
105
|
+
'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.',
|
|
106
|
+
'Previous structured handoff:\n' + prior,
|
|
107
|
+
'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.',
|
|
108
|
+
].join('\n\n')
|
|
109
|
+
const rawReport = await agent(prompt, {
|
|
110
|
+
label: 'Ralph round ' + round,
|
|
111
|
+
phase: 'Fresh-agent rounds',
|
|
112
|
+
schema: reportSchema,
|
|
113
|
+
})
|
|
114
|
+
if (rawReport === null) {
|
|
115
|
+
return { status: 'round-failed', roundsStarted: round, lastReport: previous ?? null }
|
|
116
|
+
}
|
|
117
|
+
const report = validateReport(rawReport)
|
|
118
|
+
if (report.status === 'complete') return { status: 'complete', roundsStarted: round, report }
|
|
119
|
+
if (report.status === 'blocked') return { status: 'blocked', roundsStarted: round, report }
|
|
120
|
+
previous = report
|
|
121
|
+
}
|
|
122
|
+
return { status: 'budget-limited', roundsStarted: args.maxRounds, report: previous }
|
|
123
|
+
`;
|
|
124
|
+
const DESCRIPTION = "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.";
|
|
125
|
+
/** Validate defaults even when a caller invokes apply() without Loader normalization. */
|
|
126
|
+
function resolveConfig(config) {
|
|
127
|
+
const subagentProvider = config.subagentProvider ?? "spawn";
|
|
128
|
+
const maxRounds = config.maxRounds ?? 256;
|
|
129
|
+
const maxHandoffChars = config.maxHandoffChars ?? 16384;
|
|
130
|
+
const maxResultChars = config.maxResultChars ?? 16384;
|
|
131
|
+
if (subagentProvider.length === 0 || subagentProvider !== subagentProvider.trim()) throw new TypeError("subagentProvider must be a non-empty normalized string");
|
|
132
|
+
if (!Number.isSafeInteger(maxRounds) || maxRounds < 1) throw new TypeError("maxRounds must be a positive safe integer");
|
|
133
|
+
if (!Number.isSafeInteger(maxHandoffChars) || maxHandoffChars < 1) throw new TypeError("maxHandoffChars must be a positive safe integer");
|
|
134
|
+
if (!Number.isSafeInteger(maxResultChars) || maxResultChars < 1) throw new TypeError("maxResultChars must be a positive safe integer");
|
|
135
|
+
return {
|
|
136
|
+
subagentProvider,
|
|
137
|
+
maxRounds,
|
|
138
|
+
maxHandoffChars,
|
|
139
|
+
maxResultChars
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** Resolve one model-selected cap against the deployment ceiling. */
|
|
143
|
+
function resolveMaxRounds(requested, ceiling) {
|
|
144
|
+
const value = requested ?? ceiling;
|
|
145
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("Ralph maxRounds must be a positive safe integer");
|
|
146
|
+
if (value > ceiling) throw new TypeError(`Ralph maxRounds ${value} exceeds the deployment ceiling ${ceiling}`);
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
/** Require the configured route to mean a genuinely fresh structured child. */
|
|
150
|
+
function requireFreshProvider(ctx, name) {
|
|
151
|
+
const provider = ctx.subagents.getProvider(name);
|
|
152
|
+
if (provider === void 0) throw new Error(`Ralph subagent provider "${name}" is not registered`);
|
|
153
|
+
if (!provider.capabilities.outputSchema) throw new Error(`Ralph subagent provider "${name}" does not support structured output`);
|
|
154
|
+
if (provider.inheritsParentContext) throw new Error(`Ralph subagent provider "${name}" inherits parent context; Ralph requires a fresh provider`);
|
|
155
|
+
return provider;
|
|
156
|
+
}
|
|
157
|
+
function isRecord(value) {
|
|
158
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
159
|
+
}
|
|
160
|
+
function normalizedText(value) {
|
|
161
|
+
return typeof value === "string" && value.length > 0 && value === value.trim();
|
|
162
|
+
}
|
|
163
|
+
function normalizedList(value) {
|
|
164
|
+
return Array.isArray(value) && value.every(normalizedText);
|
|
165
|
+
}
|
|
166
|
+
/** Defensively decode the fixed script's report across a provider boundary. */
|
|
167
|
+
function readReport(value, expectedStatus, maxChars) {
|
|
168
|
+
if (!isRecord(value) || Object.keys(value).sort().join(",") !== "blocker,evidence,nextSteps,status,summary" || value["status"] !== expectedStatus || !normalizedText(value["summary"]) || !normalizedList(value["evidence"]) || !normalizedList(value["nextSteps"]) || typeof value["blocker"] !== "string" || value["blocker"] !== value["blocker"].trim()) throw new Error("Ralph workflow returned a malformed round report");
|
|
169
|
+
const report = {
|
|
170
|
+
status: expectedStatus,
|
|
171
|
+
summary: value["summary"],
|
|
172
|
+
evidence: value["evidence"],
|
|
173
|
+
nextSteps: value["nextSteps"],
|
|
174
|
+
blocker: value["blocker"]
|
|
175
|
+
};
|
|
176
|
+
if (expectedStatus === "continue" && (report.nextSteps.length === 0 || report.blocker !== "")) throw new Error("Ralph workflow returned an invalid continuing report");
|
|
177
|
+
if (expectedStatus === "complete" && (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== "")) throw new Error("Ralph workflow returned an invalid completion report");
|
|
178
|
+
if (expectedStatus === "blocked" && !normalizedText(report.blocker)) throw new Error("Ralph workflow returned an invalid blocked report");
|
|
179
|
+
const chars = JSON.stringify(report).length;
|
|
180
|
+
if (chars > maxChars) throw new Error(`Ralph workflow returned an oversized handoff (${chars} > ${maxChars})`);
|
|
181
|
+
return report;
|
|
182
|
+
}
|
|
183
|
+
/** Defensively decode the fixed script's terminal value. */
|
|
184
|
+
function readRunResult(value, maxRounds, maxHandoffChars) {
|
|
185
|
+
if (!isRecord(value) || typeof value["roundsStarted"] !== "number" || !Number.isSafeInteger(value["roundsStarted"]) || value["roundsStarted"] < 1 || value["roundsStarted"] > maxRounds) throw new Error("Ralph workflow returned a malformed terminal result");
|
|
186
|
+
const roundsStarted = value["roundsStarted"];
|
|
187
|
+
switch (value["status"]) {
|
|
188
|
+
case "complete":
|
|
189
|
+
if (Object.keys(value).sort().join(",") !== "report,roundsStarted,status") throw new Error("Ralph workflow returned a malformed terminal result");
|
|
190
|
+
return {
|
|
191
|
+
status: "complete",
|
|
192
|
+
roundsStarted,
|
|
193
|
+
report: readReport(value["report"], "complete", maxHandoffChars)
|
|
194
|
+
};
|
|
195
|
+
case "blocked":
|
|
196
|
+
if (Object.keys(value).sort().join(",") !== "report,roundsStarted,status") throw new Error("Ralph workflow returned a malformed terminal result");
|
|
197
|
+
return {
|
|
198
|
+
status: "blocked",
|
|
199
|
+
roundsStarted,
|
|
200
|
+
report: readReport(value["report"], "blocked", maxHandoffChars)
|
|
201
|
+
};
|
|
202
|
+
case "budget-limited":
|
|
203
|
+
if (Object.keys(value).sort().join(",") !== "report,roundsStarted,status") throw new Error("Ralph workflow returned a malformed terminal result");
|
|
204
|
+
if (roundsStarted !== maxRounds) throw new Error("Ralph workflow returned budget-limited before the round limit");
|
|
205
|
+
return {
|
|
206
|
+
status: "budget-limited",
|
|
207
|
+
roundsStarted,
|
|
208
|
+
report: readReport(value["report"], "continue", maxHandoffChars)
|
|
209
|
+
};
|
|
210
|
+
case "round-failed":
|
|
211
|
+
if (Object.keys(value).sort().join(",") !== "lastReport,roundsStarted,status") throw new Error("Ralph workflow returned a malformed terminal result");
|
|
212
|
+
if (roundsStarted === 1) {
|
|
213
|
+
if (value["lastReport"] !== null) throw new Error("Ralph workflow returned an invalid first-round failure");
|
|
214
|
+
return {
|
|
215
|
+
status: "round-failed",
|
|
216
|
+
roundsStarted
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (value["lastReport"] === null) throw new Error("Ralph workflow returned a round failure without its last handoff");
|
|
220
|
+
return {
|
|
221
|
+
status: "round-failed",
|
|
222
|
+
roundsStarted,
|
|
223
|
+
lastReport: readReport(value["lastReport"], "continue", maxHandoffChars)
|
|
224
|
+
};
|
|
225
|
+
default: throw new Error("Ralph workflow returned an unknown terminal status");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** A non-clean workflow finish is an error, never a partial Ralph success. */
|
|
229
|
+
function stopReasonError(result) {
|
|
230
|
+
switch (result.stopReason) {
|
|
231
|
+
case "completed": return;
|
|
232
|
+
case "cancelled": return `Ralph workflow was cancelled${result.error === void 0 ? "" : ` (${result.error})`}`;
|
|
233
|
+
case "error": return `Ralph workflow failed: ${result.error ?? "unknown error"}`;
|
|
234
|
+
/* v8 ignore start -- WorkflowStopReason is closed; a future variant must fail loud here. */
|
|
235
|
+
default: return `Ralph workflow ended abnormally (${String(result.stopReason)})`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const TRUNCATION_NOTICE = "\n… [truncated]";
|
|
239
|
+
/** Bound complete parent-facing text, including its envelope and truncation marker. */
|
|
240
|
+
function boundResult(text, maxChars) {
|
|
241
|
+
if (text.length <= maxChars) return text;
|
|
242
|
+
if (maxChars <= 14) return TRUNCATION_NOTICE.slice(0, maxChars);
|
|
243
|
+
return `${text.slice(0, maxChars - 14)}${TRUNCATION_NOTICE}`;
|
|
244
|
+
}
|
|
245
|
+
/** Render the fixed terminal envelope without presenting self-report as certification. */
|
|
246
|
+
function renderResult(result, maxChars) {
|
|
247
|
+
const rounds = `${result.roundsStarted} round${result.roundsStarted === 1 ? "" : "s"}`;
|
|
248
|
+
let text;
|
|
249
|
+
switch (result.status) {
|
|
250
|
+
case "complete":
|
|
251
|
+
text = `Ralph worker reported completion after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`;
|
|
252
|
+
break;
|
|
253
|
+
case "blocked":
|
|
254
|
+
text = `Ralph worker reported a blocker after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`;
|
|
255
|
+
break;
|
|
256
|
+
case "budget-limited":
|
|
257
|
+
text = `Ralph reached its ${rounds} limit; the worker reported work remaining.\nFinal report:\n${JSON.stringify(result.report, null, 2)}`;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
return boundResult(text, maxChars);
|
|
261
|
+
}
|
|
262
|
+
/** Canonical Ralph result fields shared by schema inference and rendering. */
|
|
263
|
+
const RALPH_OUTPUT_PROPERTIES = {
|
|
264
|
+
runId: {
|
|
265
|
+
type: "string",
|
|
266
|
+
required: true
|
|
267
|
+
},
|
|
268
|
+
agentsStarted: {
|
|
269
|
+
type: "integer",
|
|
270
|
+
required: true
|
|
271
|
+
},
|
|
272
|
+
result: {
|
|
273
|
+
type: "json",
|
|
274
|
+
required: true
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
/** Render an ordinary child failure with the most recent durable handoff. */
|
|
278
|
+
function renderRoundFailure(result, maxChars) {
|
|
279
|
+
const header = `Ralph round ${result.roundsStarted} child failed before producing a structured report.`;
|
|
280
|
+
return boundResult(result.lastReport === void 0 ? `${header}\nNo previous handoff was available.` : `${header}\nLast successful handoff:\n${JSON.stringify(result.lastReport, null, 2)}`, maxChars);
|
|
281
|
+
}
|
|
282
|
+
function presentCall(args) {
|
|
283
|
+
return {
|
|
284
|
+
card: "generic",
|
|
285
|
+
title: "ralph",
|
|
286
|
+
rawInput: args.objective
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function presentResult(args, result) {
|
|
290
|
+
return { card: "generic" };
|
|
291
|
+
}
|
|
292
|
+
/** Register the fixed Ralph tool and its explicit-ask usage policy. */
|
|
293
|
+
function apply(ctx, config) {
|
|
294
|
+
const resolved = resolveConfig(config);
|
|
295
|
+
ctx.systemPrompt.section({
|
|
296
|
+
name: "tool:ralph",
|
|
297
|
+
order: 116,
|
|
298
|
+
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."
|
|
299
|
+
});
|
|
300
|
+
ctx.tools.register(defineTool({
|
|
301
|
+
name: "ralph",
|
|
302
|
+
description: DESCRIPTION,
|
|
303
|
+
parameters: {
|
|
304
|
+
objective: {
|
|
305
|
+
type: "string",
|
|
306
|
+
required: true,
|
|
307
|
+
description: "The immutable completion objective for every fresh Ralph round."
|
|
308
|
+
},
|
|
309
|
+
maxRounds: {
|
|
310
|
+
type: "number",
|
|
311
|
+
description: "Optional positive safe-integer round cap, bounded by the deployment ceiling."
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
output: {
|
|
315
|
+
schema: {
|
|
316
|
+
type: "object",
|
|
317
|
+
additionalProperties: false,
|
|
318
|
+
properties: RALPH_OUTPUT_PROPERTIES
|
|
319
|
+
},
|
|
320
|
+
render: (_args, value) => [{
|
|
321
|
+
type: "text",
|
|
322
|
+
text: renderResult(value.result, resolved.maxResultChars)
|
|
323
|
+
}]
|
|
324
|
+
},
|
|
325
|
+
async execute(args, exec) {
|
|
326
|
+
const parent = exec.agent;
|
|
327
|
+
if (parent === void 0) throw new Error("Ralph tool requires a calling agent (exec.agent was undefined)");
|
|
328
|
+
const objective = args.objective.trim();
|
|
329
|
+
if (objective.length === 0) throw new Error("Ralph objective must be a non-empty string");
|
|
330
|
+
const maxRounds = resolveMaxRounds(args.maxRounds, resolved.maxRounds);
|
|
331
|
+
requireFreshProvider(ctx, resolved.subagentProvider);
|
|
332
|
+
const run = ctx.workflowEngine.start({
|
|
333
|
+
script: RALPH_SCRIPT,
|
|
334
|
+
meta: RALPH_META,
|
|
335
|
+
args: {
|
|
336
|
+
objective,
|
|
337
|
+
maxRounds,
|
|
338
|
+
maxHandoffChars: resolved.maxHandoffChars
|
|
339
|
+
},
|
|
340
|
+
subagentProvider: resolved.subagentProvider,
|
|
341
|
+
maxTotalAgents: maxRounds,
|
|
342
|
+
parent,
|
|
343
|
+
signal: exec.signal
|
|
344
|
+
});
|
|
345
|
+
const onAbort = () => {
|
|
346
|
+
run.cancel("parent step aborted");
|
|
347
|
+
};
|
|
348
|
+
exec.signal.addEventListener("abort", onAbort, { once: true });
|
|
349
|
+
if (exec.signal.aborted) run.cancel("parent step aborted");
|
|
350
|
+
try {
|
|
351
|
+
const settled = await run.result;
|
|
352
|
+
const error = stopReasonError(settled);
|
|
353
|
+
if (error !== void 0) throw new Error(error);
|
|
354
|
+
const value = readRunResult(settled.value, maxRounds, resolved.maxHandoffChars);
|
|
355
|
+
if (value.status === "round-failed") throw new Error(renderRoundFailure(value, resolved.maxResultChars));
|
|
356
|
+
return {
|
|
357
|
+
runId: run.id,
|
|
358
|
+
agentsStarted: settled.agentsStarted,
|
|
359
|
+
result: value
|
|
360
|
+
};
|
|
361
|
+
} finally {
|
|
362
|
+
exec.signal.removeEventListener("abort", onAbort);
|
|
363
|
+
await run.dispose();
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
presentCall,
|
|
367
|
+
presentResult
|
|
368
|
+
}));
|
|
369
|
+
}
|
|
370
|
+
//#endregion
|
|
371
|
+
export { Config, apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@hydraharness/harness-tool-ralph`.
|
|
4
|
+
* @module @hydraharness/harness-tool-ralph/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@hydraharness/harness-tool-ralph";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "tool-ralph-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: this model-facing orchestration adapter owns no independent event stream;
|
|
13
|
+
* workflow and subagent owners validate the runs and child lifecycles it starts.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => {};
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,26 @@
|
|
|
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 @hydraharness/harness-tool-ralph
|
|
6
|
+
*/
|
|
7
|
+
import type { Context } from '@hydraharness/cordis';
|
|
8
|
+
import z from '@hydraharness/schemastery';
|
|
9
|
+
export declare const name = "tool-ralph";
|
|
10
|
+
export declare const inject: string[];
|
|
11
|
+
/** Deployment policy for the fixed Ralph workflow. */
|
|
12
|
+
export interface Config {
|
|
13
|
+
/** Fresh structured-output provider used for every round (default `spawn`). */
|
|
14
|
+
subagentProvider?: string;
|
|
15
|
+
/** Default and deployment ceiling for one call's round count (default 256). */
|
|
16
|
+
maxRounds?: number;
|
|
17
|
+
/** Maximum serialized characters in one structured handoff (default 16384). */
|
|
18
|
+
maxHandoffChars?: number;
|
|
19
|
+
/** Maximum characters in a successful parent-facing terminal text (default 16384). */
|
|
20
|
+
maxResultChars?: number;
|
|
21
|
+
}
|
|
22
|
+
/** Schemastery configuration for the Ralph tool. */
|
|
23
|
+
export declare const Config: z<Config>;
|
|
24
|
+
/** Register the fixed Ralph tool and its explicit-ask usage policy. */
|
|
25
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
26
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@hydraharness/harness-tool-ralph`.
|
|
3
|
+
* @module @hydraharness/harness-tool-ralph/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@hydraharness/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "tool-ralph-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hydraharness/harness-tool-ralph",
|
|
3
|
+
"description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams",
|
|
4
|
+
"hydra": {
|
|
5
|
+
"plugin": {
|
|
6
|
+
"application": "Run a repeated workflow with a fresh agent for each iteration."
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"version": "0.1.1-rc.6",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/MaiHongPhong1902/Hydra-Harness.git",
|
|
16
|
+
"directory": "packages/workflow/tool-ralph"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "lib/index.js",
|
|
20
|
+
"types": "lib/types/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./lib/types/index.d.ts",
|
|
24
|
+
"default": "./lib/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./invariant": {
|
|
27
|
+
"types": "./lib/types/invariant.d.ts",
|
|
28
|
+
"default": "./lib/invariant.js"
|
|
29
|
+
},
|
|
30
|
+
"./src/*": "./src/*",
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"lib/index.js",
|
|
35
|
+
"lib/invariant.js",
|
|
36
|
+
"lib/types/**/*.d.ts"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.6",
|
|
41
|
+
"@hydraharness/harness-llm": "^0.1.1-rc.6",
|
|
42
|
+
"@hydraharness/harness-subagent": "^0.1.1-rc.6",
|
|
43
|
+
"@hydraharness/harness-system-prompt": "^0.1.1-rc.6",
|
|
44
|
+
"@hydraharness/harness-tools": "^0.1.1-rc.6",
|
|
45
|
+
"@hydraharness/harness-workflow": "^0.1.1-rc.6",
|
|
46
|
+
"@hydraharness/harness-agent": "^0.1.1-rc.6",
|
|
47
|
+
"@hydraharness/cordis": "^4.0.2"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@hydraharness/schemastery": "^3.18.2"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@hydraharness/harness-agent": "^0.1.1-rc.6",
|
|
54
|
+
"@hydraharness/cordis-plugin-loader": "^1.0.3",
|
|
55
|
+
"@hydraharness/harness-agent-loop": "^0.1.1-rc.6",
|
|
56
|
+
"@hydraharness/harness-agent-loop-testkit": "^0.1.1-rc.6",
|
|
57
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.6",
|
|
58
|
+
"@hydraharness/harness-llm": "^0.1.1-rc.6",
|
|
59
|
+
"@hydraharness/harness-session": "^0.1.1-rc.6",
|
|
60
|
+
"@hydraharness/harness-subagent-in-process-driver": "^0.1.1-rc.6",
|
|
61
|
+
"@hydraharness/harness-subagent": "^0.1.1-rc.6",
|
|
62
|
+
"@hydraharness/harness-system-prompt": "^0.1.1-rc.6",
|
|
63
|
+
"@hydraharness/harness-subagent-spawn-in-process": "^0.1.1-rc.6",
|
|
64
|
+
"@hydraharness/harness-workflow": "^0.1.1-rc.6",
|
|
65
|
+
"@hydraharness/harness-workflow-worker-thread": "^0.1.1-rc.6",
|
|
66
|
+
"@hydraharness/harness-tools": "^0.1.1-rc.6",
|
|
67
|
+
"@hydraharness/cordis": "^4.0.2"
|
|
68
|
+
}
|
|
69
|
+
}
|