@stigmer/runner 3.38.2 → 3.39.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.
Files changed (27) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +12 -10
  3. package/dist/activities/execute-deep-agent/subagent-transformer.js +12 -52
  4. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  5. package/dist/activities/execute-deep-agent/turn-setup.d.ts +5 -4
  6. package/dist/activities/execute-deep-agent/turn-setup.js +68 -24
  7. package/dist/activities/execute-deep-agent/turn-setup.js.map +1 -1
  8. package/dist/shared/model-client.d.ts +24 -2
  9. package/dist/shared/model-client.js +8 -0
  10. package/dist/shared/model-client.js.map +1 -1
  11. package/dist/shared/model-registry.d.ts +42 -1
  12. package/dist/shared/model-registry.js +38 -1
  13. package/dist/shared/model-registry.js.map +1 -1
  14. package/dist/shared/thinking-mode.d.ts +51 -4
  15. package/dist/shared/thinking-mode.js +66 -4
  16. package/dist/shared/thinking-mode.js.map +1 -1
  17. package/package.json +6 -5
  18. package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +63 -12
  19. package/src/activities/execute-deep-agent/subagent-transformer.ts +24 -63
  20. package/src/activities/execute-deep-agent/turn-setup.ts +70 -19
  21. package/src/shared/__tests__/model-client-sampling.test.ts +56 -2
  22. package/src/shared/__tests__/model-client.test.ts +72 -0
  23. package/src/shared/__tests__/model-registry.test.ts +96 -1
  24. package/src/shared/__tests__/thinking-mode.test.ts +69 -0
  25. package/src/shared/model-client.ts +35 -2
  26. package/src/shared/model-registry.ts +83 -1
  27. package/src/shared/thinking-mode.ts +77 -4
@@ -40,6 +40,8 @@ import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint";
40
40
  import type { DynamicStructuredTool } from "@langchain/core/tools";
41
41
  import type { Command } from "@langchain/langgraph";
42
42
  import { createDeepAgent } from "deepagents";
43
+ import { providerStrategy } from "langchain";
44
+ import { transformJSONSchema } from "@anthropic-ai/sdk/lib/transform-json-schema";
43
45
  import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
44
46
 
45
47
  import type { Config } from "../../config.js";
@@ -60,8 +62,9 @@ import { createThinkTool, createWebFetchTool, resolveGuardPosture } from "../../
60
62
  import { deriveExecutionFingerprintKey } from "../../shared/approval-fingerprint.js";
61
63
  import { getRunnerHitlMasterSecret } from "../../shared/fingerprint-secret.js";
62
64
  import { getModelPricing, ensureLoaded as ensurePricingLoaded, type ModelPricing } from "../../shared/model-pricing.js";
63
- import { getDefaultModel } from "../../shared/model-registry.js";
65
+ import { getDefaultModel, getNativeRequestProfile } from "../../shared/model-registry.js";
64
66
  import { buildChatModel } from "../../shared/model-client.js";
67
+ import { graphThinks, toAnthropicThinking } from "../../shared/thinking-mode.js";
65
68
  import { isUnattendedApprovalMode, type MergedToolPolicy } from "../../shared/approval-policy.js";
66
69
  import type { ToolApprovalCategory } from "../../shared/tool-kind.js";
67
70
  import { alsoAvailableSkillsNote, selectSkillsForPrompt, visionPromptInfoOf } from "../../shared/prompt-sections.js";
@@ -75,7 +78,7 @@ import { mountPlatformRoute } from "./platform-route.js";
75
78
  import { resolveResumeInput, type GraphStateSnapshot } from "./hitl.js";
76
79
  import { buildEnhancedSystemPrompt, composeUserMessage, renderSkillsSection } from "./prompt-builder.js";
77
80
  import { buildShellEnv } from "./shell-env.js";
78
- import { modelHasNativeThinking, transformAndCompileSubagents } from "./subagent-transformer.js";
81
+ import { transformAndCompileSubagents } from "./subagent-transformer.js";
79
82
  import { createTodoListMiddleware } from "./todo-list.js";
80
83
 
81
84
  /**
@@ -90,6 +93,14 @@ export type DeepAgentAdapterConfig = Pick<
90
93
  "checkpointerType" | "checkpointerProxyEndpoint" | "stigmerTokenRef" | "proxyEndpoint" | "mode"
91
94
  >;
92
95
 
96
+ /**
97
+ * A JSON object schema, the shape langchain's provider strategy takes for a
98
+ * plain schema (its own type for it is not exported). An execution's
99
+ * structured output schema is an object schema; the SDK's transform keeps
100
+ * its root `type`.
101
+ */
102
+ type ObjectJsonSchema = { type: "object"; [key: string]: unknown };
103
+
93
104
  /** The deepagents graph as this module holds it; deepagents exports no stable type for the compiled agent. */
94
105
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
106
  export type AgentGraph = any;
@@ -129,7 +140,7 @@ export interface DeepAgentEngine {
129
140
  readonly graph: AgentGraph;
130
141
  readonly checkpointer: BaseCheckpointSaver;
131
142
  readonly langgraphConfig: Record<string, unknown>;
132
- /** The registry id the turn runs on (pricing, the thinking heuristic, sub-agent inheritance key on it). */
143
+ /** The registry id the turn runs on (pricing and sub-agent inheritance key on it). */
133
144
  readonly modelName: string;
134
145
  readonly pricing: ModelPricing;
135
146
  readonly gate: DeepAgentGateState;
@@ -310,9 +321,10 @@ export function composeSystemPrompt(input: TurnInput, recalledMemories: Recalled
310
321
  * Build the engine: the model, the middleware stack with the gate, the
311
322
  * tools, the compiled sub-agents, the system prompt and `createDeepAgent`.
312
323
  *
313
- * The service tier resolved ONCE by the runtime rides every model this
314
- * execution constructs — the primary AND the sub-agent factory — so the
315
- * provider account's default can never pick the price of any turn (#361).
324
+ * The service tier and the thinking mode resolved ONCE by the runtime ride
325
+ * every model this execution constructs — the primary AND the sub-agent
326
+ * factory — so the provider account's default can never pick the price of
327
+ * any turn (#361), and every graph thinks in its own model's form.
316
328
  * `max_tool_rounds` resolves to one recursion limit that feeds BOTH the hard
317
329
  * stop on the invoke config and the budget middleware's ~80% advisory, so
318
330
  * the warning and the enforcement can never disagree.
@@ -341,25 +353,45 @@ export async function buildEngine(
341
353
  const primaryDir = input.workspace.primaryDir;
342
354
 
343
355
  // The model. Resolution to the provider API id happens inside
344
- // buildChatModel; modelName stays the registry id for pricing, the
345
- // native-thinking heuristic, and sub-agent inheritance. The credential is
346
- // read from the ref at build, once per turn: a LangChain client carries
347
- // its headers for its life, so this is the freshest a turn's model can be.
356
+ // buildChatModel; modelName stays the registry id for pricing and
357
+ // sub-agent inheritance. The credential is read from the ref at build,
358
+ // once per turn: a LangChain client carries its headers for its life, so
359
+ // this is the freshest a turn's model can be.
348
360
  //
349
- // No temperature: current Anthropic models refuse one other than their
350
- // default (claude-opus-4.7 and later and claude-fable-5 inside
351
- // @langchain/anthropic, before any request; claude-sonnet-5 at the
352
- // provider), and the Cursor harness sends none (stigmer/stigmer#1341).
353
- const buildModelFor = async (name: string) =>
354
- (await buildChatModel({
361
+ // What the request tells the provider about a model comes from the
362
+ // model's native registry row, never from a model name or a library's
363
+ // table, and is read here, the one place the execution's choices meet the
364
+ // row. The same factory builds every sub-agent's model, so each graph
365
+ // follows the row of the model it runs on:
366
+ // - no temperature: current Anthropic models refuse one other than their
367
+ // default, and the Cursor harness sends none (stigmer/stigmer#1341);
368
+ // - the row's output ceiling, where LangChain's per-model default falls to
369
+ // 4096 on models its table does not know (stigmer/stigmer#1343), and
370
+ // streaming on every call because the SDK refuses a non-streaming
371
+ // request at that ceiling;
372
+ // - the execution's thinking mode in the form the row declares
373
+ // (thinking-mode.ts toAnthropicThinking).
374
+ // A model with no native row (an unreachable registry) keeps the library
375
+ // defaults and sends no thinking parameter, as before.
376
+ const buildModelFor = async (name: string) => {
377
+ const profile = await getNativeRequestProfile(name);
378
+ return (await buildChatModel({
355
379
  modelName: name,
356
380
  proxyEndpoint: config.proxyEndpoint ?? undefined,
357
381
  stigmerToken: config.stigmerTokenRef.current ?? undefined,
358
382
  headerScope: { executionId },
359
383
  serviceTier: input.model.serviceTier,
360
384
  temperature: null,
385
+ maxTokens: profile?.maxOutputTokens,
386
+ streaming: true,
387
+ thinking: toAnthropicThinking(input.model.thinkingMode, profile?.thinking),
361
388
  })).model;
389
+ };
362
390
  const model = await buildModelFor(modelName);
391
+ // Whether this graph reasons natively, derived from the same mapping the
392
+ // request carries: it decides the `think` tool and the structured-output
393
+ // strategy below, so neither can disagree with the wire.
394
+ const parentThinks = graphThinks(input.model.thinkingMode, (await getNativeRequestProfile(modelName))?.thinking);
363
395
  sink.setupTiming.mark("build_model");
364
396
 
365
397
  await ensurePricingLoaded();
@@ -426,7 +458,13 @@ export async function buildEngine(
426
458
  // Cursor harness); its URL guard posture — strict on managed cloud runners,
427
459
  // relaxed on user-owned machines — is derived from the runner mode.
428
460
  const webFetchPosture = resolveGuardPosture(config.mode);
429
- const graphTools = [...tools.mcpTools, createThinkTool(), createWebFetchTool({ posture: webFetchPosture })];
461
+ // `think` is a reasoning aid for a graph that does not reason natively;
462
+ // a graph that thinks has no use for it.
463
+ const graphTools = [
464
+ ...tools.mcpTools,
465
+ ...(parentThinks ? [] : [createThinkTool()]),
466
+ createWebFetchTool({ posture: webFetchPosture }),
467
+ ];
430
468
 
431
469
  await sink.reportProgress("Configuring sub-agents…");
432
470
  const compiledSubagents = await transformAndCompileSubagents({
@@ -441,7 +479,8 @@ export async function buildEngine(
441
479
  // to the SAME per-turn observer as the parent (Session 26, DD-19).
442
480
  casObserver: workspace.casObserver,
443
481
  parentModelName: modelName,
444
- parentHasNativeThinking: modelHasNativeThinking(modelName),
482
+ parentThinks,
483
+ thinkingMode: input.model.thinkingMode,
445
484
  webFetchPosture,
446
485
  costAdvisory: costAdvisory ?? undefined,
447
486
  modelFactory: buildModelFor,
@@ -456,8 +495,20 @@ export async function buildEngine(
456
495
  // status the first time it is pulled, before this turn's first persist.
457
496
  const systemPrompt = composeSystemPrompt(input, await input.standing.selectRecalledMemories());
458
497
 
498
+ // Structured output takes one of langchain's two strategies, chosen here
499
+ // explicitly. A graph that thinks uses the provider strategy: Anthropic's
500
+ // own JSON output (`output_config.format`), which forces no tool, because
501
+ // forced tool use and thinking do not combine on every platform and
502
+ // model. Its schema goes through the SDK's own transform (the one
503
+ // @langchain/anthropic's withStructuredOutput applies): Anthropic accepts
504
+ // only closed objects, and the runner's zod keeps them open. A graph that
505
+ // does not think keeps the tool strategy, the raw zod, unchanged.
459
506
  const outputSchema = input.structuredOutputSchema;
460
- const responseFormat = outputSchema ? jsonSchemaToZod(outputSchema) : undefined;
507
+ const responseFormat = outputSchema === undefined
508
+ ? undefined
509
+ : parentThinks
510
+ ? providerStrategy({ schema: transformJSONSchema(outputSchema) as ObjectJsonSchema })
511
+ : jsonSchemaToZod(outputSchema);
461
512
 
462
513
  // File capture point: the CAS-observing backend — git-tracked edits flow to
463
514
  // disk (the boundary's git diff is authoritative) and the shared observer
@@ -5,12 +5,16 @@
5
5
  * validation is what failed every native turn on claude-opus-4.7,
6
6
  * claude-opus-4.8 and claude-fable-5 while the runner pinned temperature 0.
7
7
  * These tests build the client the way the execution turn does and read the
8
- * request parameters offline; no request leaves the process.
8
+ * request parameters offline; no request leaves the process. The same holds
9
+ * for the thinking parameter and the output ceiling the turn now sends: the
10
+ * wrapper's own handling of them (sent only when set, sampling stripped
11
+ * under thinking) is what reaches the provider.
9
12
  */
10
13
  import { ChatAnthropic } from "@langchain/anthropic";
11
14
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
15
 
13
- import { buildChatModel } from "../model-client.js";
16
+ import { buildChatModel, type BuildChatModelOptions } from "../model-client.js";
17
+ import { BUDGET_THINKING_TOKENS } from "../thinking-mode.js";
14
18
  import { _resetRegistryCache } from "../model-registry.js";
15
19
 
16
20
  // Ids the wrapper treats as adaptive-only and refuses a sampling override
@@ -54,3 +58,53 @@ describe("the native execution turn's sampling parameters (real @langchain/anthr
54
58
  },
55
59
  );
56
60
  });
61
+
62
+ describe("the native execution turn's thinking and ceiling (real @langchain/anthropic)", () => {
63
+ beforeEach(() => {
64
+ _resetRegistryCache();
65
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("registry unreachable in this test"));
66
+ vi.spyOn(console, "warn").mockImplementation(() => {});
67
+ });
68
+
69
+ afterEach(() => {
70
+ _resetRegistryCache();
71
+ vi.restoreAllMocks();
72
+ });
73
+
74
+ async function params(modelName: string, extra: Partial<BuildChatModelOptions>) {
75
+ const { model } = await buildChatModel({
76
+ modelName,
77
+ proxyEndpoint: "http://127.0.0.1:9",
78
+ stigmerToken: "sampling-test",
79
+ temperature: null,
80
+ ...extra,
81
+ });
82
+ return (model as ChatAnthropic).invocationParams({});
83
+ }
84
+
85
+ it.each(ADAPTIVE_ONLY_IDS)("%s: adaptive thinking with summarized display reaches the request", async (id) => {
86
+ const request = await params(id, { thinking: { type: "adaptive", display: "summarized" } });
87
+ expect(request.thinking).toEqual({ type: "adaptive", display: "summarized" });
88
+ expect(request).not.toHaveProperty("temperature");
89
+ });
90
+
91
+ it("a budget row's fixed budget reaches the request below the row's ceiling", async () => {
92
+ const request = await params("claude-haiku-4-5-20251001", {
93
+ thinking: { type: "enabled", budget_tokens: BUDGET_THINKING_TOKENS },
94
+ maxTokens: 64000,
95
+ streaming: true,
96
+ });
97
+ expect(request.thinking).toEqual({ type: "enabled", budget_tokens: BUDGET_THINKING_TOKENS });
98
+ expect(request.max_tokens).toBe(64000);
99
+ });
100
+
101
+ it("an explicit disabled is sent as such, and an omitted one is not sent at all", async () => {
102
+ expect((await params("claude-sonnet-5", { thinking: { type: "disabled" } })).thinking).toEqual({ type: "disabled" });
103
+ expect((await params("claude-sonnet-5", {})).thinking).toBeUndefined();
104
+ });
105
+
106
+ it("the row's full ceiling builds with streaming on, where the library default would be 4096", async () => {
107
+ expect((await params("claude-sonnet-5", {})).max_tokens).toBe(4096);
108
+ expect((await params("claude-sonnet-5", { maxTokens: 128000, streaming: true })).max_tokens).toBe(128000);
109
+ });
110
+ });
@@ -388,6 +388,78 @@ describe("buildChatModel", () => {
388
388
  .rejects.toThrow(/STIGMER_ANTHROPIC_BACKEND="verteks" is not a supported backend/);
389
389
  });
390
390
 
391
+ describe("thinking and streaming (the native execution turn)", () => {
392
+ it("Anthropic: the mapped thinking parameter is the wrapper's own `thinking` field", async () => {
393
+ mockRegistryResponse([
394
+ { id: "claude-sonnet-5", apiModelId: "claude-sonnet-5", provider: "anthropic" },
395
+ ]);
396
+
397
+ await buildChatModel({ modelName: "claude-sonnet-5", thinking: { type: "adaptive", display: "summarized" } });
398
+
399
+ expect(lastAnthropicArgs()).toMatchObject({ thinking: { type: "adaptive", display: "summarized" } });
400
+ });
401
+
402
+ it("an explicit disabled rides the same field, so the provider's default cannot turn thinking on", async () => {
403
+ mockRegistryResponse([
404
+ { id: "claude-sonnet-5", apiModelId: "claude-sonnet-5", provider: "anthropic" },
405
+ ]);
406
+
407
+ await buildChatModel({ modelName: "claude-sonnet-5", thinking: { type: "disabled" } });
408
+
409
+ expect(lastAnthropicArgs()).toMatchObject({ thinking: { type: "disabled" } });
410
+ });
411
+
412
+ it("an omitted thinking parameter sends none, and streaming is off unless asked for", async () => {
413
+ mockRegistryResponse([
414
+ { id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic" },
415
+ ]);
416
+
417
+ await buildChatModel({ modelName: "claude-haiku-4.5" });
418
+
419
+ expect(lastAnthropicArgs()).not.toHaveProperty("thinking");
420
+ expect(lastAnthropicArgs()).not.toHaveProperty("streaming");
421
+ });
422
+
423
+ it("streaming and the row's ceiling reach the wrapper when the caller sets them", async () => {
424
+ mockRegistryResponse([
425
+ { id: "claude-opus-4.8", apiModelId: "claude-opus-4-8", provider: "anthropic" },
426
+ ]);
427
+
428
+ await buildChatModel({ modelName: "claude-opus-4.8", maxTokens: 128000, streaming: true });
429
+
430
+ expect(lastAnthropicArgs()).toMatchObject({ maxTokens: 128000, streaming: true });
431
+ });
432
+
433
+ it("thinking rides a backend request too — unlike the tier, it exists on every Anthropic platform", async () => {
434
+ process.env.STIGMER_ANTHROPIC_BACKEND = "vertex";
435
+ process.env.CLOUD_ML_REGION = "asia-south1";
436
+ mockRegistryResponse([
437
+ { id: "claude-sonnet-4.5", apiModelId: "claude-sonnet-4-5-20250929", provider: "anthropic" },
438
+ ]);
439
+
440
+ await buildChatModel({
441
+ modelName: "claude-sonnet-4.5",
442
+ thinking: { type: "enabled", budget_tokens: 16000 },
443
+ serviceTier: ServiceTier.STANDARD,
444
+ });
445
+
446
+ const args = lastAnthropicArgs();
447
+ expect(args).toMatchObject({ thinking: { type: "enabled", budget_tokens: 16000 } });
448
+ expect(args).not.toHaveProperty("invocationKwargs");
449
+ });
450
+
451
+ it("a thinking parameter for a non-Anthropic model is refused, never silently dropped", async () => {
452
+ mockRegistryResponse([
453
+ { id: "gpt-4.1", apiModelId: "gpt-4.1", provider: "openai" },
454
+ ]);
455
+
456
+ await expect(
457
+ buildChatModel({ modelName: "gpt-4.1", thinking: { type: "adaptive" } }),
458
+ ).rejects.toThrow(/only Anthropic models have a native thinking mapping/);
459
+ expect(mockOpenAICtor).not.toHaveBeenCalled();
460
+ });
461
+ });
462
+
391
463
  describe("service tier (stigmer/stigmer#361)", () => {
392
464
  // The #357 contract on the native harness: when the caller passes the
393
465
  // execution's effective tier, every provider request pins it
@@ -4,6 +4,7 @@ import {
4
4
  getEconomyModel,
5
5
  getDefaultModel,
6
6
  getModelVisionCapability,
7
+ getNativeRequestProfile,
7
8
  resolveToApiModelId,
8
9
  _resetRegistryCache,
9
10
  } from "../model-registry.js";
@@ -345,6 +346,99 @@ describe("getModelVisionCapability", () => {
345
346
  });
346
347
  });
347
348
 
349
+ describe("getNativeRequestProfile", () => {
350
+ beforeEach(() => {
351
+ _resetRegistryCache();
352
+ vi.restoreAllMocks();
353
+ });
354
+
355
+ afterEach(() => {
356
+ _resetRegistryCache();
357
+ });
358
+
359
+ const NO_THINKING = { shape: "none", required: undefined } as const;
360
+
361
+ it("reads the native row, never the same id's cursor row", async () => {
362
+ mockRegistryResponse([
363
+ { id: "claude-sonnet-5", provider: "anthropic", costTier: "standard", harness: "cursor", maxOutputTokens: 64000, capabilities: { thinking: true, adaptiveThinking: false } },
364
+ { id: "claude-sonnet-5", apiModelId: "claude-sonnet-5", provider: "anthropic", costTier: "standard", harness: "native", maxOutputTokens: 128000, capabilities: { thinking: false, adaptiveThinking: true, thinkingRequired: false } },
365
+ ]);
366
+
367
+ expect(await getNativeRequestProfile("claude-sonnet-5")).toEqual({
368
+ maxOutputTokens: 128000,
369
+ thinking: { shape: "adaptive", required: false },
370
+ });
371
+ });
372
+
373
+ it("matches the provider api id as well as the registry id", async () => {
374
+ mockRegistryResponse([
375
+ { id: "claude-sonnet-4.6", apiModelId: "claude-sonnet-4-6", provider: "anthropic", costTier: "standard", harness: "native", maxOutputTokens: 65536 },
376
+ ]);
377
+
378
+ expect(await getNativeRequestProfile("claude-sonnet-4-6")).toEqual({ maxOutputTokens: 65536, thinking: NO_THINKING });
379
+ expect(await getNativeRequestProfile("claude-sonnet-4.6")).toEqual({ maxOutputTokens: 65536, thinking: NO_THINKING });
380
+ });
381
+
382
+ it("derives the thinking shape from the capability flags, adaptive winning over budget", async () => {
383
+ mockRegistryResponse([
384
+ { id: "budget", provider: "anthropic", costTier: "standard", harness: "native", capabilities: { thinking: true, adaptiveThinking: false } },
385
+ { id: "adaptive", provider: "anthropic", costTier: "standard", harness: "native", capabilities: { thinking: false, adaptiveThinking: true } },
386
+ { id: "both", provider: "anthropic", costTier: "standard", harness: "native", capabilities: { thinking: true, adaptiveThinking: true } },
387
+ { id: "neither", provider: "anthropic", costTier: "standard", harness: "native", capabilities: { thinking: false, adaptiveThinking: false } },
388
+ ]);
389
+
390
+ expect((await getNativeRequestProfile("budget"))?.thinking.shape).toBe("budget");
391
+ expect((await getNativeRequestProfile("adaptive"))?.thinking.shape).toBe("adaptive");
392
+ expect((await getNativeRequestProfile("both"))?.thinking.shape).toBe("adaptive");
393
+ expect((await getNativeRequestProfile("neither"))?.thinking.shape).toBe("none");
394
+ });
395
+
396
+ it("keeps thinkingRequired tri-state: stated true, stated false, and never assessed", async () => {
397
+ mockRegistryResponse([
398
+ { id: "always", provider: "anthropic", costTier: "premium", harness: "native", capabilities: { adaptiveThinking: true, thinkingRequired: true } },
399
+ { id: "optional", provider: "anthropic", costTier: "standard", harness: "native", capabilities: { adaptiveThinking: true, thinkingRequired: false } },
400
+ { id: "unassessed", provider: "anthropic", costTier: "standard", harness: "native", capabilities: { adaptiveThinking: true } },
401
+ { id: "malformed", provider: "anthropic", costTier: "standard", harness: "native", capabilities: { adaptiveThinking: true, thinkingRequired: "yes" } },
402
+ { id: "no-block", provider: "anthropic", costTier: "standard", harness: "native" },
403
+ ]);
404
+
405
+ expect((await getNativeRequestProfile("always"))?.thinking.required).toBe(true);
406
+ expect((await getNativeRequestProfile("optional"))?.thinking.required).toBe(false);
407
+ expect((await getNativeRequestProfile("unassessed"))?.thinking.required).toBeUndefined();
408
+ expect((await getNativeRequestProfile("malformed"))?.thinking.required).toBeUndefined();
409
+ expect((await getNativeRequestProfile("no-block"))?.thinking).toEqual(NO_THINKING);
410
+ });
411
+
412
+ it("states no ceiling when the row's value is absent or not a positive integer", async () => {
413
+ mockRegistryResponse([
414
+ { id: "a", provider: "anthropic", costTier: "standard", harness: "native" },
415
+ { id: "b", provider: "anthropic", costTier: "standard", harness: "native", maxOutputTokens: "128000" },
416
+ { id: "c", provider: "anthropic", costTier: "standard", harness: "native", maxOutputTokens: 0 },
417
+ { id: "d", provider: "anthropic", costTier: "standard", harness: "native", maxOutputTokens: 1.5 },
418
+ ]);
419
+
420
+ for (const id of ["a", "b", "c", "d"]) {
421
+ expect((await getNativeRequestProfile(id))?.maxOutputTokens).toBeUndefined();
422
+ }
423
+ });
424
+
425
+ it("returns undefined for Auto, an unknown model, a cursor-only model, and an unreachable registry", async () => {
426
+ mockRegistryResponse([
427
+ { id: "composer-2.5", provider: "cursor", costTier: "economy", harness: "cursor", maxOutputTokens: 64000 },
428
+ ]);
429
+
430
+ expect(await getNativeRequestProfile("default")).toBeUndefined();
431
+ expect(await getNativeRequestProfile("")).toBeUndefined();
432
+ expect(await getNativeRequestProfile("composer-2.5")).toBeUndefined();
433
+ expect(await getNativeRequestProfile("no-such-model")).toBeUndefined();
434
+
435
+ _resetRegistryCache();
436
+ vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("network down"));
437
+ vi.spyOn(console, "warn").mockImplementation(() => {});
438
+ expect(await getNativeRequestProfile("claude-sonnet-5")).toBeUndefined();
439
+ });
440
+ });
441
+
348
442
  interface MockModel {
349
443
  id: string;
350
444
  apiModelId?: string;
@@ -352,7 +446,8 @@ interface MockModel {
352
446
  costTier: string;
353
447
  harness: string;
354
448
  featured?: boolean;
355
- capabilities?: { vision?: boolean; toolUse?: boolean };
449
+ capabilities?: { vision?: boolean; toolUse?: boolean; thinking?: boolean; adaptiveThinking?: boolean; thinkingRequired?: unknown };
450
+ maxOutputTokens?: unknown;
356
451
  }
357
452
 
358
453
  function mockRegistryResponse(models: MockModel[]) {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The native thinking mapping, enumerated: every effective mode against
3
+ * every form a native registry row can declare, including the tri-state
4
+ * `required` flag. The table is the contract the wire, the `think` tool and
5
+ * the structured-output strategy all read, so it is pinned whole.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { ThinkingMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
10
+
11
+ import {
12
+ BUDGET_THINKING_TOKENS,
13
+ graphThinks,
14
+ resolveEffectiveThinkingMode,
15
+ toAnthropicThinking,
16
+ type EffectiveThinkingMode,
17
+ } from "../thinking-mode.js";
18
+ import type { NativeThinkingProfile } from "../model-registry.js";
19
+
20
+ const ADAPTIVE = { type: "adaptive", display: "summarized" } as const;
21
+ const BUDGET = { type: "enabled", budget_tokens: BUDGET_THINKING_TOKENS } as const;
22
+ const DISABLED = { type: "disabled" } as const;
23
+
24
+ type Row = readonly [
25
+ label: string,
26
+ mode: EffectiveThinkingMode,
27
+ profile: NativeThinkingProfile | undefined,
28
+ wire: ReturnType<typeof toAnthropicThinking>,
29
+ thinks: boolean,
30
+ ];
31
+
32
+ const TABLE: readonly Row[] = [
33
+ ["enabled, adaptive row", ThinkingMode.ENABLED, { shape: "adaptive", required: false }, ADAPTIVE, true],
34
+ ["enabled, budget row", ThinkingMode.ENABLED, { shape: "budget", required: false }, BUDGET, true],
35
+ ["enabled, row with no form", ThinkingMode.ENABLED, { shape: "none", required: undefined }, undefined, false],
36
+ ["enabled, adaptive row never assessed for required", ThinkingMode.ENABLED, { shape: "adaptive", required: undefined }, ADAPTIVE, true],
37
+ ["disabled, adaptive row stating required false", ThinkingMode.DISABLED, { shape: "adaptive", required: false }, DISABLED, false],
38
+ ["disabled, budget row stating required false", ThinkingMode.DISABLED, { shape: "budget", required: false }, DISABLED, false],
39
+ ["disabled, row never assessed for required", ThinkingMode.DISABLED, { shape: "adaptive", required: undefined }, undefined, false],
40
+ ["disabled, row that requires thinking", ThinkingMode.DISABLED, { shape: "adaptive", required: true }, ADAPTIVE, true],
41
+ ["enabled, row that requires thinking", ThinkingMode.ENABLED, { shape: "adaptive", required: true }, ADAPTIVE, true],
42
+ ["disabled, budget row that requires thinking", ThinkingMode.DISABLED, { shape: "budget", required: true }, BUDGET, true],
43
+ ["disabled, row with no form stating required false: no parameter to pin", ThinkingMode.DISABLED, { shape: "none", required: false }, undefined, false],
44
+ ["enabled, no native row", ThinkingMode.ENABLED, undefined, undefined, false],
45
+ ["disabled, no native row", ThinkingMode.DISABLED, undefined, undefined, false],
46
+ ];
47
+
48
+ describe("toAnthropicThinking and graphThinks", () => {
49
+ it.each(TABLE)("%s", (_label, mode, profile, wire, thinks) => {
50
+ expect(toAnthropicThinking(mode, profile)).toEqual(wire);
51
+ expect(graphThinks(mode, profile)).toBe(thinks);
52
+ });
53
+
54
+ it("keeps the budget inside the provider's bounds on every budget row the registry carries", () => {
55
+ // The provider requires at least 1,024 and less than max_tokens; the
56
+ // smallest budget-row ceiling in the bundled registry is 64,000.
57
+ expect(BUDGET_THINKING_TOKENS).toBeGreaterThanOrEqual(1024);
58
+ expect(BUDGET_THINKING_TOKENS).toBeLessThan(64_000);
59
+ });
60
+ });
61
+
62
+ describe("resolveEffectiveThinkingMode", () => {
63
+ it("resolves UNSPECIFIED and DISABLED to DISABLED, and keeps ENABLED", () => {
64
+ expect(resolveEffectiveThinkingMode(undefined)).toBe(ThinkingMode.DISABLED);
65
+ expect(resolveEffectiveThinkingMode(ThinkingMode.UNSPECIFIED)).toBe(ThinkingMode.DISABLED);
66
+ expect(resolveEffectiveThinkingMode(ThinkingMode.DISABLED)).toBe(ThinkingMode.DISABLED);
67
+ expect(resolveEffectiveThinkingMode(ThinkingMode.ENABLED)).toBe(ThinkingMode.ENABLED);
68
+ });
69
+ });
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { ChatAnthropic } from "@langchain/anthropic";
17
+ import type { ThinkingConfigParam } from "@anthropic-ai/sdk/resources/messages";
17
18
  import { ChatOpenAI } from "@langchain/openai";
18
19
  import type { BaseChatModel } from "@langchain/core/language_models/chat_models";
19
20
 
@@ -65,10 +66,20 @@ export interface BuildChatModelOptions {
65
66
  readonly temperature?: number | null;
66
67
  /**
67
68
  * Pass-through only; intentionally no default. Anthropic requires a value,
68
- * but callers differ (setup omits it, call-llm uses 4096), so the default
69
- * stays the caller's decision to avoid silently changing behavior.
69
+ * but callers differ (the execution turn reads the registry row's
70
+ * maxOutputTokens, call-llm uses 4096), so the default stays the caller's
71
+ * decision to avoid silently changing behavior.
70
72
  */
71
73
  readonly maxTokens?: number;
74
+ /**
75
+ * Stream every request. The Anthropic SDK refuses a non-streaming request
76
+ * whose max_tokens implies more than ten minutes (about 21k tokens) unless
77
+ * a timeout is set, and STIGMER_LLM_REQUEST_TIMEOUT_MS is unset by default;
78
+ * a caller that raises maxTokens to a model's full output ceiling sets
79
+ * this so no call path (a middleware's own invoke, a summary) can meet
80
+ * that refusal. The agent loop already streams through streamEvents.
81
+ */
82
+ readonly streaming?: boolean;
72
83
  /**
73
84
  * Per-request bound. When omitted, defaults to the operator's
74
85
  * STIGMER_LLM_REQUEST_TIMEOUT_MS — resolved HERE, not per caller, so no
@@ -91,6 +102,17 @@ export interface BuildChatModelOptions {
91
102
  * platform-chosen economy models.
92
103
  */
93
104
  readonly serviceTier?: EffectiveServiceTier;
105
+ /**
106
+ * Anthropic's `thinking` request parameter, already mapped by the caller
107
+ * from the execution's effective mode and the model's native registry row
108
+ * (thinking-mode.ts `toAnthropicThinking`). Unlike the tier it is sent on
109
+ * every Anthropic backend: both thinking forms exist on the public API,
110
+ * Vertex, Bedrock and Foundry alike. A value for a non-Anthropic model is
111
+ * refused rather than dropped: no other provider has a native thinking
112
+ * mapping, and a silently ignored mode is the #357 class of defect.
113
+ * Omitted, the request carries no thinking parameter (utility calls).
114
+ */
115
+ readonly thinking?: ThinkingConfigParam;
94
116
  }
95
117
 
96
118
  /**
@@ -265,12 +287,21 @@ export async function buildChatModel(opts: BuildChatModelOptions): Promise<Built
265
287
  const maxRetries = timeoutMs !== undefined || opts.maxRetries !== undefined
266
288
  ? { maxRetries: opts.maxRetries ?? 0 }
267
289
  : {};
290
+ if (opts.thinking !== undefined && provider !== "anthropic") {
291
+ throw new Error(
292
+ `buildChatModel: a thinking parameter was passed for ${opts.modelName}, a ${provider} model; ` +
293
+ "only Anthropic models have a native thinking mapping",
294
+ );
295
+ }
296
+
268
297
  const common = {
269
298
  ...(opts.temperature === null ? {} : { temperature: opts.temperature ?? 0 }),
270
299
  apiKey,
271
300
  ...(maxTokens ? { maxTokens } : {}),
301
+ ...(opts.streaming ? { streaming: true } : {}),
272
302
  ...maxRetries,
273
303
  };
304
+ const anthropicThinkingField = opts.thinking !== undefined ? { thinking: opts.thinking } : {};
274
305
 
275
306
  // The request timeout lives in a different slot per wrapper: ChatOpenAI
276
307
  // reads `timeout` as a constructor field, but ChatAnthropic ignores it
@@ -339,12 +370,14 @@ export async function buildChatModel(opts: BuildChatModelOptions): Promise<Built
339
370
  new ChatAnthropic({
340
371
  model: wireModelId,
341
372
  ...common,
373
+ ...anthropicThinkingField,
342
374
  ...anthropicClientOptionsField,
343
375
  createClient: backendCreateClient,
344
376
  })
345
377
  : new ChatAnthropic({
346
378
  model: apiModelId,
347
379
  ...common,
380
+ ...anthropicThinkingField,
348
381
  ...anthropicServiceTierField,
349
382
  ...anthropicClientOptionsField,
350
383
  });