mu-core 0.13.0 → 0.16.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mu-core",
3
- "version": "0.13.0",
4
- "description": "Agent loop orchestration core: types, plugin SDK, channels, sessions",
3
+ "version": "0.16.1",
4
+ "description": "Standalone multimodal agentic loop: content, messages, tools, provider interface, createAgent",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
package/src/agent.test.ts CHANGED
@@ -1,91 +1,45 @@
1
- /**
2
- * Focused tests for `runAgent`. Most coverage lives in higher layers
3
- * (session, mu-agents subagent, integration) but we keep a few pinpoint
4
- * cases here to lock down behaviour that's easy to break:
5
- *
6
- * - `display.llmHidden` strips messages from the network payload but
7
- * keeps them in the streamed transcript surface (regression target
8
- * for the `@`-mention dispatch flow that injects a UI-only subagent
9
- * header alongside a real synthetic tool flow).
10
- */
11
- import { describe, expect, it } from 'bun:test';
12
- import { runAgent } from './agent';
13
- import { createProviderRegistry } from './provider/registry';
14
- import { PluginRegistry } from './registry';
15
- import type { ChatMessage, ProviderConfig, StreamChunk } from './types/llm';
16
-
17
- interface CapturedCall {
18
- messages: ChatMessage[];
19
- }
20
-
21
- function fakeRegistry(captured: CapturedCall[]): PluginRegistry {
22
- const providers = createProviderRegistry();
23
- providers.register({
24
- id: 'openai',
25
- async *streamChat(messages: ChatMessage[]): AsyncIterable<StreamChunk> {
26
- // Snapshot the exact message list the provider receives so the test
27
- // can assert on what `streamTurn` actually sent over the wire.
28
- captured.push({ messages: messages.map((m) => ({ ...m })) });
29
- yield { type: 'content', text: 'ok' };
1
+ import { assertEquals } from '@std/assert';
2
+ import { createAgent } from './agent';
3
+ import { image } from './types';
4
+ import type { ContentPart, Provider, Tool } from './types';
5
+
6
+ const scripted = (turns: ContentPart[][]): Provider => {
7
+ let i = 0;
8
+ return {
9
+ async *stream() {
10
+ for (const event of turns[i++]) yield event;
30
11
  },
31
- async listModels() {
32
- return [];
33
- },
34
- });
35
- return new PluginRegistry({ cwd: '/tmp', config: {}, providers });
36
- }
37
-
38
- const CFG: ProviderConfig = { providerId: 'openai' };
39
-
40
- describe('runAgent display.llmHidden filter', () => {
41
- it('strips llmHidden messages from the provider payload', async () => {
42
- const captured: CapturedCall[] = [];
43
- const registry = fakeRegistry(captured);
44
-
45
- const messages: ChatMessage[] = [
46
- { role: 'system', content: 'sys' },
47
- // UI-only marker must be filtered before the network call.
48
- {
49
- role: 'assistant',
50
- content: 'phantom header',
51
- display: { llmHidden: true },
52
- },
53
- { role: 'user', content: 'hi' },
54
- ];
55
-
56
- for await (const _ of runAgent(messages, CFG, 'm', new AbortController().signal, registry)) {
57
- // drain
58
- }
59
-
60
- expect(captured.length).toBe(1);
61
- const sent = captured[0].messages;
62
- // Only system + user reach the provider.
63
- expect(sent.length).toBe(2);
64
- expect(sent[0].role).toBe('system');
65
- expect(sent[1].role).toBe('user');
66
- expect(sent.find((m) => m.content === 'phantom header')).toBeUndefined();
12
+ };
13
+ };
14
+
15
+ Deno.test('loops on a tool_call and returns the final message', async () => {
16
+ const provider = scripted([
17
+ [{ type: 'tool_call', id: '1', name: 'snap', input: {} }],
18
+ [{ type: 'text', text: 'voici' }],
19
+ ]);
20
+
21
+ const snap: Tool = {
22
+ name: 'snap',
23
+ description: 'returns an image',
24
+ parameters: {},
25
+ run: async () => [image('image/png', new Uint8Array([1, 2, 3]))],
26
+ };
27
+
28
+ const agent = createAgent({ provider, model: 'mock', tools: [snap] });
29
+ const { message, messages } = await agent.run('photo ?');
30
+
31
+ assertEquals(message.content, [{ type: 'text', text: 'voici' }]);
32
+ const tool = messages.find((m) => m.role === 'user' && m.content[0]?.type === 'tool_result');
33
+ assertEquals(tool?.content[0], {
34
+ type: 'tool_result',
35
+ id: '1',
36
+ content: [{ type: 'image', mime: 'image/png', data: new Uint8Array([1, 2, 3]) }],
67
37
  });
38
+ });
68
39
 
69
- it('keeps non-llmHidden messages even when `display.hidden` is set', async () => {
70
- // Inverse flag: `display.hidden` is UI-only suppression and must NOT
71
- // affect the LLM payload. This test pins that the two flags do not
72
- // accidentally collapse into the same filter.
73
- const captured: CapturedCall[] = [];
74
- const registry = fakeRegistry(captured);
75
-
76
- const messages: ChatMessage[] = [
77
- { role: 'system', content: 'sys' },
78
- { role: 'user', content: 'silent reminder', display: { hidden: true } },
79
- { role: 'user', content: 'hi' },
80
- ];
81
-
82
- for await (const _ of runAgent(messages, CFG, 'm', new AbortController().signal, registry)) {
83
- // drain
84
- }
85
-
86
- expect(captured.length).toBe(1);
87
- const sent = captured[0].messages;
88
- expect(sent.length).toBe(3);
89
- expect(sent.find((m) => m.content === 'silent reminder')).toBeDefined();
90
- });
40
+ Deno.test('merges streamed text deltas', async () => {
41
+ const provider = scripted([[{ type: 'text', text: 'bon' }, { type: 'text', text: 'jour' }]]);
42
+ const agent = createAgent({ provider, model: 'mock' });
43
+ const { message } = await agent.run('salut');
44
+ assertEquals(message.content, [{ type: 'text', text: 'bonjour' }]);
91
45
  });
package/src/agent.ts CHANGED
@@ -1,247 +1,140 @@
1
- import {
2
- runAfterAgentRunHooks,
3
- runAfterLlmHooks,
4
- runAfterToolExecHook,
5
- runBeforeLlmHooks,
6
- runBeforeToolExecHook,
7
- runDecorateMessageHooks,
8
- } from './hooks';
9
- import type { AgentEvent, PluginTool, ToolResult, TurnResult } from './plugin';
10
- import type { PluginRegistry } from './registry';
11
- import type { ChatMessage, ProviderConfig, StreamChunk, StreamOptions, ToolCall } from './types/llm';
12
-
13
- const DEFAULT_PROVIDER_ID = 'openai';
14
-
15
- /**
16
- * Stream a chat completion through the host's provider registry. Resolved
17
- * by `config.providerId` (default `'openai'`). When no provider matches, we
18
- * throw — `mu-core` is provider-agnostic, the host is responsible for
19
- * registering at least one (e.g. via `mu-openai-provider`).
20
- */
21
- function streamChatViaRegistry(
22
- registry: PluginRegistry,
23
- messages: ChatMessage[],
24
- config: ProviderConfig,
25
- model: string,
26
- options: StreamOptions,
27
- ): AsyncIterable<StreamChunk> {
28
- const id = config.providerId ?? DEFAULT_PROVIDER_ID;
29
- const providers = registry.getProviders();
30
- const provider = providers?.get(id);
31
- if (!provider) {
32
- throw new Error(
33
- `No provider registered for id "${id}". Register one (e.g. via mu-openai-provider) before calling runAgent.`,
34
- );
35
- }
36
- return provider.streamChat(messages, config, model, options);
37
- }
38
-
39
- async function executeTool(call: ToolCall, tools: PluginTool[], signal?: AbortSignal): Promise<ToolResult> {
40
- let args: Record<string, unknown>;
41
- try {
42
- args = JSON.parse(call.function.arguments);
43
- } catch {
44
- return { tool_call_id: call.id, name: call.function.name, content: 'Error: Invalid JSON arguments', error: true };
45
- }
46
-
47
- const tool = tools.find((t) => t.definition.function.name === call.function.name);
48
- if (!tool) {
49
- return {
50
- tool_call_id: call.id,
51
- name: call.function.name,
52
- content: `Error: Unknown tool: ${call.function.name}`,
53
- error: true,
54
- };
1
+ import type { ContentPart, Message, Provider, Tool, Usage } from './types';
2
+
3
+ type ToolCallPart = Extract<ContentPart, { type: 'tool_call' }>;
4
+
5
+ export type LoopEvent =
6
+ | ContentPart
7
+ | { type: 'usage'; usage: Usage }
8
+ | { type: 'reasoning'; text: string }
9
+ | { type: 'message'; message: Message }
10
+ | { type: 'done'; messages: Message[] };
11
+
12
+ const append = (parts: ContentPart[], part: ContentPart): void => {
13
+ const last = parts[parts.length - 1];
14
+ if (part.type === 'text' && last?.type === 'text') {
15
+ last.text += part.text;
16
+ } else if (part.type === 'audio' && last?.type === 'audio' && last.mime === part.mime) {
17
+ const merged = new Uint8Array(last.data.length + part.data.length);
18
+ merged.set(last.data);
19
+ merged.set(part.data, last.data.length);
20
+ last.data = merged;
21
+ } else if (part.type === 'text') {
22
+ parts.push({ type: 'text', text: part.text });
23
+ } else if (part.type === 'audio') {
24
+ parts.push({ type: 'audio', mime: part.mime, data: part.data });
25
+ } else {
26
+ parts.push(part);
55
27
  }
28
+ };
56
29
 
30
+ const execute = async (tools: Map<string, Tool>, call: ToolCallPart, signal?: AbortSignal): Promise<ContentPart[]> => {
31
+ const tool = tools.get(call.name);
32
+ if (!tool) return [{ type: 'text', text: `Unknown tool: ${call.name}` }];
57
33
  try {
58
- const result = await tool.execute(args, signal);
59
- // Tools may return either a plain string (error inferred from "Error:"
60
- // prefix — legacy/convenience form) or a typed `{ content, error }`
61
- // object that makes the error flag explicit. Both forms are accepted to
62
- // avoid breaking existing plugin authors.
63
- if (typeof result === 'string') {
64
- return { tool_call_id: call.id, name: call.function.name, content: result, error: result.startsWith('Error:') };
65
- }
66
- return {
67
- tool_call_id: call.id,
68
- name: call.function.name,
69
- content: result.content,
70
- error: result.error ?? false,
71
- };
34
+ return await tool.run(call.input, { signal });
72
35
  } catch (err) {
73
- const msg = err instanceof Error ? err.message : 'Unknown error';
74
- return { tool_call_id: call.id, name: call.function.name, content: `Error: ${msg}`, error: true };
36
+ return [{ type: 'text', text: err instanceof Error ? err.message : String(err) }];
75
37
  }
38
+ };
39
+
40
+ export interface RunOptions {
41
+ provider: Provider;
42
+ model: string;
43
+ messages: Message[];
44
+ tools?: Tool[];
45
+ signal?: AbortSignal;
76
46
  }
77
47
 
78
- async function* streamTurn(
79
- messages: ChatMessage[],
80
- config: ProviderConfig,
81
- model: string,
82
- signal: AbortSignal,
83
- registry: PluginRegistry,
84
- toolDefs: PluginTool[],
85
- ): AsyncGenerator<AgentEvent, TurnResult> {
86
- let content = '';
87
- let reasoning = '';
88
- let usage = 0;
89
- let cachedPromptTokens = 0;
90
- const toolCalls: ToolCall[] = [];
91
-
92
- const hooks = registry.getHooks();
93
- const hookedMessages = await runBeforeLlmHooks(hooks, messages, config);
94
- // `display.llmHidden` keeps a message in the on-screen transcript but
95
- // strips it from the LLM payload. Applied AFTER `beforeLlmHooks` so plugin
96
- // hooks still see the full transcript if they want it; this is the very
97
- // last filter before the network call. Inverse of `display.hidden`, which
98
- // hides from UI but keeps in the LLM payload.
99
- const llmMessages = hookedMessages.filter((m) => !m.display?.llmHidden);
100
- const toolDefinitions = toolDefs.map((t) => t.definition);
101
-
102
- for await (const chunk of streamChatViaRegistry(registry, llmMessages, config, model, {
103
- signal,
104
- tools: toolDefinitions,
105
- onUsage: (u) => {
106
- usage = u.totalTokens;
107
- cachedPromptTokens = u.cachedPromptTokens ?? 0;
108
- },
109
- })) {
110
- if (signal.aborted) {
111
- break;
112
- }
113
- if (chunk.type === 'reasoning') {
114
- reasoning += chunk.text;
115
- yield { type: 'reasoning', text: reasoning };
116
- } else if (chunk.type === 'content') {
117
- content += chunk.text;
118
- yield { type: 'content', text: content };
119
- } else if (chunk.type === 'tool_call') {
120
- toolCalls.push({ id: chunk.toolCall.id, function: chunk.toolCall.function });
48
+ export async function* run(opts: RunOptions): AsyncIterable<LoopEvent> {
49
+ const { provider, model, signal } = opts;
50
+ const tools = opts.tools ?? [];
51
+ const registry = new Map(tools.map((t) => [t.name, t]));
52
+ const messages = [...opts.messages];
53
+
54
+ while (true) {
55
+ const content: ContentPart[] = [];
56
+ const calls: ToolCallPart[] = [];
57
+
58
+ for await (const event of provider.stream({ model, messages, tools, signal })) {
59
+ if (event.type === 'usage' || event.type === 'reasoning') {
60
+ yield event;
61
+ continue;
62
+ }
63
+ yield event;
64
+ append(content, event);
65
+ if (event.type === 'tool_call') calls.push(event);
121
66
  }
122
- }
123
67
 
124
- const result: TurnResult = { content, reasoning, toolCalls, usage, cachedPromptTokens };
125
- return await runAfterLlmHooks(hooks, result);
126
- }
68
+ const message: Message = { role: 'assistant', content };
69
+ messages.push(message);
70
+ yield { type: 'message', message };
71
+
72
+ if (calls.length === 0) break;
127
73
 
128
- async function executeOneToolCall(
129
- tc: ToolCall,
130
- tools: PluginTool[],
131
- signal: AbortSignal,
132
- registry: PluginRegistry,
133
- ): Promise<ChatMessage> {
134
- const hooks = registry.getHooks();
135
- const hookOutcome = await runBeforeToolExecHook(hooks, tc);
136
-
137
- if ('blocked' in hookOutcome) {
138
- const content = await runAfterToolExecHook(hooks, tc, hookOutcome.content);
139
- return runDecorateMessageHooks(hooks, {
140
- role: 'tool',
141
- content,
142
- toolCallId: tc.id,
143
- toolResult: { name: tc.function.name, content, error: hookOutcome.error ?? true },
144
- toolCallArgs: { [tc.function.name]: tc.function.arguments },
145
- });
74
+ const results: ContentPart[] = await Promise.all(
75
+ calls.map(async (call) => ({
76
+ type: 'tool_result' as const,
77
+ id: call.id,
78
+ content: await execute(registry, call, signal),
79
+ })),
80
+ );
81
+ const toolMessage: Message = { role: 'user', content: results };
82
+ messages.push(toolMessage);
83
+ yield { type: 'message', message: toolMessage };
146
84
  }
147
85
 
148
- const result = await executeTool(hookOutcome, tools, signal);
149
- const content = await runAfterToolExecHook(hooks, hookOutcome, result.content);
150
- return runDecorateMessageHooks(hooks, {
151
- role: 'tool',
152
- content,
153
- toolCallId: result.tool_call_id,
154
- toolResult: { name: result.name, content, error: result.error },
155
- toolCallArgs: { [result.name]: tc.function.arguments },
156
- });
86
+ yield { type: 'done', messages };
157
87
  }
158
88
 
159
- async function* executeToolCalls(
160
- calls: ToolCall[],
161
- start: ChatMessage[],
162
- signal: AbortSignal,
163
- registry: PluginRegistry,
164
- tools: PluginTool[],
165
- ): AsyncGenerator<AgentEvent, ChatMessage[]> {
166
- let current = start;
167
-
168
- for (const tc of calls) {
169
- if (signal.aborted) break;
170
- const toolMessage = await executeOneToolCall(tc, tools, signal, registry);
171
- if (signal.aborted) break;
172
- current = [...current, toolMessage];
173
- yield { type: 'messages', messages: current };
174
- }
175
- return current;
89
+ export interface AgentConfig {
90
+ provider: Provider;
91
+ model: string;
92
+ tools?: Tool[];
93
+ system?: string;
94
+ signal?: AbortSignal;
176
95
  }
177
96
 
178
- async function buildMergedConfig(config: ProviderConfig, registry: PluginRegistry): Promise<ProviderConfig> {
179
- const pluginPrompts = await registry.getSystemPrompts();
180
- const merged = [config.systemPrompt, ...pluginPrompts].filter(Boolean).join('\n\n');
181
- const transformed = await registry.applySystemPromptTransforms(merged);
182
- if (!transformed) return config;
183
- return { ...config, systemPrompt: transformed };
97
+ export type Input = string | ContentPart[] | Message[];
98
+
99
+ export interface AgentResult {
100
+ message: Message;
101
+ messages: Message[];
184
102
  }
185
103
 
186
- export async function* runAgent(
187
- initialMessages: ChatMessage[],
188
- config: ProviderConfig,
189
- model: string,
190
- signal: AbortSignal,
191
- registry: PluginRegistry,
192
- ): AsyncGenerator<AgentEvent> {
193
- const mergedConfig = await buildMergedConfig(config, registry);
194
- const hooks = registry.getHooks();
195
-
196
- // Wrap the body in try/finally so `afterAgentRun` fires exactly once per
197
- // `runAgent` call, regardless of how it terminates: normal completion (LLM
198
- // produced a final response), abort via `signal.aborted`, or generator
199
- // cancellation by the consumer (Ink unmount, manual `.return()`).
200
- try {
201
- const customLoop = registry.getAgentLoop();
202
- if (customLoop) {
203
- const filtered = await registry.getFilteredTools();
204
- yield* customLoop.run(initialMessages, mergedConfig, model, signal, filtered, hooks);
205
- return;
206
- }
104
+ export interface Agent {
105
+ stream(input: Input): AsyncIterable<LoopEvent>;
106
+ run(input: Input): Promise<AgentResult>;
107
+ }
207
108
 
208
- let current = initialMessages;
209
-
210
- while (!signal.aborted) {
211
- // Re-evaluate every turn so per-turn agent switches are honoured.
212
- const tools = await registry.getFilteredTools();
213
- const { content, reasoning, toolCalls, usage, cachedPromptTokens } = yield* streamTurn(
214
- current,
215
- mergedConfig,
216
- model,
217
- signal,
218
- registry,
219
- tools,
220
- );
221
-
222
- if (usage > 0) {
223
- yield { type: 'usage', totalTokens: usage, cachedTokens: cachedPromptTokens };
224
- }
225
- if (signal.aborted) {
226
- break;
227
- }
109
+ const isMessages = (input: ContentPart[] | Message[]): input is Message[] => input.length > 0 && 'role' in input[0];
228
110
 
229
- const reasoningField = reasoning || undefined;
230
- const assistantBase: ChatMessage = { role: 'assistant', content, reasoning: reasoningField };
231
- if (toolCalls.length === 0) {
232
- const assistant = await runDecorateMessageHooks(registry.getHooks(), assistantBase);
233
- current = [...current, assistant];
234
- yield { type: 'messages', messages: current };
235
- return;
236
- }
111
+ const toMessages = (input: Input): Message[] => {
112
+ if (typeof input === 'string') return [{ role: 'user', content: [{ type: 'text', text: input }] }];
113
+ if (isMessages(input)) return input;
114
+ return [{ role: 'user', content: input }];
115
+ };
116
+
117
+ export const createAgent = (config: AgentConfig): Agent => {
118
+ const tools = config.tools ?? [];
119
+
120
+ const build = (input: Input): Message[] => {
121
+ const messages = toMessages(input);
122
+ if (!config.system) return messages;
123
+ return [{ role: 'system', content: [{ type: 'text', text: config.system }] }, ...messages];
124
+ };
125
+
126
+ const stream = (input: Input): AsyncIterable<LoopEvent> =>
127
+ run({ provider: config.provider, model: config.model, tools, messages: build(input), signal: config.signal });
237
128
 
238
- const assistantWithCalls = await runDecorateMessageHooks(registry.getHooks(), { ...assistantBase, toolCalls });
239
- current = [...current, assistantWithCalls];
240
- yield { type: 'messages', messages: current };
241
- current = yield* executeToolCalls(toolCalls, current, signal, registry, tools);
242
- yield { type: 'turn_end' };
129
+ const runToEnd = async (input: Input): Promise<AgentResult> => {
130
+ let message: Message = { role: 'assistant', content: [] };
131
+ let messages: Message[] = [];
132
+ for await (const event of stream(input)) {
133
+ if (event.type === 'message' && event.message.role === 'assistant') message = event.message;
134
+ else if (event.type === 'done') messages = event.messages;
243
135
  }
244
- } finally {
245
- await runAfterAgentRunHooks(hooks, signal.aborted ? 'aborted' : 'complete');
246
- }
247
- }
136
+ return { message, messages };
137
+ };
138
+
139
+ return { stream, run: runToEnd };
140
+ };
package/src/index.ts CHANGED
@@ -1,74 +1,5 @@
1
- export type { ActivityBus, ActivityEvent, ActivityKind, SubAgentEvent, SubAgentEventKind } from './activity';
2
- export { createActivityBus } from './activity';
3
- export { runAgent } from './agent';
4
- export type { Channel, ChannelRegistry, ChannelResponder, InboundKind, InboundMessage, ResponseMode } from './channel';
5
- export { createChannelRegistry } from './channel';
6
- export { runDecorateMessageHooks, runTransformUserInputHooks } from './hooks';
7
- export type { MuConfigShape, MuHandle, StartMuOptions } from './host/index';
8
- export { startMu } from './host/index';
9
- export type {
10
- AgentEndReason,
11
- AgentEvent,
12
- AgentLoopStrategy,
13
- AgentSourceRegistry,
14
- BeforeToolExecResult,
15
- CommandContext,
16
- InputInfoSegment,
17
- LifecycleHooks,
18
- MentionCompletion,
19
- MentionProvider,
20
- MessageBus,
21
- MessageRenderer,
22
- Plugin,
23
- PluginContext,
24
- PluginExtras,
25
- PluginRegistryView,
26
- PluginTool,
27
- PluginToolPermission,
28
- ShortcutHandler,
29
- SlashCommand,
30
- StatusSegment,
31
- ToolBlock,
32
- ToolDisplayHint,
33
- ToolExecutor,
34
- ToolExecutorResult,
35
- ToolResult,
36
- TurnResult,
37
- UserInputTransform,
38
- } from './plugin';
39
- export type {
40
- ChatRequestInput,
41
- ModelsRequestInput,
42
- ParsedChatEvent,
43
- Provider,
44
- ProviderAdapter,
45
- RequestSpec,
46
- } from './provider/adapter';
47
- export { createProvider } from './provider/adapter';
48
- export type { ProviderRegistry } from './provider/registry';
49
- export { createProviderRegistry } from './provider/registry';
50
- export { fetchWithIdleTimeout, readNDJSON, readSSE } from './provider/transport';
51
- export { PluginRegistry, type PluginRegistryOptions } from './registry';
52
- export type {
53
- CreateSessionManagerOptions,
54
- RunTurnOptions,
55
- Session,
56
- SessionEvent,
57
- SessionInit,
58
- SessionManager,
59
- } from './session';
60
- export { createSessionManager } from './session';
61
- export type {
62
- ApiModel,
63
- ChatMessage,
64
- ImageAttachment,
65
- MessageDisplay,
66
- ProviderConfig,
67
- StreamChunk,
68
- StreamOptions,
69
- ToolCall,
70
- ToolDefinition,
71
- ToolResultInfo,
72
- Usage,
73
- } from './types/llm';
74
- export { ConsoleUIService, type UINotifyLevel, type UIService } from './ui';
1
+ export type { ContentPart, Message, Provider, Role, StreamEvent, Tool, Usage } from './types';
2
+ export { audio, image, text } from './types';
3
+
4
+ export type { Agent, AgentConfig, AgentResult, Input, LoopEvent, RunOptions } from './agent';
5
+ export { createAgent, run } from './agent';
package/src/types.ts ADDED
@@ -0,0 +1,37 @@
1
+ export type ContentPart =
2
+ | { type: 'text'; text: string }
3
+ | { type: 'image'; mime: string; data: Uint8Array }
4
+ | { type: 'audio'; mime: string; data: Uint8Array }
5
+ | { type: 'tool_call'; id: string; name: string; input: unknown }
6
+ | { type: 'tool_result'; id: string; content: ContentPart[] };
7
+
8
+ export type Role = 'system' | 'user' | 'assistant';
9
+ export type Message = { role: Role; content: ContentPart[] };
10
+
11
+ export const text = (value: string): ContentPart => ({ type: 'text', text: value });
12
+ export const image = (mime: string, data: Uint8Array): ContentPart => ({ type: 'image', mime, data });
13
+ export const audio = (mime: string, data: Uint8Array): ContentPart => ({ type: 'audio', mime, data });
14
+
15
+ export interface Tool {
16
+ name: string;
17
+ description: string;
18
+ parameters: Record<string, unknown>;
19
+ prompt?: string;
20
+ run(input: unknown, ctx: { signal?: AbortSignal }): Promise<ContentPart[]>;
21
+ }
22
+
23
+ export interface Usage {
24
+ input?: number;
25
+ output?: number;
26
+ total?: number;
27
+ contextWindow?: number;
28
+ }
29
+
30
+ export type StreamEvent =
31
+ | ContentPart
32
+ | { type: 'usage'; usage: Usage }
33
+ | { type: 'reasoning'; text: string };
34
+
35
+ export interface Provider {
36
+ stream(req: { model: string; messages: Message[]; tools: Tool[]; signal?: AbortSignal }): AsyncIterable<StreamEvent>;
37
+ }