@nanobpm/nano-workforce 0.160.0 → 0.161.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 +6 -0
- package/app/agentGuide.test.ts +115 -0
- package/app/agentGuide.ts +109 -0
- package/docs/mcp-runbook.md +16 -0
- package/e2e/addressable-guide.e2e.ts +89 -0
- package/openapi.yaml +154 -0
- package/operations/getAgentGuide.test.ts +100 -0
- package/operations/getAgentGuide.ts +92 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.161.0](https://github.com/nanobpm/nano-workforce/compare/v0.160.0...v0.161.0) (2026-08-29)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* addressable operator guide via getAgentGuide(section?) over MCP ([#615](https://github.com/nanobpm/nano-workforce/issues/615)) ([8d59f54](https://github.com/nanobpm/nano-workforce/commit/8d59f541e25478ff7a076debc053d988f1d79b29)), closes [#611](https://github.com/nanobpm/nano-workforce/issues/611)
|
|
6
|
+
|
|
1
7
|
## [0.160.0](https://github.com/nanobpm/nano-workforce/compare/v0.159.1...v0.160.0) (2026-08-29)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Tests for the addressable operator guide (epic #605 slice S5, issue #611): the stable section
|
|
2
|
+
// registry, the fence-aware markdown splitter, the table of contents, and per-section rendering.
|
|
3
|
+
// The drift guard here is the load-bearing test — it fails the build if the authored guide
|
|
4
|
+
// (`docs/agent-guide.md`) grows/loses a `## ` section without a matching `GUIDE_SECTIONS` entry, so
|
|
5
|
+
// the addressable surface can never silently diverge from the prose.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assert, assertEquals } from "#test-assert";
|
|
11
|
+
import {
|
|
12
|
+
GUIDE_SECTIONS,
|
|
13
|
+
guideToc,
|
|
14
|
+
renderAgentGuide,
|
|
15
|
+
renderGuideSection,
|
|
16
|
+
splitGuideSections,
|
|
17
|
+
} from "./agentGuide.ts";
|
|
18
|
+
|
|
19
|
+
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
20
|
+
const RAW_GUIDE = readFileSync(join(REPO_ROOT, "docs", "agent-guide.md"), "utf8");
|
|
21
|
+
|
|
22
|
+
test("DRIFT GUARD: every `## ` section in docs/agent-guide.md has exactly one registry entry", () => {
|
|
23
|
+
const { sections } = splitGuideSections(RAW_GUIDE);
|
|
24
|
+
assertEquals(
|
|
25
|
+
sections.length,
|
|
26
|
+
GUIDE_SECTIONS.length,
|
|
27
|
+
`docs/agent-guide.md has ${sections.length} top-level sections but GUIDE_SECTIONS lists ` +
|
|
28
|
+
`${GUIDE_SECTIONS.length}. Add/remove a { id, summary } entry so the addressable surface ` +
|
|
29
|
+
"matches the prose.",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("section ids are unique and non-empty; summaries are non-empty", () => {
|
|
34
|
+
const ids = new Set<string>();
|
|
35
|
+
for (const s of GUIDE_SECTIONS) {
|
|
36
|
+
assert(s.id.length > 0, "section id must be non-empty");
|
|
37
|
+
assert(!ids.has(s.id), `duplicate section id "${s.id}"`);
|
|
38
|
+
ids.add(s.id);
|
|
39
|
+
assert(s.summary.trim().length > 0, `section "${s.id}" needs a one-line summary`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("the fence-aware splitter ignores `## ` inside fenced code blocks", () => {
|
|
44
|
+
const raw = [
|
|
45
|
+
"# Title",
|
|
46
|
+
"",
|
|
47
|
+
"## 0. Real heading",
|
|
48
|
+
"body",
|
|
49
|
+
"```",
|
|
50
|
+
"## not a heading (inside a fence)",
|
|
51
|
+
"```",
|
|
52
|
+
"more body",
|
|
53
|
+
"## 1. Second heading",
|
|
54
|
+
"tail",
|
|
55
|
+
].join("\n");
|
|
56
|
+
const { sections } = splitGuideSections(raw);
|
|
57
|
+
assertEquals(sections.length, 2);
|
|
58
|
+
assertEquals(sections[0].title, "0. Real heading");
|
|
59
|
+
assert(sections[0].body.includes("## not a heading"), "fenced content stays in its section body");
|
|
60
|
+
assertEquals(sections[1].title, "1. Second heading");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("guideToc lists every registry id with a derived title and its summary", () => {
|
|
64
|
+
const toc = guideToc();
|
|
65
|
+
assertEquals(toc.length, GUIDE_SECTIONS.length);
|
|
66
|
+
for (let i = 0; i < toc.length; i++) {
|
|
67
|
+
assertEquals(toc[i].id, GUIDE_SECTIONS[i].id);
|
|
68
|
+
assertEquals(toc[i].summary, GUIDE_SECTIONS[i].summary);
|
|
69
|
+
assert(toc[i].title.length > 0, `toc[${i}] must carry the derived heading title`);
|
|
70
|
+
}
|
|
71
|
+
// The TOC is compact by construction — comfortably under any tool-result limit.
|
|
72
|
+
assert(JSON.stringify(toc).length < 4000, "the table of contents must stay small");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("delivery-graphs is addressable, smaller than the full guide, and base-keyed", () => {
|
|
76
|
+
const base = "https://example.test/app/api";
|
|
77
|
+
const section = renderGuideSection("delivery-graphs", base);
|
|
78
|
+
assert(section !== undefined, "delivery-graphs must resolve");
|
|
79
|
+
const full = renderAgentGuide(base);
|
|
80
|
+
assert(section!.length > 0);
|
|
81
|
+
assert(section!.length < full.length, "a single section must be smaller than the whole guide");
|
|
82
|
+
assert(section!.length < 30000, "the delivery-graphs section must fit a typical tool-result budget");
|
|
83
|
+
assert(!section!.includes("__BASE__"), "placeholders must be substituted in a section render");
|
|
84
|
+
assert(!section!.includes("__ENGINE__"), "engine placeholder must be substituted too");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a compact section is a small fraction of the whole guide", () => {
|
|
88
|
+
const base = "https://example.test/app/api";
|
|
89
|
+
const orient = renderGuideSection("orient", base)!;
|
|
90
|
+
const full = renderAgentGuide(base);
|
|
91
|
+
assert(orient.length < full.length / 10, "the orient section must be a small fraction of the guide");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("every registry id resolves to a non-empty section whose heading matches its title", () => {
|
|
95
|
+
const base = "https://example.test/app/api";
|
|
96
|
+
const toc = guideToc();
|
|
97
|
+
for (const { id, title } of toc) {
|
|
98
|
+
const md = renderGuideSection(id, base);
|
|
99
|
+
assert(md !== undefined, `section "${id}" must resolve`);
|
|
100
|
+
assert(md!.startsWith(`## ${title}`), `section "${id}" body must open with its heading`);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("an unknown section id resolves to undefined (the op turns that into a 400)", () => {
|
|
105
|
+
assertEquals(renderGuideSection("does-not-exist", "https://x/app/api"), undefined);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("no content regression: each section body is a verbatim slice of the raw guide", () => {
|
|
109
|
+
// Rendering keys examples to an instance; the UN-substituted section bodies must be exact
|
|
110
|
+
// substrings of the authored doc, so the addressable surface never rewrites guide content.
|
|
111
|
+
const { sections } = splitGuideSections(RAW_GUIDE);
|
|
112
|
+
for (const s of sections) {
|
|
113
|
+
assert(RAW_GUIDE.includes(s.body), `section "${s.title}" must be a verbatim slice of the guide`);
|
|
114
|
+
}
|
|
115
|
+
});
|
package/app/agentGuide.ts
CHANGED
|
@@ -59,8 +59,117 @@ export function resolveEngineBase(): string {
|
|
|
59
59
|
/**
|
|
60
60
|
* Render the guide for a given app control-API base (e.g. "https://host/app/api"). The engine base
|
|
61
61
|
* is resolved from the environment. Substitutes every `__BASE__`/`__ENGINE__` occurrence.
|
|
62
|
+
*
|
|
63
|
+
* This is the FULL guide, byte-for-byte the same content the non-MCP fallback doors serve
|
|
64
|
+
* (`GET /app/api/agent`, and — via the skill — `GET /app/api/agent/skill`). The addressable helpers
|
|
65
|
+
* below (`guideToc` / `renderGuideSection`) never touch this path, so those doors stay unchanged.
|
|
62
66
|
*/
|
|
63
67
|
export function renderAgentGuide(apiBase: string): string {
|
|
64
68
|
const base = apiBase.replace(/\/+$/, "");
|
|
65
69
|
return RAW_GUIDE.replaceAll("__BASE__", base).replaceAll("__ENGINE__", resolveEngineBase());
|
|
66
70
|
}
|
|
71
|
+
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
// Addressable guide (epic nano-workforce#605, slice S5, issue #611).
|
|
74
|
+
//
|
|
75
|
+
// The full guide is ~43KB — a single `getAgentInstructions` call can exceed an agent's tool-result
|
|
76
|
+
// limit, forcing it to persist the blob and carve out the section it wanted out-of-band. The guide
|
|
77
|
+
// is already well-structured as top-level `## N. Title` sections, so we make each one individually
|
|
78
|
+
// addressable: a compact table of contents (id + one-line summary), and per-section retrieval.
|
|
79
|
+
//
|
|
80
|
+
// STABLE IDS ARE THE CONTRACT. `GUIDE_SECTIONS` below is the single source of truth for the stable
|
|
81
|
+
// section ids and their summaries, in document order. The section *bodies* are derived by parsing
|
|
82
|
+
// the authored markdown (`docs/agent-guide.md`) at module load — never duplicated here — so prose
|
|
83
|
+
// edits never drift from the addressable surface. The parity is guarded by a drift test
|
|
84
|
+
// (`app/agentGuide.test.ts`): add or remove a `## ` section in the doc without updating this
|
|
85
|
+
// registry and the build fails.
|
|
86
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/** One addressable top-level guide section: a stable id and a one-line summary, in document order.
|
|
89
|
+
* The `id` is the durable handle agents pass to `getAgentGuide(section)`; keep it stable across
|
|
90
|
+
* prose edits (rename the heading freely, never the id). */
|
|
91
|
+
export interface GuideSectionMeta {
|
|
92
|
+
readonly id: string;
|
|
93
|
+
readonly summary: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The stable section registry — ONE per `## N.` heading in `docs/agent-guide.md`, in order. Adding
|
|
97
|
+
* a section to the doc requires adding an entry here (enforced by the drift test). */
|
|
98
|
+
export const GUIDE_SECTIONS: readonly GuideSectionMeta[] = [
|
|
99
|
+
{ id: "orient", summary: "Orient first: confirm which instance you're driving; see the live version and every PR in flight." },
|
|
100
|
+
{ id: "submit-pr", summary: "Submit a PR to the review-convergence loop — review-only vs. converge-and-merge, dependency barriers, round caps." },
|
|
101
|
+
{ id: "submit-epic", summary: "Hand a whole issue to the fleet: plan → implement → converge across coding agents, against a required base branch." },
|
|
102
|
+
{ id: "escalations", summary: "Find and answer parked human-in-the-loop escalations (PR-review, feature, plan-review, trial-merge) by user-task key." },
|
|
103
|
+
{ id: "lifecycle", summary: "The PR/epic lifecycle and status vocabulary, so you can reason about where a run currently sits." },
|
|
104
|
+
{ id: "debug", summary: "Debug: find the engine process instance behind a PR and inspect its jobs, incidents, and element-instances." },
|
|
105
|
+
{ id: "debug-models", summary: "Debug the deployed BPMN models and the agent prompts a running instance is actually using." },
|
|
106
|
+
{ id: "unstick", summary: "Unstick a wedged process — publish a correlating message, cancel, or otherwise recover a stalled instance." },
|
|
107
|
+
{ id: "raise-issue", summary: "Raise an issue or open a PR against the nano-workforce repository itself." },
|
|
108
|
+
{ id: "delivery-graphs", summary: "Author, preview, compile/stage and run an agent-authored delivery graph (ADR 0005): node/wait/connector vocabulary." },
|
|
109
|
+
] as const;
|
|
110
|
+
|
|
111
|
+
/** A parsed section: its stable id + summary (from the registry), the derived heading `title` (the
|
|
112
|
+
* markdown text after `## `, e.g. "9. Author and run a delivery graph (ADR 0005)"), and the raw
|
|
113
|
+
* section markdown `body` INCLUDING the heading line, with `__BASE__`/`__ENGINE__` un-substituted. */
|
|
114
|
+
export interface ParsedGuideSection extends GuideSectionMeta {
|
|
115
|
+
readonly title: string;
|
|
116
|
+
readonly body: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Split raw guide markdown into its top-level `## ` sections, fence-aware (a `## ` inside a fenced
|
|
120
|
+
* code block is content, not a heading). Everything before the first heading is the preamble.
|
|
121
|
+
* Pure/testable — takes the raw text so the drift test can feed it the on-disk doc directly. */
|
|
122
|
+
export function splitGuideSections(raw: string): { preamble: string; sections: { title: string; body: string }[] } {
|
|
123
|
+
const lines = raw.split("\n");
|
|
124
|
+
const sections: { title: string; lines: string[] }[] = [];
|
|
125
|
+
const preambleLines: string[] = [];
|
|
126
|
+
let inFence = false;
|
|
127
|
+
let current: { title: string; lines: string[] } | null = null;
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
if (/^(```|~~~)/.test(line.trim())) inFence = !inFence;
|
|
130
|
+
const heading = !inFence ? /^## (.+)$/.exec(line) : null;
|
|
131
|
+
if (heading) {
|
|
132
|
+
current = { title: heading[1].trim(), lines: [line] };
|
|
133
|
+
sections.push(current);
|
|
134
|
+
} else if (current) {
|
|
135
|
+
current.lines.push(line);
|
|
136
|
+
} else {
|
|
137
|
+
preambleLines.push(line);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
preamble: preambleLines.join("\n"),
|
|
142
|
+
sections: sections.map((s) => ({ title: s.title, body: s.lines.join("\n").replace(/\s+$/, "") })),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The parsed sections of THIS deployment's guide, zipped against the stable registry, computed once
|
|
147
|
+
* at module load. When the doc is unreadable (fallback guide, no `## ` headings) this is empty — the
|
|
148
|
+
* addressable surface degrades to "no sections", never throws. Length/registry parity is asserted by
|
|
149
|
+
* the drift test, not at runtime, so a mismatched deploy still serves what it can. */
|
|
150
|
+
const PARSED_SECTIONS: readonly ParsedGuideSection[] = (() => {
|
|
151
|
+
const { sections } = splitGuideSections(RAW_GUIDE);
|
|
152
|
+
const n = Math.min(sections.length, GUIDE_SECTIONS.length);
|
|
153
|
+
const out: ParsedGuideSection[] = [];
|
|
154
|
+
for (let i = 0; i < n; i++) {
|
|
155
|
+
out.push({ ...GUIDE_SECTIONS[i], title: sections[i].title, body: sections[i].body });
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
})();
|
|
159
|
+
|
|
160
|
+
/** The compact table of contents: every addressable section's id, derived heading title, and stable
|
|
161
|
+
* one-line summary, in document order. Small by construction — safe to return whole under any
|
|
162
|
+
* tool-result limit. Titles reflect THIS deployment's doc; summaries come from the registry. */
|
|
163
|
+
export function guideToc(): { id: string; title: string; summary: string }[] {
|
|
164
|
+
return PARSED_SECTIONS.map((s) => ({ id: s.id, title: s.title, summary: s.summary }));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Render a SINGLE section's markdown for a given app control-API base, `__BASE__`/`__ENGINE__`
|
|
168
|
+
* substituted exactly as {@link renderAgentGuide} does for the whole guide. Returns `undefined` for
|
|
169
|
+
* an unknown id (the caller turns that into a 400 listing the valid ids). */
|
|
170
|
+
export function renderGuideSection(id: string, apiBase: string): string | undefined {
|
|
171
|
+
const section = PARSED_SECTIONS.find((s) => s.id === id);
|
|
172
|
+
if (!section) return undefined;
|
|
173
|
+
const base = apiBase.replace(/\/+$/, "");
|
|
174
|
+
return section.body.replaceAll("__BASE__", base).replaceAll("__ENGINE__", resolveEngineBase());
|
|
175
|
+
}
|
package/docs/mcp-runbook.md
CHANGED
|
@@ -150,6 +150,22 @@ Agents without MCP are unchanged — resolve the instance, then
|
|
|
150
150
|
live guide. `GET /app/api/agent` and `GET /app/api/agent/skill` keep working exactly as
|
|
151
151
|
before.
|
|
152
152
|
|
|
153
|
+
### Addressable guide (MCP) — `getAgentGuide`
|
|
154
|
+
|
|
155
|
+
The full guide is ~43KB — a single `getAgentInstructions` call can overrun a tool-result
|
|
156
|
+
limit. Over MCP, prefer the **addressable** companion tool `getAgentGuide(section?)`
|
|
157
|
+
(`GET /app/api/agent/guide`):
|
|
158
|
+
|
|
159
|
+
- **No argument** → a compact **table of contents**: every stable section id
|
|
160
|
+
(`orient`, `submit-pr`, `submit-epic`, `escalations`, `lifecycle`, `debug`,
|
|
161
|
+
`debug-models`, `unstick`, `raise-issue`, `delivery-graphs`) with a one-line summary.
|
|
162
|
+
- **`section=<id>`** → **only** that section's markdown, small enough to fit a typical
|
|
163
|
+
limit. An unknown id is rejected with `issues[{path,message}]` listing the valid ids.
|
|
164
|
+
|
|
165
|
+
The section ids are the single source of truth in `app/agentGuide.ts` (`GUIDE_SECTIONS`),
|
|
166
|
+
derived-and-checked against the authored `docs/agent-guide.md`. The `getAgentInstructions`
|
|
167
|
+
/ `GET /agent` full-guide door is unchanged — the addressable tool is additive.
|
|
168
|
+
|
|
153
169
|
## 6. Regression harness — pin the MCP surface from nwf's side
|
|
154
170
|
|
|
155
171
|
The MCP projection layer (schema shape, argument encoding, session handshake) is
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Addressable operator-guide regression net (epic #605 slice S5, issue #611).
|
|
2
|
+
//
|
|
3
|
+
// Drives the app's REAL runtime-served MCP endpoint (`/app/mcp`, ADR 0067) via the reusable
|
|
4
|
+
// `e2e/support/mcp-harness.ts` module (slice S1, #607) — it does NOT re-implement the handshake.
|
|
5
|
+
// It PINS the addressable-guide contract from a client's point of view:
|
|
6
|
+
//
|
|
7
|
+
// • `getAgentGuide` is projected with a `$ref`-free, explicitly-typed input schema (S0 invariant);
|
|
8
|
+
// • no argument → a compact table of contents listing every stable section id + summary, small
|
|
9
|
+
// enough to fit a typical tool-result limit;
|
|
10
|
+
// • `section=<id>` → ONLY that section, far smaller than the whole guide (the defect this fixes:
|
|
11
|
+
// `getAgentInstructions` returns ~43KB that overran the limit);
|
|
12
|
+
// • an unknown id → a uniform `issues[{path,message}]` validation error;
|
|
13
|
+
// • the full-guide fallback door (`getAgentInstructions`) is UNCHANGED — still the whole guide.
|
|
14
|
+
//
|
|
15
|
+
// Run with `npm run e2e`.
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { after, before, describe, test } from "node:test";
|
|
18
|
+
import { assertSchemaSelfContained, bootMcpHarness, type McpHarness } from "./support/mcp-harness.ts";
|
|
19
|
+
|
|
20
|
+
describe("S5 — the addressable operator guide over MCP (#611)", () => {
|
|
21
|
+
let h: McpHarness;
|
|
22
|
+
before(async () => {
|
|
23
|
+
h = await bootMcpHarness();
|
|
24
|
+
});
|
|
25
|
+
after(async () => {
|
|
26
|
+
await h.stop();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("getAgentGuide is projected with a self-contained, explicitly-typed input schema", async () => {
|
|
30
|
+
const tools = await h.listTools();
|
|
31
|
+
const tool = tools.find((t) => t.name === "getAgentGuide");
|
|
32
|
+
assert(tool, "getAgentGuide must be projected onto the MCP surface");
|
|
33
|
+
assertSchemaSelfContained(tool!.inputSchema, "getAgentGuide");
|
|
34
|
+
const props = (tool!.inputSchema as { properties?: Record<string, unknown> }).properties ?? {};
|
|
35
|
+
const section = props.section as { type?: string } | undefined;
|
|
36
|
+
assert(section, "getAgentGuide must expose a `section` argument");
|
|
37
|
+
assert.equal(section!.type, "string", "`section` must be an explicitly-typed string");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("no argument returns a compact table of contents with every section id", async () => {
|
|
41
|
+
const res = await h.callTool("getAgentGuide", {});
|
|
42
|
+
assert(!res.isError, `getAgentGuide (TOC) must not error: ${res.text}`);
|
|
43
|
+
const body = res.json as { kind?: string; sections?: { id: string; title: string; summary: string }[] };
|
|
44
|
+
assert.equal(body.kind, "toc");
|
|
45
|
+
assert(Array.isArray(body.sections) && body.sections.length > 0, "the TOC must list sections");
|
|
46
|
+
const ids = body.sections!.map((s) => s.id);
|
|
47
|
+
for (const id of ["orient", "submit-pr", "submit-epic", "escalations", "delivery-graphs"]) {
|
|
48
|
+
assert(ids.includes(id), `the TOC must list "${id}"`);
|
|
49
|
+
}
|
|
50
|
+
for (const s of body.sections!) {
|
|
51
|
+
assert(s.title.length > 0 && s.summary.length > 0, `TOC entry "${s.id}" needs a title + summary`);
|
|
52
|
+
}
|
|
53
|
+
// The whole point: the TOC is tiny relative to the ~43KB monolith.
|
|
54
|
+
assert(res.text.length < 4000, "the TOC must fit comfortably under a tool-result limit");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("section=delivery-graphs returns ONLY that section, under a typical result budget", async () => {
|
|
58
|
+
const res = await h.callTool("getAgentGuide", { section: "delivery-graphs" });
|
|
59
|
+
assert(!res.isError, `getAgentGuide(delivery-graphs) must not error: ${res.text}`);
|
|
60
|
+
const body = res.json as { kind?: string; section?: { id: string; instructions: string } };
|
|
61
|
+
assert.equal(body.kind, "section");
|
|
62
|
+
assert.equal(body.section?.id, "delivery-graphs");
|
|
63
|
+
assert(body.section!.instructions.length > 200, "the section must carry real content");
|
|
64
|
+
assert(!body.section!.instructions.includes("__BASE__"), "placeholders must be substituted");
|
|
65
|
+
|
|
66
|
+
// It must be smaller than the full guide the fallback door still serves — a proper subset.
|
|
67
|
+
const full = await h.callTool("getAgentInstructions", {});
|
|
68
|
+
assert(res.text.length < full.text.length, "one section must be smaller than the whole guide");
|
|
69
|
+
assert(res.text.length < 30000, "one section must fit a typical tool-result budget in a single call");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("an unknown section id is rejected with issues[{path,message}]", async () => {
|
|
73
|
+
const res = await h.callTool("getAgentGuide", { section: "no-such-section" });
|
|
74
|
+
assert(res.isError, "an unknown section id must surface as a tool-level error");
|
|
75
|
+
const body = res.json as { issues?: { path: string; message: string }[] };
|
|
76
|
+
assert(Array.isArray(body.issues) && body.issues.length >= 1, "must answer with issues[]");
|
|
77
|
+
assert.equal(body.issues![0].path, "section");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("the full-guide fallback door is unchanged — still the whole guide", async () => {
|
|
81
|
+
const res = await h.callTool("getAgentInstructions", {});
|
|
82
|
+
assert(!res.isError, `getAgentInstructions must still answer: ${res.text}`);
|
|
83
|
+
const body = res.json as { instructions?: string };
|
|
84
|
+
assert(typeof body.instructions === "string", "getAgentInstructions still returns the full guide");
|
|
85
|
+
// The monolith still contains a section the addressable tool now carves out — no content regression.
|
|
86
|
+
assert(body.instructions!.includes("delivery graph"), "the full guide still contains every section");
|
|
87
|
+
assert(body.instructions!.length > 10000, "the full guide door is unshrunk");
|
|
88
|
+
});
|
|
89
|
+
});
|
package/openapi.yaml
CHANGED
|
@@ -35,6 +35,37 @@ components:
|
|
|
35
35
|
properties:
|
|
36
36
|
error:
|
|
37
37
|
type: string
|
|
38
|
+
ValidationError:
|
|
39
|
+
description: >-
|
|
40
|
+
The uniform validation-error contract: a human-readable `error` plus a path-qualified
|
|
41
|
+
`issues[]` naming each offending input. Returned by endpoints that reject a bad query/body
|
|
42
|
+
param (e.g. `GET /agent/guide` with an unknown `section` id).
|
|
43
|
+
type: object
|
|
44
|
+
additionalProperties: false
|
|
45
|
+
required:
|
|
46
|
+
- error
|
|
47
|
+
- issues
|
|
48
|
+
properties:
|
|
49
|
+
error:
|
|
50
|
+
type: string
|
|
51
|
+
description: A human-readable summary of the rejection.
|
|
52
|
+
issues:
|
|
53
|
+
type: array
|
|
54
|
+
minItems: 1
|
|
55
|
+
items:
|
|
56
|
+
type: object
|
|
57
|
+
additionalProperties: false
|
|
58
|
+
required:
|
|
59
|
+
- path
|
|
60
|
+
- message
|
|
61
|
+
properties:
|
|
62
|
+
path:
|
|
63
|
+
type: string
|
|
64
|
+
description: JSON-path pointer at the offending input (e.g. `section`).
|
|
65
|
+
message:
|
|
66
|
+
type: string
|
|
67
|
+
description: Human-actionable description of the failure.
|
|
68
|
+
description: The path-qualified `{ path, message }` failures (at least one).
|
|
38
69
|
ActivePr:
|
|
39
70
|
type: object
|
|
40
71
|
description: A tracked PR that is not in a terminal (converged/abandoned) state.
|
|
@@ -881,6 +912,82 @@ components:
|
|
|
881
912
|
skill:
|
|
882
913
|
type: string
|
|
883
914
|
description: The full operator skill (SKILL.md) as markdown, including its YAML frontmatter.
|
|
915
|
+
AgentGuideResponse:
|
|
916
|
+
type: object
|
|
917
|
+
description: The addressable operator-guide response served by `getAgentGuide`. Two shapes,
|
|
918
|
+
discriminated by `kind`. `kind:"toc"` (no `section` argument) carries `sections` — the compact
|
|
919
|
+
table of contents, one entry per stable section id. `kind:"section"` (a `section` id given)
|
|
920
|
+
carries `section` — that one section's markdown, with its examples keyed to this instance.
|
|
921
|
+
additionalProperties: false
|
|
922
|
+
required:
|
|
923
|
+
- kind
|
|
924
|
+
- appVersion
|
|
925
|
+
- generatedAt
|
|
926
|
+
- baseUrl
|
|
927
|
+
properties:
|
|
928
|
+
kind:
|
|
929
|
+
type: string
|
|
930
|
+
description: '"toc" when listing sections (no `section` argument); "section" when returning one.'
|
|
931
|
+
enum:
|
|
932
|
+
- toc
|
|
933
|
+
- section
|
|
934
|
+
appVersion:
|
|
935
|
+
type: string
|
|
936
|
+
nullable: true
|
|
937
|
+
description: The running app version this guide matches (null when unreadable).
|
|
938
|
+
generatedAt:
|
|
939
|
+
type: string
|
|
940
|
+
description: When this response was rendered (ISO-8601).
|
|
941
|
+
baseUrl:
|
|
942
|
+
type: string
|
|
943
|
+
description: The app control-API base the examples target (e.g. https://host/app/api).
|
|
944
|
+
engineBase:
|
|
945
|
+
type: string
|
|
946
|
+
description: The engine's Camunda-8 v2 REST base this app talks to (present on a section response).
|
|
947
|
+
sections:
|
|
948
|
+
type: array
|
|
949
|
+
description: The table of contents — present when `kind` is "toc". One entry per addressable section.
|
|
950
|
+
items:
|
|
951
|
+
type: object
|
|
952
|
+
additionalProperties: false
|
|
953
|
+
required:
|
|
954
|
+
- id
|
|
955
|
+
- title
|
|
956
|
+
- summary
|
|
957
|
+
properties:
|
|
958
|
+
id:
|
|
959
|
+
type: string
|
|
960
|
+
description: The stable section id to pass back as `getAgentGuide(section)`.
|
|
961
|
+
title:
|
|
962
|
+
type: string
|
|
963
|
+
description: The section's heading text (e.g. "9. Author and run a delivery graph (ADR 0005)").
|
|
964
|
+
summary:
|
|
965
|
+
type: string
|
|
966
|
+
description: A one-line summary of what the section covers.
|
|
967
|
+
section:
|
|
968
|
+
type: object
|
|
969
|
+
description: The requested section — present when `kind` is "section".
|
|
970
|
+
additionalProperties: false
|
|
971
|
+
required:
|
|
972
|
+
- id
|
|
973
|
+
- title
|
|
974
|
+
- format
|
|
975
|
+
- instructions
|
|
976
|
+
properties:
|
|
977
|
+
id:
|
|
978
|
+
type: string
|
|
979
|
+
description: The stable section id that was requested.
|
|
980
|
+
title:
|
|
981
|
+
type: string
|
|
982
|
+
description: The section's heading text.
|
|
983
|
+
format:
|
|
984
|
+
type: string
|
|
985
|
+
description: The `instructions` media format. Always "markdown".
|
|
986
|
+
enum:
|
|
987
|
+
- markdown
|
|
988
|
+
instructions:
|
|
989
|
+
type: string
|
|
990
|
+
description: The section's markdown, with example commands keyed to this instance.
|
|
884
991
|
SubmitResult:
|
|
885
992
|
type: object
|
|
886
993
|
required:
|
|
@@ -3080,6 +3187,53 @@ paths:
|
|
|
3080
3187
|
application/json:
|
|
3081
3188
|
schema:
|
|
3082
3189
|
$ref: "#/components/schemas/ErrorBody"
|
|
3190
|
+
/agent/guide:
|
|
3191
|
+
get:
|
|
3192
|
+
operationId: getAgentGuide
|
|
3193
|
+
summary: The operator guide, ADDRESSABLE — fetch one section instead of the whole ~43KB blob.
|
|
3194
|
+
Read-only, pure, idempotent. Call with NO `section` to get a compact table of contents (every
|
|
3195
|
+
stable section id + a one-line summary); call with `section` set to a TOC id (e.g.
|
|
3196
|
+
`delivery-graphs`) to get ONLY that section's markdown, small enough to fit a typical
|
|
3197
|
+
tool-result limit. This is the MCP-friendly companion to `getAgentInstructions`, which still
|
|
3198
|
+
returns the full guide unchanged for non-MCP callers. Typical flow — first call
|
|
3199
|
+
`getAgentGuide` (no arg) to see the ids, then `getAgentGuide(section=<id>)` for the one you
|
|
3200
|
+
need. An unknown id is rejected with `issues[{path,message}]` that lists the valid ids.
|
|
3201
|
+
security:
|
|
3202
|
+
- hookSecret: []
|
|
3203
|
+
- {}
|
|
3204
|
+
parameters:
|
|
3205
|
+
# Self-contained tool input (epic #605 S0 convention): a single inline `type: string` query
|
|
3206
|
+
# param — no `$ref`, an explicit type and example — so the projected MCP tool schema is
|
|
3207
|
+
# client-usable as-is (no request body; the inline-mcp-bodies generator does not apply here).
|
|
3208
|
+
- name: section
|
|
3209
|
+
in: query
|
|
3210
|
+
required: false
|
|
3211
|
+
schema:
|
|
3212
|
+
type: string
|
|
3213
|
+
example: delivery-graphs
|
|
3214
|
+
description: OPTIONAL stable section id (from the table of contents `getAgentGuide` returns
|
|
3215
|
+
with no argument), e.g. `orient`, `submit-pr`, `submit-epic`, `escalations`, `lifecycle`,
|
|
3216
|
+
`debug`, `debug-models`, `unstick`, `raise-issue`, `delivery-graphs`. Omit it to get the
|
|
3217
|
+
table of contents. An unknown id yields a 400 listing the valid ids.
|
|
3218
|
+
responses:
|
|
3219
|
+
"200":
|
|
3220
|
+
description: Either the table of contents (no `section`) or a single section's markdown.
|
|
3221
|
+
content:
|
|
3222
|
+
application/json:
|
|
3223
|
+
schema:
|
|
3224
|
+
$ref: "#/components/schemas/AgentGuideResponse"
|
|
3225
|
+
"400":
|
|
3226
|
+
description: The `section` id is not a known section; `issues` lists the valid ids.
|
|
3227
|
+
content:
|
|
3228
|
+
application/json:
|
|
3229
|
+
schema:
|
|
3230
|
+
$ref: "#/components/schemas/ValidationError"
|
|
3231
|
+
"401":
|
|
3232
|
+
description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
|
|
3233
|
+
content:
|
|
3234
|
+
application/json:
|
|
3235
|
+
schema:
|
|
3236
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
3083
3237
|
/actions/start/convergence-loop:
|
|
3084
3238
|
post:
|
|
3085
3239
|
operationId: startConvergenceLoop
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Tests for GET /app/api/agent/guide → operation `getAgentGuide` (epic #605 slice S5, issue #611):
|
|
2
|
+
// the addressable operator guide. No `section` → a compact table of contents; `section=<id>` → just
|
|
3
|
+
// that section; an unknown id → 400 with `issues[{path,message}]`. Mirrors the getAgentInstructions
|
|
4
|
+
// test's request shape and shared-secret guard pattern.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assert, assertEquals } from "#test-assert";
|
|
7
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
8
|
+
import { noopLog } from "../test/log.ts";
|
|
9
|
+
import { GUIDE_SECTIONS } from "../app/agentGuide.ts";
|
|
10
|
+
import handler from "./getAgentGuide.ts";
|
|
11
|
+
|
|
12
|
+
const app = { log: noopLog() } as any as AppApi;
|
|
13
|
+
|
|
14
|
+
function input(query: Record<string, string> = {}, headers: Record<string, string> = {}) {
|
|
15
|
+
return {
|
|
16
|
+
req: {
|
|
17
|
+
method: "GET",
|
|
18
|
+
path: "/app/api/agent/guide",
|
|
19
|
+
query: new URLSearchParams(query),
|
|
20
|
+
headers: new Headers(headers),
|
|
21
|
+
text: async () => "",
|
|
22
|
+
} as any,
|
|
23
|
+
params: {},
|
|
24
|
+
query,
|
|
25
|
+
body: undefined,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("no section → the table of contents, one entry per registry section", async () => {
|
|
30
|
+
const r = (await handler(input(), app)) as any;
|
|
31
|
+
assertEquals(r.status, 200);
|
|
32
|
+
assertEquals(r.body.kind, "toc");
|
|
33
|
+
assert(typeof r.body.baseUrl === "string" && r.body.baseUrl.length > 0);
|
|
34
|
+
assert(Array.isArray(r.body.sections));
|
|
35
|
+
assertEquals(r.body.sections.length, GUIDE_SECTIONS.length);
|
|
36
|
+
const ids = r.body.sections.map((s: any) => s.id);
|
|
37
|
+
for (const s of GUIDE_SECTIONS) assert(ids.includes(s.id), `TOC must list "${s.id}"`);
|
|
38
|
+
for (const s of r.body.sections) {
|
|
39
|
+
assert(typeof s.title === "string" && s.title.length > 0);
|
|
40
|
+
assert(typeof s.summary === "string" && s.summary.length > 0);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the TOC is far smaller than the full guide (fits a tool-result limit)", async () => {
|
|
45
|
+
const r = (await handler(input(), app)) as any;
|
|
46
|
+
assert(JSON.stringify(r.body).length < 4000, "the TOC response must stay compact");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("section=delivery-graphs → just that section's markdown, base-keyed", async () => {
|
|
50
|
+
const r = (await handler(input({ section: "delivery-graphs" }), app)) as any;
|
|
51
|
+
assertEquals(r.status, 200);
|
|
52
|
+
assertEquals(r.body.kind, "section");
|
|
53
|
+
assertEquals(r.body.section.id, "delivery-graphs");
|
|
54
|
+
assertEquals(r.body.section.format, "markdown");
|
|
55
|
+
assert(r.body.section.instructions.length > 200);
|
|
56
|
+
assert(!r.body.section.instructions.includes("__BASE__"), "placeholders must be substituted");
|
|
57
|
+
assert(typeof r.body.engineBase === "string" && r.body.engineBase.length > 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a section is much smaller than the whole guide", async () => {
|
|
61
|
+
const toc = (await handler(input(), app)) as any;
|
|
62
|
+
const section = (await handler(input({ section: "orient" }), app)) as any;
|
|
63
|
+
assert(
|
|
64
|
+
JSON.stringify(section.body).length < 30000,
|
|
65
|
+
"a single section must comfortably fit a typical tool-result limit",
|
|
66
|
+
);
|
|
67
|
+
assertEquals(toc.body.kind, "toc");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("an unknown section id → 400 with issues[{path,message}] listing valid ids", async () => {
|
|
71
|
+
const r = (await handler(input({ section: "nope" }), app)) as any;
|
|
72
|
+
assertEquals(r.status, 400);
|
|
73
|
+
assert(typeof r.body.error === "string");
|
|
74
|
+
assert(Array.isArray(r.body.issues) && r.body.issues.length === 1);
|
|
75
|
+
assertEquals(r.body.issues[0].path, "section");
|
|
76
|
+
assert(r.body.issues[0].message.includes("delivery-graphs"), "the 400 must name the valid ids");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("blank/whitespace section is treated as no section (TOC)", async () => {
|
|
80
|
+
const r = (await handler(input({ section: " " }), app)) as any;
|
|
81
|
+
assertEquals(r.status, 200);
|
|
82
|
+
assertEquals(r.body.kind, "toc");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("shared-secret guard: rejects when the secret is set and header is wrong", async () => {
|
|
86
|
+
const prev = process.env.NANO_PR_WEBHOOK_SECRET;
|
|
87
|
+
process.env.NANO_PR_WEBHOOK_SECRET = "s3cr3t";
|
|
88
|
+
try {
|
|
89
|
+
// Re-import with the secret set so the module-level SECRET picks it up.
|
|
90
|
+
const mod = await import(`./getAgentGuide.ts?secret=${Date.now()}`);
|
|
91
|
+
const guarded = mod.default;
|
|
92
|
+
const rejected = (await guarded(input({}, {}), app)) as any;
|
|
93
|
+
assertEquals(rejected.status, 401);
|
|
94
|
+
const ok = (await guarded(input({}, { "x-hook-secret": "s3cr3t" }), app)) as any;
|
|
95
|
+
assertEquals(ok.status, 200);
|
|
96
|
+
} finally {
|
|
97
|
+
if (prev === undefined) delete process.env.NANO_PR_WEBHOOK_SECRET;
|
|
98
|
+
else process.env.NANO_PR_WEBHOOK_SECRET = prev;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// GET /app/api/agent/guide → operationId `getAgentGuide` (epic nano-workforce#605, slice S5,
|
|
2
|
+
// issue #611). The ADDRESSABLE operator guide: fetch one section instead of the whole ~43KB blob
|
|
3
|
+
// that `getAgentInstructions` returns (which can exceed an agent's tool-result limit, forcing it to
|
|
4
|
+
// persist the blob and carve out a section out-of-band).
|
|
5
|
+
//
|
|
6
|
+
// • No `section` query param → a compact table of contents: every stable section id + a one-line
|
|
7
|
+
// summary (`kind: "toc"`). Small by construction — safe under any tool-result limit.
|
|
8
|
+
// • `section=<id>` → ONLY that section's markdown (`kind: "section"`), examples keyed to THIS
|
|
9
|
+
// instance's control-API base + engine base, exactly as the full guide keys them.
|
|
10
|
+
// • An unknown id → 400 with `issues: [{ path: "section", message }]` listing the valid ids
|
|
11
|
+
// (the uniform validation-error contract).
|
|
12
|
+
//
|
|
13
|
+
// This is the MCP-friendly companion to `getAgentInstructions`; the full-guide doors
|
|
14
|
+
// (`GET /agent`, `GET /agent/skill`) are untouched and byte-identical. Read-only, pure, idempotent.
|
|
15
|
+
//
|
|
16
|
+
// The optional shared-secret guard mirrors /agent and /version: enforced HERE only when
|
|
17
|
+
// NANO_PR_WEBHOOK_SECRET is set (the runtime does not enforce OpenAPI `security`).
|
|
18
|
+
import { guideToc, renderGuideSection, resolveEngineBase } from "../app/agentGuide.ts";
|
|
19
|
+
import { resolveApiBase } from "../app/resolveApiBase.ts";
|
|
20
|
+
import { buildVersionInfo, envVar } from "../app/version.ts";
|
|
21
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
22
|
+
|
|
23
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
24
|
+
|
|
25
|
+
export default defineOperation("getAgentGuide", ({ query, req }, app) => {
|
|
26
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
27
|
+
app.log.warn("getAgentGuide rejected: missing/invalid shared secret");
|
|
28
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const baseUrl = resolveApiBase(req, "agent/guide");
|
|
32
|
+
const rawSection = query.section;
|
|
33
|
+
const section = typeof rawSection === "string" ? rawSection.trim() : "";
|
|
34
|
+
|
|
35
|
+
// No section → the table of contents.
|
|
36
|
+
if (!section) {
|
|
37
|
+
return {
|
|
38
|
+
status: 200,
|
|
39
|
+
body: {
|
|
40
|
+
kind: "toc",
|
|
41
|
+
appVersion: buildVersionInfo().version,
|
|
42
|
+
generatedAt: new Date().toISOString(),
|
|
43
|
+
baseUrl,
|
|
44
|
+
sections: guideToc(),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A section id → just that section, or a 400 that names the valid ids.
|
|
50
|
+
const instructions = renderGuideSection(section, baseUrl);
|
|
51
|
+
if (instructions === undefined) {
|
|
52
|
+
// Derive the valid ids from the PARSED table of contents — the sections this deployment can
|
|
53
|
+
// actually serve — not the static registry. When the guide doc is unreadable (RAW_GUIDE
|
|
54
|
+
// fallback, no `##` headings) the TOC is empty and NO id is retrievable, so say so explicitly
|
|
55
|
+
// rather than list registry ids that would themselves 400.
|
|
56
|
+
const validIds = guideToc().map((s) => s.id);
|
|
57
|
+
const detail =
|
|
58
|
+
validIds.length > 0
|
|
59
|
+
? `valid ids: ${validIds.join(", ")}`
|
|
60
|
+
: "no sections are available in this deployment";
|
|
61
|
+
return {
|
|
62
|
+
status: 400,
|
|
63
|
+
body: {
|
|
64
|
+
error: `unknown guide section "${section}"`,
|
|
65
|
+
issues: [
|
|
66
|
+
{
|
|
67
|
+
path: "section",
|
|
68
|
+
message: `unknown section id "${section}"; ${detail}`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const title = guideToc().find((s) => s.id === section)?.title ?? section;
|
|
76
|
+
return {
|
|
77
|
+
status: 200,
|
|
78
|
+
body: {
|
|
79
|
+
kind: "section",
|
|
80
|
+
appVersion: buildVersionInfo().version,
|
|
81
|
+
generatedAt: new Date().toISOString(),
|
|
82
|
+
baseUrl,
|
|
83
|
+
engineBase: resolveEngineBase(),
|
|
84
|
+
section: {
|
|
85
|
+
id: section,
|
|
86
|
+
title,
|
|
87
|
+
format: "markdown",
|
|
88
|
+
instructions,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.161.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",
|