mu-core 0.8.0 → 0.10.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/package.json +1 -1
- package/src/agent.test.ts +91 -0
- package/src/agent.ts +16 -7
- package/src/hooks.test.ts +29 -0
- package/src/hooks.ts +24 -1
- package/src/index.ts +2 -1
- package/src/plugin.ts +51 -1
- package/src/registry.ts +54 -0
- package/src/session.ts +19 -3
- package/src/types/llm.ts +13 -0
package/package.json
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
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' };
|
|
30
|
+
},
|
|
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();
|
|
67
|
+
});
|
|
68
|
+
|
|
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
|
+
});
|
|
91
|
+
});
|
package/src/agent.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
runAfterToolExecHook,
|
|
5
5
|
runBeforeLlmHooks,
|
|
6
6
|
runBeforeToolExecHook,
|
|
7
|
+
runDecorateMessageHooks,
|
|
7
8
|
} from './hooks';
|
|
8
9
|
import type { AgentEvent, PluginTool, ToolResult, TurnResult } from './plugin';
|
|
9
10
|
import type { PluginRegistry } from './registry';
|
|
@@ -90,9 +91,15 @@ async function* streamTurn(
|
|
|
90
91
|
|
|
91
92
|
const hooks = registry.getHooks();
|
|
92
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);
|
|
93
100
|
const toolDefinitions = toolDefs.map((t) => t.definition);
|
|
94
101
|
|
|
95
|
-
for await (const chunk of streamChatViaRegistry(registry,
|
|
102
|
+
for await (const chunk of streamChatViaRegistry(registry, llmMessages, config, model, {
|
|
96
103
|
signal,
|
|
97
104
|
tools: toolDefinitions,
|
|
98
105
|
onUsage: (u) => {
|
|
@@ -129,24 +136,24 @@ async function executeOneToolCall(
|
|
|
129
136
|
|
|
130
137
|
if ('blocked' in hookOutcome) {
|
|
131
138
|
const content = await runAfterToolExecHook(hooks, tc, hookOutcome.content);
|
|
132
|
-
return {
|
|
139
|
+
return runDecorateMessageHooks(hooks, {
|
|
133
140
|
role: 'tool',
|
|
134
141
|
content,
|
|
135
142
|
toolCallId: tc.id,
|
|
136
143
|
toolResult: { name: tc.function.name, content, error: hookOutcome.error ?? true },
|
|
137
144
|
toolCallArgs: { [tc.function.name]: tc.function.arguments },
|
|
138
|
-
};
|
|
145
|
+
});
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
const result = await executeTool(hookOutcome, tools, signal);
|
|
142
149
|
const content = await runAfterToolExecHook(hooks, hookOutcome, result.content);
|
|
143
|
-
return {
|
|
150
|
+
return runDecorateMessageHooks(hooks, {
|
|
144
151
|
role: 'tool',
|
|
145
152
|
content,
|
|
146
153
|
toolCallId: result.tool_call_id,
|
|
147
154
|
toolResult: { name: result.name, content, error: result.error },
|
|
148
155
|
toolCallArgs: { [result.name]: tc.function.arguments },
|
|
149
|
-
};
|
|
156
|
+
});
|
|
150
157
|
}
|
|
151
158
|
|
|
152
159
|
async function* executeToolCalls(
|
|
@@ -220,14 +227,16 @@ export async function* runAgent(
|
|
|
220
227
|
}
|
|
221
228
|
|
|
222
229
|
const reasoningField = reasoning || undefined;
|
|
223
|
-
const
|
|
230
|
+
const assistantBase: ChatMessage = { role: 'assistant', content, reasoning: reasoningField };
|
|
224
231
|
if (toolCalls.length === 0) {
|
|
232
|
+
const assistant = await runDecorateMessageHooks(registry.getHooks(), assistantBase);
|
|
225
233
|
current = [...current, assistant];
|
|
226
234
|
yield { type: 'messages', messages: current };
|
|
227
235
|
return;
|
|
228
236
|
}
|
|
229
237
|
|
|
230
|
-
|
|
238
|
+
const assistantWithCalls = await runDecorateMessageHooks(registry.getHooks(), { ...assistantBase, toolCalls });
|
|
239
|
+
current = [...current, assistantWithCalls];
|
|
231
240
|
yield { type: 'messages', messages: current };
|
|
232
241
|
current = yield* executeToolCalls(toolCalls, current, signal, registry, tools);
|
|
233
242
|
yield { type: 'turn_end' };
|
package/src/hooks.test.ts
CHANGED
|
@@ -73,4 +73,33 @@ describe('runTransformUserInputHooks', () => {
|
|
|
73
73
|
expect(result.kind).toBe('intercept');
|
|
74
74
|
expect(secondCalled).toBe(false);
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
it('propagates continue to the caller', async () => {
|
|
78
|
+
// Regression: the composer used to silently drop `continue`, falling
|
|
79
|
+
// back to `pass`. That made the host re-push the user message on top
|
|
80
|
+
// of the one a plugin (mu-agents @-mention dispatch) had already
|
|
81
|
+
// appended, producing a duplicate user bubble in the transcript.
|
|
82
|
+
const hooks: LifecycleHooks[] = [{ transformUserInput: () => ({ kind: 'continue' }) }];
|
|
83
|
+
const result = await runTransformUserInputHooks(hooks, 'X');
|
|
84
|
+
expect(result.kind).toBe('continue');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('continue short-circuits the chain', async () => {
|
|
88
|
+
// Once a plugin has appended the user message itself, downstream
|
|
89
|
+
// hooks can't safely transform absent text — same chain-termination
|
|
90
|
+
// semantics as `intercept`.
|
|
91
|
+
let secondCalled = false;
|
|
92
|
+
const hooks: LifecycleHooks[] = [
|
|
93
|
+
{ transformUserInput: () => ({ kind: 'continue' }) },
|
|
94
|
+
{
|
|
95
|
+
transformUserInput: () => {
|
|
96
|
+
secondCalled = true;
|
|
97
|
+
return { kind: 'transform', text: 'should-not-apply' };
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
const result = await runTransformUserInputHooks(hooks, 'X');
|
|
102
|
+
expect(result.kind).toBe('continue');
|
|
103
|
+
expect(secondCalled).toBe(false);
|
|
104
|
+
});
|
|
76
105
|
});
|
package/src/hooks.ts
CHANGED
|
@@ -58,6 +58,22 @@ export async function runAfterToolExecHook(
|
|
|
58
58
|
return current;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Pipe a freshly built `ChatMessage` through every `decorateMessage` hook in
|
|
63
|
+
* order. Each hook may return a (possibly mutated) message; later hooks see
|
|
64
|
+
* the result of the previous one. Used to stamp display hints (agent badge,
|
|
65
|
+
* color) without coupling the host to any specific plugin.
|
|
66
|
+
*/
|
|
67
|
+
export async function runDecorateMessageHooks(hooks: LifecycleHooks[], msg: ChatMessage): Promise<ChatMessage> {
|
|
68
|
+
let current = msg;
|
|
69
|
+
for (const hook of hooks) {
|
|
70
|
+
if (hook.decorateMessage) {
|
|
71
|
+
current = await hook.decorateMessage(current);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return current;
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
export async function runAfterAgentRunHooks(hooks: LifecycleHooks[], reason: AgentEndReason): Promise<void> {
|
|
62
78
|
for (const hook of hooks) {
|
|
63
79
|
if (hook.afterAgentRun) {
|
|
@@ -69,7 +85,11 @@ export async function runAfterAgentRunHooks(hooks: LifecycleHooks[], reason: Age
|
|
|
69
85
|
/**
|
|
70
86
|
* Compose every `transformUserInput` hook. Earlier hooks see the raw text;
|
|
71
87
|
* each subsequent hook sees the (possibly rewritten) text emitted by the
|
|
72
|
-
* previous one. The first `intercept` short-circuits the chain
|
|
88
|
+
* previous one. The first `intercept` or `continue` short-circuits the chain
|
|
89
|
+
* — once a plugin has either suppressed the input or appended the user
|
|
90
|
+
* message itself, downstream hooks can't safely keep transforming absent
|
|
91
|
+
* text, and the host needs to see the terminating signal verbatim so it
|
|
92
|
+
* skips its own user-message push (see `useOnSend`).
|
|
73
93
|
*/
|
|
74
94
|
export async function runTransformUserInputHooks(hooks: LifecycleHooks[], text: string): Promise<UserInputTransform> {
|
|
75
95
|
let current: UserInputTransform = { kind: 'pass' };
|
|
@@ -80,6 +100,9 @@ export async function runTransformUserInputHooks(hooks: LifecycleHooks[], text:
|
|
|
80
100
|
if (next.kind === 'intercept') {
|
|
81
101
|
return next;
|
|
82
102
|
}
|
|
103
|
+
if (next.kind === 'continue') {
|
|
104
|
+
return next;
|
|
105
|
+
}
|
|
83
106
|
if (next.kind === 'transform') {
|
|
84
107
|
working = next.text;
|
|
85
108
|
current = next;
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { createActivityBus } from './activity';
|
|
|
3
3
|
export { runAgent } from './agent';
|
|
4
4
|
export type { Channel, ChannelRegistry, ChannelResponder, InboundKind, InboundMessage, ResponseMode } from './channel';
|
|
5
5
|
export { createChannelRegistry } from './channel';
|
|
6
|
-
export { runTransformUserInputHooks } from './hooks';
|
|
6
|
+
export { runDecorateMessageHooks, runTransformUserInputHooks } from './hooks';
|
|
7
7
|
export type { MuConfigShape, MuHandle, StartMuOptions } from './host/index';
|
|
8
8
|
export { startMu } from './host/index';
|
|
9
9
|
export type {
|
|
@@ -13,6 +13,7 @@ export type {
|
|
|
13
13
|
AgentSourceRegistry,
|
|
14
14
|
BeforeToolExecResult,
|
|
15
15
|
CommandContext,
|
|
16
|
+
InputInfoSegment,
|
|
16
17
|
LifecycleHooks,
|
|
17
18
|
MentionCompletion,
|
|
18
19
|
MentionProvider,
|
package/src/plugin.ts
CHANGED
|
@@ -59,6 +59,12 @@ export interface MentionCompletion {
|
|
|
59
59
|
label?: string;
|
|
60
60
|
/** Secondary text shown dimly in the picker. */
|
|
61
61
|
description?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Optional grouping label rendered as a section header in the picker
|
|
64
|
+
* (e.g. "agents", "files"). When unset, completions are rendered without
|
|
65
|
+
* a group header. The host hides the header when only one group is shown.
|
|
66
|
+
*/
|
|
67
|
+
category?: string;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
export type MentionProvider = (partial: string) => MentionCompletion[] | Promise<MentionCompletion[]>;
|
|
@@ -96,6 +102,12 @@ export interface PluginRegistryView {
|
|
|
96
102
|
getHooks: () => LifecycleHooks[];
|
|
97
103
|
getSystemPrompts: () => Promise<string[]>;
|
|
98
104
|
applySystemPromptTransforms: (prompt: string) => Promise<string>;
|
|
105
|
+
/**
|
|
106
|
+
* Provider registry handle (or `undefined` if the host didn't supply one).
|
|
107
|
+
* Exposed so plugins that re-issue LLM calls (e.g. the mu-agents subagent
|
|
108
|
+
* loop) can resolve the configured provider exactly like `runAgent` does.
|
|
109
|
+
*/
|
|
110
|
+
getProviders: () => ProviderRegistry | undefined;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
export interface PluginContext extends PluginExtras {
|
|
@@ -119,6 +131,13 @@ export interface PluginContext extends PluginExtras {
|
|
|
119
131
|
* polling-based `Plugin.statusLine()` getter. Pass `[]` to clear.
|
|
120
132
|
*/
|
|
121
133
|
setStatusLine?: (segments: StatusSegment[]) => void;
|
|
134
|
+
/**
|
|
135
|
+
* Push info chips into the host's input footer (e.g. "Coding" agent
|
|
136
|
+
* label). Replaces the segments previously pushed by *this* plugin. Pass
|
|
137
|
+
* `[]` to clear. Hosts that don't render an input footer are free to
|
|
138
|
+
* ignore the call.
|
|
139
|
+
*/
|
|
140
|
+
setInputInfo?: (segments: InputInfoSegment[]) => void;
|
|
122
141
|
/**
|
|
123
142
|
* Host-provided graceful shutdown hook. When supplied, plugins should prefer
|
|
124
143
|
* this over `process.exit(...)` so the host can deactivate plugins and restore
|
|
@@ -242,8 +261,18 @@ export type BeforeToolExecResult = ToolCall | ToolBlock;
|
|
|
242
261
|
* - `intercept` — suppress the input entirely; the host should not call the
|
|
243
262
|
* LLM. Plugins typically pair this with `MessageBus.append` to surface a
|
|
244
263
|
* reply or status entry.
|
|
264
|
+
* - `continue` — the hook has appended the user message itself (e.g. via
|
|
265
|
+
* `MessageBus.append` so it shows up live in the transcript). The host
|
|
266
|
+
* should NOT push another user message but should still run a turn:
|
|
267
|
+
* drain the injectNext queue and stream the LLM. Used by the subagent
|
|
268
|
+
* `@`-mention dispatch path so the user's message lands first, the
|
|
269
|
+
* subagent runs live, and the parent agent then takes a real turn.
|
|
245
270
|
*/
|
|
246
|
-
export type UserInputTransform =
|
|
271
|
+
export type UserInputTransform =
|
|
272
|
+
| { kind: 'pass' }
|
|
273
|
+
| { kind: 'transform'; text: string }
|
|
274
|
+
| { kind: 'intercept' }
|
|
275
|
+
| { kind: 'continue' };
|
|
247
276
|
|
|
248
277
|
export interface LifecycleHooks {
|
|
249
278
|
beforeLlmCall?: (messages: ChatMessage[], config: ProviderConfig) => ChatMessage[] | Promise<ChatMessage[]>;
|
|
@@ -276,6 +305,14 @@ export interface LifecycleHooks {
|
|
|
276
305
|
* cleanup; per-turn cleanup belongs in `afterLlmCall`.
|
|
277
306
|
*/
|
|
278
307
|
afterAgentRun?: (reason: AgentEndReason) => void | Promise<void>;
|
|
308
|
+
/**
|
|
309
|
+
* Decorate a freshly built `ChatMessage` (user / assistant / tool) before
|
|
310
|
+
* it's appended to the transcript. Plugins typically use this to stamp
|
|
311
|
+
* `display.badge` / `display.color` (e.g. with the active agent name +
|
|
312
|
+
* color) or augment `meta`. Hooks compose left-to-right; later hooks see
|
|
313
|
+
* the prior hook's output. Should not change `role` or `content`.
|
|
314
|
+
*/
|
|
315
|
+
decorateMessage?: (msg: ChatMessage) => ChatMessage | Promise<ChatMessage>;
|
|
279
316
|
}
|
|
280
317
|
|
|
281
318
|
export interface CommandContext {
|
|
@@ -315,6 +352,19 @@ export interface StatusSegment {
|
|
|
315
352
|
dim?: boolean;
|
|
316
353
|
}
|
|
317
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Info chip a plugin pushes into the input footer (e.g. active agent name).
|
|
357
|
+
* Aggregated across plugins by `PluginRegistry.getInputInfoSegments()` and
|
|
358
|
+
* surfaced to the host's input UI in registration order.
|
|
359
|
+
*/
|
|
360
|
+
export interface InputInfoSegment {
|
|
361
|
+
/** Stable key — used by the renderer for list reconciliation. */
|
|
362
|
+
key: string;
|
|
363
|
+
text: string;
|
|
364
|
+
color?: string;
|
|
365
|
+
bold?: boolean;
|
|
366
|
+
}
|
|
367
|
+
|
|
318
368
|
export interface Plugin {
|
|
319
369
|
name: string;
|
|
320
370
|
version?: string;
|
package/src/registry.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ChannelRegistry } from './channel';
|
|
|
3
3
|
import type {
|
|
4
4
|
AgentLoopStrategy,
|
|
5
5
|
AgentSourceRegistry,
|
|
6
|
+
InputInfoSegment,
|
|
6
7
|
LifecycleHooks,
|
|
7
8
|
MentionProvider,
|
|
8
9
|
MessageBus,
|
|
@@ -73,6 +74,8 @@ export class PluginRegistry {
|
|
|
73
74
|
private context: PluginContext;
|
|
74
75
|
private statusSegmentsByPlugin: Map<string, StatusSegment[]> = new Map();
|
|
75
76
|
private statusListeners: Set<StatusListener> = new Set();
|
|
77
|
+
private inputInfoByPlugin: Map<string, InputInfoSegment[]> = new Map();
|
|
78
|
+
private inputInfoListeners: Set<StatusListener> = new Set();
|
|
76
79
|
private renderers: RendererEntry[] = [];
|
|
77
80
|
private shortcuts: ShortcutEntry[] = [];
|
|
78
81
|
private mentions: MentionEntry[] = [];
|
|
@@ -120,6 +123,9 @@ export class PluginRegistry {
|
|
|
120
123
|
if (this.statusSegmentsByPlugin.delete(name)) {
|
|
121
124
|
this.emitStatus();
|
|
122
125
|
}
|
|
126
|
+
if (this.inputInfoByPlugin.delete(name)) {
|
|
127
|
+
this.emitInputInfo();
|
|
128
|
+
}
|
|
123
129
|
this.dropPluginRegistrations(name);
|
|
124
130
|
}
|
|
125
131
|
|
|
@@ -246,6 +252,24 @@ export class PluginRegistry {
|
|
|
246
252
|
};
|
|
247
253
|
}
|
|
248
254
|
|
|
255
|
+
/** Aggregate of every plugin's most recently pushed input-info segments, in registration order. */
|
|
256
|
+
getInputInfoSegments(): InputInfoSegment[] {
|
|
257
|
+
const segments: InputInfoSegment[] = [];
|
|
258
|
+
for (const plugin of this.plugins.values()) {
|
|
259
|
+
const pluginSegments = this.inputInfoByPlugin.get(plugin.name);
|
|
260
|
+
if (pluginSegments?.length) segments.push(...pluginSegments);
|
|
261
|
+
}
|
|
262
|
+
return segments;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Subscribe to input-info segment changes. Returns an unsubscribe fn. */
|
|
266
|
+
onInputInfoChange(listener: StatusListener): () => void {
|
|
267
|
+
this.inputInfoListeners.add(listener);
|
|
268
|
+
return () => {
|
|
269
|
+
this.inputInfoListeners.delete(listener);
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
249
273
|
getAgentLoop(): AgentLoopStrategy | undefined {
|
|
250
274
|
for (const plugin of this.plugins.values()) {
|
|
251
275
|
if (plugin.agentLoop) {
|
|
@@ -319,8 +343,10 @@ export class PluginRegistry {
|
|
|
319
343
|
getHooks: () => this.getHooks(),
|
|
320
344
|
getSystemPrompts: () => this.getSystemPrompts(),
|
|
321
345
|
applySystemPromptTransforms: (prompt) => this.applySystemPromptTransforms(prompt),
|
|
346
|
+
getProviders: () => this.getProviders(),
|
|
322
347
|
},
|
|
323
348
|
setStatusLine: (segments) => this.setStatusLine(pluginName, segments),
|
|
349
|
+
setInputInfo: (segments) => this.setInputInfo(pluginName, segments),
|
|
324
350
|
registerMessageRenderer: (customType, renderer) => this.addRenderer(pluginName, customType, renderer),
|
|
325
351
|
registerShortcut: (key, handler) => this.addShortcut(pluginName, key, handler),
|
|
326
352
|
registerMentionProvider: (trigger, provider) => this.addMention(pluginName, trigger, provider),
|
|
@@ -349,12 +375,30 @@ export class PluginRegistry {
|
|
|
349
375
|
this.emitStatus();
|
|
350
376
|
}
|
|
351
377
|
|
|
378
|
+
private setInputInfo(pluginName: string, segments: InputInfoSegment[]): void {
|
|
379
|
+
if (segments.length === 0) {
|
|
380
|
+
const removed = this.inputInfoByPlugin.delete(pluginName);
|
|
381
|
+
if (removed) this.emitInputInfo();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const prev = this.inputInfoByPlugin.get(pluginName);
|
|
385
|
+
if (prev && inputInfoEqual(prev, segments)) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.inputInfoByPlugin.set(pluginName, segments);
|
|
389
|
+
this.emitInputInfo();
|
|
390
|
+
}
|
|
391
|
+
|
|
352
392
|
private emitStatus(): void {
|
|
353
393
|
for (const listener of this.statusListeners) {
|
|
354
394
|
listener();
|
|
355
395
|
}
|
|
356
396
|
}
|
|
357
397
|
|
|
398
|
+
private emitInputInfo(): void {
|
|
399
|
+
for (const listener of this.inputInfoListeners) listener();
|
|
400
|
+
}
|
|
401
|
+
|
|
358
402
|
private addRenderer(plugin: string, customType: string, renderer: MessageRenderer): () => void {
|
|
359
403
|
const entry: RendererEntry = { plugin, customType, renderer };
|
|
360
404
|
this.renderers.push(entry);
|
|
@@ -428,3 +472,13 @@ function segmentsEqual(a: StatusSegment[], b: StatusSegment[]): boolean {
|
|
|
428
472
|
}
|
|
429
473
|
return true;
|
|
430
474
|
}
|
|
475
|
+
|
|
476
|
+
function inputInfoEqual(a: InputInfoSegment[], b: InputInfoSegment[]): boolean {
|
|
477
|
+
if (a.length !== b.length) return false;
|
|
478
|
+
for (let i = 0; i < a.length; i++) {
|
|
479
|
+
if (a[i].key !== b[i].key || a[i].text !== b[i].text || a[i].color !== b[i].color || a[i].bold !== b[i].bold) {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return true;
|
|
484
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -22,8 +22,15 @@ export type SessionEvent =
|
|
|
22
22
|
| { type: 'error'; message: string };
|
|
23
23
|
|
|
24
24
|
export interface RunTurnOptions {
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Pre-built user message to append before running the agent loop.
|
|
27
|
+
* Optional: when a plugin's `transformUserInput` returns `'continue'`
|
|
28
|
+
* the hook has already appended its own user message via
|
|
29
|
+
* `MessageBus.append`, and the host calls `runTurn` without a
|
|
30
|
+
* `userMessage` to drain the injectNext queue and stream the LLM
|
|
31
|
+
* without pushing a duplicate.
|
|
32
|
+
*/
|
|
33
|
+
userMessage?: ChatMessage;
|
|
27
34
|
/** Override config for this single turn (e.g. fresh model id). */
|
|
28
35
|
config?: ProviderConfig;
|
|
29
36
|
/** Override model for this single turn. */
|
|
@@ -159,8 +166,14 @@ class SessionImpl implements Session {
|
|
|
159
166
|
} else if (e.type === 'usage') {
|
|
160
167
|
this.emit({ type: 'usage', totalTokens: e.totalTokens, cachedTokens: e.cachedTokens ?? 0 });
|
|
161
168
|
} else if (e.type === 'turn_end') {
|
|
169
|
+
// Clear the locally-tracked partial buffers AND notify subscribers,
|
|
170
|
+
// otherwise the host's `stream` state still holds the previous step's
|
|
171
|
+
// reasoning/content between agent loop iterations — visible as a
|
|
172
|
+
// stale "thinking…" block lingering after a tool call until the next
|
|
173
|
+
// step's first `content`/`reasoning` chunk overwrites it.
|
|
162
174
|
partialText = '';
|
|
163
175
|
partialReasoning = '';
|
|
176
|
+
this.emit({ type: 'stream_partial', text: '', reasoning: '' });
|
|
164
177
|
}
|
|
165
178
|
}
|
|
166
179
|
return final;
|
|
@@ -175,7 +188,10 @@ class SessionImpl implements Session {
|
|
|
175
188
|
throw new Error(`Session "${this.id}" already running a turn. Call abort() first or wait for completion.`);
|
|
176
189
|
}
|
|
177
190
|
if (options.baseMessages) this.messages = options.baseMessages.slice();
|
|
178
|
-
|
|
191
|
+
// Skip the push when the caller didn't supply a userMessage — that
|
|
192
|
+
// happens when a `transformUserInput` hook returned `'continue'` and
|
|
193
|
+
// already appended the user's message itself (see `UserInputTransform`).
|
|
194
|
+
if (options.userMessage) this.messages.push(options.userMessage);
|
|
179
195
|
if (this.queue.length) {
|
|
180
196
|
this.messages.push(...this.queue);
|
|
181
197
|
this.queue = [];
|
package/src/types/llm.ts
CHANGED
|
@@ -62,12 +62,17 @@ export interface ToolResultInfo {
|
|
|
62
62
|
* - `badge` is shown in a small box before the body (e.g. agent name)
|
|
63
63
|
* - `hidden` keeps the message in the transcript (sent to the LLM) but skips
|
|
64
64
|
* its on-screen rendering — useful for system reminders.
|
|
65
|
+
* - `llmHidden` is the inverse: keep the message in the on-screen transcript
|
|
66
|
+
* but strip it from the LLM payload right before the network call. Useful
|
|
67
|
+
* for UI-only markers (subagent dispatch headers, status pings) that
|
|
68
|
+
* plugins want users to see but the model shouldn't read as conversation.
|
|
65
69
|
*/
|
|
66
70
|
export interface MessageDisplay {
|
|
67
71
|
color?: string;
|
|
68
72
|
prefix?: string;
|
|
69
73
|
badge?: string;
|
|
70
74
|
hidden?: boolean;
|
|
75
|
+
llmHidden?: boolean;
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
export interface ChatMessage {
|
|
@@ -104,4 +109,12 @@ export interface StreamOptions {
|
|
|
104
109
|
|
|
105
110
|
export interface ApiModel {
|
|
106
111
|
id: string;
|
|
112
|
+
/**
|
|
113
|
+
* Maximum input + output token window the provider advertises for this
|
|
114
|
+
* model. OpenAI itself doesn't expose this on `/models`; compat servers
|
|
115
|
+
* (llama.cpp, LM Studio, vLLM, Ollama's openai shim, ...) often do under
|
|
116
|
+
* names like `context_length`, `max_context_length`, or
|
|
117
|
+
* `max_position_embeddings`. Omitted when the provider doesn't report it.
|
|
118
|
+
*/
|
|
119
|
+
contextLimit?: number;
|
|
107
120
|
}
|