@nanhara/hara 0.112.3 → 0.112.4
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/CHANGELOG.md +18 -0
- package/dist/agent/loop.js +7 -2
- package/dist/config.js +3 -2
- package/dist/index.js +28 -2
- package/dist/providers/openai.js +2 -1
- package/dist/tools/builtin.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.112.4 — reasoning models don't false-timeout · cross-provider fallback routes correctly
|
|
9
|
+
|
|
10
|
+
- **A reasoning model (qwen3.7-plus / GLM / DeepSeek) thinking on a long context no longer false-times-out.**
|
|
11
|
+
These models stream `reasoning_content` (the thought) for a while — often long — before the first real
|
|
12
|
+
`content` token. The stall watchdog was only fed by rendered output, so on a big context (e.g. 58
|
|
13
|
+
messages) the model could still be thinking when the 120s "no output" timer fired. Now **every stream
|
|
14
|
+
chunk — reasoning, tool-args, even suppressed reasoning — resets the watchdog** (new `onActivity`), and
|
|
15
|
+
the default stall timeout is **120s → 240s** (still `HARA_STALL_TIMEOUT`-tunable). Confirmed: simple
|
|
16
|
+
calls always worked; only the long-context interactive case timed out — a hara bug, not the API.
|
|
17
|
+
- **`bash` default timeout 120s → 300s** — a long file transform/build legitimately runs past two minutes;
|
|
18
|
+
it was erroring out. (Set `timeout_ms`, or `background:true` for a server.)
|
|
19
|
+
- **Cross-provider fallback now targets the right endpoint.** With `fallbackModel` set but no
|
|
20
|
+
`fallbackBaseURL`, the fallback reused the PRIMARY baseURL — so e.g. a `deepseek-v4-pro` fallback got
|
|
21
|
+
posted to `coding.dashscope` → 400. New **`fallbackProvider`** config routes the fallback to that
|
|
22
|
+
vendor's endpoint + its own key (`fallbackBaseURL`/`fallbackApiKey` still override); and a mismatched
|
|
23
|
+
fallback (a different-vendor model with no routing configured) is now refused with a clear warning
|
|
24
|
+
instead of silently 400-ing on failover.
|
|
25
|
+
|
|
8
26
|
## 0.112.3 — big file write no longer traps the model in a "params not passed" loop
|
|
9
27
|
|
|
10
28
|
- **Fixed the loop where a model (glm-5 / qwen via DashScope) repeats `write_file` / `bash` with empty or
|
package/dist/agent/loop.js
CHANGED
|
@@ -23,8 +23,8 @@ const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
|
|
|
23
23
|
* hidden-reasoning models can legitimately go quiet for a while; HARA_STALL_TIMEOUT (ms) tunes it,
|
|
24
24
|
* floor 1s (tests). codex's equivalent is its 2–9s stream-idle timeout. */
|
|
25
25
|
export function stallMs() {
|
|
26
|
-
const raw = Number(process.env.HARA_STALL_TIMEOUT ??
|
|
27
|
-
return Math.max(1_000, Number.isFinite(raw) && raw > 0 ? raw :
|
|
26
|
+
const raw = Number(process.env.HARA_STALL_TIMEOUT ?? 240_000);
|
|
27
|
+
return Math.max(1_000, Number.isFinite(raw) && raw > 0 ? raw : 240_000);
|
|
28
28
|
}
|
|
29
29
|
/** Spinner verb (terminal mode + reused by TUI tests): when the agent has an in_progress todo,
|
|
30
30
|
* surface its activeForm/text so the bottom-of-screen line reads concretely ("▶ updating tests… 3s")
|
|
@@ -192,6 +192,11 @@ export async function runAgent(history, opts) {
|
|
|
192
192
|
system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
|
|
193
193
|
history,
|
|
194
194
|
tools: specs,
|
|
195
|
+
// Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
|
|
196
|
+
// reasoning model thinking for a long while before its first `content` token can't be false-timed-out.
|
|
197
|
+
onActivity: () => {
|
|
198
|
+
lastEvent = Date.now();
|
|
199
|
+
},
|
|
195
200
|
onText: (d) => {
|
|
196
201
|
alive();
|
|
197
202
|
if (opts.quiet)
|
package/dist/config.js
CHANGED
|
@@ -30,7 +30,7 @@ const PROVIDER_DEFAULTS = {
|
|
|
30
30
|
},
|
|
31
31
|
"hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device → token in ~/.hara/org.json, routed by the gateway
|
|
32
32
|
};
|
|
33
|
-
export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "fallbackModel", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
|
|
33
|
+
export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "fallbackModel", "fallbackProvider", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
|
|
34
34
|
export const REASONING_EFFORTS = ["off", "low", "medium", "high"];
|
|
35
35
|
export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
|
|
36
36
|
export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
|
|
@@ -160,13 +160,14 @@ export function loadConfig(opts = {}) {
|
|
|
160
160
|
const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
|
|
161
161
|
const updateCheck = !(process.env.HARA_UPDATE_CHECK === "0" || merged.updateCheck === false || merged.updateCheck === "false"); // default ON
|
|
162
162
|
const fallbackModel = process.env.HARA_FALLBACK_MODEL ?? merged.fallbackModel;
|
|
163
|
+
const fallbackProvider = (process.env.HARA_FALLBACK_PROVIDER ?? merged.fallbackProvider);
|
|
163
164
|
const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
|
|
164
165
|
const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
|
|
165
166
|
const reasoningRaw = process.env.HARA_REASONING_EFFORT ?? merged.reasoningEffort;
|
|
166
167
|
const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high"].includes(reasoningRaw)
|
|
167
168
|
? reasoningRaw
|
|
168
169
|
: undefined;
|
|
169
|
-
return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, vimMode, autoCompact, fileCheckpoints, updateCheck, fallbackModel, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: process.cwd() };
|
|
170
|
+
return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, vimMode, autoCompact, fileCheckpoints, updateCheck, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: process.cwd() };
|
|
170
171
|
}
|
|
171
172
|
export function providerEnvKey(provider) {
|
|
172
173
|
return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
|
package/dist/index.js
CHANGED
|
@@ -2105,8 +2105,34 @@ program.action(async (opts) => {
|
|
|
2105
2105
|
if (opts.model)
|
|
2106
2106
|
cfg.model = opts.model;
|
|
2107
2107
|
const provider0 = await withRouting(await buildProvider(cfg), cfg);
|
|
2108
|
-
|
|
2109
|
-
|
|
2108
|
+
// Fallback provider, built correctly for CROSS-PROVIDER failover. The old `baseURL: fallbackBaseURL ??
|
|
2109
|
+
// baseURL` sent the fallback model to the PRIMARY endpoint when no fallbackBaseURL was set — e.g.
|
|
2110
|
+
// deepseek-v4-pro posted to coding.dashscope → 400. Now: `fallbackProvider` routes to that vendor's
|
|
2111
|
+
// endpoint + its own key (fallbackBaseURL/fallbackApiKey still override); without it we stay on the
|
|
2112
|
+
// primary endpoint (correct only for a same-endpoint fallback).
|
|
2113
|
+
let fallbackProv = null;
|
|
2114
|
+
if (provider0 && cfg.fallbackModel && cfg.fallbackModel !== cfg.model) {
|
|
2115
|
+
const fp = cfg.fallbackProvider;
|
|
2116
|
+
const cross = !!fp && fp !== cfg.provider;
|
|
2117
|
+
const family = (m) => m.toLowerCase().split(/[-.:/]/)[0];
|
|
2118
|
+
if (cross && !cfg.fallbackApiKey && !cfg.apiKey) {
|
|
2119
|
+
process.stderr.write(`hara: fallbackProvider '${fp}' needs its own key — set fallbackApiKey. Fallback disabled.\n`);
|
|
2120
|
+
}
|
|
2121
|
+
else if (!fp && !cfg.fallbackBaseURL && family(cfg.fallbackModel) !== family(cfg.model)) {
|
|
2122
|
+
// A different-looking vendor with no routing set would hit the primary endpoint and 400 on failover.
|
|
2123
|
+
process.stderr.write(`hara: fallbackModel '${cfg.fallbackModel}' looks like a different vendor than '${cfg.model}', but no fallbackProvider/fallbackBaseURL is set — it would hit the PRIMARY endpoint (likely 400). Set fallbackProvider (+ fallbackApiKey). Fallback disabled.\n`);
|
|
2124
|
+
}
|
|
2125
|
+
else {
|
|
2126
|
+
fallbackProv = await buildProvider({
|
|
2127
|
+
...cfg,
|
|
2128
|
+
provider: fp ?? cfg.provider,
|
|
2129
|
+
model: cfg.fallbackModel,
|
|
2130
|
+
baseURL: cfg.fallbackBaseURL ?? (fp ? providerDefaultBaseURL(fp) : cfg.baseURL),
|
|
2131
|
+
apiKey: cfg.fallbackApiKey ?? (cross ? undefined : cfg.apiKey),
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
const fbOpt = fallbackProv ? { provider: fallbackProv } : undefined; // app-failover for the main chat turns
|
|
2110
2136
|
const guardianOpt = await buildGuardian(cfg, provider0); // internal safety layer (high-risk actions only)
|
|
2111
2137
|
if (!provider0) {
|
|
2112
2138
|
// First-run friendliness: offer the setup wizard instead of just erroring (interactive TTY only).
|
package/dist/providers/openai.js
CHANGED
|
@@ -84,7 +84,7 @@ export function createOpenAIProvider(opts) {
|
|
|
84
84
|
return {
|
|
85
85
|
id: opts.label ?? "openai",
|
|
86
86
|
model: opts.model,
|
|
87
|
-
async turn({ system, history, tools, onText, onReasoning, signal }) {
|
|
87
|
+
async turn({ system, history, tools, onText, onReasoning, onActivity, signal }) {
|
|
88
88
|
const oaiTools = tools.map((t) => ({
|
|
89
89
|
type: "function",
|
|
90
90
|
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
@@ -112,6 +112,7 @@ export function createOpenAIProvider(opts) {
|
|
|
112
112
|
try {
|
|
113
113
|
const stream = await client.chat.completions.create(params, { signal });
|
|
114
114
|
for await (const chunk of stream) {
|
|
115
|
+
onActivity?.(); // ANY chunk (reasoning, tool-args, content, even a keep-alive) → the model is alive
|
|
115
116
|
const choice = chunk.choices?.[0];
|
|
116
117
|
const delta = choice?.delta;
|
|
117
118
|
if (delta?.content) {
|
package/dist/tools/builtin.js
CHANGED
|
@@ -78,7 +78,7 @@ registerTool({
|
|
|
78
78
|
type: "object",
|
|
79
79
|
properties: {
|
|
80
80
|
command: { type: "string" },
|
|
81
|
-
timeout_ms: { type: "number", description: "default
|
|
81
|
+
timeout_ms: { type: "number", description: "default 300000 (5 min); raise for a long build/transform, or use background:true for a server" },
|
|
82
82
|
background: { type: "boolean", description: "run as a background job (dev server, watcher, long task); returns a job id immediately — tail/kill it with the `job` tool" },
|
|
83
83
|
},
|
|
84
84
|
required: ["command"],
|
|
@@ -104,7 +104,7 @@ registerTool({
|
|
|
104
104
|
: undefined;
|
|
105
105
|
try {
|
|
106
106
|
const { stdout, stderr } = await runShell(input.command, ctx.cwd, ctx.sandbox ?? "off", {
|
|
107
|
-
timeout: input.timeout_ms ??
|
|
107
|
+
timeout: input.timeout_ms ?? 300_000, // was 120s — a long file transform/build legitimately runs longer
|
|
108
108
|
maxBuffer: 10 * 1024 * 1024,
|
|
109
109
|
onData: live,
|
|
110
110
|
});
|