chapterhouse 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/agent-edit-access.js +11 -0
- package/dist/api/agents.api.test.js +48 -0
- package/dist/api/server.js +182 -11
- package/dist/api/server.test.js +334 -3
- package/dist/copilot/agent-event-bus.js +1 -0
- package/dist/copilot/agents.js +114 -46
- package/dist/copilot/agents.mcp-servers.test.js +87 -0
- package/dist/copilot/agents.parse.test.js +69 -0
- package/dist/copilot/agents.test.js +125 -1
- package/dist/copilot/orchestrator.js +50 -12
- package/dist/copilot/orchestrator.test.js +127 -1
- package/dist/copilot/session-manager.js +34 -0
- package/dist/test/setup-env.js +2 -1
- package/dist/test/setup-env.test.js +8 -1
- package/package.json +3 -1
- package/web/dist/assets/index-DuKYxMIR.css +10 -0
- package/web/dist/assets/{index-B5oDsQ5y.js → index-DytB69KC.js} +88 -88
- package/web/dist/assets/index-DytB69KC.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-B5oDsQ5y.js.map +0 -1
- package/web/dist/assets/index-DknKAtDS.css +0 -10
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* session:tool_call — tool invoked payload: { toolName, toolArgs, resultType?, _kind?, _seq?, _ts?, _summary? }
|
|
12
12
|
* session:destroyed — subagent finished payload: { agentName, reason: "complete" | "error" | "abort" }
|
|
13
13
|
* session:error — subagent failed payload: { agentName, error }
|
|
14
|
+
* agent_saved — custom agent saved payload: { slug }
|
|
14
15
|
*
|
|
15
16
|
* @module copilot/agent-event-bus
|
|
16
17
|
*/
|
package/dist/copilot/agents.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readdirSync, readFileSync, mkdirSync, writeFileSync, existsSync, rmSync
|
|
|
3
3
|
import { createHash } from "crypto";
|
|
4
4
|
import { join, dirname, sep } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
|
+
import { load as yamlLoad, dump as yamlDump } from "js-yaml";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { approveAll } from "@github/copilot-sdk";
|
|
8
9
|
import { AGENTS_DIR, SESSIONS_DIR } from "../paths.js";
|
|
@@ -34,48 +35,42 @@ const agentFrontmatterSchema = z.object({
|
|
|
34
35
|
// Agent Registry
|
|
35
36
|
// ---------------------------------------------------------------------------
|
|
36
37
|
let agentRegistry = [];
|
|
38
|
+
const defaultAgentSaveRuntimeHooks = {
|
|
39
|
+
getPersistentSessionState: () => "none",
|
|
40
|
+
reloadPersistentSession: async () => "none",
|
|
41
|
+
emitAgentReloadEvent: () => { },
|
|
42
|
+
};
|
|
43
|
+
let agentSaveRuntimeHooks = { ...defaultAgentSaveRuntimeHooks };
|
|
37
44
|
/** Bundled agents shipped with the package */
|
|
38
45
|
const BUNDLED_AGENTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "agents");
|
|
39
|
-
const RESERVED_SLUGS = new Set(["chapterhouse", "designer", "coder", "general-purpose"]);
|
|
40
|
-
const SLUG_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
46
|
+
export const RESERVED_SLUGS = new Set(["chapterhouse", "designer", "coder", "general-purpose"]);
|
|
47
|
+
export const SLUG_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
48
|
+
const BUILTIN_AGENT_SLUGS = new Set(RESERVED_SLUGS);
|
|
49
|
+
if (existsSync(BUNDLED_AGENTS_DIR)) {
|
|
50
|
+
for (const file of readdirSync(BUNDLED_AGENTS_DIR)) {
|
|
51
|
+
if (file.endsWith(".agent.md")) {
|
|
52
|
+
BUILTIN_AGENT_SLUGS.add(file.replace(/\.agent\.md$/, ""));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function isBuiltinAgent(slug) {
|
|
57
|
+
return BUILTIN_AGENT_SLUGS.has(slug);
|
|
58
|
+
}
|
|
41
59
|
/** Parse YAML frontmatter and markdown body from an .agent.md file. */
|
|
42
|
-
export function
|
|
60
|
+
export function parseAgentMdOrThrow(content, slug) {
|
|
43
61
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\s*([\s\S]*)$/);
|
|
44
|
-
if (!fmMatch)
|
|
45
|
-
|
|
62
|
+
if (!fmMatch) {
|
|
63
|
+
throw new Error("Missing YAML frontmatter");
|
|
64
|
+
}
|
|
46
65
|
const frontmatterRaw = fmMatch[1];
|
|
47
66
|
const body = fmMatch[2].trim();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const idx = line.indexOf(": ");
|
|
52
|
-
if (idx <= 0)
|
|
53
|
-
continue;
|
|
54
|
-
const key = line.slice(0, idx).trim();
|
|
55
|
-
let value = line.slice(idx + 2).trim();
|
|
56
|
-
// Handle YAML quoted strings
|
|
57
|
-
if (typeof value === "string" && value.startsWith('"') && value.endsWith('"')) {
|
|
58
|
-
value = value.slice(1, -1);
|
|
59
|
-
}
|
|
60
|
-
parsed[key] = value;
|
|
67
|
+
const loaded = yamlLoad(frontmatterRaw);
|
|
68
|
+
if (loaded !== undefined && (loaded === null || typeof loaded !== "object" || Array.isArray(loaded))) {
|
|
69
|
+
throw new Error("Agent frontmatter must be a YAML object");
|
|
61
70
|
}
|
|
62
|
-
|
|
63
|
-
for (const key of ["skills", "tools", "mcpServers", "allowed_paths"]) {
|
|
64
|
-
const raw = parsed[key];
|
|
65
|
-
if (typeof raw === "string") {
|
|
66
|
-
const arrMatch = raw.match(/^\[(.*)\]$/);
|
|
67
|
-
if (arrMatch) {
|
|
68
|
-
parsed[key] = arrMatch[1]
|
|
69
|
-
.split(",")
|
|
70
|
-
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
|
|
71
|
-
.filter(Boolean);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
const result = agentFrontmatterSchema.safeParse(parsed);
|
|
71
|
+
const result = agentFrontmatterSchema.safeParse(loaded ?? {});
|
|
76
72
|
if (!result.success) {
|
|
77
|
-
|
|
78
|
-
return null;
|
|
73
|
+
throw new Error(z.prettifyError(result.error));
|
|
79
74
|
}
|
|
80
75
|
const fm = result.data;
|
|
81
76
|
return {
|
|
@@ -92,6 +87,29 @@ export function parseAgentMd(content, slug) {
|
|
|
92
87
|
systemMessage: body,
|
|
93
88
|
};
|
|
94
89
|
}
|
|
90
|
+
export function parseAgentMd(content, slug) {
|
|
91
|
+
try {
|
|
92
|
+
return parseAgentMdOrThrow(content, slug);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
log.warn({ slug, err: err instanceof Error ? err.message : err }, "Invalid agent file");
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function serializeAgentMd(agent) {
|
|
100
|
+
const frontmatter = {
|
|
101
|
+
name: agent.name,
|
|
102
|
+
description: agent.description,
|
|
103
|
+
model: agent.model,
|
|
104
|
+
...(agent.persistent !== undefined ? { persistent: agent.persistent } : {}),
|
|
105
|
+
...(agent.scope !== undefined ? { scope: agent.scope } : {}),
|
|
106
|
+
...(agent.skills !== undefined ? { skills: agent.skills } : {}),
|
|
107
|
+
...(agent.tools !== undefined ? { tools: agent.tools } : {}),
|
|
108
|
+
...(agent.mcpServers !== undefined ? { mcpServers: agent.mcpServers } : {}),
|
|
109
|
+
...(agent.allowedPaths !== undefined ? { allowed_paths: agent.allowedPaths } : {}),
|
|
110
|
+
};
|
|
111
|
+
return `---\n${yamlDump(frontmatter, { lineWidth: -1 })}---\n\n${agent.systemMessage}`;
|
|
112
|
+
}
|
|
95
113
|
/** Scan ~/.chapterhouse/agents/ for .agent.md files and load configs. */
|
|
96
114
|
export function loadAgents() {
|
|
97
115
|
if (!existsSync(AGENTS_DIR)) {
|
|
@@ -132,6 +150,49 @@ export function getAgent(nameOrSlug) {
|
|
|
132
150
|
export function getAgentRegistry() {
|
|
133
151
|
return [...agentRegistry];
|
|
134
152
|
}
|
|
153
|
+
export function setAgentSaveRuntimeHooks(hooks) {
|
|
154
|
+
agentSaveRuntimeHooks = {
|
|
155
|
+
...agentSaveRuntimeHooks,
|
|
156
|
+
...hooks,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export function resetAgentSaveRuntimeHooks() {
|
|
160
|
+
agentSaveRuntimeHooks = { ...defaultAgentSaveRuntimeHooks };
|
|
161
|
+
}
|
|
162
|
+
export async function notifyAgentSaved(slug, savedConfig) {
|
|
163
|
+
const config = savedConfig ?? parseAgentMdOrThrow(readFileSync(join(AGENTS_DIR, `${slug}.agent.md`), "utf-8"), slug);
|
|
164
|
+
const existingIndex = agentRegistry.findIndex((entry) => entry.slug === slug);
|
|
165
|
+
if (existingIndex >= 0) {
|
|
166
|
+
agentRegistry[existingIndex] = config;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
agentRegistry.push(config);
|
|
170
|
+
}
|
|
171
|
+
void import("./orchestrator.js").then(({ enqueueForSse }) => {
|
|
172
|
+
enqueueForSse({ type: "agent_saved", slug });
|
|
173
|
+
}).catch((err) => {
|
|
174
|
+
log.warn({ slug, err: err instanceof Error ? err.message : err }, "Failed to emit agent_saved SSE event");
|
|
175
|
+
});
|
|
176
|
+
const emitReloaded = () => {
|
|
177
|
+
agentSaveRuntimeHooks.emitAgentReloadEvent({
|
|
178
|
+
type: "agent_reloaded",
|
|
179
|
+
slug,
|
|
180
|
+
reason: "session_restart",
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
const result = await agentSaveRuntimeHooks.reloadPersistentSession(slug, emitReloaded);
|
|
184
|
+
if (result === "scheduled") {
|
|
185
|
+
agentSaveRuntimeHooks.emitAgentReloadEvent({
|
|
186
|
+
type: "agent_reload_pending",
|
|
187
|
+
slug,
|
|
188
|
+
reason: "in_flight",
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (result === "reloaded") {
|
|
193
|
+
emitReloaded();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
135
196
|
/** Copy bundled agents to ~/.chapterhouse/agents/, updating stale copies when the bundled version changes.
|
|
136
197
|
* Respects user customizations: if the user edited the deployed file after our last sync, we skip it. */
|
|
137
198
|
export function ensureDefaultAgents() {
|
|
@@ -186,16 +247,14 @@ export function createAgentFile(slug, name, description, model, systemPrompt, sk
|
|
|
186
247
|
if (existsSync(filePath)) {
|
|
187
248
|
return `Agent '${slug}' already exists. Edit it directly or remove it first.`;
|
|
188
249
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
frontmatter += "\n---\n\n";
|
|
198
|
-
writeFileSync(filePath, frontmatter + systemPrompt + "\n");
|
|
250
|
+
writeFileSync(filePath, serializeAgentMd({
|
|
251
|
+
name,
|
|
252
|
+
description,
|
|
253
|
+
model,
|
|
254
|
+
skills,
|
|
255
|
+
tools,
|
|
256
|
+
systemMessage: `${systemPrompt}\n`,
|
|
257
|
+
}));
|
|
199
258
|
return null;
|
|
200
259
|
}
|
|
201
260
|
/** Remove an agent .md file. Returns error string or null on success. */
|
|
@@ -203,7 +262,7 @@ export function removeAgentFile(slug) {
|
|
|
203
262
|
if (!SLUG_REGEX.test(slug)) {
|
|
204
263
|
return `Invalid slug '${slug}'.`;
|
|
205
264
|
}
|
|
206
|
-
if (
|
|
265
|
+
if (isBuiltinAgent(slug)) {
|
|
207
266
|
return `Cannot remove built-in agent '${slug}'. You can edit its file instead.`;
|
|
208
267
|
}
|
|
209
268
|
const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
|
|
@@ -323,6 +382,14 @@ export function filterToolsForAgent(agent, allTools) {
|
|
|
323
382
|
}
|
|
324
383
|
return allTools.filter((t) => !MANAGEMENT_TOOL_NAMES.has(t.name));
|
|
325
384
|
}
|
|
385
|
+
/** Filter MCP servers based on agent config. */
|
|
386
|
+
export function filterMcpServersForAgent(agent, allMcpServers) {
|
|
387
|
+
if (agent.mcpServers && agent.mcpServers.length > 0) {
|
|
388
|
+
const allowed = new Set(agent.mcpServers);
|
|
389
|
+
return Object.fromEntries(Object.entries(allMcpServers).filter(([name]) => allowed.has(name)));
|
|
390
|
+
}
|
|
391
|
+
return allMcpServers;
|
|
392
|
+
}
|
|
326
393
|
/** Create an ephemeral session for an agent. Always creates a fresh session — caller is responsible for destroying it. */
|
|
327
394
|
export async function createEphemeralAgentSession(slug, client, allTools, modelOverride, systemMessagePrefix, taskId) {
|
|
328
395
|
const agent = getAgent(slug);
|
|
@@ -334,7 +401,8 @@ export async function createEphemeralAgentSession(slug, client, allTools, modelO
|
|
|
334
401
|
? modelOverride
|
|
335
402
|
: (agent.model === "auto" ? "claude-sonnet-4.6" : agent.model);
|
|
336
403
|
const tools = bindToolsToAgent(agent.slug, filterToolsForAgent(agent, allTools), taskId);
|
|
337
|
-
const
|
|
404
|
+
const allMcpServers = loadMcpConfig();
|
|
405
|
+
const mcpServers = filterMcpServersForAgent(agent, allMcpServers);
|
|
338
406
|
const skillDirectories = getSkillDirectories();
|
|
339
407
|
const baseSystemMessage = composeAgentSystemMessage(agent);
|
|
340
408
|
const systemMessageContent = systemMessagePrefix
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
const MCP_SERVERS = {
|
|
7
|
+
filesystem: { command: "filesystem" },
|
|
8
|
+
truenas: { command: "truenas" },
|
|
9
|
+
};
|
|
10
|
+
async function loadIsolatedAgentsModule(t, agentFrontmatter) {
|
|
11
|
+
const root = mkdtempSync(join(tmpdir(), "chapterhouse-agents-mcp-"));
|
|
12
|
+
const agentsDir = join(root, "agents");
|
|
13
|
+
const sessionsDir = join(root, "sessions");
|
|
14
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
15
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
16
|
+
writeFileSync(join(agentsDir, "mcp-test-agent.agent.md"), [
|
|
17
|
+
"---",
|
|
18
|
+
"name: MCP Test Agent",
|
|
19
|
+
"description: Agent used to verify MCP session config",
|
|
20
|
+
"model: claude-sonnet-4.6",
|
|
21
|
+
agentFrontmatter,
|
|
22
|
+
"---",
|
|
23
|
+
"",
|
|
24
|
+
"You are an MCP test agent.",
|
|
25
|
+
].filter(Boolean).join("\n"));
|
|
26
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
27
|
+
t.mock.module("../paths.js", {
|
|
28
|
+
namedExports: {
|
|
29
|
+
AGENTS_DIR: agentsDir,
|
|
30
|
+
SESSIONS_DIR: sessionsDir,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
t.mock.module("./mcp-config.js", {
|
|
34
|
+
namedExports: {
|
|
35
|
+
loadMcpConfig: () => MCP_SERVERS,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
t.mock.module("./skills.js", {
|
|
39
|
+
namedExports: {
|
|
40
|
+
getSkillDirectories: () => [],
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
t.mock.module("../store/db.js", {
|
|
44
|
+
namedExports: {
|
|
45
|
+
getState: () => undefined,
|
|
46
|
+
setState: () => undefined,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
let createSessionOptions;
|
|
50
|
+
const agentsModule = await import(new URL(`./agents.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
51
|
+
agentsModule.loadAgents();
|
|
52
|
+
return {
|
|
53
|
+
agentsModule,
|
|
54
|
+
get createSessionOptions() {
|
|
55
|
+
return createSessionOptions;
|
|
56
|
+
},
|
|
57
|
+
set createSessionOptions(value) {
|
|
58
|
+
createSessionOptions = value;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async function createAgentSession(agentsModule, setCreateSessionOptions) {
|
|
63
|
+
const client = {
|
|
64
|
+
createSession: async (options) => {
|
|
65
|
+
setCreateSessionOptions(options);
|
|
66
|
+
return {};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
await agentsModule.createEphemeralAgentSession("mcp-test-agent", client, []);
|
|
70
|
+
}
|
|
71
|
+
test("createEphemeralAgentSession only passes MCP servers listed by the agent allowlist", async (t) => {
|
|
72
|
+
const context = await loadIsolatedAgentsModule(t, "mcpServers: [truenas]");
|
|
73
|
+
await createAgentSession(context.agentsModule, (options) => {
|
|
74
|
+
context.createSessionOptions = options;
|
|
75
|
+
});
|
|
76
|
+
assert.deepEqual(context.createSessionOptions?.mcpServers, {
|
|
77
|
+
truenas: { command: "truenas" },
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
test("createEphemeralAgentSession passes all MCP servers when the agent has no allowlist", async (t) => {
|
|
81
|
+
const context = await loadIsolatedAgentsModule(t, "");
|
|
82
|
+
await createAgentSession(context.agentsModule, (options) => {
|
|
83
|
+
context.createSessionOptions = options;
|
|
84
|
+
});
|
|
85
|
+
assert.deepEqual(context.createSessionOptions?.mcpServers, MCP_SERVERS);
|
|
86
|
+
});
|
|
87
|
+
//# sourceMappingURL=agents.mcp-servers.test.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { parseAgentMd } from "./agents.js";
|
|
4
|
+
test("parseAgentMd parses block-style YAML arrays", () => {
|
|
5
|
+
const agent = parseAgentMd([
|
|
6
|
+
"---",
|
|
7
|
+
"name: Designer",
|
|
8
|
+
"description: Handles UI flows",
|
|
9
|
+
"model: claude-sonnet-4.6",
|
|
10
|
+
"skills:",
|
|
11
|
+
" - frontend-design",
|
|
12
|
+
" - ux-copy",
|
|
13
|
+
"tools:",
|
|
14
|
+
" - read",
|
|
15
|
+
" - write",
|
|
16
|
+
"---",
|
|
17
|
+
"",
|
|
18
|
+
"You are Designer.",
|
|
19
|
+
].join("\n"), "designer");
|
|
20
|
+
assert.ok(agent, "agent charter should still parse");
|
|
21
|
+
assert.deepEqual(agent.skills, ["frontend-design", "ux-copy"]);
|
|
22
|
+
assert.deepEqual(agent.tools, ["read", "write"]);
|
|
23
|
+
});
|
|
24
|
+
test("parseAgentMd still parses inline YAML arrays", () => {
|
|
25
|
+
const agent = parseAgentMd([
|
|
26
|
+
"---",
|
|
27
|
+
"name: Designer",
|
|
28
|
+
"description: Handles UI flows",
|
|
29
|
+
"model: claude-sonnet-4.6",
|
|
30
|
+
"skills: [frontend-design, ux-copy]",
|
|
31
|
+
"tools: [read, write]",
|
|
32
|
+
"---",
|
|
33
|
+
"",
|
|
34
|
+
"You are Designer.",
|
|
35
|
+
].join("\n"), "designer");
|
|
36
|
+
assert.ok(agent, "agent charter should parse");
|
|
37
|
+
assert.deepEqual(agent.skills, ["frontend-design", "ux-copy"]);
|
|
38
|
+
assert.deepEqual(agent.tools, ["read", "write"]);
|
|
39
|
+
});
|
|
40
|
+
test("parseAgentMd tolerates missing optional fields", () => {
|
|
41
|
+
const agent = parseAgentMd([
|
|
42
|
+
"---",
|
|
43
|
+
"name: Designer",
|
|
44
|
+
"description: Handles UI flows",
|
|
45
|
+
"model: claude-sonnet-4.6",
|
|
46
|
+
"---",
|
|
47
|
+
"",
|
|
48
|
+
"You are Designer.",
|
|
49
|
+
].join("\n"), "designer");
|
|
50
|
+
assert.ok(agent, "agent charter should parse");
|
|
51
|
+
assert.equal(agent.skills, undefined);
|
|
52
|
+
assert.equal(agent.tools, undefined);
|
|
53
|
+
assert.equal(agent.mcpServers, undefined);
|
|
54
|
+
assert.equal(agent.allowedPaths, undefined);
|
|
55
|
+
});
|
|
56
|
+
test("parseAgentMd returns null for invalid YAML frontmatter", () => {
|
|
57
|
+
const agent = parseAgentMd([
|
|
58
|
+
"---",
|
|
59
|
+
"name: Designer",
|
|
60
|
+
"description: Handles UI flows",
|
|
61
|
+
"model: claude-sonnet-4.6",
|
|
62
|
+
"skills: [frontend-design",
|
|
63
|
+
"---",
|
|
64
|
+
"",
|
|
65
|
+
"You are Designer.",
|
|
66
|
+
].join("\n"), "designer");
|
|
67
|
+
assert.equal(agent, null);
|
|
68
|
+
});
|
|
69
|
+
//# sourceMappingURL=agents.parse.test.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { composeAgentSystemMessage, filterToolsForAgent, bindToolsToAgent, getCurrentToolTaskId, parseAgentMd, withToolTaskContext, } from "./agents.js";
|
|
3
|
+
import { composeAgentSystemMessage, filterToolsForAgent, bindToolsToAgent, getCurrentToolTaskId, getAgentRegistry, parseAgentMd, withToolTaskContext, } from "./agents.js";
|
|
4
4
|
function makeAgent(slug) {
|
|
5
5
|
return {
|
|
6
6
|
slug,
|
|
@@ -76,6 +76,22 @@ test("parseAgentMd detects persistent agent scope from charter frontmatter", ()
|
|
|
76
76
|
assert.equal(agent.persistent, true);
|
|
77
77
|
assert.equal(agent.scope, "infra");
|
|
78
78
|
});
|
|
79
|
+
test("parseAgentMd preserves block-style skills arrays from charter frontmatter", () => {
|
|
80
|
+
const agent = parseAgentMd([
|
|
81
|
+
"---",
|
|
82
|
+
"name: Designer",
|
|
83
|
+
"description: UI/UX design specialist",
|
|
84
|
+
"model: claude-opus-4.6",
|
|
85
|
+
"skills:",
|
|
86
|
+
" - frontend-design",
|
|
87
|
+
" - accessibility-review",
|
|
88
|
+
"---",
|
|
89
|
+
"",
|
|
90
|
+
"You are the Designer.",
|
|
91
|
+
].join("\n"), "designer");
|
|
92
|
+
assert.ok(agent, "agent charter should parse");
|
|
93
|
+
assert.deepEqual(agent.skills, ["frontend-design", "accessibility-review"]);
|
|
94
|
+
});
|
|
79
95
|
test("persistent agents cannot receive scope-changing management tools", () => {
|
|
80
96
|
const agent = {
|
|
81
97
|
...makeAgent("bellonda"),
|
|
@@ -102,4 +118,112 @@ test("bindToolsToAgent uses the per-turn task context when no fixed task id is p
|
|
|
102
118
|
const taskId = await withToolTaskContext("delegated-persistent-001", () => tools[0].handler({}, {}));
|
|
103
119
|
assert.equal(taskId, "delegated-persistent-001");
|
|
104
120
|
});
|
|
121
|
+
test("notifyAgentSaved uses the provided charter instead of rereading from CHAPTERHOUSE_HOME", async () => {
|
|
122
|
+
const slug = "notify-agent-saved-inline-charter";
|
|
123
|
+
const agents = await import("./agents.js");
|
|
124
|
+
const config = {
|
|
125
|
+
...makeAgent(slug),
|
|
126
|
+
persistent: true,
|
|
127
|
+
scope: "infra",
|
|
128
|
+
};
|
|
129
|
+
agents.setAgentSaveRuntimeHooks({
|
|
130
|
+
reloadPersistentSession: async () => "none",
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
await agents.notifyAgentSaved(slug, config);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
agents.resetAgentSaveRuntimeHooks();
|
|
137
|
+
}
|
|
138
|
+
const saved = getAgentRegistry().find((entry) => entry.slug === slug);
|
|
139
|
+
assert.deepEqual(saved, config);
|
|
140
|
+
});
|
|
141
|
+
test("notifyAgentSaved reloads idle persistent sessions and emits a restart event", async () => {
|
|
142
|
+
const events = [];
|
|
143
|
+
const reloads = [];
|
|
144
|
+
const agents = await import("./agents.js");
|
|
145
|
+
const config = {
|
|
146
|
+
...makeAgent("bellonda"),
|
|
147
|
+
persistent: true,
|
|
148
|
+
scope: "infra",
|
|
149
|
+
};
|
|
150
|
+
agents.setAgentSaveRuntimeHooks({
|
|
151
|
+
reloadPersistentSession: async (slug) => {
|
|
152
|
+
reloads.push(slug);
|
|
153
|
+
return "reloaded";
|
|
154
|
+
},
|
|
155
|
+
emitAgentReloadEvent: (event) => {
|
|
156
|
+
events.push(event);
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
try {
|
|
160
|
+
await agents.notifyAgentSaved("bellonda", config);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
agents.resetAgentSaveRuntimeHooks();
|
|
164
|
+
}
|
|
165
|
+
assert.deepEqual(reloads, ["bellonda"]);
|
|
166
|
+
assert.deepEqual(events, [{ type: "agent_reloaded", slug: "bellonda", reason: "session_restart" }]);
|
|
167
|
+
});
|
|
168
|
+
test("notifyAgentSaved marks in-flight persistent sessions as pending and emits restart after the deferred reload", async () => {
|
|
169
|
+
const events = [];
|
|
170
|
+
const reloads = [];
|
|
171
|
+
const agents = await import("./agents.js");
|
|
172
|
+
const config = {
|
|
173
|
+
...makeAgent("bellonda"),
|
|
174
|
+
persistent: true,
|
|
175
|
+
scope: "infra",
|
|
176
|
+
};
|
|
177
|
+
let finishReload;
|
|
178
|
+
agents.setAgentSaveRuntimeHooks({
|
|
179
|
+
reloadPersistentSession: async (slug, onReloaded) => {
|
|
180
|
+
reloads.push(slug);
|
|
181
|
+
finishReload = onReloaded;
|
|
182
|
+
return "scheduled";
|
|
183
|
+
},
|
|
184
|
+
emitAgentReloadEvent: (event) => {
|
|
185
|
+
events.push(event);
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
try {
|
|
189
|
+
await agents.notifyAgentSaved("bellonda", config);
|
|
190
|
+
assert.deepEqual(events, [{ type: "agent_reload_pending", slug: "bellonda", reason: "in_flight" }]);
|
|
191
|
+
finishReload?.();
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
agents.resetAgentSaveRuntimeHooks();
|
|
195
|
+
}
|
|
196
|
+
assert.deepEqual(reloads, ["bellonda"]);
|
|
197
|
+
assert.deepEqual(events, [
|
|
198
|
+
{ type: "agent_reload_pending", slug: "bellonda", reason: "in_flight" },
|
|
199
|
+
{ type: "agent_reloaded", slug: "bellonda", reason: "session_restart" },
|
|
200
|
+
]);
|
|
201
|
+
});
|
|
202
|
+
test("notifyAgentSaved leaves agents without open persistent sessions alone", async () => {
|
|
203
|
+
const events = [];
|
|
204
|
+
const reloads = [];
|
|
205
|
+
const agents = await import("./agents.js");
|
|
206
|
+
const config = {
|
|
207
|
+
...makeAgent("bellonda"),
|
|
208
|
+
persistent: true,
|
|
209
|
+
scope: "infra",
|
|
210
|
+
};
|
|
211
|
+
agents.setAgentSaveRuntimeHooks({
|
|
212
|
+
reloadPersistentSession: async (slug) => {
|
|
213
|
+
reloads.push(slug);
|
|
214
|
+
return "none";
|
|
215
|
+
},
|
|
216
|
+
emitAgentReloadEvent: (event) => {
|
|
217
|
+
events.push(event);
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
try {
|
|
221
|
+
await agents.notifyAgentSaved("bellonda", config);
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
agents.resetAgentSaveRuntimeHooks();
|
|
225
|
+
}
|
|
226
|
+
assert.deepEqual(reloads, ["bellonda"]);
|
|
227
|
+
assert.deepEqual(events, []);
|
|
228
|
+
});
|
|
105
229
|
//# sourceMappingURL=agents.test.js.map
|
|
@@ -16,7 +16,7 @@ import { maybeWriteEpisode } from "./episode-writer.js";
|
|
|
16
16
|
import { getWikiSummary } from "../wiki/context.js";
|
|
17
17
|
import { SESSIONS_DIR } from "../paths.js";
|
|
18
18
|
import { resolveModel } from "./router.js";
|
|
19
|
-
import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, setActiveAgent, parseAtMention, buildAgentRoster, getActiveTasks, getAgent, composeAgentSystemMessage, filterToolsForAgent, withToolTaskContext, } from "./agents.js";
|
|
19
|
+
import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, setActiveAgent, parseAtMention, buildAgentRoster, getActiveTasks, getAgent, composeAgentSystemMessage, filterMcpServersForAgent, filterToolsForAgent, withToolTaskContext, } from "./agents.js";
|
|
20
20
|
import * as agentsModule from "./agents.js";
|
|
21
21
|
import { childLogger } from "../util/logger.js";
|
|
22
22
|
import { agentEventBus } from "./agent-event-bus.js";
|
|
@@ -179,6 +179,13 @@ function getSessionConfig() {
|
|
|
179
179
|
function agentSlugFromSessionKey(sessionKey) {
|
|
180
180
|
return sessionKey.startsWith("agent:") ? sessionKey.slice("agent:".length) : undefined;
|
|
181
181
|
}
|
|
182
|
+
function persistentAgentSessionKey(slug) {
|
|
183
|
+
const agent = getAgent(slug);
|
|
184
|
+
if (!agent?.persistent) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
return `agent:${agent.slug}`;
|
|
188
|
+
}
|
|
182
189
|
function getPersistentAgentForSessionKey(sessionKey) {
|
|
183
190
|
const slug = agentSlugFromSessionKey(sessionKey);
|
|
184
191
|
if (!slug)
|
|
@@ -344,6 +351,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
344
351
|
client: copilotClient,
|
|
345
352
|
onAgentTaskComplete: feedAgentResult,
|
|
346
353
|
})));
|
|
354
|
+
mcpServers = filterMcpServersForAgent(persistentAgent, mcpServers);
|
|
347
355
|
const scopedHotTier = (await memoryCoordinator?.buildHotTierContext(sessionKey)) || undefined;
|
|
348
356
|
const channelNote = `You are in your persistent Chapterhouse channel (${sessionKey}). Your memory scope is ${persistentAgent.scope}.`;
|
|
349
357
|
systemMessageContent = [
|
|
@@ -1209,19 +1217,18 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
|
|
|
1209
1217
|
await manager.abortCurrentTurn();
|
|
1210
1218
|
log.info({ sessionKey, replacementTurnId: turnId }, "turn.interrupted");
|
|
1211
1219
|
}
|
|
1212
|
-
/**
|
|
1213
|
-
* Enqueue a turn for the new POST→SSE chat path (#130).
|
|
1214
|
-
*
|
|
1215
|
-
* Unlike `sendToOrchestrator`, this function:
|
|
1216
|
-
* - Returns the `turnId` immediately without waiting for the turn to complete.
|
|
1217
|
-
* - Routes through the shared lifecycle emitter in sendToOrchestrator.
|
|
1218
|
-
* - Does NOT write to sseClients — the SSE channel delivers events via subscribeSession().
|
|
1219
|
-
* - Supports interrupt: true which calls interruptCurrentTurn under the hood.
|
|
1220
|
-
*
|
|
1221
|
-
* @returns turnId (UUID)
|
|
1222
|
-
*/
|
|
1223
1220
|
export function enqueueForSse(opts) {
|
|
1221
|
+
if (opts.type === "agent_saved") {
|
|
1222
|
+
agentEventBus.emit({
|
|
1223
|
+
type: "agent_saved",
|
|
1224
|
+
payload: { slug: opts.slug ?? "" },
|
|
1225
|
+
});
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1224
1228
|
const { sessionKey, prompt, attachments, authUser, authHeader, interrupt } = opts;
|
|
1229
|
+
if (!sessionKey || !prompt) {
|
|
1230
|
+
throw new Error("Missing sessionKey or prompt for SSE turn enqueue");
|
|
1231
|
+
}
|
|
1225
1232
|
const turnId = randomUUID();
|
|
1226
1233
|
// sse-web carries auth and enables onQueued — unlike "background" (Fixes 2 & 3).
|
|
1227
1234
|
const source = { type: "sse-web", sessionKey, user: authUser, authorizationHeader: authHeader };
|
|
@@ -1288,6 +1295,37 @@ export async function interruptSessionTurn(sessionKey) {
|
|
|
1288
1295
|
}
|
|
1289
1296
|
return aborted;
|
|
1290
1297
|
}
|
|
1298
|
+
export function getPersistentAgentSessionState(slug) {
|
|
1299
|
+
const sessionKey = persistentAgentSessionKey(slug);
|
|
1300
|
+
if (!sessionKey) {
|
|
1301
|
+
return "none";
|
|
1302
|
+
}
|
|
1303
|
+
const manager = registry?.get(sessionKey);
|
|
1304
|
+
if (!manager) {
|
|
1305
|
+
return "none";
|
|
1306
|
+
}
|
|
1307
|
+
return manager.isProcessing ? "in_flight" : "idle";
|
|
1308
|
+
}
|
|
1309
|
+
export async function reloadPersistentAgent(slug, onReloaded) {
|
|
1310
|
+
const sessionKey = persistentAgentSessionKey(slug);
|
|
1311
|
+
if (!sessionKey || !registry) {
|
|
1312
|
+
return "none";
|
|
1313
|
+
}
|
|
1314
|
+
let manager = registry.get(sessionKey);
|
|
1315
|
+
if (!manager) {
|
|
1316
|
+
manager = registry.getOrCreate(sessionKey);
|
|
1317
|
+
await manager.ensureSession();
|
|
1318
|
+
onReloaded?.();
|
|
1319
|
+
return "reloaded";
|
|
1320
|
+
}
|
|
1321
|
+
if (manager.isProcessing) {
|
|
1322
|
+
manager.requestSessionReload(onReloaded);
|
|
1323
|
+
return "scheduled";
|
|
1324
|
+
}
|
|
1325
|
+
await manager.restartSession();
|
|
1326
|
+
onReloaded?.();
|
|
1327
|
+
return "reloaded";
|
|
1328
|
+
}
|
|
1291
1329
|
/** Switch the model on the live default orchestrator session without destroying it. */
|
|
1292
1330
|
export function switchSessionModel(newModel) {
|
|
1293
1331
|
const manager = registry?.get("default");
|