@hmharness/kernel 0.6.5 → 0.6.6

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/dist/types.d.ts CHANGED
@@ -68,6 +68,14 @@ export interface ProviderConfig {
68
68
  * built-in registry; the transcript budget then scales to the window
69
69
  * (see window.ts) instead of the fixed legacy default. */
70
70
  contextWindow?: number;
71
+ /** Capability marker: can this model accept image input?
72
+ * Set `false` on a text-only model that is (or might be) named by
73
+ * `routing.vision`. resolveProvider('vision') then SKIPS it instead of
74
+ * silently posting screenshots to a blind model - which answers HTTP 200
75
+ * with "I can't view the image" and used to be graded as "the expected
76
+ * UI text is not on screen" (a FALSE-NEGATIVE regression FAIL).
77
+ * Omitted = unknown, and the provider is used as before. */
78
+ supportsVision?: boolean;
71
79
  }
72
80
  /** User-level configuration (HMH_HOME/config.json). */
73
81
  export interface HmhConfig {
@@ -114,8 +122,46 @@ export interface HmhConfig {
114
122
  bench?: string;
115
123
  };
116
124
  }
117
- /** Resolve a purpose to a concrete provider config (routing > legacy fields). */
125
+ /** Resolve a purpose to a concrete provider config (routing > legacy fields).
126
+ *
127
+ * For 'vision' a provider explicitly marked `supportsVision: false` is NOT
128
+ * accepted: it is skipped so the dedicated `vision` block (or the chat
129
+ * default) wins instead. Rationale - `routing.vision` used to shadow the
130
+ * `vision` block unconditionally, so a text-only model there received every
131
+ * screenshot and replied "I can't view the image" with HTTP 200; the UI
132
+ * regression tool then reported a FAIL about the app instead of a provider
133
+ * failure (false negative, mis-attributed to the product). */
118
134
  export declare function resolveProvider(cfg: HmhConfig, purpose: 'chat' | 'vision' | 'evolve' | 'bench'): ProviderConfig;
135
+ /**
136
+ * Ordered vision candidates: routing.vision provider, the legacy `vision`
137
+ * block, then `visionFallbacks`; de-duplicated by endpoint+model and with
138
+ * providers marked `supportsVision: false` removed. If everything is marked
139
+ * blind the unfiltered list is returned, so callers still get today's
140
+ * (failing, but informative) behaviour instead of "no vision provider".
141
+ * A caller looping this chain turns a blind/dead provider into a retry
142
+ * rather than a wrong answer.
143
+ */
144
+ export declare function visionProviderChain(cfg: HmhConfig): ProviderConfig[];
145
+ /**
146
+ * Did the model answer "I can't see any image" instead of describing one?
147
+ * A blind (text-only) provider returns HTTP 200 with prose like this, so
148
+ * callers MUST NOT treat such a reply as evidence about the image - for UI
149
+ * regression that is the difference between a provider failure and a
150
+ * product FAIL. Checked on the head of the answer, where refusals live.
151
+ */
152
+ export declare function isVisionRefusal(text: string): boolean;
153
+ /** Head-of-answer phrases that mean "I never looked at the image". Kept as
154
+ * small separate patterns so each one is verifiable on its own. */
155
+ export declare const VISION_REFUSAL_PATTERNS: RegExp[];
156
+ /** The `FOUND: <text>` line the regression prompt demands, or null. */
157
+ export declare function foundLineText(described: string): string | null;
158
+ /**
159
+ * Did the model itself report reading NO text? Then there is no evidence
160
+ * about the app either way - the screen may be blank, or the provider may be
161
+ * blind. Either way this is "no verdict", NOT a product FAIL (confirm from
162
+ * the device view tree instead).
163
+ */
164
+ export declare function foundNothing(described: string): boolean;
119
165
  /** One row of `/model` listings: a named provider and what it currently serves. */
120
166
  export interface ProviderView {
121
167
  name: string;
package/dist/types.js CHANGED
@@ -3,22 +3,116 @@
3
3
  * The kernel contract surface. Deliberately small: a Tool, a chat message,
4
4
  * a provider config. Everything else in hmharness composes from these.
5
5
  */
6
- /** Resolve a purpose to a concrete provider config (routing > legacy fields). */
6
+ /** Resolve a purpose to a concrete provider config (routing > legacy fields).
7
+ *
8
+ * For 'vision' a provider explicitly marked `supportsVision: false` is NOT
9
+ * accepted: it is skipped so the dedicated `vision` block (or the chat
10
+ * default) wins instead. Rationale - `routing.vision` used to shadow the
11
+ * `vision` block unconditionally, so a text-only model there received every
12
+ * screenshot and replied "I can't view the image" with HTTP 200; the UI
13
+ * regression tool then reported a FAIL about the app instead of a provider
14
+ * failure (false negative, mis-attributed to the product). */
7
15
  export function resolveProvider(cfg, purpose) {
8
- const named = cfg.routing?.[purpose] ?? (purpose === 'vision' ? undefined : cfg.routing?.chat);
16
+ if (purpose === 'vision') {
17
+ const chain = visionProviderChain(cfg);
18
+ if (chain.length > 0)
19
+ return chain[0];
20
+ return cfg.vision ?? cfg.provider;
21
+ }
22
+ const named = cfg.routing?.[purpose] ?? cfg.routing?.chat;
9
23
  if (named && cfg.providers?.[named])
10
24
  return cfg.providers[named];
11
- if (purpose === 'vision')
12
- return cfg.vision ?? cfg.provider;
13
25
  return cfg.provider;
14
26
  }
27
+ /**
28
+ * Ordered vision candidates: routing.vision provider, the legacy `vision`
29
+ * block, then `visionFallbacks`; de-duplicated by endpoint+model and with
30
+ * providers marked `supportsVision: false` removed. If everything is marked
31
+ * blind the unfiltered list is returned, so callers still get today's
32
+ * (failing, but informative) behaviour instead of "no vision provider".
33
+ * A caller looping this chain turns a blind/dead provider into a retry
34
+ * rather than a wrong answer.
35
+ */
36
+ export function visionProviderChain(cfg) {
37
+ const routed = cfg.routing?.vision ? cfg.providers?.[cfg.routing.vision] : undefined;
38
+ const all = [routed, cfg.vision, ...(cfg.visionFallbacks ?? []), cfg.provider].filter((p) => Boolean(p && p.baseUrl));
39
+ const seen = new Set();
40
+ const unique = all.filter((p) => {
41
+ const k = `${p.baseUrl}|${p.model}`;
42
+ if (seen.has(k))
43
+ return false;
44
+ seen.add(k);
45
+ return true;
46
+ });
47
+ const sighted = unique.filter((p) => p.supportsVision !== false);
48
+ return sighted.length > 0 ? sighted : unique;
49
+ }
50
+ /**
51
+ * Did the model answer "I can't see any image" instead of describing one?
52
+ * A blind (text-only) provider returns HTTP 200 with prose like this, so
53
+ * callers MUST NOT treat such a reply as evidence about the image - for UI
54
+ * regression that is the difference between a provider failure and a
55
+ * product FAIL. Checked on the head of the answer, where refusals live.
56
+ */
57
+ export function isVisionRefusal(text) {
58
+ const head = (text ?? '').trim().slice(0, 600);
59
+ if (!head)
60
+ return false;
61
+ return VISION_REFUSAL_PATTERNS.some((re) => re.test(head));
62
+ }
63
+ /** Head-of-answer phrases that mean "I never looked at the image". Kept as
64
+ * small separate patterns so each one is verifiable on its own. */
65
+ export const VISION_REFUSAL_PATTERNS = [
66
+ /i\s*(?:'|\u2019)?(?:m|am)\s*(?:not\s+able|unable)\s+to\s+(?:view|see|access|read|analy[sz]e)/i,
67
+ /i\s+can(?:'|\u2019)?t\s+(?:view|see|access|read|analy[sz]e)\s+(?:the|this|that|any|an)?\s*(?:\w+\s+)?(?:image|images|picture|photo|screenshot)/i,
68
+ /\bcannot\s+(?:view|see|access|read)\s+(?:the|this|that|any)?\s*(?:\w+\s+)?(?:image|images|picture|photo|screenshot)/i,
69
+ /unable\s+to\s+(?:view|see|process|access|analy[sz]e)\s+(?:the|this|any)?\s*(?:\w+\s+)?(?:image|images|picture|photo|screenshot)/i,
70
+ /no\s+image\s+(?:was\s+|is\s+)?(?:provided|attached|received|supplied|included)/i,
71
+ /i\s+don(?:'|\u2019)?t\s+see\s+(?:an|any)\s+image/i,
72
+ /i\s+(?:do\s+not|don(?:'|\u2019)?t)\s+have\s+(?:the\s+)?(?:ability|capability)\s+to\s+(?:view|see|process)\s+(?:image|images|the\s+image)/i,
73
+ /as\s+an?\s+(?:ai|artificial\s+intelligence|language\s+model|text[- ]only\s+model)[^.\n]{0,60}(?:can(?:'|\u2019)?t|cannot|unable|do\s+not)/i,
74
+ /\bi\s+can(?:'|\u2019)?t\s+\w+\s+(?:the|this)\s+(?:image|screenshot|picture|photo)/i,
75
+ /\u6211\s*(?:\u65e0\u6cd5|\u4e0d\u80fd|\u6ca1\u6cd5|\u770b\u4e0d\u5230)\s*(?:\u67e5\u770b|\u770b\u5230|\u8bc6\u522b|\u8bfb\u53d6|\u7406\u89e3|\u770b\u89c1)?[^\u3002\n]{0,12}(?:\u56fe\u7247|\u56fe\u50cf)/,
76
+ /(?:\u65e0\u6cd5|\u4e0d\u80fd)\s*(?:\u67e5\u770b|\u8bc6\u522b|\u8bfb\u53d6)\s*(?:\u56fe\u7247|\u56fe\u50cf)/,
77
+ /\u4f5c\u4e3a\s*(?:\u4e00\u4e2a)?\s*(?:AI|\u4eba\u5de5\u667a\u80fd|\u8bed\u8a00\u6a21\u578b|\u6587\u672c\u6a21\u578b)[^\u3002\n]{0,30}(?:\u65e0\u6cd5|\u4e0d\u80fd)/,
78
+ /(?:\u672a|\u6ca1\u6709)(?:\u6536\u5230|\u770b\u5230|\u68c0\u6d4b\u5230)\s*(?:\u4efb\u4f55)?\s*\u56fe\u7247/,
79
+ // observed live 2026-09-11 from the local text-only @quality endpoint:
80
+ // "The device screen cannot be described because the provided image is
81
+ // unsupported or unavailable. FOUND: No readable UI text" - it parroted
82
+ // the required FOUND: line while admitting it never received the image.
83
+ /(?:image|screenshot|picture|photo)\s+(?:is\s+|was\s+|appears\s+)?(?:unsupported|unavailable|not\s+supported|invalid|unreadable|missing)/i,
84
+ /(?:unsupported|unavailable|invalid|unreadable)\s+(?:image|screenshot|picture|photo|image\s+format|attachment)/i,
85
+ /(?:cannot|can(?:'|\u2019)?t|unable\s+to)\s+be\s+described/i,
86
+ /(?:unable|not\s+able)\s+to\s+describe\s+(?:the|this|any)?\s*(?:image|screenshot|screen|picture|photo)/i,
87
+ ];
88
+ /** The `FOUND: <text>` line the regression prompt demands, or null. */
89
+ export function foundLineText(described) {
90
+ const m = /found:\s*(.+)/i.exec(described ?? '');
91
+ return m ? m[1].trim().replace(/[.。]+$/, '') : null;
92
+ }
93
+ /**
94
+ * Did the model itself report reading NO text? Then there is no evidence
95
+ * about the app either way - the screen may be blank, or the provider may be
96
+ * blind. Either way this is "no verdict", NOT a product FAIL (confirm from
97
+ * the device view tree instead).
98
+ */
99
+ export function foundNothing(described) {
100
+ const fnd = foundLineText(described);
101
+ if (fnd === null)
102
+ return false;
103
+ return /^(?:no|none|n\/?a|nil|nothing|null|-{1,3})[\s\S]{0,40}$/i.test(fnd) || /no\s+readable\s+(?:ui\s+)?text/i.test(fnd);
104
+ }
15
105
  export function listProviders(cfg) {
16
106
  const purposesOf = (n) => {
17
107
  const out = [];
18
108
  for (const p of ['chat', 'vision', 'evolve', 'bench']) {
19
109
  const named = cfg.routing?.[p] ?? (p !== 'vision' ? cfg.routing?.chat : undefined);
20
- if (named === n)
21
- out.push(p);
110
+ if (named !== n)
111
+ continue;
112
+ // a provider marked text-only does not serve vision, whatever routing says
113
+ if (p === 'vision' && cfg.providers?.[n]?.supportsVision === false)
114
+ continue;
115
+ out.push(p);
22
116
  }
23
117
  return out;
24
118
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/kernel",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "hmharness kernel: tool registry, provider adapters, the agent loop, session log, config. Zero runtime dependencies (Node >=22 native fetch).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",