@msm-core/mini 0.14.0 → 0.15.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/CHANGELOG.md +85 -0
- package/README.md +54 -0
- package/dist/brain/anthropic.js +21 -3
- package/dist/brain/factory.js +55 -2
- package/dist/brain/gemini.d.ts +18 -2
- package/dist/brain/gemini.js +69 -10
- package/dist/brain/streaming.d.ts +38 -5
- package/dist/brain/streaming.js +37 -12
- package/dist/core/loop.js +41 -2
- package/dist/core/types.d.ts +46 -0
- package/dist/definition/parser.d.ts +3 -0
- package/dist/definition/parser.js +52 -1
- package/dist/index.d.ts +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,91 @@ Follows [Semantic Versioning](https://semver.org/).
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
## [0.15.0] — 2026-09-13
|
|
16
|
+
|
|
17
|
+
Session ت١ (tadween chapter, item 1). Thinking is separated from the answer,
|
|
18
|
+
and text parts are joined — on both paths, for Gemini and Anthropic alike.
|
|
19
|
+
Everything here is additive; a composition that passes no new option gets
|
|
20
|
+
yesterday's behavior, byte for byte.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Gemini: the first text part was the whole answer.** The brain read its
|
|
25
|
+
reply with `parts.find(text)`, so a candidate of several text parts returned
|
|
26
|
+
the first and dropped the rest — and with thinking enabled the first part
|
|
27
|
+
IS the reasoning, so the user read the model's notes instead of its reply.
|
|
28
|
+
Now every text part NOT tagged `thought: true` is joined in emitted order
|
|
29
|
+
with no separator; tagged parts are joined separately and never enter the
|
|
30
|
+
answer. Untagged prose is kept as written: the loop drops what the provider
|
|
31
|
+
tagged and keeps what it did not.
|
|
32
|
+
- **Gemini streamed: thinking parts leaked into the merged answer.**
|
|
33
|
+
`accumulateGemini` folded every text part — tagged or not — into one slot.
|
|
34
|
+
It now keeps two (answer and thinking, each tagged as it arrived), and
|
|
35
|
+
`geminiDelta` skips tagged parts, so what an `onChunk` watcher hears stays a
|
|
36
|
+
prefix of the answer the payload finally carries (the ب١ covenant). The
|
|
37
|
+
brain decides text-versus-thinking on the folded shape with the same lines
|
|
38
|
+
it uses unstreamed.
|
|
39
|
+
- **Anthropic: the first text block was the whole answer** (measured under
|
|
40
|
+
the same rule, item 8). Thinking blocks were already excluded by their
|
|
41
|
+
type; every `text` block is now joined in order, and a `thinking` block's
|
|
42
|
+
body goes to `thoughts` (unstreamed — the streamed fold does not carry it,
|
|
43
|
+
and this brain does not request thinking).
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- `createGeminiBrain({ thinking?: { budget?, includeThoughts? } })` →
|
|
48
|
+
`generationConfig.thinkingConfig = { thinkingBudget, includeThoughts }`,
|
|
49
|
+
each field only when given, and the whole key only when the option is
|
|
50
|
+
present with a value (`thinking: undefined` from a JavaScript host is
|
|
51
|
+
absence). `budget: 0` is a value and switches thinking off. Without the
|
|
52
|
+
option the request carries no `generationConfig` key at all — guarded by a
|
|
53
|
+
before/after snapshot of the request. The option type is exported as
|
|
54
|
+
`GeminiBrainOptions`. Note: `@google/generative-ai` 0.24.1 (installed, and
|
|
55
|
+
the registry's newest as of 2026-09-13) does not declare `thinkingConfig`;
|
|
56
|
+
it serializes the request verbatim, so the field reaches the wire.
|
|
57
|
+
- `BrainPayload.thoughts?: string` — the model's reasoning for that call,
|
|
58
|
+
joined, absent when there was none (no key, never `""`). Filled by the
|
|
59
|
+
Gemini and Anthropic brains; a custom brain may fill it the same way.
|
|
60
|
+
- `LoopOutcome.thoughts?: string` — the LAST step's `thoughts`, at every exit
|
|
61
|
+
that takes its `text` from a payload (final answer, forced finalize,
|
|
62
|
+
force-respond/escalate). Absent otherwise. **Not written to the session
|
|
63
|
+
log**: the model never sees its own thinking again, so the invariant "what
|
|
64
|
+
the model sees is recorded" does not reach it — auditing it as an event is
|
|
65
|
+
raised to management, not decided here.
|
|
66
|
+
- **`thoughts` is ungated audit material — do not hand it to an end user
|
|
67
|
+
without the host's own filter.** The output gate validates `text` and
|
|
68
|
+
never reads `thoughts`. When the gate BLOCKS the answer (`type:
|
|
69
|
+
"suppressed"`) the outcome carries no `thoughts` either — what could not
|
|
70
|
+
go out as an answer does not go out as notes. On every other verdict
|
|
71
|
+
(`release`, `review`, no validator, or a gate that never ran because the
|
|
72
|
+
text was empty) `thoughts` passes exactly as the model wrote it.
|
|
73
|
+
- **Only with text the model wrote.** On a guard exit that falls back to
|
|
74
|
+
the canned "I was unable to complete…" line, `thoughts` is absent — the
|
|
75
|
+
thinking accompanies the answer it produced, never a sentence the model
|
|
76
|
+
did not write.
|
|
77
|
+
- **Reaches the `replay` tape.** `createRecordingBrain` records the whole
|
|
78
|
+
payload, `thoughts` included, like any other field; the `redact` option
|
|
79
|
+
of `createRecordingBrain` is where a host hides it (and replays with the
|
|
80
|
+
same redactor).
|
|
81
|
+
- `## Brain` reads `thinking.budget` and `thinking.includeThoughts` (flat
|
|
82
|
+
dotted keys) into `AgentDefinition.brain.thinking?: { budget?, includeThoughts? }`
|
|
83
|
+
— now an explicit field — and `buildBrain` hands it to `createGeminiBrain`
|
|
84
|
+
**after checking its shape**: an object; `budget` a finite number ≥ 0 when
|
|
85
|
+
given; `includeThoughts` a boolean when given. A value that does not fit is
|
|
86
|
+
a **named error** (from the parser for text, from `buildBrain` for a
|
|
87
|
+
hand-built definition), never a silent default. Neither key written → no
|
|
88
|
+
`thinking` on the definition and no `generationConfig` on the wire. Only the
|
|
89
|
+
Gemini brain consumes the key in this release. (`## Brain` alone grew the
|
|
90
|
+
dotted grammar; other sections parse exactly as before.)
|
|
91
|
+
- `thinking: {}` — and an object whose fields are all `undefined` — is
|
|
92
|
+
absence: no `generationConfig` on the wire, no `thinkingConfig: {}`.
|
|
93
|
+
- Guards: `tests/gemini-thoughts.test.ts` (36 tests) and
|
|
94
|
+
`tests/brain-thinking-definition.test.ts` (14 tests) — each rule above, each
|
|
95
|
+
control named as such; the rule guards were shown red by deliberate breaks
|
|
96
|
+
(ten breaks over the two rounds, 21 of the 26 new tests reddened by name; the
|
|
97
|
+
rest are controls) and restored by fingerprint; the loop's three exit sites
|
|
98
|
+
guarded one by one.
|
|
99
|
+
|
|
15
100
|
## [0.14.0] — 2026-09-01
|
|
16
101
|
|
|
17
102
|
Session ص٤. The Redis-mandatory era ends.
|
package/README.md
CHANGED
|
@@ -150,6 +150,60 @@ const outcome = await agent.handle({
|
|
|
150
150
|
|
|
151
151
|
`buildBrain(definition)` reads the `## Brain` section of an `AgentDefinition` and returns the correct brain automatically.
|
|
152
152
|
|
|
153
|
+
### Thinking (Gemini) — `thinking`
|
|
154
|
+
|
|
155
|
+
Gemini 2.5+ models reason before they answer. Opt in per brain, and the
|
|
156
|
+
reasoning comes back **separated** from the reply rather than inside it:
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
const brain = createGeminiBrain({
|
|
160
|
+
model: "gemini-2.5-flash",
|
|
161
|
+
thinking: { budget: 1024, includeThoughts: false },
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Or from the definition file — the same two fields, written flat under
|
|
166
|
+
`## Brain`, reach the brain through `buildBrain`:
|
|
167
|
+
|
|
168
|
+
```markdown
|
|
169
|
+
## Brain
|
|
170
|
+
provider: gemini
|
|
171
|
+
model: gemini-2.5-flash
|
|
172
|
+
thinking.budget: 1024
|
|
173
|
+
thinking.includeThoughts: false
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
- `budget` → `generationConfig.thinkingConfig.thinkingBudget`; `0` switches
|
|
177
|
+
thinking off on models that think by default.
|
|
178
|
+
- `includeThoughts: true` asks for the reasoning back. Gemini tags those parts
|
|
179
|
+
`thought: true`; the brain joins them into `BrainPayload.thoughts` and the
|
|
180
|
+
loop copies the **last step's** value to `LoopOutcome.thoughts`. They never
|
|
181
|
+
enter `response_text`, are never streamed to `onChunk`, and are not written
|
|
182
|
+
to the session log.
|
|
183
|
+
- **Without the option nothing changes**: the request carries no
|
|
184
|
+
`generationConfig` key at all, and the answer is built exactly as before.
|
|
185
|
+
An empty `thinking: {}` is the same as no option. A value that does not fit
|
|
186
|
+
(`thinking.budget: abc`, a negative budget, a non-boolean `includeThoughts`)
|
|
187
|
+
is a **named error** from the parser or from `buildBrain` — never a silent
|
|
188
|
+
default. Only the Gemini brain reads the key today.
|
|
189
|
+
- **`thoughts` is ungated audit material — not for an end user without your
|
|
190
|
+
own filter.** The output gate (`validator`) checks `text` and never reads
|
|
191
|
+
`thoughts`. When it blocks the answer, `thoughts` is dropped with it; on
|
|
192
|
+
every other verdict — and when the gate never ran because the text was
|
|
193
|
+
empty — the reasoning passes exactly as the model wrote it. It rides only
|
|
194
|
+
with text the model wrote: a guard exit that falls back to the canned
|
|
195
|
+
"unable to complete" line carries no `thoughts`.
|
|
196
|
+
- **It reaches the replay tape.** `@msm-core/replay`'s `createRecordingBrain`
|
|
197
|
+
records the whole payload, `thoughts` included; its `redact` option is where
|
|
198
|
+
to hide it (replay with the same redactor).
|
|
199
|
+
|
|
200
|
+
Two rules hold regardless of the option, for Gemini and Anthropic alike
|
|
201
|
+
(`0.15.0`): every text part/block is **joined in emitted order** (the first is
|
|
202
|
+
no longer taken as the whole answer), and a part the provider **tagged** as
|
|
203
|
+
thinking is kept out of the answer. Prose the provider did *not* tag is kept
|
|
204
|
+
as written — the loop does not guess. A model that narrates its reasoning
|
|
205
|
+
into the answer untagged is a prompt (or budget) matter, not a parsing one.
|
|
206
|
+
|
|
153
207
|
---
|
|
154
208
|
|
|
155
209
|
## HTTP Server
|
package/dist/brain/anthropic.js
CHANGED
|
@@ -82,23 +82,41 @@ export function createAnthropicBrain(opts) {
|
|
|
82
82
|
params: (block.input ?? {}),
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
|
+
// Text and thinking — the same rule as Gemini's (ت١/٨). EVERY `text`
|
|
86
|
+
// block, in the order Claude emitted them, joined with no separator;
|
|
87
|
+
// `thinking` blocks join separately into `thoughts` and never into the
|
|
88
|
+
// answer. Measured before this: `.find()` took the FIRST text block and
|
|
89
|
+
// dropped every later one, while thinking was already kept out by the
|
|
90
|
+
// type check. A `thinking` body only arrives unstreamed — the streamed
|
|
91
|
+
// fold carries no such field (see `AnthropicMessage`) — and this brain
|
|
92
|
+
// never asks for thinking, so `thoughts` is a contract, not a promise.
|
|
93
|
+
let text = "";
|
|
94
|
+
let thoughts = "";
|
|
95
|
+
for (const block of content) {
|
|
96
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
97
|
+
text += block.text;
|
|
98
|
+
}
|
|
99
|
+
else if (block.type === "thinking" && typeof block.thinking === "string") {
|
|
100
|
+
thoughts += block.thinking;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const thinking = thoughts ? { thoughts } : {};
|
|
85
104
|
const orchestration = useToolOrchestration(calls, 0.9);
|
|
86
105
|
if (orchestration) {
|
|
87
106
|
return {
|
|
88
107
|
orchestration,
|
|
89
108
|
usage: { inputTokens, outputTokens },
|
|
90
109
|
costUsd,
|
|
110
|
+
...thinking,
|
|
91
111
|
...respondingModel(response),
|
|
92
112
|
};
|
|
93
113
|
}
|
|
94
|
-
// Text
|
|
95
|
-
const textBlock = content.find((b) => b.type === "text");
|
|
96
|
-
const text = textBlock?.text ?? "";
|
|
97
114
|
return {
|
|
98
115
|
generation: { response_text: text },
|
|
99
116
|
orchestration: { action: "respond", confidence: 0.95 },
|
|
100
117
|
usage: { inputTokens, outputTokens },
|
|
101
118
|
costUsd,
|
|
119
|
+
...thinking,
|
|
102
120
|
// Who answered, off the message itself — Claude names the model that
|
|
103
121
|
// served the request, which is not always the alias that was asked for.
|
|
104
122
|
// Streamed or not, `response` is the same shape: `message_start`
|
package/dist/brain/factory.js
CHANGED
|
@@ -13,11 +13,64 @@ export function buildBrain(def) {
|
|
|
13
13
|
return createOpenAIBrain({ ...(model ? { model } : {}), ...(endpoint ? { baseURL: endpoint } : {}), ...(apiKey ? { apiKey } : {}) });
|
|
14
14
|
case "anthropic":
|
|
15
15
|
return createAnthropicBrain({ ...(model ? { model } : {}), ...(apiKey ? { apiKey } : {}) });
|
|
16
|
-
case "gemini":
|
|
17
|
-
|
|
16
|
+
case "gemini": {
|
|
17
|
+
// `## Brain` → `thinking` (ت١/٢): the definition's key reaches the brain
|
|
18
|
+
// here, and only after its shape is checked — a definition is data from
|
|
19
|
+
// a file or a host, and `[key: string]: unknown` lets anything through.
|
|
20
|
+
const thinking = brainThinkingOf(def.brain.thinking);
|
|
21
|
+
return createGeminiBrain({
|
|
22
|
+
...(model ? { model } : {}),
|
|
23
|
+
...(apiKey ? { apiKey } : {}),
|
|
24
|
+
...(thinking ? { thinking } : {}),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
18
27
|
case "ollama":
|
|
19
28
|
return createOllamaBrain({ ...(endpoint ? { endpoint } : {}), ...(model ? { model } : {}) });
|
|
20
29
|
default:
|
|
21
30
|
throw new Error(`msm-mini: unknown brain provider "${String(provider)}". Pass a Brain instance directly to createAgent().`);
|
|
22
31
|
}
|
|
23
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* `def.brain.thinking` checked into the option `createGeminiBrain` takes, or
|
|
35
|
+
* `undefined` when the definition has none.
|
|
36
|
+
*
|
|
37
|
+
* A NAMED error for a wrong shape, not silence: `thinking` was added so a
|
|
38
|
+
* definition could switch the model's reasoning on, and a value that quietly
|
|
39
|
+
* did nothing would be the same leak with a config line under it. An object;
|
|
40
|
+
* `budget` a finite number ≥ 0 when given (`0` is thinking OFF); `includeThoughts`
|
|
41
|
+
* a boolean when given. Each field is copied only when set, so `{}` — and a
|
|
42
|
+
* host that spreads `{ budget: undefined }` — reaches the brain as an empty
|
|
43
|
+
* option, which the brain reads as absence.
|
|
44
|
+
*/
|
|
45
|
+
function brainThinkingOf(value) {
|
|
46
|
+
if (value === undefined)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
49
|
+
throw new Error(`msm-mini: brain.thinking must be an object { budget?, includeThoughts? }, got ${describe(value)}`);
|
|
50
|
+
}
|
|
51
|
+
// `in` narrows `object` to the key it names — no cast over the definition.
|
|
52
|
+
const budget = "budget" in value ? value.budget : undefined;
|
|
53
|
+
const includeThoughts = "includeThoughts" in value ? value.includeThoughts : undefined;
|
|
54
|
+
if (budget !== undefined && !(typeof budget === "number" && Number.isFinite(budget) && budget >= 0)) {
|
|
55
|
+
throw new Error(`msm-mini: brain.thinking.budget must be a finite number >= 0, got ${describe(budget)}`);
|
|
56
|
+
}
|
|
57
|
+
if (includeThoughts !== undefined && typeof includeThoughts !== "boolean") {
|
|
58
|
+
throw new Error(`msm-mini: brain.thinking.includeThoughts must be a boolean, got ${describe(includeThoughts)}`);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
...(budget !== undefined ? { budget } : {}),
|
|
62
|
+
...(includeThoughts !== undefined ? { includeThoughts } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** A value as an error message shows it: `"abc"`, `null`, `an array`. */
|
|
66
|
+
function describe(value) {
|
|
67
|
+
if (value === null)
|
|
68
|
+
return "null";
|
|
69
|
+
if (Array.isArray(value))
|
|
70
|
+
return "an array";
|
|
71
|
+
if (typeof value === "string")
|
|
72
|
+
return JSON.stringify(value);
|
|
73
|
+
if (typeof value === "object")
|
|
74
|
+
return "an object";
|
|
75
|
+
return String(value);
|
|
76
|
+
}
|
package/dist/brain/gemini.d.ts
CHANGED
|
@@ -3,7 +3,23 @@
|
|
|
3
3
|
* Peer dependency: @google/generative-ai >= 0.14.0
|
|
4
4
|
*/
|
|
5
5
|
import type { Brain } from "./types.js";
|
|
6
|
-
export
|
|
6
|
+
export interface GeminiBrainOptions {
|
|
7
7
|
apiKey?: string;
|
|
8
8
|
model?: string;
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Thinking, opted into (ت١/٤). Translated to `generationConfig.thinkingConfig`
|
|
11
|
+
* — `budget` → `thinkingBudget`, `includeThoughts` → `includeThoughts`, each
|
|
12
|
+
* only when given — and ONLY when at least one field is given: absent,
|
|
13
|
+
* explicitly `undefined` from a JavaScript host, or an empty `{}`, the
|
|
14
|
+
* request carries no `generationConfig` at all and not one byte of it changes.
|
|
15
|
+
*
|
|
16
|
+
* `budget: 0` turns thinking off on models that allow it; `includeThoughts:
|
|
17
|
+
* true` asks for the reasoning back as parts tagged `thought: true`, which
|
|
18
|
+
* the brain routes to `BrainPayload.thoughts` and never into the answer.
|
|
19
|
+
*/
|
|
20
|
+
thinking?: {
|
|
21
|
+
budget?: number;
|
|
22
|
+
includeThoughts?: boolean;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export declare function createGeminiBrain(opts: GeminiBrainOptions): Brain;
|
package/dist/brain/gemini.js
CHANGED
|
@@ -9,6 +9,9 @@ import { accumulateGemini, consumeStream, geminiDelta, } from "./streaming.js";
|
|
|
9
9
|
import { computeCostUsd } from "./pricing.js";
|
|
10
10
|
export function createGeminiBrain(opts) {
|
|
11
11
|
const model = opts.model ?? "gemini-2.5-flash";
|
|
12
|
+
// Resolved once, at composition: the request below spreads it, so a brain
|
|
13
|
+
// built without the option builds the request it always built.
|
|
14
|
+
const thinkingConfig = thinkingConfigOf(opts.thinking);
|
|
12
15
|
return {
|
|
13
16
|
name: "gemini",
|
|
14
17
|
async run(input) {
|
|
@@ -55,13 +58,19 @@ export function createGeminiBrain(opts) {
|
|
|
55
58
|
},
|
|
56
59
|
]
|
|
57
60
|
: undefined;
|
|
58
|
-
const request =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
const request = {
|
|
62
|
+
systemInstruction: input.system_context,
|
|
63
|
+
contents,
|
|
64
|
+
...(tools
|
|
65
|
+
? {
|
|
66
|
+
tools: tools,
|
|
67
|
+
}
|
|
68
|
+
: {}),
|
|
69
|
+
// The one key ت١ adds, and only for a brain that asked for it — a
|
|
70
|
+
// conditional spread, so "no option" leaves no `generationConfig` key
|
|
71
|
+
// (not even an `undefined` one) for the SDK to serialize.
|
|
72
|
+
...(thinkingConfig ? { generationConfig: { thinkingConfig } } : {}),
|
|
73
|
+
};
|
|
65
74
|
// ── The one branch streaming adds ──────────────────────────────────
|
|
66
75
|
//
|
|
67
76
|
// No `onChunk`, no stream: the call below is the call this brain has
|
|
@@ -99,23 +108,46 @@ export function createGeminiBrain(opts) {
|
|
|
99
108
|
params: (fc.args ?? {}),
|
|
100
109
|
});
|
|
101
110
|
}
|
|
111
|
+
// Text and thinking — EVERY text part, in emitted order, joined with no
|
|
112
|
+
// separator; the parts the model tagged `thought: true` join separately
|
|
113
|
+
// into `thoughts` and never into the answer (ت١). `.find()` took the
|
|
114
|
+
// first text part and dropped the rest, and with thinking on that first
|
|
115
|
+
// part was the reasoning, so the user read the model's notes instead of
|
|
116
|
+
// its reply. Untagged prose is kept as written: what the provider did not
|
|
117
|
+
// mark is answer text, and guessing is the prompt's job, not the loop's.
|
|
118
|
+
//
|
|
119
|
+
// One loop for both shapes — the raw candidate `generateContent` returns
|
|
120
|
+
// and the folded one `accumulateGemini` builds, which keeps its thinking
|
|
121
|
+
// in a part tagged the same way — so the streamed and unstreamed paths
|
|
122
|
+
// decide text-versus-thinking on the same lines.
|
|
123
|
+
let text = "";
|
|
124
|
+
let thoughts = "";
|
|
125
|
+
for (const part of parts) {
|
|
126
|
+
if (typeof part.text !== "string")
|
|
127
|
+
continue;
|
|
128
|
+
if (part.thought === true)
|
|
129
|
+
thoughts += part.text;
|
|
130
|
+
else
|
|
131
|
+
text += part.text;
|
|
132
|
+
}
|
|
133
|
+
// Absent when there was no thinking — a key only when it says something.
|
|
134
|
+
const thinking = thoughts ? { thoughts } : {};
|
|
102
135
|
const orchestration = useToolOrchestration(calls, 0.9);
|
|
103
136
|
if (orchestration) {
|
|
104
137
|
return {
|
|
105
138
|
orchestration,
|
|
106
139
|
costUsd,
|
|
107
140
|
...(usage ? { usage } : {}),
|
|
141
|
+
...thinking,
|
|
108
142
|
...respondingModel(response),
|
|
109
143
|
};
|
|
110
144
|
}
|
|
111
|
-
// Text response
|
|
112
|
-
const textPart = parts.find((p) => "text" in p && typeof p.text === "string");
|
|
113
|
-
const text = textPart && "text" in textPart ? textPart.text : "";
|
|
114
145
|
return {
|
|
115
146
|
generation: { response_text: text },
|
|
116
147
|
orchestration: { action: "respond", confidence: 0.95 },
|
|
117
148
|
costUsd,
|
|
118
149
|
...(usage ? { usage } : {}),
|
|
150
|
+
...thinking,
|
|
119
151
|
// Who answered — Gemini spells it `modelVersion`, and it is the field
|
|
120
152
|
// that tells `gemini-2.5-flash` from the dated build that served it.
|
|
121
153
|
// The SDK's non-streaming path hands back the parsed response body as
|
|
@@ -127,6 +159,33 @@ export function createGeminiBrain(opts) {
|
|
|
127
159
|
},
|
|
128
160
|
};
|
|
129
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* `createGeminiBrain({ thinking })` → the wire's `thinkingConfig`, or
|
|
164
|
+
* `undefined` when the option says nothing.
|
|
165
|
+
*
|
|
166
|
+
* Absence is read from the VALUE, not the key (تعميم المجلس ٦): a JavaScript
|
|
167
|
+
* host that spreads `thinking: undefined` gets the request it would get with
|
|
168
|
+
* no option at all. Each field is copied only when set, and `0` is a value —
|
|
169
|
+
* `budget: 0` is how thinking is switched OFF on a model that thinks by
|
|
170
|
+
* default, and a truthiness check would have thrown that away.
|
|
171
|
+
*
|
|
172
|
+
* **An empty object is absence too** (ت١/٢ item 5). `thinking: {}` — or
|
|
173
|
+
* `{ budget: undefined }` from a host that spreads what it does not have —
|
|
174
|
+
* names no field, so nothing goes on the wire: no `generationConfig`, and no
|
|
175
|
+
* `thinkingConfig: {}` that nobody asked for. The key is written only when
|
|
176
|
+
* it carries at least one value.
|
|
177
|
+
*/
|
|
178
|
+
function thinkingConfigOf(thinking) {
|
|
179
|
+
if (thinking === undefined)
|
|
180
|
+
return undefined;
|
|
181
|
+
const config = {
|
|
182
|
+
...(thinking.budget !== undefined ? { thinkingBudget: thinking.budget } : {}),
|
|
183
|
+
...(thinking.includeThoughts !== undefined
|
|
184
|
+
? { includeThoughts: thinking.includeThoughts }
|
|
185
|
+
: {}),
|
|
186
|
+
};
|
|
187
|
+
return Object.keys(config).length > 0 ? config : undefined;
|
|
188
|
+
}
|
|
130
189
|
/**
|
|
131
190
|
* `generateContentStream` read to the end, folded back into the response shape
|
|
132
191
|
* the caller above expects.
|
|
@@ -179,6 +179,16 @@ export interface AnthropicMessage {
|
|
|
179
179
|
text?: string;
|
|
180
180
|
name?: string;
|
|
181
181
|
input?: unknown;
|
|
182
|
+
/**
|
|
183
|
+
* The body of a `thinking` block, as the unstreamed API spells it (ت١/٨).
|
|
184
|
+
* The brain joins these into `BrainPayload.thoughts` and never into the
|
|
185
|
+
* text. The streamed fold below does NOT carry it — `thinking_delta` is
|
|
186
|
+
* neither text nor JSON to `accumulateAnthropic`, so a streamed thinking
|
|
187
|
+
* block folds to `{ type: "thinking", text: "" }` as it always has, and
|
|
188
|
+
* `thoughts` is simply absent on that path. Said here so nobody reads the
|
|
189
|
+
* asymmetry as an accident: this brain does not request thinking at all.
|
|
190
|
+
*/
|
|
191
|
+
thinking?: string;
|
|
182
192
|
}>;
|
|
183
193
|
usage: {
|
|
184
194
|
input_tokens: number;
|
|
@@ -227,6 +237,18 @@ export interface GeminiPart {
|
|
|
227
237
|
name: string;
|
|
228
238
|
args?: unknown;
|
|
229
239
|
};
|
|
240
|
+
/**
|
|
241
|
+
* `true` on a text part that is the model's THINKING, not its answer (ت١).
|
|
242
|
+
*
|
|
243
|
+
* Gemini 2.5+ marks the parts it emits under `thinkingConfig.includeThoughts`
|
|
244
|
+
* with exactly this flag, on the wire, and `@google/generative-ai` 0.24.1
|
|
245
|
+
* hands the parsed body through without declaring the field — so it is
|
|
246
|
+
* declared here, where the brain reads it, and nowhere else. A part without
|
|
247
|
+
* the flag (or with it `false`) is answer text; ONLY `=== true` is thinking.
|
|
248
|
+
* The brain drops what the provider tagged and keeps what it did not — prose
|
|
249
|
+
* that merely *reads* like reasoning is the prompt's business, not the loop's.
|
|
250
|
+
*/
|
|
251
|
+
thought?: boolean;
|
|
230
252
|
}
|
|
231
253
|
/** What the Gemini brain reads off a response — streamed or not. */
|
|
232
254
|
export interface GeminiResponse {
|
|
@@ -265,17 +287,28 @@ export declare function geminiDelta(item: GeminiResponse): string;
|
|
|
265
287
|
* Fold Gemini stream items into the response they describe.
|
|
266
288
|
*
|
|
267
289
|
* **The text parts MERGE into one.** This is the whole reason this function is
|
|
268
|
-
* not a flat concatenation of `parts` arrays: the brain
|
|
269
|
-
* `parts.find(p => "text" in p)`, so a response left as fifty
|
|
270
|
-
*
|
|
271
|
-
*
|
|
290
|
+
* not a flat concatenation of `parts` arrays: until ت١ the brain found its
|
|
291
|
+
* answer with `parts.find(p => "text" in p)`, so a response left as fifty
|
|
292
|
+
* little text parts returned the first syllable of the answer and dropped the
|
|
293
|
+
* rest. The brain now JOINS every text part, so the merge is no longer what
|
|
294
|
+
* saves the answer — but one part in, one part out is still the shape
|
|
295
|
+
* `generateContent` returns, and a fold that equals the unstreamed object is
|
|
296
|
+
* the claim the ب١ fixtures check.
|
|
272
297
|
*
|
|
273
298
|
* The merged text keeps the POSITION of the first text part it saw, so a
|
|
274
299
|
* response that opened with a function call still reads in emitted order.
|
|
275
300
|
* (Order between text and calls does not reach the payload — the brain collects
|
|
276
|
-
* calls by filter and text by
|
|
301
|
+
* calls by filter and text by join — but a shape that reorders parts for no
|
|
277
302
|
* reason is a shape that will eventually be believed.)
|
|
278
303
|
*
|
|
304
|
+
* **Two slots, not one (ت١).** A part the model tagged `thought: true` merges
|
|
305
|
+
* into its OWN part, tagged the same way, and never into the answer's. The
|
|
306
|
+
* folded shape therefore still carries both kinds, tagged as they arrived, and
|
|
307
|
+
* the brain decides text-versus-thinking on that shape with the same lines it
|
|
308
|
+
* uses on an unstreamed candidate — one place for the decision, two paths
|
|
309
|
+
* through it. A stream with no thinking part folds to the object it folded to
|
|
310
|
+
* before the second slot existed, key for key.
|
|
311
|
+
*
|
|
279
312
|
* Usage is cumulative in Gemini's stream: the last item that carries it wins.
|
|
280
313
|
*/
|
|
281
314
|
export declare function accumulateGemini(items: readonly GeminiResponse[]): GeminiResponse;
|
package/dist/brain/streaming.js
CHANGED
|
@@ -266,6 +266,11 @@ export function geminiDelta(item) {
|
|
|
266
266
|
const parts = item.candidates?.[0]?.content?.parts ?? [];
|
|
267
267
|
let text = "";
|
|
268
268
|
for (const part of parts) {
|
|
269
|
+
// A thinking part is not the answer, and what reaches the watcher must
|
|
270
|
+
// stay a prefix of the answer the payload finally carries (ب١) — so it is
|
|
271
|
+
// skipped here for the same reason the brain keeps it out of the text.
|
|
272
|
+
if (part.thought === true)
|
|
273
|
+
continue;
|
|
269
274
|
if (typeof part.text === "string")
|
|
270
275
|
text += part.text;
|
|
271
276
|
}
|
|
@@ -275,24 +280,47 @@ export function geminiDelta(item) {
|
|
|
275
280
|
* Fold Gemini stream items into the response they describe.
|
|
276
281
|
*
|
|
277
282
|
* **The text parts MERGE into one.** This is the whole reason this function is
|
|
278
|
-
* not a flat concatenation of `parts` arrays: the brain
|
|
279
|
-
* `parts.find(p => "text" in p)`, so a response left as fifty
|
|
280
|
-
*
|
|
281
|
-
*
|
|
283
|
+
* not a flat concatenation of `parts` arrays: until ت١ the brain found its
|
|
284
|
+
* answer with `parts.find(p => "text" in p)`, so a response left as fifty
|
|
285
|
+
* little text parts returned the first syllable of the answer and dropped the
|
|
286
|
+
* rest. The brain now JOINS every text part, so the merge is no longer what
|
|
287
|
+
* saves the answer — but one part in, one part out is still the shape
|
|
288
|
+
* `generateContent` returns, and a fold that equals the unstreamed object is
|
|
289
|
+
* the claim the ب١ fixtures check.
|
|
282
290
|
*
|
|
283
291
|
* The merged text keeps the POSITION of the first text part it saw, so a
|
|
284
292
|
* response that opened with a function call still reads in emitted order.
|
|
285
293
|
* (Order between text and calls does not reach the payload — the brain collects
|
|
286
|
-
* calls by filter and text by
|
|
294
|
+
* calls by filter and text by join — but a shape that reorders parts for no
|
|
287
295
|
* reason is a shape that will eventually be believed.)
|
|
288
296
|
*
|
|
297
|
+
* **Two slots, not one (ت١).** A part the model tagged `thought: true` merges
|
|
298
|
+
* into its OWN part, tagged the same way, and never into the answer's. The
|
|
299
|
+
* folded shape therefore still carries both kinds, tagged as they arrived, and
|
|
300
|
+
* the brain decides text-versus-thinking on that shape with the same lines it
|
|
301
|
+
* uses on an unstreamed candidate — one place for the decision, two paths
|
|
302
|
+
* through it. A stream with no thinking part folds to the object it folded to
|
|
303
|
+
* before the second slot existed, key for key.
|
|
304
|
+
*
|
|
289
305
|
* Usage is cumulative in Gemini's stream: the last item that carries it wins.
|
|
290
306
|
*/
|
|
291
307
|
export function accumulateGemini(items) {
|
|
292
308
|
const parts = [];
|
|
293
309
|
let textSlot = -1;
|
|
310
|
+
let thoughtSlot = -1;
|
|
294
311
|
let usage;
|
|
295
312
|
let modelVersion;
|
|
313
|
+
// Append `text` to the part at `slot`, opening it at the end if there is none
|
|
314
|
+
// yet; returns the slot. `thought` rides along so the merged part stays tagged.
|
|
315
|
+
const merge = (slot, text, thought) => {
|
|
316
|
+
if (slot === -1) {
|
|
317
|
+
parts.push(thought ? { text, thought: true } : { text });
|
|
318
|
+
return parts.length - 1;
|
|
319
|
+
}
|
|
320
|
+
const joined = (parts[slot]?.text ?? "") + text;
|
|
321
|
+
parts[slot] = thought ? { text: joined, thought: true } : { text: joined };
|
|
322
|
+
return slot;
|
|
323
|
+
};
|
|
296
324
|
for (const item of items) {
|
|
297
325
|
if (item.usageMetadata)
|
|
298
326
|
usage = item.usageMetadata;
|
|
@@ -302,13 +330,10 @@ export function accumulateGemini(items) {
|
|
|
302
330
|
modelVersion = item.modelVersion;
|
|
303
331
|
for (const part of item.candidates?.[0]?.content?.parts ?? []) {
|
|
304
332
|
if (typeof part.text === "string") {
|
|
305
|
-
if (
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
else {
|
|
310
|
-
parts[textSlot] = { text: (parts[textSlot]?.text ?? "") + part.text };
|
|
311
|
-
}
|
|
333
|
+
if (part.thought === true)
|
|
334
|
+
thoughtSlot = merge(thoughtSlot, part.text, true);
|
|
335
|
+
else
|
|
336
|
+
textSlot = merge(textSlot, part.text, false);
|
|
312
337
|
}
|
|
313
338
|
else if (part.functionCall) {
|
|
314
339
|
parts.push({ functionCall: part.functionCall });
|
package/dist/core/loop.js
CHANGED
|
@@ -892,6 +892,7 @@ export function createAgent(config) {
|
|
|
892
892
|
terminatedBy: hardSignal.type,
|
|
893
893
|
toolCalls: toolResults,
|
|
894
894
|
...(gated.validation ? { validation: gated.validation } : {}),
|
|
895
|
+
...thoughtsOf(finalPayload, gated.validation),
|
|
895
896
|
});
|
|
896
897
|
}
|
|
897
898
|
}
|
|
@@ -900,8 +901,15 @@ export function createAgent(config) {
|
|
|
900
901
|
}
|
|
901
902
|
}
|
|
902
903
|
// force_respond / escalate → emit last text if available
|
|
903
|
-
|
|
904
|
-
|
|
904
|
+
//
|
|
905
|
+
// Two sources, told apart on purpose (ت١/٢ item 4): text the model
|
|
906
|
+
// wrote in its last payload, or the canned line below when it wrote
|
|
907
|
+
// none. The last payload's `thoughts` rides out ONLY with the former
|
|
908
|
+
// — thinking accompanies the answer it produced, never a sentence
|
|
909
|
+
// the model did not write.
|
|
910
|
+
const lastWritten = state.lastPayload?.generation?.response_text ??
|
|
911
|
+
state.lastPayload?.final_output?.text;
|
|
912
|
+
const lastText = lastWritten ??
|
|
905
913
|
"I was unable to complete this request within the allowed limits. Please try a more specific question.";
|
|
906
914
|
const gatedLast = await gateAndLog(lastText, stepId);
|
|
907
915
|
const outcomeType = gatedLast.validation?.action === "block"
|
|
@@ -918,6 +926,9 @@ export function createAgent(config) {
|
|
|
918
926
|
terminatedBy: hardSignal.type,
|
|
919
927
|
toolCalls: toolResults,
|
|
920
928
|
...(gatedLast.validation ? { validation: gatedLast.validation } : {}),
|
|
929
|
+
...(lastWritten !== undefined
|
|
930
|
+
? thoughtsOf(state.lastPayload, gatedLast.validation)
|
|
931
|
+
: {}),
|
|
921
932
|
});
|
|
922
933
|
}
|
|
923
934
|
// ── Context build ─────────────────────────────────────
|
|
@@ -1080,6 +1091,9 @@ export function createAgent(config) {
|
|
|
1080
1091
|
text: finalText,
|
|
1081
1092
|
toolCalls: toolResults,
|
|
1082
1093
|
...(validation ? { validation } : {}),
|
|
1094
|
+
// The last step's thinking rides out with its answer (ت١) — and only
|
|
1095
|
+
// here, not into the log: the model never sees it again.
|
|
1096
|
+
...thoughtsOf(payload, validation),
|
|
1083
1097
|
});
|
|
1084
1098
|
}
|
|
1085
1099
|
}
|
|
@@ -1211,6 +1225,31 @@ async function runWithTimeout(brainCall, budgetMs, controller) {
|
|
|
1211
1225
|
clearTimeout(timer);
|
|
1212
1226
|
}
|
|
1213
1227
|
}
|
|
1228
|
+
/**
|
|
1229
|
+
* `{ thoughts }` off the payload the outcome's text came from, or `{}` (ت١).
|
|
1230
|
+
*
|
|
1231
|
+
* Spread into the outcome at every site that takes its `text` from a payload,
|
|
1232
|
+
* and nowhere else — the thinking travels with the answer it belongs to. A
|
|
1233
|
+
* conditional spread so an outcome without thinking has no key, not an
|
|
1234
|
+
* `undefined` one that `toEqual` would wave through (تعميم المجلس ٦).
|
|
1235
|
+
*
|
|
1236
|
+
* **A blocked answer takes its thinking with it** (ت١/٢ item 3). The output
|
|
1237
|
+
* gate validates the TEXT; it never reads `thoughts`. So when the gate says
|
|
1238
|
+
* `block` — the outcome is `suppressed` and the text is the canned line — the
|
|
1239
|
+
* reasoning that produced the blocked answer is dropped too: what the gate
|
|
1240
|
+
* would not let out as an answer does not leave as notes. On every other
|
|
1241
|
+
* verdict (`release`, `review`, or no validator, including a gate that never
|
|
1242
|
+
* ran because the text was empty) `thoughts` passes UNGATED — it is audit
|
|
1243
|
+
* material for the host, declared as such on `LoopOutcome.thoughts`, and not
|
|
1244
|
+
* a second answer for an end user.
|
|
1245
|
+
*/
|
|
1246
|
+
function thoughtsOf(payload, validation) {
|
|
1247
|
+
if (validation?.action === "block")
|
|
1248
|
+
return {};
|
|
1249
|
+
return typeof payload?.thoughts === "string" && payload.thoughts !== ""
|
|
1250
|
+
? { thoughts: payload.thoughts }
|
|
1251
|
+
: {};
|
|
1252
|
+
}
|
|
1214
1253
|
function makeOutcome(type, sessionId, taskId, state, started, extra = {}) {
|
|
1215
1254
|
return {
|
|
1216
1255
|
type,
|
package/dist/core/types.d.ts
CHANGED
|
@@ -360,6 +360,23 @@ export interface BrainPayload {
|
|
|
360
360
|
* falsehood this field exists to stop telling.
|
|
361
361
|
*/
|
|
362
362
|
model?: string;
|
|
363
|
+
/**
|
|
364
|
+
* The model's THINKING for this call, separated from its answer (ت١).
|
|
365
|
+
*
|
|
366
|
+
* Filled by a brain whose provider hands reasoning back tagged as such —
|
|
367
|
+
* Gemini's parts marked `thought: true`, Claude's `thinking` blocks — joined
|
|
368
|
+
* in emitted order and kept OUT of `generation.response_text`. Before this
|
|
369
|
+
* field the Gemini brain took the first text part as the answer, and with
|
|
370
|
+
* thinking on, the first text part was the reasoning: the user read the
|
|
371
|
+
* model's notes instead of its reply.
|
|
372
|
+
*
|
|
373
|
+
* **Optional, and absent means absent.** A call with no thinking carries no
|
|
374
|
+
* key at all — never `""` — so `"thoughts" in payload` is the honest test.
|
|
375
|
+
* The loop copies the LAST step's value to `LoopOutcome.thoughts` and does
|
|
376
|
+
* not write it to the session log: it is an output the model never sees
|
|
377
|
+
* again, so the rule "what the model sees is recorded" does not reach it.
|
|
378
|
+
*/
|
|
379
|
+
thoughts?: string;
|
|
363
380
|
[key: string]: unknown;
|
|
364
381
|
}
|
|
365
382
|
/**
|
|
@@ -480,6 +497,21 @@ export interface LoopOutcome {
|
|
|
480
497
|
terminatedBy?: GuardSignalType;
|
|
481
498
|
/** Output-gate result, when a validator is configured. */
|
|
482
499
|
validation?: OutputValidation;
|
|
500
|
+
/**
|
|
501
|
+
* The thinking behind the answer, when the last model step reported any
|
|
502
|
+
* (`BrainPayload.thoughts`, ت١). Present only then — a run whose brain never
|
|
503
|
+
* separated thinking carries no key, exactly as before the field existed.
|
|
504
|
+
*
|
|
505
|
+
* **Ungated audit material — not for an end user without the host's
|
|
506
|
+
* filter** (ت١/٢ item 3). The output gate validates `text` and never reads
|
|
507
|
+
* this field. It is DROPPED when the gate blocks the answer (`type:
|
|
508
|
+
* "suppressed"`) — what could not go out as an answer does not go out as
|
|
509
|
+
* notes — and on every other verdict it passes as the model wrote it,
|
|
510
|
+
* including when the gate never ran because the text was empty. It also
|
|
511
|
+
* accompanies only text the model wrote: a guard exit that falls back to
|
|
512
|
+
* the canned "unable to complete" line carries no `thoughts`.
|
|
513
|
+
*/
|
|
514
|
+
thoughts?: string;
|
|
483
515
|
error?: string;
|
|
484
516
|
}
|
|
485
517
|
/**
|
|
@@ -1002,6 +1034,20 @@ export interface AgentDefinition {
|
|
|
1002
1034
|
model: string;
|
|
1003
1035
|
endpoint?: string;
|
|
1004
1036
|
apiKey?: string;
|
|
1037
|
+
/**
|
|
1038
|
+
* Thinking, as `## Brain` spells it (ت١/٢): `thinking.budget` and
|
|
1039
|
+
* `thinking.includeThoughts` — flat dotted keys, parsed into this object;
|
|
1040
|
+
* neither key → no `thinking` at all. `buildBrain` hands it to
|
|
1041
|
+
* `createGeminiBrain({ thinking })` after checking its shape (an object;
|
|
1042
|
+
* `budget` a finite number ≥ 0 when given; `includeThoughts` a boolean
|
|
1043
|
+
* when given — anything else is a named error at composition, not
|
|
1044
|
+
* silence). Only the Gemini brain consumes it in this release; the other
|
|
1045
|
+
* providers do not read the key. An empty object is absence.
|
|
1046
|
+
*/
|
|
1047
|
+
thinking?: {
|
|
1048
|
+
budget?: number;
|
|
1049
|
+
includeThoughts?: boolean;
|
|
1050
|
+
};
|
|
1005
1051
|
[key: string]: unknown;
|
|
1006
1052
|
};
|
|
1007
1053
|
capabilities: string[];
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Supported sections: Persona, Brain, Capabilities, Limits, Sections
|
|
5
5
|
* Stripped: Equipment, Skills, Hours, Memory rules (owned by the app layer)
|
|
6
|
+
*
|
|
7
|
+
* `## Brain` additionally reads `thinking.budget` and `thinking.includeThoughts`
|
|
8
|
+
* (flat dotted keys) into `brain.thinking` (ت١/٢).
|
|
6
9
|
*/
|
|
7
10
|
import type { AgentDefinition } from "../core/types.js";
|
|
8
11
|
/**
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Supported sections: Persona, Brain, Capabilities, Limits, Sections
|
|
5
5
|
* Stripped: Equipment, Skills, Hours, Memory rules (owned by the app layer)
|
|
6
|
+
*
|
|
7
|
+
* `## Brain` additionally reads `thinking.budget` and `thinking.includeThoughts`
|
|
8
|
+
* (flat dotted keys) into `brain.thinking` (ت١/٢).
|
|
6
9
|
*/
|
|
7
10
|
import { readFileSync } from "fs";
|
|
8
11
|
/**
|
|
@@ -34,6 +37,7 @@ function parseMd(md) {
|
|
|
34
37
|
const personaName = extractKey(persona, "name") ?? extractKey(persona, "Name");
|
|
35
38
|
const personaStyle = extractKey(persona, "style") ?? extractKey(persona, "Style");
|
|
36
39
|
const brainEndpoint = brain["endpoint"] ?? brain["Endpoint"];
|
|
40
|
+
const brainThinking = parseBrainThinking(brain);
|
|
37
41
|
const domain = meta["domain"] ?? meta["Domain"];
|
|
38
42
|
const language = meta["language"] ?? meta["Language"];
|
|
39
43
|
return {
|
|
@@ -50,6 +54,7 @@ function parseMd(md) {
|
|
|
50
54
|
"openai"),
|
|
51
55
|
model: brain["model"] ?? brain["Model"] ?? "gpt-4o-mini",
|
|
52
56
|
...(brainEndpoint !== undefined ? { endpoint: brainEndpoint } : {}),
|
|
57
|
+
...(brainThinking !== undefined ? { thinking: brainThinking } : {}),
|
|
53
58
|
},
|
|
54
59
|
capabilities,
|
|
55
60
|
limits: {
|
|
@@ -107,8 +112,54 @@ function parseSection(lines, sectionName) {
|
|
|
107
112
|
}
|
|
108
113
|
return result;
|
|
109
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* `## Brain` accepts DOTTED keys (`thinking.budget: 1024`) on top of the plain
|
|
117
|
+
* ones every section accepts — that is how a nested option is written flat in
|
|
118
|
+
* a definition file (ت١/٢). The other sections keep the plain-key grammar; a
|
|
119
|
+
* dotted line there is ignored exactly as it was.
|
|
120
|
+
*/
|
|
110
121
|
function parseBrainSection(lines) {
|
|
111
|
-
|
|
122
|
+
const result = {};
|
|
123
|
+
for (const line of getSectionLines(lines, "Brain")) {
|
|
124
|
+
const m = /^([A-Za-z]+(?:\.[A-Za-z]+)*)\s*:\s*(.+)$/.exec(line.trim());
|
|
125
|
+
if (m && m[1] && m[2])
|
|
126
|
+
result[m[1]] = m[2].trim();
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* `thinking.budget` / `thinking.includeThoughts` → `brain.thinking`, or
|
|
132
|
+
* `undefined` when neither key was written (then the definition has no
|
|
133
|
+
* `thinking` key at all, and `buildBrain` composes yesterday's brain).
|
|
134
|
+
*
|
|
135
|
+
* A value that does not parse is a NAMED error, not a fallback: `limits` may
|
|
136
|
+
* fall back to a default because a default exists, but there is no default
|
|
137
|
+
* thinking budget to fall back to, and a typo that silently switched the
|
|
138
|
+
* feature off is the silence this key was added to end. `budget` must be a
|
|
139
|
+
* finite number ≥ 0 (`0` is a value — thinking OFF); `includeThoughts` must
|
|
140
|
+
* be `true` or `false`.
|
|
141
|
+
*/
|
|
142
|
+
function parseBrainThinking(brain) {
|
|
143
|
+
const rawBudget = brain["thinking.budget"] ?? brain["Thinking.budget"];
|
|
144
|
+
const rawInclude = brain["thinking.includeThoughts"] ?? brain["Thinking.includeThoughts"];
|
|
145
|
+
if (rawBudget === undefined && rawInclude === undefined)
|
|
146
|
+
return undefined;
|
|
147
|
+
const thinking = {};
|
|
148
|
+
if (rawBudget !== undefined) {
|
|
149
|
+
const budget = Number(rawBudget);
|
|
150
|
+
if (!Number.isFinite(budget) || budget < 0) {
|
|
151
|
+
throw new Error(`msm-mini: ## Brain thinking.budget must be a finite number >= 0, got "${rawBudget}"`);
|
|
152
|
+
}
|
|
153
|
+
thinking.budget = budget;
|
|
154
|
+
}
|
|
155
|
+
if (rawInclude !== undefined) {
|
|
156
|
+
const lowered = rawInclude.toLowerCase();
|
|
157
|
+
if (lowered !== "true" && lowered !== "false") {
|
|
158
|
+
throw new Error(`msm-mini: ## Brain thinking.includeThoughts must be true or false, got "${rawInclude}"`);
|
|
159
|
+
}
|
|
160
|
+
thinking.includeThoughts = lowered === "true";
|
|
161
|
+
}
|
|
162
|
+
return thinking;
|
|
112
163
|
}
|
|
113
164
|
function parseListSection(lines, sectionName) {
|
|
114
165
|
return getSectionLines(lines, sectionName)
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export { createAgent } from "./core/loop.js";
|
|
|
10
10
|
export { createBrainCompactor } from "./core/loop.js";
|
|
11
11
|
export type { BrainCompactorOptions } from "./core/loop.js";
|
|
12
12
|
export { createGeminiBrain } from "./brain/gemini.js";
|
|
13
|
+
export type { GeminiBrainOptions } from "./brain/gemini.js";
|
|
13
14
|
export { createOpenAIBrain } from "./brain/openai.js";
|
|
14
15
|
export { createAnthropicBrain } from "./brain/anthropic.js";
|
|
15
16
|
export { createOllamaBrain } from "./brain/ollama.js";
|