@msm-core/mini 0.5.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/dist/adapters/index.d.ts +4 -0
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/memory-store.d.ts +38 -0
- package/dist/adapters/memory-store.js +73 -0
- package/dist/adapters/redis-memory.d.ts +13 -8
- package/dist/adapters/redis-memory.js +5 -0
- package/dist/brain/anthropic.js +50 -17
- package/dist/brain/gemini.js +68 -35
- package/dist/brain/ollama.js +68 -19
- package/dist/brain/openai.js +57 -23
- package/dist/brain/pricing.js +1 -0
- package/dist/brain/streaming.d.ts +315 -0
- package/dist/brain/streaming.js +439 -0
- package/dist/brain/tool-context.d.ts +50 -2
- package/dist/brain/tool-context.js +88 -0
- package/dist/core/context-builder.d.ts +11 -0
- package/dist/core/context-builder.js +11 -1
- package/dist/core/hooks.d.ts +10 -0
- package/dist/core/hooks.js +14 -0
- package/dist/core/loop.d.ts +43 -1
- package/dist/core/loop.js +749 -98
- package/dist/core/types.d.ts +223 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -0
- package/package.json +11 -11
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ Follows [Semantic Versioning](https://semver.org/).
|
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
+
## [0.5.2] — 2026-07-01
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **The Gemini brain now reports token usage + cost on the tool-call path.** It
|
|
13
|
+
previously returned a bare `{ orchestration }` there (only the text path carried
|
|
14
|
+
usage/cost), so the loop's `costCapPerTask` accrued $0 on exactly the iterations
|
|
15
|
+
that dominate an agentic run — the cost cap never fired on the primary provider.
|
|
16
|
+
Both paths now compute usage + cost once, via the shared pricing table
|
|
17
|
+
(`computeCostUsd`) instead of hardcoded Flash rates, so non-Flash models bill
|
|
18
|
+
correctly. Locked with `tests/gemini-usage.test.ts`.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Pricing table entry for `gemini-2.5-pro` (Pro agents previously had no cost tracking).
|
|
23
|
+
|
|
24
|
+
## [0.5.1] — 2026-06-30
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Array tool-parameters.** `ToolParameter.items` is now forwarded to every
|
|
29
|
+
provider (Gemini included), so an array-typed tool parameter (e.g. a list of
|
|
30
|
+
template variables) is described to the model instead of being dropped — which
|
|
31
|
+
had caused arrays to 400 on Gemini.
|
|
32
|
+
|
|
8
33
|
## [0.5.0] — 2026-06-30
|
|
9
34
|
|
|
10
35
|
Brain-parity + guard-integrity release from the 2026-06 SDK audit (H1, H2, H10, M1).
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -8,5 +8,9 @@ export { RedisDistributedLock } from "./redis-lock.js";
|
|
|
8
8
|
export type { LockHandle } from "./redis-lock.js";
|
|
9
9
|
/** Correct single-node RedisLike for local / no-Redis / sovereign deploys + tests. */
|
|
10
10
|
export { InMemoryRedis, createInMemoryRedis } from "./memory-redis.js";
|
|
11
|
+
/** In-RAM SessionStore — the memory port with no Redis and no network. */
|
|
12
|
+
export { InMemorySessionStore } from "./memory-store.js";
|
|
11
13
|
/** The client shape RedisConfig.client expects — for wiring a custom client. */
|
|
12
14
|
export type { RedisLike } from "./redis-types.js";
|
|
15
|
+
/** The port `AgentConfig.memory` accepts — for writing a custom session store. */
|
|
16
|
+
export type { SessionStore } from "../core/types.js";
|
package/dist/adapters/index.js
CHANGED
|
@@ -6,3 +6,5 @@ export { RedisControlBus } from "./redis-control.js";
|
|
|
6
6
|
export { RedisDistributedLock } from "./redis-lock.js";
|
|
7
7
|
/** Correct single-node RedisLike for local / no-Redis / sovereign deploys + tests. */
|
|
8
8
|
export { InMemoryRedis, createInMemoryRedis } from "./memory-redis.js";
|
|
9
|
+
/** In-RAM SessionStore — the memory port with no Redis and no network. */
|
|
10
|
+
export { InMemorySessionStore } from "./memory-store.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory SessionStore — the memory port with no Redis and no network.
|
|
3
|
+
*
|
|
4
|
+
* The bundled counterpart to `RedisMemory`: a complete implementation of the
|
|
5
|
+
* six `SessionStore` functions, held in process memory. It exists because until
|
|
6
|
+
* now the loop built its store by hand from a Redis config, so a test — or a
|
|
7
|
+
* local / air-gapped deploy — could not run the loop without a live Redis for
|
|
8
|
+
* session memory.
|
|
9
|
+
*
|
|
10
|
+
* Fidelity to `RedisMemory` is deliberate, so swapping one for the other does
|
|
11
|
+
* not change loop behaviour:
|
|
12
|
+
* - `getHistory` returns the LAST `limit` entries, oldest-first (`lrange -limit -1`).
|
|
13
|
+
* - history is capped at `maxHistoryEntries` (default 500), oldest dropped
|
|
14
|
+
* first — the same silent head-loss the Redis adapter has via `ltrim`.
|
|
15
|
+
* - reads and writes are deep-copied, mirroring the JSON round-trip a real
|
|
16
|
+
* store performs, so a caller holding a returned object can never mutate
|
|
17
|
+
* what is stored (the loop does mutate the DocumentState it reads back).
|
|
18
|
+
*
|
|
19
|
+
* Scope: ONE process, no expiry. TTLs are a persistence concern; nothing here
|
|
20
|
+
* outlives the process, so nothing needs evicting. For multi-replica deploys
|
|
21
|
+
* and for durability across restarts, use `RedisMemory`.
|
|
22
|
+
*/
|
|
23
|
+
import type { DocumentState, Message, SessionMetadata, SessionStore } from "../core/types.js";
|
|
24
|
+
export declare class InMemorySessionStore implements SessionStore {
|
|
25
|
+
private readonly sessions;
|
|
26
|
+
private readonly maxHistoryEntries;
|
|
27
|
+
constructor(opts?: {
|
|
28
|
+
/** Hard cap on stored history entries per session (default: 500). */
|
|
29
|
+
maxHistoryEntries?: number;
|
|
30
|
+
});
|
|
31
|
+
private slot;
|
|
32
|
+
appendHistory(sessionId: string, entry: Message): Promise<void>;
|
|
33
|
+
getHistory(sessionId: string, limit?: number): Promise<Message[]>;
|
|
34
|
+
getMetadata(sessionId: string): Promise<SessionMetadata | null>;
|
|
35
|
+
setMetadata(sessionId: string, meta: SessionMetadata): Promise<void>;
|
|
36
|
+
getDocumentState(sessionId: string): Promise<DocumentState | null>;
|
|
37
|
+
setDocumentState(sessionId: string, state: DocumentState): Promise<void>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory SessionStore — the memory port with no Redis and no network.
|
|
3
|
+
*
|
|
4
|
+
* The bundled counterpart to `RedisMemory`: a complete implementation of the
|
|
5
|
+
* six `SessionStore` functions, held in process memory. It exists because until
|
|
6
|
+
* now the loop built its store by hand from a Redis config, so a test — or a
|
|
7
|
+
* local / air-gapped deploy — could not run the loop without a live Redis for
|
|
8
|
+
* session memory.
|
|
9
|
+
*
|
|
10
|
+
* Fidelity to `RedisMemory` is deliberate, so swapping one for the other does
|
|
11
|
+
* not change loop behaviour:
|
|
12
|
+
* - `getHistory` returns the LAST `limit` entries, oldest-first (`lrange -limit -1`).
|
|
13
|
+
* - history is capped at `maxHistoryEntries` (default 500), oldest dropped
|
|
14
|
+
* first — the same silent head-loss the Redis adapter has via `ltrim`.
|
|
15
|
+
* - reads and writes are deep-copied, mirroring the JSON round-trip a real
|
|
16
|
+
* store performs, so a caller holding a returned object can never mutate
|
|
17
|
+
* what is stored (the loop does mutate the DocumentState it reads back).
|
|
18
|
+
*
|
|
19
|
+
* Scope: ONE process, no expiry. TTLs are a persistence concern; nothing here
|
|
20
|
+
* outlives the process, so nothing needs evicting. For multi-replica deploys
|
|
21
|
+
* and for durability across restarts, use `RedisMemory`.
|
|
22
|
+
*/
|
|
23
|
+
/** Deep copy through JSON — the same serialization boundary a real store has. */
|
|
24
|
+
function clone(value) {
|
|
25
|
+
return JSON.parse(JSON.stringify(value));
|
|
26
|
+
}
|
|
27
|
+
export class InMemorySessionStore {
|
|
28
|
+
sessions = new Map();
|
|
29
|
+
maxHistoryEntries;
|
|
30
|
+
constructor(opts = {}) {
|
|
31
|
+
this.maxHistoryEntries = opts.maxHistoryEntries ?? 500;
|
|
32
|
+
}
|
|
33
|
+
slot(sessionId) {
|
|
34
|
+
let slot = this.sessions.get(sessionId);
|
|
35
|
+
if (!slot) {
|
|
36
|
+
slot = { history: [], metadata: null, document: null };
|
|
37
|
+
this.sessions.set(sessionId, slot);
|
|
38
|
+
}
|
|
39
|
+
return slot;
|
|
40
|
+
}
|
|
41
|
+
// ─── History ──────────────────────────────────────────────
|
|
42
|
+
async appendHistory(sessionId, entry) {
|
|
43
|
+
const slot = this.slot(sessionId);
|
|
44
|
+
slot.history.push(clone(entry));
|
|
45
|
+
// Cap storage growth for chatty sessions, exactly as RedisMemory's ltrim does.
|
|
46
|
+
if (slot.history.length > this.maxHistoryEntries) {
|
|
47
|
+
slot.history.splice(0, slot.history.length - this.maxHistoryEntries);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async getHistory(sessionId, limit = 50) {
|
|
51
|
+
const history = this.sessions.get(sessionId)?.history ?? [];
|
|
52
|
+
// `lrange(key, -limit, -1)`: the tail of `limit` entries. A limit of 0 (or
|
|
53
|
+
// below) means "no tail window" and returns everything, as Redis does.
|
|
54
|
+
const start = limit > 0 ? Math.max(history.length - limit, 0) : 0;
|
|
55
|
+
return history.slice(start).map(clone);
|
|
56
|
+
}
|
|
57
|
+
// ─── Metadata ─────────────────────────────────────────────
|
|
58
|
+
async getMetadata(sessionId) {
|
|
59
|
+
const meta = this.sessions.get(sessionId)?.metadata;
|
|
60
|
+
return meta ? clone(meta) : null;
|
|
61
|
+
}
|
|
62
|
+
async setMetadata(sessionId, meta) {
|
|
63
|
+
this.slot(sessionId).metadata = clone(meta);
|
|
64
|
+
}
|
|
65
|
+
// ─── Document State (section tracker) ────────────────────
|
|
66
|
+
async getDocumentState(sessionId) {
|
|
67
|
+
const doc = this.sessions.get(sessionId)?.document;
|
|
68
|
+
return doc ? clone(doc) : null;
|
|
69
|
+
}
|
|
70
|
+
async setDocumentState(sessionId, state) {
|
|
71
|
+
this.slot(sessionId).document = clone(state);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -9,15 +9,20 @@
|
|
|
9
9
|
* {prefix}:session:{id}:metadata JSON object, TTL 24h
|
|
10
10
|
* {prefix}:session:{id}:document JSON object, TTL 4h
|
|
11
11
|
*/
|
|
12
|
-
import type { Message, DocumentState } from "../core/types.js";
|
|
12
|
+
import type { Message, DocumentState, SessionMetadata, SessionStore } from "../core/types.js";
|
|
13
13
|
import type { RedisLike } from "./redis-types.js";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
14
|
+
/**
|
|
15
|
+
* `SessionMetadata` now lives in core/types.ts with the `SessionStore` port it
|
|
16
|
+
* belongs to. Re-exported here so `import { SessionMetadata } from
|
|
17
|
+
* "@msm-core/mini/adapters"` — and every existing deep import — keeps working.
|
|
18
|
+
*/
|
|
19
|
+
export type { SessionMetadata };
|
|
20
|
+
/**
|
|
21
|
+
* The Redis implementation of the `SessionStore` port. The `implements` clause
|
|
22
|
+
* is load-bearing: it makes the compiler break on any future drift between this
|
|
23
|
+
* adapter and the port the loop programs against.
|
|
24
|
+
*/
|
|
25
|
+
export declare class RedisMemory implements SessionStore {
|
|
21
26
|
private readonly redis;
|
|
22
27
|
private readonly prefix;
|
|
23
28
|
private readonly historyTtl;
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* {prefix}:session:{id}:metadata JSON object, TTL 24h
|
|
10
10
|
* {prefix}:session:{id}:document JSON object, TTL 4h
|
|
11
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* The Redis implementation of the `SessionStore` port. The `implements` clause
|
|
14
|
+
* is load-bearing: it makes the compiler break on any future drift between this
|
|
15
|
+
* adapter and the port the loop programs against.
|
|
16
|
+
*/
|
|
12
17
|
export class RedisMemory {
|
|
13
18
|
redis;
|
|
14
19
|
prefix;
|
package/dist/brain/anthropic.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { computeCostUsd } from "./pricing.js";
|
|
6
6
|
import { withRetry } from "./retry.js";
|
|
7
|
-
import { foldToolResults, toolParamsToJsonSchema } from "./tool-context.js";
|
|
7
|
+
import { foldToolResults, toolParamsToJsonSchema, toWireMessage, useToolOrchestration, } from "./tool-context.js";
|
|
8
|
+
import { accumulateAnthropic, anthropicDelta, consumeStream, } from "./streaming.js";
|
|
8
9
|
export function createAnthropicBrain(opts) {
|
|
9
10
|
// claude-3-5-sonnet-20241022 was retired (2025-10-28); claude-sonnet-4-6 is
|
|
10
11
|
// its current drop-in replacement.
|
|
@@ -24,10 +25,13 @@ export function createAnthropicBrain(opts) {
|
|
|
24
25
|
if (!apiKey)
|
|
25
26
|
throw new Error("msm-mini: ANTHROPIC_API_KEY not set");
|
|
26
27
|
const client = new Anthropic({ apiKey });
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
// Derived tool calls / tool results arrive TAGGED rather than flattened
|
|
29
|
+
// into anonymous `user` turns — see the wire contract in tool-context.ts.
|
|
30
|
+
// Claude read its own tool round-trips as the user's words before this.
|
|
31
|
+
const messages = input.history.map((m) => {
|
|
32
|
+
const wire = toWireMessage(m);
|
|
33
|
+
return { role: wire.role, content: wire.content };
|
|
34
|
+
});
|
|
31
35
|
// Fold prior tool results into the current turn so Claude sees what its
|
|
32
36
|
// tool calls returned (and is nudged to finalize) — without this it
|
|
33
37
|
// re-emits use_tool every iteration until the guard cap (H1).
|
|
@@ -46,25 +50,39 @@ export function createAnthropicBrain(opts) {
|
|
|
46
50
|
system: input.system_context,
|
|
47
51
|
messages: messages,
|
|
48
52
|
};
|
|
49
|
-
const
|
|
53
|
+
const params = tools
|
|
50
54
|
? {
|
|
51
55
|
...baseParams,
|
|
52
56
|
tools: tools,
|
|
53
57
|
}
|
|
54
|
-
: baseParams
|
|
58
|
+
: baseParams;
|
|
59
|
+
// ── The one branch streaming adds ──────────────────────────────────
|
|
60
|
+
//
|
|
61
|
+
// No `onChunk`, no stream: the call below is the call this brain has
|
|
62
|
+
// always made, and every line after this block is untouched. Both paths
|
|
63
|
+
// land in `AnthropicMessage` — the fields this brain reads — so the
|
|
64
|
+
// payload is built once, from one shape.
|
|
65
|
+
const response = input.onChunk
|
|
66
|
+
? await withRetry(() => streamMessage(client, params, input.onChunk, input.signal), input.signal ? { signal: input.signal } : {})
|
|
67
|
+
: await withRetry(() => client.messages.create(params, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {});
|
|
55
68
|
const content = response.content;
|
|
56
69
|
const inputTokens = response.usage.input_tokens;
|
|
57
70
|
const outputTokens = response.usage.output_tokens;
|
|
58
71
|
const costUsd = computeCostUsd(model, inputTokens, outputTokens);
|
|
59
|
-
// Tool use
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
// Tool use — EVERY `tool_use` block, in the order Claude emitted them.
|
|
73
|
+
// `.find()` kept the first and dropped the rest, and Claude emits parallel
|
|
74
|
+
// blocks routinely: each dropped block cost a full extra round-trip.
|
|
75
|
+
const calls = [];
|
|
76
|
+
for (const block of content) {
|
|
77
|
+
if (block.type !== "tool_use")
|
|
78
|
+
continue;
|
|
79
|
+
calls.push({
|
|
80
|
+
name: block.name ?? "",
|
|
81
|
+
params: (block.input ?? {}),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const orchestration = useToolOrchestration(calls, 0.9);
|
|
85
|
+
if (orchestration) {
|
|
68
86
|
return {
|
|
69
87
|
orchestration,
|
|
70
88
|
usage: { inputTokens, outputTokens },
|
|
@@ -73,7 +91,7 @@ export function createAnthropicBrain(opts) {
|
|
|
73
91
|
}
|
|
74
92
|
// Text
|
|
75
93
|
const textBlock = content.find((b) => b.type === "text");
|
|
76
|
-
const text = textBlock
|
|
94
|
+
const text = textBlock?.text ?? "";
|
|
77
95
|
return {
|
|
78
96
|
generation: { response_text: text },
|
|
79
97
|
orchestration: { action: "respond", confidence: 0.95 },
|
|
@@ -83,3 +101,18 @@ export function createAnthropicBrain(opts) {
|
|
|
83
101
|
},
|
|
84
102
|
};
|
|
85
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* The same request with `stream: true`, read to the end, folded back into the
|
|
106
|
+
* message shape the caller above expects.
|
|
107
|
+
*
|
|
108
|
+
* Claude's stream is block-addressed rather than linear — `content_block_start`
|
|
109
|
+
* opens a block, deltas name its index, `content_block_stop` closes it — so the
|
|
110
|
+
* accumulator rebuilds blocks by index rather than by arrival. Text deltas are
|
|
111
|
+
* emitted; `input_json_delta` fragments of a tool call are not (v1 is text
|
|
112
|
+
* only), and thinking deltas are not text and are emitted by nobody.
|
|
113
|
+
*/
|
|
114
|
+
async function streamMessage(client, params, sink, signal) {
|
|
115
|
+
const stream = await client.messages.create({ ...params, stream: true }, signal ? { signal } : {});
|
|
116
|
+
const events = await consumeStream(stream, anthropicDelta, sink, signal);
|
|
117
|
+
return accumulateAnthropic(events);
|
|
118
|
+
}
|
package/dist/brain/gemini.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* Peer dependency: @google/generative-ai >= 0.14.0
|
|
4
4
|
*/
|
|
5
5
|
import { withRetry } from "./retry.js";
|
|
6
|
-
import { foldToolResults } from "./tool-context.js";
|
|
6
|
+
import { foldToolResults, toWireMessage, useToolOrchestration, } from "./tool-context.js";
|
|
7
|
+
import { accumulateGemini, consumeStream, geminiDelta, } from "./streaming.js";
|
|
8
|
+
import { computeCostUsd } from "./pricing.js";
|
|
7
9
|
export function createGeminiBrain(opts) {
|
|
8
10
|
const model = opts.model ?? "gemini-2.5-flash";
|
|
9
11
|
return {
|
|
@@ -59,55 +61,86 @@ export function createGeminiBrain(opts) {
|
|
|
59
61
|
tools: tools,
|
|
60
62
|
}
|
|
61
63
|
: { systemInstruction: input.system_context, contents };
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
// ── The one branch streaming adds ──────────────────────────────────
|
|
65
|
+
//
|
|
66
|
+
// No `onChunk`, no stream: the call below is the call this brain has
|
|
67
|
+
// always made, and every line after this block is untouched. Both paths
|
|
68
|
+
// land in `GeminiResponse` — the fields this brain reads — so the payload
|
|
69
|
+
// is built once, from one shape.
|
|
70
|
+
const response = input.onChunk
|
|
71
|
+
? await withRetry(() => streamContent(() => geminiModel.generateContentStream(request, input.signal ? { signal: input.signal } : {}), input.onChunk, input.signal), input.signal ? { signal: input.signal } : {})
|
|
72
|
+
: (await withRetry(() => geminiModel.generateContent(request, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {})).response;
|
|
64
73
|
const parts = response?.candidates?.[0]?.content?.parts ?? [];
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
74
|
+
// Token usage + cost — computed ONCE and returned on BOTH the tool-call and text
|
|
75
|
+
// paths. Most iterations of an agentic run are tool calls, so omitting usage/cost
|
|
76
|
+
// there (as before) left the loop's cost cap seeing $0 and never firing. Price via
|
|
77
|
+
// the shared table (not hardcoded Flash rates) so gemini-2.5-pro bills correctly.
|
|
78
|
+
const usageMeta = response?.usageMetadata;
|
|
79
|
+
const inputTokens = usageMeta?.promptTokenCount;
|
|
80
|
+
const outputTokens = usageMeta?.candidatesTokenCount;
|
|
81
|
+
const usage = inputTokens !== undefined || outputTokens !== undefined
|
|
82
|
+
? {
|
|
83
|
+
...(inputTokens !== undefined ? { inputTokens } : {}),
|
|
84
|
+
...(outputTokens !== undefined ? { outputTokens } : {}),
|
|
85
|
+
}
|
|
86
|
+
: undefined;
|
|
87
|
+
const costUsd = computeCostUsd(model, inputTokens, outputTokens);
|
|
88
|
+
// Function calls — EVERY `functionCall` part, in emitted order. Gemini
|
|
89
|
+
// returns parallel calls as several parts of one candidate; `.find()`
|
|
90
|
+
// kept the first and paid for the rest with extra round-trips.
|
|
91
|
+
const calls = [];
|
|
92
|
+
for (const part of parts) {
|
|
93
|
+
if (!("functionCall" in part) || !part.functionCall)
|
|
94
|
+
continue;
|
|
95
|
+
const fc = part.functionCall;
|
|
96
|
+
calls.push({
|
|
97
|
+
name: fc.name,
|
|
98
|
+
params: (fc.args ?? {}),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const orchestration = useToolOrchestration(calls, 0.9);
|
|
102
|
+
if (orchestration) {
|
|
103
|
+
return { orchestration, costUsd, ...(usage ? { usage } : {}) };
|
|
78
104
|
}
|
|
79
105
|
// Text response
|
|
80
106
|
const textPart = parts.find((p) => "text" in p && typeof p.text === "string");
|
|
81
107
|
const text = textPart && "text" in textPart ? textPart.text : "";
|
|
82
|
-
// Token usage
|
|
83
|
-
const usage = response?.usageMetadata;
|
|
84
|
-
const inputTokens = usage?.promptTokenCount;
|
|
85
|
-
const outputTokens = usage?.candidatesTokenCount;
|
|
86
108
|
return {
|
|
87
109
|
generation: { response_text: text },
|
|
88
110
|
orchestration: { action: "respond", confidence: 0.95 },
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
? (usage.promptTokenCount ?? 0) * 0.000_000_075 +
|
|
92
|
-
(usage.candidatesTokenCount ?? 0) * 0.0000003
|
|
93
|
-
: 0,
|
|
94
|
-
...(inputTokens !== undefined || outputTokens !== undefined
|
|
95
|
-
? {
|
|
96
|
-
usage: {
|
|
97
|
-
...(inputTokens !== undefined ? { inputTokens } : {}),
|
|
98
|
-
...(outputTokens !== undefined ? { outputTokens } : {}),
|
|
99
|
-
},
|
|
100
|
-
}
|
|
101
|
-
: {}),
|
|
111
|
+
costUsd,
|
|
112
|
+
...(usage ? { usage } : {}),
|
|
102
113
|
};
|
|
103
114
|
},
|
|
104
115
|
};
|
|
105
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* `generateContentStream` read to the end, folded back into the response shape
|
|
119
|
+
* the caller above expects.
|
|
120
|
+
*
|
|
121
|
+
* The SDK hands back BOTH an iterable `stream` and an already-aggregated
|
|
122
|
+
* `response` promise — and this deliberately ignores the second. The fold that
|
|
123
|
+
* runs in production has to be the fold the tests exercise; taking the SDK's
|
|
124
|
+
* aggregate instead would leave `accumulateGemini` proven and unused, which is
|
|
125
|
+
* the same as unproven.
|
|
126
|
+
*
|
|
127
|
+
* It takes the call as a thunk rather than the model, so `withRetry` re-opens a
|
|
128
|
+
* fresh stream on a retry instead of re-reading a drained one.
|
|
129
|
+
*/
|
|
130
|
+
async function streamContent(open, sink, signal) {
|
|
131
|
+
const result = await open();
|
|
132
|
+
const items = await consumeStream(result.stream, geminiDelta, sink, signal);
|
|
133
|
+
return accumulateGemini(items);
|
|
134
|
+
}
|
|
106
135
|
function buildGeminiContents(input) {
|
|
107
136
|
const contents = [];
|
|
137
|
+
// Derived tool calls / tool results arrive TAGGED rather than flattened into
|
|
138
|
+
// anonymous `user` turns — see the wire contract in tool-context.ts. Gemini is
|
|
139
|
+
// the one provider that renames the role: `assistant` is spelled `model`.
|
|
108
140
|
for (const msg of input.history) {
|
|
109
|
-
const
|
|
110
|
-
|
|
141
|
+
const wire = toWireMessage(msg);
|
|
142
|
+
const role = wire.role === "assistant" ? "model" : "user";
|
|
143
|
+
contents.push({ role, parts: [{ text: wire.content }] });
|
|
111
144
|
}
|
|
112
145
|
// Current user message — shared fold + finalize nudge (parity with all brains).
|
|
113
146
|
const userText = foldToolResults(input.raw, input.tool_results);
|
package/dist/brain/ollama.js
CHANGED
|
@@ -3,16 +3,27 @@
|
|
|
3
3
|
* No peer dependencies — plain HTTP fetch.
|
|
4
4
|
*/
|
|
5
5
|
import { withRetry } from "./retry.js";
|
|
6
|
-
import { foldToolResults, toolParamsToJsonSchema } from "./tool-context.js";
|
|
6
|
+
import { foldToolResults, toolParamsToJsonSchema, toWireMessage, useToolOrchestration, } from "./tool-context.js";
|
|
7
|
+
import { accumulateOllama, consumeNdjson, } from "./streaming.js";
|
|
7
8
|
export function createOllamaBrain(opts) {
|
|
8
9
|
const endpoint = opts.endpoint ?? process.env["OLLAMA_ENDPOINT"] ?? "http://localhost:11434";
|
|
9
10
|
const model = opts.model ?? "llama3.2";
|
|
10
11
|
return {
|
|
11
12
|
name: "ollama",
|
|
12
13
|
async run(input) {
|
|
14
|
+
// Ollama is the one adapter that passed roles through verbatim, so a
|
|
15
|
+
// derived `role: "tool"` message went out as a bare `tool` turn with no
|
|
16
|
+
// native pairing — which no /api/chat backend can honour. The two DERIVED
|
|
17
|
+
// tool roles are tagged (the same text the other three send); every other
|
|
18
|
+
// role still passes through untouched.
|
|
13
19
|
const messages = [
|
|
14
20
|
{ role: "system", content: input.system_context },
|
|
15
|
-
...input.history.map((m) =>
|
|
21
|
+
...input.history.map((m) => {
|
|
22
|
+
const wire = toWireMessage(m);
|
|
23
|
+
return wire.tagged
|
|
24
|
+
? { role: wire.role, content: wire.content }
|
|
25
|
+
: { role: m.role, content: m.content };
|
|
26
|
+
}),
|
|
16
27
|
// Fold prior tool results into the turn (parity with the cloud brains).
|
|
17
28
|
{ role: "user", content: foldToolResults(input.raw, input.tool_results) },
|
|
18
29
|
];
|
|
@@ -30,37 +41,54 @@ export function createOllamaBrain(opts) {
|
|
|
30
41
|
},
|
|
31
42
|
}))
|
|
32
43
|
: undefined;
|
|
33
|
-
|
|
44
|
+
// ── The one branch streaming adds ──────────────────────────────────
|
|
45
|
+
//
|
|
46
|
+
// No `onChunk`, no stream: `stream: false` and one `response.json()`, the
|
|
47
|
+
// way this brain has always spoken to Ollama. Both paths land in
|
|
48
|
+
// `OllamaChatResponse` — the fields this brain reads — so the payload is
|
|
49
|
+
// built once, from one shape.
|
|
50
|
+
const post = (stream) => fetch(`${endpoint}/api/chat`, {
|
|
34
51
|
method: "POST",
|
|
35
52
|
headers: { "Content-Type": "application/json" },
|
|
36
53
|
body: JSON.stringify({
|
|
37
54
|
model,
|
|
38
55
|
messages,
|
|
39
|
-
stream
|
|
56
|
+
stream,
|
|
40
57
|
...(tools ? { tools } : {}),
|
|
41
58
|
}),
|
|
42
59
|
...(input.signal ? { signal: input.signal } : {}),
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
});
|
|
61
|
+
let data;
|
|
62
|
+
if (input.onChunk) {
|
|
63
|
+
data = await withRetry(() => streamChat(post, input.onChunk, input.signal), input.signal ? { signal: input.signal } : {});
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// Verbatim: the fetch is retried, the status check and the body read
|
|
67
|
+
// are not — exactly where those three lines have always sat.
|
|
68
|
+
const response = await withRetry(() => post(false), input.signal ? { signal: input.signal } : {});
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new Error(`Ollama error: ${response.status} ${response.statusText}`);
|
|
71
|
+
}
|
|
72
|
+
data = (await response.json());
|
|
46
73
|
}
|
|
47
|
-
const data = (await response.json());
|
|
48
74
|
const inputTokens = data.prompt_eval_count;
|
|
49
75
|
const outputTokens = data.eval_count;
|
|
50
76
|
const usageBlock = inputTokens !== undefined || outputTokens !== undefined
|
|
51
77
|
? { usage: { ...(inputTokens !== undefined ? { inputTokens } : {}), ...(outputTokens !== undefined ? { outputTokens } : {}) } }
|
|
52
78
|
: {};
|
|
53
|
-
// Tool
|
|
54
|
-
// OpenAI's JSON string).
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
// Tool calls — ALL of them (Ollama returns arguments already parsed as an
|
|
80
|
+
// object, unlike OpenAI's JSON string). The gate stays what it was: the
|
|
81
|
+
// FIRST entry must name a tool, or this is a text response. A local model
|
|
82
|
+
// that emits a nameless first entry behaves exactly as it did yesterday.
|
|
83
|
+
const rawCalls = data.message?.tool_calls ?? [];
|
|
84
|
+
const calls = rawCalls.map((c) => ({
|
|
85
|
+
name: c.function?.name ?? "",
|
|
86
|
+
params: c.function?.arguments ?? {},
|
|
87
|
+
}));
|
|
88
|
+
if (calls[0]?.name) {
|
|
89
|
+
const orchestration = useToolOrchestration(calls, 0.9);
|
|
90
|
+
if (orchestration)
|
|
91
|
+
return { orchestration, ...usageBlock };
|
|
64
92
|
}
|
|
65
93
|
const text = data.message?.content ?? "";
|
|
66
94
|
return {
|
|
@@ -71,3 +99,24 @@ export function createOllamaBrain(opts) {
|
|
|
71
99
|
},
|
|
72
100
|
};
|
|
73
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* The same POST with `stream: true`, whose body is NDJSON rather than one JSON
|
|
104
|
+
* object, folded back into the response shape the caller above expects.
|
|
105
|
+
*
|
|
106
|
+
* The status check stays where it is on the other path — before a single byte
|
|
107
|
+
* of body is read — so a refusing Ollama fails the same way whether or not
|
|
108
|
+
* anyone asked to watch. A body that never materialises is the one case this
|
|
109
|
+
* has to add: `response.body` is nullable in the fetch spec, and reading a
|
|
110
|
+
* null stream is a `TypeError` with no useful message.
|
|
111
|
+
*/
|
|
112
|
+
async function streamChat(post, sink, signal) {
|
|
113
|
+
const response = await post(true);
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
throw new Error(`Ollama error: ${response.status} ${response.statusText}`);
|
|
116
|
+
}
|
|
117
|
+
if (!response.body) {
|
|
118
|
+
throw new Error("Ollama error: streaming response carried no body");
|
|
119
|
+
}
|
|
120
|
+
const lines = await consumeNdjson(response.body, sink, signal);
|
|
121
|
+
return accumulateOllama(lines);
|
|
122
|
+
}
|