@goondan/openharness-base 0.0.1-alpha4 → 0.1.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/index.d.ts +72 -5
- package/dist/index.js +506 -5
- package/package.json +14 -33
- package/src/__tests__/compaction-summarize.test.ts +234 -0
- package/src/__tests__/context-message.test.ts +203 -0
- package/src/__tests__/logging.test.ts +200 -0
- package/src/__tests__/message-window.test.ts +193 -0
- package/src/__tests__/required-tools-guard.test.ts +206 -0
- package/src/__tests__/tool-search.test.ts +187 -0
- package/src/__tests__/tools.test.ts +332 -0
- package/src/extensions/compaction-summarize.ts +58 -0
- package/src/extensions/context-message.ts +37 -0
- package/src/extensions/logging.ts +42 -0
- package/src/extensions/message-window.ts +23 -0
- package/src/extensions/required-tools-guard.ts +24 -0
- package/src/extensions/tool-search.ts +38 -0
- package/src/index.ts +16 -0
- package/src/tools/bash.ts +38 -0
- package/src/tools/file-system.ts +83 -0
- package/src/tools/http-fetch.ts +64 -0
- package/src/tools/json-query.ts +71 -0
- package/src/tools/text-transform.ts +59 -0
- package/src/tools/wait.ts +46 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +7 -0
- package/README.md +0 -11
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { BashTool } from "../tools/bash.js";
|
|
3
|
+
import { FileReadTool, FileWriteTool, FileListTool } from "../tools/file-system.js";
|
|
4
|
+
import { HttpFetchTool } from "../tools/http-fetch.js";
|
|
5
|
+
import { JsonQueryTool } from "../tools/json-query.js";
|
|
6
|
+
import { TextTransformTool } from "../tools/text-transform.js";
|
|
7
|
+
import { WaitTool } from "../tools/wait.js";
|
|
8
|
+
import type { ToolContext } from "@goondan/openharness-types";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
function makeCtx(abortSignal?: AbortSignal): ToolContext {
|
|
18
|
+
return {
|
|
19
|
+
conversationId: "conv-1",
|
|
20
|
+
agentName: "test-agent",
|
|
21
|
+
abortSignal: abortSignal ?? new AbortController().signal,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// All tools — consolidated schema validation
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
describe("All tools — schema structure", () => {
|
|
30
|
+
it("every tool has name, description, non-empty required params, and a handler function", () => {
|
|
31
|
+
const tools = [
|
|
32
|
+
{ factory: BashTool, expectedName: "bash", expectedRequired: ["command"] },
|
|
33
|
+
{ factory: FileReadTool, expectedName: "file_read", expectedRequired: ["path"] },
|
|
34
|
+
{ factory: FileWriteTool, expectedName: "file_write", expectedRequired: ["path", "content"] },
|
|
35
|
+
{ factory: FileListTool, expectedName: "file_list", expectedRequired: ["path"] },
|
|
36
|
+
{ factory: HttpFetchTool, expectedName: "http_fetch", expectedRequired: ["url"] },
|
|
37
|
+
{ factory: JsonQueryTool, expectedName: "json_query", expectedRequired: ["data", "path"] },
|
|
38
|
+
{ factory: TextTransformTool, expectedName: "text_transform", expectedRequired: ["text", "operation"] },
|
|
39
|
+
{ factory: WaitTool, expectedName: "wait", expectedRequired: ["ms"] },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
for (const { factory, expectedName, expectedRequired } of tools) {
|
|
43
|
+
const tool = factory();
|
|
44
|
+
expect(tool.name).toBe(expectedName);
|
|
45
|
+
expect(typeof tool.description).toBe("string");
|
|
46
|
+
expect(tool.description.length).toBeGreaterThan(0);
|
|
47
|
+
expect(typeof tool.handler).toBe("function");
|
|
48
|
+
|
|
49
|
+
const params = tool.parameters as { properties: Record<string, unknown>; required: string[] };
|
|
50
|
+
for (const req of expectedRequired) {
|
|
51
|
+
expect(params.properties).toHaveProperty(req);
|
|
52
|
+
expect(params.required).toContain(req);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// BashTool
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
describe("BashTool", () => {
|
|
63
|
+
it("returns stdout as text result on success", async () => {
|
|
64
|
+
const tool = BashTool();
|
|
65
|
+
const result = await tool.handler({ command: "echo hello" }, makeCtx());
|
|
66
|
+
expect(result.type).toBe("text");
|
|
67
|
+
if (result.type === "text") {
|
|
68
|
+
expect(result.text.trim()).toBe("hello");
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns error result on command failure", async () => {
|
|
73
|
+
const tool = BashTool();
|
|
74
|
+
const result = await tool.handler({ command: "cat /nonexistent_file_xyz_abc_123" }, makeCtx());
|
|
75
|
+
expect(result.type).toBe("error");
|
|
76
|
+
if (result.type === "error") {
|
|
77
|
+
expect(typeof result.error).toBe("string");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// FileReadTool
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
describe("FileReadTool", () => {
|
|
87
|
+
let tmpFile: string;
|
|
88
|
+
|
|
89
|
+
beforeEach(async () => {
|
|
90
|
+
tmpFile = join(tmpdir(), `openharness-test-${Date.now()}.txt`);
|
|
91
|
+
await writeFile(tmpFile, "hello file", "utf8");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("reads file content successfully", async () => {
|
|
95
|
+
const tool = FileReadTool();
|
|
96
|
+
const result = await tool.handler({ path: tmpFile }, makeCtx());
|
|
97
|
+
expect(result.type).toBe("text");
|
|
98
|
+
if (result.type === "text") {
|
|
99
|
+
expect(result.text).toBe("hello file");
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns error for non-existent file", async () => {
|
|
104
|
+
const tool = FileReadTool();
|
|
105
|
+
const result = await tool.handler({ path: "/nonexistent/path/file.txt" }, makeCtx());
|
|
106
|
+
expect(result.type).toBe("error");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// FileWriteTool
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
describe("FileWriteTool", () => {
|
|
115
|
+
it("writes file successfully and returns text result", async () => {
|
|
116
|
+
const tmpFile = join(tmpdir(), `openharness-write-${Date.now()}.txt`);
|
|
117
|
+
const tool = FileWriteTool();
|
|
118
|
+
const result = await tool.handler({ path: tmpFile, content: "written content" }, makeCtx());
|
|
119
|
+
expect(result.type).toBe("text");
|
|
120
|
+
if (result.type === "text") {
|
|
121
|
+
expect(result.text).toContain(tmpFile);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns error when path is invalid", async () => {
|
|
126
|
+
const tool = FileWriteTool();
|
|
127
|
+
const result = await tool.handler({ path: "/nonexistent/deeply/nested/path.txt", content: "x" }, makeCtx());
|
|
128
|
+
expect(result.type).toBe("error");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// FileListTool
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
describe("FileListTool", () => {
|
|
137
|
+
let tmpDir: string;
|
|
138
|
+
|
|
139
|
+
beforeEach(async () => {
|
|
140
|
+
tmpDir = join(tmpdir(), `openharness-list-${Date.now()}`);
|
|
141
|
+
await mkdir(tmpDir, { recursive: true });
|
|
142
|
+
await writeFile(join(tmpDir, "a.txt"), "a");
|
|
143
|
+
await writeFile(join(tmpDir, "b.txt"), "b");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("lists directory entries as json result", async () => {
|
|
147
|
+
const tool = FileListTool();
|
|
148
|
+
const result = await tool.handler({ path: tmpDir }, makeCtx());
|
|
149
|
+
expect(result.type).toBe("json");
|
|
150
|
+
if (result.type === "json") {
|
|
151
|
+
const entries = result.data as Array<{ name: string; type: string }>;
|
|
152
|
+
expect(Array.isArray(entries)).toBe(true);
|
|
153
|
+
const names = entries.map((e) => e.name);
|
|
154
|
+
expect(names).toContain("a.txt");
|
|
155
|
+
expect(names).toContain("b.txt");
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("returns error for non-existent directory", async () => {
|
|
160
|
+
const tool = FileListTool();
|
|
161
|
+
const result = await tool.handler({ path: "/nonexistent/dir/xyz" }, makeCtx());
|
|
162
|
+
expect(result.type).toBe("error");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// HttpFetchTool
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
describe("HttpFetchTool", () => {
|
|
171
|
+
afterEach(() => {
|
|
172
|
+
vi.restoreAllMocks();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("returns json result with status, headers, body on success", async () => {
|
|
176
|
+
const mockResponse = {
|
|
177
|
+
status: 200,
|
|
178
|
+
headers: {
|
|
179
|
+
get: (name: string) => (name === "content-type" ? "application/json" : null),
|
|
180
|
+
forEach: (cb: (value: string, key: string) => void) => {
|
|
181
|
+
cb("application/json", "content-type");
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
json: async () => ({ ok: true }),
|
|
185
|
+
};
|
|
186
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(mockResponse));
|
|
187
|
+
|
|
188
|
+
const tool = HttpFetchTool();
|
|
189
|
+
const result = await tool.handler({ url: "https://example.com/api" }, makeCtx());
|
|
190
|
+
|
|
191
|
+
expect(result.type).toBe("json");
|
|
192
|
+
if (result.type === "json") {
|
|
193
|
+
const data = result.data as { status: number; headers: Record<string, string>; body: unknown };
|
|
194
|
+
expect(data.status).toBe(200);
|
|
195
|
+
expect(data.body).toEqual({ ok: true });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("returns error result when fetch throws", async () => {
|
|
200
|
+
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network error")));
|
|
201
|
+
|
|
202
|
+
const tool = HttpFetchTool();
|
|
203
|
+
const result = await tool.handler({ url: "https://example.com/fail" }, makeCtx());
|
|
204
|
+
expect(result.type).toBe("error");
|
|
205
|
+
if (result.type === "error") {
|
|
206
|
+
expect(result.error).toContain("Network error");
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// JsonQueryTool
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
describe("JsonQueryTool", () => {
|
|
216
|
+
it("queries nested object with dot notation", async () => {
|
|
217
|
+
const tool = JsonQueryTool();
|
|
218
|
+
const data = { user: { name: "Alice", age: 30 } };
|
|
219
|
+
const result = await tool.handler({ data, path: "$.user.name" }, makeCtx());
|
|
220
|
+
expect(result.type).toBe("json");
|
|
221
|
+
if (result.type === "json") {
|
|
222
|
+
expect(result.data).toBe("Alice");
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("queries array with bracket notation", async () => {
|
|
227
|
+
const tool = JsonQueryTool();
|
|
228
|
+
const data = { items: ["a", "b", "c"] };
|
|
229
|
+
const result = await tool.handler({ data, path: "$.items[1]" }, makeCtx());
|
|
230
|
+
expect(result.type).toBe("json");
|
|
231
|
+
if (result.type === "json") {
|
|
232
|
+
expect(result.data).toBe("b");
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("returns root data for $ path", async () => {
|
|
237
|
+
const tool = JsonQueryTool();
|
|
238
|
+
const data = { x: 1 };
|
|
239
|
+
const result = await tool.handler({ data, path: "$" }, makeCtx());
|
|
240
|
+
expect(result.type).toBe("json");
|
|
241
|
+
if (result.type === "json") {
|
|
242
|
+
expect(result.data).toEqual(data);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("returns undefined for missing path", async () => {
|
|
247
|
+
const tool = JsonQueryTool();
|
|
248
|
+
const data = { a: 1 };
|
|
249
|
+
const result = await tool.handler({ data, path: "$.b.c" }, makeCtx());
|
|
250
|
+
expect(result.type).toBe("json");
|
|
251
|
+
if (result.type === "json") {
|
|
252
|
+
expect(result.data).toBeUndefined();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// TextTransformTool
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
describe("TextTransformTool", () => {
|
|
262
|
+
it("uppercase operation", async () => {
|
|
263
|
+
const tool = TextTransformTool();
|
|
264
|
+
const result = await tool.handler({ text: "hello world", operation: "uppercase" }, makeCtx());
|
|
265
|
+
expect(result).toEqual({ type: "text", text: "HELLO WORLD" });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("lowercase operation", async () => {
|
|
269
|
+
const tool = TextTransformTool();
|
|
270
|
+
const result = await tool.handler({ text: "HELLO WORLD", operation: "lowercase" }, makeCtx());
|
|
271
|
+
expect(result).toEqual({ type: "text", text: "hello world" });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("trim operation", async () => {
|
|
275
|
+
const tool = TextTransformTool();
|
|
276
|
+
const result = await tool.handler({ text: " spaces ", operation: "trim" }, makeCtx());
|
|
277
|
+
expect(result).toEqual({ type: "text", text: "spaces" });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("split operation returns json array", async () => {
|
|
281
|
+
const tool = TextTransformTool();
|
|
282
|
+
const result = await tool.handler(
|
|
283
|
+
{ text: "a,b,c", operation: "split", options: { delimiter: "," } },
|
|
284
|
+
makeCtx(),
|
|
285
|
+
);
|
|
286
|
+
expect(result.type).toBe("json");
|
|
287
|
+
if (result.type === "json") {
|
|
288
|
+
expect(result.data).toEqual(["a", "b", "c"]);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("replace operation", async () => {
|
|
293
|
+
const tool = TextTransformTool();
|
|
294
|
+
const result = await tool.handler(
|
|
295
|
+
{ text: "foo bar foo", operation: "replace", options: { find: "foo", replacement: "baz" } },
|
|
296
|
+
makeCtx(),
|
|
297
|
+
);
|
|
298
|
+
expect(result).toEqual({ type: "text", text: "baz bar baz" });
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// WaitTool
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
describe("WaitTool", () => {
|
|
307
|
+
it("waits and returns text result", async () => {
|
|
308
|
+
const tool = WaitTool();
|
|
309
|
+
const result = await tool.handler({ ms: 10 }, makeCtx());
|
|
310
|
+
expect(result.type).toBe("text");
|
|
311
|
+
if (result.type === "text") {
|
|
312
|
+
expect(result.text).toBe("Waited 10ms");
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("respects maxMs cap", async () => {
|
|
317
|
+
const tool = WaitTool({ maxMs: 20 });
|
|
318
|
+
const result = await tool.handler({ ms: 1000 }, makeCtx());
|
|
319
|
+
expect(result.type).toBe("text");
|
|
320
|
+
if (result.type === "text") {
|
|
321
|
+
expect(result.text).toBe("Waited 20ms");
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("rejects when aborted", async () => {
|
|
326
|
+
const tool = WaitTool();
|
|
327
|
+
const ac = new AbortController();
|
|
328
|
+
const promise = tool.handler({ ms: 5000 }, makeCtx(ac.signal));
|
|
329
|
+
ac.abort();
|
|
330
|
+
await expect(promise).rejects.toThrow();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Extension, ExtensionApi } from "@goondan/openharness-types";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CompactionSummarize extension — when message count exceeds `threshold`,
|
|
6
|
+
* removes the oldest messages and prepends a summary system message.
|
|
7
|
+
*
|
|
8
|
+
* For MVP the "summary" is just the concatenation of removed message text.
|
|
9
|
+
*/
|
|
10
|
+
export function CompactionSummarize(config: {
|
|
11
|
+
threshold: number;
|
|
12
|
+
summaryPrompt?: string;
|
|
13
|
+
}): Extension {
|
|
14
|
+
return {
|
|
15
|
+
name: "compaction-summarize",
|
|
16
|
+
|
|
17
|
+
register(api: ExtensionApi): void {
|
|
18
|
+
api.pipeline.register("step", async (ctx, next) => {
|
|
19
|
+
const messages = ctx.conversation.messages;
|
|
20
|
+
if (messages.length > config.threshold) {
|
|
21
|
+
const keepCount = Math.floor(config.threshold / 2);
|
|
22
|
+
const removeCount = messages.length - keepCount;
|
|
23
|
+
const toRemove = messages.slice(0, removeCount);
|
|
24
|
+
|
|
25
|
+
// Build a naive summary from removed messages
|
|
26
|
+
const summaryText = toRemove
|
|
27
|
+
.map((m) =>
|
|
28
|
+
typeof m.data.content === "string"
|
|
29
|
+
? m.data.content
|
|
30
|
+
: JSON.stringify(m.data.content),
|
|
31
|
+
)
|
|
32
|
+
.join(" ");
|
|
33
|
+
|
|
34
|
+
// Remove old messages
|
|
35
|
+
for (const msg of toRemove) {
|
|
36
|
+
ctx.conversation.emit({ type: "remove", messageId: msg.id });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Prepend summary
|
|
40
|
+
ctx.conversation.emit({
|
|
41
|
+
type: "append",
|
|
42
|
+
message: {
|
|
43
|
+
id: `summary-${randomUUID()}`,
|
|
44
|
+
data: {
|
|
45
|
+
role: "system",
|
|
46
|
+
content: `[Summary of earlier conversation]: ${summaryText}`,
|
|
47
|
+
},
|
|
48
|
+
metadata: {
|
|
49
|
+
__createdBy: "compaction-summarize",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return next();
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Extension, ExtensionApi } from "@goondan/openharness-types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Logging extension — subscribes to core events and logs them.
|
|
5
|
+
*/
|
|
6
|
+
export function Logging(config?: { logger?: (msg: string) => void }): Extension {
|
|
7
|
+
const log = config?.logger ?? console.log;
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
name: "logging",
|
|
11
|
+
|
|
12
|
+
register(api: ExtensionApi): void {
|
|
13
|
+
api.on("turn.start", (payload) => {
|
|
14
|
+
log(`[turn.start] ${JSON.stringify(payload)}`);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
api.on("turn.done", (payload) => {
|
|
18
|
+
log(`[turn.done] ${JSON.stringify(payload)}`);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
api.on("turn.error", (payload) => {
|
|
22
|
+
log(`[turn.error] ${JSON.stringify(payload)}`);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
api.on("step.start", (payload) => {
|
|
26
|
+
log(`[step.start] ${JSON.stringify(payload)}`);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
api.on("step.done", (payload) => {
|
|
30
|
+
log(`[step.done] ${JSON.stringify(payload)}`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
api.on("tool.start", (payload) => {
|
|
34
|
+
log(`[tool.start] ${JSON.stringify(payload)}`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
api.on("tool.done", (payload) => {
|
|
38
|
+
log(`[tool.done] ${JSON.stringify(payload)}`);
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Extension, ExtensionApi } from "@goondan/openharness-types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MessageWindow extension — truncates conversation history to keep only
|
|
5
|
+
* the most recent `maxMessages` messages before each step.
|
|
6
|
+
*/
|
|
7
|
+
export function MessageWindow(config: { maxMessages: number }): Extension {
|
|
8
|
+
return {
|
|
9
|
+
name: "message-window",
|
|
10
|
+
|
|
11
|
+
register(api: ExtensionApi): void {
|
|
12
|
+
api.pipeline.register("step", async (ctx, next) => {
|
|
13
|
+
if (ctx.conversation.messages.length > config.maxMessages) {
|
|
14
|
+
ctx.conversation.emit({
|
|
15
|
+
type: "truncate",
|
|
16
|
+
keepLast: config.maxMessages,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return next();
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Extension, ExtensionApi } from "@goondan/openharness-types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RequiredToolsGuard extension — blocks a turn if any required tools are
|
|
5
|
+
* not registered.
|
|
6
|
+
*/
|
|
7
|
+
export function RequiredToolsGuard(config: { tools: string[] }): Extension {
|
|
8
|
+
return {
|
|
9
|
+
name: "required-tools-guard",
|
|
10
|
+
|
|
11
|
+
register(api: ExtensionApi): void {
|
|
12
|
+
api.pipeline.register("turn", async (ctx, next) => {
|
|
13
|
+
const registered = api.tools.list().map((t) => t.name);
|
|
14
|
+
const missing = config.tools.filter((name) => !registered.includes(name));
|
|
15
|
+
if (missing.length > 0) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`RequiredToolsGuard: missing required tools: ${missing.join(", ")}`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return next();
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Extension, ExtensionApi, JsonValue } from "@goondan/openharness-types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ToolSearch extension — registers a meta-tool `search_tools` that searches
|
|
5
|
+
* registered tool names and descriptions by keyword.
|
|
6
|
+
*/
|
|
7
|
+
export function ToolSearch(): Extension {
|
|
8
|
+
return {
|
|
9
|
+
name: "tool-search",
|
|
10
|
+
|
|
11
|
+
register(api: ExtensionApi): void {
|
|
12
|
+
api.tools.register({
|
|
13
|
+
name: "search_tools",
|
|
14
|
+
description: "Search registered tools by keyword in name or description.",
|
|
15
|
+
parameters: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
query: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Keyword to search for in tool names and descriptions.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ["query"],
|
|
24
|
+
},
|
|
25
|
+
handler: async (args) => {
|
|
26
|
+
const query = (args["query"] as string).toLowerCase();
|
|
27
|
+
const allTools = api.tools.list();
|
|
28
|
+
const matching = allTools.filter(
|
|
29
|
+
(t) =>
|
|
30
|
+
t.name.toLowerCase().includes(query) ||
|
|
31
|
+
t.description.toLowerCase().includes(query),
|
|
32
|
+
);
|
|
33
|
+
return { type: "json", data: matching as unknown as JsonValue };
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { ContextMessage } from "./extensions/context-message.js";
|
|
2
|
+
export { MessageWindow } from "./extensions/message-window.js";
|
|
3
|
+
export { CompactionSummarize } from "./extensions/compaction-summarize.js";
|
|
4
|
+
export { Logging } from "./extensions/logging.js";
|
|
5
|
+
export { ToolSearch } from "./extensions/tool-search.js";
|
|
6
|
+
export { RequiredToolsGuard } from "./extensions/required-tools-guard.js";
|
|
7
|
+
|
|
8
|
+
// Tools
|
|
9
|
+
export { BashTool } from "./tools/bash.js";
|
|
10
|
+
export type { BashToolConfig } from "./tools/bash.js";
|
|
11
|
+
export { FileReadTool, FileWriteTool, FileListTool } from "./tools/file-system.js";
|
|
12
|
+
export { HttpFetchTool } from "./tools/http-fetch.js";
|
|
13
|
+
export { JsonQueryTool } from "./tools/json-query.js";
|
|
14
|
+
export { TextTransformTool } from "./tools/text-transform.js";
|
|
15
|
+
export { WaitTool } from "./tools/wait.js";
|
|
16
|
+
export type { WaitToolConfig } from "./tools/wait.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import type { ToolDefinition, JsonObject, ToolContext } from "@goondan/openharness-types";
|
|
3
|
+
|
|
4
|
+
export interface BashToolConfig {
|
|
5
|
+
timeout?: number;
|
|
6
|
+
maxBuffer?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function BashTool(config: BashToolConfig = {}): ToolDefinition {
|
|
10
|
+
const { timeout = 30_000, maxBuffer = 1024 * 1024 } = config;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
name: "bash",
|
|
14
|
+
description: "Execute a shell command and return its output.",
|
|
15
|
+
parameters: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
command: { type: "string", description: "The shell command to execute." },
|
|
19
|
+
cwd: { type: "string", description: "Optional working directory for the command." },
|
|
20
|
+
},
|
|
21
|
+
required: ["command"],
|
|
22
|
+
},
|
|
23
|
+
async handler(args: JsonObject, _ctx: ToolContext) {
|
|
24
|
+
const command = args["command"] as string;
|
|
25
|
+
const cwd = args["cwd"] as string | undefined;
|
|
26
|
+
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
exec(command, { timeout, maxBuffer, cwd }, (error, stdout, stderr) => {
|
|
29
|
+
if (error) {
|
|
30
|
+
resolve({ type: "error", error: stderr || error.message });
|
|
31
|
+
} else {
|
|
32
|
+
resolve({ type: "text", text: stdout });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|