@tiens.nguyen/gu-cli 1.0.793 → 1.0.795
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 +124 -0
- package/gu-repl.mjs +90 -11
- package/model-fallback.mjs +127 -0
- package/package.json +5 -1
- package/terminal-layout.mjs +15 -0
package/gonext_agent_chat.py
CHANGED
|
@@ -992,6 +992,80 @@ def _web_search_impl(query, max_results=5, searxng_url="",
|
|
|
992
992
|
# the text still reaches both UIs, the prefix tells them it is a failure, not a reply.
|
|
993
993
|
TURN_FAILED = "__TURN_FAILED__::"
|
|
994
994
|
|
|
995
|
+
# ANOTHER MODEL STOOD IN (task #343). Reported live: the coding model had a problem, a different
|
|
996
|
+
# model answered, and nothing on screen said so — the turn "worked", only with a model nobody
|
|
997
|
+
# picked. Every place in this file where one model takes over from another now says so with ONE
|
|
998
|
+
# event, a step carrying this prefix and a JSON body, so the terminal can hold it in yellow on the
|
|
999
|
+
# bottom bar. A step, like __RAGSTATE__::, so it rides the job's chunk stream and still arrives
|
|
1000
|
+
# when the agent runs on a different machine than the terminal.
|
|
1001
|
+
#
|
|
1002
|
+
# What is NOT a fallback and must never be reported as one: a retry on the SAME model, and the
|
|
1003
|
+
# 429 / 502 exits below — those end the turn as failed, no other model answers.
|
|
1004
|
+
MODEL_FALLBACK = "__MODELFALLBACK__::"
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _fallback_reason(err) -> str:
|
|
1008
|
+
"""Why a model was given up on, in a few words that fit a status bar — never a stack of
|
|
1009
|
+
exception text (an nginx error is a whole HTML page). The status code when there is one,
|
|
1010
|
+
else the kind of failure, else the first line clipped."""
|
|
1011
|
+
if err is None:
|
|
1012
|
+
return ""
|
|
1013
|
+
status = _http_status_of(err)
|
|
1014
|
+
text = str(err)
|
|
1015
|
+
low = text.lower()
|
|
1016
|
+
if status in (401, 403):
|
|
1017
|
+
return f"{status} key refused"
|
|
1018
|
+
if status == 404:
|
|
1019
|
+
return "404 model not found"
|
|
1020
|
+
if status == 429 or "overloaded" in low or "rate limit" in low:
|
|
1021
|
+
return "429 overloaded"
|
|
1022
|
+
if status and status >= 500:
|
|
1023
|
+
return f"{status} server error"
|
|
1024
|
+
if "timed out" in low or "timeout" in low:
|
|
1025
|
+
return "timed out"
|
|
1026
|
+
if "empty response" in low:
|
|
1027
|
+
return "empty reply"
|
|
1028
|
+
if "context" in low and ("length" in low or "window" in low or "too long" in low or "exceed" in low):
|
|
1029
|
+
return "context too large"
|
|
1030
|
+
if "connection" in low or "connect" in low or "refused" in low or "unreachable" in low:
|
|
1031
|
+
return "could not connect"
|
|
1032
|
+
if status:
|
|
1033
|
+
return f"HTTP {status}"
|
|
1034
|
+
first = next((ln.strip() for ln in text.splitlines() if ln.strip()), "")
|
|
1035
|
+
first = re.sub(r"<[^>]+>", " ", first)
|
|
1036
|
+
first = re.sub(r"\s+", " ", first).strip()
|
|
1037
|
+
return first if len(first) <= 48 else first[:47].rstrip() + "…"
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _fallback_event(role, from_model, to_model, reason, *, tools=True, answered=True) -> str:
|
|
1041
|
+
"""The step text for one fallback. Pure.
|
|
1042
|
+
|
|
1043
|
+
role "coder" — the coding model was given up on; "chat" — the chat model was
|
|
1044
|
+
from/to the model that had the problem, and the one used instead
|
|
1045
|
+
reason a few words (see _fallback_reason)
|
|
1046
|
+
tools False when the stand-in answered as a bare chat reply — no files, no commands
|
|
1047
|
+
answered False when the stand-in failed too: "fell back" is only said when it answered
|
|
1048
|
+
"""
|
|
1049
|
+
body = {
|
|
1050
|
+
"role": "chat" if role == "chat" else "coder",
|
|
1051
|
+
"from": str(from_model or "").strip(),
|
|
1052
|
+
"to": str(to_model or "").strip(),
|
|
1053
|
+
"reason": str(reason or "").strip(),
|
|
1054
|
+
"tools": bool(tools),
|
|
1055
|
+
"answered": bool(answered),
|
|
1056
|
+
}
|
|
1057
|
+
return MODEL_FALLBACK + json.dumps(body, ensure_ascii=False)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _report_fallback(role, from_model, to_model, reason, *, tools=True, answered=True) -> None:
|
|
1061
|
+
"""Tell the terminal another model stood in. Logged too, so a run log shows it."""
|
|
1062
|
+
_log(
|
|
1063
|
+
f"model fallback: {role} {from_model!r} -> {to_model!r} ({reason or 'no reason'})"
|
|
1064
|
+
f"{'' if tools else ' — no tools'}{'' if answered else ' — it failed too'}"
|
|
1065
|
+
)
|
|
1066
|
+
_emit({"type": "step", "text": _fallback_event(
|
|
1067
|
+
role, from_model, to_model, reason, tools=tools, answered=answered)})
|
|
1068
|
+
|
|
995
1069
|
|
|
996
1070
|
class _AgentConfigError(RuntimeError):
|
|
997
1071
|
"""Deterministic setup error (e.g. wrong model name/URL). Not retryable and not
|
|
@@ -1473,6 +1547,24 @@ def _resolve_coding_target(agent_base_url, agent_model_id, raw_coding_base, raw_
|
|
|
1473
1547
|
return agent_base_url, agent_model_id
|
|
1474
1548
|
|
|
1475
1549
|
|
|
1550
|
+
def _coder_fell_back_to_chat(agent_base_url, raw_coding_base, resolved_base) -> bool:
|
|
1551
|
+
"""Did _resolve_coding_target give the chat model a job a DIFFERENT coding server was chosen
|
|
1552
|
+
for? Pure. False when no coder is set, and false for a coder on the chat model's own server
|
|
1553
|
+
with no name — that is "use the chat model", chosen, not a fallback."""
|
|
1554
|
+
raw = (raw_coding_base or "").rstrip("/")
|
|
1555
|
+
if not raw or raw == (agent_base_url or "").rstrip("/"):
|
|
1556
|
+
return False
|
|
1557
|
+
return (resolved_base or "").rstrip("/") != raw
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def _coder_label(model_id, base_url) -> str:
|
|
1561
|
+
"""What to call a coder on the bar: its name, or — when it never told us one — its host."""
|
|
1562
|
+
if (model_id or "").strip():
|
|
1563
|
+
return model_id.strip()
|
|
1564
|
+
host = re.sub(r"^[a-z]+://", "", (base_url or "").strip(), flags=re.I).split("/")[0]
|
|
1565
|
+
return host or "the coding model"
|
|
1566
|
+
|
|
1567
|
+
|
|
1476
1568
|
def _coding_backend_flags(backend):
|
|
1477
1569
|
"""Everything the resolved backend decides, derived in ONE place (task #117 → #123).
|
|
1478
1570
|
|
|
@@ -2943,6 +3035,7 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
|
|
|
2943
3035
|
targets.append((fallback_base_url, fallback_model_id, fallback_api_key or api_key))
|
|
2944
3036
|
|
|
2945
3037
|
last_err = None
|
|
3038
|
+
first_err = None
|
|
2946
3039
|
for i, (b, mid, key) in enumerate(targets):
|
|
2947
3040
|
emitted_here = False
|
|
2948
3041
|
|
|
@@ -2981,12 +3074,22 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
|
|
|
2981
3074
|
last_err = RuntimeError(f"{mid} returned an empty response")
|
|
2982
3075
|
except Exception as e: # noqa: BLE001
|
|
2983
3076
|
last_err = e
|
|
3077
|
+
if i == 0:
|
|
3078
|
+
first_err = last_err # why the model that was ASKED gave nothing
|
|
3079
|
+
else:
|
|
3080
|
+
# The stand-in did not answer either (#343) — take back "it answered".
|
|
3081
|
+
_report_fallback("chat", targets[0][1], mid, _fallback_reason(first_err),
|
|
3082
|
+
tools=False, answered=False)
|
|
2984
3083
|
if emitted_here:
|
|
2985
3084
|
# Part of THIS attempt's answer already streamed to the user — don't
|
|
2986
3085
|
# silently splice a DIFFERENT model's output after a partial one.
|
|
2987
3086
|
break
|
|
2988
3087
|
if i + 1 < len(targets):
|
|
2989
3088
|
nb, nmid, _ = targets[i + 1] # the model we're about to try next
|
|
3089
|
+
# SAID AT THE SWITCH (#343), before the stand-in streams: a step that arrived after
|
|
3090
|
+
# its answer would reopen the reasoning block under the reply. Corrected just above
|
|
3091
|
+
# if it fails too.
|
|
3092
|
+
_report_fallback("chat", mid, nmid, _fallback_reason(last_err), tools=False)
|
|
2990
3093
|
_log(f"plain reply: {mid} @ {b} failed ({last_err}); "
|
|
2991
3094
|
f"falling back to {nmid} @ {nb}")
|
|
2992
3095
|
# Every target failed or returned nothing — raise rather than return a sentinel
|
|
@@ -7671,6 +7774,11 @@ def run_agent_chat(cfg):
|
|
|
7671
7774
|
agent_base_url, agent_model_id, raw_coding_base, raw_coding_model,
|
|
7672
7775
|
agent_api_key=agent_api_key, coding_api_key=coding_api_key,
|
|
7673
7776
|
)
|
|
7777
|
+
# A coder WAS chosen and the chat model is doing its work (#343): its server would not say
|
|
7778
|
+
# which model it serves. Said when the agent path starts — a plain chat turn does no coding,
|
|
7779
|
+
# so there is nothing to have fallen back from.
|
|
7780
|
+
_coder_unresolved = _coder_fell_back_to_chat(
|
|
7781
|
+
agent_base_url, raw_coding_base, coding_base_url)
|
|
7674
7782
|
# Task #108: an OpenAI-compatible coder REQUIRES an explicit model name (its
|
|
7675
7783
|
# /v1/chat/completions needs a `model` id, and we won't silently reuse the MLX chat
|
|
7676
7784
|
# model's name against a cloud endpoint). Fail with a clear, actionable message.
|
|
@@ -8490,6 +8598,9 @@ def run_agent_chat(cfg):
|
|
|
8490
8598
|
# Agent path — from here all step events go into <think>.
|
|
8491
8599
|
_log("router: agent (HTTP tool use needed)")
|
|
8492
8600
|
_emit({"type": "step", "text": "Choosing a tool…"})
|
|
8601
|
+
if _coder_unresolved:
|
|
8602
|
+
_report_fallback("coder", _coder_label(raw_coding_model, raw_coding_base), agent_model_id,
|
|
8603
|
+
"its server named no model")
|
|
8493
8604
|
|
|
8494
8605
|
# Prepend explicit tool instructions so small models pick the right tool, never
|
|
8495
8606
|
# fabricate URLs/responses, and always terminate with final_answer().
|
|
@@ -11293,12 +11404,19 @@ def run_agent_chat(cfg):
|
|
|
11293
11404
|
# No usable tool output (e.g. every step's code failed to parse).
|
|
11294
11405
|
# Don't dead-end on a canned apology — answer from the conversation.
|
|
11295
11406
|
_log("max-steps fallback → plain reply over conversation")
|
|
11407
|
+
# Every step was unusable and the chat model now answers from the conversation
|
|
11408
|
+
# alone (#343). Said BEFORE it streams — a step after the answer would reopen
|
|
11409
|
+
# the reasoning block under it — and corrected below if it fails too.
|
|
11410
|
+
_report_fallback("coder", coding_model_id, agent_model_id,
|
|
11411
|
+
"no usable step", tools=False)
|
|
11296
11412
|
try:
|
|
11297
11413
|
text = _plain_reply(
|
|
11298
11414
|
messages, agent_base_url, agent_api_key, agent_model_id
|
|
11299
11415
|
).strip()
|
|
11300
11416
|
except Exception as e: # noqa: BLE001
|
|
11301
11417
|
_log(f"plain-reply fallback error: {e}")
|
|
11418
|
+
_report_fallback("coder", coding_model_id, agent_model_id,
|
|
11419
|
+
"no usable step", tools=False, answered=False)
|
|
11302
11420
|
text = ""
|
|
11303
11421
|
if not text:
|
|
11304
11422
|
text = ("I couldn't complete that within the step budget. Please "
|
|
@@ -13427,10 +13545,16 @@ def run_agent_chat(cfg):
|
|
|
13427
13545
|
# answer from the conversation rather than surfacing a raw error to the user —
|
|
13428
13546
|
# but only when the fallback ACTUALLY has something useful to say.
|
|
13429
13547
|
_log(f"agent error: {e} — degrading to plain reply")
|
|
13548
|
+
# The tool-capable loop failed and the chat model answers instead, with no tools (#343).
|
|
13549
|
+
# Said BEFORE it streams; corrected in the except if it fails too.
|
|
13550
|
+
_report_fallback("coder", coding_model_id, agent_model_id, _fallback_reason(e), tools=False)
|
|
13430
13551
|
try:
|
|
13431
13552
|
fallback = _plain_reply(messages, agent_base_url, agent_api_key, agent_model_id).strip()
|
|
13432
13553
|
except Exception as e2: # noqa: BLE001
|
|
13433
13554
|
_log(f"plain-reply degrade error: {e2}")
|
|
13555
|
+
# The stand-in failed too (#343): say THAT, never "fell back".
|
|
13556
|
+
_report_fallback("coder", coding_model_id, agent_model_id, _fallback_reason(e),
|
|
13557
|
+
tools=False, answered=False)
|
|
13434
13558
|
# Nothing useful to say — re-raise the ORIGINAL (more relevant) agent
|
|
13435
13559
|
# failure so this becomes a genuine job failure: a red error the REPL/web
|
|
13436
13560
|
# already handle correctly, NOT persisted to conversation history. Emitting
|
package/gu-repl.mjs
CHANGED
|
@@ -138,6 +138,11 @@ import { unapprovedProjectServers } from "./mcp-command.mjs";
|
|
|
138
138
|
import { readHooks } from "./hooks-config.mjs";
|
|
139
139
|
import { readSkillSource, sourceLabel, SKILL_SOURCE_KEY } from "./skill-source.mjs";
|
|
140
140
|
import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
|
|
141
|
+
import { thoughtPrintSeq } from "./terminal-layout.mjs";
|
|
142
|
+
import {
|
|
143
|
+
MODELFALLBACK_PREFIX, fallbackAfterTurn, fallbackHistoryLine, fallbackNotice, keyMissingNotice,
|
|
144
|
+
parseModelFallback,
|
|
145
|
+
} from "./model-fallback.mjs";
|
|
141
146
|
import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
|
|
142
147
|
import { IMAGE_NEEDS_CLAUDE, coderTakesImagesFor, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
|
|
143
148
|
import {
|
|
@@ -1635,6 +1640,28 @@ const missingNoticeRows = () => [
|
|
|
1635
1640
|
...(embedMissing ? ["⚠ no embed model — RAG is off · /embed-model"] : []),
|
|
1636
1641
|
];
|
|
1637
1642
|
|
|
1643
|
+
// ANOTHER MODEL STOOD IN (task #343). The agent reports it as a __MODELFALLBACK__:: step; it is
|
|
1644
|
+
// held here and shown in YELLOW on the bar — from the moment it happens, through the rest of the
|
|
1645
|
+
// turn, and on while it stays true: until a turn is answered with no fallback (the model works
|
|
1646
|
+
// again) or another model is picked. `turnFallback` is the one reported during the CURRENT turn.
|
|
1647
|
+
let modelFallback = null;
|
|
1648
|
+
let turnFallback = null;
|
|
1649
|
+
function setModelFallback(fb) {
|
|
1650
|
+
modelFallback = fb;
|
|
1651
|
+
if (barPinned()) paintBar();
|
|
1652
|
+
}
|
|
1653
|
+
// Its rows: the reported fallback, and — the one fallback the agent cannot report, because it
|
|
1654
|
+
// never sees the coder the API dropped — a coding model with no API key (task #222).
|
|
1655
|
+
const fallbackNoticeRows = () => {
|
|
1656
|
+
const width = process.stdout.columns || 80;
|
|
1657
|
+
return [
|
|
1658
|
+
...(modelFallback ? [fallbackNotice(modelFallback, { width, gutter: GUTTER })] : []),
|
|
1659
|
+
...(codingKeyMissingKnown === true
|
|
1660
|
+
? [keyMissingNotice({ coder: activeCodingModelId(), width, gutter: GUTTER })]
|
|
1661
|
+
: []),
|
|
1662
|
+
];
|
|
1663
|
+
};
|
|
1664
|
+
|
|
1638
1665
|
// #307: the calm note after a quick change of mind — dim, not the ⚠ orange of a warning, and in the
|
|
1639
1666
|
// bar rather than the transcript (which the whole point is to leave untouched).
|
|
1640
1667
|
let stopNotice = "";
|
|
@@ -2213,6 +2240,9 @@ const UPDATE_CHECK_MS =
|
|
|
2213
2240
|
? Number(process.env.GONEXT_UPDATE_CHECK_MS)
|
|
2214
2241
|
: 60 * 60 * 1000;
|
|
2215
2242
|
async function refreshAvailableUpdates() {
|
|
2243
|
+
// The terminal UI tests (tests/ui) run with no network and compare whole screens: an update
|
|
2244
|
+
// notice that depends on what npm holds today would make every golden screen a coin toss.
|
|
2245
|
+
if (process.env.GU_NO_UPDATE_CHECK === "1") return;
|
|
2216
2246
|
const { updatesAvailable } = await import("./version-check.mjs");
|
|
2217
2247
|
const cliLatest = await (async () => {
|
|
2218
2248
|
try {
|
|
@@ -2597,7 +2627,9 @@ const footerRows = () =>
|
|
|
2597
2627
|
? dim(clipVisible(stopNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
|
|
2598
2628
|
: "",
|
|
2599
2629
|
// Task #14: the skipped-setup warning — persistent (not a timed notice), red, its own row.
|
|
2600
|
-
|
|
2630
|
+
// …then the fallback rows (#343), YELLOW: a different model is doing the work — a warning,
|
|
2631
|
+
// where the red rows above mean something is not set up at all.
|
|
2632
|
+
missing: [...missingNoticeRows().map((r) => red(r)), ...fallbackNoticeRows().map((r) => yellow(r))],
|
|
2601
2633
|
});
|
|
2602
2634
|
// An ask()'s hint lines (#305), drawn in yellow under its input while it waits. Plain text.
|
|
2603
2635
|
let askHint = [];
|
|
@@ -3357,7 +3389,7 @@ const thoughtWidth = () =>
|
|
|
3357
3389
|
* Progress recedes and the answer is the only full-bright thing on screen — a page of bright
|
|
3358
3390
|
* reasoning per step would bury the deliverable it is supposed to lead up to.
|
|
3359
3391
|
*/
|
|
3360
|
-
function printThoughtBlock(raw, before) {
|
|
3392
|
+
function printThoughtBlock(raw, before, { gap = false } = {}) {
|
|
3361
3393
|
if (!sessionShowThought) return false;
|
|
3362
3394
|
const prose = thoughtProse(raw);
|
|
3363
3395
|
if (!prose) return false; // a bare tool label or pure code — not what anyone means by a thought
|
|
@@ -3404,8 +3436,14 @@ function printThoughtBlock(raw, before) {
|
|
|
3404
3436
|
}
|
|
3405
3437
|
// Scrolled out of the buffer — fall through and print a new block rather than lose it.
|
|
3406
3438
|
}
|
|
3407
|
-
|
|
3408
|
-
//
|
|
3439
|
+
// ONE EMPTY LINE ABOVE A NEW THOUGHT (#344) when the row above it is not already blank.
|
|
3440
|
+
// Reported with a screenshot: "The download is moving…" sat directly under "✓ Command finished
|
|
3441
|
+
// → python3 -c …" and read as that command's OUTPUT — and under a bright "✗ Command failed" row,
|
|
3442
|
+
// as its error text. A thought is printed when its step's reasoning ends, BEFORE that step's
|
|
3443
|
+
// tools report: it introduces what comes next, it does not belong to the row above. Only for a
|
|
3444
|
+
// NEW block — a continuation is merged into the previous one above and never gets here.
|
|
3445
|
+
process.stdout.write(thoughtPrintSeq(rows.map((r) => GUTTER + dim(r)), { gap }));
|
|
3446
|
+
// Record where it landed. The blank is above `from`, so the block is still exactly its rows. Measured AFTER the write so a trim during it cannot skew the
|
|
3409
3447
|
// range: the last completed line is length-2 (the final entry is the still-open line).
|
|
3410
3448
|
const endAbs = transcript.dropped() + transcript.length() - 2;
|
|
3411
3449
|
thoughtBlocks.push({ from: endAbs - rows.length + 1, to: endAbs, prose, collapsed: true });
|
|
@@ -6721,6 +6759,7 @@ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locali
|
|
|
6721
6759
|
console.log(GUTTER + dim("restart it with `gu api restart`, then add that provider again.\n"));
|
|
6722
6760
|
}
|
|
6723
6761
|
sessionCodingModel = offered.id;
|
|
6762
|
+
setModelFallback(null); // #343
|
|
6724
6763
|
sessionCodingLabel = model;
|
|
6725
6764
|
sessionCodingUrl = url;
|
|
6726
6765
|
// …and the host the bar puts in front of it (#263): left alone, the PREVIOUS coder's
|
|
@@ -6860,6 +6899,7 @@ async function chooseModel() {
|
|
|
6860
6899
|
// Empty override means "use the account default" — so don't send an override when the
|
|
6861
6900
|
// user picks the default (keeps the payload clean and lets a later default change win).
|
|
6862
6901
|
sessionCodingModel = chosen.id === def ? "" : chosen.id;
|
|
6902
|
+
setModelFallback(null); // #343: the warning named the coder that was just replaced
|
|
6863
6903
|
// Remember the friendly name for the token footer, which should never show a raw
|
|
6864
6904
|
// "<kind>::<model>" wire id.
|
|
6865
6905
|
sessionCodingLabel = chosen.id === def ? "" : chosen.model;
|
|
@@ -11060,7 +11100,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11060
11100
|
if (heldThought === null) return;
|
|
11061
11101
|
const raw = heldThought;
|
|
11062
11102
|
heldThought = null;
|
|
11063
|
-
if (printThoughtBlock(raw, onContent)) lastWasBlank = false;
|
|
11103
|
+
if (printThoughtBlock(raw, onContent, { gap: !lastWasBlank })) lastWasBlank = false;
|
|
11064
11104
|
};
|
|
11065
11105
|
let hoverRow = -1; // last mouse viewport row (from motion events) → highlight when it's on the thought line
|
|
11066
11106
|
let thoughtRow = -1; // absolute viewport row of the thought line (line 1), learned via \x1b[6n
|
|
@@ -11485,10 +11525,13 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11485
11525
|
// changed since is the VOLUME: a long turn now prints dozens of bullets interleaved with
|
|
11486
11526
|
// collapsed thought prose, and with no blanks anywhere the two run together into a wall.
|
|
11487
11527
|
//
|
|
11488
|
-
// Above rather than below
|
|
11489
|
-
//
|
|
11490
|
-
//
|
|
11491
|
-
//
|
|
11528
|
+
// Above rather than below: a trailing blank would collide with the seam the caller owns
|
|
11529
|
+
// before the bright answer (LINE_SPACING).
|
|
11530
|
+
//
|
|
11531
|
+
// A THOUGHT GETS THE SAME BLANK ABOVE IT (#344, see printThoughtBlock). This comment used to say
|
|
11532
|
+
// the prose after a bullet "sits tight against it — which is right, since that prose is the
|
|
11533
|
+
// bullet's own reasoning". It is not: the thought is printed when the NEXT step's reasoning
|
|
11534
|
+
// ends, so glued under "Command finished → …" it read as that command's output.
|
|
11492
11535
|
//
|
|
11493
11536
|
// `lastWasBlank` makes it idempotent: nothing doubles a blank that is already there.
|
|
11494
11537
|
const openBullet = () => {
|
|
@@ -11509,7 +11552,10 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11509
11552
|
const at = thoughtBlocks.indexOf(stepGroup);
|
|
11510
11553
|
if (at < 0) return false; // dropped from the registry (it is capped): no longer ours to rewrite
|
|
11511
11554
|
for (let i = at + 1; i < thoughtBlocks.length; i++) {
|
|
11512
|
-
|
|
11555
|
+
// Directly under, or under the ONE blank a thought now opens with (#344) — the look-up
|
|
11556
|
+
// group must stay open across it, or every thought would start a new counted row.
|
|
11557
|
+
const step = thoughtBlocks[i].from - end;
|
|
11558
|
+
if (step !== 1 && step !== 2) return false;
|
|
11513
11559
|
end = thoughtBlocks[i].to;
|
|
11514
11560
|
}
|
|
11515
11561
|
return end === transcriptTail();
|
|
@@ -11612,6 +11658,18 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11612
11658
|
lastContentAt = Date.now();
|
|
11613
11659
|
continue;
|
|
11614
11660
|
}
|
|
11661
|
+
// Task #343: another model took over. Swallowed from the action list — it goes on the
|
|
11662
|
+
// BAR, now, in yellow — and the turn leaves one line about it once it has ended (see the
|
|
11663
|
+
// caller), so a fallback that lasted a single step can still be found afterwards.
|
|
11664
|
+
if (line.startsWith(MODELFALLBACK_PREFIX)) {
|
|
11665
|
+
const fb = parseModelFallback(line);
|
|
11666
|
+
if (fb) {
|
|
11667
|
+
turnFallback = fb;
|
|
11668
|
+
setModelFallback(fb);
|
|
11669
|
+
}
|
|
11670
|
+
lastContentAt = Date.now();
|
|
11671
|
+
continue;
|
|
11672
|
+
}
|
|
11615
11673
|
// A fence opening starts a fresh step's raw stream → reset the harvested Thought
|
|
11616
11674
|
// so the blinking line doesn't carry last step's clause into this one.
|
|
11617
11675
|
if (isFenceLine(line)) {
|
|
@@ -11625,7 +11683,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11625
11683
|
// says what it intends, then the actions it took appear under it.
|
|
11626
11684
|
if (!REPL_VERBOSE && stepThought.trim() && !stepHasCode(stepThought)) {
|
|
11627
11685
|
heldThought = stepThought; // #45: decided by the next line — see releaseHeldThought
|
|
11628
|
-
} else if (printThoughtBlock(stepThought, onContent)) {
|
|
11686
|
+
} else if (printThoughtBlock(stepThought, onContent, { gap: !lastWasBlank })) {
|
|
11629
11687
|
lastWasBlank = false;
|
|
11630
11688
|
}
|
|
11631
11689
|
stepThought = "";
|
|
@@ -13355,6 +13413,7 @@ async function main() {
|
|
|
13355
13413
|
if (line === "/agent-model") {
|
|
13356
13414
|
await chooseAgentModel();
|
|
13357
13415
|
agentMissing = false; // Task #14: an agent model was just chosen → clear its warning
|
|
13416
|
+
setModelFallback(null); // #343: so is the stand-in a fallback warning named
|
|
13358
13417
|
continue;
|
|
13359
13418
|
}
|
|
13360
13419
|
if (line === "/embed-model") {
|
|
@@ -13706,6 +13765,7 @@ async function main() {
|
|
|
13706
13765
|
// #323: priced by the coder this turn STARTS on — a /model switch afterwards must not put
|
|
13707
13766
|
// another model's price on it.
|
|
13708
13767
|
const turnCoder = { url: activeCodingUrl(), model: activeCodingModelId() };
|
|
13768
|
+
turnFallback = null; // #343: nothing has fallen back in THIS turn yet
|
|
13709
13769
|
if (reattach) console.log(GUTTER + dim("following the turn that was running when the connection went…\n"));
|
|
13710
13770
|
const turn = await runAgentTurn(history, reattach ? { attachJobId: reattach.jobId, ownJob: true } : {});
|
|
13711
13771
|
// NOT SENT (licence block, or the token prompt declined): runAgentTurn already said so.
|
|
@@ -13720,6 +13780,15 @@ async function main() {
|
|
|
13720
13780
|
// Detached: the agent keeps running on the worker, so the question is neither answered
|
|
13721
13781
|
// nor stopped — leave it untagged, and say nothing contradicting "the job keeps running".
|
|
13722
13782
|
if (detached) continue;
|
|
13783
|
+
// #343: the bar after this turn — a fallback reported in it stays; an answered turn with
|
|
13784
|
+
// none means the model works again and the warning goes; a failed or stopped turn proves
|
|
13785
|
+
// nothing and leaves it.
|
|
13786
|
+
const fellBack = turnFallback;
|
|
13787
|
+
setModelFallback(fallbackAfterTurn({
|
|
13788
|
+
current: modelFallback,
|
|
13789
|
+
reportedThisTurn: fellBack,
|
|
13790
|
+
answered: Boolean(answer) && !answer.startsWith(TURN_FAILED_PREFIX),
|
|
13791
|
+
}));
|
|
13723
13792
|
// Feed the bottom bar's token row (task #104). Assigned for EVERY turn — including one
|
|
13724
13793
|
// that ended without an answer — so the row never shows a stale count from an older
|
|
13725
13794
|
// turn. It replaces the print-once-per-turn footer that used to live here: the bar is
|
|
@@ -13749,6 +13818,7 @@ async function main() {
|
|
|
13749
13818
|
failures: noteTaskFailure(taskLine, classifyTurnFailure(_failText)),
|
|
13750
13819
|
authored: true,
|
|
13751
13820
|
});
|
|
13821
|
+
if (fellBack) printFallbackLine(fellBack);
|
|
13752
13822
|
} else if (answer) {
|
|
13753
13823
|
noteTaskSuccess();
|
|
13754
13824
|
history.push({ role: "assistant", content: answer });
|
|
@@ -13792,6 +13862,9 @@ async function main() {
|
|
|
13792
13862
|
// builder, so a change to the block's shape can no longer leave this behind.
|
|
13793
13863
|
registerCopyBlock(block.rows, plain, 1);
|
|
13794
13864
|
}
|
|
13865
|
+
// #343: the turn's own record of the fallback, under its answer — the bar's row can be
|
|
13866
|
+
// gone by the next turn, and the scrollback is where "which model wrote this?" is asked.
|
|
13867
|
+
if (fellBack) printFallbackLine(fellBack);
|
|
13795
13868
|
} else {
|
|
13796
13869
|
// A turn that ended WITHOUT an answer (Ctrl+C cancel, or an abort/failure) must not
|
|
13797
13870
|
// silently launch another turn. Any keystrokes the user typed DURING the dead turn
|
|
@@ -13841,6 +13914,12 @@ async function main() {
|
|
|
13841
13914
|
process.exit(0);
|
|
13842
13915
|
}
|
|
13843
13916
|
|
|
13917
|
+
/** One yellow line in the transcript saying which model stood in for which (task #343). */
|
|
13918
|
+
function printFallbackLine(fb) {
|
|
13919
|
+
const rows = wrapBlock(fallbackHistoryLine(fb), GUTTER, process.stdout.columns || 80);
|
|
13920
|
+
console.log(rows.map((r) => GUTTER + yellow(r)).join("\n") + "\n");
|
|
13921
|
+
}
|
|
13922
|
+
|
|
13844
13923
|
/**
|
|
13845
13924
|
* Give the screen back to the shell with its prompt RIGHT UNDER the last output (#314). unpinBar()
|
|
13846
13925
|
* parks the caret at the zone's top row, which is where the shell then printed its prompt: seven
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANOTHER MODEL STOOD IN — and the screen says so (task #343).
|
|
3
|
+
*
|
|
4
|
+
* Reported: "When the coding model has an issue and it falls back to another model it should show
|
|
5
|
+
* a warning at the bottom bar in yellow." Until now a fallback was a line in the worker's log: the
|
|
6
|
+
* turn "worked", only with a model nobody picked, and worse code or an answer with no tools was
|
|
7
|
+
* the only evidence.
|
|
8
|
+
*
|
|
9
|
+
* The agent reports each fallback as one step — `__MODELFALLBACK__::{json}` (see MODEL_FALLBACK in
|
|
10
|
+
* gonext_agent_chat.py) — and this module turns it into the bar's row and the turn's history line.
|
|
11
|
+
* Pure: no terminal, no colour. The caller paints it yellow.
|
|
12
|
+
*
|
|
13
|
+
* WHAT IS A FALLBACK. Only "a different model did the work, or answered". A retry on the same
|
|
14
|
+
* model is not one, and a turn that failed outright (429 overloaded, 502) is not one either —
|
|
15
|
+
* nothing answered, and the failure notice already says that.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const MODELFALLBACK_PREFIX = "__MODELFALLBACK__::";
|
|
19
|
+
|
|
20
|
+
const text = (v) => String(v ?? "").replace(/\s+/g, " ").trim();
|
|
21
|
+
|
|
22
|
+
/** One step line → the fallback it reports, or null when it is not one (or is malformed). */
|
|
23
|
+
export function parseModelFallback(line) {
|
|
24
|
+
const s = String(line ?? "");
|
|
25
|
+
if (!s.startsWith(MODELFALLBACK_PREFIX)) return null;
|
|
26
|
+
let body;
|
|
27
|
+
try {
|
|
28
|
+
body = JSON.parse(s.slice(MODELFALLBACK_PREFIX.length));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
33
|
+
const from = text(body.from);
|
|
34
|
+
const to = text(body.to);
|
|
35
|
+
// Without the model that was used instead there is nothing true to say.
|
|
36
|
+
if (!from && !to) return null;
|
|
37
|
+
return {
|
|
38
|
+
role: body.role === "chat" ? "chat" : "coder",
|
|
39
|
+
from,
|
|
40
|
+
to,
|
|
41
|
+
reason: text(body.reason),
|
|
42
|
+
// Absent = the stand-in had tools / did answer: only an explicit false changes the wording.
|
|
43
|
+
tools: body.tools !== false,
|
|
44
|
+
answered: body.answered !== false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const clipName = (name, max) => (name.length <= max ? name : name.slice(0, Math.max(1, max - 1)) + "…");
|
|
49
|
+
|
|
50
|
+
/** A model name is never shortened below this — fewer characters no longer identify it. */
|
|
51
|
+
const NAME_FLOOR = 5;
|
|
52
|
+
|
|
53
|
+
/** The sentence's three parts, so the fitting below can shorten each on its own. */
|
|
54
|
+
function parts(fb, { fromMax = Infinity, toMax = Infinity } = {}) {
|
|
55
|
+
const from = clipName(fb.from || (fb.role === "chat" ? "the chat model" : "the coding model"), fromMax);
|
|
56
|
+
const to = clipName(fb.to || "another model", toMax);
|
|
57
|
+
const same = Boolean(fb.from) && fb.from === fb.to;
|
|
58
|
+
const subject = fb.role === "chat" ? `chat model ${from}` : same ? from : `coder ${from}`;
|
|
59
|
+
// "unavailable": it never started (its server named no model). "failed": it was asked and broke.
|
|
60
|
+
const verb = fb.tools && fb.answered ? "unavailable" : "failed";
|
|
61
|
+
let outcome;
|
|
62
|
+
if (!fb.answered) outcome = same ? "and its plain reply failed too" : `${to} failed too`;
|
|
63
|
+
else if (fb.role === "chat") outcome = `${to} answered`;
|
|
64
|
+
else if (fb.tools) outcome = `coding with ${to}`;
|
|
65
|
+
else outcome = same ? "answered without tools" : `${to} answered without tools`;
|
|
66
|
+
return { subject, verb, outcome };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const join = ({ subject, verb, outcome }, reason) =>
|
|
70
|
+
`⚠ ${subject} ${verb}${reason ? ` (${reason})` : ""} — ${outcome}`;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The bar's row for a fallback, FITTED to `width` columns (after the gutter): every bar row must
|
|
74
|
+
* stay one physical row (#109), and a row clipped mid-name hides the one thing it is for. So the
|
|
75
|
+
* REASON is shortened first, then dropped, and only then are the model names shortened — the
|
|
76
|
+
* longer one first.
|
|
77
|
+
*/
|
|
78
|
+
export function fallbackNotice(fb, { width = 80, gutter = " " } = {}) {
|
|
79
|
+
if (!fb) return "";
|
|
80
|
+
const room = Math.max(20, (width || 80) - 1 - gutter.length);
|
|
81
|
+
const full = join(parts(fb), fb.reason);
|
|
82
|
+
if (full.length <= room) return full;
|
|
83
|
+
const bare = join(parts(fb), "");
|
|
84
|
+
if (fb.reason) {
|
|
85
|
+
const left = room - bare.length - 3; // " (" + ")"
|
|
86
|
+
if (left >= 6) return join(parts(fb), clipName(fb.reason, left));
|
|
87
|
+
}
|
|
88
|
+
if (bare.length <= room) return bare;
|
|
89
|
+
let fromMax = Math.max(NAME_FLOOR, (fb.from || "").length);
|
|
90
|
+
let toMax = Math.max(NAME_FLOOR, (fb.to || "").length);
|
|
91
|
+
while (join(parts(fb, { fromMax, toMax }), "").length > room && (fromMax > NAME_FLOOR || toMax > NAME_FLOOR)) {
|
|
92
|
+
if (fromMax >= toMax && fromMax > NAME_FLOOR) fromMax -= 1;
|
|
93
|
+
else toMax -= 1;
|
|
94
|
+
}
|
|
95
|
+
// A window too narrow even for that: cut the row rather than let it wrap onto a second one.
|
|
96
|
+
return clipName(join(parts(fb, { fromMax, toMax }), ""), room);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The line a fallback leaves in the turn's history — whole, wrapped by the caller. */
|
|
100
|
+
export function fallbackHistoryLine(fb) {
|
|
101
|
+
return fb ? join(parts(fb), fb.reason) : "";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* THE CODER WAS DROPPED FOR WANT OF A KEY (task #222): the API omits a coding backend whose API
|
|
106
|
+
* key is missing and the chat model does the coding. Nothing failed, so the agent has nothing to
|
|
107
|
+
* report — the terminal knows it from the payload and says it on the same row.
|
|
108
|
+
*/
|
|
109
|
+
export function keyMissingNotice({ coder = "", width = 80, gutter = " " } = {}) {
|
|
110
|
+
const room = Math.max(20, (width || 80) - 1 - gutter.length);
|
|
111
|
+
const tail = " — coding with the chat model · ctrl+k";
|
|
112
|
+
const named = `⚠ no API key for coder ${text(coder)}${tail}`;
|
|
113
|
+
if (text(coder) && named.length <= room) return named;
|
|
114
|
+
const plain = `⚠ no API key for the coding model${tail}`;
|
|
115
|
+
if (plain.length <= room) return plain;
|
|
116
|
+
return clipName("⚠ no coder API key — on the chat model · ctrl+k", room);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What the bar shows after a turn ended. A fallback reported THIS turn stays; a turn that was
|
|
121
|
+
* answered with none reported means the model works again, so the warning goes; a turn that
|
|
122
|
+
* failed or was stopped proves nothing either way and leaves it as it was.
|
|
123
|
+
*/
|
|
124
|
+
export function fallbackAfterTurn({ current = null, reportedThisTurn = null, answered = false } = {}) {
|
|
125
|
+
if (reportedThisTurn) return reportedThisTurn;
|
|
126
|
+
return answered ? null : current;
|
|
127
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.795",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
"postinstall": "node install-path.mjs",
|
|
22
22
|
"test": "python3 -m unittest discover tests -v && node --test",
|
|
23
23
|
"test:js": "node --test",
|
|
24
|
+
"test:ui": "node --test --test-concurrency=1 tests/ui/ui.test.mjs",
|
|
25
|
+
"ui:show": "node tests/ui/show.mjs",
|
|
26
|
+
"ui:accept": "UPDATE_UI=1 node --test --test-concurrency=1 tests/ui/ui.test.mjs",
|
|
24
27
|
"test:keys": "node --test tests/key-choice-regression.test.mjs tests/coding-key-line.test.mjs tests/coding-key-picker.test.mjs tests/coding-key-prompt.test.mjs tests/coding-key-notice.test.mjs tests/coding-key-fallback-line.test.mjs tests/manage-coding-providers.test.mjs tests/add-coding-provider.test.mjs tests/coding-provider-setup.test.mjs",
|
|
25
28
|
"deploy:local": "npm install -g .",
|
|
26
29
|
"run": "gu-cli",
|
|
@@ -105,6 +108,7 @@
|
|
|
105
108
|
"proxy-dispatcher.mjs",
|
|
106
109
|
"model-doctor.mjs",
|
|
107
110
|
"coding-key-prompt.mjs",
|
|
111
|
+
"model-fallback.mjs",
|
|
108
112
|
"server-keys.mjs",
|
|
109
113
|
"add-server.mjs",
|
|
110
114
|
"powershell-alias.mjs",
|
package/terminal-layout.mjs
CHANGED
|
@@ -528,6 +528,21 @@ export function buildFooterRows({
|
|
|
528
528
|
];
|
|
529
529
|
}
|
|
530
530
|
|
|
531
|
+
/**
|
|
532
|
+
* The bytes that print a NEW thought block into the transcript (task #344): its painted rows,
|
|
533
|
+
* with ONE empty line above when the row above is not already blank (`gap`).
|
|
534
|
+
*
|
|
535
|
+
* A thought used to go directly under whatever was last printed. Under "✓ Command finished → …"
|
|
536
|
+
* it then read as that command's output, and under a bright "✗ Command failed → …" as its error
|
|
537
|
+
* text — but a thought is printed when its step's reasoning ends, before that step's tools
|
|
538
|
+
* report: it introduces what follows. Never two blanks: the caller passes gap=false when the
|
|
539
|
+
* last row is already empty. No blank BELOW either — the next bullet opens its own.
|
|
540
|
+
*/
|
|
541
|
+
export function thoughtPrintSeq(paintedRows, { gap = false } = {}) {
|
|
542
|
+
if (!paintedRows.length) return "";
|
|
543
|
+
return (gap ? "\n" : "") + paintedRows.join("\n") + "\n";
|
|
544
|
+
}
|
|
545
|
+
|
|
531
546
|
/**
|
|
532
547
|
* Bytes that draw the bar below the input line and put the caret back exactly where readline
|
|
533
548
|
* left it. Relative moves only: the block must survive the terminal scrolling — writing past
|