Skip to content

Commit 3c55da0

Browse files
osolmazpi-mono
andcommitted
fix: preserve literal strings when coercing tool parameter text
The review of the coercion found that fields can legitimately carry a bare string (a text-producing step output, a string workflow input), and the strict parse rejected those literals and broke both the extension and RPC submission paths. A transport-encoded structured value always serializes as JSON text starting with { or [, so only such text is parsed; every other string is a genuine literal and passes through untouched, and scalars stay literal because they are indistinguishable from the literal text. The runner's own validation still rejects shapes a workflow cannot accept. Co-Authored-By: GLM-5.3-Flash · Fireworks (price not published) <noreply@pi.dev> Generated-By: pi 0.87.1 (https://pi.dev)
1 parent cebde5f commit 3c55da0

2 files changed

Lines changed: 37 additions & 39 deletions

File tree

‎src/workflows/tool-input.ts‎

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,14 @@ function providerObjectSchema(variants: readonly TObject[]): TObject {
209209
}
210210

211211
/**
212-
* Structured (untyped) tool parameters. Some tool transports deliver these as raw
213-
* JSON text instead of a parsed value, and one workflow runner crash per start is
214-
* too late to learn about it. Coerce the text into a value before schema validation
215-
* so malformed text fails in the tool result with a clear message.
212+
* Structured (untyped) tool parameters. Some tool transports deliver these as
213+
* raw JSON text instead of a parsed value, and one workflow runner crash per
214+
* start is too late to learn about it. A transport-encoded structured value
215+
* always serializes as JSON text that starts with `{` or `[`, so only such
216+
* text is parsed; every other string is a genuine literal and passes through
217+
* untouched. Scalars stay literal because a transport-encoded scalar such as
218+
* `123` is indistinguishable from the literal text, and the runner's own
219+
* validation still rejects a value whose shape the workflow cannot accept.
216220
*/
217221
const structuredFieldsByAction: Readonly<Record<string, readonly string[]>> = {
218222
start: ["input"],
@@ -225,44 +229,33 @@ const structuredFieldsByAction: Readonly<Record<string, readonly string[]>> = {
225229
function coerceStructuredFields(
226230
action: string,
227231
value: Record<string, unknown>,
228-
label: string,
232+
_label: string,
229233
): Record<string, unknown> {
230234
const fields = structuredFieldsByAction[action];
231235
if (fields === undefined) return value;
232236
const coerced = { ...value };
233237
for (const field of fields) {
234238
const raw = coerced[field];
235239
if (typeof raw !== "string") continue;
236-
if (action === "answer" && field === "input") {
237-
// Free-text answers are legitimate: the command path wraps text that is not
238-
// JSON in { answer: text }, and the tool path must behave the same.
239-
const parsed = tryParseJsonText(raw);
240-
coerced[field] = parsed === undefined ? { answer: raw } : parsed;
240+
const trimmed = raw.trim();
241+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
242+
if (action === "answer" && field === "input") {
243+
// Free-text answers are legitimate: the command path wraps text that
244+
// is not JSON in { answer: text }, and the tool path behaves the same.
245+
coerced[field] = { answer: raw };
246+
}
241247
continue;
242248
}
243-
coerced[field] = parseJsonText(raw, `${label} ${field}`);
249+
try {
250+
coerced[field] = parseJson(raw);
251+
} catch {
252+
// Text that merely looks like JSON is a literal string; the runner's
253+
// input validation reports it if the shape cannot be accepted.
254+
}
244255
}
245256
return coerced;
246257
}
247258

248-
function tryParseJsonText(text: string): unknown {
249-
try {
250-
return parseJson(text);
251-
} catch {
252-
return undefined;
253-
}
254-
}
255-
256-
function parseJsonText(text: string, label: string): unknown {
257-
try {
258-
return parseJson(text);
259-
} catch (error) {
260-
throw new Error(`Invalid ${label}: the parameter arrived as text and is not valid JSON.`, {
261-
cause: error,
262-
});
263-
}
264-
}
265-
266259
function parseSelectedAction<Output>(
267260
parsers: Readonly<Record<string, ToolInputParser<Output>>>,
268261
value: unknown,

‎test/workflow-tool-input.test.ts‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,29 +103,34 @@ describe("workflow tool input", () => {
103103
});
104104
});
105105

106-
it("wraps unparseable answer text like the command path", () => {
106+
it("wraps free-text answer text like the command path", () => {
107107
expect(
108108
parseWorkflowToolInput({ action: "answer", requestId: "request-1", input: "approve now" }),
109109
).toEqual({ action: "answer", requestId: "request-1", input: { answer: "approve now" } });
110-
expect(
111-
parseWorkflowToolInput({ action: "answer", requestId: "request-1", input: '"plain"' }),
112-
).toEqual({ action: "answer", requestId: "request-1", input: "plain" });
113110
expect(
114111
parseWorkflowToolInput({ action: "answer", requestId: "request-1", input: "null" }),
115-
).toEqual({ action: "answer", requestId: "request-1", input: null });
112+
).toEqual({ action: "answer", requestId: "request-1", input: { answer: "null" } });
116113
});
117114

118-
it("rejects unparseable structured text in the tool result instead of the runner", () => {
119-
expect(() =>
115+
it("keeps literal strings for fields the workflow may receive as text", () => {
116+
expect(
117+
parseWorkflowToolInput({ action: "submit", requestId: "request-1", output: "just text" }),
118+
).toEqual({ action: "submit", requestId: "request-1", output: "just text" });
119+
expect(
120120
parseWorkflowToolInput({ action: "start", workflow: "monitor", input: "{task: check}" }),
121-
).toThrow("Invalid workflow input: the parameter arrived as text and is not valid JSON.");
122-
expect(() =>
121+
).toEqual({ action: "start", workflow: "monitor", input: "{task: check}" });
122+
expect(
123123
parseWorkflowSubmissionInput({
124124
action: "submit",
125125
requestId: "request-1",
126126
output: "{result:",
127127
}),
128-
).toThrow("Invalid workflow submission output: the parameter arrived as text");
128+
).toEqual({ action: "submit", requestId: "request-1", output: "{result:" });
129+
expect(parseWorkflowToolInput({ action: "start", workflow: "monitor", input: "123" })).toEqual({
130+
action: "start",
131+
workflow: "monitor",
132+
input: "123",
133+
});
129134
});
130135

131136
it("keeps object parameters unchanged", () => {

0 commit comments

Comments
 (0)