feat(acp): advertise vision-bridge image capability in initialize response (#6269)

* feat(acp): advertise vision-bridge image capability in initialize response

Adds `_meta.imageCapability` to both stdio and HTTP ACP initialize
responses so external hosts like sudowork can feature-detect native
image handling instead of maintaining a hardcoded allowlist.

Resolves #6086

* test(acp): cover image capability advertisement

* docs(acp): clarify image capability threshold

* fix(acp): align image capability contract
This commit is contained in:
易良 2026-07-04 22:08:45 +08:00 committed by GitHub
parent bd9bf50aa8
commit aa4bccfc47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 80 additions and 11 deletions

View file

@ -148,6 +148,11 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
AGENT: 'agent',
},
FORK_SUBAGENT_TYPE: 'fork',
IMAGE_CAPABILITY: Object.freeze({
autoHandlesWrongModel: true,
maxBytes: 10380902,
maxImagesPerTurn: 4,
}),
ALL_PROVIDERS: [
{
id: 'deepseek',
@ -1298,6 +1303,13 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
sse: true,
http: true,
},
_meta: {
imageCapability: {
autoHandlesWrongModel: true,
maxBytes: 10380902,
maxImagesPerTurn: 4,
},
},
},
});

View file

@ -64,6 +64,7 @@ import {
runManagedAutoMemoryDream,
runManagedRememberByAgent,
matchesAnyServerPattern,
IMAGE_CAPABILITY,
registerAcpEventLoopLagGauge,
startEventLoopLagMonitor,
} from '@qwen-code/qwen-code-core';
@ -2832,6 +2833,9 @@ class QwenAgent implements Agent {
sse: true,
http: true,
},
_meta: {
imageCapability: IMAGE_CAPABILITY,
},
},
};
}

View file

@ -18,6 +18,7 @@ import {
writeWorkspaceContextFile,
type SessionArchiveState,
type SubagentLevel,
IMAGE_CAPABILITY,
} from '@qwen-code/qwen-code-core';
// Import the permission error classes from the same module REST's
// `sendPermissionVoteError` uses, so `instanceof` matches the class the bridge
@ -843,6 +844,7 @@ export class AcpDispatcher {
this.sessionShellCommandEnabled,
),
},
imageCapability: IMAGE_CAPABILITY,
},
},
};

View file

@ -979,6 +979,20 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
);
});
it('initialize advertises image capability at _meta.imageCapability', async () => {
const { body } = await initializeRaw();
const result = body['result'] as {
agentCapabilities: {
_meta: { imageCapability: Record<string, unknown> };
};
};
expect(result.agentCapabilities._meta.imageCapability).toEqual({
autoHandlesWrongModel: true,
maxBytes: 10380902,
maxImagesPerTurn: 4,
});
});
it('initialize advertises _qwen/workspace/permissions methods', async () => {
const { body } = await initializeRaw();
const result = body['result'] as {

View file

@ -216,6 +216,7 @@ export * from './services/gitWorktreeService.js';
export { DEFAULT_MAX_TOOL_CALLS_PER_TURN } from './services/loopDetectionService.js';
export * from './services/visionBridge/vision-bridge-service.js';
export * from './services/visionBridge/image-part-utils.js';
export * from './services/visionBridge/image-capability.js';
export * from './services/sessionRecap.js';
export * from './services/sessionService.js';
export * from './services/sessionTitle.js';

View file

@ -0,0 +1,28 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Vision-bridge capability advertisement for ACP initialize responses.
*
* Boolean flags describe backend-handled behavior for ACP hosts; numeric
* thresholds track the same constants enforced by the vision bridge.
*
* Placed in `agentCapabilities._meta.imageCapability` of the ACP
* `initialize` response on both stdio and HTTP paths.
*/
import {
VISION_BRIDGE_MAX_IMAGE_BASE64_BYTES,
VISION_BRIDGE_MAX_IMAGES,
} from './vision-bridge-constants.js';
export const IMAGE_CAPABILITY = Object.freeze({
/** Text-only active models are handled by the vision bridge when configured. */
autoHandlesWrongModel: true,
/** Current per-image inline base64 payload cap, in bytes. */
maxBytes: VISION_BRIDGE_MAX_IMAGE_BASE64_BYTES,
/** Current max images processed per turn. */
maxImagesPerTurn: VISION_BRIDGE_MAX_IMAGES,
});

View file

@ -5,15 +5,7 @@
*/
import type { Part, PartListUnion } from '@google/genai';
/**
* Conservative cap on a single image part's base64 length (in MB) before the
* vision bridge refuses it, so the bridge never makes a side call a provider
* would reject for size. Measured on the base64 string, which overstates the
* decoded bytes by ~33%, keeping this comfortably under the repo's ~10MB
* decoded inline-media ceiling.
*/
const MAX_IMAGE_BASE64_MB = 9.9;
import { VISION_BRIDGE_MAX_IMAGE_BASE64_BYTES } from './vision-bridge-constants.js';
/**
* Normalize a {@link PartListUnion} into a flat array of {@link Part} objects.
@ -159,7 +151,7 @@ export function isUsableImagePart(part: Part): boolean {
if (typeof data !== 'string' || data.length === 0) {
return false;
}
return data.length / (1024 * 1024) <= MAX_IMAGE_BASE64_MB;
return data.length <= VISION_BRIDGE_MAX_IMAGE_BASE64_BYTES;
}
/**

View file

@ -0,0 +1,16 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export const VISION_BRIDGE_MAX_IMAGES = 4;
/**
* Conservative cap on a single image part's base64 payload before the vision
* bridge refuses it. Measured on the inline base64 string, which is what ACP
* transports over the wire.
*/
export const VISION_BRIDGE_MAX_IMAGE_BASE64_BYTES = Math.floor(
9.9 * 1024 * 1024,
);

View file

@ -16,10 +16,10 @@ import {
replaceImagesWithText,
splitImageParts,
} from './image-part-utils.js';
import { VISION_BRIDGE_MAX_IMAGES } from './vision-bridge-constants.js';
const debugLogger = createDebugLogger('VISION_BRIDGE');
const BRIDGE_MAX_OUTPUT_TOKENS = 2048;
const VISION_BRIDGE_MAX_IMAGES = 4;
const VISION_BRIDGE_TIMEOUT_MS = 30_000;
// Cap intent so @-file contents in nonImageParts aren't dumped to the bridge model.
const BRIDGE_INTENT_MAX_CHARS = 2000;