@argszero/cordis-plugin-read-image-guidance 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 argszero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @argszero/cordis-plugin-read-image-guidance
2
+
3
+ **Actionable `read_image` refusal for dsh.**
4
+
5
+ When the harness refuses to read an image because the resolved model route does
6
+ not *declare* image input, it currently tells you to *switch models* — but the
7
+ model is usually fine. The **declaration** is what's missing. This plugin turns
8
+ that opaque refusal into concrete config guidance: declare
9
+ `input: [text, image]` on the model's `models` entry (or extend its catalog
10
+ entry), instead of sending you on a model-swap hunt.
11
+
12
+ ## The gap
13
+
14
+ `read_image`'s capability gate (`assertImageCapableRoute` in `dsh-tool-fs`)
15
+ reads the **declared** `inputModalities` of the resolved route. For custom
16
+ providers with no catalog entry, the resolution chain
17
+ (`declaredInput ?? base?.input ?? defaultInput`) falls back to `['text']`, so a
18
+ vision-capable model is refused with:
19
+
20
+ > cannot read "<path>" as an image: model "<model>" does not declare image
21
+ > input; switch to an image-capable model to read images
22
+
23
+ The correct fix is `input: [text, image]` on the model entry — not a model swap.
24
+ This is the request-① gap of discussion #6049 (the `inputModalities` family,
25
+ 6th member: the *tool-read-side* capability gate).
26
+
27
+ ## How it works
28
+
29
+ It wraps `tools/execute` (the same around-dispatch seam as the in-tree
30
+ `guard/timeout-policy`). When a `read_image` result carries the exact refusal
31
+ signature, it rewrites the model-facing message into actionable guidance while
32
+ **preserving** the `isError` flag and the structured `error` (so retry/replay code
33
+ paths stay routable). Every other tool result passes through untouched.
34
+
35
+ ## Usage
36
+
37
+ Mount as a dsh bundle plugin (defaults are sane, rewrite on):
38
+
39
+ ```sh
40
+ dsh plugin --profile web add github:argszero/cordis-plugin-read-image-guidance
41
+ ```
42
+
43
+ Tune via a profile layer:
44
+
45
+ ```yaml
46
+ - set:
47
+ - id: read-image-guidance
48
+ config:
49
+ rewrite: false # observe-only, no rewrite
50
+ ```
51
+
52
+ ## Config
53
+
54
+ | field | type | default | description |
55
+ |-------|------|---------|-------------|
56
+ | `rewrite` | `boolean` | `true` | Set `false` to observe-only (no rewrite). |
57
+
58
+ ## Scope
59
+
60
+ - Rewrites **only** the exact `read_image` refusal marker
61
+ (`does not declare image input; switch to an image-capable model to read images`).
62
+ - Never drops the refusal, never touches `isError`, never touches other tools.
63
+ - No native/OCR dependency in v0.1 (an OCR-fallback form is a separate, heavier
64
+ candidate; this one is the "declaration detection + actionable error" half).
65
+
66
+ ## Compatibility
67
+
68
+ - dsh `0.1.5-alpha.1` (verified against `packages/fs/tool-fs/src/read-image.ts`
69
+ and `packages/core/tools/src/index.ts`).
70
+ - `peerDependencies`: `@deepseek-ai/cordis ^4.0.2`, `@deepseek-ai/dsh-tools >=0.1.2`.
71
+
72
+ ## Test
73
+
74
+ ```sh
75
+ npm test
76
+ ```
77
+
78
+ MIT.
@@ -0,0 +1,15 @@
1
+ # The @argszero/cordis-plugin-read-image-guidance bundle patch: no deployment-
2
+ # specific config is needed to mount (rewrite is on by default). Tune via a
3
+ # profile layer if desired:
4
+ #
5
+ # - set:
6
+ # - id: read-image-guidance
7
+ # config:
8
+ # rewrite: true
9
+ #
10
+ # The plugin only rewrites the exact read_image refusal message that points the
11
+ # user at a model swap; every other tool result passes through untouched.
12
+
13
+ - insert:
14
+ - id: read-image-guidance
15
+ name: '@argszero/cordis-plugin-read-image-guidance'
package/lib/index.js ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Actionable `read_image` refusal for the dsh harness.
3
+ *
4
+ * `packages/fs/tool-fs/src/read-image.ts` gates the model-facing `read_image`
5
+ * tool on the *declared* input modalities of the resolved model route
6
+ * (`assertImageCapableRoute`). When a vision-capable model's image input is not
7
+ * declared for the route in use (common for custom providers that lack a
8
+ * catalog entry — the resolution chain `declaredInput ?? base?.input ??
9
+ * defaultInput` then falls back to `['text']`), the tool refuses with:
10
+ *
11
+ * cannot read "<path>" as an image: model "<model>" does not declare image
12
+ * input; switch to an image-capable model to read images
13
+ *
14
+ * That message sends the user to swap models — but the model is fine; the
15
+ * *declaration* is what's missing. The correct fix is to declare
16
+ * `input: [text, image]` on the model's `models` entry (or, for catalog-backed
17
+ * providers, to extend the catalog). This is the request-① gap of discussion
18
+ * #6049.
19
+ *
20
+ * The in-tree core fix is a one-line message change, but it lives in the tarred
21
+ * read path of `dsh-tool-fs` and is an upstream-fix candidate the community
22
+ * cannot merge. This plugin is the community-side replacement: it wraps
23
+ * `tools/execute` (the same around-dispatch seam the in-tree
24
+ * `guard/timeout-policy` uses) and, when the exact refusal signature is
25
+ * detected on a `read_image` result, rewrites the model-facing message into
26
+ * actionable config guidance. Every other tool result passes through untouched.
27
+ *
28
+ * Mechanism notes (verified against packages/core/tools/src/index.ts and
29
+ * packages/fs/tool-fs/src/read-image.ts on dsh 0.1.5-alpha.1):
30
+ * - `tools/execute` is a Cordis waterfall around dispatch; a wrapper's `next()`
31
+ * returns a resolved `ToolExecutionResult`. A tool body that throws (which
32
+ * `assertImageCapableRoute` does) is converted by the registry into an
33
+ * `isError` result before the around-dispatch waterfall sees it, so the
34
+ * wrapper observes a resolved error — never a thrown one.
35
+ * - The refusal is a plain `Error` with a stable textual signature (the
36
+ * "does not declare image input; switch to an image-capable model" suffix).
37
+ * The plugin matches that exact substring and rewrites the `content` text
38
+ * while preserving the structured `error` (retry/replay code paths stay
39
+ * routable).
40
+ * - The plugin never drops the refusal; it only makes it actionable. The
41
+ * `isError` flag is preserved, so a model that genuinely cannot image-read
42
+ * still learns the read failed and can fall back / try another route.
43
+ *
44
+ * @module @argszero/cordis-plugin-read-image-guidance
45
+ */
46
+ import { Context } from '@deepseek-ai/cordis';
47
+ import z from '@deepseek-ai/schemastery';
48
+ /** Cordis plugin name used by loader diagnostics. */
49
+ export const name = 'read-image-guidance';
50
+ /** The tool registry service this plugin wraps (`tools/execute`). */
51
+ export const inject = ['tools'];
52
+ export const Config = z.object({
53
+ rewrite: z.boolean().default(true),
54
+ });
55
+ /** The only tool this plugin examines: the model-facing image reader. */
56
+ const READ_IMAGE_TOOL = 'read_image';
57
+ /** The stable refusal suffix (from `assertImageCapableRoute` in dsh-tool-fs). */
58
+ const REFUSAL_MARKER = 'does not declare image input; switch to an image-capable model to read images';
59
+ /** Limit guard: never let a single rewrite chain grow unboundedly. */
60
+ const MAX_MESSAGE_BYTES = 12_000;
61
+ /** Extract the model name from a refusal, if it is present and parseable. */
62
+ function modelOf(message) {
63
+ // "cannot read "<path>" as an image: model "<model>" does not declare ..."
64
+ const m = /model "([^"]+)"/.exec(message);
65
+ return m?.[1] ?? '';
66
+ }
67
+ /** Extract the requested path from a refusal, if present. */
68
+ function pathOf(message) {
69
+ // "cannot read "<path>" as an image: ..."
70
+ const m = /cannot read "([^"]+)"/.exec(message);
71
+ return m?.[1] ?? '';
72
+ }
73
+ /**
74
+ * Rewrite the refuser's message into concrete config guidance. Returns the
75
+ * original `message` unchanged when the refusal marker is absent (so non-image
76
+ * errors and other tool results are never touched).
77
+ */
78
+ export function rewriteMessage(message) {
79
+ if (!message.includes(REFUSAL_MARKER))
80
+ return message;
81
+ const model = modelOf(message);
82
+ const path = pathOf(message);
83
+ const modelPart = model.length > 0 ? `model "${model}"` : `the resolved model`;
84
+ const pathPart = path.length > 0 ? `"${path}"` : 'the requested image';
85
+ const guidance = `cannot read ${pathPart} as an image: ${modelPart} does not declare image input. ` +
86
+ `This is a declaration issue, not a model-capability issue: the route in use has no ` +
87
+ `\`image\` input declared, so the harness refuses before reading. If this model supports ` +
88
+ `images, declare \`input: [text, image]\` on its \`models\` entry (under the active provider ` +
89
+ `in \`settings.yaml\` / your custom provider config) and restart the session. For a catalog-` +
90
+ `backed provider, extend its catalog entry instead. If you meant to read a non-image file, use ` +
91
+ `\`read_file\` instead; if the model truly cannot process images, switch to an image-capable model.`;
92
+ return guidance.length > MAX_MESSAGE_BYTES ? message : guidance;
93
+ }
94
+ /**
95
+ * The pure around-dispatch logic, exported separately from {@link apply} so it
96
+ * is testable without a live Cordis context. Returns the dispatch result to
97
+ * pass on (either the tool's own result or the rewritten one).
98
+ */
99
+ /** Return the string content of a text block, or undefined for any other block. */
100
+ export function textOf(block) {
101
+ if (block.type !== 'text')
102
+ return undefined;
103
+ const t = block.text;
104
+ return typeof t === 'string' ? t : undefined;
105
+ }
106
+ export async function runWrapper(config, exec, next) {
107
+ const result = await next();
108
+ // Only examine a failing read_image with the exact refusal signature.
109
+ if (!config.rewrite)
110
+ return result;
111
+ if (exec.name !== READ_IMAGE_TOOL || !result.isError)
112
+ return result;
113
+ const firstText = result.content.find((block) => textOf(block) !== undefined);
114
+ if (firstText === undefined)
115
+ return result;
116
+ const original = textOf(firstText) ?? '';
117
+ const rewritten = rewriteMessage(original);
118
+ if (rewritten === original)
119
+ return result;
120
+ return {
121
+ ...result,
122
+ content: result.content.map((block) => textOf(block) === original ? { ...block, text: rewritten } : block),
123
+ };
124
+ }
125
+ /**
126
+ * Register the wrapper. {@link runWrapper}'s structural {@link WrapperExec} is a
127
+ * subset of the registry's own `ToolDispatchExecution`, so the registration is a
128
+ * plain delegate with no casts.
129
+ */
130
+ export function apply(ctx, config) {
131
+ ctx.on('tools/execute', (exec, next) => runWrapper(config, exec, next));
132
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Actionable `read_image` refusal for the dsh harness.
3
+ *
4
+ * `packages/fs/tool-fs/src/read-image.ts` gates the model-facing `read_image`
5
+ * tool on the *declared* input modalities of the resolved model route
6
+ * (`assertImageCapableRoute`). When a vision-capable model's image input is not
7
+ * declared for the route in use (common for custom providers that lack a
8
+ * catalog entry — the resolution chain `declaredInput ?? base?.input ??
9
+ * defaultInput` then falls back to `['text']`), the tool refuses with:
10
+ *
11
+ * cannot read "<path>" as an image: model "<model>" does not declare image
12
+ * input; switch to an image-capable model to read images
13
+ *
14
+ * That message sends the user to swap models — but the model is fine; the
15
+ * *declaration* is what's missing. The correct fix is to declare
16
+ * `input: [text, image]` on the model's `models` entry (or, for catalog-backed
17
+ * providers, to extend the catalog). This is the request-① gap of discussion
18
+ * #6049.
19
+ *
20
+ * The in-tree core fix is a one-line message change, but it lives in the tarred
21
+ * read path of `dsh-tool-fs` and is an upstream-fix candidate the community
22
+ * cannot merge. This plugin is the community-side replacement: it wraps
23
+ * `tools/execute` (the same around-dispatch seam the in-tree
24
+ * `guard/timeout-policy` uses) and, when the exact refusal signature is
25
+ * detected on a `read_image` result, rewrites the model-facing message into
26
+ * actionable config guidance. Every other tool result passes through untouched.
27
+ *
28
+ * Mechanism notes (verified against packages/core/tools/src/index.ts and
29
+ * packages/fs/tool-fs/src/read-image.ts on dsh 0.1.5-alpha.1):
30
+ * - `tools/execute` is a Cordis waterfall around dispatch; a wrapper's `next()`
31
+ * returns a resolved `ToolExecutionResult`. A tool body that throws (which
32
+ * `assertImageCapableRoute` does) is converted by the registry into an
33
+ * `isError` result before the around-dispatch waterfall sees it, so the
34
+ * wrapper observes a resolved error — never a thrown one.
35
+ * - The refusal is a plain `Error` with a stable textual signature (the
36
+ * "does not declare image input; switch to an image-capable model" suffix).
37
+ * The plugin matches that exact substring and rewrites the `content` text
38
+ * while preserving the structured `error` (retry/replay code paths stay
39
+ * routable).
40
+ * - The plugin never drops the refusal; it only makes it actionable. The
41
+ * `isError` flag is preserved, so a model that genuinely cannot image-read
42
+ * still learns the read failed and can fall back / try another route.
43
+ *
44
+ * @module @argszero/cordis-plugin-read-image-guidance
45
+ */
46
+ import { Context } from '@deepseek-ai/cordis';
47
+ import z from '@deepseek-ai/schemastery';
48
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm';
49
+ import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools';
50
+ /** Cordis plugin name used by loader diagnostics. */
51
+ export declare const name = "read-image-guidance";
52
+ /** The tool registry service this plugin wraps (`tools/execute`). */
53
+ export declare const inject: string[];
54
+ /** Plugin configuration. */
55
+ export interface Config {
56
+ /**
57
+ * When `true` (default), rewrite the exact `read_image` refusal into
58
+ * actionable config guidance. Set `false` to observe-only (no rewrite).
59
+ */
60
+ rewrite?: boolean;
61
+ }
62
+ /** Resolved config: every field carries its validated default. */
63
+ export type ResolvedConfig = Required<Config>;
64
+ export declare const Config: z<Config>;
65
+ /** Structural view of the exec object this wrapper needs. */
66
+ export interface WrapperExec {
67
+ readonly name: string;
68
+ }
69
+ /** Structural view of the delegated dispatch (`next()`). */
70
+ export type WrapperNext = () => Promise<ToolExecutionResult>;
71
+ /**
72
+ * Rewrite the refuser's message into concrete config guidance. Returns the
73
+ * original `message` unchanged when the refusal marker is absent (so non-image
74
+ * errors and other tool results are never touched).
75
+ */
76
+ export declare function rewriteMessage(message: string): string;
77
+ /**
78
+ * The pure around-dispatch logic, exported separately from {@link apply} so it
79
+ * is testable without a live Cordis context. Returns the dispatch result to
80
+ * pass on (either the tool's own result or the rewritten one).
81
+ */
82
+ /** Return the string content of a text block, or undefined for any other block. */
83
+ export declare function textOf(block: ContentBlock): string | undefined;
84
+ export declare function runWrapper(config: ResolvedConfig, exec: WrapperExec, next: WrapperNext): Promise<ToolExecutionResult>;
85
+ /**
86
+ * Register the wrapper. {@link runWrapper}'s structural {@link WrapperExec} is a
87
+ * subset of the registry's own `ToolDispatchExecution`, so the registration is a
88
+ * plain delegate with no casts.
89
+ */
90
+ export declare function apply(ctx: Context, config: ResolvedConfig): void;
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@argszero/cordis-plugin-read-image-guidance",
3
+ "description": "Actionable read_image refusal for dsh: when the harness refuses to read an image because the resolved model route does not declare image input, turn that opaque 'switch to an image-capable model' message into concrete config guidance (declare \"input: [text, image]\" on the model's entry) instead of sending the user to swap models. Compatible with dsh 0.1.5-alpha.1 (read_image assertImageCapableRoute refuses on undeclared image input).",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./src/*": "./src/*",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib/index.js",
18
+ "lib/types/**/*.d.ts",
19
+ "cordis.patch.yml",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "license": "MIT",
24
+ "keywords": [
25
+ "cordis",
26
+ "deepseek-harness",
27
+ "dsh",
28
+ "plugin",
29
+ "read_image",
30
+ "image",
31
+ "inputModalities",
32
+ "guidance"
33
+ ],
34
+ "dsh": {
35
+ "bundle": {
36
+ "patch": "./cordis.patch.yml"
37
+ }
38
+ },
39
+ "peerDependencies": {
40
+ "@deepseek-ai/cordis": "^4.0.2",
41
+ "@deepseek-ai/dsh-tools": ">=0.1.2"
42
+ },
43
+ "dependencies": {
44
+ "@deepseek-ai/schemastery": "^3.18.1"
45
+ },
46
+ "devDependencies": {
47
+ "@deepseek-ai/cordis": "^4.0.2",
48
+ "@deepseek-ai/dsh-agent": "0.1.5-alpha.1",
49
+ "@deepseek-ai/dsh-tools": "0.1.5-alpha.1",
50
+ "@deepseek-ai/dsh-llm": "0.1.5-alpha.1",
51
+ "@types/node": "^26.5.0",
52
+ "typescript": "^5.5.0"
53
+ },
54
+ "scripts": {
55
+ "build": "tsc",
56
+ "test": "tsc && node --test \"test/*.test.js\"",
57
+ "prepublishOnly": "tsc"
58
+ }
59
+ }