Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions src/assets/templates/export-harness-python/model/load.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}}
Expand All @@ -60,6 +63,9 @@ def load_model():
{{/if}}
{{/if}}
{{else}}
{{#if modelAdditionalParams}}
import json
{{/if}}
from strands.models.bedrock import BedrockModel


Expand All @@ -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")}}
Expand Down Expand Up @@ -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}}
Expand All @@ -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
Expand Down Expand Up @@ -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}}
Expand All @@ -186,7 +201,7 @@ def load_model() -> GeminiModel:
{{/if}}
{{#if (eq modelProvider "LiteLLM")}}
import os
{{#if litellmAdditionalParams}}
{{#if modelAdditionalParams}}
import json
{{/if}}

Expand Down Expand Up @@ -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}}
Expand Down
20 changes: 20 additions & 0 deletions src/core/project/manager.export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,26 @@ function exportInput(overrides: Partial<ExportHarnessInput> = {}): ExportHarness
}

describe("FsProjectManager.exportHarness rendered tree", () => {
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, {
Expand Down
1 change: 1 addition & 0 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}'` };
Expand Down
84 changes: 72 additions & 12 deletions src/core/project/templates/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,22 @@ 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("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" } }),
Expand Down Expand Up @@ -353,7 +362,7 @@ describe("mapHarnessToExportPlan tools", () => {

const restricted = plan({
spec: harness({
allowedTools: ["@builtin/shell", "exa"],
allowedTools: ["@builtin/shell", "@exa"],
tools: [
{
type: "remote_mcp",
Expand Down Expand Up @@ -382,18 +391,69 @@ 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("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);
});
});

Expand Down
74 changes: 45 additions & 29 deletions src/core/project/templates/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}

/** The pure mapping result; the project manager executes it against the filesystem. */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -284,11 +286,16 @@ function resolveModel(
projectSpec: ProjectSpec,
credentials: Credential[],
notes: ExportNote[],
serviceAdditionalParams: Record<string, unknown> | undefined,
): ModelResolution {
const model = spec.model;
const additionalParams = serviceAdditionalParams ?? model.additionalParams;
const context: Record<string, unknown> = {
modelId: model.modelId,
modelApiFormat: model.apiFormat,
// Provider parameters; the explicit model settings below take precedence over them.
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,
Expand Down Expand Up @@ -351,9 +358,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,
Expand Down Expand Up @@ -537,7 +541,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": {
Expand Down Expand Up @@ -880,34 +888,42 @@ 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;
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;
}
}
return false;
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`. The tools an MCP server, gateway, browser,
* or code interpreter provides are only known at runtime, so export keeps or drops it whole.
*/
function isServerAllowed(server: string, patterns: string[]): boolean {
if (patterns.includes("*")) return true;
return patterns.some(
(pattern) => pattern.startsWith("@") && fnmatch(splitServerPattern(pattern)[0], server),
);
}

/** Builtins are keyed as "builtin/<name>": only @builtin or @builtin/<name> 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 {
Expand Down
6 changes: 4 additions & 2 deletions src/handlers/project/export/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading