@hmharness/kernel 0.6.5 → 0.6.7
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/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/loop.js +13 -1
- package/dist/provider.d.ts +7 -0
- package/dist/provider.js +21 -3
- package/dist/shellgate.d.ts +24 -0
- package/dist/shellgate.js +34 -0
- package/dist/types.d.ts +59 -3
- package/dist/types.js +100 -6
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/loop.js
CHANGED
|
@@ -72,6 +72,18 @@ export async function runLoop(opts) {
|
|
|
72
72
|
});
|
|
73
73
|
usage.promptTokens += chatRes.usage?.prompt_tokens ?? 0;
|
|
74
74
|
usage.completionTokens += chatRes.usage?.completion_tokens ?? 0;
|
|
75
|
+
// Usage fallback: many OpenAI-compatible gateways never report usage
|
|
76
|
+
// (even with stream_options.include_usage), which used to leave the token
|
|
77
|
+
// valve counting zero - a runaway loop had NO stop. Estimate from the
|
|
78
|
+
// wire text instead (chars/4 heuristic): coarse, but the valve only needs
|
|
79
|
+
// an order of magnitude to trip at 50M.
|
|
80
|
+
if (!chatRes.usage || (!chatRes.usage.prompt_tokens && !chatRes.usage.completion_tokens)) {
|
|
81
|
+
const promptChars = compacted.reduce((n, m) => n + (m.content?.length ?? 0), 0);
|
|
82
|
+
const replyChars = (chatRes.message.content?.length ?? 0)
|
|
83
|
+
+ (chatRes.message.tool_calls ?? []).reduce((n, c) => n + c.function.arguments.length, 0);
|
|
84
|
+
usage.promptTokens += Math.ceil(promptChars / 4);
|
|
85
|
+
usage.completionTokens += Math.ceil(replyChars / 4);
|
|
86
|
+
}
|
|
75
87
|
const { message } = chatRes;
|
|
76
88
|
events?.onAssistant?.(message);
|
|
77
89
|
const calls = message.tool_calls ?? [];
|
|
@@ -105,7 +117,7 @@ export async function runLoop(opts) {
|
|
|
105
117
|
p.isError = true;
|
|
106
118
|
p.skip = true;
|
|
107
119
|
}
|
|
108
|
-
else if (tool.needsApproval?.(args)) {
|
|
120
|
+
else if (tool.needsApproval?.(args, ctx)) {
|
|
109
121
|
// Safe default: with no gate wired in, risky tools are denied.
|
|
110
122
|
const granted = opts.approval ? await opts.approval.ask(name, args) : false;
|
|
111
123
|
events?.onApproval?.(name, args, granted);
|
package/dist/provider.d.ts
CHANGED
|
@@ -15,6 +15,13 @@ export interface ChatResponse {
|
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
17
|
export type DeltaKind = 'text' | 'reasoning';
|
|
18
|
+
/** Parse a Retry-After header into a delay in ms (MDN: exactly two legal
|
|
19
|
+
* forms - delta-seconds or HTTP-date), clamped to [0, 120s]. Returns 0 when
|
|
20
|
+
* absent/illegal (caller falls back to exponential backoff). Deliberately
|
|
21
|
+
* does NOT read x-ratelimit-reset: that header is an epoch timestamp in the
|
|
22
|
+
* wild, which naive Number() parsing turned into a ~1.7e12 ms setTimeout
|
|
23
|
+
* that overflowed and fired immediately - a retry storm. */
|
|
24
|
+
export declare function parseRetryAfterMs(raw: string | null | undefined, maxMs?: number): number;
|
|
18
25
|
export interface ChatOptions {
|
|
19
26
|
timeoutMs?: number;
|
|
20
27
|
/** Streaming callback; presence switches the request to stream:true. */
|
package/dist/provider.js
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/** Parse a Retry-After header into a delay in ms (MDN: exactly two legal
|
|
2
|
+
* forms - delta-seconds or HTTP-date), clamped to [0, 120s]. Returns 0 when
|
|
3
|
+
* absent/illegal (caller falls back to exponential backoff). Deliberately
|
|
4
|
+
* does NOT read x-ratelimit-reset: that header is an epoch timestamp in the
|
|
5
|
+
* wild, which naive Number() parsing turned into a ~1.7e12 ms setTimeout
|
|
6
|
+
* that overflowed and fired immediately - a retry storm. */
|
|
7
|
+
export function parseRetryAfterMs(raw, maxMs = 120_000) {
|
|
8
|
+
if (!raw)
|
|
9
|
+
return 0;
|
|
10
|
+
const v = raw.trim();
|
|
11
|
+
if (/^\d{1,10}$/.test(v))
|
|
12
|
+
return Math.min(maxMs, Number(v) * 1000);
|
|
13
|
+
const at = Date.parse(v);
|
|
14
|
+
if (!Number.isNaN(at))
|
|
15
|
+
return Math.min(maxMs, Math.max(0, at - Date.now()));
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
1
18
|
export async function chat(cfg, messages, tools, opts = {}) {
|
|
2
19
|
const streaming = typeof opts.onDelta === 'function';
|
|
3
20
|
const body = { model: cfg.model, messages };
|
|
@@ -31,9 +48,10 @@ export async function chat(cfg, messages, tools, opts = {}) {
|
|
|
31
48
|
signal: ctrl.signal,
|
|
32
49
|
});
|
|
33
50
|
if (res.status === 429) {
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
51
|
+
// Retry-After per RFC/MDN has two legal forms: delta-seconds ("120")
|
|
52
|
+
// or HTTP-date ("Wed, 21 Oct 2015 07:28:00 GMT"). See
|
|
53
|
+
// parseRetryAfterMs for why x-ratelimit-reset is deliberately ignored.
|
|
54
|
+
const delay = parseRetryAfterMs(res.headers.get('retry-after')) || Math.min(30_000, 2000 * Math.pow(2, attempt));
|
|
37
55
|
lastError = `HTTP 429 (rate limited): ${(await res.text()).slice(0, 200)}`;
|
|
38
56
|
await sleep(delay + Math.random() * 1000); // jitter
|
|
39
57
|
continue;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/kernel - shell fast-path gate
|
|
3
|
+
*
|
|
4
|
+
* One shared implementation for every "run this without an approval card"
|
|
5
|
+
* decision over remotely/exec-ed shell strings (the agent's ssh_run tool and
|
|
6
|
+
* the web /api/ssh proxy). String allowlists are bypassable in general -
|
|
7
|
+
* documented bypass classes include command substitution `$()`/backticks
|
|
8
|
+
* (cli-mcp-server 0.2.5, CVE-2026-28470), `&&`-blind-spots (Claude Code),
|
|
9
|
+
* and argument-level execution such as `find -exec`/`-delete`
|
|
10
|
+
* (GHSA-cv3g-hj65-pcfh). The design here closes all of them at once:
|
|
11
|
+
*
|
|
12
|
+
* fast path = ONE bare read-only verb + plain arguments + ZERO shell
|
|
13
|
+
* metacharacters anywhere (no $ ` ( ) { } < > | ; & \ quotes/newlines)
|
|
14
|
+
* and no verb whose own ARGUMENTS can execute or mutate (find, echo,
|
|
15
|
+
* xargs, awk, date -s are excluded outright).
|
|
16
|
+
*
|
|
17
|
+
* Everything else returns false - i.e. needs an approval card. Fail closed:
|
|
18
|
+
* an unknown or oddly-shaped command is never a fast path.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* True only for a bare read-only probe (e.g. `df -h`, `ps aux`, `cat /etc/hosts`).
|
|
22
|
+
* Callers: `if (!isBareProbe(cmd)) → approval required`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isBareProbe(cmd: string): boolean;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/kernel - shell fast-path gate
|
|
3
|
+
*
|
|
4
|
+
* One shared implementation for every "run this without an approval card"
|
|
5
|
+
* decision over remotely/exec-ed shell strings (the agent's ssh_run tool and
|
|
6
|
+
* the web /api/ssh proxy). String allowlists are bypassable in general -
|
|
7
|
+
* documented bypass classes include command substitution `$()`/backticks
|
|
8
|
+
* (cli-mcp-server 0.2.5, CVE-2026-28470), `&&`-blind-spots (Claude Code),
|
|
9
|
+
* and argument-level execution such as `find -exec`/`-delete`
|
|
10
|
+
* (GHSA-cv3g-hj65-pcfh). The design here closes all of them at once:
|
|
11
|
+
*
|
|
12
|
+
* fast path = ONE bare read-only verb + plain arguments + ZERO shell
|
|
13
|
+
* metacharacters anywhere (no $ ` ( ) { } < > | ; & \ quotes/newlines)
|
|
14
|
+
* and no verb whose own ARGUMENTS can execute or mutate (find, echo,
|
|
15
|
+
* xargs, awk, date -s are excluded outright).
|
|
16
|
+
*
|
|
17
|
+
* Everything else returns false - i.e. needs an approval card. Fail closed:
|
|
18
|
+
* an unknown or oddly-shaped command is never a fast path.
|
|
19
|
+
*/
|
|
20
|
+
/** Metacharacters that enable substitution, chaining, or redirection. */
|
|
21
|
+
const SHELL_METACHARS = /[$`(){}<>|;&'"\\\n\r]/;
|
|
22
|
+
/** Verbs safe to run with plain arguments only (read-only, no exec-in-args). */
|
|
23
|
+
const BARE_PROBE_VERBS = /^(ls|cat|head|tail|df|du|free|uptime|whoami|hostname|uname|ps|grep|wc|id|pwd|date)(\s+[A-Za-z0-9_.:@/=,-]+)*\s*$/;
|
|
24
|
+
/**
|
|
25
|
+
* True only for a bare read-only probe (e.g. `df -h`, `ps aux`, `cat /etc/hosts`).
|
|
26
|
+
* Callers: `if (!isBareProbe(cmd)) → approval required`.
|
|
27
|
+
*/
|
|
28
|
+
export function isBareProbe(cmd) {
|
|
29
|
+
if (!cmd || SHELL_METACHARS.test(cmd))
|
|
30
|
+
return false;
|
|
31
|
+
if (/\bdate\b/.test(cmd) && /\s--?s(e[rt])?\b/.test(cmd))
|
|
32
|
+
return false; // clock set
|
|
33
|
+
return BARE_PROBE_VERBS.test(cmd);
|
|
34
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface Tool {
|
|
|
33
33
|
* false means read-only / safe. Remote (MCP) tools default to needing
|
|
34
34
|
* approval unless their server is marked trusted.
|
|
35
35
|
*/
|
|
36
|
-
needsApproval?(args: Record<string, unknown
|
|
36
|
+
needsApproval?(args: Record<string, unknown>, ctx?: ToolContext): boolean;
|
|
37
37
|
execute(args: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>;
|
|
38
38
|
}
|
|
39
39
|
/** OpenAI-style chat message, reused across provider adapters. */
|
|
@@ -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 {
|
|
@@ -98,8 +106,18 @@ export interface HmhConfig {
|
|
|
98
106
|
* error self-notes (every task, zero cost), Tier 2 = one model-call
|
|
99
107
|
* lesson per erroring task (instant reflection), Tier 3 = this - full
|
|
100
108
|
* cycle with bench gate. Guards unchanged: double-gate, holdout, poison
|
|
101
|
-
* screen
|
|
109
|
+
* screen; writes skills/ + memory/ + evolution logs, and repo code only
|
|
110
|
+
* when evolution.autoPatch is explicitly enabled. */
|
|
102
111
|
autoEvolveEvery?: number;
|
|
112
|
+
/** Evolution-cycle options. autoPatch (default FALSE) opts into code-level
|
|
113
|
+
* self-evolution (the DGM-style sandboxed patch loop). Off by default:
|
|
114
|
+
* self-modifying systems are unsafe by default (the DGM paper's own
|
|
115
|
+
* framing) - with it off, evolution writes only skills/ + memory/ + its
|
|
116
|
+
* own logs, never repo code. */
|
|
117
|
+
evolution?: {
|
|
118
|
+
/** Enable the code-patch (self-modification) step. Default false. */
|
|
119
|
+
autoPatch?: boolean;
|
|
120
|
+
};
|
|
103
121
|
/** Named vendor endpoints for multi-provider routing. */
|
|
104
122
|
providers?: Record<string, ProviderConfig>;
|
|
105
123
|
/** Per-purpose provider names resolved against `providers`. */
|
|
@@ -114,8 +132,46 @@ export interface HmhConfig {
|
|
|
114
132
|
bench?: string;
|
|
115
133
|
};
|
|
116
134
|
}
|
|
117
|
-
/** Resolve a purpose to a concrete provider config (routing > legacy fields).
|
|
135
|
+
/** Resolve a purpose to a concrete provider config (routing > legacy fields).
|
|
136
|
+
*
|
|
137
|
+
* For 'vision' a provider explicitly marked `supportsVision: false` is NOT
|
|
138
|
+
* accepted: it is skipped so the dedicated `vision` block (or the chat
|
|
139
|
+
* default) wins instead. Rationale - `routing.vision` used to shadow the
|
|
140
|
+
* `vision` block unconditionally, so a text-only model there received every
|
|
141
|
+
* screenshot and replied "I can't view the image" with HTTP 200; the UI
|
|
142
|
+
* regression tool then reported a FAIL about the app instead of a provider
|
|
143
|
+
* failure (false negative, mis-attributed to the product). */
|
|
118
144
|
export declare function resolveProvider(cfg: HmhConfig, purpose: 'chat' | 'vision' | 'evolve' | 'bench'): ProviderConfig;
|
|
145
|
+
/**
|
|
146
|
+
* Ordered vision candidates: routing.vision provider, the legacy `vision`
|
|
147
|
+
* block, then `visionFallbacks`; de-duplicated by endpoint+model and with
|
|
148
|
+
* providers marked `supportsVision: false` removed. If everything is marked
|
|
149
|
+
* blind the unfiltered list is returned, so callers still get today's
|
|
150
|
+
* (failing, but informative) behaviour instead of "no vision provider".
|
|
151
|
+
* A caller looping this chain turns a blind/dead provider into a retry
|
|
152
|
+
* rather than a wrong answer.
|
|
153
|
+
*/
|
|
154
|
+
export declare function visionProviderChain(cfg: HmhConfig): ProviderConfig[];
|
|
155
|
+
/**
|
|
156
|
+
* Did the model answer "I can't see any image" instead of describing one?
|
|
157
|
+
* A blind (text-only) provider returns HTTP 200 with prose like this, so
|
|
158
|
+
* callers MUST NOT treat such a reply as evidence about the image - for UI
|
|
159
|
+
* regression that is the difference between a provider failure and a
|
|
160
|
+
* product FAIL. Checked on the head of the answer, where refusals live.
|
|
161
|
+
*/
|
|
162
|
+
export declare function isVisionRefusal(text: string): boolean;
|
|
163
|
+
/** Head-of-answer phrases that mean "I never looked at the image". Kept as
|
|
164
|
+
* small separate patterns so each one is verifiable on its own. */
|
|
165
|
+
export declare const VISION_REFUSAL_PATTERNS: RegExp[];
|
|
166
|
+
/** The `FOUND: <text>` line the regression prompt demands, or null. */
|
|
167
|
+
export declare function foundLineText(described: string): string | null;
|
|
168
|
+
/**
|
|
169
|
+
* Did the model itself report reading NO text? Then there is no evidence
|
|
170
|
+
* about the app either way - the screen may be blank, or the provider may be
|
|
171
|
+
* blind. Either way this is "no verdict", NOT a product FAIL (confirm from
|
|
172
|
+
* the device view tree instead).
|
|
173
|
+
*/
|
|
174
|
+
export declare function foundNothing(described: string): boolean;
|
|
119
175
|
/** One row of `/model` listings: a named provider and what it currently serves. */
|
|
120
176
|
export interface ProviderView {
|
|
121
177
|
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
|
-
|
|
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
|
|
21
|
-
|
|
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.
|
|
3
|
+
"version": "0.6.7",
|
|
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",
|