@nanhara/hara 0.112.2 → 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,37 @@ 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
+
26
+ ## 0.112.3 — big file write no longer traps the model in a "params not passed" loop
27
+
28
+ - **Fixed the loop where a model (glm-5 / qwen via DashScope) repeats `write_file` / `bash` with empty or
29
+ `undefined` arguments.** Two problems compounded: the OpenAI-compatible path capped output at
30
+ `max_tokens: 8192`, so a large `write_file` (e.g. a 64-hexagram data file) ran PAST the limit and its
31
+ tool-call-arguments JSON was cut off — and hara then **silently swallowed the unparseable JSON into an
32
+ empty `{}`**. So the tool ran with no path/content (`bash` got `command: undefined` →
33
+ `/bin/sh: undefined: command not found`), the model saw the failure, "fixed" it by calling again, and
34
+ looped — never realizing its *output* had been truncated. Now: (1) `max_tokens` is raised to 32000
35
+ (glm-5/qwen accept it), and (2) **truncated/malformed tool arguments surface as an actionable error**
36
+ ("the model hit its output-length limit mid tool-call — write the file in smaller parts") instead of a
37
+ silent `{}`, so the loop breaks and the fix is obvious.
38
+
8
39
  ## 0.112.2 — moving/resizing the window no longer garbles the UI
9
40
 
10
41
  - **Terminal resize no longer stacks the status row + input box into a garble.** ink 6.8's resize
@@ -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).
@@ -2,6 +2,35 @@ import OpenAI from "openai";
2
2
  import { imageToBase64 } from "../images.js";
3
3
  import { reasoningParams } from "./reasoning.js";
4
4
  import { resolvePlatform } from "./registry.js";
5
+ /** Assemble streamed tool-call fragments into tool uses. CRITICAL: non-empty arguments that don't parse
6
+ * mean the model was cut off MID tool-call (almost always the output-length limit on a big write_file /
7
+ * bash). Silently substituting `{}` makes the model loop forever — it calls write_file with no path, bash
8
+ * with `command: undefined` (`/bin/sh: undefined: command not found`), sees the failure, and retries,
9
+ * never realizing its OUTPUT was truncated. So we surface it as an actionable error instead. Exported for
10
+ * tests. */
11
+ export function assembleToolCalls(entries, finish) {
12
+ let truncated = false;
13
+ const toolUses = entries
14
+ .filter((t) => t.id && t.name)
15
+ .map((t) => {
16
+ let input = {};
17
+ const raw = (t.args || "").trim();
18
+ if (raw) {
19
+ try {
20
+ input = JSON.parse(raw);
21
+ }
22
+ catch {
23
+ truncated = true;
24
+ }
25
+ }
26
+ return { id: t.id, name: t.name, input };
27
+ });
28
+ if (truncated) {
29
+ const why = finish === "length" ? "the model hit its output-length limit mid tool-call" : "the model emitted malformed tool-call arguments";
30
+ return { toolUses: [], error: `Tool call dropped — ${why}, so its arguments were incomplete. For a large file, write it in smaller parts (several write_file/edit calls) rather than one giant call.` };
31
+ }
32
+ return { toolUses };
33
+ }
5
34
  // Re-exported for callers that still import it from here (the reasoning-family check now lives in reasoning.ts).
6
35
  export { isReasoningModel } from "./reasoning.js";
7
36
  /** Build OpenAI chat-completions messages from neutral history. */
@@ -55,7 +84,7 @@ export function createOpenAIProvider(opts) {
55
84
  return {
56
85
  id: opts.label ?? "openai",
57
86
  model: opts.model,
58
- async turn({ system, history, tools, onText, onReasoning, signal }) {
87
+ async turn({ system, history, tools, onText, onReasoning, onActivity, signal }) {
59
88
  const oaiTools = tools.map((t) => ({
60
89
  type: "function",
61
90
  function: { name: t.name, description: t.description, parameters: t.input_schema },
@@ -63,7 +92,7 @@ export function createOpenAIProvider(opts) {
63
92
  const params = {
64
93
  model: opts.model,
65
94
  messages: toOpenAI(system, history),
66
- max_tokens: 8192,
95
+ max_tokens: 32000, // was 8192 — too small: a big write_file's args got truncated → unparseable → loop
67
96
  stream: true,
68
97
  stream_options: { include_usage: true },
69
98
  };
@@ -83,6 +112,7 @@ export function createOpenAIProvider(opts) {
83
112
  try {
84
113
  const stream = await client.chat.completions.create(params, { signal });
85
114
  for await (const chunk of stream) {
115
+ onActivity?.(); // ANY chunk (reasoning, tool-args, content, even a keep-alive) → the model is alive
86
116
  const choice = chunk.choices?.[0];
87
117
  const delta = choice?.delta;
88
118
  if (delta?.content) {
@@ -118,18 +148,9 @@ export function createOpenAIProvider(opts) {
118
148
  return { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
119
149
  return { text: "", toolUses: [], stop: "error", errorMsg: `${e?.status ?? ""} ${e?.message ?? e}` };
120
150
  }
121
- const toolUses = [...acc.values()]
122
- .filter((t) => t.id && t.name)
123
- .map((t) => {
124
- let input = {};
125
- try {
126
- input = JSON.parse(t.args || "{}");
127
- }
128
- catch {
129
- input = {};
130
- }
131
- return { id: t.id, name: t.name, input };
132
- });
151
+ const { toolUses, error: argsError } = assembleToolCalls([...acc.values()], finish);
152
+ if (argsError)
153
+ return { text, toolUses: [], stop: "error", errorMsg: argsError, usage };
133
154
  const stop = finish === "tool_calls" || toolUses.length ? "tool_use" : "end";
134
155
  return { text, toolUses, stop, usage };
135
156
  },
@@ -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.2",
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"