fix(blocks): trim whitespace around prompt-value keys (#16613)

### Why / What / How

**Why:** A prompt-values key with stray whitespace, such as `name ` from
a paste, leaves `{{name}}` empty in the prompt. The block gives no
error, so users can't tell why part of their prompt is missing.

**What:** Whitespace around prompt-values keys no longer breaks the
variable, at run time and in the builder.

**How:** A small helper strips whitespace from the keys before the
prompt is rendered, in the AI structured response block (which the other
AI text blocks delegate to) and in the orchestrator. Running it at run
time also fixes workflows that are already saved and keys that come in
over links. If a clean key and a padded key both exist, the clean one
wins, and a key that is only whitespace is dropped. In the builder, the
key editor trims a prompt-values key when it loses focus, so what the
user sees is what runs. A key of only whitespace falls back to the old
key, as an empty key already does. Other dictionary inputs share the
same key editor but keep their keys exactly as typed, since blocks such
as Find in Dictionary look keys up as they are.

Ticket: [SECRT-1328](https://linear.app/autogpt/issue/SECRT-1328)

### Changes 🏗️

- `backend/blocks/llm.py`: new `strip_prompt_value_keys` helper, used
before the prompt and system prompt are formatted.
- `backend/blocks/orchestrator.py`: uses the same helper.
- `WrapIfAdditionalTemplate.tsx`: trims the key on blur when the
dictionary is `prompt_values` (new `isPromptValuesEntry` in
`InputRenderer/helpers.ts`).
- Tests: helper cases and a block run in `test_llm.py`; an orchestrator
run in `test_orchestrator_execution_mode.py`; three RTL cases in
`DictionaryValues.test.tsx`.

### Agents and large language models used

- Claude Code with Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `pytest backend/blocks/test/test_llm.py` (mock-only): a block with
key `name ` and prompt `{{name}}` fills in the value
- [x] `pytest test_orchestrator_execution_mode.py` (mock-only): the
orchestrator fills `{{name}}` from key `name `
- [x] `vitest DictionaryValues.test.tsx`: a prompt-values key typed as `
name ` is saved as `name`; a key of only spaces keeps the old key;
another dictionary keeps ` name ` as typed
- [x] ruff, black, isort, pyright on the changed backend files;
prettier, eslint, `tsc --noEmit` on the frontend

#### For frontend changes (design system, see
`docs/engineering/design-system.md`):

- [x] `pnpm lint` passes, including the `design-system/*` rules; I did
not raise any count in
`scripts/eslint-rules/design-system-baseline.json` except for a moved,
renamed or split file (explained under **Changes**)
- [x] No `dark:`/`light:` classes, raw palette classes (`bg-zinc-200`)
or hex/`rgb()` colours (`bg-[#fff]`); colours use role tokens
(`bg-surface`, `text-foreground-secondary`)
- [x] No imports from `src/components/__legacy__`, `legacy/` paths or
`@/components/ui`, and no `LegacyButton` / `legacy-*` Button variants
- [x] Copy uses the `Text` atom with a `variant`/`tone`; primitives get
a `variant`/`size`. `className` holds layout, plus on `Text` only
truncation, line clamping, wrapping, breaking,
`text-balance`/`text-pretty` and alignment
- [x] Before/after screenshots at 1440 and 390 px are attached for
changes to `src/components/**`, `globals.css` or `tailwind*.ts`, or I
wrote "no pixel change" and how I checked: no pixel change; only the
blur handler's logic changed, no markup or classes
- [x] User-facing copy says workflow and Otto, never agent or graph
(root `AGENTS.md`, "User-facing copy")

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**): none

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Krzysztof CzerwinskiandClaude Opus 5.5 authored and GitHub committed 2026-11-04 08:50:11 +00:00
1 parent 98674c560f
commit bbbe18bf89
7 files changed
+189 -23

No files matched your search

+21 -2
View File
@@ -75,6 +75,25 @@ settings = Settings()
logger = TruncatedLogger(logging.getLogger(__name__), "[LLM-Block]")
fmt = TextFormatter(autoescape=False)
# str.strip() keeps U+FEFF, which the builder's JavaScript trim() removes.
_KEY_PADDING = re.compile(r"^[\s\ufeff]+|[\s\ufeff]+$")
def strip_prompt_value_keys(values: dict[str, str]) -> dict[str, str]:
"""Strip whitespace around prompt-value keys so `{{name}}` finds `name `.
A key that is already clean wins over one that only matches once stripped;
among padded keys that strip to the same name, the first one wins. A key
that is only whitespace is dropped.
"""
stripped: dict[str, str] = {}
for key, value in values.items():
clean = _KEY_PADDING.sub("", key)
if clean and (clean == key or clean not in values):
stripped.setdefault(clean, value)
return stripped
# HTTP status codes for user-caused errors that should not be reported to Sentry.
USER_ERROR_STATUS_CODES = (401, 403, 429)
@@ -676,8 +695,8 @@ class AIStructuredResponseGeneratorBlock(AIBlockBase):
logger.debug(f"Calling LLM with input data: {input_data}")
prompt = [json.to_dict(p) for p in input_data.conversation_history or [] if p]
values = input_data.prompt_values
if values:
if input_data.prompt_values:
values = strip_prompt_value_keys(input_data.prompt_values)
input_data.prompt = await fmt.format_string(input_data.prompt, values)
input_data.sys_prompt = await fmt.format_string(
input_data.sys_prompt, values
@@ -2109,8 +2109,8 @@ class OrchestratorBlock(Block):
)
prompt.extend(tool_output)
values = input_data.prompt_values
if values:
if input_data.prompt_values:
values = llm.strip_prompt_value_keys(input_data.prompt_values)
input_data.prompt = await llm.fmt.format_string(input_data.prompt, values)
input_data.sys_prompt = await llm.fmt.format_string(
input_data.sys_prompt, values
@@ -2365,3 +2365,55 @@ def test_stagehand_keeps_remote_model_credentials_required(block_name):
]
== []
)
@pytest.mark.parametrize(
"values, expected",
[
({"name ": "Ada"}, {"name": "Ada"}),
({" name\t": "Ada", "topic": "maths"}, {"name": "Ada", "topic": "maths"}),
({"name ": "padded", "name": "clean"}, {"name": "clean"}),
({"name": "clean", " name": "padded"}, {"name": "clean"}),
({" name": "first", "name ": "second"}, {"name": "first"}),
({"\ufeffname\u00a0": "Ada"}, {"name": "Ada"}),
({" ": "blank", "name": "Ada"}, {"name": "Ada"}),
({}, {}),
],
)
def test_strip_prompt_value_keys(values, expected):
assert llm.strip_prompt_value_keys(values) == expected
@pytest.mark.asyncio
async def test_a_prompt_value_key_with_whitespace_fills_its_variable():
block = llm.AIStructuredResponseGeneratorBlock()
sent: dict[str, Any] = {}
async def fake_llm_call(*, prompt, **_):
sent["prompt"] = prompt
return llm.LLMResponse(
raw_response="",
prompt=[],
response="Hello",
tool_calls=None,
prompt_tokens=1,
completion_tokens=1,
reasoning=None,
)
input_data = llm.AIStructuredResponseGeneratorBlock.Input(
prompt="Greet {{name}}",
sys_prompt="You talk to {{name}}.",
prompt_values={"name ": "Ada"},
expected_format={},
model=llm.DEFAULT_LLM_MODEL,
credentials=_TEST_AI_CREDENTIALS,
)
with patch.object(block, "llm_call", new=AsyncMock(side_effect=fake_llm_call)):
async for _ in block.run(input_data, credentials=llm.TEST_CREDENTIALS):
pass
contents = [message["content"] for message in sent["prompt"]]
assert any("You talk to Ada." in content for content in contents)
assert any("Greet Ada" in content for content in contents)
@@ -578,3 +578,45 @@ async def test_built_in_mode_stopped_mid_call_records_estimated_input() -> None:
assert block.execution_stats.abandoned_input_token_count > 100
assert block.execution_stats.input_token_count == 0
# ---------------------------------------------------------------------------
# prompt_values keys with whitespace still fill their variables
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_a_prompt_value_key_with_whitespace_fills_its_variable():
block = OrchestratorBlock()
input_data = _make_input_data(model=LLMModel.GPT4O)
input_data.prompt = "Greet {{name}}"
input_data.sys_prompt = "You talk to {{name}}."
input_data.prompt_values = {"name ": "Ada"}
# The extended-thinking provider check fails right after the prompt is
# filled in, so the run stops before any model call.
with (
patch.object(
block,
"_create_tool_node_signatures",
new_callable=AsyncMock,
return_value=[],
),
pytest.raises(ValueError, match="Anthropic-compatible"),
):
await _collect_run_outputs(
block,
input_data,
credentials=MagicMock(),
graph_id="g",
node_id="n",
graph_exec_id="ge",
node_exec_id="ne",
user_id="u",
graph_version=1,
execution_context=MagicMock(),
execution_processor=MagicMock(),
)
assert input_data.prompt == "Greet Ada"
assert input_data.sys_prompt == "You talk to Ada."
@@ -9,23 +9,29 @@ import { render } from "@/tests/integrations/test-utils";
import { FormRenderer } from "../FormRenderer";
interface DictionaryData {
prompt_values: Record<string, string>;
type DictionaryData = Record<string, Record<string, string>>;
function schemaFor(field: string): RJSFSchema {
return {
type: "object",
properties: {
[field]: {
title: "Dictionary",
type: "object",
additionalProperties: { type: "string" },
},
},
};
}
const schema: RJSFSchema = {
type: "object",
properties: {
prompt_values: {
title: "Prompt values",
type: "object",
additionalProperties: { type: "string" },
},
},
};
function DictionaryForm({ values }: { values: Record<string, string> }) {
const [data, setData] = useState<DictionaryData>({ prompt_values: values });
function DictionaryForm({
values,
field = "prompt_values",
}: {
values: Record<string, string>;
field?: string;
}) {
const [data, setData] = useState<DictionaryData>({ [field]: values });
function handleChange(event: IChangeEvent<DictionaryData>) {
if (event.formData) setData(event.formData);
@@ -34,7 +40,7 @@ function DictionaryForm({ values }: { values: Record<string, string> }) {
return (
<>
<FormRenderer
jsonSchema={schema}
jsonSchema={schemaFor(field)}
handleChange={handleChange}
uiSchema={{}}
initialValues={data}
@@ -50,10 +56,10 @@ function DictionaryForm({ values }: { values: Record<string, string> }) {
);
}
function readDictionary(): Record<string, string> {
function readDictionary(field = "prompt_values"): Record<string, string> {
return JSON.parse(
screen.getByLabelText("Stored dictionary").textContent || "{}",
).prompt_values;
)[field];
}
describe("Dictionary input preservation", () => {
@@ -104,6 +110,44 @@ describe("Dictionary input preservation", () => {
});
});
it("trims whitespace around a prompt-values key when it is blurred", async () => {
render(<DictionaryForm values={{ title: "Keep this value" }} />);
const key = screen.getByDisplayValue("title");
fireEvent.change(key, { target: { value: " name \t" } });
fireEvent.blur(key);
await waitFor(() => {
expect(readDictionary()).toEqual({ name: "Keep this value" });
expect(screen.getByDisplayValue("name")).toBeDefined();
});
});
it("keeps whitespace in the keys of other dictionaries", async () => {
render(<DictionaryForm field="values" values={{ title: "Keep this" }} />);
const key = screen.getByDisplayValue("title");
fireEvent.change(key, { target: { value: " name " } });
fireEvent.blur(key);
await waitFor(() => {
expect(readDictionary("values")).toEqual({ " name ": "Keep this" });
});
});
it("keeps the old key when a key of only whitespace is blurred", async () => {
render(<DictionaryForm values={{ title: "Keep this value" }} />);
const key = screen.getByDisplayValue("title");
fireEvent.change(key, { target: { value: " " } });
fireEvent.blur(key);
await waitFor(() => {
expect(readDictionary()).toEqual({ title: "Keep this value" });
expect(screen.getByDisplayValue("title")).toBeDefined();
});
});
it("removes an entry only through its remove action", async () => {
render(<DictionaryForm values={{ title: "Remove this value" }} />);
@@ -12,6 +12,7 @@ import {
} from "@rjsf/utils";
import { Input } from "@/components/atoms/Input/Input";
import { isPromptValuesEntry } from "../../helpers";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
export default function WrapIfAdditionalTemplate(
@@ -64,10 +65,13 @@ export default function WrapIfAdditionalTemplate(
const keyId = `${id}-key`;
function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
if (e.currentTarget.value === "") {
const typed = e.currentTarget.value;
const key = isPromptValuesEntry(handleId) ? typed.trim() : typed;
if (key === "") {
e.currentTarget.value = label;
return;
}
e.currentTarget.value = key;
onKeyRenameBlur(e);
}
@@ -268,3 +268,8 @@ export function ensurePathExists(
return modified;
}
export function isPromptValuesEntry(handleId: unknown) {
if (typeof handleId !== "string") return false;
return parseHandleIdToPath(handleId)[0]?.key === "prompt_values";
}