@osolmaz/pi-workflows 0.9.1 → 0.11.0
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/README.md +64 -19
- package/dist/builtins/autodoc.workflow.d.ts +58 -0
- package/dist/builtins/autodoc.workflow.js +266 -0
- package/dist/builtins/autodoc.workflow.js.map +1 -0
- package/dist/builtins/autoimplement.workflow.d.ts +353 -0
- package/dist/builtins/autoimplement.workflow.js +961 -0
- package/dist/builtins/autoimplement.workflow.js.map +1 -0
- package/dist/builtins/autoplan.workflow.d.ts +58 -0
- package/dist/builtins/autoplan.workflow.js +190 -0
- package/dist/builtins/autoplan.workflow.js.map +1 -0
- package/dist/builtins/catalog.js +9 -1
- package/dist/builtins/catalog.js.map +1 -1
- package/dist/builtins/index.d.ts +6 -0
- package/dist/builtins/index.js +7 -0
- package/dist/builtins/index.js.map +1 -0
- package/dist/builtins/monitor.workflow.d.ts +29 -3
- package/dist/builtins/monitor.workflow.js +325 -13
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/builtins/plan-approval.workflow.d.ts +83 -0
- package/dist/builtins/plan-approval.workflow.js +148 -0
- package/dist/builtins/plan-approval.workflow.js.map +1 -0
- package/dist/builtins/plan-presentation.d.ts +7 -0
- package/dist/builtins/plan-presentation.js +44 -0
- package/dist/builtins/plan-presentation.js.map +1 -0
- package/dist/extension/decision-channels.d.ts +132 -0
- package/dist/extension/decision-channels.js +1082 -0
- package/dist/extension/decision-channels.js.map +1 -0
- package/dist/extension/index.d.ts +1 -0
- package/dist/extension/index.js +426 -11
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/widget.js +26 -2
- package/dist/extension/widget.js.map +1 -1
- package/dist/extension/workflow-tool.d.ts +1 -37
- package/dist/extension/workflow-tool.js +1 -42
- package/dist/extension/workflow-tool.js.map +1 -1
- package/dist/host/rpc-bridge.js +4 -21
- package/dist/host/rpc-bridge.js.map +1 -1
- package/dist/render/graph-render.js +46 -10
- package/dist/render/graph-render.js.map +1 -1
- package/dist/viewer/render.js +36 -2
- package/dist/viewer/render.js.map +1 -1
- package/dist/workflows/catalog.d.ts +1 -0
- package/dist/workflows/catalog.js +6 -0
- package/dist/workflows/catalog.js.map +1 -1
- package/dist/workflows/composition.d.ts +45 -0
- package/dist/workflows/composition.js +485 -0
- package/dist/workflows/composition.js.map +1 -0
- package/dist/workflows/decision-presentation.d.ts +18 -0
- package/dist/workflows/decision-presentation.js +417 -0
- package/dist/workflows/decision-presentation.js.map +1 -0
- package/dist/workflows/decision.d.ts +11 -5
- package/dist/workflows/decision.js.map +1 -1
- package/dist/workflows/definition.d.ts +22 -3
- package/dist/workflows/definition.js +46 -3
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +2 -1
- package/dist/workflows/engine.js +199 -18
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/graph.js +8 -6
- package/dist/workflows/graph.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +87 -0
- package/dist/workflows/human-decision.js +583 -0
- package/dist/workflows/human-decision.js.map +1 -0
- package/dist/workflows/index.d.ts +5 -2
- package/dist/workflows/index.js +4 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/loader.d.ts +5 -4
- package/dist/workflows/loader.js +118 -18
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/schema.d.ts +3 -1
- package/dist/workflows/schema.js +64 -2
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.js +39 -2
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +72 -0
- package/dist/workflows/tool-input.js +141 -0
- package/dist/workflows/tool-input.js.map +1 -0
- package/dist/workflows/types.d.ts +300 -2
- package/docs/CONTROLLERS.md +1 -1
- package/docs/DESIGN_PHILOSOPHY.md +1 -1
- package/docs/HUMAN_DECISIONS.md +371 -0
- package/docs/HUMAN_DECISION_PRESENTATIONS.md +323 -0
- package/docs/MONITOR.md +46 -18
- package/docs/WORKFLOW_COMPOSITION.md +333 -0
- package/docs/plans/2026-08-19-human-decision-gates-plan.md +296 -0
- package/docs/plans/2026-08-19-human-decision-presentations-plan.md +172 -0
- package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +45 -0
- package/docs/plans/2026-08-19-workflow-composition-plan.md +308 -0
- package/docs/run-bundles.md +45 -10
- package/docs/workflows.md +106 -15
- package/examples/workflows/approved-plan.workflow.ts +58 -0
- package/examples/workflows/autoimplement.workflow.ts +1 -92
- package/examples/workflows/autoplan.workflow.ts +1 -0
- package/examples/workflows/human-decision.workflow.ts +62 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +6 -1
- package/schemas/decision-presentation-v1.schema.json +83 -0
- package/schemas/human-decision-accepted-v1.schema.json +44 -0
- package/schemas/human-decision-accepted-v2.schema.json +50 -0
- package/schemas/human-decision-answer-attempt-v1.schema.json +37 -0
- package/schemas/human-decision-cancellation-v1.schema.json +15 -0
- package/schemas/human-decision-continuation-v1.schema.json +16 -0
- package/schemas/human-decision-delivery-v1.schema.json +28 -0
- package/schemas/human-decision-delivery-v2.schema.json +36 -0
- package/schemas/human-decision-receipt-v1.schema.json +33 -0
- package/schemas/human-decision-receipt-v2.schema.json +39 -0
- package/schemas/human-decision-request-v1.schema.json +59 -0
- package/schemas/human-decision-request-v2.schema.json +69 -0
- package/schemas/human-decision-resolution-v1.schema.json +27 -0
- package/schemas/human-decision-resolution-v2.schema.json +27 -0
- package/schemas/human-decision-settlement-v1.schema.json +28 -0
- package/skills/autodoc/SKILL.md +43 -0
- package/skills/autoimplement/SKILL.md +58 -0
- package/skills/autoimplement/agents/openai.yaml +4 -0
- package/skills/autoplan/SKILL.md +25 -0
- package/skills/monitor/SKILL.md +8 -1
- package/skills/pi-workflows/SKILL.md +5 -3
- package/src/builtins/autodoc.workflow.ts +325 -0
- package/src/builtins/autoimplement.workflow.ts +1123 -0
- package/src/builtins/autoplan.workflow.ts +231 -0
- package/src/builtins/catalog.ts +9 -1
- package/src/builtins/index.ts +32 -0
- package/src/builtins/monitor.workflow.ts +396 -15
- package/src/builtins/plan-approval.workflow.ts +190 -0
- package/src/builtins/plan-presentation.ts +57 -0
- package/src/extension/decision-channels.ts +1533 -0
- package/src/extension/index.ts +499 -9
- package/src/extension/widget.ts +39 -2
- package/src/extension/workflow-tool.ts +5 -81
- package/src/host/rpc-bridge.ts +7 -30
- package/src/render/graph-render.ts +54 -5
- package/src/viewer/render.ts +56 -3
- package/src/workflows/catalog.ts +7 -0
- package/src/workflows/composition.ts +642 -0
- package/src/workflows/decision-presentation.ts +501 -0
- package/src/workflows/decision.ts +12 -5
- package/src/workflows/definition.ts +118 -8
- package/src/workflows/engine.ts +257 -20
- package/src/workflows/graph.ts +8 -6
- package/src/workflows/human-decision.ts +819 -0
- package/src/workflows/index.ts +89 -0
- package/src/workflows/loader.ts +186 -18
- package/src/workflows/schema.ts +79 -2
- package/src/workflows/store.ts +45 -2
- package/src/workflows/tool-input.ts +202 -0
- package/src/workflows/types.ts +374 -2
- package/examples/workflows/elegant-solution.workflow.ts +0 -95
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
DecisionPresentation,
|
|
4
|
+
DecisionPresentationBlock,
|
|
5
|
+
HumanDecisionChannelRequest,
|
|
6
|
+
HumanDecisionRequest,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
|
|
9
|
+
export const MAX_PRESENTATION_CODE_UNITS = 64_000;
|
|
10
|
+
export const MAX_PRESENTATION_BLOCKS = 256;
|
|
11
|
+
export const MAX_PRESENTATION_ITEMS = 256;
|
|
12
|
+
export const MAX_PRESENTATION_STRING_CODE_UNITS = 16_000;
|
|
13
|
+
export const MAX_PRESENTATION_TRANSPORT_PARTS = 20;
|
|
14
|
+
|
|
15
|
+
export type DecisionDocumentSegment = {
|
|
16
|
+
kind: "title" | "summary" | DecisionPresentationBlock["kind"] | "choices";
|
|
17
|
+
text: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function normalizeDecisionPresentation(value: unknown): DecisionPresentation {
|
|
21
|
+
const presentation = requireRecord(value, "Decision presentation");
|
|
22
|
+
requireExactKeys(presentation, ["schema", "summary", "blocks"], "Decision presentation");
|
|
23
|
+
if (presentation.schema !== "pi-workflows.decision-presentation.v1") {
|
|
24
|
+
throw new Error("Decision presentation schema must be pi-workflows.decision-presentation.v1");
|
|
25
|
+
}
|
|
26
|
+
const summary = normalizeDisplayString(presentation.summary, "Decision presentation summary");
|
|
27
|
+
if (!Array.isArray(presentation.blocks)) {
|
|
28
|
+
throw new Error("Decision presentation blocks must be an array");
|
|
29
|
+
}
|
|
30
|
+
if (presentation.blocks.length > MAX_PRESENTATION_BLOCKS) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Decision presentation has ${presentation.blocks.length} blocks; limit is ${MAX_PRESENTATION_BLOCKS}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const blocks = presentation.blocks.map((block, index) =>
|
|
36
|
+
normalizeBlock(block, `Decision presentation block ${index + 1}`),
|
|
37
|
+
);
|
|
38
|
+
const normalized: DecisionPresentation = {
|
|
39
|
+
schema: "pi-workflows.decision-presentation.v1",
|
|
40
|
+
summary,
|
|
41
|
+
blocks,
|
|
42
|
+
};
|
|
43
|
+
const codeUnits = presentationCodeUnits(normalized);
|
|
44
|
+
if (codeUnits > MAX_PRESENTATION_CODE_UNITS) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Decision presentation has ${codeUnits} UTF-16 code units; limit is ${MAX_PRESENTATION_CODE_UNITS}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return normalized;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function decisionPresentationDigest(presentation: DecisionPresentation): string {
|
|
53
|
+
return digestCanonical(normalizeDecisionPresentation(presentation));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validateHumanDecisionRequestIntegrity(
|
|
57
|
+
request: HumanDecisionRequest,
|
|
58
|
+
): HumanDecisionRequest {
|
|
59
|
+
if (request.schema === "pi-workflows.human-decision-request.v1") return request;
|
|
60
|
+
const presentation = normalizeDecisionPresentation(request.presentation);
|
|
61
|
+
const subjectDigest = digestCanonical(request.subject);
|
|
62
|
+
const presentationDigest = digestCanonical(presentation);
|
|
63
|
+
const requestDigest = digestCanonical({
|
|
64
|
+
schema: request.schema,
|
|
65
|
+
runId: request.runId,
|
|
66
|
+
workflowName: request.workflowName,
|
|
67
|
+
nodeId: request.nodeId,
|
|
68
|
+
attemptId: request.attemptId,
|
|
69
|
+
audience: request.audience,
|
|
70
|
+
title: request.title,
|
|
71
|
+
choices: request.choices,
|
|
72
|
+
...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}),
|
|
73
|
+
subject: request.subject,
|
|
74
|
+
presentation,
|
|
75
|
+
revision: request.revision,
|
|
76
|
+
});
|
|
77
|
+
if (subjectDigest !== request.subjectDigest) {
|
|
78
|
+
throw new Error("Human decision subject digest does not match its durable request");
|
|
79
|
+
}
|
|
80
|
+
if (presentationDigest !== request.presentationDigest) {
|
|
81
|
+
throw new Error("Human decision presentation digest does not match its durable request");
|
|
82
|
+
}
|
|
83
|
+
if (requestDigest !== request.requestDigest) {
|
|
84
|
+
throw new Error("Human decision request digest does not match its durable request");
|
|
85
|
+
}
|
|
86
|
+
return request;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function humanDecisionChannelRequest(
|
|
90
|
+
request: HumanDecisionRequest,
|
|
91
|
+
): HumanDecisionChannelRequest {
|
|
92
|
+
validateHumanDecisionRequestIntegrity(request);
|
|
93
|
+
const presentation =
|
|
94
|
+
request.schema === "pi-workflows.human-decision-request.v2"
|
|
95
|
+
? normalizeDecisionPresentation(request.presentation)
|
|
96
|
+
: legacyDecisionPresentation(request.body);
|
|
97
|
+
const presentationDigest = decisionPresentationDigest(presentation);
|
|
98
|
+
return {
|
|
99
|
+
schema: "pi-workflows.human-decision-channel-request.v1",
|
|
100
|
+
sourceSchema: request.schema,
|
|
101
|
+
decisionId: request.decisionId,
|
|
102
|
+
requestDigest: request.requestDigest,
|
|
103
|
+
runId: request.runId,
|
|
104
|
+
workflowName: request.workflowName,
|
|
105
|
+
nodeId: request.nodeId,
|
|
106
|
+
attemptId: request.attemptId,
|
|
107
|
+
audience: request.audience,
|
|
108
|
+
title: request.title,
|
|
109
|
+
presentation,
|
|
110
|
+
presentationDigest,
|
|
111
|
+
revision: request.schema === "pi-workflows.human-decision-request.v2" ? request.revision : 1,
|
|
112
|
+
choices: request.choices,
|
|
113
|
+
createdAt: request.createdAt,
|
|
114
|
+
...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function legacyDecisionPresentation(body: unknown): DecisionPresentation {
|
|
119
|
+
const blocks: DecisionPresentationBlock[] = [];
|
|
120
|
+
if (typeof body === "string") {
|
|
121
|
+
appendLegacyText(blocks, body, "Decision details");
|
|
122
|
+
} else {
|
|
123
|
+
appendLegacyValue(blocks, body, undefined);
|
|
124
|
+
}
|
|
125
|
+
const summary =
|
|
126
|
+
typeof body === "string"
|
|
127
|
+
? "Review the decision message below."
|
|
128
|
+
: "Review the decision details below.";
|
|
129
|
+
return normalizeDecisionPresentation(boundLegacyPresentation(summary, blocks, body));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function decisionDocumentSegments(
|
|
133
|
+
request: HumanDecisionChannelRequest,
|
|
134
|
+
): DecisionDocumentSegment[] {
|
|
135
|
+
const segments: DecisionDocumentSegment[] = [
|
|
136
|
+
{ kind: "title", text: request.title },
|
|
137
|
+
{ kind: "summary", text: request.presentation.summary },
|
|
138
|
+
];
|
|
139
|
+
for (const block of request.presentation.blocks) {
|
|
140
|
+
if (block.kind === "paragraph") segments.push({ kind: block.kind, text: block.text });
|
|
141
|
+
else if (block.kind === "section") segments.push({ kind: block.kind, text: block.title });
|
|
142
|
+
else if (block.kind === "bullets") {
|
|
143
|
+
segments.push({ kind: block.kind, text: block.items.map((item) => `• ${item}`).join("\n") });
|
|
144
|
+
} else if (block.kind === "fields") {
|
|
145
|
+
segments.push({
|
|
146
|
+
kind: block.kind,
|
|
147
|
+
text: block.items.map(({ label, value }) => `${label}: ${value}`).join("\n"),
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
segments.push({ kind: block.kind, text: block.text });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const choices = Object.values(request.choices).map((choice) =>
|
|
154
|
+
choice.input === undefined
|
|
155
|
+
? `• ${choice.label}`
|
|
156
|
+
: `• ${choice.label}\n ${choice.input.prompt}`,
|
|
157
|
+
);
|
|
158
|
+
segments.push({ kind: "choices", text: `Choices\n${choices.join("\n")}` });
|
|
159
|
+
return segments;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function decisionPresentationFingerprint(request: HumanDecisionChannelRequest): string {
|
|
163
|
+
return request.presentationDigest.slice("sha256:".length, "sha256:".length + 12);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function digestCanonical(value: unknown): string {
|
|
167
|
+
return `sha256:${createHash("sha256")
|
|
168
|
+
.update(`${canonicalJson(value)}\n`)
|
|
169
|
+
.digest("hex")}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeBlock(value: unknown, label: string): DecisionPresentationBlock {
|
|
173
|
+
const block = requireRecord(value, label);
|
|
174
|
+
if (block.kind === "paragraph") {
|
|
175
|
+
requireExactKeys(block, ["kind", "text"], label);
|
|
176
|
+
return { kind: "paragraph", text: normalizeMultilineString(block.text, `${label} text`) };
|
|
177
|
+
}
|
|
178
|
+
if (block.kind === "section") {
|
|
179
|
+
requireExactKeys(block, ["kind", "title"], label);
|
|
180
|
+
return { kind: "section", title: normalizeDisplayString(block.title, `${label} title`) };
|
|
181
|
+
}
|
|
182
|
+
if (block.kind === "bullets") {
|
|
183
|
+
requireExactKeys(block, ["kind", "items"], label);
|
|
184
|
+
return {
|
|
185
|
+
kind: "bullets",
|
|
186
|
+
items: normalizeItems(block.items, `${label} items`, (item, itemLabel) =>
|
|
187
|
+
normalizeDisplayString(item, itemLabel),
|
|
188
|
+
),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (block.kind === "fields") {
|
|
192
|
+
requireExactKeys(block, ["kind", "items"], label);
|
|
193
|
+
return {
|
|
194
|
+
kind: "fields",
|
|
195
|
+
items: normalizeItems(block.items, `${label} items`, (item, itemLabel) => {
|
|
196
|
+
const field = requireRecord(item, itemLabel);
|
|
197
|
+
requireExactKeys(field, ["label", "value"], itemLabel);
|
|
198
|
+
return {
|
|
199
|
+
label: normalizeDisplayString(field.label, `${itemLabel} label`),
|
|
200
|
+
value: normalizeDisplayString(field.value, `${itemLabel} value`),
|
|
201
|
+
};
|
|
202
|
+
}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (block.kind === "preformatted") {
|
|
206
|
+
requireExactKeys(block, ["kind", "text"], label);
|
|
207
|
+
return { kind: "preformatted", text: normalizeMultilineString(block.text, `${label} text`) };
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`${label} kind is not supported`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function normalizeItems<T>(
|
|
213
|
+
value: unknown,
|
|
214
|
+
label: string,
|
|
215
|
+
normalize: (item: unknown, label: string) => T,
|
|
216
|
+
): T[] {
|
|
217
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
218
|
+
throw new Error(`${label} must be a non-empty array`);
|
|
219
|
+
}
|
|
220
|
+
if (value.length > MAX_PRESENTATION_ITEMS) {
|
|
221
|
+
throw new Error(`${label} has ${value.length} items; limit is ${MAX_PRESENTATION_ITEMS}`);
|
|
222
|
+
}
|
|
223
|
+
return value.map((item, index) => normalize(item, `${label} item ${index + 1}`));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function normalizeDisplayString(value: unknown, label: string): string {
|
|
227
|
+
const text = normalizeString(value, label);
|
|
228
|
+
if (hasControlCharacter(text, false)) {
|
|
229
|
+
throw new Error(`${label} contains a control character`);
|
|
230
|
+
}
|
|
231
|
+
return text;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeMultilineString(value: unknown, label: string): string {
|
|
235
|
+
const text = normalizeString(value, label);
|
|
236
|
+
if (hasControlCharacter(text, true)) {
|
|
237
|
+
throw new Error(`${label} contains a terminal control character`);
|
|
238
|
+
}
|
|
239
|
+
return text;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizeString(value: unknown, label: string): string {
|
|
243
|
+
if (typeof value !== "string") throw new Error(`${label} must be a string`);
|
|
244
|
+
const text = value.replaceAll("\r\n", "\n").replaceAll("\r", "\n").normalize("NFC");
|
|
245
|
+
if (text.trim().length === 0) throw new Error(`${label} must not be empty`);
|
|
246
|
+
if (text.length > MAX_PRESENTATION_STRING_CODE_UNITS) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`${label} has ${text.length} UTF-16 code units; limit is ${MAX_PRESENTATION_STRING_CODE_UNITS}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return text;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function boundLegacyPresentation(
|
|
255
|
+
summary: string,
|
|
256
|
+
blocks: DecisionPresentationBlock[],
|
|
257
|
+
body: unknown,
|
|
258
|
+
): DecisionPresentation {
|
|
259
|
+
const candidate: DecisionPresentation = {
|
|
260
|
+
schema: "pi-workflows.decision-presentation.v1",
|
|
261
|
+
summary,
|
|
262
|
+
blocks,
|
|
263
|
+
};
|
|
264
|
+
if (
|
|
265
|
+
blocks.length <= MAX_PRESENTATION_BLOCKS &&
|
|
266
|
+
blocks.every(legacyBlockFitsSchema) &&
|
|
267
|
+
presentationCodeUnits(candidate) <= MAX_PRESENTATION_CODE_UNITS
|
|
268
|
+
) {
|
|
269
|
+
return candidate;
|
|
270
|
+
}
|
|
271
|
+
const originalCodeUnits = canonicalJson(body).length;
|
|
272
|
+
const omission = `Some legacy decision content is not shown because it exceeds the readable presentation limit. Inspect the durable v1 request before answering. Body digest: ${digestCanonical(body)}. Original body: ${originalCodeUnits} UTF-16 code units. Generated blocks: ${blocks.length}.`;
|
|
273
|
+
const budget = MAX_PRESENTATION_CODE_UNITS - summary.length - omission.length;
|
|
274
|
+
const kept: DecisionPresentationBlock[] = [];
|
|
275
|
+
let used = 0;
|
|
276
|
+
let omitted = false;
|
|
277
|
+
for (const block of blocks) {
|
|
278
|
+
const units = presentationBlockCodeUnits(block);
|
|
279
|
+
if (
|
|
280
|
+
kept.length >= MAX_PRESENTATION_BLOCKS ||
|
|
281
|
+
!legacyBlockFitsSchema(block) ||
|
|
282
|
+
used + units > budget
|
|
283
|
+
) {
|
|
284
|
+
omitted = true;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
kept.push(block);
|
|
288
|
+
used += units;
|
|
289
|
+
}
|
|
290
|
+
if (omitted) {
|
|
291
|
+
if (kept.length >= MAX_PRESENTATION_BLOCKS) kept.pop();
|
|
292
|
+
kept.push({ kind: "paragraph", text: omission });
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
schema: "pi-workflows.decision-presentation.v1",
|
|
296
|
+
summary,
|
|
297
|
+
blocks: kept,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function legacyBlockFitsSchema(block: DecisionPresentationBlock): boolean {
|
|
302
|
+
if (block.kind === "paragraph" || block.kind === "preformatted") {
|
|
303
|
+
return block.text.length <= MAX_PRESENTATION_STRING_CODE_UNITS;
|
|
304
|
+
}
|
|
305
|
+
if (block.kind === "section") {
|
|
306
|
+
return block.title.length <= MAX_PRESENTATION_STRING_CODE_UNITS;
|
|
307
|
+
}
|
|
308
|
+
if (block.kind === "bullets") {
|
|
309
|
+
return block.items.every((item) => item.length <= MAX_PRESENTATION_STRING_CODE_UNITS);
|
|
310
|
+
}
|
|
311
|
+
return block.items.every(
|
|
312
|
+
(item) =>
|
|
313
|
+
item.label.length <= MAX_PRESENTATION_STRING_CODE_UNITS &&
|
|
314
|
+
item.value.length <= MAX_PRESENTATION_STRING_CODE_UNITS,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function presentationBlockCodeUnits(block: DecisionPresentationBlock): number {
|
|
319
|
+
if (block.kind === "paragraph" || block.kind === "preformatted") return block.text.length;
|
|
320
|
+
if (block.kind === "section") return block.title.length;
|
|
321
|
+
if (block.kind === "bullets") {
|
|
322
|
+
return block.items.reduce((sum, item) => sum + item.length, 0);
|
|
323
|
+
}
|
|
324
|
+
return block.items.reduce((sum, item) => sum + item.label.length + item.value.length, 0);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function presentationCodeUnits(presentation: DecisionPresentation): number {
|
|
328
|
+
let total = presentation.summary.length;
|
|
329
|
+
for (const block of presentation.blocks) {
|
|
330
|
+
total += presentationBlockCodeUnits(block);
|
|
331
|
+
}
|
|
332
|
+
return total;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function appendLegacyValue(
|
|
336
|
+
blocks: DecisionPresentationBlock[],
|
|
337
|
+
value: unknown,
|
|
338
|
+
label: string | undefined,
|
|
339
|
+
): void {
|
|
340
|
+
if (isScalar(value)) {
|
|
341
|
+
const scalar = legacyScalar(value);
|
|
342
|
+
if (label === undefined || scalar.length > MAX_PRESENTATION_STRING_CODE_UNITS) {
|
|
343
|
+
appendLegacyText(
|
|
344
|
+
blocks,
|
|
345
|
+
scalar,
|
|
346
|
+
label === undefined ? "Decision details" : legacyLabel(label),
|
|
347
|
+
);
|
|
348
|
+
} else {
|
|
349
|
+
blocks.push({ kind: "fields", items: [{ label: legacyLabel(label), value: scalar }] });
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (Array.isArray(value)) {
|
|
354
|
+
if (label !== undefined) blocks.push({ kind: "section", title: legacyLabel(label) });
|
|
355
|
+
if (value.length === 0) {
|
|
356
|
+
blocks.push({ kind: "paragraph", text: "None" });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (
|
|
360
|
+
value.every(isScalar) &&
|
|
361
|
+
value.every((item) => legacyScalar(item).length <= MAX_PRESENTATION_STRING_CODE_UNITS)
|
|
362
|
+
) {
|
|
363
|
+
const items = value.map(legacyScalar);
|
|
364
|
+
for (let index = 0; index < items.length; index += MAX_PRESENTATION_ITEMS) {
|
|
365
|
+
blocks.push({ kind: "bullets", items: items.slice(index, index + MAX_PRESENTATION_ITEMS) });
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
value.forEach((item, index) => appendLegacyValue(blocks, item, `Item ${index + 1}`));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([left], [right]) =>
|
|
373
|
+
compareKeys(left, right),
|
|
374
|
+
);
|
|
375
|
+
if (label !== undefined) blocks.push({ kind: "section", title: legacyLabel(label) });
|
|
376
|
+
if (entries.length === 0) {
|
|
377
|
+
blocks.push({ kind: "paragraph", text: "None" });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const fields = entries
|
|
381
|
+
.filter(
|
|
382
|
+
([, child]) =>
|
|
383
|
+
isScalar(child) && legacyScalar(child).length <= MAX_PRESENTATION_STRING_CODE_UNITS,
|
|
384
|
+
)
|
|
385
|
+
.map(([key, child]) => ({ label: legacyLabel(key), value: legacyScalar(child) }));
|
|
386
|
+
for (let index = 0; index < fields.length; index += MAX_PRESENTATION_ITEMS) {
|
|
387
|
+
blocks.push({ kind: "fields", items: fields.slice(index, index + MAX_PRESENTATION_ITEMS) });
|
|
388
|
+
}
|
|
389
|
+
for (const [key, child] of entries.filter(
|
|
390
|
+
([, child]) =>
|
|
391
|
+
!isScalar(child) || legacyScalar(child).length > MAX_PRESENTATION_STRING_CODE_UNITS,
|
|
392
|
+
)) {
|
|
393
|
+
appendLegacyValue(blocks, child, key);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function appendLegacyText(
|
|
398
|
+
blocks: DecisionPresentationBlock[],
|
|
399
|
+
value: string,
|
|
400
|
+
section: string,
|
|
401
|
+
): void {
|
|
402
|
+
const text = sanitizeLegacyText(value);
|
|
403
|
+
blocks.push({ kind: "section", title: section });
|
|
404
|
+
for (const part of splitByCodeUnits(text, MAX_PRESENTATION_STRING_CODE_UNITS)) {
|
|
405
|
+
blocks.push({ kind: "paragraph", text: part.trim().length === 0 ? "None" : part });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function legacyLabel(value: string): string {
|
|
410
|
+
const sanitized = sanitizeLegacyText(value)
|
|
411
|
+
.replaceAll(/[_-]+/gu, " ")
|
|
412
|
+
.replaceAll(/([a-z0-9])([A-Z])/gu, "$1 $2")
|
|
413
|
+
.replaceAll(/\s+/gu, " ")
|
|
414
|
+
.trim();
|
|
415
|
+
if (sanitized.length === 0) return "Value";
|
|
416
|
+
return `${sanitized[0]!.toUpperCase()}${sanitized.slice(1)}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function legacyScalar(value: unknown): string {
|
|
420
|
+
if (value === null) return "None";
|
|
421
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
422
|
+
return sanitizeLegacyText(String(value));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function sanitizeLegacyText(value: string): string {
|
|
426
|
+
return value
|
|
427
|
+
.replaceAll("\r\n", "\n")
|
|
428
|
+
.replaceAll("\r", "\n")
|
|
429
|
+
.normalize("NFC")
|
|
430
|
+
.split("")
|
|
431
|
+
.map((character) => (isControlCode(character.charCodeAt(0), true) ? "�" : character))
|
|
432
|
+
.join("");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function hasControlCharacter(value: string, allowLineAndTab: boolean): boolean {
|
|
436
|
+
return [...value].some((character) =>
|
|
437
|
+
isControlCode(character.codePointAt(0) ?? 0, allowLineAndTab),
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function isControlCode(code: number, allowLineAndTab: boolean): boolean {
|
|
442
|
+
if (allowLineAndTab && (code === 9 || code === 10)) return false;
|
|
443
|
+
return code <= 31 || code === 127;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function splitByCodeUnits(value: string, limit: number): string[] {
|
|
447
|
+
if (value.length <= limit) return [value];
|
|
448
|
+
const parts: string[] = [];
|
|
449
|
+
let remaining = value;
|
|
450
|
+
while (remaining.length > 0) {
|
|
451
|
+
let end = Math.min(limit, remaining.length);
|
|
452
|
+
const last = remaining.charCodeAt(end - 1);
|
|
453
|
+
if (last >= 0xd800 && last <= 0xdbff && end < remaining.length) end -= 1;
|
|
454
|
+
parts.push(remaining.slice(0, end));
|
|
455
|
+
remaining = remaining.slice(end);
|
|
456
|
+
}
|
|
457
|
+
return parts;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function isScalar(value: unknown): value is null | string | number | boolean {
|
|
461
|
+
return value === null || ["string", "number", "boolean"].includes(typeof value);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
465
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
466
|
+
throw new Error(`${label} must be an object`);
|
|
467
|
+
}
|
|
468
|
+
return value as Record<string, unknown>;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function requireExactKeys(
|
|
472
|
+
value: Record<string, unknown>,
|
|
473
|
+
allowed: readonly string[],
|
|
474
|
+
label: string,
|
|
475
|
+
): void {
|
|
476
|
+
const keys = Object.keys(value);
|
|
477
|
+
const extras = keys.filter((key) => !allowed.includes(key));
|
|
478
|
+
const missing = allowed.filter((key) => !Object.hasOwn(value, key));
|
|
479
|
+
if (missing.length > 0) throw new Error(`${label} is missing ${missing.join(", ")}`);
|
|
480
|
+
if (extras.length > 0) throw new Error(`${label} has unknown field ${extras.join(", ")}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function canonicalJson(value: unknown): string {
|
|
484
|
+
return JSON.stringify(sortJson(value));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function compareKeys(left: string, right: string): number {
|
|
488
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function sortJson(value: unknown): unknown {
|
|
492
|
+
if (Array.isArray(value)) return value.map(sortJson);
|
|
493
|
+
if (value !== null && typeof value === "object") {
|
|
494
|
+
return Object.fromEntries(
|
|
495
|
+
Object.entries(value as Record<string, unknown>)
|
|
496
|
+
.sort(([left], [right]) => compareKeys(left, right))
|
|
497
|
+
.map(([key, child]) => [key, sortJson(child)]),
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
return value;
|
|
501
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { agent } from "./definition.js";
|
|
2
2
|
import { extractJsonValue } from "./json.js";
|
|
3
|
-
import type { AgentNodeDefinition,
|
|
3
|
+
import type { AgentNodeDefinition, WorkflowNodeContext } from "./types.js";
|
|
4
4
|
|
|
5
5
|
const DEFAULT_FIELD = "route";
|
|
6
6
|
const SIMPLE_FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -64,12 +64,19 @@ export function decision<TChoice extends string>(
|
|
|
64
64
|
* Build the matching `switch` edge for a `decision` node. Typing `cases` as
|
|
65
65
|
* `Record<TChoice, string>` makes a missing case a compile error.
|
|
66
66
|
*/
|
|
67
|
-
export function decisionEdge<
|
|
68
|
-
|
|
67
|
+
export function decisionEdge<
|
|
68
|
+
TChoice extends string,
|
|
69
|
+
const TFrom extends string,
|
|
70
|
+
const TCases extends Record<TChoice, string>,
|
|
71
|
+
>(args: {
|
|
72
|
+
from: TFrom;
|
|
69
73
|
choices: readonly TChoice[];
|
|
70
74
|
field?: string;
|
|
71
|
-
cases:
|
|
72
|
-
}):
|
|
75
|
+
cases: TCases;
|
|
76
|
+
}): {
|
|
77
|
+
from: TFrom;
|
|
78
|
+
switch: { on: string; cases: TCases };
|
|
79
|
+
} {
|
|
73
80
|
const field = normalizeField(args.field);
|
|
74
81
|
assertValidChoices(args.choices);
|
|
75
82
|
for (const choice of args.choices) {
|
|
@@ -16,27 +16,137 @@ import type {
|
|
|
16
16
|
NotifyNodeDefinition,
|
|
17
17
|
ShellActionNodeDefinition,
|
|
18
18
|
WorkflowDefinition,
|
|
19
|
+
WorkflowExitMap,
|
|
20
|
+
WorkflowIncludeDefinition,
|
|
21
|
+
WorkflowIncludedResult,
|
|
22
|
+
WorkflowIncludeMap,
|
|
23
|
+
WorkflowInputOf,
|
|
24
|
+
WorkflowNodeContext,
|
|
25
|
+
WorkflowNodeDefinition,
|
|
26
|
+
WorkflowTypedEdge,
|
|
27
|
+
WorkflowValueParser,
|
|
19
28
|
} from "./types.js";
|
|
20
29
|
|
|
21
30
|
const WORKFLOW_DEFINITION_BRAND = Symbol.for("pi-workflows.definition");
|
|
22
31
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
type WorkflowDefinitionInput<
|
|
33
|
+
TInput,
|
|
34
|
+
TNodes extends Record<string, WorkflowNodeDefinition>,
|
|
35
|
+
TIncludes extends WorkflowIncludeMap,
|
|
36
|
+
TExits extends WorkflowExitMap,
|
|
37
|
+
> = Omit<
|
|
38
|
+
WorkflowDefinition<TInput, TExits, TIncludes>,
|
|
39
|
+
"input" | "nodes" | "includes" | "exits" | "edges"
|
|
40
|
+
> & {
|
|
41
|
+
input?: WorkflowValueParser<TInput>;
|
|
42
|
+
nodes: TNodes;
|
|
43
|
+
includes?: TIncludes;
|
|
44
|
+
exits?: TExits;
|
|
45
|
+
edges: WorkflowTypedEdge<TNodes, TIncludes>[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function defineWorkflow<
|
|
49
|
+
TInput = unknown,
|
|
50
|
+
const TNodes extends Record<string, WorkflowNodeDefinition> = Record<
|
|
51
|
+
string,
|
|
52
|
+
WorkflowNodeDefinition
|
|
53
|
+
>,
|
|
54
|
+
const TIncludes extends WorkflowIncludeMap = Record<never, never>,
|
|
55
|
+
const TExits extends WorkflowExitMap = Record<never, never>,
|
|
56
|
+
>(
|
|
57
|
+
definition: WorkflowDefinitionInput<TInput, TNodes, TIncludes, TExits>,
|
|
58
|
+
): WorkflowDefinition<TInput, TExits, TIncludes> & {
|
|
59
|
+
nodes: TNodes;
|
|
60
|
+
includes?: TIncludes;
|
|
61
|
+
exits?: TExits;
|
|
62
|
+
} {
|
|
63
|
+
assertValidWorkflowDefinitionShape(definition as WorkflowDefinition);
|
|
64
|
+
const typed = definition as WorkflowDefinition<TInput, TExits, TIncludes> & {
|
|
65
|
+
nodes: TNodes;
|
|
66
|
+
includes?: TIncludes;
|
|
67
|
+
exits?: TExits;
|
|
68
|
+
};
|
|
69
|
+
if (isWorkflowDefinition(typed)) {
|
|
70
|
+
return typed;
|
|
29
71
|
}
|
|
30
|
-
Object.defineProperty(
|
|
72
|
+
Object.defineProperty(typed, WORKFLOW_DEFINITION_BRAND, {
|
|
31
73
|
value: true,
|
|
32
74
|
enumerable: false,
|
|
33
75
|
configurable: false,
|
|
34
76
|
writable: false,
|
|
35
77
|
});
|
|
78
|
+
return typed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function includeWorkflow<TWorkflow extends WorkflowDefinition<any, any, any>>(
|
|
82
|
+
workflow: TWorkflow,
|
|
83
|
+
options?: {
|
|
84
|
+
input?: (
|
|
85
|
+
context: WorkflowNodeContext,
|
|
86
|
+
) => Promise<WorkflowInputOf<TWorkflow>> | WorkflowInputOf<TWorkflow>;
|
|
87
|
+
},
|
|
88
|
+
): WorkflowIncludeDefinition<TWorkflow>;
|
|
89
|
+
export function includeWorkflow<TWorkflow extends WorkflowDefinition<any, any, any>>(
|
|
90
|
+
definition: WorkflowIncludeDefinition<TWorkflow>,
|
|
91
|
+
): WorkflowIncludeDefinition<TWorkflow>;
|
|
92
|
+
export function includeWorkflow<TWorkflow extends WorkflowDefinition<any, any, any>>(
|
|
93
|
+
workflowOrDefinition: TWorkflow | WorkflowIncludeDefinition<TWorkflow>,
|
|
94
|
+
options: {
|
|
95
|
+
input?: (
|
|
96
|
+
context: WorkflowNodeContext,
|
|
97
|
+
) => Promise<WorkflowInputOf<TWorkflow>> | WorkflowInputOf<TWorkflow>;
|
|
98
|
+
} = {},
|
|
99
|
+
): WorkflowIncludeDefinition<TWorkflow> {
|
|
100
|
+
const definition = isWorkflowDefinition(workflowOrDefinition)
|
|
101
|
+
? { workflow: workflowOrDefinition, ...options }
|
|
102
|
+
: workflowOrDefinition;
|
|
103
|
+
if (typeof definition.workflow !== "string" && !isWorkflowDefinition(definition.workflow)) {
|
|
104
|
+
throw new Error("Included workflow must be a defined workflow or a workflow reference");
|
|
105
|
+
}
|
|
106
|
+
if (definition.input !== undefined && typeof definition.input !== "function") {
|
|
107
|
+
throw new Error("Included workflow input must be a function");
|
|
108
|
+
}
|
|
109
|
+
if (definition.contract !== undefined && !isWorkflowDefinition(definition.contract)) {
|
|
110
|
+
throw new Error("Included workflow contract must be defined with defineWorkflow");
|
|
111
|
+
}
|
|
36
112
|
return definition;
|
|
37
113
|
}
|
|
38
114
|
|
|
39
|
-
export function
|
|
115
|
+
export function includedResult<TWorkflow extends WorkflowDefinition<any, any, any>>(
|
|
116
|
+
workflow: TWorkflow,
|
|
117
|
+
value: unknown,
|
|
118
|
+
): WorkflowIncludedResult<TWorkflow> {
|
|
119
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
120
|
+
throw new Error(`Included ${workflow.name} result must be an object`);
|
|
121
|
+
}
|
|
122
|
+
const exit = (value as { exit?: unknown }).exit;
|
|
123
|
+
if (typeof exit !== "string" || !Object.hasOwn(workflow.exits ?? {}, exit)) {
|
|
124
|
+
throw new Error(`Included ${workflow.name} result has unknown exit ${JSON.stringify(exit)}`);
|
|
125
|
+
}
|
|
126
|
+
if (!Object.hasOwn(value, "output")) {
|
|
127
|
+
throw new Error(`Included ${workflow.name} result requires output`);
|
|
128
|
+
}
|
|
129
|
+
return value as WorkflowIncludedResult<TWorkflow>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Preserve exact workflow types while checking duplicate registry names. */
|
|
133
|
+
export function defineWorkflowRegistry<
|
|
134
|
+
const TRegistry extends Record<string, WorkflowDefinition<any, any, any>>,
|
|
135
|
+
>(registry: TRegistry): Readonly<TRegistry> {
|
|
136
|
+
const names = new Set<string>();
|
|
137
|
+
for (const [key, workflow] of Object.entries(registry)) {
|
|
138
|
+
if (!isWorkflowDefinition(workflow)) {
|
|
139
|
+
throw new Error(`Workflow registry entry ${key} is not defined with defineWorkflow`);
|
|
140
|
+
}
|
|
141
|
+
if (names.has(workflow.name)) {
|
|
142
|
+
throw new Error(`Workflow registry has duplicate workflow name: ${workflow.name}`);
|
|
143
|
+
}
|
|
144
|
+
names.add(workflow.name);
|
|
145
|
+
}
|
|
146
|
+
return Object.freeze({ ...registry });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function isWorkflowDefinition(value: unknown): value is WorkflowDefinition<any, any, any> {
|
|
40
150
|
return (
|
|
41
151
|
value != null &&
|
|
42
152
|
typeof value === "object" &&
|