@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +12 -10
- package/dist/activities/execute-deep-agent/subagent-transformer.js +12 -52
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/activities/execute-deep-agent/turn-setup.d.ts +5 -4
- package/dist/activities/execute-deep-agent/turn-setup.js +68 -24
- package/dist/activities/execute-deep-agent/turn-setup.js.map +1 -1
- package/dist/shared/model-client.d.ts +24 -2
- package/dist/shared/model-client.js +8 -0
- package/dist/shared/model-client.js.map +1 -1
- package/dist/shared/model-registry.d.ts +42 -1
- package/dist/shared/model-registry.js +38 -1
- package/dist/shared/model-registry.js.map +1 -1
- package/dist/shared/thinking-mode.d.ts +51 -4
- package/dist/shared/thinking-mode.js +66 -4
- package/dist/shared/thinking-mode.js.map +1 -1
- package/package.json +6 -5
- package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +63 -12
- package/src/activities/execute-deep-agent/subagent-transformer.ts +24 -63
- package/src/activities/execute-deep-agent/turn-setup.ts +70 -19
- package/src/shared/__tests__/model-client-sampling.test.ts +56 -2
- package/src/shared/__tests__/model-client.test.ts +72 -0
- package/src/shared/__tests__/model-registry.test.ts +96 -1
- package/src/shared/__tests__/thinking-mode.test.ts +69 -0
- package/src/shared/model-client.ts +35 -2
- package/src/shared/model-registry.ts +83 -1
- 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 {
|
|
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
|
|
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
|
|
314
|
-
* execution constructs — the primary AND the sub-agent
|
|
315
|
-
* provider account's default can never pick the price of
|
|
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
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
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
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
69
|
-
*
|
|
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
|
});
|