Skip to content

The stored route drops a result class's structured and keepWhenWithheld getters, so a script gets structuredContent: null #649

Open

Description

@nissa-seru

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 own structured (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 success and data, and dropped structured and keepWhenWithheld. The plain object's kept all four.
  • The script's answer for the class instance has structuredContent: null, and its content is data as text. For the plain object, it has the structured rows.
  • keptWhenWithheld keeps 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions