@goondan/openharness-base 0.5.6 → 1.0.0-rc.1

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 CHANGED
@@ -1,27 +1,62 @@
1
- import { Extension, LlmChatOptions, Message, ToolDefinition } from '@goondan/openharness-types';
1
+ import { AgentExtension, LlmChatOptions, Message, ToolDefinition } from '@goondan/openharness-types';
2
2
 
3
3
  /**
4
- * BasicSystemPrompt extension prepends a system message to the conversation
5
- * at the start of every turn.
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.
4
+ * Extension returned by {@link BasicSystemPrompt}. Carries the prompt text so
5
+ * consumers that used to read it off the durable system message (compaction,
6
+ * prewarm, slack.host) can recover it via {@link getSystemPromptText} now that
7
+ * the prompt lives only in the projected view.
8
+ */
9
+ interface BasicSystemPromptExtension extends AgentExtension {
10
+ readonly systemPromptText: string;
11
+ }
12
+ /**
13
+ * Recover the system prompt text from a {@link BasicSystemPrompt} extension.
14
+ * Returns `undefined` for any other extension.
15
+ */
16
+ declare function getSystemPromptText(extension: AgentExtension): string | undefined;
17
+ /**
18
+ * BasicSystemPrompt extension — makes a system message lead the prompt on every
19
+ * step.
9
20
  *
10
- * Priority 10 (HIGH) ensures it runs before other turn middleware.
21
+ * As of 1.0 this is a *projection* via `useModelInput`: the system prompt is
22
+ * part of the throwaway model input, never the durable log. If this projection
23
+ * ran zero times the durable log would still be correct — which is exactly the
24
+ * test for "view, not mutation". A one-time turn middleware (registered with
25
+ * `{ before: "*" }`, so it lands at the outermost band before any step) removes
26
+ * the legacy persisted copy from conversations created by older versions.
11
27
  */
12
- declare function BasicSystemPrompt(text: string): Extension;
28
+ declare function BasicSystemPrompt(text: string): BasicSystemPromptExtension;
13
29
 
14
30
  /**
15
- * MessageWindow extension — truncates conversation history to keep only
16
- * the most recent `maxMessages` messages before each step.
31
+ * MessageWindow extension — keeps the model input to roughly the most recent
32
+ * `maxMessages` non-system messages.
33
+ *
34
+ * As of 1.0 this is a *projection* via `useModelInput`, not a durable
35
+ * truncation. The old `truncate` event mutated the log and made history
36
+ * unrecoverable; windowing the view instead leaves the durable log intact (pair
37
+ * this with CompactionSummarize if the log itself needs bounding).
38
+ *
39
+ * Two boundary rules keep the windowed view valid:
40
+ * - leading system messages are always retained (the view invariant requires
41
+ * system messages to lead, and the system prompt must survive windowing);
42
+ * - the start boundary is extended backward off any tool-result so a windowed
43
+ * view never begins with an orphaned tool-result whose assistant tool-call
44
+ * was dropped.
17
45
  */
18
46
  declare function MessageWindow(config: {
19
47
  maxMessages: number;
20
- }): Extension;
48
+ }): AgentExtension;
21
49
 
22
50
  /**
23
51
  * CompactionSummarize extension — when message count exceeds `threshold`,
24
- * removes the oldest messages and replaces them with an LLM-generated summary.
52
+ * removes the oldest *non-system* messages and replaces them with an
53
+ * LLM-generated summary.
54
+ *
55
+ * This is a durable mutation, not a projection: removing history and recording a
56
+ * summary changes the log itself, and must survive replay. It runs as step
57
+ * middleware (`useStep`) so it assembles context before the model call. System
58
+ * messages are never folded into the summary — filtering them out keeps stale
59
+ * prompts/summaries out of the new summary.
25
60
  *
26
61
  * By default, uses the agent's own LLM (`ctx.llm`) to produce the summary.
27
62
  * A custom `summarizer` callback can override this for advanced use cases
@@ -37,28 +72,38 @@ declare function CompactionSummarize(config: {
37
72
  /** LLM options for the summarization call (e.g. model override for cheaper summarization). */
38
73
  llmOptions?: LlmChatOptions;
39
74
  summarizer?: (messages: Message[]) => Promise<string>;
40
- }): Extension;
75
+ }): AgentExtension;
41
76
 
42
77
  /**
43
78
  * Logging extension — subscribes to core events and logs them.
44
79
  */
45
80
  declare function Logging(config?: {
46
81
  logger?: (msg: string) => void;
47
- }): Extension;
82
+ }): AgentExtension;
48
83
 
49
84
  /**
50
85
  * ToolSearch extension — registers a meta-tool `search_tools` that searches
51
86
  * registered tool names and descriptions by keyword.
52
87
  */
53
- declare function ToolSearch(): Extension;
88
+ declare function ToolSearch(): AgentExtension;
54
89
 
90
+ /**
91
+ * Registration name of {@link RequiredToolsGuard}. Exported as a marker so other
92
+ * middleware can order around it with `before`/`after` without hardcoding the
93
+ * string — e.g. `{ after: REQUIRED_TOOLS_GUARD }`.
94
+ */
95
+ declare const REQUIRED_TOOLS_GUARD = "required-tools-guard";
55
96
  /**
56
97
  * RequiredToolsGuard extension — blocks a turn if any required tools are
57
98
  * not registered.
99
+ *
100
+ * Registered with `{ after: "*" }` so it sits at the innermost band — the last
101
+ * check before the model call, after all other context-assembling middleware
102
+ * has run.
58
103
  */
59
104
  declare function RequiredToolsGuard(config: {
60
105
  tools: string[];
61
- }): Extension;
106
+ }): AgentExtension;
62
107
 
63
108
  interface BashToolConfig {
64
109
  timeout?: number;
@@ -81,4 +126,4 @@ interface WaitToolConfig {
81
126
  }
82
127
  declare function WaitTool(config?: WaitToolConfig): ToolDefinition;
83
128
 
84
- export { BashTool, type BashToolConfig, BasicSystemPrompt, CompactionSummarize, FileListTool, FileReadTool, FileWriteTool, HttpFetchTool, JsonQueryTool, Logging, MessageWindow, RequiredToolsGuard, TextTransformTool, ToolSearch, WaitTool, type WaitToolConfig };
129
+ export { BashTool, type BashToolConfig, BasicSystemPrompt, type BasicSystemPromptExtension, CompactionSummarize, FileListTool, FileReadTool, FileWriteTool, HttpFetchTool, JsonQueryTool, Logging, MessageWindow, REQUIRED_TOOLS_GUARD, RequiredToolsGuard, TextTransformTool, ToolSearch, WaitTool, type WaitToolConfig, getSystemPromptText };
package/dist/index.js CHANGED
@@ -1,59 +1,75 @@
1
1
  // src/extensions/basic-system-prompt.ts
2
- var SYSTEM_MESSAGE_ID = "sys-basic-system-prompt";
2
+ import {
3
+ createMessage
4
+ } from "@goondan/openharness-types";
5
+ var LEGACY_SYSTEM_MESSAGE_ID = "sys-basic-system-prompt";
6
+ var CREATED_BY = "basic-system-prompt";
7
+ function getSystemPromptText(extension) {
8
+ return extension.systemPromptText;
9
+ }
3
10
  function BasicSystemPrompt(text) {
4
11
  return {
5
12
  name: "basic-system-prompt",
13
+ systemPromptText: text,
6
14
  register(api) {
7
- api.pipeline.register(
8
- "turn",
15
+ api.useTurn(
9
16
  async (ctx, next) => {
10
- const alreadyExists = ctx.conversation.messages.some(
11
- (m) => m.id === SYSTEM_MESSAGE_ID
12
- );
13
- if (!alreadyExists) {
14
- ctx.conversation.emit({
15
- type: "appendSystem",
16
- message: {
17
- id: SYSTEM_MESSAGE_ID,
18
- data: {
19
- role: "system",
20
- content: text
21
- },
22
- metadata: {
23
- __createdBy: "basic-system-prompt"
24
- }
25
- }
17
+ const hasLegacy = ctx.conversation.getMessages().some((m) => m.id === LEGACY_SYSTEM_MESSAGE_ID);
18
+ if (hasLegacy) {
19
+ ctx.conversation.append({
20
+ type: "remove",
21
+ messageId: LEGACY_SYSTEM_MESSAGE_ID
26
22
  });
27
23
  }
28
24
  return next();
29
25
  },
30
- { priority: 10 }
26
+ { before: "*" }
31
27
  );
28
+ api.useModelInput((view) => {
29
+ const rest = view.filter((m) => m.id !== LEGACY_SYSTEM_MESSAGE_ID);
30
+ const system = createMessage({
31
+ id: LEGACY_SYSTEM_MESSAGE_ID,
32
+ data: { role: "system", content: text },
33
+ createdBy: CREATED_BY
34
+ });
35
+ return [system, ...rest];
36
+ });
32
37
  }
33
38
  };
34
39
  }
35
40
 
36
41
  // src/extensions/message-window.ts
42
+ function isToolResult(message) {
43
+ if (message.data.role !== "tool") return false;
44
+ const content = message.data.content;
45
+ if (typeof content === "string") return false;
46
+ return content.some(
47
+ (part) => part != null && typeof part === "object" && part.type === "tool-result"
48
+ );
49
+ }
37
50
  function MessageWindow(config) {
38
51
  return {
39
52
  name: "message-window",
40
53
  register(api) {
41
- api.pipeline.register("step", async (ctx, next) => {
42
- if (ctx.conversation.messages.length > config.maxMessages) {
43
- ctx.conversation.emit({
44
- type: "truncate",
45
- keepLast: config.maxMessages
46
- });
47
- }
48
- return next();
54
+ api.useModelInput((view) => {
55
+ const system = view.filter((m) => m.data.role === "system");
56
+ const body = view.filter((m) => m.data.role !== "system");
57
+ if (body.length <= config.maxMessages) return view;
58
+ let start = body.length - config.maxMessages;
59
+ while (start > 0 && isToolResult(body[start])) start--;
60
+ return [...system, ...body.slice(start)];
49
61
  });
50
62
  }
51
63
  };
52
64
  }
53
65
 
54
66
  // src/extensions/compaction-summarize.ts
67
+ import {
68
+ createMessage as createMessage2
69
+ } from "@goondan/openharness-types";
55
70
  import { randomUUID } from "crypto";
56
71
  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.";
72
+ var CREATED_BY2 = "compaction-summarize";
57
73
  function messageToText(m) {
58
74
  const role = m.data.role;
59
75
  const content = typeof m.data.content === "string" ? m.data.content : JSON.stringify(m.data.content);
@@ -63,12 +79,14 @@ function CompactionSummarize(config) {
63
79
  return {
64
80
  name: "compaction-summarize",
65
81
  register(api) {
66
- api.pipeline.register("step", async (ctx, next) => {
67
- const messages = ctx.conversation.messages;
82
+ api.useStep(async (ctx, next) => {
83
+ const messages = ctx.conversation.getMessages();
68
84
  if (messages.length > config.threshold) {
69
85
  const keepCount = Math.floor(config.threshold / 2);
70
- const removeCount = messages.length - keepCount;
71
- const toRemove = messages.slice(0, removeCount);
86
+ const removable = messages.filter((m) => m.data.role !== "system");
87
+ if (removable.length <= keepCount) return next();
88
+ const removeCount = removable.length - keepCount;
89
+ const toRemove = removable.slice(0, removeCount);
72
90
  let summaryText;
73
91
  if (config.summarizer) {
74
92
  summaryText = await config.summarizer([...toRemove]);
@@ -77,8 +95,16 @@ function CompactionSummarize(config) {
77
95
  const prompt = config.summaryPrompt ?? DEFAULT_SUMMARY_PROMPT;
78
96
  const llmResponse = await ctx.llm.chat(
79
97
  [
80
- { id: `compaction-sys-${randomUUID()}`, data: { role: "system", content: prompt }, metadata: {} },
81
- { id: `compaction-usr-${randomUUID()}`, data: { role: "user", content: transcript }, metadata: {} }
98
+ createMessage2({
99
+ id: `compaction-sys-${randomUUID()}`,
100
+ data: { role: "system", content: prompt },
101
+ createdBy: CREATED_BY2
102
+ }),
103
+ createMessage2({
104
+ id: `compaction-usr-${randomUUID()}`,
105
+ data: { role: "user", content: transcript },
106
+ createdBy: CREATED_BY2
107
+ })
82
108
  ],
83
109
  [],
84
110
  // no tools needed for summarization
@@ -87,26 +113,19 @@ function CompactionSummarize(config) {
87
113
  );
88
114
  summaryText = llmResponse.text ?? transcript;
89
115
  }
90
- const [firstToRemove, ...restToRemove] = toRemove;
91
- ctx.conversation.emit({
92
- type: "remove",
93
- messageId: firstToRemove.id
94
- });
95
- for (const msg of restToRemove) {
96
- ctx.conversation.emit({ type: "remove", messageId: msg.id });
116
+ for (const msg of toRemove) {
117
+ ctx.conversation.append({ type: "remove", messageId: msg.id });
97
118
  }
98
- ctx.conversation.emit({
119
+ ctx.conversation.append({
99
120
  type: "appendSystem",
100
- message: {
121
+ message: createMessage2({
101
122
  id: `summary-${randomUUID()}`,
102
123
  data: {
103
124
  role: "system",
104
125
  content: `[Summary of earlier conversation]: ${summaryText}`
105
126
  },
106
- metadata: {
107
- __createdBy: "compaction-summarize"
108
- }
109
- }
127
+ createdBy: CREATED_BY2
128
+ })
110
129
  });
111
130
  }
112
131
  return next();
@@ -178,20 +197,26 @@ function ToolSearch() {
178
197
  }
179
198
 
180
199
  // src/extensions/required-tools-guard.ts
200
+ var REQUIRED_TOOLS_GUARD = "required-tools-guard";
181
201
  function RequiredToolsGuard(config) {
182
202
  return {
183
- name: "required-tools-guard",
203
+ name: REQUIRED_TOOLS_GUARD,
184
204
  register(api) {
185
- api.pipeline.register("turn", async (ctx, next) => {
186
- const registered = api.tools.list().map((t) => t.name);
187
- const missing = config.tools.filter((name) => !registered.includes(name));
188
- if (missing.length > 0) {
189
- throw new Error(
190
- `RequiredToolsGuard: missing required tools: ${missing.join(", ")}`
205
+ api.useTurn(
206
+ async (ctx, next) => {
207
+ const registered = api.tools.list().map((t) => t.name);
208
+ const missing = config.tools.filter(
209
+ (name) => !registered.includes(name)
191
210
  );
192
- }
193
- return next();
194
- });
211
+ if (missing.length > 0) {
212
+ throw new Error(
213
+ `RequiredToolsGuard: missing required tools: ${missing.join(", ")}`
214
+ );
215
+ }
216
+ return next();
217
+ },
218
+ { after: "*" }
219
+ );
195
220
  }
196
221
  };
197
222
  }
@@ -530,8 +555,10 @@ export {
530
555
  JsonQueryTool,
531
556
  Logging,
532
557
  MessageWindow,
558
+ REQUIRED_TOOLS_GUARD,
533
559
  RequiredToolsGuard,
534
560
  TextTransformTool,
535
561
  ToolSearch,
536
- WaitTool
562
+ WaitTool,
563
+ getSystemPromptText
537
564
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goondan/openharness-base",
3
- "version": "0.5.6",
3
+ "version": "1.0.0-rc.1",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "exports": {
@@ -12,19 +12,20 @@
12
12
  "files": [
13
13
  "dist"
14
14
  ],
15
+ "scripts": {
16
+ "build": "tsup src/index.ts --format esm --dts",
17
+ "prepack": "pnpm build",
18
+ "test": "vitest run",
19
+ "typecheck": "tsc --noEmit",
20
+ "clean": "rm -rf dist"
21
+ },
15
22
  "dependencies": {
16
- "@goondan/openharness-types": "^0.5.6"
23
+ "@goondan/openharness-types": "workspace:^"
17
24
  },
18
25
  "devDependencies": {
19
26
  "@types/node": "^25.5.0",
20
27
  "tsup": "^8.4.0",
21
28
  "typescript": "^5.7.0",
22
29
  "vitest": "^3.0.0"
23
- },
24
- "scripts": {
25
- "build": "tsup src/index.ts --format esm --dts",
26
- "test": "vitest run",
27
- "typecheck": "tsc --noEmit",
28
- "clean": "rm -rf dist"
29
30
  }
30
- }
31
+ }