Repository navigation
The stored route drops a result class's structured and keepWhenWithheld getters, so a script gets structuredContent: null #649
Description
checkedToolResult (src/module-registry.ts:1022) is the form every module tool's result takes on the stored route, a model's call and a script's call alike (storableToolCall, :305, from dispatchToolCallEvent, src/framework.ts:21045). It reads endTurn, error, success, isError and data by name, and then only the result's own enumerable keys (Object.keys, :1051). So when a module returns an instance of a class whose structured and keepWhenWithheld are getters on its prototype, both are lost to what reads the stored result:
- a script calling the tool gets
structuredContent: null, since its answer reads an ownstructured(scriptResultAnswer,src/framework.ts:20203,:20264); - the tool-result guard, which keeps the fields a withheld result names in
keepWhenWithheld(keptWhenWithheld,src/tool-result-guard.ts:96-97), keeps none.
The function's own description says the opposite: "Each field a consumer reads is read by name, so a result class's getters count" (:991-992). The in-process route reads every field ToolResult declares by name (TOOL_RESULT_FIELDS, :902).
A run at main 97ca7eba (Linux, Node 24.22.0) passed storableToolResult a class instance whose success, data, structured and keepWhenWithheld are prototype getters, and a plain object with the same four fields as its own. It then handed each stored result to a framework's own scriptResultAnswer and to keptWhenWithheld:
- The class instance's stored result kept
successanddata, and droppedstructuredandkeepWhenWithheld. The plain object's kept all four. - The script's answer for the class instance has
structuredContent: null, and its content isdataas text. For the plain object, it has the structured rows. keptWhenWithheldkeeps nothing of the class instance's result (undefined), and{"path":"notes.txt"}of the plain object's.
The probe and its output
// Probe (Imogen-2490): a module's result class whose ToolResult fields are
// prototype getters, through the stored route (storableToolResult, which a
// model's call and a script's call take), then what reads the stored result:
// a script's answer (the framework's scriptResultAnswer) and the tool-result
// guard's keptWhenWithheld. Run with tsx from the agent-framework checkout's root.
import { tmpdir } from 'node:os';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { storableToolResult } from '../src/module-registry.js';
import { keptWhenWithheld } from '../src/tool-result-guard.js';
import { AgentFramework } from '../src/index.js';
import { MockMembrane } from '../test/helpers/mock-membrane.js';
console.log(`node ${process.version}, ${process.platform}`);
class LookupResult {
get success() { return true; }
get data() { return { path: 'notes.txt', rows: 2 }; }
get structured() { return { rows: [{ id: 1 }, { id: 2 }] }; }
get keepWhenWithheld() { return ['path']; }
}
const plainResult = { success: true, data: { path: 'notes.txt', rows: 2 }, structured: { rows: [{ id: 1 }, { id: 2 }] }, keepWhenWithheld: ['path'] };
const call = { id: 'c1', name: 'db--lookup', input: {} };
const stored = storableToolResult(call as never, new LookupResult());
const storedPlain = storableToolResult(call as never, plainResult);
console.log('class instance, stored:', JSON.stringify(stored));
console.log('plain object, stored: ', JSON.stringify(storedPlain));
// What a script gets for each stored result: the framework's own scriptResultAnswer.
const tempDir = mkdtempSync(join(tmpdir(), 'f291-'));
const framework = await AgentFramework.create({
storePath: join(tempDir, 'test.chronicle'),
membrane: new MockMembrane().asMembrane(),
agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'You are helpful.' }],
modules: [],
});
type Answer = { json: string };
const answer = (r: unknown) => (framework as unknown as { scriptResultAnswer(r: unknown, tool: string): Promise<Answer> }).scriptResultAnswer(r, 'db--lookup');
console.log(`\na script's answer, class instance: ${(await answer(stored)).json}`);
console.log(`a script's answer, plain object: ${(await answer(storedPlain)).json}`);
// What the tool-result guard keeps of each when the result is withheld.
console.log(`\nkeptWhenWithheld, class instance: ${JSON.stringify(keptWhenWithheld(stored as never, 10_000))}`);
console.log(`keptWhenWithheld, plain object: ${JSON.stringify(keptWhenWithheld(storedPlain as never, 10_000))}`);
await framework.stop();
rmSync(tempDir, { recursive: true, force: true });
process.exit(0);node v24.22.0, linux
class instance, stored: {"success":true,"data":{"path":"notes.txt","rows":2}}
plain object, stored: {"success":true,"data":{"path":"notes.txt","rows":2},"structured":{"rows":[{"id":1},{"id":2}]},"keepWhenWithheld":["path"]}
a script's answer, class instance: {"content":[{"type":"text","text":"{\"path\":\"notes.txt\",\"rows\":2}"}],"structuredContent":null,"isError":false}
a script's answer, plain object: {"content":[],"structuredContent":{"rows":[{"id":1},{"id":2}]},"isError":false}
keptWhenWithheld, class instance: undefined
keptWhenWithheld, plain object: "{\"path\":\"notes.txt\"}"
Our internal review of #608 found it, by reading, and Imogen-2490, that change's author, judged it main's: #608 made the in-process route read every declared field by name and left the stored route as it was.
A shape to weigh: the stored route reads the fields ToolResult declares by name, as the in-process route does, before it takes the other own fields.
Written by Imogen-2490.
Activity