@nanobpm/nano-workforce 0.60.0 → 0.61.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/CHANGELOG.md +7 -0
- package/README.md +14 -5
- package/SPEC.md +56 -25
- package/app/mergeRebaseArm.test.ts +2 -2
- package/app/plan.ts +6 -5
- package/app/retro.ts +1 -1
- package/app/service.ts +5 -4
- package/nano.app.json +2 -4
- package/package.json +1 -1
- package/resources/agent-guide.md +11 -3
- package/resources/processes/convergence-loop.bpmn +3 -3
- package/resources/processes/feature.bpmn +3 -3
- package/resources/processes/merge-loop.bpmn +6 -6
- package/resources/processes/plan-fanout.bpmn +12 -12
- package/resources/processes/retro.bpmn +3 -3
- package/scripts/check-agent-prompts.test.ts +141 -35
- package/scripts/check-agent-prompts.ts +145 -93
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.61.0](https://github.com/nanobpm/nano-workforce/compare/v0.60.0...v0.61.0) (2026-08-13)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* prompts as linked resources (bindingType: latest) — live mid-epic updates ([#198](https://github.com/nanobpm/nano-workforce/issues/198)) ([#203](https://github.com/nanobpm/nano-workforce/issues/203)) ([24a83e0](https://github.com/nanobpm/nano-workforce/commit/24a83e01f7e7b4f6e5296ce90c63fa44105aa058)), closes [#169](https://github.com/nanobpm/nano-workforce/issues/169) [#169](https://github.com/nanobpm/nano-workforce/issues/169)
|
|
7
|
+
|
|
1
8
|
# [0.60.0](https://github.com/nanobpm/nano-workforce/compare/v0.59.0...v0.60.0) (2026-08-13)
|
|
2
9
|
|
|
3
10
|
|
package/README.md
CHANGED
|
@@ -156,7 +156,8 @@ capability):
|
|
|
156
156
|
|
|
157
157
|
- `--command 'copilot -p - --allow-all-tools'` starts the Copilot CLI reading its
|
|
158
158
|
prompt from **stdin** (`-p -`). The harness pipes the whole job JSON (prompt +
|
|
159
|
-
`job.variables`) to stdin; the relevant `prompts/*.md`
|
|
159
|
+
`job.variables`) to stdin; the relevant `prompts/*.md` resource (linked into the task
|
|
160
|
+
and fetched by the harness at activation) tells the agent how
|
|
160
161
|
to read it and where to write its result.
|
|
161
162
|
- **`--allow-all-tools` is essential** for an unattended worker — without it Copilot
|
|
162
163
|
pauses for permission before each tool call and the job stalls.
|
|
@@ -280,10 +281,18 @@ The `senior:*` tasks are serviced by **external** workers — they are **not** i
|
|
|
280
281
|
manifest `workers[]`. Point a `c8ctl nano work` daemon (or any Zeebe-style worker) at
|
|
281
282
|
the task types; each job carries its variables (e.g. `senior:pr-review` gets `{prUrl,
|
|
282
283
|
repo, prNumber, round, answer?}` and returns `{status, summary, question?}`). An
|
|
283
|
-
agent's base prompt is **not** in the job payload — it is delivered
|
|
284
|
-
**
|
|
285
|
-
|
|
286
|
-
|
|
284
|
+
agent's base prompt is **not** in the job payload — it is delivered as a
|
|
285
|
+
**linked resource** (likewise `plan.md`, `feature.md`, `fix-ci.md`, …):
|
|
286
|
+
|
|
287
|
+
```xml
|
|
288
|
+
<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
that the engine resolves to the latest deployed `prompts/*.md` at job
|
|
292
|
+
activation (the prompts deploy as generic resources via a `models` glob in
|
|
293
|
+
`nano.app.json`); per-instance context (e.g. a human's escalation answer) is appended
|
|
294
|
+
by the harness. Because the binding is `latest`, redeploying one `prompts/*.md` updates
|
|
295
|
+
the prompt for the next activation in a **running** epic — see SPEC §9.
|
|
287
296
|
|
|
288
297
|
---
|
|
289
298
|
|
package/SPEC.md
CHANGED
|
@@ -86,11 +86,11 @@ known at submit time, carried as a process variable and stored on the DB row.
|
|
|
86
86
|
│
|
|
87
87
|
▼
|
|
88
88
|
[Register PR] (script/handler) → insert DB row; round = 1
|
|
89
|
-
│ (base prompt delivered via the
|
|
90
|
-
│
|
|
89
|
+
│ (base prompt delivered via the review-round.md
|
|
90
|
+
│ linked resource, not a process variable)
|
|
91
91
|
▼
|
|
92
92
|
┌──▶ [Review round] (service task, taskType: senior:pr-review)
|
|
93
|
-
│ in : prUrl, repo, prNumber, round, answer? (prompt via
|
|
93
|
+
│ in : prUrl, repo, prNumber, round, answer? (prompt via linked resource)
|
|
94
94
|
│ out: status, summary, question?
|
|
95
95
|
│ │
|
|
96
96
|
│ ▼
|
|
@@ -153,10 +153,11 @@ Notes:
|
|
|
153
153
|
| `round` | int | 1-based round counter |
|
|
154
154
|
| `answer` | string? | present only when resuming from an escalation |
|
|
155
155
|
|
|
156
|
-
The base instructions are **not** a job variable: they are delivered as a
|
|
157
|
-
**
|
|
158
|
-
|
|
159
|
-
`prompts/review-round.md` at
|
|
156
|
+
The base instructions are **not** a job variable: they are delivered as a
|
|
157
|
+
**linked resource** on the `senior:pr-review` task —
|
|
158
|
+
`<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>`,
|
|
159
|
+
which the engine resolves to the latest deployed `prompts/review-round.md` at job
|
|
160
|
+
activation.
|
|
160
161
|
|
|
161
162
|
**Output** (job result variables):
|
|
162
163
|
| var | type | notes |
|
|
@@ -292,24 +293,54 @@ tracked row to `abandoned` via the `instanceTracking` `onTerminated.set` patch.
|
|
|
292
293
|
`deno task purge` wipes and re-migrates the app db (used
|
|
293
294
|
when the engine data is purged, to keep app state and engine state consistent).
|
|
294
295
|
|
|
295
|
-
## 9. Prompt delivery —
|
|
296
|
+
## 9. Prompt delivery — linked resources (`bindingType: latest`)
|
|
296
297
|
|
|
297
|
-
Each agent task's base prompt lives **only** in its `prompts/*.md` side-car
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
assets or carries them as process variables.
|
|
298
|
+
Each agent task's base prompt lives **only** in its `prompts/*.md` side-car, deployed
|
|
299
|
+
as a **generic resource** and **linked** — not baked — into the model (issue #169).
|
|
300
|
+
`nano.app.json` lists `prompts/*.md` in a `models` deploy glob, so `@nanobpm/urban`
|
|
301
|
+
deploys each file as an `application/octet-stream` resource whose deployed **name is
|
|
302
|
+
the file's basename** (`prompts/review-round.md` → resource `review-round.md`). Each
|
|
303
|
+
agent service task links it:
|
|
304
304
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
305
|
+
```xml
|
|
306
|
+
<zeebe:linkedResources>
|
|
307
|
+
<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt" />
|
|
308
|
+
</zeebe:linkedResources>
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
At **job activation** the engine resolves the *latest deployed* key for that
|
|
312
|
+
`resourceId` and hands the content to the harness in the `linkedResources` activation
|
|
313
|
+
header; the harness fetches by key and uses it as the base prompt. Because the binding
|
|
314
|
+
is `latest`, **redeploying a single `prompts/*.md` changes the prompt for the next task
|
|
315
|
+
activation in a running epic** — no process redeploy, no in-flight epic restart. This
|
|
316
|
+
is the live-prompt debugging loop: edit one Markdown file, `urban deploy` (or restart
|
|
317
|
+
the app, which deploys on boot), and the next agent job of that type picks it up.
|
|
318
|
+
|
|
319
|
+
> **Latest-for-now, audited.** The engine currently keeps only the latest version per
|
|
320
|
+
> `resourceId` (`deployment`/`versionTag` bindings degrade to latest — no pinning yet).
|
|
321
|
+
> We accept `latest` (ideal for active debugging) and rely on the harness recording the
|
|
322
|
+
> resolved `resourceKey` per job for "which prompt did this run use?". True
|
|
323
|
+
> `deployment`-binding pinning for reproducible production epics is an engine follow-up,
|
|
324
|
+
> not part of #169.
|
|
325
|
+
|
|
326
|
+
> **The engine silently omits an unresolvable link.** A typo'd or undeployed
|
|
327
|
+
> `resourceId` is dropped from the activation header (no incident) — the agent would
|
|
328
|
+
> then run prompt-less. `scripts/check-agent-prompts.ts` (CI gate `check:prompts`)
|
|
329
|
+
> guards against this: every `linkName="prompt"` link's `resourceId` must match a
|
|
330
|
+
> prompt file the app actually deploys (a file in a `models` deploy glob), each linked
|
|
331
|
+
> prompt must be non-blank and teach the agent to emit a machine-readable result
|
|
332
|
+
> (`$AGENT_RESULT_FILE` / `::nano:result::`), and no task may still carry the retired
|
|
333
|
+
> baked `io.nanobpm.agentTask.task.prompt` header.
|
|
334
|
+
|
|
335
|
+
Per-instance dynamic context still rides **`appendPrompt`** (unchanged): an ioMapping
|
|
336
|
+
sets a job-local `appendPrompt` string (a plan's rejection findings, a feature task's
|
|
337
|
+
brief, the failing-check list) which the agent harness concatenates **verbatim** onto
|
|
338
|
+
the linked base — the model owns any separator, and a null/empty append leaves the base
|
|
309
339
|
untouched. Base prompts can't be composed in FEEL (they are quote-heavy, and XML
|
|
310
340
|
attribute escaping would corrupt a FEEL string literal), so composition happens via
|
|
311
|
-
this append seam rather than inline in FEEL. Requires `@nanobpm/urban`
|
|
312
|
-
|
|
341
|
+
this append seam rather than inline in FEEL. Requires an `@nanobpm/urban` deploy that
|
|
342
|
+
emits generic-resource deployments for `prompts/*.md` and a harness that consumes
|
|
343
|
+
`linkedResources` and fetches the resource by key.
|
|
313
344
|
|
|
314
345
|
## 10. Poller
|
|
315
346
|
|
|
@@ -365,7 +396,7 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
|
|
|
365
396
|
|
|
366
397
|
- **CI auto-fix** — a `blocked` verdict means a **required check failed**
|
|
367
398
|
(`classifyMergeability`). Rather than escalate immediately, the stage dispatches a
|
|
368
|
-
`senior:fix-ci` agent (base prompt via the `
|
|
399
|
+
`senior:fix-ci` agent (base prompt via the `fix-ci.md` linked resource; the failing
|
|
369
400
|
check names ride `appendPrompt`) to green the checks on the branch, then re-arms the
|
|
370
401
|
poller. It repeats while `ciFixRound < ciFixMax`
|
|
371
402
|
(`NANO_PR_MAX_CI_FIX_ROUNDS`, default 3; `0` disables). Only when the budget is
|
|
@@ -432,7 +463,7 @@ Start(issue) → plan → record-plan → implement (parallel MI) → record-res
|
|
|
432
463
|
```
|
|
433
464
|
|
|
434
465
|
- **`plan`** — service task, job type `senior:plan`. Its base prompt is delivered
|
|
435
|
-
via the `
|
|
466
|
+
via the `plan.md` linked resource (`bindingType: latest`); when a prior review
|
|
436
467
|
rejected the plan, the rejection findings ride `appendPrompt` (an ioMapping over
|
|
437
468
|
`planFindings`) rather than being concatenated in FEEL. The agent reads the issue
|
|
438
469
|
via `gh` and emits `tasks: [{ id, title, prompt }]`.
|
|
@@ -442,8 +473,8 @@ Start(issue) → plan → record-plan → implement (parallel MI) → record-res
|
|
|
442
473
|
iterates the canonical list.
|
|
443
474
|
- **`implement`** — service task, job type `senior:feature`, **parallel
|
|
444
475
|
multi-instance** over `=tasks` (`inputElement="task"`,
|
|
445
|
-
`outputCollection="results"`). Its base prompt is delivered via the `
|
|
446
|
-
|
|
476
|
+
`outputCollection="results"`). Its base prompt is delivered via the `feature.md`
|
|
477
|
+
linked resource (`bindingType: latest`); each child's per-task brief
|
|
447
478
|
(`"\n\n---\n\n" + task.prompt`) rides `appendPrompt` — an input mapping evaluated
|
|
448
479
|
**per child** (Zeebe parity: the inner activity keeps its own `zeebe:ioMapping`,
|
|
449
480
|
applied on each inner-instance activation with `task`/`loopCounter` bound). Each
|
|
@@ -62,9 +62,9 @@ test("rebase arm mirrors the fix-ci arm: budget gate → agent → result gate",
|
|
|
62
62
|
assert(hasFlow("gw-rebase", "merge-esc-conflict"), "gw-rebase → merge-esc-conflict (budget exhausted) missing");
|
|
63
63
|
assertStringIncludes(flat, "rebaseRound < rebaseMax");
|
|
64
64
|
|
|
65
|
-
// The agent is the senior:rebase fleet task, carrying its base prompt via the
|
|
65
|
+
// The agent is the senior:rebase fleet task, carrying its base prompt via the rebase.md linked resource.
|
|
66
66
|
assertStringIncludes(flat, 'type="senior:rebase"');
|
|
67
|
-
assertStringIncludes(flat, '
|
|
67
|
+
assertStringIncludes(flat, 'resourceId="rebase.md"');
|
|
68
68
|
|
|
69
69
|
// Agent → result gate; the round counter advances so the budget can actually be exhausted.
|
|
70
70
|
assert(hasFlow("rebase", "gw-rebase-result"), "rebase → gw-rebase-result missing");
|
package/app/plan.ts
CHANGED
|
@@ -31,11 +31,12 @@ export const ESCALATION_SLA_TIMEOUT = escalationSlaTimeout(
|
|
|
31
31
|
const now = () => new Date().toISOString();
|
|
32
32
|
|
|
33
33
|
// Agent prompts are no longer read by the host. The `senior:plan`, `senior:plan-review`, and
|
|
34
|
-
// `senior:feature` prompts are
|
|
35
|
-
// `
|
|
36
|
-
//
|
|
37
|
-
// rejection findings, a task's brief) rides
|
|
38
|
-
// the
|
|
34
|
+
// `senior:feature` prompts are generic resources (`prompts/plan.md` / `prompts/plan-review.md` /
|
|
35
|
+
// `prompts/feature.md`, deployed via a `models` glob in nano.app.json) linked into each task as
|
|
36
|
+
// `<zeebe:linkedResource … bindingType="latest" linkName="prompt"/>` and resolved by the engine at
|
|
37
|
+
// job activation. Per-instance dynamic context (a plan's rejection findings, a task's brief) rides
|
|
38
|
+
// `appendPrompt`, which the harness concatenates onto the linked base. The host only carries
|
|
39
|
+
// runtime identity + `planFindings`.
|
|
39
40
|
|
|
40
41
|
export interface Plan {
|
|
41
42
|
plan_key: string;
|
package/app/retro.ts
CHANGED
|
@@ -161,7 +161,7 @@ export async function gatherRetro(data: DataLayer, planKey: string): Promise<Ret
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/** Render the digest as the compact markdown brief handed to the retro agent (rides `appendPrompt`,
|
|
164
|
-
* concatenated after the base `
|
|
164
|
+
* concatenated after the base `retro.md` linked-resource prompt — so it owns its own leading separator). */
|
|
165
165
|
export function renderRetroBrief(d: RetroDigest): string {
|
|
166
166
|
const lines: string[] = [
|
|
167
167
|
"",
|
package/app/service.ts
CHANGED
|
@@ -85,10 +85,11 @@ export const MERGE_ADMIN = ["1", "true", "on", "yes"].includes(
|
|
|
85
85
|
(process.env.NANO_PR_MERGE_ADMIN ?? "0").trim().toLowerCase(),
|
|
86
86
|
);
|
|
87
87
|
|
|
88
|
-
// The `senior:pr-review` agent prompt is no longer read by the host: it is
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
// runtime PR identity + the
|
|
88
|
+
// The `senior:pr-review` agent prompt is no longer read by the host: it is a generic resource
|
|
89
|
+
// (`prompts/review-round.md`, deployed via a `models` glob in nano.app.json) linked into the task
|
|
90
|
+
// as `<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>`
|
|
91
|
+
// and resolved by the engine at job activation. The host only carries runtime PR identity + the
|
|
92
|
+
// round counter now.
|
|
92
93
|
|
|
93
94
|
const now = () => new Date().toISOString();
|
|
94
95
|
|
package/nano.app.json
CHANGED
|
@@ -6,13 +6,11 @@
|
|
|
6
6
|
"codename": "nano-workforce",
|
|
7
7
|
"models": {
|
|
8
8
|
"processes": [
|
|
9
|
-
"resources/processes/*.bpmn"
|
|
9
|
+
"resources/processes/*.bpmn",
|
|
10
|
+
"prompts/*.md"
|
|
10
11
|
],
|
|
11
12
|
"forms": [
|
|
12
13
|
"resources/forms/*.form"
|
|
13
|
-
],
|
|
14
|
-
"templates": [
|
|
15
|
-
"prompts/*.md"
|
|
16
14
|
]
|
|
17
15
|
},
|
|
18
16
|
"data": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/resources/agent-guide.md
CHANGED
|
@@ -309,9 +309,17 @@ both live in the source repo, not in the job payload.
|
|
|
309
309
|
sequence flows) for the element it is parked on (§5).
|
|
310
310
|
- **Prompts (agent base instructions):** `prompts/*.md` — `review-round.md`,
|
|
311
311
|
`plan.md`, `feature.md`, `fix-ci.md`, `rebase.md`, `trial-merge.md`, etc. An
|
|
312
|
-
agent's base prompt is **not** a job variable: it is delivered as a
|
|
313
|
-
**
|
|
314
|
-
|
|
312
|
+
agent's base prompt is **not** a job variable: it is delivered as a
|
|
313
|
+
**linked resource** (likewise `plan.md`, …):
|
|
314
|
+
|
|
315
|
+
```xml
|
|
316
|
+
<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
that the engine
|
|
320
|
+
resolves to the latest deployed prompt at job activation. Because the binding is
|
|
321
|
+
`latest`, redeploying one of these files updates the prompt mid-epic without a process
|
|
322
|
+
redeploy. If an agent misbehaves systematically, the prompt is the first thing
|
|
315
323
|
to inspect/fix.
|
|
316
324
|
- **Job contract:** `senior:pr-review` receives `{ prUrl, repo, prNumber, round,
|
|
317
325
|
answer? }` and must return a flat result `{ status, summary, question? }` with
|
|
@@ -67,9 +67,9 @@
|
|
|
67
67
|
<bpmn:serviceTask id="review-round" name="Review round (agent)">
|
|
68
68
|
<bpmn:extensionElements>
|
|
69
69
|
<zeebe:taskDefinition type="senior:pr-review" />
|
|
70
|
-
<zeebe:
|
|
71
|
-
<zeebe:
|
|
72
|
-
</zeebe:
|
|
70
|
+
<zeebe:linkedResources>
|
|
71
|
+
<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt" />
|
|
72
|
+
</zeebe:linkedResources>
|
|
73
73
|
<zeebe:properties>
|
|
74
74
|
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrReviewRoundIn" />
|
|
75
75
|
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="PrReviewRoundOut" />
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
<bpmn:serviceTask id="implement-task" name="Implement (agent)">
|
|
19
19
|
<bpmn:extensionElements>
|
|
20
20
|
<zeebe:taskDefinition type="senior:feature" />
|
|
21
|
-
<zeebe:
|
|
22
|
-
<zeebe:
|
|
23
|
-
</zeebe:
|
|
21
|
+
<zeebe:linkedResources>
|
|
22
|
+
<zeebe:linkedResource resourceId="feature.md" bindingType="latest" linkName="prompt" />
|
|
23
|
+
</zeebe:linkedResources>
|
|
24
24
|
<zeebe:ioMapping>
|
|
25
25
|
<zeebe:input source="=" --- " + task.prompt + (if (baseBranchBrief = null) then "" else baseBranchBrief)" target="appendPrompt" />
|
|
26
26
|
<zeebe:output source="=status" target="status" />
|
|
@@ -216,9 +216,9 @@
|
|
|
216
216
|
<bpmn:serviceTask id="fix-ci" name="Fix CI (agent)">
|
|
217
217
|
<bpmn:extensionElements>
|
|
218
218
|
<zeebe:taskDefinition type="senior:fix-ci" />
|
|
219
|
-
<zeebe:
|
|
220
|
-
<zeebe:
|
|
221
|
-
</zeebe:
|
|
219
|
+
<zeebe:linkedResources>
|
|
220
|
+
<zeebe:linkedResource resourceId="fix-ci.md" bindingType="latest" linkName="prompt" />
|
|
221
|
+
</zeebe:linkedResources>
|
|
222
222
|
<zeebe:properties>
|
|
223
223
|
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="FixCiIn" />
|
|
224
224
|
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="FixCiOut" />
|
|
@@ -246,9 +246,9 @@
|
|
|
246
246
|
<bpmn:serviceTask id="rebase" name="Rebase (agent)">
|
|
247
247
|
<bpmn:extensionElements>
|
|
248
248
|
<zeebe:taskDefinition type="senior:rebase" />
|
|
249
|
-
<zeebe:
|
|
250
|
-
<zeebe:
|
|
251
|
-
</zeebe:
|
|
249
|
+
<zeebe:linkedResources>
|
|
250
|
+
<zeebe:linkedResource resourceId="rebase.md" bindingType="latest" linkName="prompt" />
|
|
251
|
+
</zeebe:linkedResources>
|
|
252
252
|
<zeebe:properties>
|
|
253
253
|
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="RebaseIn" />
|
|
254
254
|
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="RebaseOut" />
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
<bpmn:serviceTask id="plan" name="Plan (agent)">
|
|
24
24
|
<bpmn:extensionElements>
|
|
25
25
|
<zeebe:taskDefinition type="senior:plan" />
|
|
26
|
-
<zeebe:
|
|
27
|
-
<zeebe:
|
|
28
|
-
</zeebe:
|
|
26
|
+
<zeebe:linkedResources>
|
|
27
|
+
<zeebe:linkedResource resourceId="plan.md" bindingType="latest" linkName="prompt" />
|
|
28
|
+
</zeebe:linkedResources>
|
|
29
29
|
<zeebe:ioMapping>
|
|
30
30
|
<zeebe:input source="=(if (planFindings = null or planFindings = "") then "" else " --- A prior review REJECTED your last plan. Address every point, then re-emit the full plan: " + planFindings)" target="appendPrompt" />
|
|
31
31
|
</zeebe:ioMapping>
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
<bpmn:serviceTask id="review-plan" name="Review plan (agent)">
|
|
47
47
|
<bpmn:extensionElements>
|
|
48
48
|
<zeebe:taskDefinition type="senior:plan-review" />
|
|
49
|
-
<zeebe:
|
|
50
|
-
<zeebe:
|
|
51
|
-
</zeebe:
|
|
49
|
+
<zeebe:linkedResources>
|
|
50
|
+
<zeebe:linkedResource resourceId="plan-review.md" bindingType="latest" linkName="prompt" />
|
|
51
|
+
</zeebe:linkedResources>
|
|
52
52
|
</bpmn:extensionElements>
|
|
53
53
|
<bpmn:incoming>f_toReviewPlan</bpmn:incoming>
|
|
54
54
|
<bpmn:outgoing>f_toRecordPlanReview</bpmn:outgoing>
|
|
@@ -121,9 +121,9 @@
|
|
|
121
121
|
<bpmn:serviceTask id="implement-task" name="Implement (agent)">
|
|
122
122
|
<bpmn:extensionElements>
|
|
123
123
|
<zeebe:taskDefinition type="senior:feature" />
|
|
124
|
-
<zeebe:
|
|
125
|
-
<zeebe:
|
|
126
|
-
</zeebe:
|
|
124
|
+
<zeebe:linkedResources>
|
|
125
|
+
<zeebe:linkedResource resourceId="feature.md" bindingType="latest" linkName="prompt" />
|
|
126
|
+
</zeebe:linkedResources>
|
|
127
127
|
<zeebe:ioMapping>
|
|
128
128
|
<zeebe:input source="=" --- " + task.prompt + (if (blackboardBrief = null) then "" else blackboardBrief) + (if (baseBranchBrief = null) then "" else baseBranchBrief)" target="appendPrompt" />
|
|
129
129
|
<zeebe:output source="=status" target="status" />
|
|
@@ -192,9 +192,9 @@
|
|
|
192
192
|
<bpmn:serviceTask id="trial-merge" name="Trial merge (agent)">
|
|
193
193
|
<bpmn:extensionElements>
|
|
194
194
|
<zeebe:taskDefinition type="senior:trial-merge" />
|
|
195
|
-
<zeebe:
|
|
196
|
-
<zeebe:
|
|
197
|
-
</zeebe:
|
|
195
|
+
<zeebe:linkedResources>
|
|
196
|
+
<zeebe:linkedResource resourceId="trial-merge.md" bindingType="latest" linkName="prompt" />
|
|
197
|
+
</zeebe:linkedResources>
|
|
198
198
|
<zeebe:ioMapping>
|
|
199
199
|
<zeebe:input source="=null" target="result" />
|
|
200
200
|
<zeebe:input source="=null" target="conflicts" />
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
<bpmn:serviceTask id="synthesize" name="Synthesize & promote (agent)">
|
|
15
15
|
<bpmn:extensionElements>
|
|
16
16
|
<zeebe:taskDefinition type="senior:retro" />
|
|
17
|
-
<zeebe:
|
|
18
|
-
<zeebe:
|
|
19
|
-
</zeebe:
|
|
17
|
+
<zeebe:linkedResources>
|
|
18
|
+
<zeebe:linkedResource resourceId="retro.md" bindingType="latest" linkName="prompt" />
|
|
19
|
+
</zeebe:linkedResources>
|
|
20
20
|
<zeebe:ioMapping>
|
|
21
21
|
<zeebe:input source="=retroDigest" target="appendPrompt" />
|
|
22
22
|
</zeebe:ioMapping>
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// Red/green coverage for the agent-prompt deploy guard (scripts/check-agent-prompts.ts).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// "
|
|
6
|
-
//
|
|
3
|
+
// Since #169 each agent's base prompt is a linked *resource*: `prompts/<token>.md` is deployed as a
|
|
4
|
+
// generic resource (a `models` deploy glob) and each service task links it with
|
|
5
|
+
// `<zeebe:linkedResource resourceId="<token>.md" bindingType="latest" linkName="prompt"/>`. The
|
|
6
|
+
// engine silently OMITS an unresolvable link — a typo'd or undeployed `resourceId` yields a blank
|
|
7
|
+
// base prompt at runtime (the prompt-less-agent root of the empty "(no question provided)"
|
|
8
|
+
// escalations, Magikcraft/nano-bpm #597/#599). These cases assert the guard fails on each broken
|
|
9
|
+
// shape and passes on a well-formed app.
|
|
7
10
|
import { test } from "node:test";
|
|
8
11
|
import { assert, assertEquals } from "#test-assert";
|
|
9
12
|
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
@@ -11,12 +14,18 @@ import { tmpdir } from "node:os";
|
|
|
11
14
|
import { dirname, join } from "node:path";
|
|
12
15
|
import { checkAgentPrompts } from "./check-agent-prompts.ts";
|
|
13
16
|
|
|
17
|
+
// A manifest that deploys BPMN and the prompt resources (the migrated shape: prompts are a deploy
|
|
18
|
+
// glob, not a `templates` substitution source).
|
|
14
19
|
const MANIFEST = JSON.stringify({
|
|
15
|
-
models: { processes: ["resources/processes/*.bpmn"
|
|
20
|
+
models: { processes: ["resources/processes/*.bpmn", "prompts/*.md"] },
|
|
16
21
|
});
|
|
17
22
|
|
|
18
|
-
function
|
|
19
|
-
return `<zeebe:
|
|
23
|
+
function link(resourceId: string, bindingType = "latest"): string {
|
|
24
|
+
return `<zeebe:linkedResource resourceId="${resourceId}" bindingType="${bindingType}" linkName="prompt" />`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function serviceTask(inner: string): string {
|
|
28
|
+
return `<bpmn:serviceTask id="t"><bpmn:extensionElements>${inner}</bpmn:extensionElements></bpmn:serviceTask>`;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
// Build a throwaway app tree and return its root. Each entry maps a repo-relative path to content.
|
|
@@ -30,10 +39,10 @@ function fixture(files: Record<string, string>): string {
|
|
|
30
39
|
return root;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
|
-
test("passes when every
|
|
34
|
-
const root =
|
|
42
|
+
test("passes when every prompt link resolves to a deployed, non-blank, result-emitting resource", () => {
|
|
43
|
+
const root = fixture({
|
|
35
44
|
"nano.app.json": MANIFEST,
|
|
36
|
-
"resources/processes/loop.bpmn":
|
|
45
|
+
"resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
|
|
37
46
|
"prompts/review-round.md": "# Round\nDo the thing, then write your result to `$AGENT_RESULT_FILE`.",
|
|
38
47
|
});
|
|
39
48
|
const res = checkAgentPrompts(root);
|
|
@@ -42,72 +51,169 @@ test("passes when every {{token}} resolves to a non-blank template that emits a
|
|
|
42
51
|
assertEquals(res.resolved, ["review-round"]);
|
|
43
52
|
});
|
|
44
53
|
|
|
45
|
-
test("fails when a
|
|
46
|
-
const root =
|
|
54
|
+
test("fails when a prompt link references a resourceId with no deployed file", () => {
|
|
55
|
+
const root = fixture({
|
|
47
56
|
"nano.app.json": MANIFEST,
|
|
48
|
-
"resources/processes/loop.bpmn":
|
|
49
|
-
"prompts/review-round.md": "# Round",
|
|
57
|
+
"resources/processes/loop.bpmn": serviceTask(link("does-not-exist.md")),
|
|
58
|
+
"prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
|
|
50
59
|
});
|
|
51
60
|
const res = checkAgentPrompts(root);
|
|
52
61
|
assert(!res.ok);
|
|
53
|
-
assert(res.errors.some((e) => e.includes("
|
|
62
|
+
assert(res.errors.some((e) => e.includes("does-not-exist.md") && e.includes("no deployed resource")));
|
|
54
63
|
});
|
|
55
64
|
|
|
56
|
-
test("fails when the
|
|
57
|
-
|
|
65
|
+
test("fails when the prompt exists on disk but is not wired into a deploy glob", () => {
|
|
66
|
+
// The classic migration mistake: the prompt is left in the retired `models.templates` (which is
|
|
67
|
+
// substituted, never deployed) instead of a deploy glob, so the engine never receives it.
|
|
68
|
+
const root = fixture({
|
|
69
|
+
"nano.app.json": JSON.stringify({
|
|
70
|
+
models: { processes: ["resources/processes/*.bpmn"], templates: ["prompts/*.md"] },
|
|
71
|
+
}),
|
|
72
|
+
"resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
|
|
73
|
+
"prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
|
|
74
|
+
});
|
|
75
|
+
const res = checkAgentPrompts(root);
|
|
76
|
+
assert(!res.ok);
|
|
77
|
+
assert(res.errors.some((e) => e.includes("review-round.md") && e.includes("no deployed resource")));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("fails when the linked prompt resource is blank (would run prompt-less)", () => {
|
|
81
|
+
const root = fixture({
|
|
58
82
|
"nano.app.json": MANIFEST,
|
|
59
|
-
"resources/processes/loop.bpmn":
|
|
83
|
+
"resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
|
|
60
84
|
"prompts/review-round.md": " \n \n",
|
|
61
85
|
});
|
|
62
86
|
const res = checkAgentPrompts(root);
|
|
63
87
|
assert(!res.ok);
|
|
64
|
-
assert(res.errors.some((e) => e.includes("empty") && e.includes("review-round")));
|
|
88
|
+
assert(res.errors.some((e) => e.includes("empty") && e.includes("review-round.md")));
|
|
65
89
|
});
|
|
66
90
|
|
|
67
|
-
test("fails when a
|
|
68
|
-
const root =
|
|
91
|
+
test("fails when a linkName=prompt link has an empty resourceId", () => {
|
|
92
|
+
const root = fixture({
|
|
69
93
|
"nano.app.json": MANIFEST,
|
|
70
|
-
"resources/processes/loop.bpmn":
|
|
71
|
-
"prompts/review-round.md": "# Round",
|
|
94
|
+
"resources/processes/loop.bpmn": serviceTask(link("")),
|
|
95
|
+
"prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
|
|
72
96
|
});
|
|
73
97
|
const res = checkAgentPrompts(root);
|
|
74
98
|
assert(!res.ok);
|
|
75
|
-
assert(res.errors.some((e) => e.includes("
|
|
99
|
+
assert(res.errors.some((e) => e.includes("empty resourceId")));
|
|
76
100
|
});
|
|
77
101
|
|
|
78
|
-
test("fails when
|
|
102
|
+
test("fails when the retired baked prompt header is still present", () => {
|
|
103
|
+
// The deploy no longer substitutes {{token}} templates, so a surviving baked header ships a
|
|
104
|
+
// literal placeholder as the prompt.
|
|
105
|
+
const root = fixture({
|
|
106
|
+
"nano.app.json": MANIFEST,
|
|
107
|
+
"resources/processes/loop.bpmn": serviceTask(
|
|
108
|
+
'<zeebe:taskHeaders><zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{review-round}}" /></zeebe:taskHeaders>',
|
|
109
|
+
),
|
|
110
|
+
"prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
|
|
111
|
+
});
|
|
112
|
+
const res = checkAgentPrompts(root);
|
|
113
|
+
assert(!res.ok);
|
|
114
|
+
assert(res.errors.some((e) => e.includes("retired") && e.includes("linkedResource")));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("fails when the retired baked prompt header survives with reordered attributes", () => {
|
|
118
|
+
// XML attribute order is not significant: a retired header with `value` before `key` must still be
|
|
119
|
+
// caught. The guard used to anchor `key` immediately after `<zeebe:header`, so a reordered header
|
|
120
|
+
// would slip through and ship a literal placeholder as the prompt.
|
|
121
|
+
const root = fixture({
|
|
122
|
+
"nano.app.json": MANIFEST,
|
|
123
|
+
"resources/processes/loop.bpmn": serviceTask(
|
|
124
|
+
'<zeebe:taskHeaders><zeebe:header value="{{review-round}}" key="io.nanobpm.agentTask.task.prompt" /></zeebe:taskHeaders>',
|
|
125
|
+
),
|
|
126
|
+
"prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
|
|
127
|
+
});
|
|
128
|
+
const res = checkAgentPrompts(root);
|
|
129
|
+
assert(!res.ok);
|
|
130
|
+
assert(res.errors.some((e) => e.includes("retired") && e.includes("linkedResource")));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("fails when a linked prompt resource omits the machine-readable result mechanism", () => {
|
|
79
134
|
// A prompt wired as an agent's base prompt must tell it to write $AGENT_RESULT_FILE (or use the
|
|
80
135
|
// ::nano:result:: fallback). Without it the agent finishes with prose only, `status` comes back
|
|
81
136
|
// blank, and the status gateway escalates/stalls — the fix-ci/rebase gap behind #746's stuck merge.
|
|
82
|
-
const root =
|
|
137
|
+
const root = fixture({
|
|
83
138
|
"nano.app.json": MANIFEST,
|
|
84
|
-
"resources/processes/loop.bpmn":
|
|
139
|
+
"resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
|
|
85
140
|
"prompts/review-round.md": "# Round\nReturn status: converged. (but never says how to emit it)",
|
|
86
141
|
});
|
|
87
142
|
const res = checkAgentPrompts(root);
|
|
88
143
|
assert(!res.ok);
|
|
89
|
-
assert(res.errors.some((e) => e.includes("
|
|
144
|
+
assert(res.errors.some((e) => e.includes("review-round.md") && e.includes("AGENT_RESULT_FILE")));
|
|
90
145
|
});
|
|
91
146
|
|
|
92
|
-
test("passes when
|
|
93
|
-
const root =
|
|
147
|
+
test("passes when a linked prompt resource emits via the ::nano:result:: fallback", () => {
|
|
148
|
+
const root = fixture({
|
|
94
149
|
"nano.app.json": MANIFEST,
|
|
95
|
-
"resources/processes/loop.bpmn":
|
|
96
|
-
"prompts/review-round.md":
|
|
150
|
+
"resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
|
|
151
|
+
"prompts/review-round.md": '# Round\nEmit `::nano:result:: {"status":"converged"}` at the end.',
|
|
97
152
|
});
|
|
98
153
|
const res = checkAgentPrompts(root);
|
|
99
154
|
assertEquals(res.errors, []);
|
|
100
155
|
assert(res.ok);
|
|
101
156
|
});
|
|
102
157
|
|
|
103
|
-
test("
|
|
158
|
+
test("fails when a prompt link uses a bindingType other than latest", () => {
|
|
159
|
+
const root = fixture({
|
|
160
|
+
"nano.app.json": MANIFEST,
|
|
161
|
+
"resources/processes/loop.bpmn": serviceTask(link("review-round.md", "deployment")),
|
|
162
|
+
"prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
|
|
163
|
+
});
|
|
164
|
+
const res = checkAgentPrompts(root);
|
|
165
|
+
assert(!res.ok);
|
|
166
|
+
assert(res.errors.some((e) => e.includes("review-round.md") && e.includes('bindingType="deployment"')));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("fails when a prompt link omits bindingType entirely", () => {
|
|
170
|
+
const root = fixture({
|
|
171
|
+
"nano.app.json": MANIFEST,
|
|
172
|
+
"resources/processes/loop.bpmn": serviceTask(
|
|
173
|
+
'<zeebe:linkedResource resourceId="review-round.md" linkName="prompt" />',
|
|
174
|
+
),
|
|
175
|
+
"prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
|
|
176
|
+
});
|
|
177
|
+
const res = checkAgentPrompts(root);
|
|
178
|
+
assert(!res.ok);
|
|
179
|
+
assert(res.errors.some((e) => e.includes("review-round.md") && e.includes("(absent)")));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("fails when no prompt link is wired at all", () => {
|
|
183
|
+
const root = fixture({
|
|
184
|
+
"nano.app.json": MANIFEST,
|
|
185
|
+
"resources/processes/loop.bpmn": serviceTask('<zeebe:taskDefinition type="pr.noop" />'),
|
|
186
|
+
"prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
|
|
187
|
+
});
|
|
188
|
+
const res = checkAgentPrompts(root);
|
|
189
|
+
assert(!res.ok);
|
|
190
|
+
assert(res.errors.some((e) => e.includes("unwired")));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("fails when two deploy globs match files sharing a basename (ambiguous resource name)", () => {
|
|
194
|
+
// Two deployed files with the same basename would silently overwrite in the resourceId lookup, so
|
|
195
|
+
// a linkName="prompt" resourceId could resolve to the wrong file. The guard must fail fast.
|
|
196
|
+
const root = fixture({
|
|
197
|
+
"nano.app.json": JSON.stringify({
|
|
198
|
+
models: { processes: ["resources/processes/*.bpmn", "prompts/*.md", "extra/*.md"] },
|
|
199
|
+
}),
|
|
200
|
+
"resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
|
|
201
|
+
"prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
|
|
202
|
+
"extra/review-round.md": "# Duplicate\nWrite `$AGENT_RESULT_FILE`.",
|
|
203
|
+
});
|
|
204
|
+
const res = checkAgentPrompts(root);
|
|
205
|
+
assert(!res.ok);
|
|
206
|
+
assert(res.errors.some((e) => e.includes("duplicate deployed resource name") && e.includes("review-round.md")));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("checks the real repo: all committed agent prompts link to deployed resources", () => {
|
|
104
210
|
// The guard must be green against the actual app it protects — this is the case CI relies on.
|
|
105
211
|
const repoRoot = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
106
212
|
const res = checkAgentPrompts(repoRoot);
|
|
107
213
|
assertEquals(res.errors, []);
|
|
108
214
|
assert(res.ok);
|
|
109
|
-
// Every senior:* agent prompt
|
|
215
|
+
// Every senior:* agent prompt across the processes must resolve to a deployed prompt resource.
|
|
110
216
|
for (const t of ["review-round", "fix-ci", "plan", "plan-review", "feature", "trial-merge", "rebase", "retro"]) {
|
|
111
|
-
assert(res.resolved.includes(t), `expected
|
|
217
|
+
assert(res.resolved.includes(t), `expected prompt ${t} to resolve`);
|
|
112
218
|
}
|
|
113
219
|
});
|
|
@@ -1,33 +1,53 @@
|
|
|
1
|
-
// check-agent-prompts — deploy-safety gate for the
|
|
1
|
+
// check-agent-prompts — deploy-safety gate for the agent prompts, now authored as *linked
|
|
2
|
+
// resources* (issue #169) rather than baked `{{token}}` templates.
|
|
2
3
|
//
|
|
3
|
-
// Since #
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// `models.templates`) at deploy time; the harness (`c8ctl nano work`) does NO substitution and
|
|
7
|
-
// relays whatever header ships. So a token that resolves to a missing/blank template — or a blank
|
|
8
|
-
// agent-prompt header — runs the agent effectively prompt-less. For `senior:pr-review` that makes
|
|
9
|
-
// it improvise as a "reviewer" and escalate with no question (Magikcraft/nano-bpm #597/#599).
|
|
4
|
+
// Since #169 each agent's base prompt is a generic resource: `prompts/<token>.md` is deployed as
|
|
5
|
+
// an `application/octet-stream` resource (via a `models` deploy glob — see nano.app.json) and each
|
|
6
|
+
// agent service task links it at job-activation time:
|
|
10
7
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
8
|
+
// <zeebe:linkedResources>
|
|
9
|
+
// <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt" />
|
|
10
|
+
// </zeebe:linkedResources>
|
|
11
|
+
//
|
|
12
|
+
// The engine resolves the LATEST deployed key for that `resourceId` when the job activates and hands
|
|
13
|
+
// the content to the harness in the `linkedResources` activation header. Crucially, the engine
|
|
14
|
+
// *silently omits* an unresolvable link (a typo'd or undeployed `resourceId`) from the header — no
|
|
15
|
+
// incident — so a mistake yields a blank base prompt at runtime, exactly the prompt-less-agent
|
|
16
|
+
// failure that produced the empty "(no question provided)" escalations (Magikcraft/nano-bpm
|
|
17
|
+
// #597/#599). This guard turns that silent runtime failure into a hard build failure:
|
|
18
|
+
//
|
|
19
|
+
// 1. Every `linkName="prompt"` link's `resourceId` MUST match a prompt file that the app actually
|
|
20
|
+
// deploys (a file matched by a `models` deploy glob). This catches both a typo'd `resourceId`
|
|
21
|
+
// and a prompt that exists on disk but is not wired into a deploy glob (so never reaches the
|
|
22
|
+
// engine — the link would resolve to nothing).
|
|
23
|
+
// 2. Each linked prompt file must be non-blank and must teach the agent to emit a machine-readable
|
|
24
|
+
// result (`$AGENT_RESULT_FILE`, or the `::nano:result::` stdout fallback) — a prose-only agent
|
|
25
|
+
// leaves `status` blank and the status gateway escalates/stalls (the fix-ci/rebase gap behind
|
|
26
|
+
// Magikcraft/nano-bpm#746's stuck merge).
|
|
27
|
+
// 3. No service task may still carry the retired baked `io.nanobpm.agentTask.task.prompt` header:
|
|
28
|
+
// the deploy no longer substitutes `{{token}}` templates, so such a header would ship a literal
|
|
29
|
+
// `{{token}}` (or stale frozen text) as the prompt.
|
|
18
30
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
19
31
|
import { basename, join } from "node:path";
|
|
20
|
-
import { applyTemplates } from "@nanobpm/urban/runtime";
|
|
21
32
|
|
|
22
|
-
// The
|
|
23
|
-
//
|
|
24
|
-
const
|
|
33
|
+
// The retired header that used to carry an agent's baked base prompt. Its continued presence is a
|
|
34
|
+
// migration regression (the deploy no longer substitutes templates), so we flag it.
|
|
35
|
+
const RETIRED_PROMPT_HEADER = "io.nanobpm.agentTask.task.prompt";
|
|
36
|
+
|
|
37
|
+
// The `linkName` that designates a linked resource as an agent's base prompt. Other link names (if
|
|
38
|
+
// any are ever added) are not agent prompts and are ignored by this guard.
|
|
39
|
+
const PROMPT_LINK_NAME = "prompt";
|
|
40
|
+
|
|
41
|
+
// A prompt link MUST bind `latest` — this whole migration (#169) is about live mid-epic prompt
|
|
42
|
+
// updates, which only work when the engine resolves the latest deployed key at activation. A
|
|
43
|
+
// missing or different `bindingType` would silently pin/omit the prompt, so the guard fails it.
|
|
44
|
+
const PROMPT_BINDING_TYPE = "latest";
|
|
25
45
|
|
|
26
46
|
interface AppManifest {
|
|
27
47
|
models?: { processes?: string[]; decisions?: string[]; forms?: string[]; templates?: string[] };
|
|
28
48
|
}
|
|
29
49
|
|
|
30
|
-
//
|
|
50
|
+
// Only the escapable model types are XML we scan for `<zeebe:linkedResource>` links.
|
|
31
51
|
function contentTypeFor(path: string): string {
|
|
32
52
|
if (path.endsWith(".bpmn") || path.endsWith(".dmn")) return "text/xml";
|
|
33
53
|
if (path.endsWith(".form")) return "application/json";
|
|
@@ -48,53 +68,47 @@ function expandGlob(root: string, pattern: string): string[] {
|
|
|
48
68
|
.map((f) => join(dir, f));
|
|
49
69
|
}
|
|
50
70
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
for (const pattern of patterns) {
|
|
55
|
-
for (const rel of expandGlob(root, pattern)) {
|
|
56
|
-
const stem = basename(rel).replace(/\.[^.]+$/, "");
|
|
57
|
-
map[stem] = readFileSync(join(root, rel), "utf8");
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return map;
|
|
71
|
+
interface PromptLink {
|
|
72
|
+
resourceId: string;
|
|
73
|
+
bindingType: string | null;
|
|
61
74
|
}
|
|
62
75
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
function
|
|
66
|
-
const
|
|
76
|
+
// Extract the `<zeebe:linkedResource linkName="prompt" …>` links from a BPMN source. Each agent
|
|
77
|
+
// service task carries exactly one; a process may host several tasks.
|
|
78
|
+
function promptLinks(bpmn: string): PromptLink[] {
|
|
79
|
+
const links: PromptLink[] = [];
|
|
80
|
+
const re = /<zeebe:linkedResource\b([^>]*?)\/?>/g;
|
|
67
81
|
let m = re.exec(bpmn);
|
|
68
82
|
while (m !== null) {
|
|
69
|
-
|
|
83
|
+
const attrs = m[1];
|
|
84
|
+
const linkName = /\blinkName="([^"]*)"/.exec(attrs)?.[1];
|
|
85
|
+
if (linkName === PROMPT_LINK_NAME) {
|
|
86
|
+
const resourceId = /\bresourceId="([^"]*)"/.exec(attrs)?.[1] ?? "";
|
|
87
|
+
const bindingType = /\bbindingType="([^"]*)"/.exec(attrs)?.[1] ?? null;
|
|
88
|
+
links.push({ resourceId, bindingType });
|
|
89
|
+
}
|
|
70
90
|
m = re.exec(bpmn);
|
|
71
91
|
}
|
|
72
|
-
return
|
|
92
|
+
return links;
|
|
73
93
|
}
|
|
74
94
|
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
m = re.exec(bpmn);
|
|
88
|
-
}
|
|
89
|
-
return tokens;
|
|
95
|
+
// Escape a literal string for safe embedding in a RegExp — the header key contains dots that would
|
|
96
|
+
// otherwise act as wildcards and match unintended header keys.
|
|
97
|
+
function escapeRegExp(literal: string): string {
|
|
98
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Any surviving retired baked-prompt header — a migration regression. XML attribute order is not
|
|
102
|
+
// significant, so match the `key` attribute anywhere within the opening `<zeebe:header …>` tag (not
|
|
103
|
+
// only immediately after the element name) — otherwise a reordered header would bypass the guard.
|
|
104
|
+
function hasRetiredPromptHeader(bpmn: string): boolean {
|
|
105
|
+
return new RegExp(`<zeebe:header\\s[^>]*\\bkey="${escapeRegExp(RETIRED_PROMPT_HEADER)}"`).test(bpmn);
|
|
90
106
|
}
|
|
91
107
|
|
|
92
108
|
// A prompt that drives an agent must tell it how to return a machine-readable result — the
|
|
93
109
|
// `$AGENT_RESULT_FILE` write (or the `::nano:result::` stdout fallback). Without it the agent can
|
|
94
110
|
// finish with prose only, its `status` variable comes back empty, the status gateway falls through
|
|
95
|
-
// to its default escalation arm, and the run parks a human escalation / stalls the merge
|
|
96
|
-
// fix-ci/rebase gap behind Magikcraft/nano-bpm#746's stuck merge). Prose is never parsed, so this
|
|
97
|
-
// instruction is load-bearing, not documentation.
|
|
111
|
+
// to its default escalation arm, and the run parks a human escalation / stalls the merge.
|
|
98
112
|
function agentPromptEmitsResult(body: string): boolean {
|
|
99
113
|
return body.includes("AGENT_RESULT_FILE") || body.includes("::nano:result::");
|
|
100
114
|
}
|
|
@@ -102,14 +116,13 @@ function agentPromptEmitsResult(body: string): boolean {
|
|
|
102
116
|
export interface CheckResult {
|
|
103
117
|
ok: boolean;
|
|
104
118
|
errors: string[];
|
|
105
|
-
/**
|
|
119
|
+
/** prompt resource ids (file stems) successfully linked — surfaced for the CLI summary line. */
|
|
106
120
|
resolved: string[];
|
|
107
121
|
}
|
|
108
122
|
|
|
109
123
|
export function checkAgentPrompts(root: string): CheckResult {
|
|
110
124
|
const errors: string[] = [];
|
|
111
125
|
const resolved = new Set<string>();
|
|
112
|
-
const agentTokens = new Set<string>();
|
|
113
126
|
|
|
114
127
|
const manifestPath = join(root, "nano.app.json");
|
|
115
128
|
if (!existsSync(manifestPath)) {
|
|
@@ -118,57 +131,96 @@ export function checkAgentPrompts(root: string): CheckResult {
|
|
|
118
131
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
119
132
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AppManifest;
|
|
120
133
|
const models = manifest.models ?? {};
|
|
121
|
-
const templates = templateMap(root, models.templates ?? []);
|
|
122
|
-
|
|
123
|
-
// A declared-but-blank template substitutes to an empty prompt without being "unresolved" —
|
|
124
|
-
// catch it up front (urban would silently produce a blank prompt).
|
|
125
|
-
for (const [name, body] of Object.entries(templates)) {
|
|
126
|
-
if (body.trim() === "") errors.push(`template {{${name}}} is empty — it would substitute to a blank prompt`);
|
|
127
|
-
}
|
|
128
134
|
|
|
129
|
-
|
|
135
|
+
// The resources the app actually DEPLOYS: every file matched by a deploy glob (processes,
|
|
136
|
+
// decisions, forms). Its deployed resource name is the file's basename — the same string a
|
|
137
|
+
// `linkedResource resourceId` must reference. Building this from the deploy globs (not from the
|
|
138
|
+
// prompts/ directory) is what catches a prompt that exists on disk but is wired only into a
|
|
139
|
+
// non-deploying key (e.g. the retired `models.templates`), so it never reaches the engine.
|
|
140
|
+
const deployGlobs = [
|
|
130
141
|
...(models.processes ?? []),
|
|
131
142
|
...(models.decisions ?? []),
|
|
132
143
|
...(models.forms ?? []),
|
|
133
|
-
]
|
|
134
|
-
|
|
135
|
-
|
|
144
|
+
];
|
|
145
|
+
// Keyed by basename (the deployed resource name a `resourceId` references). Two deploy globs
|
|
146
|
+
// matching files with the same basename would silently overwrite here, so a `resourceId` lookup
|
|
147
|
+
// could resolve to the wrong file (or mask a misconfiguration). Fail fast on the collision so the
|
|
148
|
+
// lookup stays unambiguous.
|
|
149
|
+
const deployedFiles = new Map<string, string>();
|
|
150
|
+
for (const rel of deployGlobs.flatMap((p) => expandGlob(root, p))) {
|
|
151
|
+
const name = basename(rel);
|
|
152
|
+
const prior = deployedFiles.get(name);
|
|
153
|
+
if (prior != null && prior !== rel) {
|
|
154
|
+
errors.push(
|
|
155
|
+
`duplicate deployed resource name "${name}": both "${prior}" and "${rel}" deploy under the ` +
|
|
156
|
+
`same basename, so a linkName="prompt" resourceId="${name}" would resolve ambiguously — ` +
|
|
157
|
+
`rename one so deployed resource names stay unique`,
|
|
158
|
+
);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
deployedFiles.set(name, rel);
|
|
136
162
|
}
|
|
137
163
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
164
|
+
// The model files whose XML we scan for `<zeebe:linkedResource>` links.
|
|
165
|
+
const xmlModelFiles = deployGlobs
|
|
166
|
+
.flatMap((p) => expandGlob(root, p))
|
|
167
|
+
.filter((rel) => contentTypeFor(rel) === "text/xml");
|
|
168
|
+
if (xmlModelFiles.length === 0) {
|
|
169
|
+
errors.push(`no BPMN/DMN model files matched ${JSON.stringify(deployGlobs)}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let linkCount = 0;
|
|
173
|
+
for (const rel of xmlModelFiles) {
|
|
141
174
|
const content = readFileSync(join(root, rel), "utf8");
|
|
142
175
|
|
|
143
|
-
|
|
144
|
-
// leaves unresolved (deploy only warns, which we don't tolerate).
|
|
145
|
-
const applied = applyTemplates(content, contentType, templates);
|
|
146
|
-
for (const name of applied.unresolved) {
|
|
176
|
+
if (hasRetiredPromptHeader(content)) {
|
|
147
177
|
errors.push(
|
|
148
|
-
`${rel}:
|
|
178
|
+
`${rel}: a retired "${RETIRED_PROMPT_HEADER}" header is still present — migrate it to a ` +
|
|
179
|
+
`<zeebe:linkedResource … linkName="prompt"/> (the deploy no longer substitutes {{token}} templates)`,
|
|
149
180
|
);
|
|
150
181
|
}
|
|
151
|
-
for (const name of Object.keys(templates)) {
|
|
152
|
-
if (content.includes(`{{${name}}}`)) resolved.add(name);
|
|
153
|
-
}
|
|
154
182
|
|
|
155
|
-
|
|
156
|
-
|
|
183
|
+
for (const link of promptLinks(content)) {
|
|
184
|
+
linkCount++;
|
|
185
|
+
if (link.resourceId.trim() === "") {
|
|
186
|
+
errors.push(`${rel}: a linkName="prompt" linkedResource has an empty resourceId`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (link.bindingType !== PROMPT_BINDING_TYPE) {
|
|
190
|
+
errors.push(
|
|
191
|
+
`${rel}: linkName="prompt" resourceId="${link.resourceId}" has ` +
|
|
192
|
+
`bindingType=${link.bindingType == null ? "(absent)" : `"${link.bindingType}"`} — it must be ` +
|
|
193
|
+
`bindingType="${PROMPT_BINDING_TYPE}" so the engine resolves the latest deployed prompt at ` +
|
|
194
|
+
`activation (mid-epic prompt updates rely on it); any other value silently alters runtime ` +
|
|
195
|
+
`prompt resolution`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const deployedRel = deployedFiles.get(link.resourceId);
|
|
199
|
+
if (deployedRel == null) {
|
|
200
|
+
errors.push(
|
|
201
|
+
`${rel}: linkName="prompt" resourceId="${link.resourceId}" has no deployed resource — ` +
|
|
202
|
+
`no file matched by a models deploy glob has that name, so the engine would omit the ` +
|
|
203
|
+
`link and the agent would run prompt-less`,
|
|
204
|
+
);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const body = readFileSync(join(root, deployedRel), "utf8");
|
|
208
|
+
const stem = basename(deployedRel).replace(/\.[^.]+$/, "");
|
|
209
|
+
if (body.trim() === "") {
|
|
210
|
+
errors.push(`prompt resource "${link.resourceId}" is empty — the agent would run prompt-less`);
|
|
211
|
+
} else if (!agentPromptEmitsResult(body)) {
|
|
212
|
+
errors.push(
|
|
213
|
+
`prompt resource "${link.resourceId}" drives an agent but never tells it to write ` +
|
|
214
|
+
`$AGENT_RESULT_FILE (or the ::nano:result:: fallback) — the agent can finish with prose ` +
|
|
215
|
+
`only, leaving its status blank so the process escalates/stalls`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
resolved.add(stem);
|
|
157
219
|
}
|
|
158
|
-
for (const tok of agentPromptTokens(content)) agentTokens.add(tok);
|
|
159
220
|
}
|
|
160
221
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
for (const tok of [...agentTokens].sort()) {
|
|
164
|
-
const body = templates[tok];
|
|
165
|
-
if (body != null && body.trim() !== "" && !agentPromptEmitsResult(body)) {
|
|
166
|
-
errors.push(
|
|
167
|
-
`template {{${tok}}} drives an agent but never tells it to write $AGENT_RESULT_FILE ` +
|
|
168
|
-
`(or the ::nano:result:: fallback) — the agent can finish with prose only, leaving its ` +
|
|
169
|
-
`status blank so the process escalates/stalls`,
|
|
170
|
-
);
|
|
171
|
-
}
|
|
222
|
+
if (linkCount === 0 && errors.length === 0) {
|
|
223
|
+
errors.push("no linkName=\"prompt\" linkedResource found in any model — agent prompts are unwired");
|
|
172
224
|
}
|
|
173
225
|
|
|
174
226
|
return { ok: errors.length === 0, errors, resolved: [...resolved].sort() };
|
|
@@ -183,5 +235,5 @@ if (import.meta.main) {
|
|
|
183
235
|
for (const e of errors) console.error(` - ${e}`);
|
|
184
236
|
process.exit(1);
|
|
185
237
|
}
|
|
186
|
-
console.log(`✔ agent
|
|
238
|
+
console.log(`✔ agent prompts link to deployed resources (${resolved.length}: ${resolved.join(", ")})`);
|
|
187
239
|
}
|