@tiens.nguyen/gu-cli 1.0.795 → 1.0.797
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/client-expert-api.mjs +1 -1
- package/coding-choice-tags.mjs +1 -1
- package/coding-key-prompt.mjs +4 -4
- package/device-login.mjs +1 -1
- package/embed-model-command.mjs +37 -0
- package/gonext_agent_chat.py +209 -28
- package/gu-cli.mjs +39 -4
- package/gu-repl.mjs +288 -199
- package/log-level.mjs +193 -0
- package/model-prices.mjs +27 -0
- package/model-setup-ui.mjs +1 -1
- package/ollama-setup.mjs +5 -5
- package/package.json +2 -1
- package/proxy-config.mjs +3 -3
- package/terminal-hover.mjs +32 -1
- package/terminal-layout.mjs +55 -0
package/client-expert-api.mjs
CHANGED
|
@@ -701,7 +701,7 @@ export async function probeLocalOllama() {
|
|
|
701
701
|
*/
|
|
702
702
|
/** The shared Ollama every client machine can already reach. */
|
|
703
703
|
export const SHARED_OLLAMA_URL =
|
|
704
|
-
process.env.GONEXT_CLIENT_AGENT_URL?.trim() || "https://ollama1.
|
|
704
|
+
process.env.GONEXT_CLIENT_AGENT_URL?.trim() || "https://ollama1.getmarsic.cc/v1";
|
|
705
705
|
/**
|
|
706
706
|
* Chat model — the one the agent routes and thinks with, on every step of every turn.
|
|
707
707
|
*
|
package/coding-choice-tags.mjs
CHANGED
|
@@ -69,7 +69,7 @@ export function codingChoiceTags(choices) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* WHERE AN OLLAMA CODER RUNS — "ollama.com", "127.0.0.1", "ollama1.
|
|
72
|
+
* WHERE AN OLLAMA CODER RUNS — "ollama.com", "127.0.0.1", "ollama1.getmarsic.cc" — or "" for any
|
|
73
73
|
* other kind.
|
|
74
74
|
*
|
|
75
75
|
* Reported: "/model ✓ coding model → kimi-k3 (ollama)". The tag was the API's provider label, the
|
package/coding-key-prompt.mjs
CHANGED
|
@@ -37,10 +37,10 @@ export const SECRET_KEY_HINT = "type or paste the key, then press Enter — it s
|
|
|
37
37
|
* that does not exist and, worse, reappears on every start because it can never be satisfied.
|
|
38
38
|
*
|
|
39
39
|
* Reported live: a Client Expert machine whose coder is gemma4-quadro-49k on
|
|
40
|
-
* ollama1.
|
|
40
|
+
* ollama1.getmarsic.cc being asked, at every launch, for that model's "API key".
|
|
41
41
|
*
|
|
42
|
-
* Matched on the HOST, not a substring: `evil.com/?x=
|
|
43
|
-
* `
|
|
42
|
+
* Matched on the HOST, not a substring: `evil.com/?x=getmarsic.cc` and
|
|
43
|
+
* `getmarsic.cc.attacker.net` must not be treated as ours — being wrong here would suppress a
|
|
44
44
|
* genuine prompt and leave someone's coder quietly unusable.
|
|
45
45
|
*/
|
|
46
46
|
export function isKeylessCodingHost(url) {
|
|
@@ -51,7 +51,7 @@ export function isKeylessCodingHost(url) {
|
|
|
51
51
|
return false;
|
|
52
52
|
}
|
|
53
53
|
if (host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]") return true;
|
|
54
|
-
return host === "
|
|
54
|
+
return host === "getmarsic.cc" || host.endsWith(".getmarsic.cc");
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
package/device-login.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { homedir, platform } from "node:os";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import dotenv from "dotenv";
|
|
10
10
|
|
|
11
|
-
export const DEFAULT_API_BASE = "https://chat-api-v2.
|
|
11
|
+
export const DEFAULT_API_BASE = "https://chat-api-v2.getmarsic.cc";
|
|
12
12
|
const ENV_FILE = join(homedir(), ".gonext", "worker.env");
|
|
13
13
|
const POLL_INTERVAL_MS = 2500;
|
|
14
14
|
const POLL_TIMEOUT_MS = 10 * 60_000; // matches the server's pairing TTL
|
package/embed-model-command.mjs
CHANGED
|
@@ -186,3 +186,40 @@ export function describeEmbedChange(plan) {
|
|
|
186
186
|
lines.push("They will be ignored at search time and rebuilt on the next search.");
|
|
187
187
|
return lines;
|
|
188
188
|
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* IS RAG ACTUALLY ON, AND IF NOT, WHY? (task #347)
|
|
192
|
+
*
|
|
193
|
+
* Reported: "Choose embed model but exit and gu again, it keeps asking 'embed model not set',
|
|
194
|
+
* again and again." Two settings decide it and they were treated as one: WHICH embedder
|
|
195
|
+
* (`ragEmbedModel` + where it is served) and WHETHER RAG is on (`ragEnabled`). /embed-model saved
|
|
196
|
+
* the first and never the second, and the bar's warning is driven by the second — so the choice
|
|
197
|
+
* was stored, never took effect, and the warning came back at every start.
|
|
198
|
+
*
|
|
199
|
+
* → { state, model }
|
|
200
|
+
* "on" an embedder is chosen and RAG is on
|
|
201
|
+
* "off" an embedder IS chosen, RAG is switched off — not "no embed model"
|
|
202
|
+
* "unset" no embedder was ever chosen
|
|
203
|
+
* Pure. `settings` is the worker-settings view (null/undefined = unknown → "unset").
|
|
204
|
+
*/
|
|
205
|
+
export function embedState(settings) {
|
|
206
|
+
const model = String(settings?.ragEmbedModel ?? "").trim();
|
|
207
|
+
const where = String(settings?.ragEmbedUrl ?? "").trim() || String(settings?.ragEmbedModelPath ?? "").trim();
|
|
208
|
+
if (!model || !where) return { state: "unset", model };
|
|
209
|
+
return { state: settings?.ragEnabled === true ? "on" : "off", model };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* What saving an embedder must write: the model, its server — AND RAG on. Choosing an embedder
|
|
214
|
+
* is the act of turning RAG on; a choice that leaves it off is the bug above.
|
|
215
|
+
*/
|
|
216
|
+
export function embedSaveFields({ url, model }) {
|
|
217
|
+
return { ragEmbedUrl: url, ragEmbedModel: model, ragEmbedKeyId: "", ragEnabled: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The bar's row for a state that is not "on" — "" when RAG is on. One short row (#317). */
|
|
221
|
+
export function embedNotice({ state, model } = {}) {
|
|
222
|
+
if (state === "on") return "";
|
|
223
|
+
if (state === "off" && model) return `⚠ RAG is switched off — ${model} is set · /embed-model turns it on`;
|
|
224
|
+
return "⚠ no embed model — RAG is off · /embed-model";
|
|
225
|
+
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -1072,6 +1072,75 @@ class _AgentConfigError(RuntimeError):
|
|
|
1072
1072
|
worth a plain-reply degrade — the user needs the message itself to fix Settings."""
|
|
1073
1073
|
|
|
1074
1074
|
|
|
1075
|
+
class _OutputBudgetExhausted(_AgentConfigError):
|
|
1076
|
+
"""The coding model used its WHOLE output budget and wrote nothing — twice (issue #104).
|
|
1077
|
+
|
|
1078
|
+
Found in a real turn: Claude returned 0 characters for exactly max_tokens (32,000) output
|
|
1079
|
+
tokens, six calls in a row, ~6.5 minutes each. Every one was retried unchanged, and the turn
|
|
1080
|
+
finally stopped with "the coding model kept replying with explanations instead of a runnable
|
|
1081
|
+
code block" — which is not what happened. A subclass of the config error so the turn ends as
|
|
1082
|
+
FAILED with this message verbatim: no degrade to a plain reply, no three more steps of the
|
|
1083
|
+
same."""
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
# The retry of a budget-exhausted reply gets this much room (the first attempt had 32,000).
|
|
1087
|
+
_BUDGET_RETRY_MAX_TOKENS = 64000
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _output_budget_exhausted(content, finish_reason) -> bool:
|
|
1091
|
+
"""Did this reply end at the output limit with nothing written? Pure.
|
|
1092
|
+
|
|
1093
|
+
Only the pair counts: an empty reply that stopped normally is the "thought but didn't act"
|
|
1094
|
+
case the code-now retry exists for, and a reply cut at the limit WITH text is a truncated
|
|
1095
|
+
block, handled where blocks are closed."""
|
|
1096
|
+
return not (content or "").strip() and (finish_reason or "") == "length"
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _budget_retry_kwargs(completion_kwargs, native_claude: bool, with_effort: bool = True) -> dict:
|
|
1100
|
+
"""The ONE retry of a budget-exhausted reply: more room, and (native Claude) lower effort.
|
|
1101
|
+
|
|
1102
|
+
Never the same request again — that is what cost 192,000 output tokens. Pure."""
|
|
1103
|
+
kw = dict(completion_kwargs)
|
|
1104
|
+
current = int(kw.get("max_tokens") or kw.get("max_completion_tokens") or 0)
|
|
1105
|
+
if native_claude:
|
|
1106
|
+
kw["max_tokens"] = max(_BUDGET_RETRY_MAX_TOKENS, current)
|
|
1107
|
+
if with_effort:
|
|
1108
|
+
kw["gu_effort"] = "low"
|
|
1109
|
+
else:
|
|
1110
|
+
kw.pop("gu_effort", None)
|
|
1111
|
+
elif current:
|
|
1112
|
+
# An OpenAI-compatible server: only a limit WE set can be raised; with none, it is the
|
|
1113
|
+
# server's own and the request is left as it is.
|
|
1114
|
+
key = "max_tokens" if kw.get("max_tokens") else "max_completion_tokens"
|
|
1115
|
+
kw[key] = max(_BUDGET_RETRY_MAX_TOKENS, current * 2)
|
|
1116
|
+
return kw
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _no_code_message(model) -> str:
|
|
1120
|
+
"""Three steps in a row with no runnable code block. Pure.
|
|
1121
|
+
|
|
1122
|
+
It used to end "…or switch the coding backend back to Ollama in Settings" — said to someone
|
|
1123
|
+
whose coder was a hosted Claude model (issue #104). It now names the model and the two
|
|
1124
|
+
things that are always available."""
|
|
1125
|
+
return (
|
|
1126
|
+
f"I had to stop: {model or 'the coding model'} replied three times in a row without a "
|
|
1127
|
+
"runnable code block, so none of those steps could execute. Some models put their answer "
|
|
1128
|
+
"in their thinking and leave the reply empty. Type continue to try again, or pick another "
|
|
1129
|
+
"coding model with /model."
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _budget_exhausted_message(model, out_tokens) -> str:
|
|
1134
|
+
"""What the user is told when the retry came back empty at the limit too. Pure."""
|
|
1135
|
+
spent = f"{int(out_tokens):,} output tokens" if out_tokens else "its whole output allowance"
|
|
1136
|
+
return (
|
|
1137
|
+
f"I had to stop: {model or 'the coding model'} used {spent} on this step — twice — and "
|
|
1138
|
+
"wrote nothing. It ran out of output while thinking, before it got to the answer; this is "
|
|
1139
|
+
"not a problem with your request. Nothing was changed by this step. Type continue to try "
|
|
1140
|
+
"again, ask for the work in smaller pieces, or pick another coding model with /model."
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
|
|
1075
1144
|
class _AgentOffline(_AgentConfigError):
|
|
1076
1145
|
"""The network was gone for longer than the wait budget (task #302, part 3). Ends the turn
|
|
1077
1146
|
as FAILED — never as a stop — with the work so far kept and "continue" as the way on. A
|
|
@@ -1223,6 +1292,13 @@ def _anthropic_request(kw: dict) -> dict:
|
|
|
1223
1292
|
# NO temperature / top_p: current Claude models reject sampling parameters with a 400, and
|
|
1224
1293
|
# the thinking / effort settings are left at the model's defaults so they never vary per
|
|
1225
1294
|
# request (a change there invalidates the cache).
|
|
1295
|
+
#
|
|
1296
|
+
# ONE EXCEPTION (issue #104): the retry of a reply that used its WHOLE output budget and wrote
|
|
1297
|
+
# nothing asks for lower effort, so the second attempt cannot spend the budget the same way.
|
|
1298
|
+
# A one-off request — its cache miss is the price of not repeating a 32,000-token blank.
|
|
1299
|
+
effort = str(kw.get("gu_effort") or "").strip().lower()
|
|
1300
|
+
if effort in ("low", "medium", "high"):
|
|
1301
|
+
req["output_config"] = {"effort": effort}
|
|
1226
1302
|
return req
|
|
1227
1303
|
|
|
1228
1304
|
|
|
@@ -1319,6 +1395,10 @@ class _AnthropicChatAdapter:
|
|
|
1319
1395
|
usage["output_tokens"] = int(out)
|
|
1320
1396
|
if stop_reason == "refusal":
|
|
1321
1397
|
_log("claude: the model declined this request (stop_reason=refusal)")
|
|
1398
|
+
elif stop_reason == "max_tokens":
|
|
1399
|
+
# Issue #104: six calls ended this way with no text and nothing in the log said so.
|
|
1400
|
+
_log(f"warning: claude stopped at the output limit (stop_reason=max_tokens, "
|
|
1401
|
+
f"max_tokens={req.get('max_tokens')}, output {usage.get('output_tokens', 0)} tokens)")
|
|
1322
1402
|
_log_anthropic_usage(usage)
|
|
1323
1403
|
yield chunk(content="", finish=_ANTHROPIC_FINISH.get(stop_reason, "stop"))
|
|
1324
1404
|
yield chunk(usage=_anthropic_usage(usage))
|
|
@@ -1426,7 +1506,7 @@ def _resolve_coding_backend(kind, base_url, probe=None):
|
|
|
1426
1506
|
budget). Every branch now keys off this one value.
|
|
1427
1507
|
|
|
1428
1508
|
Order matters. The Ollama probe runs BEFORE the remote-host rule so a remote Ollama
|
|
1429
|
-
(ollama1.
|
|
1509
|
+
(ollama1.getmarsic.cc) still resolves to "ollama" and keeps its own treatment.
|
|
1430
1510
|
|
|
1431
1511
|
explicit kind → that value
|
|
1432
1512
|
probe says Ollama → "ollama"
|
|
@@ -1467,7 +1547,7 @@ def _sentinel_against_ollama(agent_base_url, agent_model_id, probe=None):
|
|
|
1467
1547
|
|
|
1468
1548
|
— an error naming a model the user never chose, on a machine whose settings look fine. That
|
|
1469
1549
|
is the whole defect: not that it fails, but that it fails illegibly and the reader has no
|
|
1470
|
-
thread to pull. Verified live against ollama1.
|
|
1550
|
+
thread to pull. Verified live against ollama1.getmarsic.cc, 2026-08-28.
|
|
1471
1551
|
|
|
1472
1552
|
WHY IT CANNOT BE FIXED WHERE IT IS PRODUCED. The API builds that payload with no idea which
|
|
1473
1553
|
KIND of server the agent URL points at — there is no agentModelKind, only
|
|
@@ -1505,7 +1585,7 @@ def _resolve_coding_target(agent_base_url, agent_model_id, raw_coding_base, raw_
|
|
|
1505
1585
|
Extracted from run_agent_chat unchanged (task #213). It decides the single most consequential
|
|
1506
1586
|
thing about a turn — the model that writes every code block — and it lived in the middle of a
|
|
1507
1587
|
400-line function, so no test could execute it. The Client Expert default puts the chat model,
|
|
1508
|
-
the coder and the embedder on ONE Ollama box (ollama1.
|
|
1588
|
+
the coder and the embedder on ONE Ollama box (ollama1.getmarsic.cc), which is exactly the shape
|
|
1509
1589
|
where "same server" and "different model" collide, and where a silent fall-back to the chat
|
|
1510
1590
|
model is invisible: both answers are a real model on a real server and the turn still works,
|
|
1511
1591
|
just not with the coder that was chosen.
|
|
@@ -3877,7 +3957,7 @@ def _is_request_timeout(err) -> bool:
|
|
|
3877
3957
|
likely STILL RUNNING on the box. Re-sending the identical request — streamed or
|
|
3878
3958
|
not — starts that same multi-minute generation again from zero.
|
|
3879
3959
|
|
|
3880
|
-
Live (2026-08-30, qwen3.8:27b on ollama1.
|
|
3960
|
+
Live (2026-08-30, qwen3.8:27b on ollama1.getmarsic.cc): a 7,251-token prompt produced no
|
|
3881
3961
|
first token inside the 600s client timeout. We logged "streamed generate failed (Request
|
|
3882
3962
|
timed out.) → falling back to non-streaming", told the user "Streaming unavailable", and
|
|
3883
3963
|
re-sent it unstreamed — buying a second 600s wait with no first-token signal at all.
|
|
@@ -7485,21 +7565,42 @@ def _ws_distill_output(out: str, cap_head: int = 1200, cap_tail: int = 4000) ->
|
|
|
7485
7565
|
# d6ad7211 walked a 6,665-line file in slices for ~40 steps. With cache reads at 0.1x a bigger
|
|
7486
7566
|
# read costs about what the extra step did, and steps are what we save. Claude Code's own Read
|
|
7487
7567
|
# allows 25k tokens. Small or unknown windows keep 3,000 — a 16k local Ollama is unchanged.
|
|
7568
|
+
#
|
|
7569
|
+
# VERY LARGE WINDOWS GET MORE (GitHub #105 / task #355) — for ANY model the user picks, by its
|
|
7570
|
+
# window, never by its name. The 24,000-char ceiling was reached at a 200k window and then stood
|
|
7571
|
+
# still: a 1,000,000-token coder was allowed the same read as a 200k one — 0.6% of its window —
|
|
7572
|
+
# and read_file_lines stopped at 400 lines whatever the model. A 6,665-line file is 17 reads at
|
|
7573
|
+
# 400 lines, 4 at 2,000. So above 200k the same 3% share keeps applying, up to 80,000 chars
|
|
7574
|
+
# (~20k tokens — about what Claude Code's own Read allows), and the line limit follows the cap.
|
|
7575
|
+
# Nothing changes at or below 200k: those rows of the table are exactly what they were.
|
|
7576
|
+
#
|
|
7577
|
+
# The ceiling is about COST, not fit: every read stays in the step memory and is sent again on
|
|
7578
|
+
# each later step (cached at ~0.1x on hosted coders, re-evaluated in full on a self-hosted one),
|
|
7579
|
+
# so "it fits" is not a reason to hand over a whole 500k-char file.
|
|
7488
7580
|
_OBS_CAP_FLOOR = 3000
|
|
7489
|
-
_OBS_CAP_READ_MAX = 24_000
|
|
7490
|
-
|
|
7491
|
-
|
|
7492
|
-
|
|
7581
|
+
_OBS_CAP_READ_MAX = 24_000 # the ceiling up to a 200k window ≈ a 400-line read
|
|
7582
|
+
_OBS_CAP_READ_MAX_LARGE = 80_000 # …and above it (reached at ~667k tokens)
|
|
7583
|
+
_OBS_CAP_SEARCH_MAX = 12_000 # ≈ grep_repo's 100 hits / list_dir's 300 entries
|
|
7584
|
+
_OBS_CAP_SEARCH_MAX_LARGE = 24_000
|
|
7585
|
+
_OBS_CAP_LARGE_WINDOW = 200_000 # tokens; above this the large ceilings apply
|
|
7586
|
+
_OBS_CAP_WINDOW_SHARE = 0.03 # one read may take ~3% of the window
|
|
7587
|
+
_OBS_LINES_FLOOR = 400 # read_file_lines' limit, as it always was
|
|
7588
|
+
_OBS_LINES_MAX = 2000
|
|
7589
|
+
_OBS_CHARS_PER_LINE = 60 # 24,000 chars ≈ 400 lines — the ratio the old pair already had
|
|
7590
|
+
_OBS_CAPS = {"read": _OBS_CAP_FLOOR, "search": _OBS_CAP_FLOOR, "lines": _OBS_LINES_FLOOR}
|
|
7493
7591
|
|
|
7494
7592
|
|
|
7495
7593
|
def _obs_caps_for_window(window_tokens: int) -> dict:
|
|
7496
|
-
"""{read, search} character caps for a model with this
|
|
7594
|
+
"""{read, search} character caps and the `lines` limit of one read, for a model with this
|
|
7595
|
+
context window (0 = unknown). Pure — the whole rule is this function."""
|
|
7497
7596
|
if not window_tokens or window_tokens <= 0:
|
|
7498
|
-
return {"read": _OBS_CAP_FLOOR, "search": _OBS_CAP_FLOOR}
|
|
7597
|
+
return {"read": _OBS_CAP_FLOOR, "search": _OBS_CAP_FLOOR, "lines": _OBS_LINES_FLOOR}
|
|
7598
|
+
large = window_tokens > _OBS_CAP_LARGE_WINDOW
|
|
7499
7599
|
read = int(window_tokens * 4 * _OBS_CAP_WINDOW_SHARE) # ~4 chars per token
|
|
7500
|
-
read = max(_OBS_CAP_FLOOR, min(_OBS_CAP_READ_MAX, read))
|
|
7501
|
-
search = max(_OBS_CAP_FLOOR, min(_OBS_CAP_SEARCH_MAX, read // 2))
|
|
7502
|
-
|
|
7600
|
+
read = max(_OBS_CAP_FLOOR, min(_OBS_CAP_READ_MAX_LARGE if large else _OBS_CAP_READ_MAX, read))
|
|
7601
|
+
search = max(_OBS_CAP_FLOOR, min(_OBS_CAP_SEARCH_MAX_LARGE if large else _OBS_CAP_SEARCH_MAX, read // 2))
|
|
7602
|
+
lines = max(_OBS_LINES_FLOOR, min(_OBS_LINES_MAX, read // _OBS_CHARS_PER_LINE))
|
|
7603
|
+
return {"read": read, "search": search, "lines": lines}
|
|
7503
7604
|
|
|
7504
7605
|
|
|
7505
7606
|
def _set_obs_caps(window_tokens: int) -> dict:
|
|
@@ -7664,8 +7765,12 @@ def run_agent_chat(cfg):
|
|
|
7664
7765
|
# current task are logged early, and a real turn caught them leaking when this came later).
|
|
7665
7766
|
# "production" (the account has no Enable Full Log): content becomes <content, N chars> and the
|
|
7666
7767
|
# model-request dump is only what is new per step. "full" (or absent — an older worker): as before.
|
|
7667
|
-
global _LOG_PRODUCTION
|
|
7668
|
-
|
|
7768
|
+
global _LOG_PRODUCTION, _LOG_MINIMAL
|
|
7769
|
+
_profile = str(cfg.get("logProfile") or "full").strip().lower()
|
|
7770
|
+
# "minimal" (task #352) is production's rules — no conversation content — plus: only trouble
|
|
7771
|
+
# is logged at all (see _log).
|
|
7772
|
+
_LOG_MINIMAL = _profile == "minimal"
|
|
7773
|
+
_LOG_PRODUCTION = _profile in ("production", "minimal")
|
|
7669
7774
|
# One turn, one ledger (task #142): the process is per-turn today, but a stale entry would
|
|
7670
7775
|
# credit RAG for tokens it never cost, and a wrong number is worse than none.
|
|
7671
7776
|
_rag_ledger_reset()
|
|
@@ -9703,7 +9808,8 @@ def run_agent_chat(cfg):
|
|
|
9703
9808
|
_ctx_window = _model_context_window(coding_base_url, coding_model_id, coding_api_key)
|
|
9704
9809
|
_compact_at = _compact_threshold(_ctx_window)
|
|
9705
9810
|
_caps = _set_obs_caps(_ctx_window)
|
|
9706
|
-
_log(f"read caps: {_caps['read']} chars per read, {_caps['search']}
|
|
9811
|
+
_log(f"read caps: {_caps['read']} chars / {_caps['lines']} lines per read, {_caps['search']} chars "
|
|
9812
|
+
f"per search — for a {_ctx_window or 'unknown'}-token window (issues #49, #105)")
|
|
9707
9813
|
if _ctx_window:
|
|
9708
9814
|
# SAY WHICH SOURCE ANSWERED. A measured window and a remembered one are not the same
|
|
9709
9815
|
# claim, and a table entry presented as fact is how a stale number survives unquestioned.
|
|
@@ -10088,7 +10194,7 @@ def run_agent_chat(cfg):
|
|
|
10088
10194
|
# stream_agent: request the completion with stream=True and reassemble the
|
|
10089
10195
|
# deltas into one message. Enabled for Ollama, where a long single-shot
|
|
10090
10196
|
# (non-stream) generation sends nothing until the very end — a reverse proxy
|
|
10091
|
-
# in front of the box (ollama1.
|
|
10197
|
+
# in front of the box (ollama1.getmarsic.cc) then hits its idle read-timeout
|
|
10092
10198
|
# and kills the request mid-generation. Streaming keeps tokens flowing so the
|
|
10093
10199
|
# idle timer never fires. smolagents still receives the COMPLETE text, so its
|
|
10094
10200
|
# <code>/final_answer parsing is unaffected.
|
|
@@ -10178,6 +10284,11 @@ def run_agent_chat(cfg):
|
|
|
10178
10284
|
return super().generate(*args, **kwargs)
|
|
10179
10285
|
try:
|
|
10180
10286
|
return self._streamed_generate(*args, **kwargs)
|
|
10287
|
+
except _AgentConfigError:
|
|
10288
|
+
# A final reason to stop (issue #104), not a streaming problem: the unstreamed
|
|
10289
|
+
# re-send below would be the same blank a third time — and it marked streaming
|
|
10290
|
+
# as "refused by the server" for the rest of the process.
|
|
10291
|
+
raise
|
|
10181
10292
|
except Exception as e: # noqa: BLE001
|
|
10182
10293
|
_started = getattr(self, "_first_token_seen", False)
|
|
10183
10294
|
if _stream_fallback_decision(e, _started) == "raise":
|
|
@@ -10523,6 +10634,47 @@ def run_agent_chat(cfg):
|
|
|
10523
10634
|
# OpenAI-compatible backend, not a failure. _close_dangling_code_block runs in
|
|
10524
10635
|
# _run_stream (and again inside _code_nudge_reason) so it is already excluded.
|
|
10525
10636
|
_pair_re = _CODE_PAIR_RE
|
|
10637
|
+
# THE WHOLE OUTPUT BUDGET, AND NOTHING WRITTEN (issue #104). Checked BEFORE the
|
|
10638
|
+
# code-now retry, which would resend the same request and get the same blank: this
|
|
10639
|
+
# reply is retried ONCE with room to answer (and lower effort on the native Claude
|
|
10640
|
+
# path); a second blank at the limit ends the turn with what really happened.
|
|
10641
|
+
if _output_budget_exhausted(content, getattr(self, "_last_finish_reason", "")):
|
|
10642
|
+
_native = isinstance(self.client, _AnthropicChatAdapter)
|
|
10643
|
+
_log(f"warning: the model used its whole output budget ({out_tok} tokens) and wrote "
|
|
10644
|
+
"nothing (finish_reason=length) → retrying ONCE with a larger budget"
|
|
10645
|
+
+ (" and lower effort" if _native else ""))
|
|
10646
|
+
_emit({"type": "step", "text": (
|
|
10647
|
+
f"{_VERBOSE_PREFIX}The model ran out of output before writing anything — "
|
|
10648
|
+
"asking once more with room to answer…")})
|
|
10649
|
+
_first_out = out_tok
|
|
10650
|
+
_retry = _budget_retry_kwargs(completion_kwargs, _native)
|
|
10651
|
+
_retry["messages"] = list(completion_kwargs.get("messages") or []) + [
|
|
10652
|
+
{"role": "user", "content": self._CODE_NOW_DIRECTIVE}
|
|
10653
|
+
]
|
|
10654
|
+
try:
|
|
10655
|
+
r_content, r_role, r_in, r_out, _ = self._run_stream(_retry)
|
|
10656
|
+
except Exception as _e: # noqa: BLE001
|
|
10657
|
+
# A server that does not know the effort setting answers 400 — the larger
|
|
10658
|
+
# budget alone is still a different request, so try that before giving up.
|
|
10659
|
+
if _native and _http_status_of(_e) == 400:
|
|
10660
|
+
_log(f"warning: the lower-effort retry was refused (HTTP 400) — retrying "
|
|
10661
|
+
"with the larger budget only")
|
|
10662
|
+
_retry = _budget_retry_kwargs(completion_kwargs, _native, with_effort=False)
|
|
10663
|
+
_retry["messages"] = list(completion_kwargs.get("messages") or []) + [
|
|
10664
|
+
{"role": "user", "content": self._CODE_NOW_DIRECTIVE}
|
|
10665
|
+
]
|
|
10666
|
+
r_content, r_role, r_in, r_out, _ = self._run_stream(_retry)
|
|
10667
|
+
else:
|
|
10668
|
+
raise
|
|
10669
|
+
out_tok += r_out # generated and billed, whatever came back (#112)
|
|
10670
|
+
if _output_budget_exhausted(r_content, getattr(self, "_last_finish_reason", "")) \
|
|
10671
|
+
or not (r_content or "").strip():
|
|
10672
|
+
_log("error: the retry used its output budget and wrote nothing too → ending "
|
|
10673
|
+
"the turn instead of repeating it")
|
|
10674
|
+
raise _OutputBudgetExhausted(
|
|
10675
|
+
_budget_exhausted_message(self.model_id, r_out or _first_out))
|
|
10676
|
+
content, role = r_content, r_role
|
|
10677
|
+
in_tok = r_in or in_tok
|
|
10526
10678
|
why = _code_nudge_reason(content, self._openai_backend)
|
|
10527
10679
|
if why:
|
|
10528
10680
|
_log(f"{why} turn (reasoning={reasoning_len} chars) → retrying once with a "
|
|
@@ -10747,6 +10899,11 @@ def run_agent_chat(cfg):
|
|
|
10747
10899
|
_emit({"type": "step", "text": "Connection back — continuing"})
|
|
10748
10900
|
_log(f"connection back after {time.monotonic() - offline['since']:.0f}s")
|
|
10749
10901
|
break
|
|
10902
|
+
except _AgentConfigError:
|
|
10903
|
+
# Already a final, user-ready reason to stop (issue #104: a reply that
|
|
10904
|
+
# used its whole output budget twice). Retrying it here would repeat
|
|
10905
|
+
# exactly the calls it exists to prevent.
|
|
10906
|
+
raise
|
|
10750
10907
|
except Exception as e: # noqa: BLE001
|
|
10751
10908
|
emsg = str(e)
|
|
10752
10909
|
# A 404 "model not found" is a config error (wrong model NAME
|
|
@@ -11234,14 +11391,7 @@ def run_agent_chat(cfg):
|
|
|
11234
11391
|
if self._no_code_streak >= 3:
|
|
11235
11392
|
_log("no-code streak hit limit → ending the turn honestly "
|
|
11236
11393
|
"instead of looping on a coder that won't emit code")
|
|
11237
|
-
_nocode_msg = (
|
|
11238
|
-
"I had to stop: the coding model kept replying with "
|
|
11239
|
-
"explanations instead of a runnable code block, so none of "
|
|
11240
|
-
"my steps could actually execute. This is usually a "
|
|
11241
|
-
"reasoning model putting its answer in the thinking channel. "
|
|
11242
|
-
"Try a non-reasoning coding model, or switch the coding "
|
|
11243
|
-
"backend back to Ollama in Settings, then run this again."
|
|
11244
|
-
)
|
|
11394
|
+
_nocode_msg = _no_code_message(self.model_id)
|
|
11245
11395
|
msg.content = ("<code>\nfinal_answer("
|
|
11246
11396
|
+ repr(_nocode_msg) + ")\n</code>")
|
|
11247
11397
|
self._no_code_streak = 0
|
|
@@ -12379,7 +12529,9 @@ def run_agent_chat(cfg):
|
|
|
12379
12529
|
Args:
|
|
12380
12530
|
path: local file path.
|
|
12381
12531
|
start_line: first line to show (1-based).
|
|
12382
|
-
end_line: last line to show inclusive; 0 = rest of file
|
|
12532
|
+
end_line: last line to show inclusive; 0 = rest of file. One read shows at most 400
|
|
12533
|
+
lines (more with a large-context model) — the first line of the result says the
|
|
12534
|
+
range shown and the file's total, so read the next range if there is more.
|
|
12383
12535
|
"""
|
|
12384
12536
|
try:
|
|
12385
12537
|
_emit_running("Reading lines",
|
|
@@ -12391,7 +12543,9 @@ def run_agent_chat(cfg):
|
|
|
12391
12543
|
lines = fh.readlines()
|
|
12392
12544
|
s = max(1, int(start_line or 1))
|
|
12393
12545
|
e = int(end_line or 0) or len(lines)
|
|
12394
|
-
|
|
12546
|
+
# The line limit follows the coder's window (#105): 400, up to 2,000 on a very
|
|
12547
|
+
# large one — see _obs_caps_for_window.
|
|
12548
|
+
e = min(e, len(lines), s + int(_OBS_CAPS.get("lines") or _OBS_LINES_FLOOR) - 1)
|
|
12395
12549
|
# AFTER normalising, never before: end_line=0 means "to the end, capped at
|
|
12396
12550
|
# 400", so the range the model asked for and the range it actually gets are
|
|
12397
12551
|
# different numbers. Comparing the raw arguments would miss every whole-file
|
|
@@ -13590,13 +13744,40 @@ def run_agent_chat(cfg):
|
|
|
13590
13744
|
_emit({"type": "final", "text": note})
|
|
13591
13745
|
|
|
13592
13746
|
|
|
13747
|
+
# What still gets logged at the MINIMAL level (task #352): a line that says something went wrong.
|
|
13748
|
+
# The same list as log-level.mjs TROUBLE — the worker filters again, but dropping the narration
|
|
13749
|
+
# HERE means it is never built into an event, sent down the pipe and parsed just to be discarded
|
|
13750
|
+
# (about 150 lines a turn, measured).
|
|
13751
|
+
_LOG_TROUBLE = re.compile(
|
|
13752
|
+
r"\b(error|errors|errored|fail|failed|failure|fatal|exception|traceback|panic|refused|unreachable|"
|
|
13753
|
+
r"unavailable|overloaded|denied|forbidden|unauthori[sz]ed|invalid|rejected|aborted|killed|crash(ed)?|"
|
|
13754
|
+
r"timed out|timeout|not found|cannot|can't|could ?n[o']t|couldn't|unable|warning|warn|degrad\w*|"
|
|
13755
|
+
r"fall(ing|s)? back|fell back|fallback|skipped|lost)\b|✗|⚠|HTTP [45]\d\d|status[= ][45]\d\d",
|
|
13756
|
+
re.IGNORECASE,
|
|
13757
|
+
)
|
|
13758
|
+
# System error codes (ENOENT, ECONNRESET…) — case-sensitive, or it matches every word in "e…".
|
|
13759
|
+
_LOG_ERRNO = re.compile(r"\bE[A-Z]{4,}\b")
|
|
13760
|
+
|
|
13761
|
+
|
|
13762
|
+
def _log_kept_at_minimal(text) -> bool:
|
|
13763
|
+
"""Is this line trouble? Pure."""
|
|
13764
|
+
t = str(text or "")
|
|
13765
|
+
return bool(_LOG_TROUBLE.search(t) or _LOG_ERRNO.search(t))
|
|
13766
|
+
|
|
13767
|
+
|
|
13593
13768
|
def _log(text: str):
|
|
13594
|
-
"""Emit a log event — worker prints it to console, not forwarded to chat.
|
|
13769
|
+
"""Emit a log event — worker prints it to console, not forwarded to chat.
|
|
13770
|
+
|
|
13771
|
+
At the minimal level (the default, task #352) only trouble is emitted; the rest is what
|
|
13772
|
+
`gu --log` is for."""
|
|
13773
|
+
if _LOG_MINIMAL and not _log_kept_at_minimal(text):
|
|
13774
|
+
return
|
|
13595
13775
|
_emit({"type": "log", "text": text})
|
|
13596
13776
|
|
|
13597
13777
|
|
|
13598
13778
|
_CLIENT_MODE = False # task #134: agent runs here, models live on another machine
|
|
13599
13779
|
_LOG_PRODUCTION = False # task #249: set from cfg.logProfile; False = log as always
|
|
13780
|
+
_LOG_MINIMAL = False # task #352: only trouble is logged
|
|
13600
13781
|
# How many messages of THIS run's model requests are already in the log (task #249, production
|
|
13601
13782
|
# only). One python process runs one job, so module state is per turn.
|
|
13602
13783
|
_DUMP_SEEN = {"n": 0}
|
package/gu-cli.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { createRequire } from "node:module";
|
|
|
36
36
|
import dotenv from "dotenv";
|
|
37
37
|
import OpenAI from "openai";
|
|
38
38
|
import { resolvePython } from "./platform-tools.mjs";
|
|
39
|
+
import { MINIMAL_RUN_LOGS, agentLogProfile, currentLevel, installLevelFilter, levelLine } from "./log-level.mjs";
|
|
39
40
|
import { skillsForTurn } from "./skills.mjs";
|
|
40
41
|
import { mcpForTurn } from "./mcp-config.mjs";
|
|
41
42
|
import { hooksForTurn, sessionForTurn } from "./hooks-config.mjs";
|
|
@@ -398,6 +399,7 @@ if (["start", "stop", "status", "logs", "restart"].includes(args[0])) {
|
|
|
398
399
|
if (s.running) {
|
|
399
400
|
const who = s.pidFilePid ? `pid ${s.pidFilePid}` : `pid ${s.pids.join(", ")}`;
|
|
400
401
|
console.log(`[gu-worker] daemon RUNNING (${who}). Logs: ${ctl.LOG_FILE}`);
|
|
402
|
+
console.log(`[gu-worker] ${levelLine(currentLevel())}`); // #352: which level it is writing at
|
|
401
403
|
} else {
|
|
402
404
|
console.log("[gu-worker] daemon is NOT running. Start it with: gu-cli start");
|
|
403
405
|
}
|
|
@@ -1926,7 +1928,7 @@ const AGENT_WARMUP_IDLE_MS = 30 * 60_000;
|
|
|
1926
1928
|
const AGENT_WARMUP_INTERVAL_MS = 240_000; // 4 min — under Ollama's 5-min default unload
|
|
1927
1929
|
// OCR translation/correction is hardcoded to a managed Ollama service + model.
|
|
1928
1930
|
// (The user-configurable "OCR translation model" chooser was removed.)
|
|
1929
|
-
const OCR_TRANSLATE_OLLAMA_URL = "https://ollama1.
|
|
1931
|
+
const OCR_TRANSLATE_OLLAMA_URL = "https://ollama1.getmarsic.cc";
|
|
1930
1932
|
const OCR_TRANSLATE_OLLAMA_MODEL = "qwen3:14b";
|
|
1931
1933
|
// Ollama defaults num_ctx to 2048 and silently truncates longer inputs (keeping
|
|
1932
1934
|
// only the tail), which would drop the start of a long OCR page. Pin the context
|
|
@@ -3068,7 +3070,7 @@ function normalizeCorrection(raw, originalText) {
|
|
|
3068
3070
|
|
|
3069
3071
|
/**
|
|
3070
3072
|
* Call an Ollama host's native chat endpoint (POST {base}/api/chat) for text
|
|
3071
|
-
* correction/translation, e.g. base "https://ollama1.
|
|
3073
|
+
* correction/translation, e.g. base "https://ollama1.getmarsic.cc" model "qwen3:14b".
|
|
3072
3074
|
* `think:false` suppresses reasoning models' <think> preamble so we get clean text.
|
|
3073
3075
|
*/
|
|
3074
3076
|
const OCR_CORRECT_SYSTEM_PROMPT =
|
|
@@ -4081,7 +4083,9 @@ async function runAgentChatJob(job) {
|
|
|
4081
4083
|
alwaysExtendOnMaxStep: payload?.alwaysExtendOnMaxStep === true,
|
|
4082
4084
|
// What the agent may log (task #249): "production" = no conversation content, and the
|
|
4083
4085
|
// model-request dump only what is new per step. "full" = as it always has.
|
|
4084
|
-
|
|
4086
|
+
// #352: "minimal" (starts, stops, errors) unless a `gu --log` terminal is open right now —
|
|
4087
|
+
// then the account's own profile, i.e. what was logged before the minimal level existed.
|
|
4088
|
+
logProfile: agentLogProfile(logLevelNow(), logPolicy?.profile() ?? "full"),
|
|
4085
4089
|
// Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
|
|
4086
4090
|
autoTest: payload?.autoTest === true,
|
|
4087
4091
|
// Deploy target chosen via the terminal /server picker (task #69). Host/user only.
|
|
@@ -5222,6 +5226,7 @@ let shuttingDown = false;
|
|
|
5222
5226
|
async function failInFlightJobsAndExit(signal, onExit = () => {}) {
|
|
5223
5227
|
if (shuttingDown) return;
|
|
5224
5228
|
shuttingDown = true;
|
|
5229
|
+
console.log(`[gu-worker] stopping (${signal})`); // #352: the STOP line
|
|
5225
5230
|
const ids = [...inFlightJobs.keys()];
|
|
5226
5231
|
if (ids.length) {
|
|
5227
5232
|
console.log(
|
|
@@ -5338,6 +5343,9 @@ async function pollOnce() {
|
|
|
5338
5343
|
return;
|
|
5339
5344
|
}
|
|
5340
5345
|
if (job.jobType === "agent_chat") {
|
|
5346
|
+
// #352: the START line of a turn — with "completed"/"failed" it is all a turn leaves in the
|
|
5347
|
+
// minimal log.
|
|
5348
|
+
console.log(`[gu-worker] started agent_chat ${String(job.jobId ?? job._id ?? job.id ?? "")}`);
|
|
5341
5349
|
// Fire-and-forget into the pool — the same shape local_health has used all along. The
|
|
5342
5350
|
// `await … return` this replaces is the entire bug: it held the poll loop for the whole
|
|
5343
5351
|
// turn, so no other folder could be served (task #152).
|
|
@@ -5385,6 +5393,14 @@ async function pollOnce() {
|
|
|
5385
5393
|
* Log, or the setting not readable yet) is exactly today's logging. See log-policy.mjs.
|
|
5386
5394
|
*/
|
|
5387
5395
|
let logPolicy = null;
|
|
5396
|
+
// #352: the level is read from the `gu --log` lease, at most twice a second — it sits on the path
|
|
5397
|
+
// of every line printed.
|
|
5398
|
+
let _levelCache = { at: 0, level: "minimal" };
|
|
5399
|
+
function logLevelNow() {
|
|
5400
|
+
const now = Date.now();
|
|
5401
|
+
if (now - _levelCache.at > 500) _levelCache = { at: now, level: currentLevel() };
|
|
5402
|
+
return _levelCache.level;
|
|
5403
|
+
}
|
|
5388
5404
|
async function startLogPolicy() {
|
|
5389
5405
|
try {
|
|
5390
5406
|
const lp = await import("./log-policy.mjs");
|
|
@@ -5399,15 +5415,34 @@ async function startLogPolicy() {
|
|
|
5399
5415
|
: "[gu-worker] log profile: full — Enable Full Log is on for this account"
|
|
5400
5416
|
),
|
|
5401
5417
|
});
|
|
5402
|
-
|
|
5418
|
+
// #352: at the minimal level every line is stamped too — a log of starts, stops and errors is
|
|
5419
|
+
// only useful if each says WHEN.
|
|
5420
|
+
lp.installTimestamps(() => logPolicy.profile() === "production" || logLevelNow() === "minimal");
|
|
5421
|
+
// THE LEVEL FILTER, last so it is the first thing a line meets (see log-level.mjs).
|
|
5422
|
+
installLevelFilter(logLevelNow);
|
|
5403
5423
|
await logPolicy.refresh();
|
|
5404
5424
|
const runLogs = join(homedir(), ".gonext", "run-logs");
|
|
5405
5425
|
const housekeep = async () => {
|
|
5426
|
+
if (logLevelNow() === "minimal") {
|
|
5427
|
+
await lp.rotateIfLarge(LOG_FILE);
|
|
5428
|
+
await lp.pruneRunLogs(runLogs, MINIMAL_RUN_LOGS);
|
|
5429
|
+
return;
|
|
5430
|
+
}
|
|
5406
5431
|
if (logPolicy.profile() !== "production") return;
|
|
5407
5432
|
await lp.rotateIfLarge(LOG_FILE);
|
|
5408
5433
|
await lp.pruneRunLogs(runLogs);
|
|
5409
5434
|
};
|
|
5410
5435
|
await housekeep();
|
|
5436
|
+
// Say which level this log is written at — and say it again whenever it changes, so a stretch
|
|
5437
|
+
// of detail in the file is explained by the line above it.
|
|
5438
|
+
let saidLevel = logLevelNow();
|
|
5439
|
+
console.log(`[gu-worker] ${levelLine(saidLevel)}`);
|
|
5440
|
+
setInterval(() => {
|
|
5441
|
+
const now = logLevelNow();
|
|
5442
|
+
if (now === saidLevel) return;
|
|
5443
|
+
saidLevel = now;
|
|
5444
|
+
console.log(`[gu-worker] ${levelLine(now)}`);
|
|
5445
|
+
}, 2_000).unref();
|
|
5411
5446
|
let ticks = 0;
|
|
5412
5447
|
const { REFRESH_FLAG } = await import("./full-log-command.mjs");
|
|
5413
5448
|
setInterval(() => {
|