la-machina-engine 0.7.3 → 0.7.4

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/dist/index.d.cts CHANGED
@@ -1280,6 +1280,18 @@ interface StreamRequest {
1280
1280
  readonly tools?: readonly ModelToolDefinition[] | undefined;
1281
1281
  readonly maxTokens?: number | undefined;
1282
1282
  readonly temperature?: number | undefined;
1283
+ /**
1284
+ * Tool-call policy (Plan 025). Default: `'auto'` (model decides).
1285
+ * - `'auto'` — model picks; pass-through behavior
1286
+ * - `'required'` — model MUST call a tool on its next response;
1287
+ * translated per-provider (Anthropic:
1288
+ * `tool_choice: { type: 'any' }`; OpenAI / AI SDK:
1289
+ * `toolChoice: 'required'`).
1290
+ *
1291
+ * Note: `'none'` is handled engine-side by stripping `tools` before
1292
+ * the request reaches the adapter, so adapters never see it.
1293
+ */
1294
+ readonly toolChoice?: 'auto' | 'required' | undefined;
1283
1295
  }
1284
1296
 
1285
1297
  interface ModelAdapter {
@@ -2000,7 +2012,30 @@ interface RunOptions {
2000
2012
  readonly runId?: string;
2001
2013
  readonly nodeId: string;
2002
2014
  readonly task: string;
2015
+ /**
2016
+ * Per-run tool allowlist (Plan 025). When undefined, the run sees
2017
+ * every tool registered at engine init (subject to
2018
+ * `config.tools.enabled / disabled`). When provided, the visible
2019
+ * tool set is further restricted to this exact list — names that
2020
+ * aren't registered are silently ignored. Pass `[]` to disable
2021
+ * all tools for this run; equivalent to `toolChoice: 'none'`.
2022
+ */
2003
2023
  readonly tools?: readonly string[] | undefined;
2024
+ /**
2025
+ * Tool-call policy for this run (Plan 025). Default: `'auto'`.
2026
+ * - `'auto'` — model decides whether to call tools
2027
+ * - `'none'` — model is told it has no tools; tools list is
2028
+ * stripped before the model adapter sees it
2029
+ * - `'required'` — model MUST call a tool on its next response;
2030
+ * plumbed to the provider as the "force tool use"
2031
+ * flag (Anthropic: `tool_choice: { type: 'any' }`,
2032
+ * OpenAI / AI SDK: `toolChoice: 'required'`)
2033
+ *
2034
+ * Precedence: `'none'` wins over any `tools: [...]` allowlist.
2035
+ * `'required'` paired with an empty effective tool set throws
2036
+ * `ERR_TOOL_CHOICE_CONFLICT` at run start.
2037
+ */
2038
+ readonly toolChoice?: 'auto' | 'none' | 'required' | undefined;
2004
2039
  readonly context?: Readonly<Record<string, unknown>> | undefined;
2005
2040
  /** Override maxTurns for this run only. Used by the orchestrator for per-phase budgets. */
2006
2041
  readonly maxTurns?: number | undefined;
package/dist/index.d.ts CHANGED
@@ -1280,6 +1280,18 @@ interface StreamRequest {
1280
1280
  readonly tools?: readonly ModelToolDefinition[] | undefined;
1281
1281
  readonly maxTokens?: number | undefined;
1282
1282
  readonly temperature?: number | undefined;
1283
+ /**
1284
+ * Tool-call policy (Plan 025). Default: `'auto'` (model decides).
1285
+ * - `'auto'` — model picks; pass-through behavior
1286
+ * - `'required'` — model MUST call a tool on its next response;
1287
+ * translated per-provider (Anthropic:
1288
+ * `tool_choice: { type: 'any' }`; OpenAI / AI SDK:
1289
+ * `toolChoice: 'required'`).
1290
+ *
1291
+ * Note: `'none'` is handled engine-side by stripping `tools` before
1292
+ * the request reaches the adapter, so adapters never see it.
1293
+ */
1294
+ readonly toolChoice?: 'auto' | 'required' | undefined;
1283
1295
  }
1284
1296
 
1285
1297
  interface ModelAdapter {
@@ -2000,7 +2012,30 @@ interface RunOptions {
2000
2012
  readonly runId?: string;
2001
2013
  readonly nodeId: string;
2002
2014
  readonly task: string;
2015
+ /**
2016
+ * Per-run tool allowlist (Plan 025). When undefined, the run sees
2017
+ * every tool registered at engine init (subject to
2018
+ * `config.tools.enabled / disabled`). When provided, the visible
2019
+ * tool set is further restricted to this exact list — names that
2020
+ * aren't registered are silently ignored. Pass `[]` to disable
2021
+ * all tools for this run; equivalent to `toolChoice: 'none'`.
2022
+ */
2003
2023
  readonly tools?: readonly string[] | undefined;
2024
+ /**
2025
+ * Tool-call policy for this run (Plan 025). Default: `'auto'`.
2026
+ * - `'auto'` — model decides whether to call tools
2027
+ * - `'none'` — model is told it has no tools; tools list is
2028
+ * stripped before the model adapter sees it
2029
+ * - `'required'` — model MUST call a tool on its next response;
2030
+ * plumbed to the provider as the "force tool use"
2031
+ * flag (Anthropic: `tool_choice: { type: 'any' }`,
2032
+ * OpenAI / AI SDK: `toolChoice: 'required'`)
2033
+ *
2034
+ * Precedence: `'none'` wins over any `tools: [...]` allowlist.
2035
+ * `'required'` paired with an empty effective tool set throws
2036
+ * `ERR_TOOL_CHOICE_CONFLICT` at run start.
2037
+ */
2038
+ readonly toolChoice?: 'auto' | 'none' | 'required' | undefined;
2004
2039
  readonly context?: Readonly<Record<string, unknown>> | undefined;
2005
2040
  /** Override maxTurns for this run only. Used by the orchestrator for per-phase budgets. */
2006
2041
  readonly maxTurns?: number | undefined;
package/dist/index.js CHANGED
@@ -1716,7 +1716,11 @@ var AnthropicClient = class {
1716
1716
  messages: request.messages,
1717
1717
  stream: true,
1718
1718
  ...request.system !== void 0 ? { system: request.system } : {},
1719
- ...request.tools !== void 0 ? { tools: request.tools } : {}
1719
+ ...request.tools !== void 0 ? { tools: request.tools } : {},
1720
+ // Plan 025 — `'required'` maps to Anthropic's `tool_choice: { type: 'any' }`,
1721
+ // which forces the model to call SOME tool but doesn't pin which.
1722
+ // `'auto'` is the SDK default — omit to let it through unchanged.
1723
+ ...request.toolChoice === "required" ? { tool_choice: { type: "any" } } : {}
1720
1724
  };
1721
1725
  const requestOptions = {};
1722
1726
  if (betas.length > 0) {
@@ -1898,6 +1902,9 @@ var AISdkAdapter = class {
1898
1902
  tools,
1899
1903
  ...request.maxTokens !== void 0 ? { maxOutputTokens: request.maxTokens } : {},
1900
1904
  ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
1905
+ // Plan 025 — pass through `'required'` so the AI SDK forwards it
1906
+ // to the provider as that provider's "force tool call" flag.
1907
+ ...request.toolChoice === "required" ? { toolChoice: "required" } : {},
1901
1908
  maxRetries: this.options.maxRetries ?? 2
1902
1909
  });
1903
1910
  for await (const event of result.fullStream) {
@@ -2517,6 +2524,17 @@ function ensureToolResultPairing(messages) {
2517
2524
  return messages;
2518
2525
  }
2519
2526
 
2527
+ // src/engine/lastTurnGuard.ts
2528
+ init_esm_shims();
2529
+ var LAST_TURN_INSTRUCTION_JSON = "SYSTEM NOTE: This is your final allowed turn. Emit ONLY the JSON object that satisfies the output schema in the system prompt. Do not call any more tools. Do not write any explanation, narration, or markdown \u2014 only the raw JSON.";
2530
+ var LAST_TURN_INSTRUCTION_TEXT = "SYSTEM NOTE: This is your final allowed turn. Stop calling tools and deliver your final answer now. The next turn will not happen.";
2531
+ function lastTurnInstruction(outputFormat) {
2532
+ return outputFormat === "json" ? LAST_TURN_INSTRUCTION_JSON : LAST_TURN_INSTRUCTION_TEXT;
2533
+ }
2534
+ function shouldInjectLastTurnInstruction(opts) {
2535
+ return opts.turnCount + 1 === opts.maxTurns;
2536
+ }
2537
+
2520
2538
  // src/compact/compactor.ts
2521
2539
  init_esm_shims();
2522
2540
 
@@ -3008,15 +3026,29 @@ async function agentLoop(options) {
3008
3026
  const toolCalls = [];
3009
3027
  let stopReason = null;
3010
3028
  let turnUsage = { input: 0, output: 0 };
3029
+ let messagesForApi = messages;
3030
+ if (shouldInjectLastTurnInstruction({
3031
+ turnCount: ctx.getTurnCount(),
3032
+ maxTurns: ctx.getMaxTurns()
3033
+ })) {
3034
+ messagesForApi = [
3035
+ ...messages,
3036
+ {
3037
+ role: "user",
3038
+ content: [{ type: "text", text: lastTurnInstruction(options.outputFormat) }]
3039
+ }
3040
+ ];
3041
+ }
3011
3042
  const normalizedMessages = normalizeMessages(
3012
- messages
3043
+ messagesForApi
3013
3044
  );
3014
3045
  try {
3015
3046
  for await (const event of client.streamMessage({
3016
3047
  messages: normalizedMessages,
3017
3048
  system,
3018
3049
  tools: anthropicTools,
3019
- ...escalatedMaxTokens !== void 0 ? { maxTokens: escalatedMaxTokens } : {}
3050
+ ...escalatedMaxTokens !== void 0 ? { maxTokens: escalatedMaxTokens } : {},
3051
+ ...options.toolChoice !== void 0 ? { toolChoice: options.toolChoice } : {}
3020
3052
  })) {
3021
3053
  const handled = consumeEvent(event);
3022
3054
  if (handled.text !== void 0) textBlocks.push(handled.text);
@@ -3567,6 +3599,9 @@ var RunContext = class {
3567
3599
  getTurnCount() {
3568
3600
  return this.turnCount;
3569
3601
  }
3602
+ getMaxTurns() {
3603
+ return this.maxTurns;
3604
+ }
3570
3605
  getTokensUsed() {
3571
3606
  return this.tokensUsed;
3572
3607
  }
@@ -9565,6 +9600,35 @@ var TranscriptReader = class {
9565
9600
  }
9566
9601
  };
9567
9602
 
9603
+ // src/engine/runToolFilter.ts
9604
+ init_esm_shims();
9605
+ function applyRunToolFilter(registry, options) {
9606
+ const stripAll = options.toolChoice === "none" || options.tools !== void 0 && options.tools.length === 0;
9607
+ if (stripAll) {
9608
+ if (options.toolChoice === "required") {
9609
+ throw new EngineError(
9610
+ "ERR_TOOL_CHOICE_CONFLICT",
9611
+ "toolChoice: 'required' is incompatible with an empty tool set (received tools: [] or toolChoice: 'none')."
9612
+ );
9613
+ }
9614
+ for (const tool of registry.list()) {
9615
+ registry.unregister(tool.name);
9616
+ }
9617
+ return;
9618
+ }
9619
+ if (options.tools === void 0) return;
9620
+ const allow = new Set(options.tools);
9621
+ for (const tool of registry.list()) {
9622
+ if (!allow.has(tool.name)) registry.unregister(tool.name);
9623
+ }
9624
+ if (options.toolChoice === "required" && registry.count() === 0) {
9625
+ throw new EngineError(
9626
+ "ERR_TOOL_CHOICE_CONFLICT",
9627
+ "toolChoice: 'required' was requested but no tools matched the per-run allowlist after applying config filters."
9628
+ );
9629
+ }
9630
+ }
9631
+
9568
9632
  // src/engine/rehydrate.ts
9569
9633
  init_esm_shims();
9570
9634
  function rebuildMessagesFromEntries(entries) {
@@ -10041,6 +10105,7 @@ var Engine = class {
10041
10105
  ...knowledgeRuntime !== void 0 ? { knowledge: knowledgeRuntime } : {},
10042
10106
  ...this.internals.fetch !== void 0 ? { fetch: this.internals.fetch } : {}
10043
10107
  });
10108
+ applyRunToolFilter(registry, options);
10044
10109
  const writer = new TranscriptWriter({
10045
10110
  storage: storage.workspace,
10046
10111
  logPath,
@@ -10095,7 +10160,14 @@ var Engine = class {
10095
10160
  ...runTimeout.signal !== void 0 ? { runSignal: runTimeout.signal, runTimeoutMs: this.config.execution.runTimeoutMs } : {},
10096
10161
  ...gate !== void 0 ? { gateBeforeTool: gate } : {},
10097
10162
  ..._internal?.handoffToRunner === true ? { handoffToRunner: true } : {},
10098
- ...offloadConfig !== void 0 ? { toolResultOffload: offloadConfig } : {}
10163
+ ...offloadConfig !== void 0 ? { toolResultOffload: offloadConfig } : {},
10164
+ // Plan 025 — output mode + tool-choice plumbed down so the
10165
+ // last-turn guard picks the right instruction text and the
10166
+ // model adapter can pass `'required'` to the provider. `'none'`
10167
+ // is already handled above by stripping the tool list, so the
10168
+ // loop only ever sees `'auto'` or `'required'`.
10169
+ ...options.outputFormat !== void 0 ? { outputFormat: options.outputFormat } : {},
10170
+ ...options.toolChoice === "required" ? { toolChoice: "required" } : {}
10099
10171
  });
10100
10172
  const result = await this.finalizeResult(loopResult, writer, logPath, {
10101
10173
  ...options.outputFormat !== void 0 ? { outputFormat: options.outputFormat } : {},