mu-core 0.8.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 +110 -0
- package/package.json +14 -0
- package/src/activity.test.ts +44 -0
- package/src/activity.ts +83 -0
- package/src/agent.ts +238 -0
- package/src/channel.test.ts +52 -0
- package/src/channel.ts +77 -0
- package/src/hooks.test.ts +76 -0
- package/src/hooks.ts +89 -0
- package/src/host/index.ts +135 -0
- package/src/host/startMu.test.ts +66 -0
- package/src/index.ts +73 -0
- package/src/plugin.ts +337 -0
- package/src/provider/adapter.ts +100 -0
- package/src/provider/registry.test.ts +37 -0
- package/src/provider/registry.ts +26 -0
- package/src/provider/transport.test.ts +58 -0
- package/src/provider/transport.ts +103 -0
- package/src/registry.context.test.ts +71 -0
- package/src/registry.ts +430 -0
- package/src/session.test.ts +99 -0
- package/src/session.ts +227 -0
- package/src/types/llm.ts +107 -0
- package/src/ui.ts +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# mu-core
|
|
2
|
+
|
|
3
|
+
The mu plugin SDK. Provides the agent loop, plugin registry, LLM types, and
|
|
4
|
+
the multi-host primitives (channels, sessions, activity bus, providers).
|
|
5
|
+
Provider implementations are separate packages — for OpenAI-compatible APIs,
|
|
6
|
+
add `mu-openai-provider` and register its plugin alongside.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install mu-core mu-openai-provider
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { runAgent, PluginRegistry } from "mu-core";
|
|
18
|
+
import type { ChatMessage, ProviderConfig } from "mu-core";
|
|
19
|
+
import { createOpenAIProviderPlugin } from "mu-openai-provider";
|
|
20
|
+
|
|
21
|
+
const config: ProviderConfig = {
|
|
22
|
+
baseUrl: "http://localhost:11434/v1",
|
|
23
|
+
maxTokens: 4096,
|
|
24
|
+
temperature: 0.7,
|
|
25
|
+
streamTimeoutMs: 30000,
|
|
26
|
+
// providerId defaults to 'openai' — register at least one provider
|
|
27
|
+
// implementation (e.g. via createOpenAIProviderPlugin) before running.
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const registry = new PluginRegistry({ cwd: process.cwd(), config: {} });
|
|
31
|
+
await registry.register(createOpenAIProviderPlugin());
|
|
32
|
+
|
|
33
|
+
const messages: ChatMessage[] = [
|
|
34
|
+
{ role: "user", content: "Hello" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
|
|
39
|
+
for await (const event of runAgent(messages, config, "qwen2.5", controller.signal, registry)) {
|
|
40
|
+
if (event.type === "content") process.stdout.write(event.text);
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For a higher-level API that owns conversation state, channel I/O, and
|
|
45
|
+
multi-session lifecycle, see `startMu` and `Session` (`createSessionManager`).
|
|
46
|
+
|
|
47
|
+
## Plugin System
|
|
48
|
+
|
|
49
|
+
Plugins can provide tools, system prompts, lifecycle hooks, slash commands,
|
|
50
|
+
custom agent loops, and side-channel registries (channels, providers,
|
|
51
|
+
activity bus, agent sources).
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import type { Plugin } from "mu-core";
|
|
55
|
+
|
|
56
|
+
const myPlugin: Plugin = {
|
|
57
|
+
name: "my-plugin",
|
|
58
|
+
tools: [
|
|
59
|
+
{
|
|
60
|
+
definition: {
|
|
61
|
+
type: "function",
|
|
62
|
+
function: {
|
|
63
|
+
name: "hello",
|
|
64
|
+
description: "Say hello",
|
|
65
|
+
parameters: { type: "object", properties: {} },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
execute: async () => "Hello, world!",
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
hooks: {
|
|
72
|
+
beforeLlmCall: (messages, config) => messages,
|
|
73
|
+
afterLlmCall: (result) => result,
|
|
74
|
+
beforeToolExec: (toolCall) => toolCall,
|
|
75
|
+
afterToolExec: (toolCall, result) => result,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
await registry.register(myPlugin);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Filesystem and shell tools live in `mu-coding` (`createCodingToolsPlugin`),
|
|
83
|
+
not in mu-core — keeps the SDK host-agnostic.
|
|
84
|
+
|
|
85
|
+
## API
|
|
86
|
+
|
|
87
|
+
### Agent loop
|
|
88
|
+
- `runAgent(messages, config, model, signal, registry)` — async generator yielding `AgentEvent` (`content`, `reasoning`, `messages`, `usage`, `turn_end`).
|
|
89
|
+
- Provider resolution: looks up `config.providerId ?? 'openai'` in the registered `ProviderRegistry`. Throws if no provider is registered.
|
|
90
|
+
|
|
91
|
+
### Sessions
|
|
92
|
+
- `createSessionManager({ registry, config, model })` returns a `SessionManager`.
|
|
93
|
+
- `session.runTurn({ userMessage, ... })` — appends, drains queue, runs agent loop, emits events.
|
|
94
|
+
- `session.subscribe(listener)` — `messages_changed`, `stream_partial`, `stream_started`, `stream_ended`, `usage`, `error`.
|
|
95
|
+
|
|
96
|
+
### Channels
|
|
97
|
+
- `Channel` interface (`id`, `start`, `stop`) — input surfaces (TUI, Telegram, websocket).
|
|
98
|
+
- `createChannelRegistry()` — host-managed registry; `startAll()` / `stopAll()` for lifecycle.
|
|
99
|
+
|
|
100
|
+
### Providers
|
|
101
|
+
- `ProviderAdapter` + `createProvider(adapter)` — build a `Provider` from raw HTTP semantics.
|
|
102
|
+
- `readSSE`, `readNDJSON`, `fetchWithIdleTimeout` — transport primitives.
|
|
103
|
+
- `ProviderRegistry` — host-managed; populated by provider plugins.
|
|
104
|
+
|
|
105
|
+
### Host
|
|
106
|
+
- `startMu(options)` — generic bootstrap: loads config, builds registries, activates plugins (config-listed via `options.resolvePlugin`, then code-passed), starts channels.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mu-core",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Agent loop orchestration core: types, plugin SDK, channels, sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createActivityBus } from './activity';
|
|
3
|
+
|
|
4
|
+
describe('ActivityBus', () => {
|
|
5
|
+
it('emits events to multiple subscribers', () => {
|
|
6
|
+
const bus = createActivityBus();
|
|
7
|
+
const a: string[] = [];
|
|
8
|
+
const b: string[] = [];
|
|
9
|
+
bus.subscribe((e) => a.push(e.summary));
|
|
10
|
+
bus.subscribe((e) => b.push(e.summary));
|
|
11
|
+
bus.emit('tool_start', 'bash', 'running git status');
|
|
12
|
+
expect(a).toEqual(['running git status']);
|
|
13
|
+
expect(b).toEqual(['running git status']);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('unsubscribes', () => {
|
|
17
|
+
const bus = createActivityBus();
|
|
18
|
+
const seen: string[] = [];
|
|
19
|
+
const off = bus.subscribe((e) => seen.push(e.summary));
|
|
20
|
+
bus.emit('tool_start', 'bash', 'first');
|
|
21
|
+
off();
|
|
22
|
+
bus.emit('tool_start', 'bash', 'second');
|
|
23
|
+
expect(seen).toEqual(['first']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('subagent stream is independent', () => {
|
|
27
|
+
const bus = createActivityBus();
|
|
28
|
+
const sub: string[] = [];
|
|
29
|
+
bus.subscribeSubAgent((e) => sub.push(e.kind));
|
|
30
|
+
bus.emitSubAgent({ runId: 'r1', agentId: 'review', kind: 'invocation_start', ts: 1, data: {} });
|
|
31
|
+
expect(sub).toEqual(['invocation_start']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('throwing listener does not break the bus', () => {
|
|
35
|
+
const bus = createActivityBus();
|
|
36
|
+
bus.subscribe(() => {
|
|
37
|
+
throw new Error('boom');
|
|
38
|
+
});
|
|
39
|
+
const seen: string[] = [];
|
|
40
|
+
bus.subscribe((e) => seen.push(e.summary));
|
|
41
|
+
bus.emit('tool_end', 'bash', 'ok');
|
|
42
|
+
expect(seen).toEqual(['ok']);
|
|
43
|
+
});
|
|
44
|
+
});
|
package/src/activity.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActivityBus — pub/sub for high-level events emitted by the agent loop and
|
|
3
|
+
* by tools. Hosts subscribe to render timelines (TUI), broadcast to
|
|
4
|
+
* companion websockets, etc. Independent of the session message store —
|
|
5
|
+
* messages_changed is on Session, ActivityBus is for sidebar/observability.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type ActivityKind =
|
|
9
|
+
| 'agent_start'
|
|
10
|
+
| 'agent_end'
|
|
11
|
+
| 'tool_start'
|
|
12
|
+
| 'tool_end'
|
|
13
|
+
| 'task_started'
|
|
14
|
+
| 'task_completed'
|
|
15
|
+
| 'task_error';
|
|
16
|
+
|
|
17
|
+
export interface ActivityEvent {
|
|
18
|
+
id: number;
|
|
19
|
+
ts: number;
|
|
20
|
+
kind: ActivityKind;
|
|
21
|
+
source: string;
|
|
22
|
+
summary: string;
|
|
23
|
+
detail?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SubAgentEventKind =
|
|
27
|
+
| 'invocation_start'
|
|
28
|
+
| 'text_delta'
|
|
29
|
+
| 'message_end'
|
|
30
|
+
| 'tool_call_start'
|
|
31
|
+
| 'tool_call_end'
|
|
32
|
+
| 'invocation_end';
|
|
33
|
+
|
|
34
|
+
export interface SubAgentEvent {
|
|
35
|
+
runId: string;
|
|
36
|
+
parentRunId?: string;
|
|
37
|
+
agentId: string;
|
|
38
|
+
kind: SubAgentEventKind;
|
|
39
|
+
ts: number;
|
|
40
|
+
data: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ActivityBus {
|
|
44
|
+
subscribe: (fn: (e: ActivityEvent) => void) => () => void;
|
|
45
|
+
emit: (kind: ActivityKind, source: string, summary: string, detail?: Record<string, unknown>) => void;
|
|
46
|
+
subscribeSubAgent: (fn: (e: SubAgentEvent) => void) => () => void;
|
|
47
|
+
emitSubAgent: (e: SubAgentEvent) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createActivityBus(): ActivityBus {
|
|
51
|
+
let nextId = 1;
|
|
52
|
+
const listeners = new Set<(e: ActivityEvent) => void>();
|
|
53
|
+
const subListeners = new Set<(e: SubAgentEvent) => void>();
|
|
54
|
+
return {
|
|
55
|
+
subscribe(fn) {
|
|
56
|
+
listeners.add(fn);
|
|
57
|
+
return () => listeners.delete(fn);
|
|
58
|
+
},
|
|
59
|
+
emit(kind, source, summary, detail) {
|
|
60
|
+
const event: ActivityEvent = { id: nextId++, ts: Date.now(), kind, source, summary, detail };
|
|
61
|
+
for (const fn of listeners) {
|
|
62
|
+
try {
|
|
63
|
+
fn(event);
|
|
64
|
+
} catch {
|
|
65
|
+
// listeners must not break the bus
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
subscribeSubAgent(fn) {
|
|
70
|
+
subListeners.add(fn);
|
|
71
|
+
return () => subListeners.delete(fn);
|
|
72
|
+
},
|
|
73
|
+
emitSubAgent(e) {
|
|
74
|
+
for (const fn of subListeners) {
|
|
75
|
+
try {
|
|
76
|
+
fn(e);
|
|
77
|
+
} catch {
|
|
78
|
+
// ignore
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runAfterAgentRunHooks,
|
|
3
|
+
runAfterLlmHooks,
|
|
4
|
+
runAfterToolExecHook,
|
|
5
|
+
runBeforeLlmHooks,
|
|
6
|
+
runBeforeToolExecHook,
|
|
7
|
+
} from './hooks';
|
|
8
|
+
import type { AgentEvent, PluginTool, ToolResult, TurnResult } from './plugin';
|
|
9
|
+
import type { PluginRegistry } from './registry';
|
|
10
|
+
import type { ChatMessage, ProviderConfig, StreamChunk, StreamOptions, ToolCall } from './types/llm';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PROVIDER_ID = 'openai';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Stream a chat completion through the host's provider registry. Resolved
|
|
16
|
+
* by `config.providerId` (default `'openai'`). When no provider matches, we
|
|
17
|
+
* throw — `mu-core` is provider-agnostic, the host is responsible for
|
|
18
|
+
* registering at least one (e.g. via `mu-openai-provider`).
|
|
19
|
+
*/
|
|
20
|
+
function streamChatViaRegistry(
|
|
21
|
+
registry: PluginRegistry,
|
|
22
|
+
messages: ChatMessage[],
|
|
23
|
+
config: ProviderConfig,
|
|
24
|
+
model: string,
|
|
25
|
+
options: StreamOptions,
|
|
26
|
+
): AsyncIterable<StreamChunk> {
|
|
27
|
+
const id = config.providerId ?? DEFAULT_PROVIDER_ID;
|
|
28
|
+
const providers = registry.getProviders();
|
|
29
|
+
const provider = providers?.get(id);
|
|
30
|
+
if (!provider) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`No provider registered for id "${id}". Register one (e.g. via mu-openai-provider) before calling runAgent.`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return provider.streamChat(messages, config, model, options);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function executeTool(call: ToolCall, tools: PluginTool[], signal?: AbortSignal): Promise<ToolResult> {
|
|
39
|
+
let args: Record<string, unknown>;
|
|
40
|
+
try {
|
|
41
|
+
args = JSON.parse(call.function.arguments);
|
|
42
|
+
} catch {
|
|
43
|
+
return { tool_call_id: call.id, name: call.function.name, content: 'Error: Invalid JSON arguments', error: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const tool = tools.find((t) => t.definition.function.name === call.function.name);
|
|
47
|
+
if (!tool) {
|
|
48
|
+
return {
|
|
49
|
+
tool_call_id: call.id,
|
|
50
|
+
name: call.function.name,
|
|
51
|
+
content: `Error: Unknown tool: ${call.function.name}`,
|
|
52
|
+
error: true,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const result = await tool.execute(args, signal);
|
|
58
|
+
// Tools may return either a plain string (error inferred from "Error:"
|
|
59
|
+
// prefix — legacy/convenience form) or a typed `{ content, error }`
|
|
60
|
+
// object that makes the error flag explicit. Both forms are accepted to
|
|
61
|
+
// avoid breaking existing plugin authors.
|
|
62
|
+
if (typeof result === 'string') {
|
|
63
|
+
return { tool_call_id: call.id, name: call.function.name, content: result, error: result.startsWith('Error:') };
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
tool_call_id: call.id,
|
|
67
|
+
name: call.function.name,
|
|
68
|
+
content: result.content,
|
|
69
|
+
error: result.error ?? false,
|
|
70
|
+
};
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
73
|
+
return { tool_call_id: call.id, name: call.function.name, content: `Error: ${msg}`, error: true };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function* streamTurn(
|
|
78
|
+
messages: ChatMessage[],
|
|
79
|
+
config: ProviderConfig,
|
|
80
|
+
model: string,
|
|
81
|
+
signal: AbortSignal,
|
|
82
|
+
registry: PluginRegistry,
|
|
83
|
+
toolDefs: PluginTool[],
|
|
84
|
+
): AsyncGenerator<AgentEvent, TurnResult> {
|
|
85
|
+
let content = '';
|
|
86
|
+
let reasoning = '';
|
|
87
|
+
let usage = 0;
|
|
88
|
+
let cachedPromptTokens = 0;
|
|
89
|
+
const toolCalls: ToolCall[] = [];
|
|
90
|
+
|
|
91
|
+
const hooks = registry.getHooks();
|
|
92
|
+
const hookedMessages = await runBeforeLlmHooks(hooks, messages, config);
|
|
93
|
+
const toolDefinitions = toolDefs.map((t) => t.definition);
|
|
94
|
+
|
|
95
|
+
for await (const chunk of streamChatViaRegistry(registry, hookedMessages, config, model, {
|
|
96
|
+
signal,
|
|
97
|
+
tools: toolDefinitions,
|
|
98
|
+
onUsage: (u) => {
|
|
99
|
+
usage = u.totalTokens;
|
|
100
|
+
cachedPromptTokens = u.cachedPromptTokens ?? 0;
|
|
101
|
+
},
|
|
102
|
+
})) {
|
|
103
|
+
if (signal.aborted) {
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
if (chunk.type === 'reasoning') {
|
|
107
|
+
reasoning += chunk.text;
|
|
108
|
+
yield { type: 'reasoning', text: reasoning };
|
|
109
|
+
} else if (chunk.type === 'content') {
|
|
110
|
+
content += chunk.text;
|
|
111
|
+
yield { type: 'content', text: content };
|
|
112
|
+
} else if (chunk.type === 'tool_call') {
|
|
113
|
+
toolCalls.push({ id: chunk.toolCall.id, function: chunk.toolCall.function });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result: TurnResult = { content, reasoning, toolCalls, usage, cachedPromptTokens };
|
|
118
|
+
return await runAfterLlmHooks(hooks, result);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function executeOneToolCall(
|
|
122
|
+
tc: ToolCall,
|
|
123
|
+
tools: PluginTool[],
|
|
124
|
+
signal: AbortSignal,
|
|
125
|
+
registry: PluginRegistry,
|
|
126
|
+
): Promise<ChatMessage> {
|
|
127
|
+
const hooks = registry.getHooks();
|
|
128
|
+
const hookOutcome = await runBeforeToolExecHook(hooks, tc);
|
|
129
|
+
|
|
130
|
+
if ('blocked' in hookOutcome) {
|
|
131
|
+
const content = await runAfterToolExecHook(hooks, tc, hookOutcome.content);
|
|
132
|
+
return {
|
|
133
|
+
role: 'tool',
|
|
134
|
+
content,
|
|
135
|
+
toolCallId: tc.id,
|
|
136
|
+
toolResult: { name: tc.function.name, content, error: hookOutcome.error ?? true },
|
|
137
|
+
toolCallArgs: { [tc.function.name]: tc.function.arguments },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = await executeTool(hookOutcome, tools, signal);
|
|
142
|
+
const content = await runAfterToolExecHook(hooks, hookOutcome, result.content);
|
|
143
|
+
return {
|
|
144
|
+
role: 'tool',
|
|
145
|
+
content,
|
|
146
|
+
toolCallId: result.tool_call_id,
|
|
147
|
+
toolResult: { name: result.name, content, error: result.error },
|
|
148
|
+
toolCallArgs: { [result.name]: tc.function.arguments },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function* executeToolCalls(
|
|
153
|
+
calls: ToolCall[],
|
|
154
|
+
start: ChatMessage[],
|
|
155
|
+
signal: AbortSignal,
|
|
156
|
+
registry: PluginRegistry,
|
|
157
|
+
tools: PluginTool[],
|
|
158
|
+
): AsyncGenerator<AgentEvent, ChatMessage[]> {
|
|
159
|
+
let current = start;
|
|
160
|
+
|
|
161
|
+
for (const tc of calls) {
|
|
162
|
+
if (signal.aborted) break;
|
|
163
|
+
const toolMessage = await executeOneToolCall(tc, tools, signal, registry);
|
|
164
|
+
if (signal.aborted) break;
|
|
165
|
+
current = [...current, toolMessage];
|
|
166
|
+
yield { type: 'messages', messages: current };
|
|
167
|
+
}
|
|
168
|
+
return current;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function buildMergedConfig(config: ProviderConfig, registry: PluginRegistry): Promise<ProviderConfig> {
|
|
172
|
+
const pluginPrompts = await registry.getSystemPrompts();
|
|
173
|
+
const merged = [config.systemPrompt, ...pluginPrompts].filter(Boolean).join('\n\n');
|
|
174
|
+
const transformed = await registry.applySystemPromptTransforms(merged);
|
|
175
|
+
if (!transformed) return config;
|
|
176
|
+
return { ...config, systemPrompt: transformed };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function* runAgent(
|
|
180
|
+
initialMessages: ChatMessage[],
|
|
181
|
+
config: ProviderConfig,
|
|
182
|
+
model: string,
|
|
183
|
+
signal: AbortSignal,
|
|
184
|
+
registry: PluginRegistry,
|
|
185
|
+
): AsyncGenerator<AgentEvent> {
|
|
186
|
+
const mergedConfig = await buildMergedConfig(config, registry);
|
|
187
|
+
const hooks = registry.getHooks();
|
|
188
|
+
|
|
189
|
+
// Wrap the body in try/finally so `afterAgentRun` fires exactly once per
|
|
190
|
+
// `runAgent` call, regardless of how it terminates: normal completion (LLM
|
|
191
|
+
// produced a final response), abort via `signal.aborted`, or generator
|
|
192
|
+
// cancellation by the consumer (Ink unmount, manual `.return()`).
|
|
193
|
+
try {
|
|
194
|
+
const customLoop = registry.getAgentLoop();
|
|
195
|
+
if (customLoop) {
|
|
196
|
+
const filtered = await registry.getFilteredTools();
|
|
197
|
+
yield* customLoop.run(initialMessages, mergedConfig, model, signal, filtered, hooks);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let current = initialMessages;
|
|
202
|
+
|
|
203
|
+
while (!signal.aborted) {
|
|
204
|
+
// Re-evaluate every turn so per-turn agent switches are honoured.
|
|
205
|
+
const tools = await registry.getFilteredTools();
|
|
206
|
+
const { content, reasoning, toolCalls, usage, cachedPromptTokens } = yield* streamTurn(
|
|
207
|
+
current,
|
|
208
|
+
mergedConfig,
|
|
209
|
+
model,
|
|
210
|
+
signal,
|
|
211
|
+
registry,
|
|
212
|
+
tools,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (usage > 0) {
|
|
216
|
+
yield { type: 'usage', totalTokens: usage, cachedTokens: cachedPromptTokens };
|
|
217
|
+
}
|
|
218
|
+
if (signal.aborted) {
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const reasoningField = reasoning || undefined;
|
|
223
|
+
const assistant: ChatMessage = { role: 'assistant', content, reasoning: reasoningField };
|
|
224
|
+
if (toolCalls.length === 0) {
|
|
225
|
+
current = [...current, assistant];
|
|
226
|
+
yield { type: 'messages', messages: current };
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
current = [...current, { ...assistant, toolCalls }];
|
|
231
|
+
yield { type: 'messages', messages: current };
|
|
232
|
+
current = yield* executeToolCalls(toolCalls, current, signal, registry, tools);
|
|
233
|
+
yield { type: 'turn_end' };
|
|
234
|
+
}
|
|
235
|
+
} finally {
|
|
236
|
+
await runAfterAgentRunHooks(hooks, signal.aborted ? 'aborted' : 'complete');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { type Channel, createChannelRegistry } from './channel';
|
|
3
|
+
|
|
4
|
+
function makeChannel(id: string, started: { value: boolean }): Channel {
|
|
5
|
+
return {
|
|
6
|
+
id,
|
|
7
|
+
async start() {
|
|
8
|
+
started.value = true;
|
|
9
|
+
},
|
|
10
|
+
async stop() {
|
|
11
|
+
started.value = false;
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('ChannelRegistry', () => {
|
|
17
|
+
it('registers, lists, gets', () => {
|
|
18
|
+
const r = createChannelRegistry();
|
|
19
|
+
const flag = { value: false };
|
|
20
|
+
r.register(makeChannel('tui', flag));
|
|
21
|
+
expect(r.list().map((c) => c.id)).toEqual(['tui']);
|
|
22
|
+
expect(r.get('tui')?.id).toBe('tui');
|
|
23
|
+
expect(r.get('missing')).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('rejects duplicate id', () => {
|
|
27
|
+
const r = createChannelRegistry();
|
|
28
|
+
r.register(makeChannel('a', { value: false }));
|
|
29
|
+
expect(() => r.register(makeChannel('a', { value: false }))).toThrow();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('startAll / stopAll', async () => {
|
|
33
|
+
const r = createChannelRegistry();
|
|
34
|
+
const f1 = { value: false };
|
|
35
|
+
const f2 = { value: false };
|
|
36
|
+
r.register(makeChannel('a', f1));
|
|
37
|
+
r.register(makeChannel('b', f2));
|
|
38
|
+
await r.startAll();
|
|
39
|
+
expect(f1.value).toBe(true);
|
|
40
|
+
expect(f2.value).toBe(true);
|
|
41
|
+
await r.stopAll();
|
|
42
|
+
expect(f1.value).toBe(false);
|
|
43
|
+
expect(f2.value).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('unregister callback removes', () => {
|
|
47
|
+
const r = createChannelRegistry();
|
|
48
|
+
const off = r.register(makeChannel('a', { value: false }));
|
|
49
|
+
off();
|
|
50
|
+
expect(r.list()).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel — input surface for sessions. A channel converts an external
|
|
3
|
+
* trigger (TUI keystroke, Telegram message, voice transcription, websocket
|
|
4
|
+
* frame) into an `InboundMessage` and forwards it to a `Session`.
|
|
5
|
+
*
|
|
6
|
+
* The abstraction is intentionally tiny: a channel knows how to start, stop,
|
|
7
|
+
* and (optionally) respond. The mu-core host owns lifecycle; channels are
|
|
8
|
+
* registered via `PluginContext.channels?.register(...)`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type InboundKind = 'text' | 'audio';
|
|
12
|
+
export type ResponseMode = 'text' | 'voice';
|
|
13
|
+
|
|
14
|
+
export interface InboundMessage {
|
|
15
|
+
kind: InboundKind;
|
|
16
|
+
channelId: string;
|
|
17
|
+
sessionId: string;
|
|
18
|
+
messageId?: string;
|
|
19
|
+
userId?: string;
|
|
20
|
+
userName?: string;
|
|
21
|
+
text?: string;
|
|
22
|
+
responseMode?: ResponseMode;
|
|
23
|
+
audio?: { url?: string; mimeType?: string; filePath?: string };
|
|
24
|
+
raw?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChannelResponder {
|
|
28
|
+
sendText: (text: string) => Promise<void>;
|
|
29
|
+
sendVoice?: (text: string) => Promise<void>;
|
|
30
|
+
sendAck?: (text: string) => Promise<void>;
|
|
31
|
+
sendError?: (text: string) => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Channel {
|
|
35
|
+
id: string;
|
|
36
|
+
start: () => Promise<void>;
|
|
37
|
+
stop?: () => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ChannelRegistry {
|
|
41
|
+
register: (channel: Channel) => () => void;
|
|
42
|
+
list: () => Channel[];
|
|
43
|
+
get: (id: string) => Channel | undefined;
|
|
44
|
+
startAll: () => Promise<void>;
|
|
45
|
+
stopAll: () => Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createChannelRegistry(): ChannelRegistry {
|
|
49
|
+
const channels = new Map<string, Channel>();
|
|
50
|
+
return {
|
|
51
|
+
register(channel) {
|
|
52
|
+
if (channels.has(channel.id)) {
|
|
53
|
+
throw new Error(`Channel already registered: ${channel.id}`);
|
|
54
|
+
}
|
|
55
|
+
channels.set(channel.id, channel);
|
|
56
|
+
return () => {
|
|
57
|
+
channels.delete(channel.id);
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
list() {
|
|
61
|
+
return Array.from(channels.values());
|
|
62
|
+
},
|
|
63
|
+
get(id) {
|
|
64
|
+
return channels.get(id);
|
|
65
|
+
},
|
|
66
|
+
async startAll() {
|
|
67
|
+
for (const c of channels.values()) {
|
|
68
|
+
await c.start();
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
async stopAll() {
|
|
72
|
+
for (const c of channels.values()) {
|
|
73
|
+
if (c.stop) await c.stop();
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { runBeforeToolExecHook, runTransformUserInputHooks } from './hooks';
|
|
3
|
+
import type { LifecycleHooks } from './plugin';
|
|
4
|
+
|
|
5
|
+
const call = { id: '1', function: { name: 'foo', arguments: '{}' } };
|
|
6
|
+
|
|
7
|
+
describe('runBeforeToolExecHook', () => {
|
|
8
|
+
it('returns the call unchanged when no hook intervenes', async () => {
|
|
9
|
+
const result = await runBeforeToolExecHook([], call);
|
|
10
|
+
expect(result).toEqual(call);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('lets later hooks transform the call', async () => {
|
|
14
|
+
const hooks: LifecycleHooks[] = [
|
|
15
|
+
{
|
|
16
|
+
beforeToolExec: (c) => ({ ...c, function: { ...c.function, name: 'bar' } }),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
beforeToolExec: (c) =>
|
|
20
|
+
'blocked' in c ? c : { ...c, function: { ...c.function, name: c.function.name.toUpperCase() } },
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
const result = await runBeforeToolExecHook(hooks, call);
|
|
24
|
+
expect('blocked' in result).toBe(false);
|
|
25
|
+
if (!('blocked' in result)) {
|
|
26
|
+
expect(result.function.name).toBe('BAR');
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('short-circuits on first block', async () => {
|
|
31
|
+
let secondCalled = false;
|
|
32
|
+
const hooks: LifecycleHooks[] = [
|
|
33
|
+
{ beforeToolExec: () => ({ blocked: true, content: 'denied', error: true }) },
|
|
34
|
+
{
|
|
35
|
+
beforeToolExec: (c) => {
|
|
36
|
+
secondCalled = true;
|
|
37
|
+
return c;
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
const result = await runBeforeToolExecHook(hooks, call);
|
|
42
|
+
expect('blocked' in result && result.content).toBe('denied');
|
|
43
|
+
expect(secondCalled).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('runTransformUserInputHooks', () => {
|
|
48
|
+
it('returns pass when no hook intervenes', async () => {
|
|
49
|
+
expect(await runTransformUserInputHooks([], 'hello')).toEqual({ kind: 'pass' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('threads transformed text through subsequent hooks', async () => {
|
|
53
|
+
const hooks: LifecycleHooks[] = [
|
|
54
|
+
{ transformUserInput: (t) => ({ kind: 'transform', text: `[a]${t}` }) },
|
|
55
|
+
{ transformUserInput: (t) => ({ kind: 'transform', text: `${t}[b]` }) },
|
|
56
|
+
];
|
|
57
|
+
const result = await runTransformUserInputHooks(hooks, 'X');
|
|
58
|
+
expect(result.kind === 'transform' && result.text).toBe('[a]X[b]');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('intercept short-circuits the chain', async () => {
|
|
62
|
+
let secondCalled = false;
|
|
63
|
+
const hooks: LifecycleHooks[] = [
|
|
64
|
+
{ transformUserInput: () => ({ kind: 'intercept' }) },
|
|
65
|
+
{
|
|
66
|
+
transformUserInput: () => {
|
|
67
|
+
secondCalled = true;
|
|
68
|
+
return { kind: 'pass' };
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
const result = await runTransformUserInputHooks(hooks, 'X');
|
|
73
|
+
expect(result.kind).toBe('intercept');
|
|
74
|
+
expect(secondCalled).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|