@looprun-ai/mastra 0.1.2 → 0.3.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.
@@ -0,0 +1,692 @@
1
+ /**
2
+ * @looprun-ai/mastra — the MICRO-LOOP backend (EXPERIMENTAL; a small-language-model turn driver).
3
+ *
4
+ * ⚠️ EXPERIMENTAL — NOT wired as a default anywhere. The certified turn driver is
5
+ * {@link runSpecConversation} / {@link LoopRunAgent}; this is an additive arm for tiny models.
6
+ *
7
+ * WHY a separate driver. The certified driver lets the model run a MULTI-STEP free generation and close
8
+ * the turn with a free-text terminal (replyToUser/askUser). A tiny model is reliable at ONE
9
+ * grammar-forced tool call but dies in the LOOP: multi-step free generation rambles to the output cap and
10
+ * never closes the turn, and the free-text terminal is the failing surface. This driver decomposes the
11
+ * TURN into forced MICRO-DECISIONS: ONE tool call per generate (stepCountIs(1) + toolChoice 'required' =
12
+ * ramble structurally impossible), plus a STRUCTURED terminal (`replyStructured`) whose user-facing text
13
+ * is produced by a DETERMINISTIC renderer, so the model never free-writes the reply.
14
+ *
15
+ * ISOLATION. Shares only PURE helpers + the governed-turn primitives with the certified driver
16
+ * (imported, never mutated). Guard hooks, ledger, chain-completion, mutators, onReply checks and the
17
+ * honest-abstain exhaustion are the certified machinery; only the GENERATION strategy differs.
18
+ *
19
+ * TERMINAL FORCING (v6). The forced close and the onReply redrive do NOT go through the Mastra tool loop:
20
+ * llama-server's TOOL grammar is LAZY, so `toolChoice:'required'` degraded to free text and no terminal
21
+ * ever landed. They call `generateObject` directly with the replyStructured schema as
22
+ * `response_format: json_schema` — a NON-lazy whole-output grammar the model cannot escape. The remaining
23
+ * forcing sites (the micro-steps, flowChain completion) still use single-`activeTools` + `toolChoice:'required'`
24
+ * (llama-server IGNORES the named `{ type:'tool', toolName }` form). (v7) The close sees a MINIMAL context —
25
+ * the turn's user tail + this turn's fresh tool-result digest + the steering line — not the whole transcript
26
+ * (the probe-proven short-context regime; a tiny model fills the schema with meta-junk at long context).
27
+ *
28
+ * DOMAIN-NEUTRAL. Holds ZERO business strings and NO language-specific wording: the renderer supplies
29
+ * only separators/newlines; the model supplies every natural-language token in the user's own language.
30
+ */
31
+ import { stepCountIs, generateObject } from 'ai';
32
+ import { Agent } from '@mastra/core/agent';
33
+ import { createTool } from '@mastra/core/tools';
34
+ import { beginTurn, createLedger, defaultExhaustionReply, enforcePostTool, normalizeModelParams, renderScopedSpecTrunk, resolveGuards, resolveModelSettings, resolveMutators, resultOk, runChainCompletionPass, VETO_STORM_LIMIT, } from '@looprun-ai/core';
35
+ import { jsonSchemaToZodObject } from './json-schema-zod.js';
36
+ const DEFAULT_MICRO_STEPS = 6;
37
+ const DEFAULT_REDRIVES = 1;
38
+ /** Tight per-step output cap (anti-ramble). Overridable via spec.controls.sampling.maxOutputTokens. */
39
+ const MICRO_STEP_TOKENS = 320;
40
+ /** Slightly larger cap for a forced closing/redrive replyStructured step. */
41
+ const FORCE_TOKENS = 256;
42
+ /** Total char budget for the this-turn tool-result digest spliced into the MINIMAL force-close context. */
43
+ const MICRO_DIGEST_CHARS = 2400;
44
+ /** Per-tool-result char cap inside that digest (a fresh result summary, never the whole transcript). */
45
+ const MICRO_DIGEST_ITEM_CHARS = 600;
46
+ /** Free-text terminals (a capable model may still use them). */
47
+ const FREE_TERMINAL = new Set(['replyToUser', 'askUser']);
48
+ /** The structured terminal — this arm only; a deterministic renderer owns the reply text. */
49
+ const STRUCTURED_TERMINAL = 'replyStructured';
50
+ /** Every tool that ends a turn in micro-loop mode. */
51
+ const MICRO_TERMINAL = new Set([...FREE_TERMINAL, STRUCTURED_TERMINAL]);
52
+ /**
53
+ * The replyStructured schema — grammar-safe (enum + plain strings/array only; NO pattern/format so a GBNF
54
+ * backend can enforce it). `kind` is required; every content field is optional and language-neutral.
55
+ */
56
+ const REPLY_STRUCTURED_SCHEMA = {
57
+ type: 'object',
58
+ properties: {
59
+ kind: { type: 'string', enum: ['answer', 'list', 'question', 'refusal'], description: 'Shape of this reply.' },
60
+ intro: { type: 'string', description: "Opening line in the user's language (about 200 chars max)." },
61
+ items: {
62
+ type: 'array',
63
+ items: { type: 'string' },
64
+ description: "Up to 8 short bullet lines in the user's language (about 140 chars each).",
65
+ },
66
+ question: { type: 'string', description: "One clarifying question in the user's language." },
67
+ caution: { type: 'string', description: "Optional caveat line in the user's language." },
68
+ },
69
+ required: ['kind'],
70
+ };
71
+ /**
72
+ * The replyStructured schema as a Zod object — built ONCE from {@link REPLY_STRUCTURED_SCHEMA} (a pure
73
+ * computation, no I/O and no global mutation, so importing this module stays side-effect-free). Shared by
74
+ * BOTH terminal-close paths so they enforce an identical shape: (a) the replyStructured tool's `inputSchema`
75
+ * (a capable model that DOES land the tool call), and (b) the grammar-forced `generateObject` close/redrive —
76
+ * where it becomes the openai-compatible provider's `response_format: json_schema`, a NON-lazy whole-output
77
+ * grammar (llama-server's TOOL grammar is LAZY, so `toolChoice:'required'` degraded to free text and no
78
+ * terminal ever landed).
79
+ */
80
+ const REPLY_STRUCTURED_ZOD = jsonSchemaToZodObject(REPLY_STRUCTURED_SCHEMA);
81
+ /**
82
+ * Strips model `<think>…</think>` leakage from a raw text field — PURE, domain-neutral. Two passes:
83
+ * (1) every CLOSED block anywhere (incl. an empty one) is removed whole; (2) a still-present LEADING
84
+ * `<think>` with no close (truncated by a token cap) has just the opening tag trimmed. Exported for test.
85
+ */
86
+ export function stripThinkBlocks(text) {
87
+ if (!text)
88
+ return text;
89
+ return text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^\s*<think>\s*/i, '');
90
+ }
91
+ /**
92
+ * Per-turn "last terminal wins" recorder — PURE. A blank/whitespace-only `next` is a no-op (keeps
93
+ * `current`); any non-blank `next` REPLACES `current`. Never concatenates. Exported for test.
94
+ */
95
+ export function recordTerminalReply(current, next) {
96
+ return next.trim() ? next : current;
97
+ }
98
+ /**
99
+ * Final per-turn answerText assembly — PURE, exported for tests. Exactly one of: the tripwire abort
100
+ * reason; the LAST terminal's rendered text; a defensive fallback to the last raw step text; or ''.
101
+ */
102
+ export function assembleAnswerText(args) {
103
+ if (args.tripwire)
104
+ return args.tripwireReason;
105
+ return args.terminalReply || args.lastText || '';
106
+ }
107
+ /**
108
+ * The DETERMINISTIC structured-reply renderer — PURE and DOMAIN-NEUTRAL. Joins whatever fields the model
109
+ * supplied, in a stable order (intro, bullet items, question, caution), using only newlines and a "- "
110
+ * bullet prefix. Writes NO natural-language content itself. Every field is passed through
111
+ * {@link stripThinkBlocks} first. Exported for the unit test.
112
+ */
113
+ export function renderStructuredReply(args) {
114
+ const asStr = (v) => (typeof v === 'string' ? stripThinkBlocks(v).trim() : '');
115
+ const intro = asStr(args.intro);
116
+ const items = Array.isArray(args.items)
117
+ ? args.items.filter((x) => typeof x === 'string').map((s) => stripThinkBlocks(s).trim()).filter(Boolean).slice(0, 8)
118
+ : [];
119
+ const question = asStr(args.question);
120
+ const caution = asStr(args.caution);
121
+ const blocks = [];
122
+ if (intro)
123
+ blocks.push(intro);
124
+ if (items.length)
125
+ blocks.push(items.map((it) => `- ${it}`).join('\n'));
126
+ if (question)
127
+ blocks.push(question);
128
+ if (caution)
129
+ blocks.push(caution);
130
+ return blocks.join('\n\n').trim();
131
+ }
132
+ /**
133
+ * A distinctive, domain-neutral marker embedded in EVERY steering/forcing prompt this driver injects. A
134
+ * weak local model sometimes PARROTS a steering prompt into its reply; because the marker rides along,
135
+ * {@link scrubSteeringEcho} can drop those echoed lines. Bracketed + ASCII so it never collides with a
136
+ * legitimate business reply and keeps the runtime language-neutral.
137
+ */
138
+ export const STEERING_SENTINEL = '[[close-turn]]';
139
+ /**
140
+ * Scrub runtime STEERING ECHO out of a model-produced candidate reply — PURE and DOMAIN-NEUTRAL (injects
141
+ * no words; only drops what the model parroted from THIS driver's scaffolding). A LINE-based walk:
142
+ * (a) a fenced ```…``` block whose body carries a `"kind":` field is DROPPED (the model NARRATING the
143
+ * replyStructured schema as TEXT instead of calling the tool); a non-narration fenced block is kept;
144
+ * (b) any LINE containing the {@link STEERING_SENTINEL} is DROPPED (a parroted forcing prompt).
145
+ * Removed lines are elided; a leftover run of 3+ newlines collapses to one blank line, then trimmed.
146
+ * Returns '' when nothing survives. Exported for test.
147
+ */
148
+ export function scrubSteeringEcho(text) {
149
+ if (!text)
150
+ return text;
151
+ const hasKind = (s) => /"kind"\s*:/.test(s);
152
+ const lines = text.split('\n');
153
+ const out = [];
154
+ let i = 0;
155
+ while (i < lines.length) {
156
+ const original = lines[i];
157
+ const line = original.replace(/```[a-zA-Z0-9]*[ \t]*[^\n]*?```/g, (b) => (hasKind(b) ? '' : b));
158
+ if (line.trim() === '' && original.trim() !== '') {
159
+ i++;
160
+ continue;
161
+ }
162
+ if ((line.match(/```/g)?.length ?? 0) % 2 === 1) {
163
+ const block = [line];
164
+ i++;
165
+ while (i < lines.length) {
166
+ block.push(lines[i]);
167
+ const closed = lines[i].includes('```');
168
+ i++;
169
+ if (closed)
170
+ break;
171
+ }
172
+ const joined = block.join('\n');
173
+ if (!hasKind(joined))
174
+ out.push(joined);
175
+ continue;
176
+ }
177
+ if (line.includes(STEERING_SENTINEL)) {
178
+ i++;
179
+ continue;
180
+ }
181
+ out.push(line);
182
+ i++;
183
+ }
184
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
185
+ }
186
+ /**
187
+ * Ingest a grammar-guaranteed structured-reply OBJECT (from the {@link generateObject} close/redrive, whose
188
+ * `response_format: json_schema` the provider enforces) into a candidate reply string — PURE, and the EXACT
189
+ * same candidate path the replyStructured tool `execute` uses: render via {@link renderStructuredReply} (which
190
+ * strips `<think>` per field) then {@link scrubSteeringEcho}. Returns '' for an empty / echo-only object — an
191
+ * empty-after-scrub is a SOFT fail: the caller records the attempt but does NOT set the terminal reply, so the
192
+ * driver falls through to the next attempt / the deterministic exhaustion closure. Exported for the unit test.
193
+ */
194
+ export function ingestStructuredObject(obj) {
195
+ const args = (obj ?? {});
196
+ return scrubSteeringEcho(renderStructuredReply(args));
197
+ }
198
+ /**
199
+ * Commit the SINGLE winning reply to the world — micro-loop mode ONLY. Every terminal ATTEMPT runs DRY
200
+ * (records its candidate into the ledger, never touches the world). Once the driver has the FINAL text it
201
+ * calls this EXACTLY ONCE through the SAME `replyToUser` seam the certified terminals use, so the world
202
+ * logs one reply per turn. A blank finalText is a no-op. Exported for test.
203
+ */
204
+ export async function commitFinalReply(world, finalText) {
205
+ if (finalText.trim())
206
+ await world.exec('replyToUser', { text: finalText });
207
+ }
208
+ /**
209
+ * Compact digest of THIS TURN's successful tool-call RESULTS — PURE and DOMAIN-NEUTRAL. WHY (v7): feeding the
210
+ * grammar-forced close the WHOLE conversation made a tiny model fill the schema with meta-junk ("this turn is
211
+ * closed", generic bullets); the split-step probe proved the SAME model composes clean text at a SHORT context.
212
+ * So the forced close now sees only (user text + this turn's FRESH results + steering), and this function
213
+ * produces the middle section from the ONLY source the driver already holds: the response messages the
214
+ * micro-step loop pushed into `messages` (`gen.response.messages`, sliced from the turn start).
215
+ *
216
+ * It walks every message's `content` array for `type:'tool-result'` parts — AI-SDK v6 emits them inside
217
+ * role:'tool' messages (`ToolResultPart` = `{ type:'tool-result', toolName, output }`), but we scan all roles
218
+ * defensively — unwrapping each `output` (`{type:'json'|'text', value}` → value; else the raw output). Runtime
219
+ * terminals (replyStructured / replyToUser / askUser, i.e. their DRY-record echoes) are skipped, and only
220
+ * results passing {@link resultOk} survive (the "successful calls" the model should restate). Each is
221
+ * `JSON.stringify`'d + clipped to {@link MICRO_DIGEST_ITEM_CHARS}; the whole body is clipped to `maxChars`
222
+ * with a truncation marker. No non-terminal successful result ⇒ '' (the caller omits the digest section).
223
+ * Exported for the unit test.
224
+ */
225
+ export function digestTurnToolResults(turnMessages, maxChars = MICRO_DIGEST_CHARS) {
226
+ const safeStringify = (value) => {
227
+ try {
228
+ const s = JSON.stringify(value);
229
+ return typeof s === 'string' ? s : String(value);
230
+ }
231
+ catch {
232
+ return String(value);
233
+ }
234
+ };
235
+ const unwrap = (output) => {
236
+ if (output && typeof output === 'object') {
237
+ const o = output;
238
+ if ((o.type === 'json' || o.type === 'text') && 'value' in o)
239
+ return o.value;
240
+ }
241
+ return output;
242
+ };
243
+ const lines = [];
244
+ for (const msg of Array.isArray(turnMessages) ? turnMessages : []) {
245
+ const content = msg?.content;
246
+ if (!Array.isArray(content))
247
+ continue;
248
+ for (const part of content) {
249
+ const p = part;
250
+ if (!p || p.type !== 'tool-result')
251
+ continue;
252
+ const name = typeof p.toolName === 'string' ? p.toolName : 'tool';
253
+ if (MICRO_TERMINAL.has(name))
254
+ continue; // skip runtime terminals (their dry-record echoes / steering)
255
+ const value = unwrap(p.output);
256
+ if (!resultOk(value))
257
+ continue; // successful results only (the calls the model should restate)
258
+ lines.push(`- ${name}: ${safeStringify(value).slice(0, MICRO_DIGEST_ITEM_CHARS)}`);
259
+ }
260
+ }
261
+ if (!lines.length)
262
+ return '';
263
+ const body = lines.join('\n');
264
+ return body.length > maxChars
265
+ ? `### Tool results this turn\n${body.slice(0, maxChars)}\n… (truncated)`
266
+ : `### Tool results this turn\n${body}`;
267
+ }
268
+ /**
269
+ * Build the MINIMAL message array for a grammar-forced structured close/redrive — PURE, exported for test.
270
+ * Replaces the whole-transcript context (on which a tiny model produced meta-junk) with ONE user message,
271
+ * composed in order: (a) the CURRENT turn's user content — the certified D8 tail EXACTLY as it rode the turn's
272
+ * user message (`## Account state` block + uploads + user text), so the state survives; (b) the compact
273
+ * this-turn tool-result digest ({@link digestTurnToolResults}); (c) the ephemeral steering line — the
274
+ * forced-close instruction OR the onReply redrive correction (either carrying {@link STEERING_SENTINEL}). The
275
+ * system prompt (the scoped trunk carrying the voice/language law) is passed SEPARATELY by the caller and
276
+ * deliberately NOT slimmed. Blank sections are omitted; present sections join with a blank line.
277
+ */
278
+ export function buildForceCloseMessages(args) {
279
+ const digest = digestTurnToolResults(args.turnMessages, args.maxDigestChars ?? MICRO_DIGEST_CHARS);
280
+ const parts = [];
281
+ if (args.userContent && args.userContent.trim())
282
+ parts.push(args.userContent);
283
+ if (digest)
284
+ parts.push(digest);
285
+ if (args.steering && args.steering.trim())
286
+ parts.push(args.steering);
287
+ return [{ role: 'user', content: parts.join('\n\n') }];
288
+ }
289
+ // ── Micro-loop turn protocol (appended to the SAME scoped trunk the certified path renders) ──
290
+ const MICRO_PROTOCOL = '\n\n## Turn protocol (ABSOLUTE — one action per step)\n' +
291
+ '- Each step you MUST call EXACTLY ONE tool. Do the domain tools you need first, one per step, reading each result before the next.\n' +
292
+ '- To speak to the user, call **replyStructured**: set `kind` and put the message in `intro`, `items` (bullet lines), `question`, and/or `caution`. The system renders the text — never write a free-text reply.\n' +
293
+ '- You MAY instead call **replyToUser** (final answer) or **askUser** (one question) if a single free-text field fits better.\n' +
294
+ '- The turn ends the moment you call one terminal (replyStructured / replyToUser / askUser).';
295
+ const MICRO_PROTOCOL_REPLY_ONLY = '\n\n## Turn protocol (ABSOLUTE — one action per step)\n' +
296
+ '- Each step you MUST call EXACTLY ONE tool. Do the domain tools you need first, one per step, reading each result before the next.\n' +
297
+ '- Never ask the user a question — make the most reasonable assumption and PROCEED.\n' +
298
+ '- To speak to the user, call **replyStructured** (set `kind` + `intro`/`items`/`caution`) or **replyToUser**. The system renders replyStructured text — never write a free-text reply.\n' +
299
+ '- The turn ends the moment you call one terminal.';
300
+ /** Fold one generate()'s usage into the running per-turn accumulator. */
301
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
302
+ function accUsage(acc, u) {
303
+ const add = (a, b) => (b == null ? a : (a ?? 0) + b);
304
+ acc.input = add(acc.input, u?.inputTokens);
305
+ acc.output = add(acc.output, u?.outputTokens);
306
+ acc.reasoning = add(acc.reasoning, u?.reasoningTokens);
307
+ acc.cacheRead = add(acc.cacheRead, u?.cachedInputTokens);
308
+ acc.total = add(acc.total, u?.totalTokens);
309
+ }
310
+ /**
311
+ * Run one case (all turns) for `spec` under the MICRO-LOOP driver. SAME signature + return shape as
312
+ * {@link runSpecConversation}, so a host can switch on a flag. Only the GENERATION strategy differs:
313
+ * forced single-tool micro-steps + a structured terminal.
314
+ */
315
+ export async function runSpecConversationMicroLoop(spec, turns, deps) {
316
+ const { world, model } = deps;
317
+ const theme = deps.theme ?? spec.theme;
318
+ if (!theme && !spec.surface.systemPrompt) {
319
+ throw new Error(`runSpecConversationMicroLoop: spec "${spec.id}" has no theme — pass deps.theme or set spec.theme.`);
320
+ }
321
+ const genParams = resolveModelSettings(normalizeModelParams(deps.modelParams ?? {}), spec.controls.sampling);
322
+ const baseSettings = (genParams.modelSettings ?? {});
323
+ const withCap = (cap) => ({ ...genParams, modelSettings: { ...baseSettings, maxOutputTokens: cap } });
324
+ // A per-agent sampling.maxOutputTokens (if set) already flows through genParams; here it also becomes
325
+ // the per-step cap so the anti-ramble bound is respected.
326
+ const microStepTokens = spec.controls.sampling?.maxOutputTokens ?? MICRO_STEP_TOKENS;
327
+ const forceTokens = spec.controls.sampling?.maxOutputTokens ?? FORCE_TOKENS;
328
+ const maxSteps = spec.controls.maxSteps ?? deps.maxSteps ?? DEFAULT_MICRO_STEPS;
329
+ const redrives = spec.controls.redrives ?? deps.redrives ?? DEFAULT_REDRIVES;
330
+ const surface = new Set(spec.surface.tools);
331
+ const ledger = createLedger();
332
+ // Record a terminal ATTEMPT's already-scrubbed candidate text DRY — it NEVER touches the world.
333
+ const recordDryTerminal = (cleanText, toolName, args) => {
334
+ if (cleanText.trim()) {
335
+ ledger.terminalReply = recordTerminalReply(ledger.terminalReply, cleanText);
336
+ ledger.observed.push({ name: toolName, args, ok: true, turnIndex: ledger.turnIndex });
337
+ return { ok: true };
338
+ }
339
+ ledger.observed.push({ name: toolName, args, ok: false, turnIndex: ledger.turnIndex });
340
+ return { success: false, error: `${STEERING_SENTINEL} Empty after removing internal steering text - write the actual user-facing message, then call the terminal again.` };
341
+ };
342
+ // Build Mastra tools: the agent's surface + the free terminals (from toolDefs) + replyStructured.
343
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
344
+ const mastraTools = {};
345
+ for (const def of deps.toolDefs) {
346
+ if (!surface.has(def.name) && !FREE_TERMINAL.has(def.name))
347
+ continue;
348
+ if (FREE_TERMINAL.has(def.name)) {
349
+ mastraTools[def.name] = createTool({
350
+ id: def.name,
351
+ description: def.description,
352
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
353
+ inputSchema: jsonSchemaToZodObject(def.inputSchema),
354
+ execute: async (input) => {
355
+ const args = (input ?? {});
356
+ const text = scrubSteeringEcho(stripThinkBlocks(typeof args.text === 'string' ? args.text : ''));
357
+ return recordDryTerminal(text, def.name, args);
358
+ },
359
+ });
360
+ continue;
361
+ }
362
+ mastraTools[def.name] = createTool({
363
+ id: def.name,
364
+ description: def.description,
365
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
366
+ inputSchema: jsonSchemaToZodObject(def.inputSchema),
367
+ execute: async (input) => world.exec(def.name, (input ?? {})),
368
+ });
369
+ }
370
+ // The structured terminal — runtime-owned. Its execute feeds the model's object through the SAME dry
371
+ // candidate path the grammar-forced generateObject close uses (ingestStructuredObject = scrub ∘ render) and
372
+ // records it DRY; it does NOT touch the world (the single reply is committed once via commitFinalReply).
373
+ mastraTools[STRUCTURED_TERMINAL] = createTool({
374
+ id: STRUCTURED_TERMINAL,
375
+ description: 'Send the final user-facing message as STRUCTURED fields; the system renders the text.',
376
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
377
+ inputSchema: REPLY_STRUCTURED_ZOD,
378
+ execute: async (input) => {
379
+ const args = (input ?? {});
380
+ return recordDryTerminal(ingestStructuredObject(args), STRUCTURED_TERMINAL, args);
381
+ },
382
+ });
383
+ let currentSystemPrompt = '';
384
+ const agent = new Agent({
385
+ name: `looprun-micro-${spec.id}`,
386
+ instructions: () => currentSystemPrompt,
387
+ model,
388
+ tools: mastraTools,
389
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
390
+ });
391
+ const renderPrompt = spec.surface.systemPrompt ?? ((w, u) => renderScopedSpecTrunk(w, spec, u, theme));
392
+ // preTool veto hook — IDENTICAL contract to the certified path (terminals skip guards).
393
+ const beforeToolCall = async ({ toolName, input }) => {
394
+ if (MICRO_TERMINAL.has(toolName))
395
+ return undefined;
396
+ const args = (input ?? {});
397
+ const guards = resolveGuards(spec.guards.preTool, toolName);
398
+ const gctx = { args, tool: toolName, world, observed: ledger.observed, turnIndex: ledger.turnIndex, attachmentsThisTurn: ledger.attachments };
399
+ for (const g of guards) {
400
+ const reason = await g.check(gctx);
401
+ if (reason) {
402
+ ledger.observed.push({ name: toolName, args, ok: false, turnIndex: ledger.turnIndex });
403
+ ledger.turnCorrections.push(`${g.dim}:${g.kind}:${toolName}`);
404
+ ledger.vetoStreak++;
405
+ const escalation = ledger.vetoStreak >= 2
406
+ ? ` ${STEERING_SENTINEL} STOP: do not call any more domain tools this turn. Close NOW with replyStructured (or replyToUser), reporting only what actually succeeded.`
407
+ : '';
408
+ return { proceed: false, output: { success: false, error: reason + escalation } };
409
+ }
410
+ }
411
+ ledger.vetoStreak = 0;
412
+ return undefined;
413
+ };
414
+ const afterToolCall = async ({ toolName, input, output }) => {
415
+ if (MICRO_TERMINAL.has(toolName))
416
+ return;
417
+ const args = (input ?? {});
418
+ const ok = output !== undefined && resultOk(output);
419
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
420
+ const requiresConfirmation = output?.requiresConfirmation === true;
421
+ ledger.observed.push({ name: toolName, args, ok, turnIndex: ledger.turnIndex, ...(requiresConfirmation ? { resultFlags: { requiresConfirmation: true } } : {}) });
422
+ if (ok) {
423
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
424
+ const lbl = output?.label;
425
+ if (typeof lbl === 'string')
426
+ ledger.producedThisTurn.push(lbl);
427
+ }
428
+ ledger.vetoStreak = 0;
429
+ if (!spec.guards.postTool?.length)
430
+ return;
431
+ const postGuards = resolveGuards(spec.guards.postTool, toolName);
432
+ if (!postGuards.length)
433
+ return;
434
+ const gctx = { args, tool: toolName, world, observed: ledger.observed, turnIndex: ledger.turnIndex, attachmentsThisTurn: ledger.attachments, result: output };
435
+ const { corrections, violations } = await enforcePostTool(postGuards, gctx);
436
+ if (corrections.length)
437
+ ledger.turnCorrections.push(...corrections);
438
+ if (violations.length)
439
+ ledger.postToolViolations.push(...violations);
440
+ };
441
+ const onInputGuards = resolveGuards(spec.guards.onInput);
442
+ const inputProcessors = onInputGuards.length
443
+ ? [{
444
+ id: 'looprun-onInput',
445
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
446
+ async processInput(a) {
447
+ const gctx = { args: {}, world, observed: ledger.observed, turnIndex: ledger.turnIndex };
448
+ for (const g of onInputGuards) {
449
+ const reason = await g.check(gctx);
450
+ if (reason) {
451
+ ledger.turnCorrections.push(`onInput:${g.kind}`);
452
+ a.abort(reason);
453
+ }
454
+ }
455
+ return a.messages;
456
+ },
457
+ }]
458
+ : undefined;
459
+ const turnRecords = [];
460
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
461
+ const messages = [];
462
+ let errorMsg;
463
+ for (let i = 0; i < turns.length; i++) {
464
+ if (i > 0)
465
+ world.advanceTurn();
466
+ beginTurn(ledger, i);
467
+ const attUrls = (turns[i].attachments ?? []);
468
+ const attLabels = attUrls.map((u) => world.ingestAttachment(u));
469
+ ledger.attachments = attLabels;
470
+ const attDisplay = attLabels.map((l, k) => {
471
+ const base = attUrls[k]?.split('/').pop();
472
+ return base ? `${l} (${base})` : l;
473
+ });
474
+ const userText = turns[i].userText;
475
+ const replyOnly = spec.controls.terminal ? spec.controls.terminal(world) === true : false;
476
+ const protocol = replyOnly ? MICRO_PROTOCOL_REPLY_ONLY : MICRO_PROTOCOL;
477
+ const terminalTools = replyOnly ? [STRUCTURED_TERMINAL, 'replyToUser'] : [STRUCTURED_TERMINAL, 'replyToUser', 'askUser'];
478
+ const activeTools = [...surface, ...terminalTools];
479
+ currentSystemPrompt = renderPrompt(world, attLabels) + protocol;
480
+ const before = world.toolCalls.length;
481
+ const sseBefore = world.sseActions.length;
482
+ const stateBlock = theme ? theme.stateBlock(world) : '';
483
+ const tailParts = [];
484
+ if (stateBlock && stateBlock.trim())
485
+ tailParts.push(`## Account state\n${stateBlock}`);
486
+ if (attLabels.length)
487
+ tailParts.push(`[Uploads this turn: ${attDisplay.join(', ')}]`);
488
+ tailParts.push(userText);
489
+ // The certified D8 tail (account state + uploads + user text). Captured so the MINIMAL force-close
490
+ // context (buildForceCloseMessages) can carry it verbatim — the Account-state block MUST ride along.
491
+ const userContent = tailParts.join('\n\n');
492
+ messages.push({ role: 'user', content: userContent });
493
+ // Slice point for this turn's response messages: everything the micro-step loop pushes AFTER the user
494
+ // message (assistant tool-call + role:'tool' result messages) → the source for the force-close digest.
495
+ const turnStartIndex = messages.length;
496
+ const t0 = Date.now();
497
+ try {
498
+ let stepCount = 0;
499
+ let extraCalls = 0;
500
+ let tripwire = false;
501
+ let tripwireReason = '';
502
+ let lastReasoning = null;
503
+ const usageAcc = { input: null, output: null, reasoning: null, cacheRead: null, cacheWrite: null, total: null };
504
+ // Grammar-guaranteed structured CLOSE (v6) — the terminal-forcing seam, shared by the forced close and
505
+ // the onReply redrive. It does NOT go through the Mastra tool loop: llama-server's TOOL grammar is LAZY
506
+ // for this template, so `toolChoice:'required'` let the model return FREE TEXT with no tool_calls — no
507
+ // terminal candidate ever landed. A DIRECT generateObject call sets `response_format: json_schema` on the
508
+ // openai-compatible provider, a NON-lazy whole-output grammar the model cannot escape. The system is
509
+ // RECONSTRUCTED from `currentSystemPrompt` (generateObject bypasses the Agent's `instructions`), and (v7)
510
+ // the context is MINIMAL: buildForceCloseMessages composes ONE user message = the current turn's user
511
+ // content (D8 tail incl. the Account-state block) + this turn's FRESH successful tool-result digest + the
512
+ // `ephemeral` steering line. That message is a throwaway (built fresh here, never pushed to the persistent
513
+ // `messages`), so a parroted steering line cannot compound into later context — only the SIDE EFFECT
514
+ // (ledger.terminalReply, via the SAME dry candidate path recordDryTerminal ∘ ingestStructuredObject that
515
+ // the replyStructured tool execute uses) carries forward. A throw (invalid/unrepairable JSON or a
516
+ // schema-validation failure) OR an empty-after-scrub object is a SOFT fail: terminalReply is left as-is
517
+ // and the driver falls through to the next attempt / the deterministic exhaustion closure. Shared by BOTH
518
+ // the forced close and the redrive — the redrive just passes its correction lines as `ephemeral`.
519
+ const forceStructuredClose = async (ephemeral) => {
520
+ const objOpts = {
521
+ model,
522
+ system: currentSystemPrompt,
523
+ messages: buildForceCloseMessages({ userContent, turnMessages: messages.slice(turnStartIndex), steering: ephemeral }),
524
+ schema: REPLY_STRUCTURED_ZOD,
525
+ ...(genParams.providerOptions !== undefined ? { providerOptions: genParams.providerOptions } : {}),
526
+ // generateObject is the RAW AI-SDK call (not a Mastra agent.generate), so it honors FLAT call
527
+ // settings top-level — spread baseSettings directly, not nested under `modelSettings`.
528
+ ...baseSettings,
529
+ maxOutputTokens: forceTokens, // the per-close cap wins over any baseSettings.maxOutputTokens
530
+ };
531
+ try {
532
+ // Options are a runtime-shaped settings bag; the load-bearing args (model, schema, system) are explicit.
533
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
534
+ const res = await generateObject(objOpts);
535
+ accUsage(usageAcc, res.usage);
536
+ const obj = (res.object ?? {});
537
+ recordDryTerminal(ingestStructuredObject(obj), STRUCTURED_TERMINAL, obj);
538
+ }
539
+ catch (e) {
540
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
541
+ if (process.env.DEBUG_ERR)
542
+ console.error('\n[looprun-micro force-close ERR]', e?.message ?? String(e));
543
+ }
544
+ };
545
+ // ── Micro-step loop: ONE forced tool call per generate (the anti-ramble constraint) ──
546
+ for (let step = 0; step < maxSteps; step++) {
547
+ if (ledger.terminalReply.trim())
548
+ break;
549
+ if (ledger.vetoStreak >= VETO_STORM_LIMIT)
550
+ break;
551
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
552
+ const gen = await agent.generate(messages, {
553
+ activeTools,
554
+ toolChoice: 'required',
555
+ stopWhen: [stepCountIs(1)],
556
+ hooks: { beforeToolCall, afterToolCall },
557
+ ...(inputProcessors && step === 0 ? { inputProcessors } : {}),
558
+ ...withCap(microStepTokens),
559
+ });
560
+ if (gen.response?.messages)
561
+ messages.push(...gen.response.messages);
562
+ accUsage(usageAcc, gen.totalUsage);
563
+ if (gen.reasoningText)
564
+ lastReasoning = gen.reasoningText;
565
+ stepCount++;
566
+ ledger.turnCorrections.push('microloop:step');
567
+ if (gen.tripwire) {
568
+ tripwire = true;
569
+ tripwireReason = String(gen.tripwireReason ?? gen.reason ?? '');
570
+ break;
571
+ }
572
+ }
573
+ // ── Terminal forcing: if no terminal landed, ONE grammar-forced structured close (v6) ──
574
+ // See forceStructuredClose above: a DIRECT generateObject call (response_format: json_schema) — NOT the
575
+ // Mastra tool loop, whose LAZY tool grammar let the model free-write past `toolChoice:'required'` so no
576
+ // terminal ever landed. Its object feeds the SAME dry candidate path; the ephemeral steering prompt is
577
+ // spliced into a MINIMAL message context only (never persisted). A soft fail ⇒ exhaustion closure below.
578
+ if (!tripwire && !ledger.terminalReply.trim()) {
579
+ const forcePrompt = replyOnly
580
+ ? `${STEERING_SENTINEL} Close the turn now. Set \`kind\` and put what you did in \`intro\`/\`items\`. Do NOT ask a question.`
581
+ : `${STEERING_SENTINEL} Close the turn now. Set \`kind\` and put the complete user-facing message in \`intro\`/\`items\` (or \`question\` to ask one thing).`;
582
+ await forceStructuredClose(forcePrompt);
583
+ extraCalls++;
584
+ ledger.turnCorrections.push('microloop:forced-terminal');
585
+ }
586
+ // ── flowChain completion — IDENTICAL contract to the certified path ──
587
+ if (spec.controls.chains?.length) {
588
+ const chainPass = await runChainCompletionPass(spec.controls.chains, {
589
+ world,
590
+ observed: ledger.observed,
591
+ turnIndex: i,
592
+ terminalReplyPresent: ledger.terminalReply.trim().length > 0,
593
+ beforeToolCall,
594
+ afterToolCall,
595
+ forceLlmCall: async (call) => {
596
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
597
+ const cc = await agent.generate([...messages, { role: 'user', content: `Complete the required follow-up now: call ${call} with the correct arguments for what the user asked. Do not reply in text.` }], { activeTools: [call], toolChoice: 'required', stopWhen: [stepCountIs(2)], hooks: { beforeToolCall, afterToolCall }, ...genParams });
598
+ if (cc.response?.messages)
599
+ messages.push(...cc.response.messages);
600
+ },
601
+ });
602
+ if (chainPass.corrections.length)
603
+ ledger.turnCorrections.push(...chainPass.corrections);
604
+ if (chainPass.replyViolations.length)
605
+ ledger.postToolViolations.push(...chainPass.replyViolations);
606
+ extraCalls += chainPass.llmCalls;
607
+ }
608
+ // Micro-loop terminals are MANDATORY: raw step text is never a deliverable reply. Empty → emptyReply
609
+ // violation → forced redrive → deterministic exhaustion.
610
+ let answerText = assembleAnswerText({ tripwire, tripwireReason, terminalReply: ledger.terminalReply, lastText: '' });
611
+ // Deterministic egress: jargonScrub mutators, applied before the final checks (as certified).
612
+ for (const m of resolveMutators(spec.guards.onReplyMutate)) {
613
+ const mctx = { args: {}, world, observed: ledger.observed, turnIndex: i, reply: answerText, producedThisTurn: ledger.producedThisTurn };
614
+ const out = m.apply(answerText, mctx);
615
+ if (out !== answerText) {
616
+ ledger.turnCorrections.push(`mutate:${m.kind}`);
617
+ answerText = out;
618
+ }
619
+ }
620
+ // onReply checks — a redrive here is ONE MORE grammar-forced structured close (generateObject, not free text).
621
+ const replyChecks = resolveGuards(spec.guards.onReply);
622
+ const checkReply = async (text) => {
623
+ const rctx = { args: {}, world, observed: ledger.observed, turnIndex: i, reply: text, producedThisTurn: ledger.producedThisTurn, attachmentsThisTurn: ledger.attachments, notes: ledger.turnCorrections };
624
+ const out = [];
625
+ for (const g of replyChecks) {
626
+ const r = await g.check(rctx);
627
+ if (r)
628
+ out.push({ guard: g, reason: r });
629
+ }
630
+ return out;
631
+ };
632
+ let violations = await checkReply(answerText);
633
+ if (ledger.postToolViolations.length)
634
+ violations = [...ledger.postToolViolations, ...violations];
635
+ // The correction rides as the ephemeral steering on the SAME minimal context as the forced close.
636
+ // Grammar-forced via generateObject — a soft fail leaves the prior terminalReply standing, so a redrive
637
+ // can only IMPROVE the reply, never blank it.
638
+ for (let r = 0; r < redrives && violations.length; r++) {
639
+ const correction = violations.map((v) => `- ${v.reason}`).join('\n');
640
+ const redrivePrompt = `${STEERING_SENTINEL} Your last reply needs fixing:\n${correction}\n${STEERING_SENTINEL} Provide the corrected reply as structured fields, in the user's language.`;
641
+ await forceStructuredClose(redrivePrompt);
642
+ extraCalls++;
643
+ for (const v of violations)
644
+ ledger.turnCorrections.push(`redrive:${v.guard.kind}`);
645
+ ledger.turnCorrections.push('microloop:redrive');
646
+ answerText = recordTerminalReply(answerText, ledger.terminalReply);
647
+ violations = await checkReply(answerText);
648
+ }
649
+ const finalViolations = violations.map((v) => v.guard.kind);
650
+ if (finalViolations.length) {
651
+ const okTools = ledger.observed.filter((o) => o.turnIndex === i && o.ok).map((o) => o.name);
652
+ const closure = spec.controls.exhaustionReply
653
+ ? spec.controls.exhaustionReply(world, okTools, ledger.producedThisTurn, finalViolations)
654
+ : defaultExhaustionReply(theme, world, okTools, ledger.producedThisTurn, finalViolations);
655
+ ledger.turnCorrections.push('exhaustion-terminal');
656
+ answerText = closure;
657
+ }
658
+ // SINGLE world commit: every terminal ATTEMPT ran DRY. Now that answerText is final, send it to the
659
+ // world EXACTLY once through the replyToUser seam. Placed BEFORE the toolCalls slice below.
660
+ await commitFinalReply(world, answerText);
661
+ const durationMs = Date.now() - t0;
662
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
663
+ const newCalls = world.toolCalls.slice(before).map((tc) => ({
664
+ name: tc.name, args: tc.args, resultSummary: JSON.stringify(tc.result ?? null).slice(0, 800), tookEffect: tc.tookEffect, latencyMs: 0,
665
+ }));
666
+ const totalSteps = stepCount + extraCalls;
667
+ turnRecords.push({
668
+ userText, assistantFinalText: answerText, finalMode: spec.mode, assistantMsgCount: 1,
669
+ iters: totalSteps, llmCalls: totalSteps, toolCalls: newCalls, thoughts: lastReasoning,
670
+ tokens: usageAcc, llmCallLatenciesMs: [durationMs], durationMs, maxIterHit: stepCount >= maxSteps,
671
+ recoveryEvents: ledger.turnCorrections.length ? ledger.turnCorrections.slice() : [],
672
+ sseActions: world.sseActions.slice(sseBefore), attachments: attLabels,
673
+ });
674
+ }
675
+ catch (e) {
676
+ const durationMs = Date.now() - t0;
677
+ errorMsg = String(e);
678
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
679
+ if (process.env.DEBUG_ERR)
680
+ console.error('\n[looprun-micro ERR]', e?.message ?? String(e));
681
+ turnRecords.push({
682
+ userText, assistantFinalText: '', finalMode: spec.mode, assistantMsgCount: 0,
683
+ iters: 0, llmCalls: 1, toolCalls: [], thoughts: null,
684
+ tokens: { input: null, output: null, reasoning: null, cacheRead: null, cacheWrite: null, total: null },
685
+ llmCallLatenciesMs: [durationMs], durationMs, maxIterHit: false, recoveryEvents: ['error'],
686
+ });
687
+ break;
688
+ }
689
+ }
690
+ return { turnRecords, messages, errorMsg };
691
+ }
692
+ //# sourceMappingURL=microloop.js.map