Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";

import { streamSimple, type Context, type Model } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { compactContext } from "../../src/service/turn-system/compaction/algorithm/compact-context.js";
import { createCheckpointSession } from "../../src/service/turn-system/compaction/execution/checkpoint-provider.js";

const summary =
"The synthetic file was read. Continue with the pending user request.";

function history(model: Model<"openai-completions">): Context["messages"] {
return [
{ role: "user", content: "Read the synthetic file.", timestamp: 1 },
{
role: "assistant",
content: [
{
type: "toolCall",
id: "read-1",
name: "read",
arguments: { path: "fixture.txt" },
},
],
api: model.api,
provider: model.provider,
model: model.id,
stopReason: "toolUse",
timestamp: 2,
usage: {
input: 100,
output: 10,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 110,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
},
{
role: "toolResult",
toolCallId: "read-1",
toolName: "read",
content: [{ type: "text", text: "Synthetic file contents." }],
isError: false,
timestamp: 3,
},
];
}

describe("OpenAI compaction HTTP transport", () => {
let server: ReturnType<typeof createServer>;
let model: Model<"openai-completions">;
let requests: Record<string, unknown>[];

beforeEach(async () => {
requests = [];
// Exercise the real SDK serializer and stream parser with a local fixture.
// This models the reported validation rule, not acceptance by a live service.
server = createServer(async (request, response) => {
let raw = "";
for await (const chunk of request) raw += chunk;
const body = JSON.parse(raw);
requests.push(body);
if (Array.isArray(body.tools) && body.tools.length === 0) {
response.writeHead(400, { "content-type": "application/json" }).end(
JSON.stringify({
error: {
message: "tools must not be an empty array",
type: "invalid_request_error",
param: "tools",
},
}),
);
return;
}
response.writeHead(200, { "content-type": "text/event-stream" });
response.write(
`data: ${JSON.stringify({
id: "synthetic-checkpoint",
object: "chat.completion.chunk",
model: body.model,
choices: [
{ index: 0, delta: { content: summary }, finish_reason: null },
],
})}\n\n`,
);
response.end(
`data: ${JSON.stringify({
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: {
prompt_tokens: 100,
completion_tokens: 15,
total_tokens: 115,
},
})}\n\ndata: [DONE]\n\n`,
);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
model = {
id: "fixture-model",
name: "Fixture model",
api: "openai-completions",
provider: "custom",
baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`,
reasoning: false,
input: ["text"],
contextWindow: 32_768,
maxTokens: 4096,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
});

afterEach(async () => {
if (!server?.listening) return;
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
});

it.each(["custom", "openai"])(
"compacts tool history through the %s provider without empty tools",
async (provider) => {
model = { ...model, provider };
const result = await compactContext({
history: history(model),
allowLegacyToolTrim: false,
limits: {
providerInputLimit: 20_000,
maxSerializedInputBytes: 100_000,
},
// Admission is synthetic; checkpoint generation and HTTP transport are real.
measurePair: async () => ({
before: { inputTokens: 1000, serializedBytes: 10_000 },
after: { inputTokens: 100, serializedBytes: 1000 },
}),
checkpoint: {
tokensBefore: 1000,
timestamp: 4,
open: () =>
createCheckpointSession({
model,
streamFn: streamSimple,
thinkingLevel: "off",
apiKey: "synthetic-key",
maxOutputTokens: 1024,
providerInputLimit: 20_000,
}),
},
});
expect(requests).toHaveLength(1);
expect(requests[0]).not.toHaveProperty("tools");
expect(requests[0]).not.toHaveProperty("tool_choice");
expect(requests[0].messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
tool_calls: [expect.objectContaining({ id: "read-1" })],
}),
expect.objectContaining({
role: "tool",
tool_call_id: "read-1",
content: "Synthetic file contents.",
}),
]),
);
expect(result).toMatchObject({
method: "llm_checkpoint",
summary,
generationAttempts: 1,
});
expect(result.replacementMessages[0]).toMatchObject({
role: "compactionSummary",
});
},
);

it.each([
{ label: "absent", tools: undefined },
{ label: "empty", tools: [] },
])("omits $label tool definitions with tool history", async ({ tools }) => {
const result = await streamSimple(
model,
{ messages: history(model), tools },
{ apiKey: "synthetic-key" },
).result();
expect(requests).toHaveLength(1);
expect(requests[0]).not.toHaveProperty("tools");
expect(result.stopReason).toBe("stop");
});

it.each(["explicit", "detected"])(
"omits tools with %s Anthropic cache compatibility and tool history",
async (mode) => {
model =
mode === "explicit"
? { ...model, compat: { cacheControlFormat: "anthropic" } }
: { ...model, provider: "openrouter", id: "anthropic/fixture-model" };
const result = await streamSimple(
model,
{ messages: history(model) },
{
apiKey: "synthetic-key",
cacheRetention: "none",
},
).result();
expect(result.stopReason).toBe("stop");
expect(requests).toHaveLength(1);
expect(requests[0]).not.toHaveProperty("tools");

await streamSimple(
model,
{
messages: [{ role: "user", content: "Hello", timestamp: 1 }],
tools: [],
},
{
apiKey: "synthetic-key",
cacheRetention: "none",
},
).result();
expect(requests[1]).not.toHaveProperty("tools");
},
);

it("preserves nonempty tool definitions on ordinary agent requests", async () => {
const result = await streamSimple(
model,
{
messages: history(model),
tools: [
{
name: "read",
description: "Read a synthetic file",
parameters: Type.Object({ path: Type.String() }),
},
],
},
{ apiKey: "synthetic-key" },
).result();
expect(result.stopReason).toBe("stop");
expect(requests[0].tools).toEqual([
expect.objectContaining({
type: "function",
function: expect.objectContaining({ name: "read" }),
}),
]);
});
});
1 change: 1 addition & 0 deletions release/public-source.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/vitest-suites.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"test/windows-contract.test.mjs"
],
"capability": [
"packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts",
"packages/local-runtime/test/unit/skill-directory-symlinks.test.ts",
"packages/agent-core/test/unit/bash-subprocess-env.test.ts",
"packages/agent-modules/permission/test/unit/permission/bash-policy-regressions.test.ts",
Expand Down
8 changes: 8 additions & 0 deletions third_party/pi-mono/MINIMAX_CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ No upstream source files are changed in the baseline import.
- Upstream PR: not opened.
- Validation: `packages/agent-tools/src/desktop/edit-diff-bounds.test.ts` (registered in the `capability` suite) covers an ordinary edit, a large block replacement that stays under the bound, a full rewrite at the bound that keeps its diff, and a whole-file rewrite that keeps the write while dropping the diff; `pnpm verify --profile platform` on macOS. Measured end to end through `createEditTool`, three runs each on the same machine: a 20 000-line whole-file rewrite took 167 620 / 173 523 / 167 547 ms unbounded and 200 / 192 / 193 ms bounded, with peak heap dropping from 47–60 MB to 14–15 MB; a 501-line rewrite takes 53 / 49 / 47 ms and a 1000-line rewrite 177 / 173 / 177 ms, both keeping their diff.

### 2026-09-19 — omit empty tools for OpenAI-compatible checkpoint requests

- Reason: checkpoint requests retain tool-call history but omit tool definitions. The OpenAI Completions provider unconditionally added `tools: []` for that history, which can cause a backend to reject compaction with HTTP 400 (public issue MiniMax-AI/minimax-code#194).
- Affected package: `packages/ai` (`@earendil-works/pi-ai`), `src/providers/openai-completions.ts` and its existing empty-tools regression fixture.
- Change type: generic, upstreamable compatibility fix. Omit `tools` when no nonempty tool definitions are supplied, regardless of tool-call history or cache compatibility. Nonempty tool definitions are unchanged. Remove the obsolete history-based empty-array workaround; no compatibility settings or recovery requests are added.
- Validation: the distribution-owned `packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts` exercises `compactContext` through checkpoint generation and the real SDK against a local HTTP fixture that rejects empty tools. Regressions cover absent/empty definitions, tool history, Anthropic cache compatibility, ordinary requests, and preserved nonempty definitions. Run with `pnpm exec vitest run --config vitest.oss.config.mjs packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts` and the full `pnpm verify` profile. Offline fixtures do not establish live LiteLLM/vLLM, OpenAI, or Anthropic proxy acceptance.
- Upstream PR: not created.

### 2026-09-19 — preserve the system role for Mistral Chat Completions

- Reason: thinking-enabled custom OpenAI-compatible connections to `api.mistral.ai` emitted `developer`, which is absent from the [Mistral Chat Completions message contract](https://docs.mistral.ai/api/endpoint/chat). [OpenClaw's compatibility defaults](https://github.com/openclaw/openclaw/blob/e2bcb1614de060927121bd72de850cee3a08d308/packages/ai/src/transports/openai-completions-compat.ts#L184-L210) also disable this role for the Mistral public endpoint.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,6 @@ import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { buildBaseOptions } from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts";

/**
* Check if conversation messages contain tool calls or tool results.
* This is needed because Anthropic (via proxy) requires the tools param
* to be present when messages include tool_calls or tool role messages.
*/
function hasToolHistory(messages: Message[]): boolean {
for (const msg of messages) {
if (msg.role === "toolResult") {
return true;
}
if (msg.role === "assistant") {
if (msg.content.some((block) => block.type === "toolCall")) {
return true;
}
}
}
return false;
}

function isTextContentBlock(block: { type: string }): block is TextContent {
return block.type === "text";
}
Expand Down Expand Up @@ -545,9 +526,6 @@ function buildParams(
if (compat.zaiToolStream) {
(params as any).tool_stream = true;
}
} else if (hasToolHistory(context.messages)) {
// Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results
params.tools = [];
}

if (cacheControl) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ describe("openai-completions empty tools handling", () => {
expect(clientOptions.defaultHeaders?.["x-session-affinity"]).toBe("session-1");
});

it("still emits tools: [] for Anthropic/LiteLLM proxy when conversation has tool history", async () => {
it("omits tools when conversation has tool history but no tool definitions", async () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = { ...baseModel, api: "openai-completions" } as const;

Expand Down Expand Up @@ -248,7 +248,6 @@ describe("openai-completions empty tools handling", () => {
).result();

const params = mockState.lastParams as { tools?: unknown[] };
expect(Array.isArray(params.tools)).toBe(true);
expect(params.tools).toEqual([]);
expect(params).not.toHaveProperty("tools");
});
});
Loading