diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index bbca23fd8..9d26c978c 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.20.2", + "version": "0.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.20.2", + "version": "0.23.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index ddd981e16..b935e4f72 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -6199,7 +6199,7 @@ from strands.models.bedrock import BedrockModel def load_model() -> BedrockModel: """Get Bedrock model client using IAM credentials.""" - return BedrockModel(model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}") + return BedrockModel(model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}"{{#if modelMaxTokens}}, max_tokens={{modelMaxTokens}}{{/if}}) {{/if}} {{/if}} {{#if (eq modelProvider "Anthropic")}} diff --git a/src/assets/python/http/strands/base/model/load.py b/src/assets/python/http/strands/base/model/load.py index d45e500dc..0b3b23eac 100644 --- a/src/assets/python/http/strands/base/model/load.py +++ b/src/assets/python/http/strands/base/model/load.py @@ -65,7 +65,7 @@ def load_model(): def load_model() -> BedrockModel: """Get Bedrock model client using IAM credentials.""" - return BedrockModel(model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}") + return BedrockModel(model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}"{{#if modelMaxTokens}}, max_tokens={{modelMaxTokens}}{{/if}}) {{/if}} {{/if}} {{#if (eq modelProvider "Anthropic")}} diff --git a/src/cli/commands/export/__tests__/harness-mapper.test.ts b/src/cli/commands/export/__tests__/harness-mapper.test.ts index 08c15a5cd..e28e3b6f5 100644 --- a/src/cli/commands/export/__tests__/harness-mapper.test.ts +++ b/src/cli/commands/export/__tests__/harness-mapper.test.ts @@ -858,6 +858,36 @@ describe('model ID propagation to renderConfig', () => { }); }); +// ============================================================================ +// model maxTokens propagation +// ============================================================================ + +describe('model maxTokens propagation to renderConfig', () => { + it('propagates maxTokens for an ordinary Converse Bedrock model', () => { + const ctx = baseContext({ + model: { provider: 'bedrock', modelId: 'anthropic.claude-3', maxTokens: 4096 }, + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.modelMaxTokens).toBe(4096); + expect(renderConfig.bedrockMantle).toBeUndefined(); + }); + + it('leaves modelMaxTokens undefined when the spec has no maxTokens', () => { + const ctx = baseContext({ model: { provider: 'bedrock', modelId: 'anthropic.claude-3' } }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.modelMaxTokens).toBeUndefined(); + }); + + it('propagates maxTokens for a Bedrock Mantle model', () => { + const ctx = baseContext({ + model: { provider: 'bedrock', modelId: 'openai.gpt-oss-120b', apiFormat: 'chat_completions', maxTokens: 2048 }, + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.bedrockMantle).toBe(true); + expect(renderConfig.modelMaxTokens).toBe(2048); + }); +}); + // ============================================================================ // resolveIdentityProvider // ============================================================================ diff --git a/src/cli/commands/export/harness-mapper.ts b/src/cli/commands/export/harness-mapper.ts index a4dbac355..d138db8e4 100644 --- a/src/cli/commands/export/harness-mapper.ts +++ b/src/cli/commands/export/harness-mapper.ts @@ -273,6 +273,9 @@ export function mapHarnessToExportConfig( hasExecutionLimits, isExportHarness: true, modelId: spec.model.modelId, + // Max output tokens, threaded into load.py for both ordinary Converse Bedrock models + // (BedrockModel max_tokens) and Mantle models (max_output_tokens/max_completion_tokens). + modelMaxTokens: spec.model.maxTokens, // LiteLLM-only model config (apiBase + additionalParams), threaded into load.py. The model // schema is a flat object, so apiBase/additionalParams are always typed-present — only the // provider check + a truthiness check are needed. @@ -556,7 +559,6 @@ function buildBedrockMantleRenderConfig(spec: HarnessSpec): Partial { + const specModel: HarnessModel = { provider: 'bedrock', modelId: 'anthropic.claude-sonnet-4-5', maxTokens: 4096 }; + + it('sends no model when neither an override nor a spec model is available', () => { + const opts = buildInvokeOptions(ARN, 'us-west-2', 's-1', []); + expect(opts.model).toBeUndefined(); + }); + + it('defaults the model from the local spec so maxTokens applies without a redeploy', () => { + const opts = buildInvokeOptions(ARN, 'us-west-2', 's-1', [], undefined, specModel); + expect(opts.model).toEqual({ + bedrockModelConfig: { modelId: 'anthropic.claude-sonnet-4-5', maxTokens: 4096 }, + }); + }); + + it('prefers an explicit UI model override over the spec model', () => { + const override = { bedrockModelConfig: { modelId: 'other-model', maxTokens: 100 } }; + const opts = buildInvokeOptions(ARN, 'us-west-2', 's-1', [], { model: override }, specModel); + expect(opts.model).toEqual(override); + }); + + it('uses the spec model when overrides carry only non-model fields', () => { + const opts = buildInvokeOptions(ARN, 'us-west-2', 's-1', [], { skills: [{ path: './skills' }] }, specModel); + expect(opts.model).toEqual({ + bedrockModelConfig: { modelId: 'anthropic.claude-sonnet-4-5', maxTokens: 4096 }, + }); + expect(opts.skills).toEqual([{ path: './skills' }]); + }); + + it('sends no default override for non-bedrock providers', () => { + const geminiModel: HarnessModel = { + provider: 'gemini', + modelId: 'gemini-2.5-flash', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret/gemini', + maxTokens: 512, + }; + const opts = buildInvokeOptions(ARN, 'us-west-2', 's-1', [], undefined, geminiModel); + expect(opts.model).toBeUndefined(); + }); +}); diff --git a/src/cli/operations/dev/web-ui/handlers/harness-invocation.ts b/src/cli/operations/dev/web-ui/handlers/harness-invocation.ts index 4e4c464f1..bc1959bb7 100644 --- a/src/cli/operations/dev/web-ui/handlers/harness-invocation.ts +++ b/src/cli/operations/dev/web-ui/handlers/harness-invocation.ts @@ -1,7 +1,7 @@ import { invokeHarness } from '../../../../aws/agentcore-harness'; import type { InvokeHarnessOptions } from '../../../../aws/agentcore-harness'; import type { HarnessInvocationOverrides } from '../api-types'; -import { buildInvokeOptions } from './harness-utils'; +import { buildInvokeOptions, readLocalHarnessModel } from './harness-utils'; import type { RouteContext } from './route-context'; import { randomUUID } from 'node:crypto'; import type { ServerResponse } from 'node:http'; @@ -56,12 +56,14 @@ export async function handleHarnessInvocation( const messages: InvokeHarnessOptions['messages'] = [{ role: 'user', content: [{ text: parsed.prompt }] }]; + const specModel = await readLocalHarnessModel(ctx.options.configRoot, parsed.harnessName); const invokeOpts = buildInvokeOptions( harness.harnessArn, harness.region, parsed.sessionId, messages, - parsed.overrides + parsed.overrides, + specModel ); ctx.setCorsHeaders(res, origin); diff --git a/src/cli/operations/dev/web-ui/handlers/harness-tool-response.ts b/src/cli/operations/dev/web-ui/handlers/harness-tool-response.ts index 369ad45d7..3705e9ba6 100644 --- a/src/cli/operations/dev/web-ui/handlers/harness-tool-response.ts +++ b/src/cli/operations/dev/web-ui/handlers/harness-tool-response.ts @@ -1,6 +1,6 @@ import { invokeHarness } from '../../../../aws/agentcore-harness'; import type { HarnessInvocationOverrides } from '../api-types'; -import { buildInvokeOptions } from './harness-utils'; +import { buildInvokeOptions, readLocalHarnessModel } from './harness-utils'; import type { RouteContext } from './route-context'; import type { IncomingMessage, ServerResponse } from 'node:http'; @@ -61,12 +61,14 @@ export async function handleHarnessToolResponse( return; } + const specModel = await readLocalHarnessModel(ctx.options.configRoot, parsed.harnessName); const invokeOpts = buildInvokeOptions( harness.harnessArn, harness.region, parsed.sessionId, parsed.messages, - parsed.harnessOverrides + parsed.harnessOverrides, + specModel ); ctx.setCorsHeaders(res, origin); diff --git a/src/cli/operations/dev/web-ui/handlers/harness-utils.ts b/src/cli/operations/dev/web-ui/handlers/harness-utils.ts index 4a2947e9e..8a8ac29ff 100644 --- a/src/cli/operations/dev/web-ui/handlers/harness-utils.ts +++ b/src/cli/operations/dev/web-ui/handlers/harness-utils.ts @@ -1,14 +1,35 @@ +import { ConfigIO } from '../../../../../lib'; +import type { HarnessModel } from '../../../../../schema'; import type { HarnessSystemPrompt, InvokeHarnessOptions } from '../../../../aws/agentcore-harness'; import type { HarnessInvocationOverrides } from '../api-types'; const DEFAULT_MAX_ITERATIONS = 75; +/** + * Read the model config from the local harness spec, best-effort. Returns undefined when there is + * no config root or the spec is unreadable — the invocation then uses the deployed model config. + */ +export async function readLocalHarnessModel( + configRoot: string | undefined, + harnessName: string +): Promise { + if (!configRoot) return undefined; + try { + const configIO = new ConfigIO({ baseDir: configRoot }); + const spec = await configIO.readHarnessSpec(harnessName); + return spec.model; + } catch { + return undefined; + } +} + export function buildInvokeOptions( harnessArn: string, region: string, sessionId: string, messages: InvokeHarnessOptions['messages'], - overrides?: HarnessInvocationOverrides + overrides?: HarnessInvocationOverrides, + specModel?: HarnessModel ): InvokeHarnessOptions { const opts: InvokeHarnessOptions = { region, @@ -17,7 +38,22 @@ export function buildInvokeOptions( messages, }; - if (overrides?.model) opts.model = overrides.model; + if (overrides?.model) { + opts.model = overrides.model; + } else if (specModel?.provider === 'bedrock') { + // The web UI only sends a model override when the user edits the model panel, so without this + // default the deployed model config silently wins during dev. Send the local spec's Bedrock + // model config so agentcore.json settings (notably maxTokens) apply without a redeploy. + opts.model = { + bedrockModelConfig: { + modelId: specModel.modelId, + ...(specModel.apiFormat && { apiFormat: specModel.apiFormat }), + ...(specModel.temperature !== undefined && { temperature: specModel.temperature }), + ...(specModel.topP !== undefined && { topP: specModel.topP }), + ...(specModel.maxTokens !== undefined && { maxTokens: specModel.maxTokens }), + }, + }; + } if (overrides?.systemPrompt) opts.systemPrompt = [{ text: overrides.systemPrompt }] as HarnessSystemPrompt; if (overrides?.skills) opts.skills = overrides.skills; if (overrides?.actorId) opts.actorId = overrides.actorId; diff --git a/src/cli/templates/types.ts b/src/cli/templates/types.ts index f025aa874..4157bf009 100644 --- a/src/cli/templates/types.ts +++ b/src/cli/templates/types.ts @@ -168,7 +168,8 @@ export interface AgentRenderConfig { modelTemperature?: number; /** Model nucleus-sampling top_p (export path, Mantle): merged into the client params when set. */ modelTopP?: number; - /** Model max output tokens (export path, Mantle): mapped to max_output_tokens / max_completion_tokens. */ + /** Model max output tokens (export path): BedrockModel max_tokens for Converse models; + * max_output_tokens / max_completion_tokens for Mantle models. */ modelMaxTokens?: number; /** True when generating from a harness export (suppresses placeholder tools) */