@hydraharness/harness-tool-workflow 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 +83 -0
- package/lib/index.js +276 -0
- package/lib/invariant.js +129 -0
- package/lib/types/index.d.ts +25 -0
- package/lib/types/index.js +278 -0
- package/lib/types/invariant.d.ts +9 -0
- package/lib/types/invariant.js +151 -0
- package/lib/types/types.d.ts +57 -0
- package/lib/types/types.js +8 -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,83 @@
|
|
|
1
|
+
# @hydraharness/harness-tool-workflow
|
|
2
|
+
|
|
3
|
+
The model-facing **`workflow` tool**: run a JavaScript orchestration script that fans out subagents, and return the script's final value. This package owns the model-facing schema and run lifecycle over [`ctx.workflowEngine`](../workflow/README.md); script parsing, execution, caps, and cancellation live behind the seam, while the consumer retains ownership of the parent-facing schema and result envelope.
|
|
4
|
+
|
|
5
|
+
## What the model sees
|
|
6
|
+
|
|
7
|
+
Three parameters: `meta` (required identity data: `name`, `description`, and optional progress annotations), `script` (required plain JavaScript body — no `export const meta` statement; the tool description carries the complete authoring contract), and `args` (optional JSON object exposed to the script as the `args` global; wrap a bare list in a field so the wire schema stays honest). The plugin also contributes a `tool:<toolName>` system-prompt section carrying the usage policy — use the tool only on an explicit user ask for a workflow / large orchestration; prefer plain subagent calls for one or two delegations — per the convention that tool guidance ships with the tool plugin, never in the deployment persona.
|
|
8
|
+
|
|
9
|
+
## Lifecycle
|
|
10
|
+
|
|
11
|
+
Collection is synchronous (like [`@hydraharness/harness-tool-subagent`](../../subagent/tool-subagent/README.md)): `execute` starts a run and awaits `run.result` inside a `try/finally` that always disposes the run, so the script and its children reach quiescence on every path. `exec.signal` is bridged to `run.cancel()` (including the already-aborted-before-start case). A non-`completed` stop reason maps to an `isError` result reporting the reason—never partial output as success; a parse/meta failure thrown synchronously by `start()` becomes an `isError` the model can correct from. Completion returns canonical `{ runId, agentsStarted, result }`; the Native renderer preserves the meta name, agent count, and JSON value, truncating only that projection at `maxResultChars`.
|
|
12
|
+
|
|
13
|
+
For a root transport execution (`exec.parent` absent), the tool also projects the run into the calling Agent's Session: run-start after `start()` returns, matching member starts and endings filtered by `run.id`, then run-end only after `run.result` is available and `dispose()` has reached quiescence. Nested transport calls execute normally but write no workflow record. The first failed Session append disables later recording for that run, emits one warning, and leaves either no record or a legal continuous prefix without changing the tool result or cleanup.
|
|
14
|
+
|
|
15
|
+
The browser-safe `@hydraharness/harness-tool-workflow/types` subpath owns these four log-only event payloads and their `SessionEventMap` declaration. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append while accepting missing terminal suffixes.
|
|
16
|
+
|
|
17
|
+
## Render intent
|
|
18
|
+
|
|
19
|
+
Decided up front (per the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)): a `generic` card titled `workflow: <meta.name>`, read directly from `args.meta.name` (presentation is a pure function of args and does not ask the engine to parse); the script text rides as `rawInput`. The result keeps the generic card.
|
|
20
|
+
|
|
21
|
+
## Config
|
|
22
|
+
|
|
23
|
+
| Key | Default | Meaning |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `toolName` | `workflow` | The model-facing tool name to register. |
|
|
26
|
+
| `maxResultChars` | `50000` | Rendered-result ceiling; longer JSON is truncated with a notice. |
|
|
27
|
+
|
|
28
|
+
## Model Experience
|
|
29
|
+
|
|
30
|
+
### System prompt
|
|
31
|
+
|
|
32
|
+
#### What the model sees
|
|
33
|
+
|
|
34
|
+
Every parent request in this plugin's registration scope receives the workflow guidance below. A scoped tool restriction can hide the schema without removing this independently registered guidance.
|
|
35
|
+
|
|
36
|
+
##### Workflow guidance
|
|
37
|
+
|
|
38
|
+
```markdown
|
|
39
|
+
Use the <toolName> tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
#### Token effect
|
|
43
|
+
|
|
44
|
+
Small fixed guidance cost per request while the plugin is active.
|
|
45
|
+
|
|
46
|
+
#### KV Cache effect
|
|
47
|
+
|
|
48
|
+
Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section.
|
|
49
|
+
|
|
50
|
+
### Tool schema
|
|
51
|
+
|
|
52
|
+
#### What the model sees
|
|
53
|
+
|
|
54
|
+
When visible, the generated default [`workflow` schema](../../../docs/tool-catalog.md#hydraharness-tool-workflow) carries the complete JavaScript hook and metadata contract; `toolName` can rename the definition, and the model submits script, metadata, and optional args.
|
|
55
|
+
|
|
56
|
+
#### Token effect
|
|
57
|
+
|
|
58
|
+
Substantial fixed schema cost on each request where the tool is visible.
|
|
59
|
+
|
|
60
|
+
#### KV Cache effect
|
|
61
|
+
|
|
62
|
+
Prefix-stable while `toolName`, definition, and visibility are unchanged. Renaming, plugin lifecycle, or scoped restrictions may invalidate reuse from this schema.
|
|
63
|
+
|
|
64
|
+
### Tool-call history and result
|
|
65
|
+
|
|
66
|
+
#### What the model sees
|
|
67
|
+
|
|
68
|
+
The full model-written script, metadata, and args remain in the assistant tool call. Success is exactly `workflow "<name>" completed (<count> agent<optional-s>).`, newline, `Return value:`, newline, and pretty-printed data-dependent JSON; a cap adds `… [truncated: <omitted> more characters]` on a new line. Failures are exactly `Error: workflow run was cancelled`, optionally suffixed ` (<error>)`, `Error: workflow run failed: <error-or-unknown error>`, or defensively `Error: workflow run ended abnormally (<reason>)`; a call without an owning agent becomes `Error: workflow tool requires a calling agent (exec.agent was undefined)`. Intermediate child messages are omitted.
|
|
69
|
+
|
|
70
|
+
#### Token effect
|
|
71
|
+
|
|
72
|
+
Call tokens can be large and remain until compaction. Result rendering is capped by `maxResultChars`; child-model tokens are separate from the parent's retained context.
|
|
73
|
+
|
|
74
|
+
#### KV Cache effect
|
|
75
|
+
|
|
76
|
+
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
|
77
|
+
|
|
78
|
+
## Known Limitations and Deferred Work
|
|
79
|
+
|
|
80
|
+
- **The parent turn blocks until the whole workflow settles** — there is no background start/poll API, and cancellation discards partial output as an error.
|
|
81
|
+
- **`args` must be an object and Native result text is bounded** — callers wrap top-level arrays/scalars in a field; the canonical workflow result remains complete, while JSON beyond `maxResultChars` is truncated in the model-facing projection rather than stored behind a retrieval handle.
|
|
82
|
+
- **Workflow policy is fixed per tool registration** — provider selection, caps, and tool name are deployment config, not model-call arguments.
|
|
83
|
+
- **Durable records are top-level and observational** — nested Code Mode dispatches are not recorded, and a recording failure intentionally degrades to an incomplete prefix rather than changing execution.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import z from "@hydraharness/schemastery";
|
|
2
|
+
import { defineTool } from "@hydraharness/harness-tools";
|
|
3
|
+
//#region lib/types/index.js
|
|
4
|
+
/**
|
|
5
|
+
* The model-facing `workflow` tool: run a JavaScript orchestration script that fans out
|
|
6
|
+
* subagents, and return the script's final value. It owns the model-facing schema and run lifecycle; script
|
|
7
|
+
* parsing, execution, caps, and cancellation live behind `ctx.workflowEngine`
|
|
8
|
+
* (`@hydraharness/harness-workflow`), so a hardened engine swaps in without touching what the model
|
|
9
|
+
* sees. Execution awaits `run.result` and always disposes the run; non-completed reasons become tool
|
|
10
|
+
* errors, and background collection remains deferred. Presentation is an args-only generic card
|
|
11
|
+
* titled from `meta.name`. Explicit-ask usage guidance is registered as the tool's own prompt
|
|
12
|
+
* section rather than deployment persona prose.
|
|
13
|
+
* @module @hydraharness/harness-tool-workflow
|
|
14
|
+
*/
|
|
15
|
+
const name = "tool-workflow";
|
|
16
|
+
const inject = [
|
|
17
|
+
"tools",
|
|
18
|
+
"workflowEngine",
|
|
19
|
+
"systemPrompt"
|
|
20
|
+
];
|
|
21
|
+
const Config = z.object({
|
|
22
|
+
toolName: z.string().default("workflow"),
|
|
23
|
+
maxResultChars: z.natural().min(1).default(5e4)
|
|
24
|
+
});
|
|
25
|
+
/** Render a contained recording failure without trusting the thrown value. */
|
|
26
|
+
function renderRecordingError(error) {
|
|
27
|
+
try {
|
|
28
|
+
return String(error);
|
|
29
|
+
} catch {
|
|
30
|
+
return "[unrenderable thrown value]";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Project active top-level workflow runs into their parent Sessions without
|
|
35
|
+
* letting recording failure affect tool execution.
|
|
36
|
+
*/
|
|
37
|
+
function createWorkflowRecorder(ctx) {
|
|
38
|
+
const active = /* @__PURE__ */ new Map();
|
|
39
|
+
const append = (session, type, data) => {
|
|
40
|
+
const appendRecord = session.append.bind(session);
|
|
41
|
+
try {
|
|
42
|
+
appendRecord(type, data);
|
|
43
|
+
return true;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
ctx.logger.warn(`tool-workflow: disabled durable record after ${type} append failed: ${renderRecordingError(error)}`);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
ctx.on("workflow/agent-start", (info, agent) => {
|
|
50
|
+
const session = active.get(info.id);
|
|
51
|
+
if (session === void 0) return;
|
|
52
|
+
if (!append(session, "tool-workflow/agent-start", {
|
|
53
|
+
runId: info.id,
|
|
54
|
+
seq: agent.seq,
|
|
55
|
+
label: agent.label,
|
|
56
|
+
...agent.phase === void 0 ? {} : { phase: agent.phase },
|
|
57
|
+
childId: agent.childId
|
|
58
|
+
})) active.delete(info.id);
|
|
59
|
+
});
|
|
60
|
+
ctx.on("workflow/agent-end", (info, agent) => {
|
|
61
|
+
const session = active.get(info.id);
|
|
62
|
+
if (session === void 0) return;
|
|
63
|
+
if (!append(session, "tool-workflow/agent-end", {
|
|
64
|
+
runId: info.id,
|
|
65
|
+
seq: agent.seq,
|
|
66
|
+
outcome: agent.outcome
|
|
67
|
+
})) active.delete(info.id);
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
start(session, run) {
|
|
71
|
+
if (append(session, "tool-workflow/run-start", {
|
|
72
|
+
runId: run.id,
|
|
73
|
+
name: run.meta.name
|
|
74
|
+
})) active.set(run.id, session);
|
|
75
|
+
},
|
|
76
|
+
finish(runId, stopReason) {
|
|
77
|
+
const session = active.get(runId);
|
|
78
|
+
if (session !== void 0) append(session, "tool-workflow/run-end", {
|
|
79
|
+
runId,
|
|
80
|
+
stopReason
|
|
81
|
+
});
|
|
82
|
+
active.delete(runId);
|
|
83
|
+
},
|
|
84
|
+
abandon: (runId) => {
|
|
85
|
+
active.delete(runId);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The script-authoring contract, embedded in the tool description. This IS the
|
|
91
|
+
* model-facing spec: the meta block, the hooks and their exact semantics, and
|
|
92
|
+
* the supported schema subset.
|
|
93
|
+
*/
|
|
94
|
+
const DESCRIPTION = `Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.
|
|
95
|
+
|
|
96
|
+
The workflow's identity rides the \`meta\` parameter as JSON: required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, provider?, model?}\`). The \`script\` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO \`export const meta\` statement — meta is a parameter, not code), running with top-level await; end with \`return <value>\` — the value must be JSON-serializable and is this tool's result.
|
|
97
|
+
|
|
98
|
+
Script-body hooks:
|
|
99
|
+
- \`agent(prompt, opts?): Promise<any>\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), and independent \`provider\`/\`model\` LLM target overrides (either may be provided alone). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly.
|
|
100
|
+
- \`pipeline(items, ...stages): Promise<any[]>\` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives \`(prev, item, index)\`. An ordinary stage throw drops that ITEM to \`null\` and skips its remaining stages.
|
|
101
|
+
- \`parallel(thunks): Promise<any[]>\` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to \`null\`.
|
|
102
|
+
- \`phase(title)\` — start a progress phase; \`log(message)\` — narrate progress; \`args\` — the tool call's \`args\` input, verbatim.
|
|
103
|
+
|
|
104
|
+
Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item \`null\`.
|
|
105
|
+
|
|
106
|
+
Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.`;
|
|
107
|
+
/** The pending-state card: a generic card titled by the workflow's meta name. */
|
|
108
|
+
function presentWorkflowCall(args) {
|
|
109
|
+
return {
|
|
110
|
+
card: "generic",
|
|
111
|
+
title: `workflow: ${args.meta.name}`,
|
|
112
|
+
rawInput: args.script
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** The completed-state card: keep the pending title; render the result content as-is. */
|
|
116
|
+
function presentWorkflowResult(args, result) {
|
|
117
|
+
return { card: "generic" };
|
|
118
|
+
}
|
|
119
|
+
/** A non-`completed` stop reason means the script did not finish cleanly. */
|
|
120
|
+
function stopReasonError(result) {
|
|
121
|
+
switch (result.stopReason) {
|
|
122
|
+
case "completed": return;
|
|
123
|
+
case "cancelled": return `workflow run was cancelled${result.error !== void 0 ? ` (${result.error})` : ""}`;
|
|
124
|
+
case "error": return `workflow run failed: ${result.error ?? "unknown error"}`;
|
|
125
|
+
/* v8 ignore start -- defensive: WorkflowStopReason is a closed union, exhaustive by construction; a future variant fails here loudly */
|
|
126
|
+
default: return `workflow run ended abnormally (${String(result.stopReason)})`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Render the run's outcome text: the meta name, agent count, and the JSON value (capped). */
|
|
130
|
+
function renderResult(name, agentsStarted, value, maxChars) {
|
|
131
|
+
const rendered = JSON.stringify(value, null, 2);
|
|
132
|
+
const clipped = rendered.length > maxChars ? `${rendered.slice(0, maxChars)}\n… [truncated: ${rendered.length - maxChars} more characters]` : rendered;
|
|
133
|
+
return `workflow "${name}" completed (${agentsStarted} agent${agentsStarted === 1 ? "" : "s"}).\nReturn value:\n${clipped}`;
|
|
134
|
+
}
|
|
135
|
+
function apply(ctx, config) {
|
|
136
|
+
const { toolName, maxResultChars } = config;
|
|
137
|
+
const recorder = createWorkflowRecorder(ctx);
|
|
138
|
+
ctx.systemPrompt.section({
|
|
139
|
+
name: `tool:${toolName}`,
|
|
140
|
+
order: 115,
|
|
141
|
+
text: `Use the ${toolName} tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.`
|
|
142
|
+
});
|
|
143
|
+
ctx.tools.register(defineTool({
|
|
144
|
+
name: toolName,
|
|
145
|
+
description: DESCRIPTION,
|
|
146
|
+
parameters: {
|
|
147
|
+
script: {
|
|
148
|
+
type: "string",
|
|
149
|
+
required: true,
|
|
150
|
+
description: "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`)."
|
|
151
|
+
},
|
|
152
|
+
meta: {
|
|
153
|
+
type: "object",
|
|
154
|
+
additionalProperties: true,
|
|
155
|
+
required: true,
|
|
156
|
+
description: "The workflow identity block (plain JSON — never code).",
|
|
157
|
+
properties: {
|
|
158
|
+
name: {
|
|
159
|
+
type: "string",
|
|
160
|
+
required: true,
|
|
161
|
+
description: "Short kebab-case workflow name."
|
|
162
|
+
},
|
|
163
|
+
description: {
|
|
164
|
+
type: "string",
|
|
165
|
+
required: true,
|
|
166
|
+
description: "One-line description of what the workflow does."
|
|
167
|
+
},
|
|
168
|
+
whenToUse: {
|
|
169
|
+
type: "string",
|
|
170
|
+
description: "Optional guidance on when this workflow applies."
|
|
171
|
+
},
|
|
172
|
+
phases: {
|
|
173
|
+
type: "array",
|
|
174
|
+
description: "Optional phase declarations matched by phase() calls.",
|
|
175
|
+
items: {
|
|
176
|
+
type: "object",
|
|
177
|
+
additionalProperties: true,
|
|
178
|
+
properties: {
|
|
179
|
+
title: {
|
|
180
|
+
type: "string",
|
|
181
|
+
required: true,
|
|
182
|
+
description: "The phase title phase() calls match by exact string."
|
|
183
|
+
},
|
|
184
|
+
detail: {
|
|
185
|
+
type: "string",
|
|
186
|
+
description: "Optional one-line description of the phase."
|
|
187
|
+
},
|
|
188
|
+
provider: {
|
|
189
|
+
type: "string",
|
|
190
|
+
description: "Optional provider override this phase is expected to use."
|
|
191
|
+
},
|
|
192
|
+
model: {
|
|
193
|
+
type: "string",
|
|
194
|
+
description: "Optional model override this phase is expected to use."
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
args: {
|
|
202
|
+
type: "object",
|
|
203
|
+
additionalProperties: true,
|
|
204
|
+
description: "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
output: {
|
|
208
|
+
schema: {
|
|
209
|
+
type: "object",
|
|
210
|
+
additionalProperties: false,
|
|
211
|
+
properties: {
|
|
212
|
+
runId: {
|
|
213
|
+
type: "string",
|
|
214
|
+
required: true
|
|
215
|
+
},
|
|
216
|
+
agentsStarted: {
|
|
217
|
+
type: "integer",
|
|
218
|
+
required: true
|
|
219
|
+
},
|
|
220
|
+
result: {
|
|
221
|
+
type: "json",
|
|
222
|
+
required: true
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
render: (args, value) => [{
|
|
227
|
+
type: "text",
|
|
228
|
+
text: renderResult(args.meta.name, value.agentsStarted, value.result, maxResultChars)
|
|
229
|
+
}]
|
|
230
|
+
},
|
|
231
|
+
async execute(args, exec) {
|
|
232
|
+
const parent = exec.agent;
|
|
233
|
+
if (!parent) throw new Error("workflow tool requires a calling agent (exec.agent was undefined)");
|
|
234
|
+
const run = ctx.workflowEngine.start({
|
|
235
|
+
script: args.script,
|
|
236
|
+
meta: args.meta,
|
|
237
|
+
...args.args !== void 0 ? { args: args.args } : {},
|
|
238
|
+
parent,
|
|
239
|
+
signal: exec.signal
|
|
240
|
+
});
|
|
241
|
+
const recordsRun = exec.parent === void 0;
|
|
242
|
+
if (recordsRun) recorder.start(parent.session, run);
|
|
243
|
+
const onAbort = () => {
|
|
244
|
+
run.cancel("parent step aborted");
|
|
245
|
+
};
|
|
246
|
+
exec.signal.addEventListener("abort", onAbort, { once: true });
|
|
247
|
+
let result;
|
|
248
|
+
try {
|
|
249
|
+
result = await run.result;
|
|
250
|
+
const error = stopReasonError(result);
|
|
251
|
+
if (error !== void 0) throw new Error(error);
|
|
252
|
+
return {
|
|
253
|
+
runId: run.id,
|
|
254
|
+
agentsStarted: result.agentsStarted,
|
|
255
|
+
result: result.value
|
|
256
|
+
};
|
|
257
|
+
} finally {
|
|
258
|
+
exec.signal.removeEventListener("abort", onAbort);
|
|
259
|
+
try {
|
|
260
|
+
await run.dispose();
|
|
261
|
+
if (recordsRun) {
|
|
262
|
+
/* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */
|
|
263
|
+
if (result === void 0) throw new Error("workflow run settled without a result");
|
|
264
|
+
recorder.finish(run.id, result.stopReason);
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
if (recordsRun) recorder.abandon(run.id);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
presentCall: (args) => presentWorkflowCall(args),
|
|
272
|
+
presentResult: (args, result) => presentWorkflowResult(args, result)
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
//#endregion
|
|
276
|
+
export { Config, apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/** Package-owned durable workflow-record invariants. @module @hydraharness/harness-tool-workflow/invariant */
|
|
3
|
+
const PACKAGE_NAME = "@hydraharness/harness-tool-workflow";
|
|
4
|
+
/** Cordis companion plugin name. */
|
|
5
|
+
const name = "tool-workflow-invariant";
|
|
6
|
+
/** Services required to validate existing and newly appended Session logs. */
|
|
7
|
+
const inject = ["invariants"];
|
|
8
|
+
/** Whether this package owns the candidate Session event. */
|
|
9
|
+
function isWorkflowRecordEvent(event) {
|
|
10
|
+
return event.type.startsWith("tool-workflow/");
|
|
11
|
+
}
|
|
12
|
+
/** Require a durable opaque identity to be a non-empty string. */
|
|
13
|
+
function stringId(value, label, fail) {
|
|
14
|
+
if (typeof value !== "string" || value.length === 0) fail(`${label} must be a non-empty string`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
/** Require one workflow member's 1-based sequence identity. */
|
|
18
|
+
function memberSeq(value, fail) {
|
|
19
|
+
if (!Number.isSafeInteger(value) || value < 1) fail("tool-workflow member seq must be a positive safe integer");
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
/** Read one plain payload field without trusting restored plugin data. */
|
|
23
|
+
function recordOf(event, fail) {
|
|
24
|
+
const data = event.data;
|
|
25
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) fail(`${event.type} data must be a JSON object`);
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
/** Copy only the run one candidate can mutate; other committed states stay shared. */
|
|
29
|
+
function cloneTraceForEvent(source, event, fail) {
|
|
30
|
+
const trace = new Map(source);
|
|
31
|
+
if (event.type === "tool-workflow/run-start") return trace;
|
|
32
|
+
const runId = stringId(recordOf(event, fail).runId, `${event.type} runId`, fail);
|
|
33
|
+
const run = source.get(runId);
|
|
34
|
+
if (run !== void 0) trace.set(runId, {
|
|
35
|
+
ended: run.ended,
|
|
36
|
+
members: new Map(run.members)
|
|
37
|
+
});
|
|
38
|
+
return trace;
|
|
39
|
+
}
|
|
40
|
+
/** Require the named run to exist and remain open. */
|
|
41
|
+
function openRun(trace, runId, eventType, fail) {
|
|
42
|
+
const run = trace.get(runId);
|
|
43
|
+
if (run === void 0) fail(`${eventType} has no matching tool-workflow/run-start for run ${runId}`);
|
|
44
|
+
if (run.ended) fail(`${eventType} appears after tool-workflow/run-end for run ${runId}`);
|
|
45
|
+
return run;
|
|
46
|
+
}
|
|
47
|
+
/** Advance the workflow-record fold with one relevant Session event. */
|
|
48
|
+
function applyEvent(trace, event, fail) {
|
|
49
|
+
const data = recordOf(event, fail);
|
|
50
|
+
const runId = stringId(data.runId, `${event.type} runId`, fail);
|
|
51
|
+
switch (event.type) {
|
|
52
|
+
case "tool-workflow/run-start":
|
|
53
|
+
if (typeof data.name !== "string" || data.name.length === 0) fail("tool-workflow/run-start name must be a non-empty string");
|
|
54
|
+
if (trace.has(runId)) fail(`tool-workflow/run-start repeats run ${runId}`);
|
|
55
|
+
trace.set(runId, {
|
|
56
|
+
ended: false,
|
|
57
|
+
members: /* @__PURE__ */ new Map()
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
case "tool-workflow/agent-start": {
|
|
61
|
+
const run = openRun(trace, runId, event.type, fail);
|
|
62
|
+
const seq = memberSeq(data.seq, fail);
|
|
63
|
+
if (typeof data.label !== "string") fail("tool-workflow/agent-start label must be a string");
|
|
64
|
+
if (data.phase !== void 0 && typeof data.phase !== "string") fail("tool-workflow/agent-start phase must be a string when present");
|
|
65
|
+
stringId(data.childId, "tool-workflow/agent-start childId", fail);
|
|
66
|
+
if (run.members.has(seq)) fail(`tool-workflow/agent-start repeats member seq ${seq} in run ${runId}`);
|
|
67
|
+
run.members.set(seq, false);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
case "tool-workflow/agent-end": {
|
|
71
|
+
const run = openRun(trace, runId, event.type, fail);
|
|
72
|
+
const seq = memberSeq(data.seq, fail);
|
|
73
|
+
if (data.outcome !== "completed" && data.outcome !== "failed" && data.outcome !== "cancelled") fail(`tool-workflow/agent-end outcome ${String(data.outcome)} is invalid`);
|
|
74
|
+
const ended = run.members.get(seq);
|
|
75
|
+
if (ended === void 0) fail(`tool-workflow/agent-end has no matching member seq ${seq} in run ${runId}`);
|
|
76
|
+
if (ended) fail(`tool-workflow/agent-end repeats member seq ${seq} in run ${runId}`);
|
|
77
|
+
run.members.set(seq, true);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
case "tool-workflow/run-end": {
|
|
81
|
+
const run = openRun(trace, runId, event.type, fail);
|
|
82
|
+
if (data.stopReason !== "completed" && data.stopReason !== "cancelled" && data.stopReason !== "error") fail(`tool-workflow/run-end stopReason ${String(data.stopReason)} is invalid`);
|
|
83
|
+
const openMembers = [...run.members].filter(([, ended]) => !ended).map(([seq]) => seq);
|
|
84
|
+
if (openMembers.length > 0) fail(`tool-workflow/run-end leaves member seq ${openMembers.join(", ")} open in run ${runId}`);
|
|
85
|
+
run.ended = true;
|
|
86
|
+
run.members.clear();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
default: fail(`unknown tool-workflow event type ${event.type}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Install an independent incremental fold over every attached Session. */
|
|
93
|
+
const install = Object.assign((ctx, fail) => {
|
|
94
|
+
const traces = /* @__PURE__ */ new WeakMap();
|
|
95
|
+
const staged = /* @__PURE__ */ new WeakMap();
|
|
96
|
+
const seed = (session) => {
|
|
97
|
+
const trace = /* @__PURE__ */ new Map();
|
|
98
|
+
for (const event of session.events.filter(isWorkflowRecordEvent)) applyEvent(trace, event, fail);
|
|
99
|
+
traces.set(session, trace);
|
|
100
|
+
return trace;
|
|
101
|
+
};
|
|
102
|
+
ctx.sessions.list().forEach(seed);
|
|
103
|
+
ctx.on("session/created", (session) => {
|
|
104
|
+
seed(session);
|
|
105
|
+
}, { global: true });
|
|
106
|
+
ctx.on("internal/dispatch", (_mode, eventName, args) => {
|
|
107
|
+
if (eventName !== "session/event") return;
|
|
108
|
+
const [session, event] = args;
|
|
109
|
+
if (!isWorkflowRecordEvent(event)) return;
|
|
110
|
+
const trace = cloneTraceForEvent(traces.get(session), event, fail);
|
|
111
|
+
applyEvent(trace, event, fail);
|
|
112
|
+
staged.set(event, {
|
|
113
|
+
session,
|
|
114
|
+
trace
|
|
115
|
+
});
|
|
116
|
+
}, { global: true });
|
|
117
|
+
ctx.on("session/event", (session, event) => {
|
|
118
|
+
if (!isWorkflowRecordEvent(event)) return;
|
|
119
|
+
const candidate = staged.get(event);
|
|
120
|
+
/* v8 ignore next 2 -- internal/dispatch stages the exact session/event callback arguments. */
|
|
121
|
+
if (candidate === void 0 || candidate.session !== session) return fail("session/event reached publication without matching workflow-record validation");
|
|
122
|
+
staged.delete(event);
|
|
123
|
+
traces.set(session, candidate.trace);
|
|
124
|
+
}, { global: true });
|
|
125
|
+
}, { inject: ["sessions"] });
|
|
126
|
+
/** Register this package's invariant companion. */
|
|
127
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
128
|
+
//#endregion
|
|
129
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing `workflow` tool: run a JavaScript orchestration script that fans out
|
|
3
|
+
* subagents, and return the script's final value. It owns the model-facing schema and run lifecycle; script
|
|
4
|
+
* parsing, execution, caps, and cancellation live behind `ctx.workflowEngine`
|
|
5
|
+
* (`@hydraharness/harness-workflow`), so a hardened engine swaps in without touching what the model
|
|
6
|
+
* sees. Execution awaits `run.result` and always disposes the run; non-completed reasons become tool
|
|
7
|
+
* errors, and background collection remains deferred. Presentation is an args-only generic card
|
|
8
|
+
* titled from `meta.name`. Explicit-ask usage guidance is registered as the tool's own prompt
|
|
9
|
+
* section rather than deployment persona prose.
|
|
10
|
+
* @module @hydraharness/harness-tool-workflow
|
|
11
|
+
*/
|
|
12
|
+
import type { Context } from '@hydraharness/cordis';
|
|
13
|
+
import z from '@hydraharness/schemastery';
|
|
14
|
+
export declare const name = "tool-workflow";
|
|
15
|
+
export declare const inject: string[];
|
|
16
|
+
/** Config: the model-facing tool name plus result rendering caps. */
|
|
17
|
+
export interface Config {
|
|
18
|
+
/** The model-facing tool name to register (default `workflow`). */
|
|
19
|
+
toolName?: string;
|
|
20
|
+
/** Rendered-result ceiling, in characters: a longer JSON value is truncated with a notice (default 50000). */
|
|
21
|
+
maxResultChars?: number;
|
|
22
|
+
}
|
|
23
|
+
export declare const Config: z<Config>;
|
|
24
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
25
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing `workflow` tool: run a JavaScript orchestration script that fans out
|
|
3
|
+
* subagents, and return the script's final value. It owns the model-facing schema and run lifecycle; script
|
|
4
|
+
* parsing, execution, caps, and cancellation live behind `ctx.workflowEngine`
|
|
5
|
+
* (`@hydraharness/harness-workflow`), so a hardened engine swaps in without touching what the model
|
|
6
|
+
* sees. Execution awaits `run.result` and always disposes the run; non-completed reasons become tool
|
|
7
|
+
* errors, and background collection remains deferred. Presentation is an args-only generic card
|
|
8
|
+
* titled from `meta.name`. Explicit-ask usage guidance is registered as the tool's own prompt
|
|
9
|
+
* section rather than deployment persona prose.
|
|
10
|
+
* @module @hydraharness/harness-tool-workflow
|
|
11
|
+
*/
|
|
12
|
+
import z from '@hydraharness/schemastery';
|
|
13
|
+
import { defineTool } from '@hydraharness/harness-tools';
|
|
14
|
+
export const name = 'tool-workflow';
|
|
15
|
+
export const inject = ['tools', 'workflowEngine', 'systemPrompt'];
|
|
16
|
+
export const Config = z.object({
|
|
17
|
+
toolName: z.string().default('workflow'),
|
|
18
|
+
maxResultChars: z.natural().min(1).default(50_000),
|
|
19
|
+
});
|
|
20
|
+
/** Render a contained recording failure without trusting the thrown value. */
|
|
21
|
+
function renderRecordingError(error) {
|
|
22
|
+
try {
|
|
23
|
+
return String(error);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return '[unrenderable thrown value]';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Project active top-level workflow runs into their parent Sessions without
|
|
31
|
+
* letting recording failure affect tool execution.
|
|
32
|
+
*/
|
|
33
|
+
function createWorkflowRecorder(ctx) {
|
|
34
|
+
const active = new Map();
|
|
35
|
+
const append = (session, type, data) => {
|
|
36
|
+
// These four package-owned events are all log-only. Narrowing the generic
|
|
37
|
+
// append face here discharges Session.append's conditional options tuple.
|
|
38
|
+
const appendRecord = session.append.bind(session);
|
|
39
|
+
try {
|
|
40
|
+
appendRecord(type, data);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
ctx.logger.warn(`tool-workflow: disabled durable record after ${type} append failed: ${renderRecordingError(error)}`);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
ctx.on('workflow/agent-start', (info, agent) => {
|
|
49
|
+
const session = active.get(info.id);
|
|
50
|
+
if (session === undefined)
|
|
51
|
+
return;
|
|
52
|
+
const data = {
|
|
53
|
+
runId: info.id,
|
|
54
|
+
seq: agent.seq,
|
|
55
|
+
label: agent.label,
|
|
56
|
+
...agent.phase === undefined ? {} : { phase: agent.phase },
|
|
57
|
+
childId: agent.childId,
|
|
58
|
+
};
|
|
59
|
+
if (!append(session, 'tool-workflow/agent-start', data))
|
|
60
|
+
active.delete(info.id);
|
|
61
|
+
});
|
|
62
|
+
ctx.on('workflow/agent-end', (info, agent) => {
|
|
63
|
+
const session = active.get(info.id);
|
|
64
|
+
if (session === undefined)
|
|
65
|
+
return;
|
|
66
|
+
const data = {
|
|
67
|
+
runId: info.id,
|
|
68
|
+
seq: agent.seq,
|
|
69
|
+
outcome: agent.outcome,
|
|
70
|
+
};
|
|
71
|
+
if (!append(session, 'tool-workflow/agent-end', data))
|
|
72
|
+
active.delete(info.id);
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
start(session, run) {
|
|
76
|
+
if (append(session, 'tool-workflow/run-start', { runId: run.id, name: run.meta.name })) {
|
|
77
|
+
active.set(run.id, session);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
finish(runId, stopReason) {
|
|
81
|
+
const session = active.get(runId);
|
|
82
|
+
if (session !== undefined)
|
|
83
|
+
append(session, 'tool-workflow/run-end', { runId, stopReason });
|
|
84
|
+
active.delete(runId);
|
|
85
|
+
},
|
|
86
|
+
abandon: (runId) => { active.delete(runId); },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The script-authoring contract, embedded in the tool description. This IS the
|
|
91
|
+
* model-facing spec: the meta block, the hooks and their exact semantics, and
|
|
92
|
+
* the supported schema subset.
|
|
93
|
+
*/
|
|
94
|
+
const DESCRIPTION = `Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.
|
|
95
|
+
|
|
96
|
+
The workflow's identity rides the \`meta\` parameter as JSON: required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, provider?, model?}\`). The \`script\` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO \`export const meta\` statement — meta is a parameter, not code), running with top-level await; end with \`return <value>\` — the value must be JSON-serializable and is this tool's result.
|
|
97
|
+
|
|
98
|
+
Script-body hooks:
|
|
99
|
+
- \`agent(prompt, opts?): Promise<any>\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), and independent \`provider\`/\`model\` LLM target overrides (either may be provided alone). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly.
|
|
100
|
+
- \`pipeline(items, ...stages): Promise<any[]>\` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives \`(prev, item, index)\`. An ordinary stage throw drops that ITEM to \`null\` and skips its remaining stages.
|
|
101
|
+
- \`parallel(thunks): Promise<any[]>\` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to \`null\`.
|
|
102
|
+
- \`phase(title)\` — start a progress phase; \`log(message)\` — narrate progress; \`args\` — the tool call's \`args\` input, verbatim.
|
|
103
|
+
|
|
104
|
+
Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item \`null\`.
|
|
105
|
+
|
|
106
|
+
Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.`;
|
|
107
|
+
/** The pending-state card: a generic card titled by the workflow's meta name. */
|
|
108
|
+
function presentWorkflowCall(args) {
|
|
109
|
+
return {
|
|
110
|
+
card: 'generic',
|
|
111
|
+
title: `workflow: ${args.meta.name}`,
|
|
112
|
+
rawInput: args.script,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** The completed-state card: keep the pending title; render the result content as-is. */
|
|
116
|
+
function presentWorkflowResult(args, result) {
|
|
117
|
+
void args;
|
|
118
|
+
void result;
|
|
119
|
+
return { card: 'generic' };
|
|
120
|
+
}
|
|
121
|
+
/** A non-`completed` stop reason means the script did not finish cleanly. */
|
|
122
|
+
function stopReasonError(result) {
|
|
123
|
+
switch (result.stopReason) {
|
|
124
|
+
case 'completed':
|
|
125
|
+
return undefined;
|
|
126
|
+
case 'cancelled':
|
|
127
|
+
return `workflow run was cancelled${result.error !== undefined ? ` (${result.error})` : ''}`;
|
|
128
|
+
case 'error':
|
|
129
|
+
return `workflow run failed: ${result.error ?? 'unknown error'}`;
|
|
130
|
+
/* v8 ignore start -- defensive: WorkflowStopReason is a closed union, exhaustive by construction; a future variant fails here loudly */
|
|
131
|
+
default:
|
|
132
|
+
return `workflow run ended abnormally (${String(result.stopReason)})`;
|
|
133
|
+
/* v8 ignore stop */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Render the run's outcome text: the meta name, agent count, and the JSON value (capped). */
|
|
137
|
+
function renderResult(name, agentsStarted, value, maxChars) {
|
|
138
|
+
// The engine returns JSON data (null for a valueless script), so stringify never yields undefined.
|
|
139
|
+
const rendered = JSON.stringify(value, null, 2);
|
|
140
|
+
const clipped = rendered.length > maxChars
|
|
141
|
+
? `${rendered.slice(0, maxChars)}\n… [truncated: ${rendered.length - maxChars} more characters]`
|
|
142
|
+
: rendered;
|
|
143
|
+
return `workflow "${name}" completed (${agentsStarted} agent${agentsStarted === 1 ? '' : 's'}).\nReturn value:\n${clipped}`;
|
|
144
|
+
}
|
|
145
|
+
export function apply(ctx, config) {
|
|
146
|
+
// schemastery (the exported Config schema) has already filled the defaulted
|
|
147
|
+
// fields; the assertion records that resolution, not a hidden fallback.
|
|
148
|
+
const { toolName, maxResultChars } = config;
|
|
149
|
+
const recorder = createWorkflowRecorder(ctx);
|
|
150
|
+
// Usage policy ships with the tool (the master convention: tool guidance
|
|
151
|
+
// lives in tool plugins as prompt sections, not in the deployment persona).
|
|
152
|
+
ctx.systemPrompt.section({
|
|
153
|
+
name: `tool:${toolName}`,
|
|
154
|
+
order: 115,
|
|
155
|
+
text: `Use the ${toolName} tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.`,
|
|
156
|
+
});
|
|
157
|
+
ctx.tools.register(defineTool({
|
|
158
|
+
name: toolName,
|
|
159
|
+
description: DESCRIPTION,
|
|
160
|
+
parameters: {
|
|
161
|
+
script: {
|
|
162
|
+
type: 'string',
|
|
163
|
+
required: true,
|
|
164
|
+
description: 'The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`).',
|
|
165
|
+
},
|
|
166
|
+
meta: {
|
|
167
|
+
type: 'object',
|
|
168
|
+
additionalProperties: true,
|
|
169
|
+
required: true,
|
|
170
|
+
description: 'The workflow identity block (plain JSON — never code).',
|
|
171
|
+
properties: {
|
|
172
|
+
name: { type: 'string', required: true, description: 'Short kebab-case workflow name.' },
|
|
173
|
+
description: { type: 'string', required: true, description: 'One-line description of what the workflow does.' },
|
|
174
|
+
whenToUse: { type: 'string', description: 'Optional guidance on when this workflow applies.' },
|
|
175
|
+
phases: {
|
|
176
|
+
type: 'array',
|
|
177
|
+
description: 'Optional phase declarations matched by phase() calls.',
|
|
178
|
+
items: {
|
|
179
|
+
type: 'object',
|
|
180
|
+
additionalProperties: true,
|
|
181
|
+
properties: {
|
|
182
|
+
title: { type: 'string', required: true, description: 'The phase title phase() calls match by exact string.' },
|
|
183
|
+
detail: { type: 'string', description: 'Optional one-line description of the phase.' },
|
|
184
|
+
provider: { type: 'string', description: 'Optional provider override this phase is expected to use.' },
|
|
185
|
+
model: { type: 'string', description: 'Optional model override this phase is expected to use.' },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
args: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
additionalProperties: true,
|
|
194
|
+
description: 'Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}).',
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
output: {
|
|
198
|
+
schema: {
|
|
199
|
+
type: 'object',
|
|
200
|
+
additionalProperties: false,
|
|
201
|
+
properties: {
|
|
202
|
+
runId: { type: 'string', required: true },
|
|
203
|
+
agentsStarted: { type: 'integer', required: true },
|
|
204
|
+
result: { type: 'json', required: true },
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
render: (args, value) => [{
|
|
208
|
+
type: 'text',
|
|
209
|
+
text: renderResult(args.meta.name, value.agentsStarted, value.result, maxResultChars),
|
|
210
|
+
}],
|
|
211
|
+
},
|
|
212
|
+
async execute(args, exec) {
|
|
213
|
+
const parent = exec.agent;
|
|
214
|
+
if (!parent) {
|
|
215
|
+
// The loop sets `exec.agent` for every model-driven call; its absence
|
|
216
|
+
// means a non-agent caller invoked the tool directly, which has no
|
|
217
|
+
// parent to attribute the children to. Fail loud rather than guess.
|
|
218
|
+
throw new Error('workflow tool requires a calling agent (exec.agent was undefined)');
|
|
219
|
+
}
|
|
220
|
+
// Meta/body validation failures (META_INVALID/SCRIPT_PARSE) throw
|
|
221
|
+
// synchronously here and become isError results via the registry — the
|
|
222
|
+
// model sees the violation list and can correct the call.
|
|
223
|
+
const run = ctx.workflowEngine.start({
|
|
224
|
+
script: args.script,
|
|
225
|
+
meta: args.meta,
|
|
226
|
+
...args.args !== undefined ? { args: args.args } : {},
|
|
227
|
+
parent,
|
|
228
|
+
signal: exec.signal,
|
|
229
|
+
});
|
|
230
|
+
const recordsRun = exec.parent === undefined;
|
|
231
|
+
// The shipped worker-thread engine publishes member events from later
|
|
232
|
+
// worker messages, after start() returns and this run record is active.
|
|
233
|
+
if (recordsRun)
|
|
234
|
+
recorder.start(parent.session, run);
|
|
235
|
+
// Bridge the tool's abort signal to the run: if the parent step is aborted while the
|
|
236
|
+
// script is in flight, cancel the whole run. The signal also enters the engine directly, but
|
|
237
|
+
// this local bridge preserves the tool contract even if an implementation ignores it.
|
|
238
|
+
const onAbort = () => { run.cancel('parent step aborted'); };
|
|
239
|
+
exec.signal.addEventListener('abort', onAbort, { once: true });
|
|
240
|
+
let result;
|
|
241
|
+
try {
|
|
242
|
+
result = await run.result;
|
|
243
|
+
const error = stopReasonError(result);
|
|
244
|
+
if (error !== undefined) {
|
|
245
|
+
// Map a non-clean finish to an isError result (the registry turns a
|
|
246
|
+
// throw into an isError). Report the reason, not partial output.
|
|
247
|
+
throw new Error(error);
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
runId: run.id,
|
|
251
|
+
agentsStarted: result.agentsStarted,
|
|
252
|
+
result: result.value,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
exec.signal.removeEventListener('abort', onAbort);
|
|
257
|
+
try {
|
|
258
|
+
// Keep member listeners alive through disposal: an engine may
|
|
259
|
+
// synthesize cancelled member endings while reaching quiescence.
|
|
260
|
+
await run.dispose();
|
|
261
|
+
if (recordsRun) {
|
|
262
|
+
/* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */
|
|
263
|
+
if (result === undefined)
|
|
264
|
+
throw new Error('workflow run settled without a result');
|
|
265
|
+
recorder.finish(run.id, result.stopReason);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
if (recordsRun)
|
|
270
|
+
recorder.abandon(run.id);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
presentCall: args => presentWorkflowCall(args),
|
|
275
|
+
presentResult: (args, result) => presentWorkflowResult(args, result),
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Package-owned durable workflow-record invariants. @module @hydraharness/harness-tool-workflow/invariant */
|
|
2
|
+
import type { Context } from '@hydraharness/cordis';
|
|
3
|
+
/** Cordis companion plugin name. */
|
|
4
|
+
export declare const name = "tool-workflow-invariant";
|
|
5
|
+
/** Services required to validate existing and newly appended Session logs. */
|
|
6
|
+
export declare const inject: string[];
|
|
7
|
+
/** Register this package's invariant companion. */
|
|
8
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
9
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/** Package-owned durable workflow-record invariants. @module @hydraharness/harness-tool-workflow/invariant */
|
|
2
|
+
const PACKAGE_NAME = '@hydraharness/harness-tool-workflow';
|
|
3
|
+
/** Cordis companion plugin name. */
|
|
4
|
+
export const name = 'tool-workflow-invariant';
|
|
5
|
+
/** Services required to validate existing and newly appended Session logs. */
|
|
6
|
+
export const inject = ['invariants'];
|
|
7
|
+
/** Whether this package owns the candidate Session event. */
|
|
8
|
+
function isWorkflowRecordEvent(event) {
|
|
9
|
+
return event.type.startsWith('tool-workflow/');
|
|
10
|
+
}
|
|
11
|
+
/** Require a durable opaque identity to be a non-empty string. */
|
|
12
|
+
function stringId(value, label, fail) {
|
|
13
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
14
|
+
fail(`${label} must be a non-empty string`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
/** Require one workflow member's 1-based sequence identity. */
|
|
18
|
+
function memberSeq(value, fail) {
|
|
19
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
20
|
+
fail('tool-workflow member seq must be a positive safe integer');
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
/** Read one plain payload field without trusting restored plugin data. */
|
|
25
|
+
function recordOf(event, fail) {
|
|
26
|
+
const data = event.data;
|
|
27
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
28
|
+
fail(`${event.type} data must be a JSON object`);
|
|
29
|
+
}
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
/** Copy only the run one candidate can mutate; other committed states stay shared. */
|
|
33
|
+
function cloneTraceForEvent(source, event, fail) {
|
|
34
|
+
const trace = new Map(source);
|
|
35
|
+
if (event.type === 'tool-workflow/run-start')
|
|
36
|
+
return trace;
|
|
37
|
+
const data = recordOf(event, fail);
|
|
38
|
+
const runId = stringId(data.runId, `${event.type} runId`, fail);
|
|
39
|
+
const run = source.get(runId);
|
|
40
|
+
if (run !== undefined) {
|
|
41
|
+
trace.set(runId, { ended: run.ended, members: new Map(run.members) });
|
|
42
|
+
}
|
|
43
|
+
return trace;
|
|
44
|
+
}
|
|
45
|
+
/** Require the named run to exist and remain open. */
|
|
46
|
+
function openRun(trace, runId, eventType, fail) {
|
|
47
|
+
const run = trace.get(runId);
|
|
48
|
+
if (run === undefined)
|
|
49
|
+
fail(`${eventType} has no matching tool-workflow/run-start for run ${runId}`);
|
|
50
|
+
if (run.ended)
|
|
51
|
+
fail(`${eventType} appears after tool-workflow/run-end for run ${runId}`);
|
|
52
|
+
return run;
|
|
53
|
+
}
|
|
54
|
+
/** Advance the workflow-record fold with one relevant Session event. */
|
|
55
|
+
function applyEvent(trace, event, fail) {
|
|
56
|
+
const data = recordOf(event, fail);
|
|
57
|
+
const runId = stringId(data.runId, `${event.type} runId`, fail);
|
|
58
|
+
switch (event.type) {
|
|
59
|
+
case 'tool-workflow/run-start': {
|
|
60
|
+
if (typeof data.name !== 'string' || data.name.length === 0) {
|
|
61
|
+
fail('tool-workflow/run-start name must be a non-empty string');
|
|
62
|
+
}
|
|
63
|
+
if (trace.has(runId))
|
|
64
|
+
fail(`tool-workflow/run-start repeats run ${runId}`);
|
|
65
|
+
trace.set(runId, { ended: false, members: new Map() });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
case 'tool-workflow/agent-start': {
|
|
69
|
+
const run = openRun(trace, runId, event.type, fail);
|
|
70
|
+
const seq = memberSeq(data.seq, fail);
|
|
71
|
+
if (typeof data.label !== 'string')
|
|
72
|
+
fail('tool-workflow/agent-start label must be a string');
|
|
73
|
+
if (data.phase !== undefined && typeof data.phase !== 'string') {
|
|
74
|
+
fail('tool-workflow/agent-start phase must be a string when present');
|
|
75
|
+
}
|
|
76
|
+
stringId(data.childId, 'tool-workflow/agent-start childId', fail);
|
|
77
|
+
if (run.members.has(seq))
|
|
78
|
+
fail(`tool-workflow/agent-start repeats member seq ${seq} in run ${runId}`);
|
|
79
|
+
run.members.set(seq, false);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
case 'tool-workflow/agent-end': {
|
|
83
|
+
const run = openRun(trace, runId, event.type, fail);
|
|
84
|
+
const seq = memberSeq(data.seq, fail);
|
|
85
|
+
if (data.outcome !== 'completed' && data.outcome !== 'failed' && data.outcome !== 'cancelled') {
|
|
86
|
+
fail(`tool-workflow/agent-end outcome ${String(data.outcome)} is invalid`);
|
|
87
|
+
}
|
|
88
|
+
const ended = run.members.get(seq);
|
|
89
|
+
if (ended === undefined)
|
|
90
|
+
fail(`tool-workflow/agent-end has no matching member seq ${seq} in run ${runId}`);
|
|
91
|
+
if (ended)
|
|
92
|
+
fail(`tool-workflow/agent-end repeats member seq ${seq} in run ${runId}`);
|
|
93
|
+
run.members.set(seq, true);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
case 'tool-workflow/run-end': {
|
|
97
|
+
const run = openRun(trace, runId, event.type, fail);
|
|
98
|
+
if (data.stopReason !== 'completed' && data.stopReason !== 'cancelled' && data.stopReason !== 'error') {
|
|
99
|
+
fail(`tool-workflow/run-end stopReason ${String(data.stopReason)} is invalid`);
|
|
100
|
+
}
|
|
101
|
+
const openMembers = [...run.members].filter(([, ended]) => !ended).map(([seq]) => seq);
|
|
102
|
+
if (openMembers.length > 0) {
|
|
103
|
+
fail(`tool-workflow/run-end leaves member seq ${openMembers.join(', ')} open in run ${runId}`);
|
|
104
|
+
}
|
|
105
|
+
run.ended = true;
|
|
106
|
+
run.members.clear();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
default:
|
|
110
|
+
fail(`unknown tool-workflow event type ${event.type}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Install an independent incremental fold over every attached Session. */
|
|
114
|
+
const install = Object.assign((ctx, fail) => {
|
|
115
|
+
const traces = new WeakMap();
|
|
116
|
+
const staged = new WeakMap();
|
|
117
|
+
const seed = (session) => {
|
|
118
|
+
const trace = new Map();
|
|
119
|
+
for (const event of session.events.filter(isWorkflowRecordEvent))
|
|
120
|
+
applyEvent(trace, event, fail);
|
|
121
|
+
traces.set(session, trace);
|
|
122
|
+
return trace;
|
|
123
|
+
};
|
|
124
|
+
ctx.sessions.list().forEach(seed);
|
|
125
|
+
ctx.on('session/created', (session) => { seed(session); }, { global: true });
|
|
126
|
+
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
|
127
|
+
if (eventName !== 'session/event')
|
|
128
|
+
return;
|
|
129
|
+
const [session, event] = args;
|
|
130
|
+
if (!isWorkflowRecordEvent(event))
|
|
131
|
+
return;
|
|
132
|
+
// session/event dispatch follows list() or session/created seeding.
|
|
133
|
+
const trace = cloneTraceForEvent(traces.get(session), event, fail);
|
|
134
|
+
applyEvent(trace, event, fail);
|
|
135
|
+
staged.set(event, { session, trace });
|
|
136
|
+
}, { global: true });
|
|
137
|
+
ctx.on('session/event', (session, event) => {
|
|
138
|
+
if (!isWorkflowRecordEvent(event))
|
|
139
|
+
return;
|
|
140
|
+
const candidate = staged.get(event);
|
|
141
|
+
/* v8 ignore next 2 -- internal/dispatch stages the exact session/event callback arguments. */
|
|
142
|
+
if (candidate === undefined || candidate.session !== session) {
|
|
143
|
+
return fail('session/event reached publication without matching workflow-record validation');
|
|
144
|
+
}
|
|
145
|
+
staged.delete(event);
|
|
146
|
+
traces.set(session, candidate.trace);
|
|
147
|
+
}, { global: true });
|
|
148
|
+
}, { inject: ['sessions'] });
|
|
149
|
+
/** Register this package's invariant companion. */
|
|
150
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
151
|
+
//# sourceMappingURL=invariant.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe durable workflow-record events written by the model-facing
|
|
3
|
+
* workflow tool into its calling parent Session.
|
|
4
|
+
*
|
|
5
|
+
* @module @hydraharness/harness-tool-workflow/types
|
|
6
|
+
*/
|
|
7
|
+
import type { SessionId } from '@hydraharness/harness-session/types';
|
|
8
|
+
import type { WorkflowAgentOutcome, WorkflowRunId, WorkflowStopReason } from '@hydraharness/harness-workflow/types';
|
|
9
|
+
/** Opens one durable top-level workflow run record. */
|
|
10
|
+
export interface ToolWorkflowRunStartData {
|
|
11
|
+
readonly runId: WorkflowRunId;
|
|
12
|
+
readonly name: string;
|
|
13
|
+
}
|
|
14
|
+
/** Records one workflow member after its child Session is published. */
|
|
15
|
+
export interface ToolWorkflowAgentStartData {
|
|
16
|
+
readonly runId: WorkflowRunId;
|
|
17
|
+
readonly seq: number;
|
|
18
|
+
readonly label: string;
|
|
19
|
+
readonly phase?: string;
|
|
20
|
+
readonly childId: SessionId;
|
|
21
|
+
}
|
|
22
|
+
/** Settles one previously started workflow member. */
|
|
23
|
+
export interface ToolWorkflowAgentEndData {
|
|
24
|
+
readonly runId: WorkflowRunId;
|
|
25
|
+
readonly seq: number;
|
|
26
|
+
readonly outcome: WorkflowAgentOutcome;
|
|
27
|
+
}
|
|
28
|
+
/** Settles one workflow run after its live resources reach quiescence. */
|
|
29
|
+
export interface ToolWorkflowRunEndData {
|
|
30
|
+
readonly runId: WorkflowRunId;
|
|
31
|
+
readonly stopReason: WorkflowStopReason;
|
|
32
|
+
}
|
|
33
|
+
declare module '@hydraharness/harness-session/types' {
|
|
34
|
+
interface SessionEventMap {
|
|
35
|
+
/**
|
|
36
|
+
* Opens one top-level workflow record.
|
|
37
|
+
* @param data - stable run identity and display name.
|
|
38
|
+
*/
|
|
39
|
+
'tool-workflow/run-start': ToolWorkflowRunStartData;
|
|
40
|
+
/**
|
|
41
|
+
* Records one published workflow member.
|
|
42
|
+
* @param data - run identity, member sequence, display identity, and child Session.
|
|
43
|
+
*/
|
|
44
|
+
'tool-workflow/agent-start': ToolWorkflowAgentStartData;
|
|
45
|
+
/**
|
|
46
|
+
* Records one member settlement.
|
|
47
|
+
* @param data - run identity, paired member sequence, and outcome.
|
|
48
|
+
*/
|
|
49
|
+
'tool-workflow/agent-end': ToolWorkflowAgentEndData;
|
|
50
|
+
/**
|
|
51
|
+
* Closes one workflow record after cleanup.
|
|
52
|
+
* @param data - stable run identity and terminal reason.
|
|
53
|
+
*/
|
|
54
|
+
'tool-workflow/run-end': ToolWorkflowRunEndData;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=types.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hydraharness/harness-tool-workflow",
|
|
3
|
+
"description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine",
|
|
4
|
+
"hydra": {
|
|
5
|
+
"plugin": {
|
|
6
|
+
"application": "Let the agent orchestrate multiple child tasks with a JavaScript workflow."
|
|
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-workflow"
|
|
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
|
+
"./types": {
|
|
31
|
+
"types": "./lib/types/types.d.ts",
|
|
32
|
+
"default": "./lib/types/types.js"
|
|
33
|
+
},
|
|
34
|
+
"./src/*": "./src/*",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"lib/index.js",
|
|
39
|
+
"lib/invariant.js",
|
|
40
|
+
"lib/types/**/*.js",
|
|
41
|
+
"lib/types/**/*.d.ts"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@hydraharness/harness-agent": "^0.1.1-rc.6",
|
|
46
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.6",
|
|
47
|
+
"@hydraharness/harness-llm": "^0.1.1-rc.6",
|
|
48
|
+
"@hydraharness/harness-system-prompt": "^0.1.1-rc.6",
|
|
49
|
+
"@hydraharness/harness-tools": "^0.1.1-rc.6",
|
|
50
|
+
"@hydraharness/harness-session": "^0.1.1-rc.6",
|
|
51
|
+
"@hydraharness/harness-workflow": "^0.1.1-rc.6",
|
|
52
|
+
"@hydraharness/cordis": "^4.0.2"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@hydraharness/schemastery": "^3.18.2"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.6",
|
|
59
|
+
"@hydraharness/harness-agent": "^0.1.1-rc.6",
|
|
60
|
+
"@hydraharness/harness-llm": "^0.1.1-rc.6",
|
|
61
|
+
"@hydraharness/harness-session": "^0.1.1-rc.6",
|
|
62
|
+
"@hydraharness/harness-subagent": "^0.1.1-rc.6",
|
|
63
|
+
"@hydraharness/harness-workflow": "^0.1.1-rc.6",
|
|
64
|
+
"@hydraharness/harness-tools": "^0.1.1-rc.6",
|
|
65
|
+
"@hydraharness/harness-system-prompt": "^0.1.1-rc.6",
|
|
66
|
+
"@hydraharness/cordis": "^4.0.2",
|
|
67
|
+
"@hydraharness/harness-workflow-worker-thread": "^0.1.1-rc.6"
|
|
68
|
+
}
|
|
69
|
+
}
|