@neutrome/lilsdk 0.6.1 → 0.6.3
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 +4 -2
- package/etc/api/managed.api.md +113 -102
- package/package.json +1 -1
- package/src/managed/collapsed-reasoner.ts +237 -0
- package/src/managed/index.ts +2 -0
- package/test/collapsed-reasoner.test.ts +190 -0
package/README.md
CHANGED
|
@@ -18,8 +18,10 @@ small — SDK contracts only — and each subpath owns one concern:
|
|
|
18
18
|
`OutputSink`, and `createExecutionEvent`
|
|
19
19
|
- `@neutrome/lilsdk/loops`: `retry`, `fallback`, and `createGoalExecutor`
|
|
20
20
|
- `@neutrome/lilsdk/tools`: `createToolsExecutor` tool-call loop
|
|
21
|
-
- `@neutrome/lilsdk/managed`: `createCapabilitiesExecutor
|
|
22
|
-
`createAttachmentToTextExecutor
|
|
21
|
+
- `@neutrome/lilsdk/managed`: `createCapabilitiesExecutor`,
|
|
22
|
+
`createAttachmentToTextExecutor`, and
|
|
23
|
+
`createCollapsedReasoner(reasoner, summarizer, options)` for safe reasoning
|
|
24
|
+
updates
|
|
23
25
|
- `@neutrome/lilsdk/stream`: `observeExecutionStream`, `streamStage`, and
|
|
24
26
|
`writeThinking`
|
|
25
27
|
- `@neutrome/lilsdk/primitives`: structural program helpers — `walk`, `map`,
|
package/etc/api/managed.api.md
CHANGED
|
@@ -1,102 +1,113 @@
|
|
|
1
|
-
## API Report File for "neutrome"
|
|
2
|
-
|
|
3
|
-
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
|
4
|
-
|
|
5
|
-
```ts
|
|
6
|
-
|
|
7
|
-
import type { Program } from '@neutrome/lil-engine';
|
|
8
|
-
import { ProgramAttachment } from '@neutrome/lil-engine';
|
|
9
|
-
|
|
10
|
-
// @public
|
|
11
|
-
export type AttachmentReaderRule = {
|
|
12
|
-
mimeTypes: readonly string[];
|
|
13
|
-
executor: ExecutorInput;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
// @public
|
|
17
|
-
export type AttachmentToTextOptions = {
|
|
18
|
-
systemPrompt?: string;
|
|
19
|
-
cacheKey?: (attachment: ProgramAttachment, ctx: ExecutorContext) => string;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
// @public
|
|
23
|
-
type Cache_2 = {
|
|
24
|
-
get(key: string): Promise<string | null>;
|
|
25
|
-
put(key: string, value: string, options?: CachePutOptions): Promise<void>;
|
|
26
|
-
delete(key: string): Promise<void>;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
// @public
|
|
30
|
-
type CachePutOptions = {
|
|
31
|
-
expiration?: number;
|
|
32
|
-
expirationTtl?: number;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
// @public
|
|
36
|
-
export type CapabilitiesExecutorOptions = {
|
|
37
|
-
enabledIterations?: number;
|
|
38
|
-
cacheKey?: (ctx: ExecutorContext) => string;
|
|
39
|
-
skipPrefixes?: readonly string[];
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
1
|
+
## API Report File for "neutrome"
|
|
2
|
+
|
|
3
|
+
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
|
|
7
|
+
import type { Program } from '@neutrome/lil-engine';
|
|
8
|
+
import { ProgramAttachment } from '@neutrome/lil-engine';
|
|
9
|
+
|
|
10
|
+
// @public
|
|
11
|
+
export type AttachmentReaderRule = {
|
|
12
|
+
mimeTypes: readonly string[];
|
|
13
|
+
executor: ExecutorInput;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// @public
|
|
17
|
+
export type AttachmentToTextOptions = {
|
|
18
|
+
systemPrompt?: string;
|
|
19
|
+
cacheKey?: (attachment: ProgramAttachment, ctx: ExecutorContext) => string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// @public
|
|
23
|
+
type Cache_2 = {
|
|
24
|
+
get(key: string): Promise<string | null>;
|
|
25
|
+
put(key: string, value: string, options?: CachePutOptions): Promise<void>;
|
|
26
|
+
delete(key: string): Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// @public
|
|
30
|
+
type CachePutOptions = {
|
|
31
|
+
expiration?: number;
|
|
32
|
+
expirationTtl?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// @public
|
|
36
|
+
export type CapabilitiesExecutorOptions = {
|
|
37
|
+
enabledIterations?: number;
|
|
38
|
+
cacheKey?: (ctx: ExecutorContext) => string;
|
|
39
|
+
skipPrefixes?: readonly string[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// @public
|
|
43
|
+
export type CollapsedReasonerOptions = {
|
|
44
|
+
prompt?: string;
|
|
45
|
+
forcedPrompt?: string;
|
|
46
|
+
interval?: number;
|
|
47
|
+
forcedInterval?: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Warning: (ae-forgotten-export) The symbol "Executor" needs to be exported by the entry point index.d.ts
|
|
51
|
+
//
|
|
52
|
+
// @public
|
|
53
|
+
export function createAttachmentToTextExecutor(inner: ExecutorInput, rules: readonly AttachmentReaderRule[], options?: AttachmentToTextOptions): Executor;
|
|
54
|
+
|
|
55
|
+
// @public
|
|
56
|
+
export function createCapabilitiesExecutor(inner: ExecutorInput, options?: CapabilitiesExecutorOptions): Executor;
|
|
57
|
+
|
|
58
|
+
// @public
|
|
59
|
+
export function createCollapsedReasoner(reasoner: ExecutorInput, summarizer: ExecutorInput, options?: CollapsedReasonerOptions): Executor;
|
|
60
|
+
|
|
61
|
+
// @public
|
|
62
|
+
type ExecutionEvent = {
|
|
63
|
+
kind: string;
|
|
64
|
+
requestId: string;
|
|
65
|
+
executionId: string;
|
|
66
|
+
timestamp: string;
|
|
67
|
+
parentExecutionId?: string;
|
|
68
|
+
target?: {
|
|
69
|
+
kind: "provider" | "executor";
|
|
70
|
+
id: string;
|
|
71
|
+
};
|
|
72
|
+
errorKind?: string;
|
|
73
|
+
data?: Record<string, unknown>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// @public
|
|
77
|
+
type Executor = {
|
|
78
|
+
execute(request: Program, ctx: ExecutorContext): Promise<Program>;
|
|
79
|
+
stream(request: Program, ctx: ExecutorContext): AsyncIterable<Program>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Warning: (ae-forgotten-export) The symbol "RuntimeContext" needs to be exported by the entry point index.d.ts
|
|
83
|
+
//
|
|
84
|
+
// @public
|
|
85
|
+
type ExecutorContext = RuntimeContext & {
|
|
86
|
+
requestId: string;
|
|
87
|
+
executionId: string;
|
|
88
|
+
parentExecutionId?: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// @public
|
|
92
|
+
type ExecutorInput = string | Executor;
|
|
93
|
+
|
|
94
|
+
// @public
|
|
95
|
+
type RuntimeContext = {
|
|
96
|
+
cache: Cache_2;
|
|
97
|
+
invoke(executor: ExecutorInput, request: Program): Promise<Program>;
|
|
98
|
+
invokeStream(executor: ExecutorInput, request: Program): AsyncIterable<Program>;
|
|
99
|
+
observe(event: ExecutionEvent): void;
|
|
100
|
+
signal: AbortSignal;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Warnings were encountered during analysis:
|
|
104
|
+
//
|
|
105
|
+
// .reference-build/types/neutrome-lilsdk/src/managed/attachment-to-text.d.ts:8:5 - (ae-forgotten-export) The symbol "ExecutorInput" needs to be exported by the entry point index.d.ts
|
|
106
|
+
// .reference-build/types/neutrome-lilsdk/src/managed/attachment-to-text.d.ts:15:5 - (ae-forgotten-export) The symbol "ExecutorContext" needs to be exported by the entry point index.d.ts
|
|
107
|
+
// .reference-build/types/neutrome-lilsdk/src/types.d.ts:42:5 - (ae-forgotten-export) The symbol "CachePutOptions" needs to be exported by the entry point index.d.ts
|
|
108
|
+
// .reference-build/types/neutrome-lilsdk/src/types.d.ts:55:5 - (ae-forgotten-export) The symbol "Cache_2" needs to be exported by the entry point index.d.ts
|
|
109
|
+
// .reference-build/types/neutrome-lilsdk/src/types.d.ts:77:5 - (ae-forgotten-export) The symbol "ExecutionEvent" needs to be exported by the entry point index.d.ts
|
|
110
|
+
|
|
111
|
+
// (No @packageDocumentation comment for this package)
|
|
112
|
+
|
|
113
|
+
```
|
package/package.json
CHANGED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendInstructions,
|
|
3
|
+
appendUserMessage,
|
|
4
|
+
createProgram,
|
|
5
|
+
extractContentText,
|
|
6
|
+
extractThinkingText,
|
|
7
|
+
findThinkingBlocks,
|
|
8
|
+
Opcode,
|
|
9
|
+
prependSystemPrompt,
|
|
10
|
+
removeInstructions,
|
|
11
|
+
type Program,
|
|
12
|
+
} from "@neutrome/lil-engine";
|
|
13
|
+
import type { Executor, ExecutorContext, ExecutorInput } from "../types.ts";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_INTERVAL = 2_000;
|
|
16
|
+
const DEFAULT_FORCED_INTERVAL = 8_000;
|
|
17
|
+
|
|
18
|
+
const DEFAULT_PROMPT =
|
|
19
|
+
"Summarize the reasoning below as a concise user-facing progress update. Return an empty response when there is no useful update.";
|
|
20
|
+
const DEFAULT_FORCED_PROMPT =
|
|
21
|
+
"Give a concise user-facing progress update based only on the reasoning below. Return an empty response when no update is useful.";
|
|
22
|
+
|
|
23
|
+
/** Options accepted by {@link createCollapsedReasoner}. */
|
|
24
|
+
export type CollapsedReasonerOptions = {
|
|
25
|
+
/** Prompt used for periodic progress updates. */
|
|
26
|
+
prompt?: string;
|
|
27
|
+
/** Prompt used when an update must be attempted. */
|
|
28
|
+
forcedPrompt?: string;
|
|
29
|
+
/** Milliseconds between ordinary progress updates. Defaults to 2 seconds. */
|
|
30
|
+
interval?: number;
|
|
31
|
+
/** Milliseconds between forced updates. Defaults to 8 seconds. */
|
|
32
|
+
forcedInterval?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Hides a reasoner's raw chain of thought and replaces it with concise,
|
|
37
|
+
* user-facing progress updates generated by a separate summarizer.
|
|
38
|
+
*
|
|
39
|
+
* @param reasoner - Executor or model id that produces the original response.
|
|
40
|
+
* @param summarizer - Executor or model id that summarizes unexposed reasoning.
|
|
41
|
+
* @param options - Prompts and wall-clock update cadence.
|
|
42
|
+
*/
|
|
43
|
+
export function createCollapsedReasoner(
|
|
44
|
+
reasoner: ExecutorInput,
|
|
45
|
+
summarizer: ExecutorInput,
|
|
46
|
+
options: CollapsedReasonerOptions = {},
|
|
47
|
+
): Executor {
|
|
48
|
+
const settings = parseOptions(options);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
async execute(request, ctx) {
|
|
52
|
+
const response = await ctx.invoke(reasoner, request);
|
|
53
|
+
const reasoning = extractThinkingText(response);
|
|
54
|
+
const concealed = withoutThinking(response);
|
|
55
|
+
if (!reasoning) return concealed;
|
|
56
|
+
|
|
57
|
+
const summary = await summarize(ctx, summarizer, settings.forcedPrompt, reasoning);
|
|
58
|
+
return summary ? appendAssistantThinking(concealed, summary) : concealed;
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async *stream(request, ctx) {
|
|
62
|
+
const source = ctx.invokeStream(reasoner, request)[Symbol.asyncIterator]();
|
|
63
|
+
let raw = "";
|
|
64
|
+
let startedAt: number | undefined;
|
|
65
|
+
let normalAt = 0;
|
|
66
|
+
let forcedAt = 0;
|
|
67
|
+
let summaries: Promise<void> = Promise.resolve();
|
|
68
|
+
const ready: string[] = [];
|
|
69
|
+
let summaryError: unknown;
|
|
70
|
+
let wake: (() => void) | undefined;
|
|
71
|
+
let wakePromise = nextWake();
|
|
72
|
+
const terminal: Program[] = [];
|
|
73
|
+
|
|
74
|
+
const notify = () => {
|
|
75
|
+
wake?.();
|
|
76
|
+
wakePromise = nextWake();
|
|
77
|
+
};
|
|
78
|
+
const enqueue = (prompt: string, force: boolean) => {
|
|
79
|
+
if (!raw) return;
|
|
80
|
+
const interval = raw;
|
|
81
|
+
raw = "";
|
|
82
|
+
summaries = summaries.then(async () => {
|
|
83
|
+
const summary = await summarize(ctx, summarizer, prompt, interval);
|
|
84
|
+
if (summary) ready.push(summary);
|
|
85
|
+
});
|
|
86
|
+
void summaries.then(notify, (error: unknown) => {
|
|
87
|
+
summaryError = error;
|
|
88
|
+
notify();
|
|
89
|
+
});
|
|
90
|
+
if (force) normalAt = Date.now();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
let next = source.next();
|
|
94
|
+
try {
|
|
95
|
+
for (;;) {
|
|
96
|
+
if (summaryError) throw summaryError;
|
|
97
|
+
while (ready.length > 0) yield thinkingChunk(ready.shift()!);
|
|
98
|
+
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
if (startedAt !== undefined) {
|
|
101
|
+
if (now >= forcedAt) {
|
|
102
|
+
enqueue(settings.forcedPrompt, true);
|
|
103
|
+
forcedAt = now + settings.forcedInterval;
|
|
104
|
+
normalAt = now + settings.interval;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (now >= normalAt) {
|
|
108
|
+
enqueue(settings.prompt, false);
|
|
109
|
+
normalAt = now + settings.interval;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const due = startedAt === undefined
|
|
115
|
+
? undefined
|
|
116
|
+
: Math.max(0, Math.min(normalAt, forcedAt) - Date.now());
|
|
117
|
+
const event = await Promise.race([
|
|
118
|
+
next.then((result) => ({ type: "source" as const, result })),
|
|
119
|
+
wakePromise.then(() => ({ type: "summary" as const })),
|
|
120
|
+
...(due === undefined ? [] : [delay(due).then(() => ({ type: "timer" as const }))]),
|
|
121
|
+
]);
|
|
122
|
+
if (event.type !== "source") continue;
|
|
123
|
+
next = source.next();
|
|
124
|
+
if (event.result.done) break;
|
|
125
|
+
|
|
126
|
+
const visible = splitStreamChunk(event.result.value, terminal, (text) => {
|
|
127
|
+
raw += text;
|
|
128
|
+
if (startedAt === undefined) {
|
|
129
|
+
const timestamp = Date.now();
|
|
130
|
+
startedAt = timestamp;
|
|
131
|
+
normalAt = timestamp + settings.interval;
|
|
132
|
+
forcedAt = timestamp + settings.forcedInterval;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
if (visible.code.length > 0) yield visible;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
enqueue(settings.forcedPrompt, true);
|
|
139
|
+
await summaries;
|
|
140
|
+
if (summaryError) throw summaryError;
|
|
141
|
+
while (ready.length > 0) yield thinkingChunk(ready.shift()!);
|
|
142
|
+
yield* terminal;
|
|
143
|
+
} finally {
|
|
144
|
+
await source.return?.();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function nextWake(): Promise<void> {
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
wake = resolve;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseOptions(options: CollapsedReasonerOptions) {
|
|
157
|
+
const interval = positiveDuration(options.interval ?? DEFAULT_INTERVAL, "interval");
|
|
158
|
+
const forcedInterval = positiveDuration(
|
|
159
|
+
options.forcedInterval ?? DEFAULT_FORCED_INTERVAL,
|
|
160
|
+
"forcedInterval",
|
|
161
|
+
);
|
|
162
|
+
if (forcedInterval < interval) {
|
|
163
|
+
throw new RangeError("forcedInterval must be greater than or equal to interval");
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
interval,
|
|
167
|
+
forcedInterval,
|
|
168
|
+
prompt: options.prompt ?? DEFAULT_PROMPT,
|
|
169
|
+
forcedPrompt: options.forcedPrompt ?? DEFAULT_FORCED_PROMPT,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function positiveDuration(value: number, name: string): number {
|
|
174
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
175
|
+
throw new RangeError(`${name} must be a positive duration in milliseconds`);
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function summarize(
|
|
181
|
+
ctx: ExecutorContext,
|
|
182
|
+
summarizer: ExecutorInput,
|
|
183
|
+
prompt: string,
|
|
184
|
+
reasoning: string,
|
|
185
|
+
): Promise<string> {
|
|
186
|
+
const request = prependSystemPrompt(appendUserMessage(createProgram(), reasoning), prompt);
|
|
187
|
+
return extractContentText(await ctx.invoke(summarizer, request)).trim();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function withoutThinking(program: Program): Program {
|
|
191
|
+
const indices = findThinkingBlocks(program).flatMap((block) =>
|
|
192
|
+
Array.from({ length: block.end - block.start + 1 }, (_, offset) => block.start + offset),
|
|
193
|
+
);
|
|
194
|
+
return removeInstructions(program, indices);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function appendAssistantThinking(program: Program, text: string): Program {
|
|
198
|
+
return appendInstructions(program, [
|
|
199
|
+
{ opcode: Opcode.MSG_START, value: { kind: "none" } },
|
|
200
|
+
{ opcode: Opcode.ROLE_AST, value: { kind: "none" } },
|
|
201
|
+
{ opcode: Opcode.THINK_START, value: { kind: "none" } },
|
|
202
|
+
{ opcode: Opcode.THINK_CHUNK, value: { kind: "string", value: text } },
|
|
203
|
+
{ opcode: Opcode.THINK_END, value: { kind: "none" } },
|
|
204
|
+
{ opcode: Opcode.MSG_END, value: { kind: "none" } },
|
|
205
|
+
]);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function splitStreamChunk(
|
|
209
|
+
chunk: Program,
|
|
210
|
+
terminal: Program[],
|
|
211
|
+
onThinking: (text: string) => void,
|
|
212
|
+
): Program {
|
|
213
|
+
const visible = [] as Program["code"];
|
|
214
|
+
const ending = [] as Program["code"];
|
|
215
|
+
for (const instruction of chunk.code) {
|
|
216
|
+
if (instruction.opcode === Opcode.STREAM_THINK_DELTA && instruction.value.kind === "string") {
|
|
217
|
+
onThinking(instruction.value.value);
|
|
218
|
+
} else if (instruction.opcode === Opcode.RESP_DONE || instruction.opcode === Opcode.STREAM_END) {
|
|
219
|
+
ending.push(instruction);
|
|
220
|
+
} else {
|
|
221
|
+
visible.push(instruction);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (ending.length > 0) terminal.push({ code: ending, buffers: chunk.buffers });
|
|
225
|
+
return { code: visible, buffers: chunk.buffers };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function thinkingChunk(text: string): Program {
|
|
229
|
+
return {
|
|
230
|
+
code: [{ opcode: Opcode.STREAM_THINK_DELTA, value: { kind: "string", value: text } }],
|
|
231
|
+
buffers: [],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function delay(milliseconds: number): Promise<void> {
|
|
236
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
237
|
+
}
|
package/src/managed/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createCapabilitiesExecutor } from "./capabilities.ts";
|
|
2
2
|
export type { CapabilitiesExecutorOptions } from "./capabilities.ts";
|
|
3
|
+
export { createCollapsedReasoner } from "./collapsed-reasoner.ts";
|
|
4
|
+
export type { CollapsedReasonerOptions } from "./collapsed-reasoner.ts";
|
|
3
5
|
export { createAttachmentToTextExecutor } from "./attachment-to-text.ts";
|
|
4
6
|
export type {
|
|
5
7
|
AttachmentReaderRule,
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendAssistantMessage,
|
|
3
|
+
appendInstructions,
|
|
4
|
+
createProgram,
|
|
5
|
+
delta,
|
|
6
|
+
extractContentText,
|
|
7
|
+
extractThinkingText,
|
|
8
|
+
Opcode,
|
|
9
|
+
ProgramView,
|
|
10
|
+
type Instruction,
|
|
11
|
+
type Program,
|
|
12
|
+
} from "@neutrome/lil-engine";
|
|
13
|
+
import { describe, expect, it, vi } from "vitest";
|
|
14
|
+
import { createCollapsedReasoner } from "../src/managed/collapsed-reasoner.ts";
|
|
15
|
+
import type { Executor, ExecutorContext } from "../src/types.ts";
|
|
16
|
+
|
|
17
|
+
describe("createCollapsedReasoner", () => {
|
|
18
|
+
it("replaces completed raw reasoning with a forced summary", async () => {
|
|
19
|
+
let summaryRequest: Program | undefined;
|
|
20
|
+
const executor = createCollapsedReasoner(
|
|
21
|
+
fromExecute(async () => withThinking("answer", "private reasoning")),
|
|
22
|
+
fromExecute(async (request) => {
|
|
23
|
+
summaryRequest = request;
|
|
24
|
+
return appendAssistantMessage(createProgram(), "Working through it.");
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const result = await executor.execute(createProgram(), context());
|
|
29
|
+
|
|
30
|
+
expect(extractContentText(result)).toBe("answer");
|
|
31
|
+
expect(extractThinkingText(result)).toBe("Working through it.");
|
|
32
|
+
expect(new ProgramView(summaryRequest!).messages.at(-1)?.text).toBe("private reasoning");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("suppresses raw stream deltas, preserves visible chunks, and flushes before the end", async () => {
|
|
36
|
+
const executor = createCollapsedReasoner(
|
|
37
|
+
streaming(async function* () {
|
|
38
|
+
yield delta.start();
|
|
39
|
+
yield delta.thinking("private reasoning");
|
|
40
|
+
yield delta.text("answer");
|
|
41
|
+
yield delta.end();
|
|
42
|
+
}),
|
|
43
|
+
fromExecute(async () => appendAssistantMessage(createProgram(), "Checking details.")),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const chunks: Program[] = [];
|
|
47
|
+
for await (const chunk of executor.stream(createProgram(), context())) chunks.push(chunk);
|
|
48
|
+
|
|
49
|
+
expect(extractStreamThinking(chunks)).toEqual(["Checking details."]);
|
|
50
|
+
expect(chunks.map((chunk) => extractStreamText(chunk)).join("")).toBe("answer");
|
|
51
|
+
expect(chunks.at(-1)?.code.map((item) => item.opcode)).toEqual([
|
|
52
|
+
Opcode.RESP_DONE,
|
|
53
|
+
Opcode.STREAM_END,
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("uses elapsed time for periodic summaries and gives a forced tick priority", async () => {
|
|
58
|
+
vi.useFakeTimers();
|
|
59
|
+
let release: (() => void) | undefined;
|
|
60
|
+
const executor = createCollapsedReasoner(
|
|
61
|
+
streaming(async function* () {
|
|
62
|
+
yield delta.thinking("first");
|
|
63
|
+
await new Promise<void>((resolve) => {
|
|
64
|
+
release = resolve;
|
|
65
|
+
});
|
|
66
|
+
yield delta.end();
|
|
67
|
+
}),
|
|
68
|
+
fromExecute(async (request) =>
|
|
69
|
+
appendAssistantMessage(createProgram(), `summary:${extractContentText(request)}`),
|
|
70
|
+
),
|
|
71
|
+
{ interval: 10, forcedInterval: 20 },
|
|
72
|
+
);
|
|
73
|
+
const iterator = executor.stream(createProgram(), context())[Symbol.asyncIterator]();
|
|
74
|
+
|
|
75
|
+
const pendingSummary = iterator.next();
|
|
76
|
+
await vi.advanceTimersByTimeAsync(20);
|
|
77
|
+
const summary = await pendingSummary;
|
|
78
|
+
release?.();
|
|
79
|
+
const end = await iterator.next();
|
|
80
|
+
vi.useRealTimers();
|
|
81
|
+
|
|
82
|
+
expect(extractStreamThinking([summary.value!])).toEqual([expect.stringContaining("first")]);
|
|
83
|
+
expect(end.value?.code.some((item: Instruction) => item.opcode === Opcode.STREAM_END)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("validates its timing options and never emits blank summaries", async () => {
|
|
87
|
+
expect(() => createCollapsedReasoner("reasoner", "summarizer", { interval: 0 })).toThrow(RangeError);
|
|
88
|
+
expect(() =>
|
|
89
|
+
createCollapsedReasoner("reasoner", "summarizer", { interval: 10, forcedInterval: 9 }),
|
|
90
|
+
).toThrow(RangeError);
|
|
91
|
+
|
|
92
|
+
const executor = createCollapsedReasoner(
|
|
93
|
+
streaming(async function* () {
|
|
94
|
+
yield delta.thinking("private");
|
|
95
|
+
yield delta.end();
|
|
96
|
+
}),
|
|
97
|
+
fromExecute(async () => appendAssistantMessage(createProgram(), " ")),
|
|
98
|
+
);
|
|
99
|
+
const chunks: Program[] = [];
|
|
100
|
+
for await (const chunk of executor.stream(createProgram(), context())) chunks.push(chunk);
|
|
101
|
+
expect(extractStreamThinking(chunks)).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("propagates source and summarizer failures without exposing reasoning", async () => {
|
|
105
|
+
const sourceFailure = new Error("source failed");
|
|
106
|
+
const brokenSource = createCollapsedReasoner(
|
|
107
|
+
streaming(async function* () {
|
|
108
|
+
throw sourceFailure;
|
|
109
|
+
}),
|
|
110
|
+
"summarizer",
|
|
111
|
+
);
|
|
112
|
+
await expect(collect(brokenSource.stream(createProgram(), context()))).rejects.toBe(sourceFailure);
|
|
113
|
+
|
|
114
|
+
const summaryFailure = new Error("summary failed");
|
|
115
|
+
const brokenSummary = createCollapsedReasoner(
|
|
116
|
+
streaming(async function* () {
|
|
117
|
+
yield delta.thinking("private");
|
|
118
|
+
yield delta.end();
|
|
119
|
+
}),
|
|
120
|
+
fromExecute(async () => {
|
|
121
|
+
throw summaryFailure;
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
await expect(collect(brokenSummary.stream(createProgram(), context()))).rejects.toBe(summaryFailure);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
function withThinking(text: string, thinking: string): Program {
|
|
129
|
+
return appendInstructions(appendAssistantMessage(createProgram(), text), [
|
|
130
|
+
{ opcode: Opcode.MSG_START, value: { kind: "none" } },
|
|
131
|
+
{ opcode: Opcode.ROLE_AST, value: { kind: "none" } },
|
|
132
|
+
{ opcode: Opcode.THINK_START, value: { kind: "none" } },
|
|
133
|
+
{ opcode: Opcode.THINK_CHUNK, value: { kind: "string", value: thinking } },
|
|
134
|
+
{ opcode: Opcode.THINK_END, value: { kind: "none" } },
|
|
135
|
+
{ opcode: Opcode.MSG_END, value: { kind: "none" } },
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function context(): ExecutorContext {
|
|
140
|
+
const ctx: ExecutorContext = {
|
|
141
|
+
cache: { get: async () => null, put: async () => {}, delete: async () => {} },
|
|
142
|
+
requestId: "request",
|
|
143
|
+
executionId: "execution",
|
|
144
|
+
invoke(executor, request) {
|
|
145
|
+
return typeof executor === "string"
|
|
146
|
+
? Promise.resolve(request)
|
|
147
|
+
: executor.execute(request, ctx);
|
|
148
|
+
},
|
|
149
|
+
async *invokeStream(executor, request) {
|
|
150
|
+
if (typeof executor !== "string") yield* executor.stream(request, ctx);
|
|
151
|
+
},
|
|
152
|
+
observe() {},
|
|
153
|
+
signal: new AbortController().signal,
|
|
154
|
+
};
|
|
155
|
+
return ctx;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function fromExecute(execute: Executor["execute"]): Executor {
|
|
159
|
+
return { execute, async *stream() {} };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function streaming(stream: Executor["stream"]): Executor {
|
|
163
|
+
return { async execute() { return createProgram(); }, stream };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function extractStreamText(chunk: Program): string {
|
|
167
|
+
let text = "";
|
|
168
|
+
for (const item of chunk.code) {
|
|
169
|
+
if (item.opcode === Opcode.STREAM_DELTA && item.value.kind === "string") text += item.value.value;
|
|
170
|
+
}
|
|
171
|
+
return text;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function extractStreamThinking(chunks: Program[]): string[] {
|
|
175
|
+
const thinking: string[] = [];
|
|
176
|
+
for (const chunk of chunks) {
|
|
177
|
+
for (const item of chunk.code) {
|
|
178
|
+
if (item.opcode === Opcode.STREAM_THINK_DELTA && item.value.kind === "string") {
|
|
179
|
+
thinking.push(item.value.value);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return thinking;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function collect(stream: AsyncIterable<Program>): Promise<Program[]> {
|
|
187
|
+
const chunks: Program[] = [];
|
|
188
|
+
for await (const chunk of stream) chunks.push(chunk);
|
|
189
|
+
return chunks;
|
|
190
|
+
}
|