@goondan/openharness-base 0.1.2 → 0.1.4
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 +18 -6
- package/dist/index.js +54 -23
- package/package.json +2 -2
- package/src/__tests__/{context-message.test.ts → basic-system-prompt.test.ts} +38 -55
- 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 +48 -0
- package/src/extensions/compaction-summarize.ts +61 -15
- package/src/index.ts +1 -1
- package/dist/extensions/message-integrity.d.ts +0 -8
- package/dist/extensions/message-integrity.d.ts.map +0 -1
- package/dist/extensions/message-integrity.js +0 -88
- package/dist/extensions/message-integrity.js.map +0 -1
- package/src/extensions/context-message.ts +0 -37
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
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
|
-
declare function
|
|
12
|
+
declare function BasicSystemPrompt(text: string): Extension;
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* MessageWindow extension — truncates conversation history to keep only
|
|
@@ -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
|
/**
|
|
@@ -69,4 +81,4 @@ interface WaitToolConfig {
|
|
|
69
81
|
}
|
|
70
82
|
declare function WaitTool(config?: WaitToolConfig): ToolDefinition;
|
|
71
83
|
|
|
72
|
-
export { BashTool, type BashToolConfig,
|
|
84
|
+
export { BashTool, type BashToolConfig, BasicSystemPrompt, CompactionSummarize, FileListTool, FileReadTool, FileWriteTool, HttpFetchTool, JsonQueryTool, Logging, MessageWindow, RequiredToolsGuard, TextTransformTool, ToolSearch, WaitTool, type WaitToolConfig };
|
package/dist/index.js
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
|
-
// src/extensions/
|
|
2
|
-
|
|
3
|
-
function
|
|
1
|
+
// src/extensions/basic-system-prompt.ts
|
|
2
|
+
var SYSTEM_MESSAGE_ID = "sys-basic-system-prompt";
|
|
3
|
+
function BasicSystemPrompt(text) {
|
|
4
4
|
return {
|
|
5
|
-
name: "
|
|
5
|
+
name: "basic-system-prompt",
|
|
6
6
|
register(api) {
|
|
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}`
|
|
@@ -490,8 +521,8 @@ function WaitTool(config = {}) {
|
|
|
490
521
|
}
|
|
491
522
|
export {
|
|
492
523
|
BashTool,
|
|
524
|
+
BasicSystemPrompt,
|
|
493
525
|
CompactionSummarize,
|
|
494
|
-
ContextMessage,
|
|
495
526
|
FileListTool,
|
|
496
527
|
FileReadTool,
|
|
497
528
|
FileWriteTool,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goondan/openharness-base",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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.4"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@types/node": "^25.5.0",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { BasicSystemPrompt } from "../extensions/basic-system-prompt.js";
|
|
3
3
|
import type {
|
|
4
4
|
ExtensionApi,
|
|
5
5
|
TurnMiddleware,
|
|
@@ -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
|
|
|
@@ -97,19 +98,17 @@ const stubTurnResult: TurnResult = {
|
|
|
97
98
|
// Tests
|
|
98
99
|
// ---------------------------------------------------------------------------
|
|
99
100
|
|
|
100
|
-
describe("
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
expect(ext.name).toBe("context-message");
|
|
101
|
+
describe("BasicSystemPrompt", () => {
|
|
102
|
+
it("creates an Extension with name 'basic-system-prompt'", () => {
|
|
103
|
+
const ext = BasicSystemPrompt("You are helpful.");
|
|
104
|
+
expect(ext.name).toBe("basic-system-prompt");
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
// Test 2: register() adds turn middleware via api.pipeline.register
|
|
108
107
|
it("register() calls api.pipeline.register with level 'turn'", () => {
|
|
109
108
|
const conversation = makeMockConversationState();
|
|
110
109
|
const { api, registeredMiddleware } = makeMockApi(conversation);
|
|
111
110
|
|
|
112
|
-
const ext =
|
|
111
|
+
const ext = BasicSystemPrompt("You are helpful.");
|
|
113
112
|
ext.register(api);
|
|
114
113
|
|
|
115
114
|
expect(api.pipeline.register).toHaveBeenCalledOnce();
|
|
@@ -118,86 +117,70 @@ describe("ContextMessage", () => {
|
|
|
118
117
|
expect(typeof registeredMiddleware[0].handler).toBe("function");
|
|
119
118
|
});
|
|
120
119
|
|
|
121
|
-
// Test 3: middleware is registered with HIGH priority (low number)
|
|
122
120
|
it("registers turn middleware with priority 10 (high priority)", () => {
|
|
123
121
|
const conversation = makeMockConversationState();
|
|
124
122
|
const { api, registeredMiddleware } = makeMockApi(conversation);
|
|
125
123
|
|
|
126
|
-
const ext =
|
|
124
|
+
const ext = BasicSystemPrompt("You are helpful.");
|
|
127
125
|
ext.register(api);
|
|
128
126
|
|
|
129
127
|
expect(registeredMiddleware[0].options?.priority).toBe(10);
|
|
130
128
|
});
|
|
131
129
|
|
|
132
|
-
|
|
133
|
-
it("middleware emits append event with system message matching the text argument", async () => {
|
|
130
|
+
it("middleware appends a system message and calls next()", async () => {
|
|
134
131
|
const conversation = makeMockConversationState();
|
|
135
132
|
const { api, registeredMiddleware } = makeMockApi(conversation);
|
|
136
|
-
const systemText = "You are a helpful assistant.";
|
|
137
133
|
|
|
138
|
-
const ext =
|
|
134
|
+
const ext = BasicSystemPrompt("You are helpful.");
|
|
139
135
|
ext.register(api);
|
|
140
136
|
|
|
141
137
|
const middleware = registeredMiddleware[0].handler;
|
|
142
138
|
const ctx = makeTurnContext(conversation);
|
|
143
139
|
const next = vi.fn(async () => stubTurnResult);
|
|
144
140
|
|
|
145
|
-
await middleware(ctx, next);
|
|
141
|
+
const result = await middleware(ctx, next);
|
|
146
142
|
|
|
143
|
+
expect(next).toHaveBeenCalledOnce();
|
|
144
|
+
expect(result).toBe(stubTurnResult);
|
|
145
|
+
|
|
146
|
+
// Verify system message was emitted
|
|
147
147
|
expect(conversation.emit).toHaveBeenCalledOnce();
|
|
148
|
-
const
|
|
149
|
-
expect(
|
|
150
|
-
if (
|
|
151
|
-
expect(
|
|
152
|
-
expect(
|
|
153
|
-
expect(
|
|
154
|
-
expect(
|
|
148
|
+
const emittedEvent = conversation.emitted[0];
|
|
149
|
+
expect(emittedEvent.type).toBe("append");
|
|
150
|
+
if (emittedEvent.type === "append") {
|
|
151
|
+
expect(emittedEvent.message.id).toBe("sys-basic-system-prompt");
|
|
152
|
+
expect(emittedEvent.message.data.role).toBe("system");
|
|
153
|
+
expect(emittedEvent.message.data.content).toBe("You are helpful.");
|
|
154
|
+
expect(emittedEvent.message.metadata?.__createdBy).toBe("basic-system-prompt");
|
|
155
155
|
}
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
it("middleware calls next() after emitting the system message", async () => {
|
|
158
|
+
it("does not duplicate the system message on subsequent turns", async () => {
|
|
160
159
|
const conversation = makeMockConversationState();
|
|
161
160
|
const { api, registeredMiddleware } = makeMockApi(conversation);
|
|
162
161
|
|
|
163
|
-
const ext =
|
|
162
|
+
const ext = BasicSystemPrompt("You are helpful.");
|
|
164
163
|
ext.register(api);
|
|
165
164
|
|
|
166
165
|
const middleware = registeredMiddleware[0].handler;
|
|
167
|
-
const ctx = makeTurnContext(conversation);
|
|
168
|
-
const next = vi.fn(async () => stubTurnResult);
|
|
169
|
-
|
|
170
|
-
const result = await middleware(ctx, next);
|
|
171
|
-
|
|
172
|
-
expect(next).toHaveBeenCalledOnce();
|
|
173
|
-
expect(result).toBe(stubTurnResult);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
// Test 6: multiple ContextMessage extensions → multiple system messages
|
|
177
|
-
it("multiple ContextMessage extensions each prepend their own system message", async () => {
|
|
178
|
-
const conversation = makeMockConversationState();
|
|
179
|
-
const { api: api1, registeredMiddleware: rm1 } = makeMockApi(conversation);
|
|
180
|
-
const { api: api2, registeredMiddleware: rm2 } = makeMockApi(conversation);
|
|
181
|
-
|
|
182
|
-
const ext1 = ContextMessage("First context");
|
|
183
|
-
const ext2 = ContextMessage("Second context");
|
|
184
|
-
|
|
185
|
-
ext1.register(api1);
|
|
186
|
-
ext2.register(api2);
|
|
187
|
-
|
|
188
|
-
const ctx = makeTurnContext(conversation);
|
|
189
166
|
const next = vi.fn(async () => stubTurnResult);
|
|
190
167
|
|
|
191
|
-
|
|
192
|
-
|
|
168
|
+
// First turn — should append
|
|
169
|
+
const ctx1 = makeTurnContext(conversation);
|
|
170
|
+
await middleware(ctx1, next);
|
|
171
|
+
expect(conversation.emitted).toHaveLength(1);
|
|
193
172
|
|
|
194
|
-
|
|
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
|
|
195
177
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
178
|
+
// Third turn — still no duplication
|
|
179
|
+
const ctx3 = makeTurnContext(conversation);
|
|
180
|
+
await middleware(ctx3, next);
|
|
181
|
+
expect(conversation.emitted).toHaveLength(1);
|
|
199
182
|
|
|
200
|
-
|
|
201
|
-
expect(
|
|
183
|
+
// next() should have been called every turn
|
|
184
|
+
expect(next).toHaveBeenCalledTimes(3);
|
|
202
185
|
});
|
|
203
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
|
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Extension, ExtensionApi } from "@goondan/openharness-types";
|
|
2
|
+
|
|
3
|
+
const SYSTEM_MESSAGE_ID = "sys-basic-system-prompt";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* BasicSystemPrompt extension — prepends a system message to the conversation
|
|
7
|
+
* at the start of every turn.
|
|
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
|
+
*
|
|
12
|
+
* Priority 10 (HIGH) ensures it runs before other turn middleware.
|
|
13
|
+
*/
|
|
14
|
+
export function BasicSystemPrompt(text: string): Extension {
|
|
15
|
+
return {
|
|
16
|
+
name: "basic-system-prompt",
|
|
17
|
+
|
|
18
|
+
register(api: ExtensionApi): void {
|
|
19
|
+
api.pipeline.register(
|
|
20
|
+
"turn",
|
|
21
|
+
async (ctx, next) => {
|
|
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
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return next();
|
|
43
|
+
},
|
|
44
|
+
{ priority: 10 },
|
|
45
|
+
);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -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: {
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { BasicSystemPrompt } from "./extensions/basic-system-prompt.js";
|
|
2
2
|
export { MessageWindow } from "./extensions/message-window.js";
|
|
3
3
|
export { CompactionSummarize } from "./extensions/compaction-summarize.js";
|
|
4
4
|
export { Logging } from "./extensions/logging.js";
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import type { Message } from '../types.js';
|
|
2
|
-
/**
|
|
3
|
-
* Removes messages that would leave invalid tool_result references after trimming.
|
|
4
|
-
* Anthropic requires every tool_result block to map to a tool_use/tool-call block
|
|
5
|
-
* in the immediately previous assistant message.
|
|
6
|
-
*/
|
|
7
|
-
export declare function normalizeRemovalTargets(messages: Message[], initialRemovedIds: ReadonlySet<string>): Set<string>;
|
|
8
|
-
//# sourceMappingURL=message-integrity.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"message-integrity.d.ts","sourceRoot":"","sources":["../../src/extensions/message-integrity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAiF3C;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,OAAO,EAAE,EACnB,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC,GACrC,GAAG,CAAC,MAAM,CAAC,CAwBb"}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
function readToolCallId(part) {
|
|
2
|
-
const value = part.toolCallId;
|
|
3
|
-
if (typeof value !== 'string' || value.length === 0) {
|
|
4
|
-
return null;
|
|
5
|
-
}
|
|
6
|
-
return value;
|
|
7
|
-
}
|
|
8
|
-
function collectToolCallIds(message) {
|
|
9
|
-
const ids = new Set();
|
|
10
|
-
if (!message) {
|
|
11
|
-
return ids;
|
|
12
|
-
}
|
|
13
|
-
const content = message.data.content;
|
|
14
|
-
if (!Array.isArray(content)) {
|
|
15
|
-
return ids;
|
|
16
|
-
}
|
|
17
|
-
for (const part of content) {
|
|
18
|
-
if (part.type !== 'tool-call') {
|
|
19
|
-
continue;
|
|
20
|
-
}
|
|
21
|
-
const toolCallId = readToolCallId(part);
|
|
22
|
-
if (!toolCallId) {
|
|
23
|
-
continue;
|
|
24
|
-
}
|
|
25
|
-
ids.add(toolCallId);
|
|
26
|
-
}
|
|
27
|
-
return ids;
|
|
28
|
-
}
|
|
29
|
-
function collectToolResultIds(message) {
|
|
30
|
-
const content = message.data.content;
|
|
31
|
-
if (!Array.isArray(content)) {
|
|
32
|
-
return [];
|
|
33
|
-
}
|
|
34
|
-
const ids = [];
|
|
35
|
-
for (const part of content) {
|
|
36
|
-
if (part.type !== 'tool-result') {
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
const toolCallId = readToolCallId(part);
|
|
40
|
-
if (!toolCallId) {
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
ids.push(toolCallId);
|
|
44
|
-
}
|
|
45
|
-
return ids;
|
|
46
|
-
}
|
|
47
|
-
function hasDanglingToolResult(message, previousMessage) {
|
|
48
|
-
const toolResultIds = collectToolResultIds(message);
|
|
49
|
-
if (toolResultIds.length === 0) {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
const previousToolCallIds = collectToolCallIds(previousMessage);
|
|
53
|
-
if (previousToolCallIds.size === 0) {
|
|
54
|
-
return true;
|
|
55
|
-
}
|
|
56
|
-
for (const toolResultId of toolResultIds) {
|
|
57
|
-
if (!previousToolCallIds.has(toolResultId)) {
|
|
58
|
-
return true;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Removes messages that would leave invalid tool_result references after trimming.
|
|
65
|
-
* Anthropic requires every tool_result block to map to a tool_use/tool-call block
|
|
66
|
-
* in the immediately previous assistant message.
|
|
67
|
-
*/
|
|
68
|
-
export function normalizeRemovalTargets(messages, initialRemovedIds) {
|
|
69
|
-
const removedIds = new Set(initialRemovedIds);
|
|
70
|
-
let changed = true;
|
|
71
|
-
while (changed) {
|
|
72
|
-
changed = false;
|
|
73
|
-
let previousRemainingMessage;
|
|
74
|
-
for (const message of messages) {
|
|
75
|
-
if (removedIds.has(message.id)) {
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
if (hasDanglingToolResult(message, previousRemainingMessage)) {
|
|
79
|
-
removedIds.add(message.id);
|
|
80
|
-
changed = true;
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
previousRemainingMessage = message;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return removedIds;
|
|
87
|
-
}
|
|
88
|
-
//# sourceMappingURL=message-integrity.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"message-integrity.js","sourceRoot":"","sources":["../../src/extensions/message-integrity.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import type { Extension, ExtensionApi } from "@goondan/openharness-types";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* ContextMessage extension — prepends a system message to the conversation
|
|
6
|
-
* at the start of every turn.
|
|
7
|
-
*
|
|
8
|
-
* Priority 10 (HIGH) ensures it runs before other turn middleware.
|
|
9
|
-
*/
|
|
10
|
-
export function ContextMessage(text: string): Extension {
|
|
11
|
-
return {
|
|
12
|
-
name: "context-message",
|
|
13
|
-
|
|
14
|
-
register(api: ExtensionApi): void {
|
|
15
|
-
api.pipeline.register(
|
|
16
|
-
"turn",
|
|
17
|
-
async (ctx, next) => {
|
|
18
|
-
ctx.conversation.emit({
|
|
19
|
-
type: "append",
|
|
20
|
-
message: {
|
|
21
|
-
id: `ctx-msg-${randomUUID()}`,
|
|
22
|
-
data: {
|
|
23
|
-
role: "system",
|
|
24
|
-
content: text,
|
|
25
|
-
},
|
|
26
|
-
metadata: {
|
|
27
|
-
__createdBy: "context-message",
|
|
28
|
-
},
|
|
29
|
-
},
|
|
30
|
-
});
|
|
31
|
-
return next();
|
|
32
|
-
},
|
|
33
|
-
{ priority: 10 },
|
|
34
|
-
);
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
}
|