@goondan/openharness-base 0.1.3 → 0.1.5
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/index.d.ts +15 -3
- package/dist/index.js +50 -19
- package/package.json +2 -2
- package/src/__tests__/basic-system-prompt.test.ts +31 -0
- package/src/__tests__/compaction-summarize.test.ts +50 -2
- package/src/__tests__/message-window.test.ts +1 -0
- package/src/__tests__/required-tools-guard.test.ts +1 -0
- package/src/extensions/basic-system-prompt.ts +24 -13
- package/src/extensions/compaction-summarize.ts +61 -15
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { Extension, ToolDefinition } from '@goondan/openharness-types';
|
|
1
|
+
import { Extension, LlmChatOptions, Message, ToolDefinition } from '@goondan/openharness-types';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* BasicSystemPrompt extension — prepends a system message to the conversation
|
|
5
5
|
* at the start of every turn.
|
|
6
6
|
*
|
|
7
|
+
* Uses a fixed message ID so the system prompt is only appended once;
|
|
8
|
+
* subsequent turns detect the existing message and skip the append.
|
|
9
|
+
*
|
|
7
10
|
* Priority 10 (HIGH) ensures it runs before other turn middleware.
|
|
8
11
|
*/
|
|
9
12
|
declare function BasicSystemPrompt(text: string): Extension;
|
|
@@ -18,13 +21,22 @@ declare function MessageWindow(config: {
|
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* CompactionSummarize extension — when message count exceeds `threshold`,
|
|
21
|
-
* removes the oldest messages and
|
|
24
|
+
* removes the oldest messages and replaces them with an LLM-generated summary.
|
|
25
|
+
*
|
|
26
|
+
* By default, uses the agent's own LLM (`ctx.llm`) to produce the summary.
|
|
27
|
+
* A custom `summarizer` callback can override this for advanced use cases
|
|
28
|
+
* (e.g. using a cheaper model, external API, or deterministic logic).
|
|
22
29
|
*
|
|
23
|
-
*
|
|
30
|
+
* @param config.threshold - Trigger compaction when messages exceed this count.
|
|
31
|
+
* @param config.summaryPrompt - Custom system prompt for the LLM summarizer.
|
|
32
|
+
* @param config.summarizer - Optional override: produce summary text from messages.
|
|
24
33
|
*/
|
|
25
34
|
declare function CompactionSummarize(config: {
|
|
26
35
|
threshold: number;
|
|
27
36
|
summaryPrompt?: string;
|
|
37
|
+
/** LLM options for the summarization call (e.g. model override for cheaper summarization). */
|
|
38
|
+
llmOptions?: LlmChatOptions;
|
|
39
|
+
summarizer?: (messages: Message[]) => Promise<string>;
|
|
28
40
|
}): Extension;
|
|
29
41
|
|
|
30
42
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/extensions/basic-system-prompt.ts
|
|
2
|
-
|
|
2
|
+
var SYSTEM_MESSAGE_ID = "sys-basic-system-prompt";
|
|
3
3
|
function BasicSystemPrompt(text) {
|
|
4
4
|
return {
|
|
5
5
|
name: "basic-system-prompt",
|
|
@@ -7,19 +7,24 @@ function BasicSystemPrompt(text) {
|
|
|
7
7
|
api.pipeline.register(
|
|
8
8
|
"turn",
|
|
9
9
|
async (ctx, next) => {
|
|
10
|
-
ctx.conversation.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
const alreadyExists = ctx.conversation.messages.some(
|
|
11
|
+
(m) => m.id === SYSTEM_MESSAGE_ID
|
|
12
|
+
);
|
|
13
|
+
if (!alreadyExists) {
|
|
14
|
+
ctx.conversation.emit({
|
|
15
|
+
type: "append",
|
|
16
|
+
message: {
|
|
17
|
+
id: SYSTEM_MESSAGE_ID,
|
|
18
|
+
data: {
|
|
19
|
+
role: "system",
|
|
20
|
+
content: text
|
|
21
|
+
},
|
|
22
|
+
metadata: {
|
|
23
|
+
__createdBy: "basic-system-prompt"
|
|
24
|
+
}
|
|
20
25
|
}
|
|
21
|
-
}
|
|
22
|
-
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
23
28
|
return next();
|
|
24
29
|
},
|
|
25
30
|
{ priority: 10 }
|
|
@@ -47,7 +52,13 @@ function MessageWindow(config) {
|
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
// src/extensions/compaction-summarize.ts
|
|
50
|
-
import { randomUUID
|
|
55
|
+
import { randomUUID } from "crypto";
|
|
56
|
+
var DEFAULT_SUMMARY_PROMPT = "You are a conversation compactor. Summarize the following messages into a concise summary that preserves all important context, decisions, facts, and action items. Be thorough but brief. Output only the summary text, nothing else.";
|
|
57
|
+
function messageToText(m) {
|
|
58
|
+
const role = m.data.role;
|
|
59
|
+
const content = typeof m.data.content === "string" ? m.data.content : JSON.stringify(m.data.content);
|
|
60
|
+
return `[${role}]: ${content}`;
|
|
61
|
+
}
|
|
51
62
|
function CompactionSummarize(config) {
|
|
52
63
|
return {
|
|
53
64
|
name: "compaction-summarize",
|
|
@@ -58,16 +69,36 @@ function CompactionSummarize(config) {
|
|
|
58
69
|
const keepCount = Math.floor(config.threshold / 2);
|
|
59
70
|
const removeCount = messages.length - keepCount;
|
|
60
71
|
const toRemove = messages.slice(0, removeCount);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
let summaryText;
|
|
73
|
+
if (config.summarizer) {
|
|
74
|
+
summaryText = await config.summarizer([...toRemove]);
|
|
75
|
+
} else {
|
|
76
|
+
const transcript = toRemove.map(messageToText).join("\n");
|
|
77
|
+
const prompt = config.summaryPrompt ?? DEFAULT_SUMMARY_PROMPT;
|
|
78
|
+
const llmResponse = await ctx.llm.chat(
|
|
79
|
+
[
|
|
80
|
+
{ id: `compaction-sys-${randomUUID()}`, data: { role: "system", content: prompt }, metadata: {} },
|
|
81
|
+
{ id: `compaction-usr-${randomUUID()}`, data: { role: "user", content: transcript }, metadata: {} }
|
|
82
|
+
],
|
|
83
|
+
[],
|
|
84
|
+
// no tools needed for summarization
|
|
85
|
+
ctx.abortSignal,
|
|
86
|
+
config.llmOptions
|
|
87
|
+
);
|
|
88
|
+
summaryText = llmResponse.text ?? transcript;
|
|
89
|
+
}
|
|
90
|
+
const [firstToRemove, ...restToRemove] = toRemove;
|
|
91
|
+
ctx.conversation.emit({
|
|
92
|
+
type: "remove",
|
|
93
|
+
messageId: firstToRemove.id
|
|
94
|
+
});
|
|
95
|
+
for (const msg of restToRemove) {
|
|
65
96
|
ctx.conversation.emit({ type: "remove", messageId: msg.id });
|
|
66
97
|
}
|
|
67
98
|
ctx.conversation.emit({
|
|
68
99
|
type: "append",
|
|
69
100
|
message: {
|
|
70
|
-
id: `summary-${
|
|
101
|
+
id: `summary-${randomUUID()}`,
|
|
71
102
|
data: {
|
|
72
103
|
role: "system",
|
|
73
104
|
content: `[Summary of earlier conversation]: ${summaryText}`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goondan/openharness-base",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@goondan/openharness-types": "0.1.
|
|
12
|
+
"@goondan/openharness-types": "0.1.5"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@types/node": "^25.5.0",
|
|
@@ -82,6 +82,7 @@ function makeTurnContext(conversation: ConversationState): TurnContext {
|
|
|
82
82
|
receivedAt: new Date().toISOString(),
|
|
83
83
|
},
|
|
84
84
|
},
|
|
85
|
+
llm: { chat: vi.fn().mockResolvedValue({ text: "mock" }) },
|
|
85
86
|
};
|
|
86
87
|
}
|
|
87
88
|
|
|
@@ -147,9 +148,39 @@ describe("BasicSystemPrompt", () => {
|
|
|
147
148
|
const emittedEvent = conversation.emitted[0];
|
|
148
149
|
expect(emittedEvent.type).toBe("append");
|
|
149
150
|
if (emittedEvent.type === "append") {
|
|
151
|
+
expect(emittedEvent.message.id).toBe("sys-basic-system-prompt");
|
|
150
152
|
expect(emittedEvent.message.data.role).toBe("system");
|
|
151
153
|
expect(emittedEvent.message.data.content).toBe("You are helpful.");
|
|
152
154
|
expect(emittedEvent.message.metadata?.__createdBy).toBe("basic-system-prompt");
|
|
153
155
|
}
|
|
154
156
|
});
|
|
157
|
+
|
|
158
|
+
it("does not duplicate the system message on subsequent turns", async () => {
|
|
159
|
+
const conversation = makeMockConversationState();
|
|
160
|
+
const { api, registeredMiddleware } = makeMockApi(conversation);
|
|
161
|
+
|
|
162
|
+
const ext = BasicSystemPrompt("You are helpful.");
|
|
163
|
+
ext.register(api);
|
|
164
|
+
|
|
165
|
+
const middleware = registeredMiddleware[0].handler;
|
|
166
|
+
const next = vi.fn(async () => stubTurnResult);
|
|
167
|
+
|
|
168
|
+
// First turn — should append
|
|
169
|
+
const ctx1 = makeTurnContext(conversation);
|
|
170
|
+
await middleware(ctx1, next);
|
|
171
|
+
expect(conversation.emitted).toHaveLength(1);
|
|
172
|
+
|
|
173
|
+
// Second turn — system message already in conversation.messages, should skip
|
|
174
|
+
const ctx2 = makeTurnContext(conversation);
|
|
175
|
+
await middleware(ctx2, next);
|
|
176
|
+
expect(conversation.emitted).toHaveLength(1); // still 1, no new append
|
|
177
|
+
|
|
178
|
+
// Third turn — still no duplication
|
|
179
|
+
const ctx3 = makeTurnContext(conversation);
|
|
180
|
+
await middleware(ctx3, next);
|
|
181
|
+
expect(conversation.emitted).toHaveLength(1);
|
|
182
|
+
|
|
183
|
+
// next() should have been called every turn
|
|
184
|
+
expect(next).toHaveBeenCalledTimes(3);
|
|
185
|
+
});
|
|
155
186
|
});
|
|
@@ -83,6 +83,12 @@ function makeMockApi(conversation: ConversationState): {
|
|
|
83
83
|
return { api, registeredMiddleware };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
function makeMockLlmClient() {
|
|
87
|
+
return {
|
|
88
|
+
chat: vi.fn().mockResolvedValue({ text: "LLM-generated summary of the conversation." }),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
86
92
|
function makeStepContext(conversation: ConversationState): StepContext {
|
|
87
93
|
return {
|
|
88
94
|
turnId: "turn-1",
|
|
@@ -101,6 +107,7 @@ function makeStepContext(conversation: ConversationState): StepContext {
|
|
|
101
107
|
receivedAt: new Date().toISOString(),
|
|
102
108
|
},
|
|
103
109
|
},
|
|
110
|
+
llm: makeMockLlmClient(),
|
|
104
111
|
};
|
|
105
112
|
}
|
|
106
113
|
|
|
@@ -223,12 +230,53 @@ describe("CompactionSummarize", () => {
|
|
|
223
230
|
|
|
224
231
|
await middleware(ctx, next);
|
|
225
232
|
|
|
233
|
+
// Verify LLM was called for summarization
|
|
234
|
+
expect(ctx.llm.chat).toHaveBeenCalledOnce();
|
|
235
|
+
|
|
226
236
|
const emitted = (conversation as ReturnType<typeof makeMockConversationState>).emitted;
|
|
227
237
|
const appendEvent = emitted.find((e) => e.type === "append");
|
|
228
238
|
if (appendEvent && appendEvent.type === "append") {
|
|
229
239
|
const content = appendEvent.message.data.content as string;
|
|
230
|
-
// Should include
|
|
231
|
-
expect(content).toContain("
|
|
240
|
+
// Should include the LLM-generated summary
|
|
241
|
+
expect(content).toContain("LLM-generated summary of the conversation.");
|
|
232
242
|
}
|
|
233
243
|
});
|
|
244
|
+
|
|
245
|
+
it("uses custom summarizer when provided", async () => {
|
|
246
|
+
const messages = makeMessages(12);
|
|
247
|
+
const conversation = makeMockConversationState(messages);
|
|
248
|
+
const { api, registeredMiddleware } = makeMockApi(conversation);
|
|
249
|
+
|
|
250
|
+
const summarizer = vi.fn(async (msgs: Message[]) => {
|
|
251
|
+
return `Custom summary of ${msgs.length} messages`;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const ext = CompactionSummarize({ threshold: 10, summarizer });
|
|
255
|
+
ext.register(api);
|
|
256
|
+
|
|
257
|
+
const middleware = registeredMiddleware[0].handler;
|
|
258
|
+
const ctx = makeStepContext(conversation);
|
|
259
|
+
const next = vi.fn(async () => stubStepResult);
|
|
260
|
+
|
|
261
|
+
await middleware(ctx, next);
|
|
262
|
+
|
|
263
|
+
// summarizer should have been called with the 7 removed messages
|
|
264
|
+
expect(summarizer).toHaveBeenCalledOnce();
|
|
265
|
+
expect(summarizer).toHaveBeenCalledWith(
|
|
266
|
+
expect.arrayContaining([
|
|
267
|
+
expect.objectContaining({ id: "msg-0" }),
|
|
268
|
+
expect.objectContaining({ id: "msg-6" }),
|
|
269
|
+
]),
|
|
270
|
+
);
|
|
271
|
+
expect(summarizer.mock.calls[0][0]).toHaveLength(7);
|
|
272
|
+
|
|
273
|
+
const emitted = (conversation as ReturnType<typeof makeMockConversationState>).emitted;
|
|
274
|
+
const appendEvent = emitted.find((e) => e.type === "append");
|
|
275
|
+
if (appendEvent && appendEvent.type === "append") {
|
|
276
|
+
const content = appendEvent.message.data.content as string;
|
|
277
|
+
expect(content).toContain("Custom summary of 7 messages");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
expect(next).toHaveBeenCalledOnce();
|
|
281
|
+
});
|
|
234
282
|
});
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { Extension, ExtensionApi } from "@goondan/openharness-types";
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
const SYSTEM_MESSAGE_ID = "sys-basic-system-prompt";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* BasicSystemPrompt extension — prepends a system message to the conversation
|
|
6
7
|
* at the start of every turn.
|
|
7
8
|
*
|
|
9
|
+
* Uses a fixed message ID so the system prompt is only appended once;
|
|
10
|
+
* subsequent turns detect the existing message and skip the append.
|
|
11
|
+
*
|
|
8
12
|
* Priority 10 (HIGH) ensures it runs before other turn middleware.
|
|
9
13
|
*/
|
|
10
14
|
export function BasicSystemPrompt(text: string): Extension {
|
|
@@ -15,19 +19,26 @@ export function BasicSystemPrompt(text: string): Extension {
|
|
|
15
19
|
api.pipeline.register(
|
|
16
20
|
"turn",
|
|
17
21
|
async (ctx, next) => {
|
|
18
|
-
ctx.conversation.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
const alreadyExists = ctx.conversation.messages.some(
|
|
23
|
+
(m) => m.id === SYSTEM_MESSAGE_ID,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (!alreadyExists) {
|
|
27
|
+
ctx.conversation.emit({
|
|
28
|
+
type: "append",
|
|
29
|
+
message: {
|
|
30
|
+
id: SYSTEM_MESSAGE_ID,
|
|
31
|
+
data: {
|
|
32
|
+
role: "system",
|
|
33
|
+
content: text,
|
|
34
|
+
},
|
|
35
|
+
metadata: {
|
|
36
|
+
__createdBy: "basic-system-prompt",
|
|
37
|
+
},
|
|
28
38
|
},
|
|
29
|
-
}
|
|
30
|
-
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
return next();
|
|
32
43
|
},
|
|
33
44
|
{ priority: 10 },
|
|
@@ -1,15 +1,41 @@
|
|
|
1
|
-
import type { Extension, ExtensionApi } from "@goondan/openharness-types";
|
|
1
|
+
import type { Extension, ExtensionApi, Message, LlmChatOptions } from "@goondan/openharness-types";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
|
|
4
|
+
const DEFAULT_SUMMARY_PROMPT =
|
|
5
|
+
"You are a conversation compactor. Summarize the following messages into a concise summary " +
|
|
6
|
+
"that preserves all important context, decisions, facts, and action items. " +
|
|
7
|
+
"Be thorough but brief. Output only the summary text, nothing else.";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract plain-text representation of a Message for summarization.
|
|
11
|
+
*/
|
|
12
|
+
function messageToText(m: Message): string {
|
|
13
|
+
const role = m.data.role;
|
|
14
|
+
const content =
|
|
15
|
+
typeof m.data.content === "string"
|
|
16
|
+
? m.data.content
|
|
17
|
+
: JSON.stringify(m.data.content);
|
|
18
|
+
return `[${role}]: ${content}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
4
21
|
/**
|
|
5
22
|
* CompactionSummarize extension — when message count exceeds `threshold`,
|
|
6
|
-
* removes the oldest messages and
|
|
23
|
+
* removes the oldest messages and replaces them with an LLM-generated summary.
|
|
24
|
+
*
|
|
25
|
+
* By default, uses the agent's own LLM (`ctx.llm`) to produce the summary.
|
|
26
|
+
* A custom `summarizer` callback can override this for advanced use cases
|
|
27
|
+
* (e.g. using a cheaper model, external API, or deterministic logic).
|
|
7
28
|
*
|
|
8
|
-
*
|
|
29
|
+
* @param config.threshold - Trigger compaction when messages exceed this count.
|
|
30
|
+
* @param config.summaryPrompt - Custom system prompt for the LLM summarizer.
|
|
31
|
+
* @param config.summarizer - Optional override: produce summary text from messages.
|
|
9
32
|
*/
|
|
10
33
|
export function CompactionSummarize(config: {
|
|
11
34
|
threshold: number;
|
|
12
35
|
summaryPrompt?: string;
|
|
36
|
+
/** LLM options for the summarization call (e.g. model override for cheaper summarization). */
|
|
37
|
+
llmOptions?: LlmChatOptions;
|
|
38
|
+
summarizer?: (messages: Message[]) => Promise<string>;
|
|
13
39
|
}): Extension {
|
|
14
40
|
return {
|
|
15
41
|
name: "compaction-summarize",
|
|
@@ -22,21 +48,41 @@ export function CompactionSummarize(config: {
|
|
|
22
48
|
const removeCount = messages.length - keepCount;
|
|
23
49
|
const toRemove = messages.slice(0, removeCount);
|
|
24
50
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
.join("
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
51
|
+
let summaryText: string;
|
|
52
|
+
|
|
53
|
+
if (config.summarizer) {
|
|
54
|
+
// User-provided summarizer takes precedence
|
|
55
|
+
summaryText = await config.summarizer([...toRemove]);
|
|
56
|
+
} else {
|
|
57
|
+
// Default: LLM-based summarization via ctx.llm
|
|
58
|
+
const transcript = toRemove.map(messageToText).join("\n");
|
|
59
|
+
const prompt = config.summaryPrompt ?? DEFAULT_SUMMARY_PROMPT;
|
|
60
|
+
|
|
61
|
+
const llmResponse = await ctx.llm.chat(
|
|
62
|
+
[
|
|
63
|
+
{ id: `compaction-sys-${randomUUID()}`, data: { role: "system", content: prompt }, metadata: {} },
|
|
64
|
+
{ id: `compaction-usr-${randomUUID()}`, data: { role: "user", content: transcript }, metadata: {} },
|
|
65
|
+
],
|
|
66
|
+
[], // no tools needed for summarization
|
|
67
|
+
ctx.abortSignal,
|
|
68
|
+
config.llmOptions,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
summaryText = llmResponse.text ?? transcript;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Remove old messages, replace first with summary for stable ordering
|
|
75
|
+
const [firstToRemove, ...restToRemove] = toRemove;
|
|
76
|
+
// Replace the first message with the summary
|
|
77
|
+
ctx.conversation.emit({
|
|
78
|
+
type: "remove",
|
|
79
|
+
messageId: firstToRemove.id,
|
|
80
|
+
});
|
|
81
|
+
for (const msg of restToRemove) {
|
|
36
82
|
ctx.conversation.emit({ type: "remove", messageId: msg.id });
|
|
37
83
|
}
|
|
38
84
|
|
|
39
|
-
// Prepend summary
|
|
85
|
+
// Prepend summary as a system message
|
|
40
86
|
ctx.conversation.emit({
|
|
41
87
|
type: "append",
|
|
42
88
|
message: {
|