@alisio/core 0.1.0 → 0.2.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/dist/agents/active.d.ts +15 -1
- package/dist/agents/active.js +48 -9
- package/dist/analysis/python/sources.js +7 -4
- package/dist/application.js +16 -1
- package/dist/artifacts/chart-lint.d.ts +8 -0
- package/dist/artifacts/chart-lint.js +84 -0
- package/dist/artifacts/publisher.js +3 -0
- package/dist/artifacts/store.d.ts +3 -1
- package/dist/artifacts/store.js +11 -1
- package/dist/changelog/data.js +44 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +15 -0
- package/dist/core/contracts.d.ts +8 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/plan/diagrams.d.ts +76 -0
- package/dist/plan/diagrams.js +326 -0
- package/dist/plan/exit-plan.d.ts +5 -12
- package/dist/plan/exit-plan.js +199 -33
- package/dist/plan/manifest.d.ts +53 -0
- package/dist/plan/manifest.js +319 -0
- package/dist/plan/state.d.ts +21 -1
- package/dist/plan/state.js +6 -12
- package/dist/tools/analysis.js +5 -2
- package/package.json +2 -2
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan diagrams: the lightweight, render-free validation of the Mermaid diagrams the plan agent
|
|
3
|
+
* passes to `exit_plan`. Mermaid cannot render without a DOM, so the server only checks what it
|
|
4
|
+
* can see in the text: a size limit, an allowlist of diagram types, a node estimate and the
|
|
5
|
+
* constructs that would run code, open links, fetch resources or relax Mermaid's security
|
|
6
|
+
* settings. A diagram that fails is DROPPED with a reason (the plan is still published); a
|
|
7
|
+
* diagram that only fails to draw is shown as source by the web renderer.
|
|
8
|
+
*
|
|
9
|
+
* Pure functions: no I/O, no Mermaid import.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
/** Largest Mermaid source of one diagram (UTF-8 bytes). */
|
|
13
|
+
export const DIAGRAM_MAX_BYTES = 8 * 1024;
|
|
14
|
+
/** Most nodes (participants, entities, states, tasks...) of one diagram, estimated from the text. */
|
|
15
|
+
export const DIAGRAM_MAX_NODES = 40;
|
|
16
|
+
/** Hard ceiling of `plan.maxDiagrams`. */
|
|
17
|
+
export const DIAGRAMS_MAX_ALLOWED = 8;
|
|
18
|
+
export const DIAGRAM_ID_MAX = 40;
|
|
19
|
+
export const DIAGRAM_TITLE_MAX = 80;
|
|
20
|
+
export const DIAGRAM_EXPLANATION_MAX = 400;
|
|
21
|
+
export const DIAGRAM_SECTION_MAX = 120;
|
|
22
|
+
/** Mermaid diagram keywords a plan may use (the first statement of the source). */
|
|
23
|
+
export const DIAGRAM_SYNTAXES = [
|
|
24
|
+
"flowchart",
|
|
25
|
+
"graph",
|
|
26
|
+
"sequenceDiagram",
|
|
27
|
+
"stateDiagram-v2",
|
|
28
|
+
"stateDiagram",
|
|
29
|
+
"erDiagram",
|
|
30
|
+
"classDiagram",
|
|
31
|
+
"gantt",
|
|
32
|
+
"mindmap",
|
|
33
|
+
"timeline",
|
|
34
|
+
"journey",
|
|
35
|
+
];
|
|
36
|
+
export const DIAGRAM_TYPES = [
|
|
37
|
+
"overview",
|
|
38
|
+
"flow",
|
|
39
|
+
"components",
|
|
40
|
+
"architecture",
|
|
41
|
+
"sequence",
|
|
42
|
+
"data",
|
|
43
|
+
"state",
|
|
44
|
+
"other",
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* The semantic classes of the style guide. The web applies the same names as a `classDef` block
|
|
48
|
+
* at render time when a diagram uses a class without defining it (colors live in the web).
|
|
49
|
+
*/
|
|
50
|
+
export const DIAGRAM_CLASSES = [
|
|
51
|
+
"input",
|
|
52
|
+
"process",
|
|
53
|
+
"data",
|
|
54
|
+
"system",
|
|
55
|
+
"external",
|
|
56
|
+
"decision",
|
|
57
|
+
"risk",
|
|
58
|
+
];
|
|
59
|
+
const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
60
|
+
/** Security settings a directive or the front matter may not touch. */
|
|
61
|
+
const FORBIDDEN_CONFIG = /(securityLevel|htmlLabels|\bsecure\b|dompurify|themeCSS|maxTextSize|maxEdges|startOnLoad|callback|logLevel|deterministicIds)/i;
|
|
62
|
+
/** Normalizes line endings and trims: what is stored and hashed. */
|
|
63
|
+
export const normalizeMermaid = (source) => source
|
|
64
|
+
.replace(/\r\n?/g, "\n")
|
|
65
|
+
.replace(/\u0000/g, "")
|
|
66
|
+
.trim();
|
|
67
|
+
const byteLength = (text) => Buffer.byteLength(text, "utf8");
|
|
68
|
+
/** Removes a leading YAML front matter block (`---` ... `---`). */
|
|
69
|
+
function splitFrontMatter(source) {
|
|
70
|
+
const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(source);
|
|
71
|
+
return match
|
|
72
|
+
? { front: match[1] ?? "", body: source.slice(match[0].length) }
|
|
73
|
+
: { front: "", body: source };
|
|
74
|
+
}
|
|
75
|
+
/** Directives `%%{ ... }%%` (possibly several lines) and plain `%%` comments. */
|
|
76
|
+
function directives(body) {
|
|
77
|
+
return [...body.matchAll(/%%\{[\s\S]*?\}%%/g)].map((m) => m[0]);
|
|
78
|
+
}
|
|
79
|
+
/** The body without directives and comments (what the statement scan reads). */
|
|
80
|
+
function statements(body) {
|
|
81
|
+
return body
|
|
82
|
+
.replace(/%%\{[\s\S]*?\}%%/g, "")
|
|
83
|
+
.split("\n")
|
|
84
|
+
.map((line) => line.replace(/%%.*$/, ""))
|
|
85
|
+
.filter((line) => line.trim());
|
|
86
|
+
}
|
|
87
|
+
/** The diagram keyword of a source (its first statement), or undefined when it has none. */
|
|
88
|
+
export function diagramSyntax(source) {
|
|
89
|
+
const { body } = splitFrontMatter(normalizeMermaid(source));
|
|
90
|
+
const first = statements(body)[0]?.trim();
|
|
91
|
+
if (!first)
|
|
92
|
+
return undefined;
|
|
93
|
+
const word = /^([A-Za-z][\w-]*)/.exec(first)?.[1];
|
|
94
|
+
return word && DIAGRAM_SYNTAXES.includes(word) ? word : (word ?? undefined);
|
|
95
|
+
}
|
|
96
|
+
/** The purpose a syntax implies when the model gave none. */
|
|
97
|
+
export function defaultDiagramType(syntax) {
|
|
98
|
+
switch (syntax) {
|
|
99
|
+
case "sequenceDiagram":
|
|
100
|
+
return "sequence";
|
|
101
|
+
case "stateDiagram":
|
|
102
|
+
case "stateDiagram-v2":
|
|
103
|
+
return "state";
|
|
104
|
+
case "erDiagram":
|
|
105
|
+
case "classDiagram":
|
|
106
|
+
return "data";
|
|
107
|
+
default:
|
|
108
|
+
return "flow";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const uniqueCount = (items) => new Set(items).size;
|
|
112
|
+
/** Words of a line without bracketed labels, quoted text and edge labels. */
|
|
113
|
+
function bareLine(line) {
|
|
114
|
+
return line
|
|
115
|
+
.replace(/"[^"]*"/g, " ")
|
|
116
|
+
.replace(/\[\[[^\]]*\]\]|\[[^\]]*\]|\(\([^)]*\)\)|\([^)]*\)|\{\{[^}]*\}\}|\{[^}]*\}/g, " ")
|
|
117
|
+
.replace(/\|[^|]*\|/g, " ")
|
|
118
|
+
.replace(/:::[\w-]+/g, " ");
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* An estimate of the diagram's nodes from its text (participants, entities, states, tasks, topics
|
|
122
|
+
* or flowchart node ids). It errs on the high side: it is a guard against walls of boxes, not a
|
|
123
|
+
* parser.
|
|
124
|
+
*/
|
|
125
|
+
export function estimateNodes(source, syntax) {
|
|
126
|
+
const { body } = splitFrontMatter(normalizeMermaid(source));
|
|
127
|
+
const lines = statements(body).map((line) => line.trim());
|
|
128
|
+
const content = lines.slice(1);
|
|
129
|
+
switch (syntax) {
|
|
130
|
+
case "flowchart":
|
|
131
|
+
case "graph": {
|
|
132
|
+
const ids = new Set();
|
|
133
|
+
for (const line of content) {
|
|
134
|
+
if (/^(subgraph|end|classDef|class|style|linkStyle|direction|click|accTitle|accDescr)\b/.test(line))
|
|
135
|
+
continue;
|
|
136
|
+
for (const part of bareLine(line).split(/<?[-=.~]+[ox>]?|&|;|\s+/)) {
|
|
137
|
+
const id = /^[A-Za-z_][\w]*$/.exec(part.trim())?.[0];
|
|
138
|
+
if (id)
|
|
139
|
+
ids.add(id);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return ids.size;
|
|
143
|
+
}
|
|
144
|
+
case "sequenceDiagram": {
|
|
145
|
+
const names = new Set();
|
|
146
|
+
for (const line of content) {
|
|
147
|
+
const declared = /^(?:participant|actor)\s+(\S+)/.exec(line)?.[1];
|
|
148
|
+
if (declared) {
|
|
149
|
+
names.add(declared);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const message = /^([^\s:]+?)\s*(?:-->>|->>|--x|-x|--\)|-\)|-->|->)\s*[+-]?([^\s:]+)/.exec(line);
|
|
153
|
+
if (message?.[1] && message[2]) {
|
|
154
|
+
names.add(message[1]);
|
|
155
|
+
names.add(message[2]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return names.size;
|
|
159
|
+
}
|
|
160
|
+
case "stateDiagram":
|
|
161
|
+
case "stateDiagram-v2": {
|
|
162
|
+
const states = new Set();
|
|
163
|
+
for (const line of content) {
|
|
164
|
+
const transition = /^(\S+)\s*-->\s*(\S+)/.exec(line);
|
|
165
|
+
if (transition?.[1] && transition[2])
|
|
166
|
+
for (const state of [transition[1], transition[2]])
|
|
167
|
+
if (state !== "[*]")
|
|
168
|
+
states.add(state);
|
|
169
|
+
const declared = /^state\s+(?:"[^"]*"\s+as\s+)?(\w+)/.exec(line)?.[1];
|
|
170
|
+
if (declared)
|
|
171
|
+
states.add(declared);
|
|
172
|
+
}
|
|
173
|
+
return states.size;
|
|
174
|
+
}
|
|
175
|
+
case "erDiagram": {
|
|
176
|
+
const entities = new Set();
|
|
177
|
+
for (const line of content) {
|
|
178
|
+
const relation = /^(\S+)\s+[|}o{.-]+\s*[|}o{.-]*\s+(\S+)\s*:/.exec(line);
|
|
179
|
+
if (relation?.[1] && relation[2]) {
|
|
180
|
+
entities.add(relation[1]);
|
|
181
|
+
entities.add(relation[2]);
|
|
182
|
+
}
|
|
183
|
+
const block = /^(\w+)\s*\{/.exec(line)?.[1];
|
|
184
|
+
if (block)
|
|
185
|
+
entities.add(block);
|
|
186
|
+
}
|
|
187
|
+
return entities.size;
|
|
188
|
+
}
|
|
189
|
+
case "classDiagram": {
|
|
190
|
+
const classes = new Set();
|
|
191
|
+
for (const line of content) {
|
|
192
|
+
const declared = /^class\s+(\w+)/.exec(line)?.[1];
|
|
193
|
+
if (declared)
|
|
194
|
+
classes.add(declared);
|
|
195
|
+
const relation = /^(\w+)\s+(?:"[^"]*"\s+)?[<|*o.\-\\/>]+\s+(?:"[^"]*"\s+)?(\w+)/.exec(line);
|
|
196
|
+
if (relation?.[1] && relation[2]) {
|
|
197
|
+
classes.add(relation[1]);
|
|
198
|
+
classes.add(relation[2]);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return classes.size;
|
|
202
|
+
}
|
|
203
|
+
case "gantt":
|
|
204
|
+
return content.filter((line) => line.includes(":") &&
|
|
205
|
+
!/^(title|dateFormat|axisFormat|section|excludes|includes|todayMarker|tickInterval|weekday|accTitle|accDescr|inclusiveEndDates|topAxis)\b/.test(line)).length;
|
|
206
|
+
case "journey":
|
|
207
|
+
return content.filter((line) => !/^(title|section|accTitle|accDescr)\b/.test(line)).length;
|
|
208
|
+
default:
|
|
209
|
+
// mindmap, timeline and anything else: one node per line.
|
|
210
|
+
return uniqueCount(content.filter((line) => !/^(title|section|accTitle|accDescr)\b/.test(line)));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** The first problem that makes a Mermaid source unsafe or unsupported, or undefined when fine. */
|
|
214
|
+
export function mermaidProblem(source, limits = {}) {
|
|
215
|
+
const maxBytes = limits.maxBytes ?? DIAGRAM_MAX_BYTES;
|
|
216
|
+
const maxNodes = limits.maxNodes ?? DIAGRAM_MAX_NODES;
|
|
217
|
+
const text = normalizeMermaid(source);
|
|
218
|
+
if (!text)
|
|
219
|
+
return "the diagram is empty";
|
|
220
|
+
if (byteLength(text) > maxBytes)
|
|
221
|
+
return `the diagram is larger than ${maxBytes} bytes`;
|
|
222
|
+
if (/[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text))
|
|
223
|
+
return "the diagram has control characters";
|
|
224
|
+
const { front, body } = splitFrontMatter(text);
|
|
225
|
+
const syntax = diagramSyntax(text);
|
|
226
|
+
if (!syntax)
|
|
227
|
+
return "the diagram does not start with a Mermaid diagram keyword";
|
|
228
|
+
if (!DIAGRAM_SYNTAXES.includes(syntax))
|
|
229
|
+
return `the diagram type "${syntax}" is not allowed (use ${DIAGRAM_SYNTAXES.join(", ")})`;
|
|
230
|
+
if (FORBIDDEN_CONFIG.test(front))
|
|
231
|
+
return "the front matter changes Mermaid security settings, which plans may not do";
|
|
232
|
+
for (const directive of directives(body))
|
|
233
|
+
if (FORBIDDEN_CONFIG.test(directive))
|
|
234
|
+
return "an init directive changes Mermaid security settings, which plans may not do";
|
|
235
|
+
// The scan below ignores comments and directives, which were checked above.
|
|
236
|
+
const code = statements(body).join("\n");
|
|
237
|
+
if (/(?:javascript|vbscript)\s*:|data\s*:\s*(?:text|image|application)\//i.test(code))
|
|
238
|
+
return "the diagram contains a javascript:, vbscript: or data: URL";
|
|
239
|
+
if (/\bhref\b/i.test(code))
|
|
240
|
+
return "the diagram contains a link (href)";
|
|
241
|
+
if (/url\s*\(|@import/i.test(code))
|
|
242
|
+
return "the diagram references an external resource (url())";
|
|
243
|
+
// `<<choice>>` and `<<interface>>` are Mermaid annotations, not tags.
|
|
244
|
+
if (/<\s*\/?\s*[A-Za-z!][^>]*>/.test(code.replace(/<<[^<>\n]*>>/g, " ")))
|
|
245
|
+
return "labels are text: HTML tags (<br/>, <b>, <script>) are not allowed";
|
|
246
|
+
const interactive = !["mindmap", "timeline", "journey"].includes(syntax);
|
|
247
|
+
if (interactive && /^\s*(?:click|links?|callback)\s/m.test(code))
|
|
248
|
+
return "click, link and callback statements are not allowed";
|
|
249
|
+
if (estimateNodes(text, syntax) > maxNodes)
|
|
250
|
+
return `the diagram has more than ${maxNodes} nodes: split it or keep only the main parts`;
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
/** SHA-256 of what makes a diagram "the same" across revisions. */
|
|
254
|
+
export function diagramHash(diagram) {
|
|
255
|
+
return createHash("sha256")
|
|
256
|
+
.update(JSON.stringify([
|
|
257
|
+
diagram.title,
|
|
258
|
+
diagram.explanation,
|
|
259
|
+
diagram.section ?? "",
|
|
260
|
+
diagram.type,
|
|
261
|
+
normalizeMermaid(diagram.mermaid),
|
|
262
|
+
]), "utf8")
|
|
263
|
+
.digest("hex");
|
|
264
|
+
}
|
|
265
|
+
const text = (value, max) => typeof value === "string" && value.trim() ? value.trim().slice(0, max) : undefined;
|
|
266
|
+
/**
|
|
267
|
+
* Validates the `diagrams` argument of `exit_plan`. Never throws: every problem becomes a dropped
|
|
268
|
+
* diagram with the reason the model needs to fix it. Order is kept; the first diagram with an id
|
|
269
|
+
* wins; only `limits.max` are accepted.
|
|
270
|
+
*/
|
|
271
|
+
export function validateDiagrams(input, limits) {
|
|
272
|
+
const accepted = [];
|
|
273
|
+
const dropped = [];
|
|
274
|
+
if (input === undefined || input === null)
|
|
275
|
+
return { accepted, dropped };
|
|
276
|
+
if (!Array.isArray(input)) {
|
|
277
|
+
dropped.push({ id: "diagrams", reason: "`diagrams` must be an array" });
|
|
278
|
+
return { accepted, dropped };
|
|
279
|
+
}
|
|
280
|
+
const seen = new Set();
|
|
281
|
+
input.forEach((raw, index) => {
|
|
282
|
+
const item = raw && typeof raw === "object" ? raw : {};
|
|
283
|
+
const given = typeof item.id === "string" ? item.id.trim() : "";
|
|
284
|
+
const label = given || `#${index + 1}`;
|
|
285
|
+
const drop = (reason) => dropped.push({ id: label, reason });
|
|
286
|
+
if (!raw || typeof raw !== "object")
|
|
287
|
+
return drop("the diagram must be an object");
|
|
288
|
+
if (!ID.test(given) || given.length > DIAGRAM_ID_MAX)
|
|
289
|
+
return drop(`the id must be kebab-case, at most ${DIAGRAM_ID_MAX} characters (like "request-flow")`);
|
|
290
|
+
if (seen.has(given))
|
|
291
|
+
return drop("the id is already used by another diagram of this plan");
|
|
292
|
+
const title = text(item.title, DIAGRAM_TITLE_MAX);
|
|
293
|
+
if (!title)
|
|
294
|
+
return drop("the title is missing");
|
|
295
|
+
const explanation = text(item.explanation, DIAGRAM_EXPLANATION_MAX);
|
|
296
|
+
if (!explanation)
|
|
297
|
+
return drop("the explanation is missing (one short sentence)");
|
|
298
|
+
if (typeof item.mermaid !== "string")
|
|
299
|
+
return drop("the `mermaid` source is missing");
|
|
300
|
+
if (accepted.length >= limits.max)
|
|
301
|
+
return drop(limits.max === 0
|
|
302
|
+
? "diagrams are turned off (plan.maxDiagrams is 0)"
|
|
303
|
+
: `only ${limits.max} diagrams are accepted per plan (plan.maxDiagrams)`);
|
|
304
|
+
const problem = mermaidProblem(item.mermaid, limits);
|
|
305
|
+
if (problem)
|
|
306
|
+
return drop(problem);
|
|
307
|
+
const mermaid = normalizeMermaid(item.mermaid);
|
|
308
|
+
const syntax = diagramSyntax(mermaid);
|
|
309
|
+
const type = DIAGRAM_TYPES.includes(item.type)
|
|
310
|
+
? item.type
|
|
311
|
+
: defaultDiagramType(syntax);
|
|
312
|
+
const section = text(item.section, DIAGRAM_SECTION_MAX);
|
|
313
|
+
seen.add(given);
|
|
314
|
+
accepted.push({
|
|
315
|
+
id: given,
|
|
316
|
+
title,
|
|
317
|
+
explanation,
|
|
318
|
+
...(section ? { section } : {}),
|
|
319
|
+
type,
|
|
320
|
+
syntax,
|
|
321
|
+
mermaid,
|
|
322
|
+
nodes: estimateNodes(mermaid, syntax),
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
return { accepted, dropped };
|
|
326
|
+
}
|
package/dist/plan/exit-plan.d.ts
CHANGED
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `exit_plan`: how the plan agent hands over its plan. The tool publishes the plan as a Markdown
|
|
3
|
-
* artifact (`plan.md`, one revision per call), asks the user for a decision through the same
|
|
4
|
-
* question infrastructure as `ask_user_question` and ALWAYS returns a tool result (approved,
|
|
5
|
-
* skipped or feedback), so a session never keeps a dangling call. It does not switch agents or
|
|
6
|
-
* start anything: an approved plan is recorded (`plan/state.ts`) and the host starts the
|
|
7
|
-
* implementation turn once the plan run has ended.
|
|
8
|
-
*
|
|
9
|
-
* The tool is opt-in (`core/opt-in.ts`): only the plan agent's run lists it. Its effect is
|
|
10
|
-
* `read`, so it is allowed under every policy; the plan agent stays read-only because its run
|
|
11
|
-
* policy has no write/process/external effect, whatever the permission mode.
|
|
12
|
-
*/
|
|
13
1
|
import type { AskQuestionsRequest, AskQuestionsResult } from "@alisio/sdk";
|
|
14
2
|
import type { ToolRegistry } from "../core/registry.ts";
|
|
15
3
|
import { type PlanStore } from "./state.ts";
|
|
@@ -21,5 +9,10 @@ export interface ExitPlanDeps {
|
|
|
21
9
|
};
|
|
22
10
|
/** `--read-only`: nothing can be implemented afterwards, so there is no decision to take. */
|
|
23
11
|
readOnly?: boolean;
|
|
12
|
+
/** `plan.diagrams` and `plan.maxDiagrams`, read on every request (they change live). */
|
|
13
|
+
diagrams?: () => {
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
max: number;
|
|
16
|
+
};
|
|
24
17
|
}
|
|
25
18
|
export declare function registerExitPlan(registry: ToolRegistry, deps: ExitPlanDeps): void;
|
package/dist/plan/exit-plan.js
CHANGED
|
@@ -1,7 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `exit_plan`: how the plan agent hands over its plan. The tool publishes the plan as a Markdown
|
|
3
|
+
* artifact (`plan.md`, one revision per call), asks the user for a decision through the same
|
|
4
|
+
* question infrastructure as `ask_user_question` and ALWAYS returns a tool result (approved,
|
|
5
|
+
* skipped or feedback), so a session never keeps a dangling call. It does not switch agents or
|
|
6
|
+
* start anything: an approved plan is recorded (`plan/state.ts`) and the host starts the
|
|
7
|
+
* implementation turn once the plan run has ended.
|
|
8
|
+
*
|
|
9
|
+
* The tool is opt-in (`core/opt-in.ts`): only the plan agent's run lists it. Its effect is
|
|
10
|
+
* `read`, so it is allowed under every policy; the plan agent stays read-only because its run
|
|
11
|
+
* policy has no write/process/external effect, whatever the permission mode.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
1
16
|
import { textResult } from "@alisio/sdk";
|
|
2
17
|
import { EXIT_PLAN_TOOL } from "../core/opt-in.js";
|
|
3
18
|
import { objectSchema } from "../tools/standard.js";
|
|
4
|
-
import {
|
|
19
|
+
import { DIAGRAM_MAX_BYTES, DIAGRAM_MAX_NODES, validateDiagrams, } from "./diagrams.js";
|
|
20
|
+
import { buildPlanManifest } from "./manifest.js";
|
|
21
|
+
import { currentPlan, decidePlan, nextPlanRevision, PLAN_CONTEXT_MAX_CHARS, PLAN_MAX_CHARS, PLAN_OPTIONS, PLAN_QUESTION_ID, PLAN_REVIEW_TITLE, PLAN_TEXT_KEY, planHash, planTitle, proposePlan, withdrawPendingPlan, } from "./state.js";
|
|
22
|
+
const DIAGRAM_SCHEMA = {
|
|
23
|
+
type: "array",
|
|
24
|
+
description: `Optional Mermaid diagrams; see the plan agent's instructions`,
|
|
25
|
+
items: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
id: { type: "string", description: "kebab-case, stable across revisions" },
|
|
29
|
+
title: { type: "string" },
|
|
30
|
+
explanation: { type: "string", description: "One short sentence" },
|
|
31
|
+
section: { type: "string", description: "Plan heading it illustrates" },
|
|
32
|
+
type: {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "overview, flow, components, architecture, sequence, data, state or other",
|
|
35
|
+
},
|
|
36
|
+
mermaid: { type: "string", description: "Mermaid source, no html, click or %%{init}" },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
const BASE_DESCRIPTION = "Finish planning: hand the complete plan, in Markdown, to the user for review. Call it once, " +
|
|
41
|
+
"when the plan is complete, instead of pasting the plan in your reply. The user can approve " +
|
|
42
|
+
"(the build agent then implements exactly this plan), skip for now, or add context (the " +
|
|
43
|
+
"result is `feedback`: revise the plan and call exit_plan again). Only the plan agent has " +
|
|
44
|
+
"this tool. If review is unavailable (headless or read-only sessions) the result says so: " +
|
|
45
|
+
"then give the complete plan as your final reply.";
|
|
5
46
|
const withArtifact = (result, artifact) => artifact
|
|
6
47
|
? {
|
|
7
48
|
...result,
|
|
@@ -9,49 +50,157 @@ const withArtifact = (result, artifact) => artifact
|
|
|
9
50
|
}
|
|
10
51
|
: result;
|
|
11
52
|
const answer = (decision, message, extra = {}) => JSON.stringify({ decision, ...extra, message });
|
|
53
|
+
const diagramReport = (outcome) => outcome.involved
|
|
54
|
+
? {
|
|
55
|
+
diagrams: {
|
|
56
|
+
accepted: outcome.accepted.map((d) => d.id),
|
|
57
|
+
...(outcome.dropped.length ? { dropped: outcome.dropped } : {}),
|
|
58
|
+
...(outcome.removed.length ? { removed: outcome.removed.map((d) => d.id) } : {}),
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
: {};
|
|
62
|
+
/**
|
|
63
|
+
* Writes the plan folder (`plan.md`, `plan.json`, `diagrams/<id>.mmd`) in a scratch directory the
|
|
64
|
+
* publisher then copies, and removes the directory again.
|
|
65
|
+
*/
|
|
66
|
+
async function publishPlanFolder(publisher, files, title) {
|
|
67
|
+
const scratch = await mkdtemp(join(tmpdir(), "alisio-plan-"));
|
|
68
|
+
try {
|
|
69
|
+
const folder = join(scratch, "plan");
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
const target = join(folder, ...file.path.split("/"));
|
|
72
|
+
await mkdir(join(target, ".."), { recursive: true });
|
|
73
|
+
await writeFile(target, file.text, { flag: "wx" });
|
|
74
|
+
}
|
|
75
|
+
const input = { source: folder, title, entry: "plan.md", fileName: "plan.md" };
|
|
76
|
+
return typeof publisher.publishDetailed === "function"
|
|
77
|
+
? (await publisher.publishDetailed(input)).artifact
|
|
78
|
+
: await publisher.publish(input);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await rm(scratch, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
12
84
|
export function registerExitPlan(registry, deps) {
|
|
13
|
-
|
|
85
|
+
const settings = () => deps.diagrams?.() ?? { enabled: true, max: 5 };
|
|
86
|
+
const offered = () => {
|
|
87
|
+
const current = settings();
|
|
88
|
+
return current.enabled && current.max > 0;
|
|
89
|
+
};
|
|
90
|
+
const plain = objectSchema({
|
|
91
|
+
title: { type: "string", maxLength: 120, description: "Short plan title" },
|
|
92
|
+
plan: {
|
|
93
|
+
type: "string",
|
|
94
|
+
minLength: 1,
|
|
95
|
+
maxLength: PLAN_MAX_CHARS,
|
|
96
|
+
description: "The whole plan in Markdown",
|
|
97
|
+
},
|
|
98
|
+
}, ["plan"]);
|
|
99
|
+
const withDiagrams = objectSchema({ ...plain.properties, diagrams: DIAGRAM_SCHEMA }, ["plan"]);
|
|
100
|
+
// The input is always VALIDATED with `diagrams` allowed (a call made while they were switched
|
|
101
|
+
// off must not fail), but the model is only OFFERED the field while diagrams are on: the
|
|
102
|
+
// registry compiles the schema once, while the provider reads it on every request.
|
|
103
|
+
let registering = true;
|
|
104
|
+
const tool = {
|
|
14
105
|
name: EXIT_PLAN_TOOL,
|
|
15
106
|
effect: "read",
|
|
16
|
-
description
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
plan: {
|
|
25
|
-
type: "string",
|
|
26
|
-
minLength: 1,
|
|
27
|
-
maxLength: PLAN_MAX_CHARS,
|
|
28
|
-
description: "The whole plan in Markdown",
|
|
29
|
-
},
|
|
30
|
-
}, ["plan"]),
|
|
107
|
+
get description() {
|
|
108
|
+
return offered()
|
|
109
|
+
? `${BASE_DESCRIPTION} Optional \`diagrams\`: up to ${settings().max} small Mermaid diagrams (see your instructions).`
|
|
110
|
+
: BASE_DESCRIPTION;
|
|
111
|
+
},
|
|
112
|
+
get inputSchema() {
|
|
113
|
+
return registering || offered() ? withDiagrams : plain;
|
|
114
|
+
},
|
|
31
115
|
async execute(input, context) {
|
|
32
116
|
const plan = String(input.plan ?? "").trim();
|
|
33
117
|
if (!plan)
|
|
34
118
|
throw new Error("exit_plan needs the whole plan in the `plan` field");
|
|
35
119
|
const sessionId = context.session;
|
|
36
120
|
const title = planTitle(plan, input.title);
|
|
37
|
-
const hash = planHash(plan);
|
|
38
121
|
const planId = crypto.randomUUID();
|
|
39
122
|
const reviewable = !!sessionId && deps.ui.interactive() && !deps.readOnly;
|
|
40
123
|
const revision = sessionId ? nextPlanRevision(deps.store, sessionId) : 1;
|
|
124
|
+
const previous = sessionId
|
|
125
|
+
? currentPlan(deps.store, sessionId)?.diagrams
|
|
126
|
+
: undefined;
|
|
127
|
+
// Diagrams: validated without rendering; a bad one is dropped and the plan still goes out.
|
|
128
|
+
const current = settings();
|
|
129
|
+
const outcome = { accepted: [], dropped: [], removed: [], involved: false };
|
|
130
|
+
if (input.diagrams !== undefined) {
|
|
131
|
+
outcome.involved = true;
|
|
132
|
+
if (!current.enabled)
|
|
133
|
+
outcome.dropped.push({
|
|
134
|
+
id: "diagrams",
|
|
135
|
+
reason: "diagrams are turned off (plan.diagrams); the plan was published without them",
|
|
136
|
+
});
|
|
137
|
+
else {
|
|
138
|
+
const validated = validateDiagrams(input.diagrams, {
|
|
139
|
+
max: current.max,
|
|
140
|
+
maxBytes: DIAGRAM_MAX_BYTES,
|
|
141
|
+
maxNodes: DIAGRAM_MAX_NODES,
|
|
142
|
+
});
|
|
143
|
+
outcome.accepted = validated.accepted;
|
|
144
|
+
outcome.dropped = validated.dropped;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const preManifest = buildPlanManifest({
|
|
148
|
+
planId,
|
|
149
|
+
revision,
|
|
150
|
+
title,
|
|
151
|
+
hash: "",
|
|
152
|
+
markdown: plan,
|
|
153
|
+
diagrams: outcome.accepted,
|
|
154
|
+
...(previous ? { previous } : {}),
|
|
155
|
+
});
|
|
156
|
+
outcome.removed = preManifest.removed;
|
|
157
|
+
if (outcome.removed.length)
|
|
158
|
+
outcome.involved = true;
|
|
159
|
+
const hash = planHash(plan, preManifest.diagrams.map((d) => ({ id: d.id, hash: d.hash })));
|
|
160
|
+
const manifest = { ...preManifest, hash };
|
|
161
|
+
const diagrams = manifest.diagrams.map((entry, index) => ({
|
|
162
|
+
id: entry.id,
|
|
163
|
+
title: entry.title,
|
|
164
|
+
explanation: entry.explanation,
|
|
165
|
+
...(entry.section ? { section: entry.section } : {}),
|
|
166
|
+
type: entry.type,
|
|
167
|
+
syntax: entry.syntax,
|
|
168
|
+
status: entry.status,
|
|
169
|
+
mermaid: outcome.accepted[index]?.mermaid ?? "",
|
|
170
|
+
}));
|
|
41
171
|
// The plan is saved first, so the review (and every later turn) can point at it.
|
|
42
172
|
let artifact;
|
|
43
173
|
let publishNote = "";
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
174
|
+
const artifactTitle = revision > 1 ? `${title} (revision ${revision})` : title;
|
|
175
|
+
const publisher = context.artifacts;
|
|
176
|
+
if (publisher) {
|
|
177
|
+
// A folder (plan.md + plan.json + diagrams) when diagrams are involved, else plan.md alone.
|
|
178
|
+
if (outcome.accepted.length || outcome.removed.length)
|
|
179
|
+
try {
|
|
180
|
+
artifact = await publishPlanFolder(publisher, [
|
|
181
|
+
{ path: "plan.md", text: `${plan}\n` },
|
|
182
|
+
{ path: "plan.json", text: `${JSON.stringify(manifest, null, 2)}\n` },
|
|
183
|
+
...outcome.accepted.map((d) => ({
|
|
184
|
+
path: `diagrams/${d.id}.mmd`,
|
|
185
|
+
text: `${d.mermaid}\n`,
|
|
186
|
+
})),
|
|
187
|
+
], artifactTitle);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
publishNote = ` The diagrams could not be saved with the plan: ${error instanceof Error ? error.message : String(error)}.`;
|
|
191
|
+
}
|
|
192
|
+
if (!artifact)
|
|
193
|
+
try {
|
|
194
|
+
artifact = await publisher.publishText({
|
|
195
|
+
fileName: "plan.md",
|
|
196
|
+
title: artifactTitle,
|
|
197
|
+
text: `${plan}\n`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
publishNote += ` The plan could not be saved as an artifact: ${error instanceof Error ? error.message : String(error)}.`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
55
204
|
const record = {
|
|
56
205
|
planId,
|
|
57
206
|
revision,
|
|
@@ -59,12 +208,18 @@ export function registerExitPlan(registry, deps) {
|
|
|
59
208
|
title,
|
|
60
209
|
...(context.callId ? { callId: context.callId } : {}),
|
|
61
210
|
...(artifact ? { artifactId: artifact.id } : {}),
|
|
211
|
+
...(manifest.diagrams.length
|
|
212
|
+
? {
|
|
213
|
+
diagrams: manifest.diagrams.map((d) => ({ id: d.id, title: d.title, hash: d.hash })),
|
|
214
|
+
}
|
|
215
|
+
: {}),
|
|
62
216
|
};
|
|
217
|
+
const report = diagramReport(outcome);
|
|
63
218
|
if (!reviewable) {
|
|
64
219
|
// Nobody to ask: keep the revision count honest and tell the model what to do instead.
|
|
65
220
|
if (sessionId)
|
|
66
221
|
proposePlan(deps.store, sessionId, { ...record, status: "skipped" });
|
|
67
|
-
return withArtifact(textResult(answer("unavailable", `No plan review is possible in this session (${deps.readOnly ? "it is read-only" : "it is not interactive"}), so nothing will be implemented from here. Give the complete plan in Markdown as your final reply and stop.${publishNote}`, artifact ? { artifactId: artifact.id } : {})), artifact);
|
|
222
|
+
return withArtifact(textResult(answer("unavailable", `No plan review is possible in this session (${deps.readOnly ? "it is read-only" : "it is not interactive"}), so nothing will be implemented from here. Give the complete plan in Markdown as your final reply and stop.${publishNote}`, { ...(artifact ? { artifactId: artifact.id } : {}), ...report })), artifact);
|
|
68
223
|
}
|
|
69
224
|
proposePlan(deps.store, sessionId, record);
|
|
70
225
|
context.emitEvent?.("plan_proposed", {
|
|
@@ -82,6 +237,7 @@ export function registerExitPlan(registry, deps) {
|
|
|
82
237
|
title,
|
|
83
238
|
markdown: plan,
|
|
84
239
|
...(artifact ? { artifact } : {}),
|
|
240
|
+
...(diagrams.length ? { diagrams } : {}),
|
|
85
241
|
};
|
|
86
242
|
const result = await deps.ui.askQuestions({
|
|
87
243
|
questions: [
|
|
@@ -114,17 +270,27 @@ export function registerExitPlan(registry, deps) {
|
|
|
114
270
|
hash,
|
|
115
271
|
decision,
|
|
116
272
|
});
|
|
117
|
-
const base = {
|
|
273
|
+
const base = {
|
|
274
|
+
planId,
|
|
275
|
+
revision,
|
|
276
|
+
...(artifact ? { artifactId: artifact.id } : {}),
|
|
277
|
+
...report,
|
|
278
|
+
};
|
|
118
279
|
const body = decision === "approve"
|
|
119
280
|
? answer("approved", "The user approved the plan. The build agent will implement exactly this plan in the " +
|
|
120
281
|
"next turn. Do not implement anything yourself: reply with one short sentence and " +
|
|
121
282
|
"call no more tools.", base)
|
|
122
283
|
: decision === "context"
|
|
123
284
|
? answer("feedback", "The user added context instead of deciding. Revise the plan to account for it and " +
|
|
124
|
-
"call exit_plan again with the complete updated plan
|
|
285
|
+
"call exit_plan again with the complete updated plan" +
|
|
286
|
+
(outcome.involved
|
|
287
|
+
? ", with every diagram that still applies updated (and any dropped one fixed)."
|
|
288
|
+
: "."), { ...base, feedback: text })
|
|
125
289
|
: answer("skipped", "The user chose to skip for now. Stay in plan mode and do not implement anything. " +
|
|
126
290
|
"Do not call exit_plan again unless the user asks; end your turn with one short sentence.", base);
|
|
127
291
|
return withArtifact(textResult(body), artifact);
|
|
128
292
|
},
|
|
129
|
-
}
|
|
293
|
+
};
|
|
294
|
+
registry.register(tool);
|
|
295
|
+
registering = false;
|
|
130
296
|
}
|