From e697e61937f527634f1f0d25e51fd158986a12f6 Mon Sep 17 00:00:00 2001 From: Amir Hegazy Date: Tue, 29 Sep 2026 15:01:52 -0400 Subject: [PATCH] fix(export): match harness allowedTools for customer tools and keep model parameters - allowedTools: a bare pattern selects builtins only, and @server selects a customer tool (MCP server, inline function, gateway, browser, code interpreter), as the harness runtime does. Previously @server dropped the tool and a bare name kept it; a bare builtin name ("shell") now also selects the builtin, as intended. - @server/tool narrows an MCP server to the matching tools through MCP tool filters, matched on the server's own tool name or _, as the harness does. - Service model additionalParams for bedrock, open_ai, and gemini are carried into the generated model loader instead of being dropped with a note. A Bedrock guardrailConfig in them adds a bedrock:ApplyGuardrail policy to the runtime role. The local harness spec still omits them, since its deploy schema accepts them only for lite_llm. --- .../mcp_client/client.py | 16 ++- .../export-harness-python/model/load.py | 25 +++- src/core/project/manager.export.test.ts | 39 ++++++ src/core/project/manager.tsx | 1 + src/core/project/templates/export.test.ts | 120 ++++++++++++++++-- src/core/project/templates/export.ts | 110 ++++++++++++---- src/handlers/project/export/harness.ts | 6 +- .../project/export/serviceHarness.test.ts | 23 +++- src/handlers/project/export/serviceHarness.ts | 38 ++---- src/handlers/project/types.ts | 1 + 10 files changed, 299 insertions(+), 80 deletions(-) diff --git a/src/assets/templates/export-harness-python/mcp_client/client.py b/src/assets/templates/export-harness-python/mcp_client/client.py index ec98d6762b..6b600156e1 100644 --- a/src/assets/templates/export-harness-python/mcp_client/client.py +++ b/src/assets/templates/export-harness-python/mcp_client/client.py @@ -6,6 +6,18 @@ logger = logging.getLogger(__name__) {{#if remoteMcpTools}} +{{#if (some remoteMcpTools "toolPatterns")}} +import fnmatch + + +def _allowed_tools(server, *patterns): + """Load only the tools allowedTools selects, by the server's name or as _.""" + def allowed(tool, **_): + names = (tool.mcp_tool.name, f"{server}_{tool.mcp_tool.name}") + return any(fnmatch.fnmatchcase(name, pattern) for name in names for pattern in patterns) + return {"allowed": [allowed]} + +{{/if}} {{#if (some remoteMcpTools "headerCredentials")}} from bedrock_agentcore.identity.auth import requires_api_key {{/if}} @@ -30,9 +42,9 @@ def transport(): headers = { {{#each headerCredentials}}{{safeJson headerKey}}: _get_{{pythonName}}_key(){{#unless @last}}, {{/unless}}{{/each}} } return streamablehttp_client(url, headers=headers) - return MCPClient(transport) + return MCPClient(transport{{#if toolPatterns}}, tool_filters=_allowed_tools({{safeJson name}}, {{#each toolPatterns}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}){{/if}}) {{else}} - return MCPClient(lambda: streamablehttp_client(url)) + return MCPClient(lambda: streamablehttp_client(url){{#if toolPatterns}}, tool_filters=_allowed_tools({{safeJson name}}, {{#each toolPatterns}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}){{/if}}) {{/if}} {{/each}} diff --git a/src/assets/templates/export-harness-python/model/load.py b/src/assets/templates/export-harness-python/model/load.py index d748e1960d..22b6ca24bf 100644 --- a/src/assets/templates/export-harness-python/model/load.py +++ b/src/assets/templates/export-harness-python/model/load.py @@ -1,5 +1,8 @@ {{#if (eq modelProvider "Bedrock")}} {{#if bedrockMantle}} +{{#if modelAdditionalParams}} +import json +{{/if}} import os from aws_bedrock_token_generator import provide_token @@ -34,7 +37,7 @@ def load_model(): {{/if}} client_args = {"api_key": token, "base_url": base_url} - params = {} + params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} {{#if modelMaxTokens}} {{#if (eq mantleApiFormat "chat_completions")}} params["max_completion_tokens"] = {{modelMaxTokens}} @@ -60,6 +63,9 @@ def load_model(): {{/if}} {{/if}} {{else}} +{{#if modelAdditionalParams}} +import json +{{/if}} from strands.models.bedrock import BedrockModel @@ -75,11 +81,17 @@ def load_model() -> BedrockModel: {{/if}} {{#if modelTopP}} top_p={{modelTopP}}, +{{/if}} +{{#if modelAdditionalParams}} + additional_args=json.loads({{pyJsonStr modelAdditionalParams}}), {{/if}} ) {{/if}} {{/if}} {{#if (eq modelProvider "OpenAI")}} +{{#if modelAdditionalParams}} +import json +{{/if}} import os {{#if (eq modelApiFormat "responses")}} @@ -116,7 +128,7 @@ def _get_api_key() -> str: def load_model(): """Get authenticated OpenAI model client.""" - params = {} + params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} {{#if modelMaxTokens}} params["{{#if (eq modelApiFormat "responses")}}max_output_tokens{{else}}max_completion_tokens{{/if}}"] = {{modelMaxTokens}} {{/if}} @@ -133,6 +145,9 @@ def load_model(): ) {{/if}} {{#if (eq modelProvider "Gemini")}} +{{#if modelAdditionalParams}} +import json +{{/if}} import os from strands.models.gemini import GeminiModel @@ -165,7 +180,7 @@ def _get_api_key() -> str: def load_model() -> GeminiModel: """Get authenticated Gemini model client.""" - params = {} + params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} {{#if modelMaxTokens}} params["max_output_tokens"] = {{modelMaxTokens}} {{/if}} @@ -186,7 +201,7 @@ def load_model() -> GeminiModel: {{/if}} {{#if (eq modelProvider "LiteLLM")}} import os -{{#if litellmAdditionalParams}} +{{#if modelAdditionalParams}} import json {{/if}} @@ -231,7 +246,7 @@ def load_model() -> LiteLLMModel: {{#if litellmApiBase}} client_args["api_base"] = {{safeJson litellmApiBase}} {{/if}} - params = {{#if litellmAdditionalParams}}json.loads({{pyJsonStr litellmAdditionalParams}}){{else}}{}{{/if}} + params = {{#if modelAdditionalParams}}json.loads({{pyJsonStr modelAdditionalParams}}){{else}}{}{{/if}} {{#if modelMaxTokens}} params["max_tokens"] = {{modelMaxTokens}} {{/if}} diff --git a/src/core/project/manager.export.test.ts b/src/core/project/manager.export.test.ts index 1843621013..de3f458eca 100644 --- a/src/core/project/manager.export.test.ts +++ b/src/core/project/manager.export.test.ts @@ -86,6 +86,45 @@ function exportInput(overrides: Partial = {}): ExportHarness } describe("FsProjectManager.exportHarness rendered tree", () => { + test("loads only the MCP tools allowedTools selects", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject, { + allowedTools: ["@exa/web_*"], + tools: [ + { + type: "remote_mcp", + name: "exa", + config: { remoteMcp: { url: "https://mcp.exa.ai/mcp" } }, + }, + ], + }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const client = await Bun.file(join(result.agentPath, "mcp_client", "client.py")).text(); + expect(client).toContain('tool_filters=_allowed_tools("exa", "web_*")'); + }); + + test("merges service model parameters under the explicit settings", async () => { + const { manager: subject } = manager(); + const project = await projectWithHarness(subject); + const spec = HarnessSpecSchema.parse({ + name: "remote", + model: { provider: "bedrock", modelId: "us.amazon.nova-lite-v1:0", temperature: 0.2 }, + }); + + const result = await drain( + subject.exportHarness(project, { + prefetched: { spec, modelAdditionalParams: { top_k: 5 } }, + targetAgentName: "remoteAgent", + }), + ); + + const loadModel = await Bun.file(join(result.agentPath, "model", "load.py")).text(); + expect(loadModel).toContain("additional_args=json.loads("); + expect(loadModel).toContain("top_k"); + }); + test("renders invocation-scoped native Strands limits without a custom hook", async () => { const { manager: subject } = manager(); const project = await projectWithHarness(subject, { diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index d02a8bdf3c..aaed77cea2 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -977,6 +977,7 @@ export class FsProjectManager implements ProjectManager { systemPrompt, projectSpec, sourceNotes: input.prefetched?.notes, + modelAdditionalParams: input.prefetched?.modelAdditionalParams, }); yield { type: "step", message: `Rendering agent code at 'app/${targetAgentName}'` }; diff --git a/src/core/project/templates/export.test.ts b/src/core/project/templates/export.test.ts index 47515860b3..14f8e841d7 100644 --- a/src/core/project/templates/export.test.ts +++ b/src/core/project/templates/export.test.ts @@ -183,13 +183,42 @@ describe("mapHarnessToExportPlan model mapping", () => { expect(result.context.modelProvider).toBe("LiteLLM"); expect(result.context.strandsExtras).toBe("litellm"); expect(result.context.litellmApiBase).toBe("https://litellm.example"); - expect(result.context.litellmAdditionalParams).toEqual({ max_retries: 2 }); + expect(result.context.modelAdditionalParams).toEqual({ max_retries: 2 }); expect(result.context.modelMaxTokens).toBe("300"); expect(result.context.modelTemperature).toBe("0.1"); expect(result.context.modelTopP).toBe("0.7"); expect(result.notes).toEqual([]); }); + test("grants bedrock:ApplyGuardrail when the model parameters set a guardrail", () => { + const guarded = (guardrailIdentifier: string) => + plan({ + modelAdditionalParams: { guardrailConfig: { guardrailIdentifier, guardrailVersion: "1" } }, + }).policyFiles["bedrock-guardrail-policy.json"]; + expect(guarded("gr-123")).toEqual({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "bedrock:ApplyGuardrail", + Resource: "arn:aws:bedrock:*:*:guardrail/gr-123", + }, + ], + }); + const arn = "arn:aws:bedrock:us-east-1:111122223333:guardrail/gr-123"; + expect(guarded(arn)).toMatchObject({ Statement: [{ Resource: arn }] }); + expect(plan({}).policyFiles["bedrock-guardrail-policy.json"]).toBeUndefined(); + }); + + test("threads service-only model parameters into the render context", () => { + const result = plan({ + modelAdditionalParams: { performanceConfig: { latency: "optimized" } }, + }); + expect(result.context.modelAdditionalParams).toEqual({ + performanceConfig: { latency: "optimized" }, + }); + }); + test("warns when a keyless LiteLLM model is not Bedrock-backed", () => { const result = plan({ spec: harness({ model: { provider: "lite_llm", modelId: "openai/gpt-4.1" } }), @@ -353,7 +382,7 @@ describe("mapHarnessToExportPlan tools", () => { const restricted = plan({ spec: harness({ - allowedTools: ["@builtin/shell", "exa"], + allowedTools: ["@builtin/shell", "@exa"], tools: [ { type: "remote_mcp", @@ -382,18 +411,85 @@ describe("mapHarnessToExportPlan tools", () => { }); }); +describe("mapHarnessToExportPlan allowedTools selection", () => { + test("a bare name or glob selects builtins only", () => { + const shellOnly = plan({ spec: harness({ allowedTools: ["shell"] }) }); + expect(shellOnly.context.hasShell).toBe(true); + expect(shellOnly.context.hasFileOperations).toBe(false); + + const fileGlob = plan({ spec: harness({ allowedTools: ["file_*"] }) }); + expect(fileGlob.context.hasShell).toBe(false); + expect(fileGlob.context.hasFileOperations).toBe(true); + }); + + test("keeps an MCP server that @server or * allows and drops it otherwise", () => { + const exa = { + type: "remote_mcp", + name: "exa", + config: { remoteMcp: { url: "https://mcp.exa.ai/mcp" } }, + }; + const servers = (allowedTools: string[]) => + ( + plan({ spec: harness({ tools: [exa], allowedTools }) }).context.remoteMcpTools as + { name: string }[] | undefined + )?.map((tool) => tool.name); + expect(servers(["*"])).toEqual(["exa"]); + expect(servers(["@exa"])).toEqual(["exa"]); + expect(servers(["@e*/search"])).toEqual(["exa"]); + // A bare pattern selects builtins only. + expect(servers(["exa"])).toBeUndefined(); + expect(servers(["@other"])).toBeUndefined(); + }); + + test("narrows an MCP server to the tools @server/tool selects", () => { + const exa = { + type: "remote_mcp", + name: "exa", + config: { remoteMcp: { url: "https://mcp.exa.ai/mcp" } }, + }; + const patterns = (allowedTools: string[]) => + ( + plan({ spec: harness({ tools: [exa], allowedTools }) }).context.remoteMcpTools as + { toolPatterns?: string[] }[] | undefined + )?.map((tool) => tool.toolPatterns); + expect(patterns(["@exa/search", "@e*/web_*"])).toEqual([["search", "web_*"]]); + expect(patterns(["@exa", "@exa/search"])).toEqual([undefined]); + expect(patterns(["*"])).toEqual([undefined]); + }); + + test("selects an inline function by @name, not by its bare name", () => { + const inline = { + type: "inline_function", + name: "lookup", + config: { inlineFunction: { description: "d", inputSchema: { type: "object" } } }, + }; + const names = (allowedTools: string[]) => + ( + plan({ spec: harness({ tools: [inline], allowedTools }) }).context.inlineFunctionTools as + { name: string }[] | undefined + )?.map((tool) => tool.name); + expect(names(["@lookup"])).toEqual(["lookup"]); + expect(names(["lookup"])).toBeUndefined(); + }); +}); + describe("matchesAllowedTools", () => { test.each([ - ["*", "anything", true], - ["exa", "exa", true], - ["e*", "exa", true], - ["@builtin/shell", "builtin/shell", true], - ["@builtin", "builtin/shell", true], - ["@server/tool", "server_tool", true], - ["exa", "other", false], - ["@builtin/shell", "builtin/file_operations", false], - ])("pattern %s vs %s -> %p", (pattern, name, expected) => { - expect(matchesAllowedTools(name, [pattern])).toBe(expected); + ["*", "exa", "search", true], + // A bare pattern is a glob over builtin names only. + ["shell", "builtin", "shell", true], + ["file_*", "builtin", "file_operations", true], + ["exa", "exa", "exa", false], + ["@builtin", "builtin", "shell", true], + ["@builtin/shell", "builtin", "file_operations", false], + // "@server" allows every tool of a server; "@server/tool" globs its tools. + ["@exa", "exa", "search", true], + ["@e*", "exa", "search", true], + ["@exa/web_*", "exa", "web_search", true], + ["@exa/web_*", "exa", "crawl", false], + ["@exa", "builtin", "shell", false], + ])("pattern %s vs %s/%s -> %p", (pattern, server, tool, expected) => { + expect(matchesAllowedTools(server, tool, [pattern])).toBe(expected); }); }); diff --git a/src/core/project/templates/export.ts b/src/core/project/templates/export.ts index 596305af47..702fce355c 100644 --- a/src/core/project/templates/export.ts +++ b/src/core/project/templates/export.ts @@ -50,6 +50,8 @@ export interface HarnessExportInput { projectSpec: ProjectSpec; /** Notes collected while converting a service response into a local harness spec. */ sourceNotes?: ExportNote[]; + /** Service model additionalParams, which the local harness spec only holds for lite_llm. */ + modelAdditionalParams?: Record; } /** The pure mapping result; the project manager executes it against the filesystem. */ @@ -139,7 +141,7 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport }); } - const model = resolveModel(spec, projectSpec, credentials, notes); + const model = resolveModel(spec, projectSpec, credentials, notes, input.modelAdditionalParams); const memory = resolveMemory(spec, projectSpec, notes); const tools = resolveTools( spec, @@ -284,11 +286,16 @@ function resolveModel( projectSpec: ProjectSpec, credentials: Credential[], notes: ExportNote[], + serviceAdditionalParams: Record | undefined, ): ModelResolution { const model = spec.model; + const additionalParams = serviceAdditionalParams ?? model.additionalParams; const context: Record = { modelId: model.modelId, modelApiFormat: model.apiFormat, + // Provider-specific parameters, passed through to the model provider unchanged. + modelAdditionalParams: + additionalParams && Object.keys(additionalParams).length > 0 ? additionalParams : undefined, // Stringified so a legal 0 (temperature/topP) stays truthy for {{#if}}. modelMaxTokens: model.maxTokens !== undefined ? String(model.maxTokens) : undefined, modelTemperature: model.temperature !== undefined ? String(model.temperature) : undefined, @@ -330,7 +337,28 @@ function resolveModel( }, }; } - return { context }; + const guardrail = (additionalParams?.guardrailConfig as { guardrailIdentifier?: unknown }) + ?.guardrailIdentifier; + if (typeof guardrail !== "string") return { context }; + // A guardrail in the request needs bedrock:ApplyGuardrail, which the default grant lacks. + return { + context, + policyFile: { + name: "bedrock-guardrail-policy.json", + doc: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "bedrock:ApplyGuardrail", + Resource: guardrail.startsWith("arn:") + ? guardrail + : `arn:aws:bedrock:*:*:guardrail/${guardrail}`, + }, + ], + }, + }, + }; } case "open_ai": case "gemini": { @@ -351,9 +379,6 @@ function resolveModel( context.modelProvider = "LiteLLM"; context.strandsExtras = "litellm"; if (model.apiBase) context.litellmApiBase = model.apiBase; - if (model.additionalParams && Object.keys(model.additionalParams).length > 0) { - context.litellmAdditionalParams = model.additionalParams; - } if (model.apiKeyArn) { attachIdentityProvider( context, @@ -508,6 +533,8 @@ interface ToolsResolution { name: string; pythonName: string; url: string; + /** Tool patterns from `@server/tool` selectors; undefined loads every tool of the server. */ + toolPatterns?: string[]; headerCredentials?: { headerKey: string; credentialName: string; @@ -537,7 +564,11 @@ function resolveTools( }; for (const tool of spec.tools) { - if (!matchesAllowedTools(tool.name, allowedPatterns)) continue; + const allowed = + tool.type === "inline_function" + ? matchesAllowedTools(tool.name, tool.name, allowedPatterns) + : isServerAllowed(tool.name, allowedPatterns); + if (!allowed) continue; switch (tool.type) { case "inline_function": { @@ -603,6 +634,7 @@ function resolveTools( pythonName: toolPythonName, url: cfg.url, headerCredentials, + toolPatterns: serverToolPatterns(tool.name, allowedPatterns), }); break; } @@ -880,34 +912,56 @@ function resolveTruncationConfig( // allowedTools matching (mirrors the harness runtime's _matches() semantics) // ============================================================================ -export function matchesAllowedTools(toolName: string, patterns: string[]): boolean { +/** + * Whether allowedTools allows `tool` from `server`: builtins are served by "builtin", and each + * customer tool by its harness tool name. A bare pattern is a glob over builtin names ("shell", + * "file_*"); "@server" and "@server/tool" glob a server and its tools; "*" allows everything. + */ +export function matchesAllowedTools(server: string, tool: string, patterns: string[]): boolean { + if (patterns.includes("*")) return true; + return patterns.some((pattern) => { + const [pServer, pTool] = pattern.startsWith("@") + ? splitServerPattern(pattern) + : ["builtin", pattern]; + return fnmatch(pServer, server) && fnmatch(pTool, tool); + }); +} + +/** + * Whether allowedTools allows any tool from `server`. A server's tools are only known at runtime, + * so an MCP server is narrowed to them when it loads (see serverToolPatterns). + */ +function isServerAllowed(server: string, patterns: string[]): boolean { if (patterns.includes("*")) return true; + return patterns.some( + (pattern) => pattern.startsWith("@") && fnmatch(splitServerPattern(pattern)[0], server), + ); +} + +/** The tool patterns `@server/tool` selectors name, or undefined when all tools are allowed. */ +function serverToolPatterns(server: string, patterns: string[]): string[] | undefined { + const toolPatterns: string[] = []; for (const pattern of patterns) { - if (pattern === toolName) return true; - if (pattern.startsWith("@")) { - const slashIdx = pattern.indexOf("/", 1); - const pServer = slashIdx === -1 ? pattern.slice(1) : pattern.slice(1, slashIdx); - const pTool = slashIdx === -1 ? "*" : pattern.slice(slashIdx + 1); - const slashInName = toolName.indexOf("/"); - if (slashInName === -1) { - // MCP tools stored as "server_tool" flat names — keep legacy behaviour - if (fnmatch(`${pServer}_${pTool}`, toolName)) return true; - } else { - // Qualified names like "builtin/shell" - const nameServer = toolName.slice(0, slashInName); - const nameTool = toolName.slice(slashInName + 1); - if (fnmatch(pServer, nameServer) && fnmatch(pTool, nameTool)) return true; - } - } else if (fnmatch(pattern, toolName)) { - return true; - } + if (pattern === "*") return undefined; + if (!pattern.startsWith("@")) continue; + const [pServer, pTool] = splitServerPattern(pattern); + if (!fnmatch(pServer, server)) continue; + if (pTool === "*") return undefined; + toolPatterns.push(pTool); } - return false; + return toolPatterns; } -/** Builtins are keyed as "builtin/": only @builtin or @builtin/ patterns match. */ function isBuiltinIncluded(builtinName: string, patterns: string[]): boolean { - return matchesAllowedTools(`builtin/${builtinName}`, patterns); + return matchesAllowedTools("builtin", builtinName, patterns); +} + +/** Split "@server/tool" into its server and tool globs; "@server" allows every tool. */ +function splitServerPattern(pattern: string): [string, string] { + const slash = pattern.indexOf("/"); + return slash === -1 + ? [pattern.slice(1), "*"] + : [pattern.slice(1, slash), pattern.slice(slash + 1)]; } function fnmatch(pattern: string, str: string): boolean { diff --git a/src/handlers/project/export/harness.ts b/src/handlers/project/export/harness.ts index 09a6097b1a..e8206e093d 100644 --- a/src/handlers/project/export/harness.ts +++ b/src/handlers/project/export/harness.ts @@ -47,9 +47,11 @@ export const createExportHarnessHandler = (config: ExportProjectResourceConfig) if (!response.harness) { throw new ResourceNotFoundError(`no harness exists for '${flags.arn}'`); } - const { spec, systemPrompt, notes } = mapServiceHarnessToSpec(response.harness); + const { spec, systemPrompt, notes, modelAdditionalParams } = mapServiceHarnessToSpec( + response.harness, + ); input = { - prefetched: { spec, systemPrompt, notes }, + prefetched: { spec, systemPrompt, notes, modelAdditionalParams }, targetAgentName: resolveTargetAgentName(flags["target-agent-name"], spec.name), }; } else { diff --git a/src/handlers/project/export/serviceHarness.test.ts b/src/handlers/project/export/serviceHarness.test.ts index db7f0e30e8..7f9558fc4f 100644 --- a/src/handlers/project/export/serviceHarness.test.ts +++ b/src/handlers/project/export/serviceHarness.test.ts @@ -284,8 +284,8 @@ describe("mapServiceHarnessToSpec", () => { // The pinned CDK only maps additionalParams for lite_llm, so carrying it on another provider // would produce a harness.yaml that fails at synth. The lite_llm keep-path is already asserted // by "maps openai and litellm model configs" above. - test("notes additionalParams the CDK cannot map", () => { - const { spec, notes } = mapServiceHarnessToSpec( + test("keeps model settings the local spec cannot hold for the export", () => { + const bedrock = mapServiceHarnessToSpec( serviceHarness({ model: { bedrockModelConfig: { @@ -295,9 +295,24 @@ describe("mapServiceHarnessToSpec", () => { }, } as Partial), ); + // The spec feeds deploy, whose schema rejects the field outside lite_llm. + expect(bedrock.spec.model.additionalParams).toBeUndefined(); + expect(bedrock.modelAdditionalParams).toEqual({ custom_parameter: true }); + expect(bedrock.notes).toEqual([]); - expect(spec.model.additionalParams).toBeUndefined(); - expect(notes.map((note) => note.category)).toEqual([SERVICE_FIELD_OMITTED_NOTE_CATEGORY]); + const openAi = mapServiceHarnessToSpec( + serviceHarness({ + model: { + openAiModelConfig: { + modelId: "gpt-5", + apiKeyArn: + "arn:aws:bedrock-agentcore:us-west-2:111122223333:token-vault/default/apikeycredentialprovider/openai", + additionalParams: { reasoning: { effort: "low" } }, + }, + }, + } as Partial), + ); + expect(openAi.modelAdditionalParams).toEqual({ reasoning: { effort: "low" } }); }); test("notes external-memory tuning that cannot be wired automatically", () => { diff --git a/src/handlers/project/export/serviceHarness.ts b/src/handlers/project/export/serviceHarness.ts index 79a333d79b..f252591f0d 100644 --- a/src/handlers/project/export/serviceHarness.ts +++ b/src/handlers/project/export/serviceHarness.ts @@ -45,6 +45,8 @@ export function mapServiceHarnessToSpec(harness: Harness): { spec: HarnessSpec; systemPrompt?: string; notes: ExportNote[]; + /** Service model additionalParams, which the local harness spec only holds for lite_llm. */ + modelAdditionalParams?: Record; } { const notes: ExportNote[] = []; const promptBlocks = harness.systemPrompt ?? []; @@ -66,7 +68,7 @@ export function mapServiceHarnessToSpec(harness: Harness): { const candidate = clean({ name: harness.harnessName, - model: mapModel(harness.model, notes), + model: mapModel(harness.model), tools: (harness.tools ?? []).map((tool) => clean({ type: tool.type, @@ -97,10 +99,16 @@ export function mapServiceHarnessToSpec(harness: Harness): { { cause: parsed.error }, ); } - return { spec: parsed.data, systemPrompt, notes }; + const { bedrockModelConfig, openAiModelConfig, geminiModelConfig } = harness.model ?? {}; + const params = (bedrockModelConfig ?? openAiModelConfig ?? geminiModelConfig)?.additionalParams; + const modelAdditionalParams = + typeof params === "object" && params !== null && !Array.isArray(params) + ? (params as Record) + : undefined; + return { spec: parsed.data, systemPrompt, notes, modelAdditionalParams }; } -function mapModel(model: Harness["model"], notes: ExportNote[]): Record { +function mapModel(model: Harness["model"]): Record { if (model?.bedrockModelConfig) { const c = model.bedrockModelConfig; return clean({ @@ -110,7 +118,6 @@ function mapModel(model: Harness["model"], notes: ExportNote[]): Record the flat local skill shape. */ function mapSkill( skill: ApiHarnessSkill, diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index a9c5b2827f..779c65a745 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -366,6 +366,7 @@ export type ExportHarnessInput = { spec: z.output; systemPrompt?: string; notes?: ExportNote[]; + modelAdditionalParams?: Record; }; /** Name of the runtime agent to generate. */ targetAgentName: string;