@noetaris/harness-types 0.3.0 → 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/README.md CHANGED
@@ -6,19 +6,51 @@ This package defines the common TypeScript interfaces that all LLM adapter packa
6
6
 
7
7
  ## Overview
8
8
 
9
- `@noetaris/harness-types` is a types-only package with zero runtime code and zero dependencies. It provides the canonical definitions for messages, tools, and the `LLM` interface — the surface that all adapters (Anthropic, OpenAI, Gemini, etc.) must satisfy.
9
+ `@noetaris/harness-types` is a types package with zero dependencies and one small runtime helper, `toBlocks()`. It provides the canonical definitions for messages, tools, and the `LLM` interface — the surface that all adapters (Anthropic, OpenAI, Gemini, etc.) must satisfy.
10
10
 
11
11
  ## Types
12
12
 
13
13
  | Type | Description |
14
14
  |------|-------------|
15
- | `Message` | Discriminated union: `{ role: 'user'; content: string }` \| `{ role: 'assistant'; content?: string; toolCalls?: ToolCall[] }` \| `{ role: 'tool'; toolCallId: string; content: string }` |
15
+ | `Message` | Discriminated union: `{ role: 'user'; content: string \| ContentBlock[] }` \| `{ role: 'assistant'; content?: string; toolCalls?: ToolCall[] }` \| `{ role: 'tool'; toolCallId: string; content: string }` |
16
+ | `TextBlock` | `{ type: 'text'; text: string }` — a run of text inside a user message |
17
+ | `ImageBlock` | `{ type: 'image'; data: string; mediaType: string }` — an image inside a user message; `data` is raw base64 (see [Image input](#image-input)) |
18
+ | `ContentBlock` | `TextBlock \| ImageBlock` |
16
19
  | `Tool` | `{ name: string; description: string; inputSchema: Record<string, unknown> }` |
17
20
  | `ToolCall` | `{ id: string; name: string; input: unknown }` |
18
- | `LLMResponse` | `{ text: string; toolCalls: ToolCall[]; stopReason: 'end' \| 'tool_use' \| 'max_tokens' }` |
19
- | `LLMUsageEvent` | `{ tokens: { input: number; output: number }; modelId: string; stopReason: LLMResponse['stopReason'] }` |
21
+ | `LLMResponse` | `{ text: string; toolCalls: ToolCall[]; stopReason: 'end' \| 'tool_use' \| 'max_tokens'; usage: { inputTokens: number; outputTokens: number; contextWindowSize?: number } }` — `usage` is always present; all adapters must populate it |
22
+ | `LLMRequestEvent` | `"llm.request"` payload: `{ modelId: string; providerName: string; messages?: unknown; tools?: unknown }` (`messages`/`tools` opt-in) |
23
+ | `LLMUsageEvent` | `"llm.response"` payload: `{ tokens: { input: number; output: number }; modelId: string; stopReason: LLMResponse['stopReason']; providerName: string; output?: unknown; contextWindowSize?: number }` |
24
+ | `ToolCallEvent` | `"tool.call"` payload: `{ toolName: string; toolCallId: string; input?: unknown }` (`input` opt-in) |
25
+ | `ToolResultEvent` | `"tool.result"` payload: `{ toolName: string; toolCallId: string; durationMs: number; result?: unknown; error?: unknown }` (`result`/`error` opt-in) |
20
26
  | `LLM` | `{ invoke(messages: Message[], options?: { tools?: Tool[] }): Promise<LLMResponse> }` |
21
27
 
28
+ The one runtime export is `toBlocks(content: string | ContentBlock[]): ContentBlock[]`. It turns a string into a single text block and returns an array as it is.
29
+
30
+ ## Image input
31
+
32
+ A `user` message can carry images next to text. Pass an array of blocks instead of a string:
33
+
34
+ ```ts
35
+ import type { Message } from '@noetaris/harness-types'
36
+
37
+ const message: Message = {
38
+ role: 'user',
39
+ content: [
40
+ { type: 'text', text: 'What is in this picture?' },
41
+ { type: 'image', data: pngBytes.toString('base64'), mediaType: 'image/png' },
42
+ ],
43
+ }
44
+ ```
45
+
46
+ - `data` is raw base64 with no `data:` prefix. Adapters do not strip or check it.
47
+ - Only base64 is supported. URL sources are not supported yet.
48
+ - Only the `user` arm can carry images. `assistant` and `tool` messages stay text.
49
+ - `content: []` is allowed by the type, but providers may reject it. Don't send an empty array.
50
+ - `mediaType` is a plain string. Anthropic, OpenAI and Ollama check it and throw on types they do not accept. Gemini passes any value through and the provider decides. The accepted types differ per adapter. See the README of your adapter: [Anthropic](https://github.com/noetaris-lab/harness-anthropic), [OpenAI](https://github.com/noetaris-lab/harness-openai), [Gemini](https://github.com/noetaris-lab/harness-google), [Ollama](https://github.com/noetaris-lab/harness-ollama).
51
+ - The model must be able to read images. The adapters do not check this.
52
+ - Large images in session state are expensive to store. See [Large media in session state](https://github.com/noetaris-lab/harness/blob/main/LIMITATIONS.md).
53
+
22
54
  ## Installation
23
55
 
24
56
  ```sh
@@ -39,6 +71,22 @@ class MyAdapter implements LLM {
39
71
  }
40
72
  ```
41
73
 
74
+ An adapter that handles image input can use `toBlocks()` to treat a string and an array the same way:
75
+
76
+ ```ts
77
+ import { toBlocks } from '@noetaris/harness-types'
78
+
79
+ for (const block of toBlocks(message.content)) {
80
+ if (block.type === 'text') {
81
+ // block.text
82
+ } else {
83
+ // block.data (base64) and block.mediaType
84
+ }
85
+ }
86
+ ```
87
+
88
+ `toBlocks` is a value import, not a type import. An adapter that calls it needs `@noetaris/harness-types` installed at runtime, not only at build time.
89
+
42
90
  ## Related Packages
43
91
 
44
92
  - [`@noetaris/harness`](https://github.com/noetaris-lab/harness) — core execution engine
package/dist/index.d.ts CHANGED
@@ -3,16 +3,35 @@
3
3
  *
4
4
  * @packageDocumentation
5
5
  */
6
+ /** A run of plain text inside a user message. */
7
+ type TextBlock = {
8
+ type: 'text';
9
+ text: string;
10
+ };
11
+ /**
12
+ * An image inside a user message.
13
+ *
14
+ * `data` is raw base64 with no `data:` prefix. `mediaType` is the MIME type
15
+ * (e.g. `'image/png'`); Anthropic, OpenAI and Ollama throw on types they do not accept; Gemini passes any value through.
16
+ */
17
+ type ImageBlock = {
18
+ type: 'image';
19
+ data: string;
20
+ mediaType: string;
21
+ };
22
+ /** One piece of user-message content. */
23
+ type ContentBlock = TextBlock | ImageBlock;
6
24
  /**
7
25
  * A single turn in a conversation.
8
26
  *
9
- * - `user` — text input from the human side.
27
+ * - `user` — input from the human side: a string, or blocks mixing text and images.
28
+ * An empty block array is allowed by the type but may be rejected by providers; don't send one.
10
29
  * - `assistant` — model reply, optionally carrying tool-call requests.
11
30
  * - `tool` — result returned to the model after a tool call.
12
31
  */
13
32
  type Message = {
14
33
  role: 'user';
15
- content: string;
34
+ content: string | ContentBlock[];
16
35
  } | {
17
36
  role: 'assistant';
18
37
  content?: string;
@@ -22,6 +41,13 @@ type Message = {
22
41
  toolCallId: string;
23
42
  content: string;
24
43
  };
44
+ /**
45
+ * Normalize user-message content to blocks.
46
+ *
47
+ * A string becomes one text block (an empty string stays one empty text block).
48
+ * An array is returned as-is, same reference, with no validation.
49
+ */
50
+ declare function toBlocks(content: string | ContentBlock[]): ContentBlock[];
25
51
  /**
26
52
  * A tool the LLM may invoke during a conversation turn.
27
53
  *
@@ -49,11 +75,24 @@ interface ToolCall {
49
75
  *
50
76
  * `text` may be empty when `stopReason` is `'tool_use'`.
51
77
  * `toolCalls` is empty when `stopReason` is `'end'` or `'max_tokens'`.
78
+ * `usage` is always present — all adapters must populate it.
52
79
  */
53
80
  interface LLMResponse {
54
81
  text: string;
55
82
  toolCalls: ToolCall[];
56
83
  stopReason: 'end' | 'tool_use' | 'max_tokens';
84
+ usage: {
85
+ /** Number of input tokens consumed by this call. */
86
+ inputTokens: number;
87
+ /** Number of output tokens generated by this call. */
88
+ outputTokens: number;
89
+ /**
90
+ * The model's total context window capacity in tokens.
91
+ * Present only when the adapter can determine it.
92
+ * Absent when the adapter cannot determine capacity.
93
+ */
94
+ contextWindowSize?: number;
95
+ };
57
96
  }
58
97
  /**
59
98
  * Payload emitted by LLM adapters as an `"llm.request"` observer event,
@@ -70,11 +109,13 @@ interface LLMRequestEvent {
70
109
  tools?: unknown;
71
110
  }
72
111
  /**
73
- * Token-usage payload emitted by LLM adapters as an `llm.response` observer event.
112
+ * Token-usage payload emitted by LLM adapters as an `"llm.response"` observer event.
74
113
  *
75
114
  * Adapters that cannot report real token counts (e.g. mock adapters) emit `{ input: 0, output: 0 }`.
76
115
  *
77
116
  * `output` is opt-in — it may contain sensitive data; adapters default to omitting it.
117
+ * `contextWindowSize` mirrors `LLMResponse.usage.contextWindowSize` — present when the
118
+ * adapter can determine it; absent otherwise.
78
119
  */
79
120
  interface LLMUsageEvent {
80
121
  tokens: {
@@ -85,6 +126,7 @@ interface LLMUsageEvent {
85
126
  stopReason: LLMResponse['stopReason'];
86
127
  providerName: string;
87
128
  output?: unknown;
129
+ contextWindowSize?: number;
88
130
  }
89
131
  /**
90
132
  * Payload emitted on `"tool.call"` before a tool is dispatched.
@@ -129,4 +171,4 @@ interface LLM {
129
171
  }): Promise<LLMResponse>;
130
172
  }
131
173
 
132
- export type { LLM, LLMRequestEvent, LLMResponse, LLMUsageEvent, Message, Tool, ToolCall, ToolCallEvent, ToolResultEvent };
174
+ export { type ContentBlock, type ImageBlock, type LLM, type LLMRequestEvent, type LLMResponse, type LLMUsageEvent, type Message, type TextBlock, type Tool, type ToolCall, type ToolCallEvent, type ToolResultEvent, toBlocks };
package/dist/index.js CHANGED
@@ -1 +1,8 @@
1
+ // src/index.ts
2
+ function toBlocks(content) {
3
+ return typeof content === "string" ? [{ type: "text", text: content }] : content;
4
+ }
5
+ export {
6
+ toBlocks
7
+ };
1
8
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * Shared LLM protocol types used by all harness LLM adapters.\n *\n * @packageDocumentation\n */\n\n/** A run of plain text inside a user message. */\nexport type TextBlock = { type: 'text'; text: string }\n\n/**\n * An image inside a user message.\n *\n * `data` is raw base64 with no `data:` prefix. `mediaType` is the MIME type\n * (e.g. `'image/png'`); Anthropic, OpenAI and Ollama throw on types they do not accept; Gemini passes any value through.\n */\nexport type ImageBlock = { type: 'image'; data: string; mediaType: string }\n\n/** One piece of user-message content. */\nexport type ContentBlock = TextBlock | ImageBlock\n\n/**\n * A single turn in a conversation.\n *\n * - `user` — input from the human side: a string, or blocks mixing text and images.\n * An empty block array is allowed by the type but may be rejected by providers; don't send one.\n * - `assistant` — model reply, optionally carrying tool-call requests.\n * - `tool` — result returned to the model after a tool call.\n */\nexport type Message =\n | { role: 'user'; content: string | ContentBlock[] }\n | { role: 'assistant'; content?: string; toolCalls?: ToolCall[] }\n | { role: 'tool'; toolCallId: string; content: string }\n\n/**\n * Normalize user-message content to blocks.\n *\n * A string becomes one text block (an empty string stays one empty text block).\n * An array is returned as-is, same reference, with no validation.\n */\nexport function toBlocks(content: string | ContentBlock[]): ContentBlock[] {\n return typeof content === 'string' ? [{ type: 'text', text: content }] : content\n}\n\n/**\n * A tool the LLM may invoke during a conversation turn.\n *\n * `inputSchema` must be a valid JSON Schema object describing the tool's\n * input parameters (LLM adapters pass it to the provider as-is).\n */\nexport interface Tool {\n name: string\n description: string\n inputSchema: Record<string, unknown>\n}\n\n/**\n * A single tool-call request emitted by the LLM in an assistant turn.\n *\n * `id` is provider-assigned and must be echoed back in the corresponding\n * `role: 'tool'` message so the model can correlate results.\n */\nexport interface ToolCall {\n id: string\n name: string\n input: unknown\n}\n\n/**\n * Normalized response returned by every {@link LLM} adapter.\n *\n * `text` may be empty when `stopReason` is `'tool_use'`.\n * `toolCalls` is empty when `stopReason` is `'end'` or `'max_tokens'`.\n * `usage` is always present — all adapters must populate it.\n */\nexport interface LLMResponse {\n text: string\n toolCalls: ToolCall[]\n stopReason: 'end' | 'tool_use' | 'max_tokens'\n usage: {\n /** Number of input tokens consumed by this call. */\n inputTokens: number\n /** Number of output tokens generated by this call. */\n outputTokens: number\n /**\n * The model's total context window capacity in tokens.\n * Present only when the adapter can determine it.\n * Absent when the adapter cannot determine capacity.\n */\n contextWindowSize?: number\n }\n}\n\n/**\n * Payload emitted by LLM adapters as an `\"llm.request\"` observer event,\n * immediately before the provider call is made.\n *\n * `messages` and `tools` are opt-in — they may contain sensitive data and are\n * typed as `unknown` because adapters pass their internal translated\n * representations; the framework never inspects this content.\n */\nexport interface LLMRequestEvent {\n modelId: string\n providerName: string\n messages?: unknown // opt-in; adapter's internal translated representation\n tools?: unknown // opt-in; adapter's internal translated representation\n}\n\n/**\n * Token-usage payload emitted by LLM adapters as an `\"llm.response\"` observer event.\n *\n * Adapters that cannot report real token counts (e.g. mock adapters) emit `{ input: 0, output: 0 }`.\n *\n * `output` is opt-in — it may contain sensitive data; adapters default to omitting it.\n * `contextWindowSize` mirrors `LLMResponse.usage.contextWindowSize` — present when the\n * adapter can determine it; absent otherwise.\n */\nexport interface LLMUsageEvent {\n tokens: { input: number; output: number }\n modelId: string\n stopReason: LLMResponse['stopReason']\n providerName: string\n output?: unknown // opt-in; normalized LLMResponse or raw provider output; may be sensitive\n contextWindowSize?: number\n}\n\n/**\n * Payload emitted on `\"tool.call\"` before a tool is dispatched.\n *\n * `input` is opt-in — it may contain sensitive data; omit by default.\n */\nexport interface ToolCallEvent {\n toolName: string\n toolCallId: string\n input?: unknown\n}\n\n/**\n * Payload emitted on `\"tool.result\"` after a tool completes or throws.\n *\n * `result` and `error` are opt-in — they may contain sensitive data; omit by default.\n * Set `error` when the tool threw; leave it absent on success.\n */\nexport interface ToolResultEvent {\n toolName: string\n toolCallId: string\n durationMs: number\n result?: unknown\n error?: unknown\n}\n\n/**\n * Core interface every LLM adapter must implement.\n *\n * @example\n * ```ts\n * const response = await llm.invoke(messages, { tools: [myTool] })\n * ```\n */\nexport interface LLM {\n /**\n * Send a message list to the LLM and return a normalized response.\n *\n * @param messages - Conversation history, oldest first.\n * @param options.tools - Optional tools the model may invoke.\n */\n invoke(messages: Message[], options?: { tools?: Tool[] }): Promise<LLMResponse>\n}\n"],"mappings":";AAuCO,SAAS,SAAS,SAAkD;AACzE,SAAO,OAAO,YAAY,WAAW,CAAC,EAAE,MAAM,QAAQ,MAAM,QAAQ,CAAC,IAAI;AAC3E;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetaris/harness-types",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shared LLM type contract for the @noetaris/harness ecosystem",
5
5
  "type": "module",
6
6
  "repository": {