@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 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
@@ -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 ?? 120_000);
27
- return Math.max(1_000, Number.isFinite(raw) && raw > 0 ? raw : 120_000);
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
- const fallbackProvider = provider0 && cfg.fallbackModel && cfg.fallbackModel !== cfg.model ? await buildProvider({ ...cfg, model: cfg.fallbackModel, baseURL: cfg.fallbackBaseURL ?? cfg.baseURL, apiKey: cfg.fallbackApiKey ?? cfg.apiKey }) : null;
2109
- const fbOpt = fallbackProvider ? { provider: fallbackProvider } : undefined; // app-failover for the main chat turns
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).
@@ -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) {
@@ -78,7 +78,7 @@ registerTool({
78
78
  type: "object",
79
79
  properties: {
80
80
  command: { type: "string" },
81
- timeout_ms: { type: "number", description: "default 120000" },
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 ?? 120_000,
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.112.3",
3
+ "version": "0.112.4",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"