Skip to content

A mid-run change to the selected tool set moves the head of the system prompt and re-bills the conversation #10024

Description

@tinoy1336

What happened?

A change to the selected tool set made while a run is in progress moves the head of the
system prompt, so the run's next request re-bills from that point. The head is where the
- <tool>: <snippet> bullets are rendered, and it re-derives the selection from the live
set every turn, so adding or removing one selected tool rewrites it.

Measured on one session (72 responses, provider deepseek): five requests followed such a
change, each exactly one tool bullet (266 characters); a sixth head move came from the
injected path described in the comment below. Two of the six re-billed the whole prompt —
156,362 and 192,928 input tokens, with only 1,664 and 128 tokens cached. The other four
re-billed 2.7–17.8% of the prompt.

Steps to reproduce

Validated with pi -ne, so no ambient extensions were loaded. One small extension is
enough — it registers a tool with a promptSnippet, selects it for the run, and removes it
from the live set during the first tool call:

// repro.ts — pi --mode rpc -ne -e ./capture.ts -e ./repro.ts
import { Type } from "typebox";

export default function (pi: any) {
  const TOOL = "probe_enable";
  pi.registerTool({
    name: TOOL,
    description: "Scratch tool that exists so the system prompt can carry a bullet for it.",
    parameters: Type.Object({}, { additionalProperties: false }),
    promptSnippet: `${TOOL} is installed. Call it to enable the scratch tools.`,
    async execute() { return { content: [{ type: "text", text: "ok" }] }; },
  });

  let removed = false;
  pi.on("tool_call", () => {
    if (removed) return undefined;
    removed = true;
    pi.setActiveTools(pi.getActiveTools().filter((n) => n !== TOOL));
    return undefined;
  });

  pi.on("before_agent_start", (event) => {
    const active = pi.getActiveTools();
    if (!active.includes(TOOL)) pi.setActiveTools([...active, TOOL]);
    const selected = event.systemPromptOptions?.selectedTools;
    if (Array.isArray(selected) && !selected.includes(TOOL)) selected.push(TOOL);
    return undefined;
  });
}

Capture what the provider is sent (wrap globalThis.fetch and write init.body to a
file), then send one prompt that makes the model call any tool:

  • before the tool call: system prompt 13,796 chars, - probe_enable: … present;
  • after it: 13,716 chars, and the only difference in the system prompt is that bullet
    line (79 chars + newline);
  • later requests stay there, so it is billed once — but the whole head moved.

Expected behavior

A tool-set change made during a run should not rewrite the head of a prompt that has
already been sent, or — if it must — the cost should not be the conversation behind it.
Rendering the bullets from the same set the request will carry would close the
prompt/payload half; applying selection changes at a run boundary would close the rest.
Happy to prepare a PR if that is the direction you prefer.

Version

0.87.1 (Linux; provider deepseek, model deepseek-flash, openai-completions).

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