feat(vision): add the model-facing recognition consumer
Registers the recognize_image tool and a bundled image-recognition skill, and hooks agent/pre-step to deterministically inject the skill body when a step input carries an image (content block or image path/URL), so the model recognizes the image before continuing the task. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-image-recognition",
|
||||
"description": "Model-facing image-recognition consumer for the DeepSeek Harness — recognize_image tool, bundled image-recognition skill, and deterministic image-task pre-step injection",
|
||||
"version": "0.1.0-rc.5",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/vision/tool-image-recognition"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-image-recognition": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Model-facing image-recognition consumer. Registers a bundled `image-recognition`
|
||||
* skill and a `recognize_image` tool, and — when a step's input carries an image
|
||||
* — deterministically injects the skill body before the model acts, so the model
|
||||
* recognizes the image first and then continues the task.
|
||||
* @module @deepseek-ai/dsh-tool-image-recognition
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { isModelInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill'
|
||||
import type { ImageInput, ImageRecognitionResult } from '@deepseek-ai/dsh-image-recognition'
|
||||
|
||||
export const name = 'tool-image-recognition'
|
||||
export const inject = ['agents', 'imageRecognition', 'skills', 'systemPrompt', 'tools']
|
||||
|
||||
const SKILL_NAME = 'image-recognition'
|
||||
|
||||
/** The `{kind:'plugin'}` source stamped on every injection this plugin makes. */
|
||||
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'tool-image-recognition' }
|
||||
|
||||
/** The bundled recognition skill body, injected verbatim when an image task is detected. */
|
||||
const SKILL_CONTENT = [
|
||||
'## Image recognition',
|
||||
'When the task involves an image (an attached image, an image file path, or an image URL), FIRST recognize its content before continuing the task:',
|
||||
'',
|
||||
'1. Determine which image the task refers to from the conversation (attachment, path, or URL).',
|
||||
'2. Call `recognize_image` with that image and, when useful, a `prompt` naming what to extract (e.g. "transcribe the text", "describe the scene", "read the numbers").',
|
||||
'3. Use the recognized text as ground truth to complete the original task.',
|
||||
'',
|
||||
'Do not guess at image contents from a filename or description — run `recognize_image` and act on its result.',
|
||||
].join('\n')
|
||||
|
||||
/** Plugin config (all optional). */
|
||||
export interface Config {
|
||||
/** Inject the skill body when a step input carries an image content block. */
|
||||
detectImageBlocks?: boolean
|
||||
/** Inject the skill body when a step input text names an image file path or URL. */
|
||||
detectImagePaths?: boolean
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
detectImageBlocks: z.boolean().default(true),
|
||||
detectImagePaths: z.boolean().default(true),
|
||||
})
|
||||
|
||||
/**
|
||||
* Whether a step input carries an image: an image content block, or a text
|
||||
* block naming an image file path / image URL. Never reads image bytes — it is
|
||||
* a cheap signal for whether to inject the recognition skill.
|
||||
* @param messages - the step's claimed batch.
|
||||
* @param detectBlocks - whether image content blocks count as a signal.
|
||||
* @param detectPaths - whether image file paths / URLs in text count as a signal.
|
||||
* @returns true when any supported image signal is present.
|
||||
*/
|
||||
export function hasImageSignal(
|
||||
messages: readonly UserMessage[],
|
||||
detectBlocks: boolean,
|
||||
detectPaths: boolean,
|
||||
): boolean {
|
||||
return messages.some(message =>
|
||||
message.content.some((block) => {
|
||||
if (block.type === 'image') return detectBlocks
|
||||
if (block.type !== 'text') return false
|
||||
return detectPaths && IMAGE_TEXT_RE.test(block.text)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** An image file path or image URL named in text. */
|
||||
const IMAGE_TEXT_RE = /\.(?:png|jpe?g|webp|gif)\b|https?:\/\/[^\s]+\.(?:png|jpe?g|webp|gif)(?:\?[^\s]*)?/i
|
||||
|
||||
/** Normalize a tool `image` argument into the seam's {@link ImageInput}. */
|
||||
function toImageInput(image: string): ImageInput {
|
||||
if (image.startsWith('data:')) {
|
||||
const match = /^data:([^;,]+);base64,(.+)$/s.exec(image)
|
||||
if (match === null) throw new Error('recognize_image: invalid data URL')
|
||||
// RegExpExecArray group access types as string | undefined; the anchored
|
||||
// pattern guarantees both groups when it matches.
|
||||
const mediaType = match[1]
|
||||
const base64 = match[2]
|
||||
if (mediaType === undefined || base64 === undefined) throw new Error('recognize_image: invalid data URL')
|
||||
return { kind: 'base64', base64, mediaType }
|
||||
}
|
||||
if (/^https?:\/\//.test(image)) return { kind: 'url', url: image }
|
||||
return { kind: 'file-path', filePath: image }
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the recognition skill, tool, deterministic injection, and prompt
|
||||
* section.
|
||||
* @param ctx - plugin context.
|
||||
* @param config - validated {@link Config}.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const detectBlocks = config.detectImageBlocks ?? true
|
||||
const detectPaths = config.detectImagePaths ?? true
|
||||
|
||||
ctx.skills.register({
|
||||
name: SKILL_NAME,
|
||||
source: 'runtime',
|
||||
description: 'Recognize and describe the contents of an image (objects, scenes, and text) so you can continue a task about it.',
|
||||
whenToUse: 'Use whenever the task involves an attached, referenced, or named image and you need to know what it contains.',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
content: SKILL_CONTENT,
|
||||
})
|
||||
|
||||
const recognizeImageTool = defineTool({
|
||||
name: 'recognize_image',
|
||||
description: 'Recognize the contents of an image (objects, scenes, and text) through the configured image provider and return the recognized text.',
|
||||
parameters: {
|
||||
image: { type: 'string', required: true, description: 'The image to recognize: a file path, an https URL, or a data: URL.' },
|
||||
prompt: { type: 'string', description: 'Optional recognition focus, e.g. "transcribe the text" or "describe the scene".' },
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
text: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value: ImageRecognitionResult) => [{ type: 'text', text: value.text }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const result = await ctx.imageRecognition.recognize({
|
||||
image: toImageInput(args.image),
|
||||
...args.prompt !== undefined ? { prompt: args.prompt } : {},
|
||||
}, exec.signal)
|
||||
return result
|
||||
},
|
||||
presentCall(args) {
|
||||
return { card: 'generic', title: 'Recognize image', kind: 'read', rawInput: args.image }
|
||||
},
|
||||
})
|
||||
ctx.tools.register(recognizeImageTool)
|
||||
|
||||
// Deterministic injection: when the step input carries an image, prepend the
|
||||
// recognition skill body so the model recognizes it before other actions.
|
||||
// Delegate first, then prepend onto the decision (never veto, never rewrite).
|
||||
ctx.on('agent/pre-step', async (
|
||||
{ agent, signal },
|
||||
next,
|
||||
): Promise<PreStepDecision> => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'reject') return decision
|
||||
if (!hasImageSignal(decision.messages, detectBlocks, detectPaths)) return decision
|
||||
signal.throwIfAborted()
|
||||
const lookup = { cwd: agent.session.header.cwd, signal, scope: agent }
|
||||
const skill = await ctx.skills.get(SKILL_NAME, lookup)
|
||||
signal.throwIfAborted()
|
||||
if (skill === undefined || !isModelInvocable(skill)) return decision
|
||||
const injection = createUserMessage({
|
||||
content: [{ type: 'text', text: renderSkillContent(skill) }],
|
||||
source: { ...PLUGIN_SOURCE, form: 'instructions', summary: 'recognize image first' },
|
||||
})
|
||||
return { kind: 'enter', messages: [injection, ...decision.messages] }
|
||||
})
|
||||
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:image-recognition',
|
||||
order: 115,
|
||||
text: 'When a task involves an image, recognize it first with `recognize_image` before continuing; do not infer image contents from a filename or description.',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-image-recognition`.
|
||||
* @module @deepseek-ai/dsh-tool-image-recognition/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-image-recognition'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-image-recognition-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the consumer registers into the tools/skills registries
|
||||
* and its injection is asserted by its own tests; it publishes no independent
|
||||
* observation stream.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { hasImageSignal } from '../src/index.ts'
|
||||
|
||||
function userMessage(blocks: UserMessage['content']): UserMessage {
|
||||
return { id: 'u', role: 'user', source: { kind: 'user' }, content: blocks } as UserMessage
|
||||
}
|
||||
|
||||
/** A content block carrying an image signal (the payload is irrelevant here). */
|
||||
function imageBlock(): UserMessage['content'][number] {
|
||||
return { type: 'image', attachment: {} as never } as UserMessage['content'][number]
|
||||
}
|
||||
|
||||
describe('hasImageSignal', () => {
|
||||
it('detects an image content block when block detection is on', () => {
|
||||
expect(hasImageSignal([userMessage([imageBlock()])], true, true)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores image blocks when block detection is off', () => {
|
||||
expect(hasImageSignal([userMessage([imageBlock()])], false, true)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects an image file path in text when path detection is on', () => {
|
||||
const message = userMessage([{ type: 'text', text: 'transcribe /tmp/screenshot.png' }])
|
||||
expect(hasImageSignal([message], true, true)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects an image URL in text', () => {
|
||||
const message = userMessage([{ type: 'text', text: 'look at https://e.test/photo.JPG' }])
|
||||
expect(hasImageSignal([message], true, true)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores plain text with no image signal', () => {
|
||||
const message = userMessage([{ type: 'text', text: 'summarize this document' }])
|
||||
expect(hasImageSignal([message], true, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../image-recognition"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user