@tiens.nguyen/gu-cli 1.0.796 → 1.0.798
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/gonext_agent_chat.py +204 -23
- package/gu-cli.mjs +37 -2
- package/gu-repl.mjs +202 -178
- package/log-level.mjs +193 -0
- package/package.json +2 -1
- package/terminal-layout.mjs +49 -0
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))
|
|
@@ -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.
|
|
@@ -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
|
}
|
|
@@ -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(() => {
|