mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-14 08:08:34 +00:00
fix: prevent nodes media base64 context bloat (#34332)
This commit is contained in:
@@ -32,16 +32,21 @@ function unexpectedGatewayMethod(method: unknown): never {
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
}
|
||||
|
||||
function getNodesTool() {
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "nodes");
|
||||
function getNodesTool(options?: { modelHasVision?: boolean }) {
|
||||
const tool = createOpenClawTools(
|
||||
options?.modelHasVision !== undefined ? { modelHasVision: options.modelHasVision } : {},
|
||||
).find((candidate) => candidate.name === "nodes");
|
||||
if (!tool) {
|
||||
throw new Error("missing nodes tool");
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
async function executeNodes(input: Record<string, unknown>) {
|
||||
return getNodesTool().execute("call1", input as never);
|
||||
async function executeNodes(
|
||||
input: Record<string, unknown>,
|
||||
options?: { modelHasVision?: boolean },
|
||||
) {
|
||||
return getNodesTool(options).execute("call1", input as never);
|
||||
}
|
||||
|
||||
type NodesToolResult = Awaited<ReturnType<typeof executeNodes>>;
|
||||
@@ -67,6 +72,11 @@ function expectSingleImage(result: NodesToolResult, params?: { mimeType?: string
|
||||
}
|
||||
}
|
||||
|
||||
function expectNoImages(result: NodesToolResult) {
|
||||
const images = (result.content ?? []).filter((block) => block.type === "image");
|
||||
expect(images).toHaveLength(0);
|
||||
}
|
||||
|
||||
function expectFirstTextContains(result: NodesToolResult, expectedText: string) {
|
||||
expect(result.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
@@ -156,10 +166,13 @@ describe("nodes camera_snap", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeNodes({
|
||||
action: "camera_snap",
|
||||
node: NODE_ID,
|
||||
});
|
||||
const result = await executeNodes(
|
||||
{
|
||||
action: "camera_snap",
|
||||
node: NODE_ID,
|
||||
},
|
||||
{ modelHasVision: true },
|
||||
);
|
||||
|
||||
expectSingleImage(result);
|
||||
});
|
||||
@@ -169,15 +182,39 @@ describe("nodes camera_snap", () => {
|
||||
invokePayload: JPG_PAYLOAD,
|
||||
});
|
||||
|
||||
const result = await executeNodes({
|
||||
action: "camera_snap",
|
||||
node: NODE_ID,
|
||||
facing: "front",
|
||||
});
|
||||
const result = await executeNodes(
|
||||
{
|
||||
action: "camera_snap",
|
||||
node: NODE_ID,
|
||||
facing: "front",
|
||||
},
|
||||
{ modelHasVision: true },
|
||||
);
|
||||
|
||||
expectSingleImage(result, { mimeType: "image/jpeg" });
|
||||
});
|
||||
|
||||
it("omits inline base64 image blocks when model has no vision", async () => {
|
||||
setupNodeInvokeMock({
|
||||
invokePayload: JPG_PAYLOAD,
|
||||
});
|
||||
|
||||
const result = await executeNodes(
|
||||
{
|
||||
action: "camera_snap",
|
||||
node: NODE_ID,
|
||||
facing: "front",
|
||||
},
|
||||
{ modelHasVision: false },
|
||||
);
|
||||
|
||||
expectNoImages(result);
|
||||
expect(result.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringMatching(/^MEDIA:/),
|
||||
});
|
||||
});
|
||||
|
||||
it("passes deviceId when provided", async () => {
|
||||
setupNodeInvokeMock({
|
||||
onInvoke: (invokeParams) => {
|
||||
@@ -299,6 +336,130 @@ describe("nodes camera_clip", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodes photos_latest", () => {
|
||||
it("returns empty content/details when no photos are available", async () => {
|
||||
setupNodeInvokeMock({
|
||||
onInvoke: (invokeParams) => {
|
||||
expect(invokeParams).toMatchObject({
|
||||
command: "photos.latest",
|
||||
params: {
|
||||
limit: 1,
|
||||
maxWidth: 1600,
|
||||
quality: 0.85,
|
||||
},
|
||||
});
|
||||
return {
|
||||
payload: {
|
||||
photos: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeNodes(
|
||||
{
|
||||
action: "photos_latest",
|
||||
node: NODE_ID,
|
||||
},
|
||||
{ modelHasVision: false },
|
||||
);
|
||||
|
||||
expect(result.content ?? []).toEqual([]);
|
||||
expect(result.details).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns MEDIA paths and no inline images when model has no vision", async () => {
|
||||
setupNodeInvokeMock({
|
||||
remoteIp: "198.51.100.42",
|
||||
onInvoke: (invokeParams) => {
|
||||
expect(invokeParams).toMatchObject({
|
||||
command: "photos.latest",
|
||||
params: {
|
||||
limit: 1,
|
||||
maxWidth: 1600,
|
||||
quality: 0.85,
|
||||
},
|
||||
});
|
||||
return {
|
||||
payload: {
|
||||
photos: [
|
||||
{
|
||||
format: "jpeg",
|
||||
base64: "aGVsbG8=",
|
||||
width: 1,
|
||||
height: 1,
|
||||
createdAt: "2026-03-04T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeNodes(
|
||||
{
|
||||
action: "photos_latest",
|
||||
node: NODE_ID,
|
||||
},
|
||||
{ modelHasVision: false },
|
||||
);
|
||||
|
||||
expectNoImages(result);
|
||||
expect(result.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringMatching(/^MEDIA:/),
|
||||
});
|
||||
const details = Array.isArray(result.details) ? result.details : [];
|
||||
expect(details[0]).toMatchObject({
|
||||
width: 1,
|
||||
height: 1,
|
||||
createdAt: "2026-03-04T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes inline image blocks when model has vision", async () => {
|
||||
setupNodeInvokeMock({
|
||||
onInvoke: (invokeParams) => {
|
||||
expect(invokeParams).toMatchObject({
|
||||
command: "photos.latest",
|
||||
params: {
|
||||
limit: 1,
|
||||
maxWidth: 1600,
|
||||
quality: 0.85,
|
||||
},
|
||||
});
|
||||
return {
|
||||
payload: {
|
||||
photos: [
|
||||
{
|
||||
format: "jpeg",
|
||||
base64: "aGVsbG8=",
|
||||
width: 1,
|
||||
height: 1,
|
||||
createdAt: "2026-03-04T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeNodes(
|
||||
{
|
||||
action: "photos_latest",
|
||||
node: NODE_ID,
|
||||
},
|
||||
{ modelHasVision: true },
|
||||
);
|
||||
|
||||
expect(result.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringMatching(/^MEDIA:/),
|
||||
});
|
||||
expectSingleImage(result, { mimeType: "image/jpeg" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodes notifications_list", () => {
|
||||
it("invokes notifications.list and returns payload", async () => {
|
||||
setupNodeInvokeMock({
|
||||
@@ -576,3 +737,44 @@ describe("nodes run", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodes invoke", () => {
|
||||
it("allows metadata-only camera.list via generic invoke", async () => {
|
||||
setupNodeInvokeMock({
|
||||
onInvoke: (invokeParams) => {
|
||||
expect(invokeParams).toMatchObject({
|
||||
command: "camera.list",
|
||||
params: {},
|
||||
});
|
||||
return {
|
||||
payload: {
|
||||
devices: [{ id: "cam-back", name: "Back Camera" }],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeNodes({
|
||||
action: "invoke",
|
||||
node: NODE_ID,
|
||||
invokeCommand: "camera.list",
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
payload: {
|
||||
devices: [{ id: "cam-back", name: "Back Camera" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks media invoke commands to avoid base64 context bloat", async () => {
|
||||
await expect(
|
||||
executeNodes({
|
||||
action: "invoke",
|
||||
node: NODE_ID,
|
||||
invokeCommand: "photos.latest",
|
||||
invokeParamsJson: '{"limit":1}',
|
||||
}),
|
||||
).rejects.toThrow(/use action="photos_latest"/i);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user