chapterhouse 0.4.3 → 0.5.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/agents/bellonda.agent.md +11 -0
- package/agents/hwi-noree.agent.md +12 -0
- package/dist/api/server.js +39 -2
- package/dist/api/server.test.js +20 -0
- package/dist/api/turn-sse.integration.test.js +12 -0
- package/dist/copilot/agents.js +13 -2
- package/dist/copilot/agents.test.js +43 -1
- package/dist/copilot/orchestrator.js +132 -29
- package/dist/copilot/orchestrator.test.js +183 -12
- package/dist/copilot/session-manager.js +11 -2
- package/dist/copilot/session-manager.test.js +25 -0
- package/dist/copilot/tools.agent.test.js +52 -4
- package/dist/copilot/tools.js +82 -11
- package/dist/copilot/tools.memory.test.js +50 -1
- package/dist/memory/active-scope.js +9 -0
- package/dist/memory/index.js +1 -1
- package/dist/store/db.js +27 -1
- package/package.json +1 -1
- package/web/dist/assets/{index-D4-uRAi6.js → index-BfHqP3-C.js} +87 -87
- package/web/dist/assets/index-BfHqP3-C.js.map +1 -0
- package/web/dist/assets/index-_O6AoWOS.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BTI_m0OE.css +0 -10
- package/web/dist/assets/index-D4-uRAi6.js.map +0 -1
|
@@ -29,6 +29,9 @@ function createFakeClient(state) {
|
|
|
29
29
|
if (state.sendErrorMessage) {
|
|
30
30
|
throw new Error(state.sendErrorMessage);
|
|
31
31
|
}
|
|
32
|
+
for (const delta of state.sendDeltas ?? []) {
|
|
33
|
+
this.emit("assistant.message_delta", { deltaContent: delta });
|
|
34
|
+
}
|
|
32
35
|
return { data: { content: state.sendResult } };
|
|
33
36
|
}
|
|
34
37
|
async setModel(model) {
|
|
@@ -102,7 +105,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
102
105
|
{ taskId: "task-2", agentSlug: "designer", status: "done", description: "Ignore me" },
|
|
103
106
|
],
|
|
104
107
|
registry: [
|
|
105
|
-
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6" },
|
|
108
|
+
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6", systemMessage: "You are Kaylee." },
|
|
106
109
|
],
|
|
107
110
|
sendResult: "Finished successfully",
|
|
108
111
|
taskEvents: new Map(),
|
|
@@ -150,11 +153,35 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
150
153
|
t.mock.module("../memory/hot-tier.js", {
|
|
151
154
|
namedExports: {
|
|
152
155
|
renderHotTierForActiveScope: () => state.hotTierXml ?? "",
|
|
156
|
+
getHotTierEntries: (scopeId) => ({
|
|
157
|
+
scope: scopeId !== undefined
|
|
158
|
+
? makeScope(scopeId, "infra", "Infra", "Infrastructure work.")
|
|
159
|
+
: state.activeScope ?? null,
|
|
160
|
+
entities: [],
|
|
161
|
+
observations: [],
|
|
162
|
+
decisions: [],
|
|
163
|
+
actionItems: [],
|
|
164
|
+
}),
|
|
165
|
+
renderHotTierXML: (entries) => entries.scope ? state.hotTierByScope?.get(entries.scope.slug) ?? "" : "",
|
|
153
166
|
},
|
|
154
167
|
});
|
|
155
168
|
t.mock.module("../memory/active-scope.js", {
|
|
156
169
|
namedExports: {
|
|
157
170
|
getActiveScope: () => state.activeScope ?? null,
|
|
171
|
+
withActiveScope: async (_slug, fn) => fn(),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
t.mock.module("../memory/scopes.js", {
|
|
175
|
+
namedExports: {
|
|
176
|
+
getScope: (slugOrId) => {
|
|
177
|
+
if (slugOrId === "infra" || slugOrId === 3) {
|
|
178
|
+
return makeScope(3, "infra", "Infra", "Infrastructure work.");
|
|
179
|
+
}
|
|
180
|
+
if (slugOrId === "brian" || slugOrId === 5) {
|
|
181
|
+
return makeScope(5, "brian", "Brian", "Brian's personal context.");
|
|
182
|
+
}
|
|
183
|
+
return state.activeScope ?? null;
|
|
184
|
+
},
|
|
158
185
|
},
|
|
159
186
|
});
|
|
160
187
|
t.mock.module("../memory/checkpoint.js", {
|
|
@@ -342,10 +369,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
342
369
|
namedExports: {
|
|
343
370
|
loadAgents: () => {
|
|
344
371
|
state.loadAgentsCalls++;
|
|
345
|
-
return
|
|
346
|
-
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6" },
|
|
347
|
-
{ slug: "designer", name: "Wash", model: "claude-opus-4.6" },
|
|
348
|
-
];
|
|
372
|
+
return state.registry;
|
|
349
373
|
},
|
|
350
374
|
ensureDefaultAgents: () => {
|
|
351
375
|
state.ensureDefaultAgentsCalls++;
|
|
@@ -354,6 +378,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
354
378
|
state.clearActiveTasksCalls++;
|
|
355
379
|
},
|
|
356
380
|
getAgentRegistry: () => state.registry,
|
|
381
|
+
getAgent: (slug) => state.registry.find((agent) => agent.slug === slug),
|
|
357
382
|
getActiveAgent: () => undefined,
|
|
358
383
|
setActiveAgent: (channelKey, agentSlug) => {
|
|
359
384
|
state.setActiveAgentCalls.push({ channelKey, agentSlug });
|
|
@@ -366,6 +391,10 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
366
391
|
state.buildAgentRosterArgs.push(projectRoot);
|
|
367
392
|
return "@coder @designer";
|
|
368
393
|
},
|
|
394
|
+
composeAgentSystemMessage: (agent) => agent.systemMessage ?? `You are ${agent.slug}.`,
|
|
395
|
+
filterToolsForAgent: (_agent, tools) => tools,
|
|
396
|
+
bindToolsToAgent: (_agentSlug, tools) => tools,
|
|
397
|
+
withToolTaskContext: (_taskId, fn) => fn(),
|
|
369
398
|
getActiveTasks: () => state.activeTasks,
|
|
370
399
|
completeTask: () => { },
|
|
371
400
|
failTask: () => { },
|
|
@@ -516,6 +545,143 @@ test("initOrchestrator omits hot-tier XML when memory injection is disabled", as
|
|
|
516
545
|
await orchestrator.initOrchestrator(client);
|
|
517
546
|
assert.equal(state.systemOptions?.hotTierXml, undefined);
|
|
518
547
|
});
|
|
548
|
+
test("initOrchestrator prewarms persistent agent sessions with scoped hot-tier context", async (t) => {
|
|
549
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
550
|
+
registry: [
|
|
551
|
+
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6", systemMessage: "You are Kaylee." },
|
|
552
|
+
{
|
|
553
|
+
slug: "bellonda",
|
|
554
|
+
name: "Bellonda",
|
|
555
|
+
model: "claude-sonnet-4.6",
|
|
556
|
+
systemMessage: "You are Bellonda.",
|
|
557
|
+
persistent: true,
|
|
558
|
+
scope: "infra",
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
hotTierByScope: new Map([
|
|
562
|
+
["infra", "<memory_context scope=\"infra\"><observation>terraform drift</observation></memory_context>"],
|
|
563
|
+
]),
|
|
564
|
+
});
|
|
565
|
+
await orchestrator.initOrchestrator(client);
|
|
566
|
+
assert.equal(state.createSessionCalls.length, 2);
|
|
567
|
+
const persistentCall = state.createSessionCalls.find((call) => String(call.systemMessage?.content ?? "").includes("Bellonda"));
|
|
568
|
+
assert.ok(persistentCall, "expected a prewarmed Bellonda session");
|
|
569
|
+
assert.equal(persistentCall.systemMessage.content.includes("Bellonda"), true);
|
|
570
|
+
assert.equal(persistentCall.systemMessage.content.includes("scope=\"infra\""), true);
|
|
571
|
+
assert.deepEqual(state.dbWrites.filter((write) => write.sql.includes("copilot_sessions")), []);
|
|
572
|
+
});
|
|
573
|
+
test("sendToOrchestrator routes agent session keys directly to persistent agent sessions", async (t) => {
|
|
574
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
575
|
+
config: {
|
|
576
|
+
copilotModel: "claude-sonnet-4.6",
|
|
577
|
+
selfEditEnabled: true,
|
|
578
|
+
memoryInjectEnabled: true,
|
|
579
|
+
},
|
|
580
|
+
registry: [
|
|
581
|
+
{
|
|
582
|
+
slug: "bellonda",
|
|
583
|
+
name: "Bellonda",
|
|
584
|
+
model: "claude-sonnet-4.6",
|
|
585
|
+
systemMessage: "You are Bellonda.",
|
|
586
|
+
persistent: true,
|
|
587
|
+
scope: "infra",
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
sendResult: "Infra answer",
|
|
591
|
+
hotTierByScope: new Map([
|
|
592
|
+
["infra", "<memory_context scope=\"infra\"><observation>vpc state</observation></memory_context>"],
|
|
593
|
+
]),
|
|
594
|
+
});
|
|
595
|
+
await orchestrator.initOrchestrator(client);
|
|
596
|
+
state.sessionPrompts.length = 0;
|
|
597
|
+
state.routerArgs.length = 0;
|
|
598
|
+
const final = await new Promise((resolve) => {
|
|
599
|
+
orchestrator.sendToOrchestrator("What changed in prod?", { type: "sse-web", sessionKey: "agent:bellonda" }, (text, done) => {
|
|
600
|
+
if (done)
|
|
601
|
+
resolve(text);
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
assert.equal(final, "Infra answer");
|
|
605
|
+
assert.deepEqual(state.routerArgs, [], "direct persistent-agent chat must not use orchestrator model routing");
|
|
606
|
+
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] What changed in prod?" }]);
|
|
607
|
+
assert.deepEqual(state.dbLogs.map((entry) => ({
|
|
608
|
+
role: entry.role,
|
|
609
|
+
content: entry.content,
|
|
610
|
+
source: entry.source,
|
|
611
|
+
sessionKey: entry.sessionKey,
|
|
612
|
+
})), [
|
|
613
|
+
{ role: "user", content: "What changed in prod?", source: "web", sessionKey: "agent:bellonda" },
|
|
614
|
+
{ role: "assistant", content: "Infra answer", source: "web", sessionKey: "agent:bellonda" },
|
|
615
|
+
]);
|
|
616
|
+
});
|
|
617
|
+
test("sendToAgentSession annotates delegated turns as via chapterhouse", async (t) => {
|
|
618
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
619
|
+
config: {
|
|
620
|
+
copilotModel: "claude-sonnet-4.6",
|
|
621
|
+
selfEditEnabled: true,
|
|
622
|
+
memoryInjectEnabled: true,
|
|
623
|
+
},
|
|
624
|
+
registry: [
|
|
625
|
+
{
|
|
626
|
+
slug: "bellonda",
|
|
627
|
+
name: "Bellonda",
|
|
628
|
+
model: "claude-sonnet-4.6",
|
|
629
|
+
systemMessage: "You are Bellonda.",
|
|
630
|
+
persistent: true,
|
|
631
|
+
scope: "infra",
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
sendResult: "Delegated infra answer",
|
|
635
|
+
});
|
|
636
|
+
await orchestrator.initOrchestrator(client);
|
|
637
|
+
state.sessionPrompts.length = 0;
|
|
638
|
+
const final = await orchestrator.sendToAgentSession("bellonda", "Check deploy health", "task-bellonda-1");
|
|
639
|
+
assert.equal(final, "Delegated infra answer");
|
|
640
|
+
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via @chapterhouse] Check deploy health" }]);
|
|
641
|
+
assert.deepEqual(state.dbLogs.map((entry) => ({
|
|
642
|
+
role: entry.role,
|
|
643
|
+
content: entry.content,
|
|
644
|
+
source: entry.source,
|
|
645
|
+
sessionKey: entry.sessionKey,
|
|
646
|
+
})), [
|
|
647
|
+
{ role: "user", content: "Check deploy health", source: "delegated", sessionKey: "agent:bellonda" },
|
|
648
|
+
{ role: "assistant", content: "Delegated infra answer", source: "delegated", sessionKey: "agent:bellonda" },
|
|
649
|
+
]);
|
|
650
|
+
});
|
|
651
|
+
test("sendToOrchestrator preserves literal mentions inside persistent agent sessions", async (t) => {
|
|
652
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
653
|
+
registry: [
|
|
654
|
+
{
|
|
655
|
+
slug: "bellonda",
|
|
656
|
+
name: "Bellonda",
|
|
657
|
+
model: "claude-sonnet-4.6",
|
|
658
|
+
systemMessage: "You are Bellonda.",
|
|
659
|
+
persistent: true,
|
|
660
|
+
scope: "infra",
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
slug: "hwi-noree",
|
|
664
|
+
name: "Hwi Noree",
|
|
665
|
+
model: "claude-sonnet-4.6",
|
|
666
|
+
systemMessage: "You are Hwi Noree.",
|
|
667
|
+
persistent: true,
|
|
668
|
+
scope: "brian",
|
|
669
|
+
},
|
|
670
|
+
],
|
|
671
|
+
parseMentionResult: { agentSlug: "hwi-noree", message: "please archive this" },
|
|
672
|
+
});
|
|
673
|
+
await orchestrator.initOrchestrator(client);
|
|
674
|
+
state.sessionPrompts.length = 0;
|
|
675
|
+
state.setActiveAgentCalls.length = 0;
|
|
676
|
+
await new Promise((resolve) => {
|
|
677
|
+
orchestrator.sendToOrchestrator("@hwi-noree please archive this", { type: "sse-web", sessionKey: "agent:bellonda" }, (text, done) => {
|
|
678
|
+
if (done)
|
|
679
|
+
resolve(text);
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] @hwi-noree please archive this" }]);
|
|
683
|
+
assert.deepEqual(state.setActiveAgentCalls, []);
|
|
684
|
+
});
|
|
519
685
|
test("sendToOrchestrator logs both sides, remembers web auth context, and records routing state", async (t) => {
|
|
520
686
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
521
687
|
config: {
|
|
@@ -1007,13 +1173,14 @@ test("@mentions route through the orchestrator session without invoking the mode
|
|
|
1007
1173
|
assert.deepEqual(state.routerArgs, []);
|
|
1008
1174
|
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] polish the landing page" }]);
|
|
1009
1175
|
});
|
|
1010
|
-
test("feedAgentResult emits an attributed agent reply
|
|
1176
|
+
test("feedAgentResult emits an attributed short agent reply before starting the orchestrator acknowledgement", async (t) => {
|
|
1011
1177
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
1012
1178
|
config: {
|
|
1013
1179
|
copilotModel: "claude-sonnet-4.6",
|
|
1014
1180
|
selfEditEnabled: true,
|
|
1015
1181
|
},
|
|
1016
1182
|
sendResult: "Agent complete",
|
|
1183
|
+
sendDeltas: ["Agent complete"],
|
|
1017
1184
|
taskSessionKeys: new Map([["task-9", "chat:bg-lifecycle"]]),
|
|
1018
1185
|
});
|
|
1019
1186
|
await orchestrator.initOrchestrator(client);
|
|
@@ -1021,17 +1188,20 @@ test("feedAgentResult emits an attributed agent reply turn and sends only a shor
|
|
|
1021
1188
|
const notified = new Promise((resolve) => {
|
|
1022
1189
|
orchestrator.setProactiveNotify(resolve);
|
|
1023
1190
|
});
|
|
1024
|
-
|
|
1191
|
+
const agentReply = "Fixed the flaky test. ".repeat(40);
|
|
1192
|
+
orchestrator.feedAgentResult("task-9", "coder", agentReply);
|
|
1193
|
+
assert.deepEqual(events.map((event) => event.type), ["turn:started", "turn:delta", "turn:complete"], "short agent replies should fully emit before the orchestrator acknowledgement starts");
|
|
1194
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1025
1195
|
assert.equal(await notified, "Agent complete");
|
|
1026
1196
|
assert.deepEqual(state.sessionPrompts, [{
|
|
1027
1197
|
prompt: "[Agent task completed] @coder finished task task-9. Their reply has been shown to the user. Acknowledge briefly.",
|
|
1028
1198
|
}]);
|
|
1029
|
-
assert.equal(state.sessionPrompts[0]?.prompt.includes(
|
|
1199
|
+
assert.equal(state.sessionPrompts[0]?.prompt.includes(agentReply), false, "orchestrator notification must not include the full agent reply body");
|
|
1030
1200
|
const started = events.filter((event) => event.type === "turn:started");
|
|
1031
1201
|
const deltas = events.filter((event) => event.type === "turn:delta");
|
|
1032
1202
|
const completed = events.filter((event) => event.type === "turn:complete");
|
|
1033
1203
|
assert.equal(started.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:started");
|
|
1034
|
-
assert.equal(deltas.length,
|
|
1204
|
+
assert.equal(deltas.length, 2, "agent reply plus orchestrator acknowledgement should each emit one delta");
|
|
1035
1205
|
assert.equal(completed.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:complete");
|
|
1036
1206
|
assert.equal(started[0]?.agentSlug, "coder");
|
|
1037
1207
|
assert.equal(started[0]?.agentDisplayName, "Kaylee");
|
|
@@ -1039,7 +1209,7 @@ test("feedAgentResult emits an attributed agent reply turn and sends only a shor
|
|
|
1039
1209
|
assert.deepEqual(state.dbLogs, [
|
|
1040
1210
|
{
|
|
1041
1211
|
role: "agent_completion",
|
|
1042
|
-
content:
|
|
1212
|
+
content: agentReply,
|
|
1043
1213
|
source: "background",
|
|
1044
1214
|
sessionKey: "chat:bg-lifecycle",
|
|
1045
1215
|
agentSlug: "coder",
|
|
@@ -1055,10 +1225,11 @@ test("feedAgentResult emits an attributed agent reply turn and sends only a shor
|
|
|
1055
1225
|
},
|
|
1056
1226
|
]);
|
|
1057
1227
|
assert.equal(deltas[0]?.turnId, started[0]?.turnId);
|
|
1058
|
-
assert.deepEqual(deltas[0]?.part, { type: "text", text:
|
|
1228
|
+
assert.deepEqual(deltas[0]?.part, { type: "text", text: agentReply });
|
|
1059
1229
|
assert.equal(completed[0]?.turnId, started[0]?.turnId);
|
|
1060
|
-
assert.equal(completed[0]?.finalMessage,
|
|
1230
|
+
assert.equal(completed[0]?.finalMessage, agentReply);
|
|
1061
1231
|
assert.notEqual(started[0]?.turnId, started[1]?.turnId, "agent reply and orchestrator acknowledgement need distinct turns");
|
|
1232
|
+
assert.deepEqual(events.map((event) => event.type), ["turn:started", "turn:delta", "turn:complete", "turn:started", "turn:delta", "turn:complete"]);
|
|
1062
1233
|
});
|
|
1063
1234
|
test("feedAgentResult emits a delta even when the agent result is empty", async (t) => {
|
|
1064
1235
|
const { orchestrator, client } = await loadOrchestratorModule(t, {
|
|
@@ -76,6 +76,9 @@ export class SessionManager {
|
|
|
76
76
|
get canEvict() {
|
|
77
77
|
return !this._processing && this._queue.length === 0;
|
|
78
78
|
}
|
|
79
|
+
get isPersistent() {
|
|
80
|
+
return this.sessionKey.startsWith("agent:");
|
|
81
|
+
}
|
|
79
82
|
get lastActivityAt() {
|
|
80
83
|
return this._lastActivityAt;
|
|
81
84
|
}
|
|
@@ -273,7 +276,7 @@ export class SessionRegistry {
|
|
|
273
276
|
const existing = this.managers.get(sessionKey);
|
|
274
277
|
if (existing)
|
|
275
278
|
return existing;
|
|
276
|
-
if (this.
|
|
279
|
+
if (this.nonPersistentSize() >= this.options.maxActive) {
|
|
277
280
|
this.evictLRU();
|
|
278
281
|
}
|
|
279
282
|
const manager = this.createManager(sessionKey);
|
|
@@ -330,6 +333,9 @@ export class SessionRegistry {
|
|
|
330
333
|
runTtlEviction() {
|
|
331
334
|
const now = Date.now();
|
|
332
335
|
for (const [sessionKey, manager] of [...this.managers.entries()]) {
|
|
336
|
+
if (manager.isPersistent) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
333
339
|
if (manager.canEvict && now - manager.lastActivityAt > this.options.idleTtlMs) {
|
|
334
340
|
const idleMs = now - manager.lastActivityAt;
|
|
335
341
|
this.managers.delete(sessionKey);
|
|
@@ -340,7 +346,7 @@ export class SessionRegistry {
|
|
|
340
346
|
}
|
|
341
347
|
evictLRU() {
|
|
342
348
|
const evictable = [...this.managers.entries()]
|
|
343
|
-
.filter(([, m]) => m.canEvict)
|
|
349
|
+
.filter(([, m]) => m.canEvict && !m.isPersistent)
|
|
344
350
|
.sort(([, a], [, b]) => a.lastActivityAt - b.lastActivityAt);
|
|
345
351
|
if (evictable.length === 0) {
|
|
346
352
|
log.warn({ size: this.managers.size, max: this.options.maxActive }, "At max active sessions and no idle sessions available for LRU eviction");
|
|
@@ -351,6 +357,9 @@ export class SessionRegistry {
|
|
|
351
357
|
void manager.evict("lru-bumped");
|
|
352
358
|
log.info({ sessionKey, reason: "lru-bumped" }, "session.evicted");
|
|
353
359
|
}
|
|
360
|
+
nonPersistentSize() {
|
|
361
|
+
return [...this.managers.values()].filter((manager) => !manager.isPersistent).length;
|
|
362
|
+
}
|
|
354
363
|
/** Shut down all sessions. Stops the eviction timer and disconnects every session. */
|
|
355
364
|
async shutdown() {
|
|
356
365
|
this.stopEvictionTimer();
|
|
@@ -312,6 +312,31 @@ test("SessionRegistry: TTL eviction removes sessions idle beyond the TTL", async
|
|
|
312
312
|
assert.ok(disconnectLog.includes("idle-session"), "idle session must be evicted after TTL");
|
|
313
313
|
assert.ok(!registry.get("idle-session"), "idle session must be removed");
|
|
314
314
|
});
|
|
315
|
+
test("SessionRegistry: TTL eviction does not remove persistent agent sessions", async () => {
|
|
316
|
+
const SHORT_TTL = 40;
|
|
317
|
+
const { registry, disconnectLog } = makeRegistry({ idleTtlMs: SHORT_TTL });
|
|
318
|
+
const manager = registry.getOrCreate("agent:bellonda");
|
|
319
|
+
await manager.ensureSession();
|
|
320
|
+
registry.startEvictionTimer();
|
|
321
|
+
await new Promise((r) => setTimeout(r, SHORT_TTL * 5));
|
|
322
|
+
registry.stopEvictionTimer();
|
|
323
|
+
assert.ok(registry.get("agent:bellonda"), "persistent agent session must stay registered after TTL");
|
|
324
|
+
assert.equal(disconnectLog.includes("agent:bellonda"), false, "persistent agent session must not disconnect on TTL");
|
|
325
|
+
});
|
|
326
|
+
test("SessionRegistry: LRU eviction skips persistent agent sessions", async () => {
|
|
327
|
+
const { registry, disconnectLog } = makeRegistry({ maxActive: 1, idleTtlMs: 60_000 });
|
|
328
|
+
const persistent = registry.getOrCreate("agent:bellonda");
|
|
329
|
+
await persistent.ensureSession();
|
|
330
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
331
|
+
const regular = registry.getOrCreate("regular-session");
|
|
332
|
+
await regular.ensureSession();
|
|
333
|
+
registry.getOrCreate("new-session");
|
|
334
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
335
|
+
assert.ok(registry.get("agent:bellonda"), "persistent agent session must not be LRU-evicted");
|
|
336
|
+
assert.ok(!registry.get("regular-session"), "oldest non-persistent idle session should be evicted");
|
|
337
|
+
assert.equal(disconnectLog.includes("agent:bellonda"), false);
|
|
338
|
+
assert.equal(disconnectLog.includes("regular-session"), true);
|
|
339
|
+
});
|
|
315
340
|
test("SessionRegistry: shutdown disconnects all sessions", async () => {
|
|
316
341
|
const { registry, disconnectLog } = makeRegistry();
|
|
317
342
|
for (const sk of ["a", "b", "c"]) {
|
|
@@ -64,6 +64,7 @@ function expectedDelegatedPrompt(task, warningLines = []) {
|
|
|
64
64
|
}
|
|
65
65
|
async function loadToolsModule(t, options) {
|
|
66
66
|
const sentPrompts = [];
|
|
67
|
+
const persistentSends = [];
|
|
67
68
|
const taskId = options?.taskId ?? `delegated-task-${Date.now()}-${Math.random()}`;
|
|
68
69
|
const fakeSession = {
|
|
69
70
|
on: () => () => { },
|
|
@@ -86,15 +87,29 @@ async function loadToolsModule(t, options) {
|
|
|
86
87
|
invalidateOrchestratorSession: () => { },
|
|
87
88
|
resetCheckpointSessionState: () => { },
|
|
88
89
|
switchSessionModel: async () => { },
|
|
90
|
+
sendToAgentSession: async (slug, prompt, delegatedTaskId) => {
|
|
91
|
+
persistentSends.push({ slug, prompt, taskId: delegatedTaskId });
|
|
92
|
+
return `persistent handled: ${prompt}`;
|
|
93
|
+
},
|
|
89
94
|
},
|
|
90
95
|
});
|
|
91
96
|
t.mock.module("./agents.js", {
|
|
92
97
|
namedExports: {
|
|
93
|
-
getAgentRegistry: () => [
|
|
98
|
+
getAgentRegistry: () => [
|
|
99
|
+
{ slug: "coder", name: "Coder", model: "claude-sonnet-4.6" },
|
|
100
|
+
{ slug: "bellonda", name: "Bellonda", model: "claude-sonnet-4.6", persistent: true, scope: "infra" },
|
|
101
|
+
],
|
|
94
102
|
getAgent: (name) => name === "coder"
|
|
95
103
|
? { slug: "coder", name: "Coder", model: "claude-sonnet-4.6" }
|
|
96
|
-
:
|
|
97
|
-
|
|
104
|
+
: name === "bellonda"
|
|
105
|
+
? { slug: "bellonda", name: "Bellonda", model: "claude-sonnet-4.6", persistent: true, scope: "infra" }
|
|
106
|
+
: undefined,
|
|
107
|
+
createEphemeralAgentSession: async () => {
|
|
108
|
+
if (options?.persistentAgent) {
|
|
109
|
+
throw new Error("persistent agent should not use ephemeral session");
|
|
110
|
+
}
|
|
111
|
+
return fakeSession;
|
|
112
|
+
},
|
|
98
113
|
getAgentSessionStatus: () => ({ tasks: [] }),
|
|
99
114
|
getActiveTasks: () => [],
|
|
100
115
|
getTask: () => undefined,
|
|
@@ -115,7 +130,7 @@ async function loadToolsModule(t, options) {
|
|
|
115
130
|
},
|
|
116
131
|
});
|
|
117
132
|
const module = await import(new URL(`./tools.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
118
|
-
return { module, sentPrompts, taskId };
|
|
133
|
+
return { module, sentPrompts, taskId, persistentSends };
|
|
119
134
|
}
|
|
120
135
|
test.beforeEach(() => {
|
|
121
136
|
process.env.CHAPTERHOUSE_HOME = mkdtempSync(join(tmpdir(), "chapterhouse-tools-agent-"));
|
|
@@ -235,4 +250,37 @@ test("delegate_to_agent does not inject orchestrator memory_context into subagen
|
|
|
235
250
|
assert.deepEqual(sentPrompts, [expectedDelegatedPrompt(task)]);
|
|
236
251
|
assert.equal(sentPrompts.some((prompt) => prompt.includes("<memory_context>")), false);
|
|
237
252
|
});
|
|
253
|
+
test("delegate_to_agent sends persistent agents through their backend session and returns result to orchestrator", async (t) => {
|
|
254
|
+
const { module, sentPrompts, persistentSends, taskId } = await loadToolsModule(t, {
|
|
255
|
+
taskId: "delegated-persistent-001",
|
|
256
|
+
persistentAgent: true,
|
|
257
|
+
});
|
|
258
|
+
const completions = [];
|
|
259
|
+
const tools = module.createTools({
|
|
260
|
+
client: { async listModels() { return []; } },
|
|
261
|
+
onAgentTaskComplete: (completedTaskId, agentSlug, result) => {
|
|
262
|
+
completions.push({ taskId: completedTaskId, agentSlug, result });
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
const tool = tools.find((entry) => entry.name === "delegate_to_agent");
|
|
266
|
+
assert.ok(tool, "delegate_to_agent tool should be registered");
|
|
267
|
+
const response = await tool.handler({
|
|
268
|
+
agent_name: "bellonda",
|
|
269
|
+
summary: "Inspect infra drift",
|
|
270
|
+
task: "Run terraform plan for the VPC.",
|
|
271
|
+
}, {});
|
|
272
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
273
|
+
assert.match(String(response), /Task delegated to @bellonda/);
|
|
274
|
+
assert.deepEqual(sentPrompts, [], "persistent delegation must not create/use an ephemeral session");
|
|
275
|
+
assert.deepEqual(persistentSends, [{
|
|
276
|
+
slug: "bellonda",
|
|
277
|
+
prompt: "Run terraform plan for the VPC.",
|
|
278
|
+
taskId,
|
|
279
|
+
}]);
|
|
280
|
+
assert.deepEqual(completions, [{
|
|
281
|
+
taskId,
|
|
282
|
+
agentSlug: "bellonda",
|
|
283
|
+
result: "persistent handled: Run terraform plan for the VPC.",
|
|
284
|
+
}]);
|
|
285
|
+
});
|
|
238
286
|
//# sourceMappingURL=tools.agent.test.js.map
|
package/dist/copilot/tools.js
CHANGED
|
@@ -7,7 +7,7 @@ import { homedir } from "os";
|
|
|
7
7
|
import { listSkills, createSkill, removeSkill } from "./skills.js";
|
|
8
8
|
import { config, persistModel } from "../config.js";
|
|
9
9
|
import { agentEventBus } from "./agent-event-bus.js";
|
|
10
|
-
import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
|
|
10
|
+
import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, sendToAgentSession, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
|
|
11
11
|
import { getRouterConfig, updateRouterConfig } from "./router.js";
|
|
12
12
|
import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
|
|
13
13
|
import { searchIndex, addToIndex, removeFromIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
|
|
@@ -65,6 +65,21 @@ function requireOrchestratorMemoryWrite() {
|
|
|
65
65
|
}
|
|
66
66
|
return null;
|
|
67
67
|
}
|
|
68
|
+
function resolveProposalScopeSlug(requestedScopeSlug) {
|
|
69
|
+
const agentSlug = agentsModule.getCurrentToolAgentSlug?.();
|
|
70
|
+
if (!agentSlug || agentSlug === "chapterhouse") {
|
|
71
|
+
return requestedScopeSlug;
|
|
72
|
+
}
|
|
73
|
+
const activeScope = getMemoryActiveScope();
|
|
74
|
+
if (activeScope) {
|
|
75
|
+
return activeScope.slug;
|
|
76
|
+
}
|
|
77
|
+
const agent = getAgent(agentSlug);
|
|
78
|
+
if (agent?.persistent && agent.scope) {
|
|
79
|
+
return agent.scope;
|
|
80
|
+
}
|
|
81
|
+
return requestedScopeSlug;
|
|
82
|
+
}
|
|
68
83
|
function resolveMemoryScopeForWrite(explicitScope, content) {
|
|
69
84
|
const explicit = explicitScope ? getMemoryScope(explicitScope) : undefined;
|
|
70
85
|
if (explicitScope && !explicit) {
|
|
@@ -210,15 +225,6 @@ export function createTools(deps) {
|
|
|
210
225
|
}
|
|
211
226
|
const delegatedSlug = agent.slug;
|
|
212
227
|
const taskId = createTaskId();
|
|
213
|
-
let session;
|
|
214
|
-
try {
|
|
215
|
-
const allTools = createTools(deps);
|
|
216
|
-
session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override, undefined, taskId);
|
|
217
|
-
}
|
|
218
|
-
catch (err) {
|
|
219
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
220
|
-
return `Failed to create session for @${delegatedSlug}: ${msg}`;
|
|
221
|
-
}
|
|
222
228
|
const task = registerTask(delegatedSlug, args.summary, getCurrentSourceChannel(), taskId);
|
|
223
229
|
const activeProjectRules = getCurrentActiveProjectRules();
|
|
224
230
|
const warningLines = activeProjectRules
|
|
@@ -232,6 +238,71 @@ export function createTools(deps) {
|
|
|
232
238
|
const db = getDb();
|
|
233
239
|
db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, prompt, status, origin_channel, session_key, source)
|
|
234
240
|
VALUES (?, ?, ?, ?, 'running', ?, ?, 'adhoc')`).run(task.taskId, delegatedSlug, args.summary, args.task, task.originChannel || null, getCurrentSessionKey());
|
|
241
|
+
if (agent.persistent) {
|
|
242
|
+
(async () => {
|
|
243
|
+
try {
|
|
244
|
+
const output = await sendToAgentSession(delegatedSlug, taskPrompt, task.taskId);
|
|
245
|
+
completeTask(task.taskId, output);
|
|
246
|
+
updateTaskResult(task.taskId, "completed", output);
|
|
247
|
+
const statusEvent = appendTaskStatusEvent(task.taskId, "completed");
|
|
248
|
+
if (statusEvent) {
|
|
249
|
+
void agentEventBus.emit({
|
|
250
|
+
type: "session:tool_call",
|
|
251
|
+
sessionId: task.taskId,
|
|
252
|
+
payload: {
|
|
253
|
+
toolName: "",
|
|
254
|
+
toolArgs: {},
|
|
255
|
+
_kind: statusEvent.kind,
|
|
256
|
+
_seq: statusEvent.seq,
|
|
257
|
+
_ts: statusEvent.ts,
|
|
258
|
+
_summary: statusEvent.summary,
|
|
259
|
+
_text: statusEvent.text,
|
|
260
|
+
_status: statusEvent.status,
|
|
261
|
+
},
|
|
262
|
+
timestamp: new Date(statusEvent.ts),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
deps.onAgentTaskComplete(task.taskId, delegatedSlug, output);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
269
|
+
failTask(task.taskId, msg);
|
|
270
|
+
updateTaskResult(task.taskId, "error", msg);
|
|
271
|
+
const statusEvent = appendTaskStatusEvent(task.taskId, "error", msg);
|
|
272
|
+
if (statusEvent) {
|
|
273
|
+
void agentEventBus.emit({
|
|
274
|
+
type: "session:tool_call",
|
|
275
|
+
sessionId: task.taskId,
|
|
276
|
+
payload: {
|
|
277
|
+
toolName: "",
|
|
278
|
+
toolArgs: {},
|
|
279
|
+
_kind: statusEvent.kind,
|
|
280
|
+
_seq: statusEvent.seq,
|
|
281
|
+
_ts: statusEvent.ts,
|
|
282
|
+
_summary: statusEvent.summary,
|
|
283
|
+
_text: statusEvent.text,
|
|
284
|
+
_status: statusEvent.status,
|
|
285
|
+
},
|
|
286
|
+
timestamp: new Date(statusEvent.ts),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
deps.onAgentTaskComplete(task.taskId, delegatedSlug, `Error: ${msg}`);
|
|
290
|
+
}
|
|
291
|
+
})();
|
|
292
|
+
const model = (args.model_override && args.model_override.length > 0)
|
|
293
|
+
? args.model_override
|
|
294
|
+
: (agent.model === "auto" ? "claude-sonnet-4.6" : agent.model || "claude-sonnet-4.6");
|
|
295
|
+
return `Task delegated to @${delegatedSlug} (${model}). Task ID: ${task.taskId}. I'll notify you when it's done.`;
|
|
296
|
+
}
|
|
297
|
+
let session;
|
|
298
|
+
try {
|
|
299
|
+
const allTools = createTools(deps);
|
|
300
|
+
session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override, undefined, taskId);
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
304
|
+
return `Failed to create session for @${delegatedSlug}: ${msg}`;
|
|
305
|
+
}
|
|
235
306
|
// Capture the parent's activity callback so the child session can stream
|
|
236
307
|
// its events back to the originating SSE connection. This survives past
|
|
237
308
|
// the parent assistant turn — the child runs long after the parent's
|
|
@@ -876,7 +947,7 @@ export function createTools(deps) {
|
|
|
876
947
|
: actionItemProposalPayloadSchema.parse(parsedArgs.payload);
|
|
877
948
|
const proposal = queueMemoryProposal({
|
|
878
949
|
kind: parsedArgs.kind,
|
|
879
|
-
scopeSlug: parsedArgs.scope_slug,
|
|
950
|
+
scopeSlug: resolveProposalScopeSlug(parsedArgs.scope_slug),
|
|
880
951
|
payload,
|
|
881
952
|
confidence: parsedArgs.confidence ?? 0.5,
|
|
882
953
|
reason: parsedArgs.reason,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import test from "node:test";
|
|
@@ -46,6 +46,7 @@ test("memory_set_scope invalidates the orchestrator session after scheduling the
|
|
|
46
46
|
invalidateOrchestratorSession: (sessionKey) => {
|
|
47
47
|
events.push(`invalidate:${sessionKey}`);
|
|
48
48
|
},
|
|
49
|
+
sendToAgentSession: async () => "",
|
|
49
50
|
switchSessionModel: async () => { },
|
|
50
51
|
},
|
|
51
52
|
});
|
|
@@ -194,6 +195,54 @@ test("memory_propose queues pending proposals, defaults scope from the active sc
|
|
|
194
195
|
assert.equal(payload.reason, "The user explicitly asked for the new proposal path.");
|
|
195
196
|
assert.equal(payload.payload.content, "Subagents can queue durable observations for orchestrator review.");
|
|
196
197
|
});
|
|
198
|
+
test("memory_propose from a persistent agent is bound to that agent's scope", async () => {
|
|
199
|
+
const home = process.env.CHAPTERHOUSE_HOME;
|
|
200
|
+
assert.ok(home, "test home should be set");
|
|
201
|
+
const chapterhouseHome = home.endsWith(".chapterhouse") ? home : join(home, ".chapterhouse");
|
|
202
|
+
const agentsDir = join(chapterhouseHome, "agents");
|
|
203
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
204
|
+
writeFileSync(join(agentsDir, "bellonda.agent.md"), [
|
|
205
|
+
"---",
|
|
206
|
+
"name: Bellonda",
|
|
207
|
+
"description: Mentat of the infrastructure domain",
|
|
208
|
+
"model: claude-sonnet-4.6",
|
|
209
|
+
"persistent: true",
|
|
210
|
+
"scope: infra",
|
|
211
|
+
"---",
|
|
212
|
+
"",
|
|
213
|
+
"You are Bellonda.",
|
|
214
|
+
].join("\n"));
|
|
215
|
+
const { toolsModule, agentsModule, dbModule } = await loadModules();
|
|
216
|
+
agentsModule.loadAgents();
|
|
217
|
+
const tools = toolsModule.createTools({
|
|
218
|
+
client: { async listModels() { return []; } },
|
|
219
|
+
onAgentTaskComplete: () => { },
|
|
220
|
+
});
|
|
221
|
+
const bindToolsToAgent = agentsModule.bindToolsToAgent;
|
|
222
|
+
assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
|
|
223
|
+
const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
|
|
224
|
+
const bellondaTools = bindToolsToAgent("bellonda", tools, "task-persistent-scope-001");
|
|
225
|
+
await findTool(chapterhouseTools, "memory_set_scope").handler({ slug: "chapterhouse" }, {});
|
|
226
|
+
const memoryModule = await import("../memory/index.js");
|
|
227
|
+
const proposed = await memoryModule.withActiveScope("infra", () => findTool(bellondaTools, "memory_propose").handler({
|
|
228
|
+
kind: "observation",
|
|
229
|
+
scope_slug: "chapterhouse",
|
|
230
|
+
payload: {
|
|
231
|
+
content: "Persistent agents should not be able to write proposals outside their bound scope.",
|
|
232
|
+
},
|
|
233
|
+
}, {}));
|
|
234
|
+
assert.equal(proposed.status, "queued");
|
|
235
|
+
const row = dbModule.getDb().prepare(`
|
|
236
|
+
SELECT source_agent, source_task_id, payload
|
|
237
|
+
FROM mem_inbox
|
|
238
|
+
WHERE id = ?
|
|
239
|
+
`).get(proposed.proposal_id);
|
|
240
|
+
assert.ok(row, "memory_propose should insert a mem_inbox row");
|
|
241
|
+
assert.equal(row.source_agent, "bellonda");
|
|
242
|
+
assert.equal(row.source_task_id, "task-persistent-scope-001");
|
|
243
|
+
const payload = JSON.parse(row.payload);
|
|
244
|
+
assert.equal(payload.scope_slug, "infra");
|
|
245
|
+
});
|
|
197
246
|
test("memory_propose accepts entity proposals with entity_kind and queues the full payload", async () => {
|
|
198
247
|
const { toolsModule, agentsModule, dbModule } = await loadModules();
|
|
199
248
|
const tools = toolsModule.createTools({
|