@tiens.nguyen/gu-cli 1.0.723 → 1.0.725
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/cli-token.mjs +38 -0
- package/client-expert-api.mjs +14 -0
- package/gonext_agent_chat.py +152 -84
- package/gu-cli.mjs +5 -10
- package/gu-repl.mjs +101 -0
- package/package.json +2 -1
- package/prompt_scaffold_check.py +114 -0
- package/terminal-layout.mjs +46 -0
package/cli-token.mjs
CHANGED
|
@@ -172,6 +172,44 @@ export async function writeState(dir, state) {
|
|
|
172
172
|
await rename(tmp, file);
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/** Why a pasted token was refused, in words that say what to do. */
|
|
176
|
+
export function tokenRefusal(why) {
|
|
177
|
+
return why === "bad-signature" ? "it was not issued by gu (or was copied incompletely)"
|
|
178
|
+
: why === "unknown-version" ? "it is from a newer gu — update this one first"
|
|
179
|
+
: "it is not a whole token — copy it again from the Account page";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check a pasted token OFFLINE and save it — the one way `gu token <paste>` and the terminal's own
|
|
184
|
+
* prompt both save one. lastAttemptAt null: the next gu start renews at once, which records THIS
|
|
185
|
+
* computer. Returns { ok: true, payload } or { ok: false, reason }.
|
|
186
|
+
*/
|
|
187
|
+
export async function saveTokenFromPaste(dir, pasted, publicKeyB64 = publicKey()) {
|
|
188
|
+
const check = verifyCliToken(String(pasted ?? "").trim(), publicKeyB64);
|
|
189
|
+
if (!check.ok) return { ok: false, reason: tokenRefusal(check.why) };
|
|
190
|
+
await writeState(dir, { token: String(pasted).trim(), lastAttemptAt: null });
|
|
191
|
+
return { ok: true, payload: check.payload };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Does a turn need a token first? Only in PRIVATE MODE (task #246): its local API fetches the
|
|
196
|
+
* agent's instructions from the gu service with this token. Hosted mode uses the worker key and
|
|
197
|
+
* never needs one. A build with no public key (tokens not configured) never asks.
|
|
198
|
+
*/
|
|
199
|
+
export function turnNeedsToken({ privateMode, token, publicKeyB64 = publicKey() }) {
|
|
200
|
+
if (!privateMode || !publicKeyB64) return false;
|
|
201
|
+
return !(token && verifyCliToken(token, publicKeyB64).ok);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** What the terminal says when a private-mode turn needs the token — friendly, and the steps. */
|
|
205
|
+
export function tokenAskLines(siteBaseUrl = siteUrl()) {
|
|
206
|
+
return [
|
|
207
|
+
"This computer needs your gu token before the agent can start — one time only.",
|
|
208
|
+
` 1. Open ${siteBaseUrl}/account and copy your gu CLI token.`,
|
|
209
|
+
" 2. Paste it below and press Enter.",
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
175
213
|
// ---------------------------------------------------------------------------
|
|
176
214
|
// WHEN TO RENEW, AND HOW
|
|
177
215
|
// ---------------------------------------------------------------------------
|
package/client-expert-api.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { createRequire } from "node:module";
|
|
21
21
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
22
22
|
import { spawnSurviving, pidOnPort } from "./platform-tools.mjs";
|
|
23
|
+
import { DEFAULT_API_BASE as HOSTED_API_BASE } from "./device-login.mjs";
|
|
23
24
|
import { spawn } from "node:child_process";
|
|
24
25
|
import { writeFile, mkdir, readFile, access } from "node:fs/promises";
|
|
25
26
|
import { homedir } from "node:os";
|
|
@@ -64,6 +65,8 @@ export function apiEnv({
|
|
|
64
65
|
mongoUri = LOCAL_MONGO_URI,
|
|
65
66
|
dbName = LOCAL_DB_NAME,
|
|
66
67
|
base = {},
|
|
68
|
+
hostedBase = hostedApiBase(),
|
|
69
|
+
home = homedir(),
|
|
67
70
|
} = {}) {
|
|
68
71
|
return {
|
|
69
72
|
...base,
|
|
@@ -71,9 +74,20 @@ export function apiEnv({
|
|
|
71
74
|
DATA_BACKEND: "mongo",
|
|
72
75
|
MONGODB_URI: mongoUri,
|
|
73
76
|
MONGODB_DB: dbName,
|
|
77
|
+
// THE AGENT'S PROMPT SCAFFOLD IS NOT ON THIS MACHINE (task #246). The local API forwards the
|
|
78
|
+
// worker's request to the hosted API, signed with this machine's gu CLI token, and keeps the
|
|
79
|
+
// answer in memory only — so private mode needs internet for a turn, and says so when it has
|
|
80
|
+
// none. No local copy exists to fall back to, by design.
|
|
81
|
+
PROMPT_SCAFFOLD_UPSTREAM: hostedBase,
|
|
82
|
+
GU_CLI_TOKEN_FILE: join(home, ".gonext", "cli-token.json"),
|
|
74
83
|
};
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
/** The hosted gu API — where a Client Expert machine's token is renewed and its scaffold fetched. */
|
|
87
|
+
export function hostedApiBase(env = process.env) {
|
|
88
|
+
return String(env.GU_TOKEN_API_BASE ?? "").trim() || HOSTED_API_BASE;
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
/**
|
|
78
92
|
* Is this health body a usable API?
|
|
79
93
|
*
|
package/gonext_agent_chat.py
CHANGED
|
@@ -1308,7 +1308,7 @@ def _render_numbered_tool_list(tools) -> str:
|
|
|
1308
1308
|
when RAG or a workspace is enabled/disabled) and the numbering is always exactly
|
|
1309
1309
|
correct, unlike the old manually-incremented counter it replaces. Kept intentionally
|
|
1310
1310
|
terse — first docstring line only, bare arg names — since the system prompt's own
|
|
1311
|
-
`tool.to_code_prompt()` rendering (
|
|
1311
|
+
`tool.to_code_prompt()` rendering (in the API-served prompt scaffold) already shows the
|
|
1312
1312
|
full typed signature on every model call; this is just a quick-reference index."""
|
|
1313
1313
|
lines = []
|
|
1314
1314
|
for i, t in enumerate(tools, 1):
|
|
@@ -1722,79 +1722,129 @@ def _looks_like_scratchpad(stripped: str) -> bool:
|
|
|
1722
1722
|
return len(_DELIBERATION_RE.findall(stripped)) >= 3
|
|
1723
1723
|
|
|
1724
1724
|
|
|
1725
|
-
#
|
|
1726
|
-
#
|
|
1727
|
-
#
|
|
1728
|
-
#
|
|
1729
|
-
#
|
|
1730
|
-
#
|
|
1731
|
-
#
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
"
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
"
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
"
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1725
|
+
# THE CODE-AGENT SCAFFOLD IS NOT IN THIS FILE (task #246). The fixed system prompt — the
|
|
1726
|
+
# Thought → Code → Observation rules that replace smolagents' stock ~9.9k-char template — is served
|
|
1727
|
+
# by the API (GET /api/worker/prompt-scaffold) and never shipped in the gu package. The worker
|
|
1728
|
+
# gets it once per job — from ~/.gonext/prompt-scaffolds/code-agent.txt if YOU put one there (gu
|
|
1729
|
+
# never writes it), else from the API — and if it cannot, the turn stops with the reason. There is
|
|
1730
|
+
# no bundled copy to fall back to: the point is that the text is not in what gu ships.
|
|
1731
|
+
#
|
|
1732
|
+
# Every scaffold passes the three checks in prompt_scaffold_check.py (size, placeholders, a trial
|
|
1733
|
+
# render) — without the code-block tags the model's code is never parsed, without the tool loop no
|
|
1734
|
+
# signature is shown, without custom_instructions the per-turn hint is dropped; each fails silently.
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
# The version of the scaffold this job is running — for the log and the prompt record, never the text.
|
|
1738
|
+
_PROMPT_SCAFFOLD_VERSION = {"v": None}
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
# THE LOCAL OVERRIDE AND THE THREE CHECKS live in prompt_scaffold_check.py — one checker, used by
|
|
1742
|
+
# this worker before every turn AND by `gu` at startup (so "scaffold loaded" means it passed).
|
|
1743
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
1744
|
+
import prompt_scaffold_check as _psc # noqa: E402
|
|
1745
|
+
|
|
1746
|
+
_LOCAL_SCAFFOLD_PATH = _psc.LOCAL_SCAFFOLD_PATH
|
|
1747
|
+
_scaffold_problem = _psc.scaffold_problem
|
|
1748
|
+
_SCAFFOLD_REQUIRED = _psc.SCAFFOLD_REQUIRED
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def _load_prompt_scaffold(api_base: str, worker_key: str):
|
|
1752
|
+
"""The scaffold for this turn → (template, version, source).
|
|
1753
|
+
|
|
1754
|
+
YOUR LOCAL FILE FIRST (~/.gonext/prompt-scaffolds/code-agent.txt): when it exists it is used and
|
|
1755
|
+
the API is not asked. Otherwise the API's version (the database's). Either way it passes the
|
|
1756
|
+
three checks in _scaffold_problem, and a failure stops the turn with the reason — a broken local
|
|
1757
|
+
file is NOT quietly replaced by the database version, or an edit that broke it would look like
|
|
1758
|
+
it had no effect."""
|
|
1759
|
+
if os.path.isfile(_LOCAL_SCAFFOLD_PATH):
|
|
1760
|
+
where = "~/.gonext/prompt-scaffolds/code-agent.txt"
|
|
1761
|
+
try:
|
|
1762
|
+
with open(_LOCAL_SCAFFOLD_PATH, encoding="utf-8") as f:
|
|
1763
|
+
text = f.read()
|
|
1764
|
+
except Exception as e: # noqa: BLE001
|
|
1765
|
+
raise _AgentConfigError(
|
|
1766
|
+
f"The agent couldn't start: your local scaffold {where} could not be read "
|
|
1767
|
+
f"({type(e).__name__}). Fix it, or delete it to use the gu service's.") from None
|
|
1768
|
+
problem = _scaffold_problem(text, f"your local scaffold {where}")
|
|
1769
|
+
if problem:
|
|
1770
|
+
raise _AgentConfigError(
|
|
1771
|
+
f"The agent couldn't start: {problem}. Fix it, or delete it to use the gu service's.")
|
|
1772
|
+
_PROMPT_SCAFFOLD_VERSION["v"] = "local"
|
|
1773
|
+
return text, "local", where
|
|
1774
|
+
template, version = _fetch_prompt_scaffold(api_base, worker_key)
|
|
1775
|
+
problem = _scaffold_problem(template, "the scaffold from the gu service")
|
|
1776
|
+
if problem:
|
|
1777
|
+
_log(f"prompt scaffold v{version} unusable: {problem}")
|
|
1778
|
+
raise _AgentConfigError(
|
|
1779
|
+
"The agent couldn't start: gu received instructions it can't use from the server. "
|
|
1780
|
+
"Please try again later.")
|
|
1781
|
+
return template, version, "api"
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def _is_local_api(base: str) -> bool:
|
|
1785
|
+
"""Is this the private-mode API on this computer (vs the hosted gu service)?"""
|
|
1786
|
+
from urllib.parse import urlparse
|
|
1787
|
+
host = (urlparse(base).hostname or "").lower()
|
|
1788
|
+
return host in ("127.0.0.1", "localhost", "::1") or host.startswith("127.")
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
def _fetch_prompt_scaffold(api_base: str, worker_key: str, name: str = "code-agent"):
|
|
1792
|
+
"""The agent's prompt scaffold from the API → (template, version).
|
|
1793
|
+
|
|
1794
|
+
Raises _AgentConfigError with a message the USER reads (it becomes a TURN_FAILED line) when
|
|
1795
|
+
the API cannot be asked, refuses, or sends something unusable. Never returns a default. The
|
|
1796
|
+
message never contains the template. Written as what to DO, not as HTTP codes: the person
|
|
1797
|
+
reading it wants the next step, and "HTTP 404 … prompt service" gave them a puzzle instead."""
|
|
1798
|
+
base = (api_base or "").rstrip("/")
|
|
1799
|
+
local = _is_local_api(base)
|
|
1800
|
+
if not base or not worker_key:
|
|
1801
|
+
raise _AgentConfigError(
|
|
1802
|
+
"The agent couldn't start: this computer isn't signed in to gu. Please run `gu login`.")
|
|
1803
|
+
from urllib.parse import urlencode
|
|
1804
|
+
req = urllib.request.Request(
|
|
1805
|
+
f"{base}/api/worker/prompt-scaffold?{urlencode({'name': name})}",
|
|
1806
|
+
headers={"X-Worker-Key": worker_key}, method="GET")
|
|
1807
|
+
try:
|
|
1808
|
+
with urllib.request.urlopen(req, timeout=15, context=_ssl_context()) as resp:
|
|
1809
|
+
data = json.loads(resp.read().decode("utf-8", "replace") or "{}")
|
|
1810
|
+
except urllib.error.HTTPError as he:
|
|
1811
|
+
detail = ""
|
|
1812
|
+
try:
|
|
1813
|
+
detail = str(json.loads(he.read().decode("utf-8", "replace") or "{}").get("error") or "")
|
|
1814
|
+
except Exception: # noqa: BLE001
|
|
1815
|
+
pass
|
|
1816
|
+
if detail:
|
|
1817
|
+
# The API's own sentence already says what to do (e.g. "please run `gu token`").
|
|
1818
|
+
raise _AgentConfigError(f"The agent couldn't start: {detail}") from None
|
|
1819
|
+
if he.code == 404:
|
|
1820
|
+
raise _AgentConfigError(
|
|
1821
|
+
"The agent couldn't start: your local gu API is out of date. "
|
|
1822
|
+
"Please run `gu api upgrade`, then send your message again." if local else
|
|
1823
|
+
"The agent couldn't start: the gu service isn't ready for this version of gu yet. "
|
|
1824
|
+
"Please try again in a few minutes.") from None
|
|
1825
|
+
raise _AgentConfigError(
|
|
1826
|
+
f"The agent couldn't start: the gu API had a problem (error {he.code}). "
|
|
1827
|
+
"Please try again in a moment.") from None
|
|
1828
|
+
except Exception: # noqa: BLE001
|
|
1829
|
+
raise _AgentConfigError(
|
|
1830
|
+
"The agent couldn't start: your local gu API isn't answering. "
|
|
1831
|
+
"Please check it with `gu api status` (or `gu api restart`), then try again." if local else
|
|
1832
|
+
"The agent couldn't start: the gu service isn't answering. "
|
|
1833
|
+
"Please check your internet connection and try again.") from None
|
|
1834
|
+
template = data.get("template") if isinstance(data, dict) else None
|
|
1835
|
+
version = data.get("version") if isinstance(data, dict) else None
|
|
1836
|
+
if not isinstance(template, str) or not template.strip():
|
|
1837
|
+
raise _AgentConfigError(
|
|
1838
|
+
"The agent couldn't start: gu received empty instructions from the server. "
|
|
1839
|
+
"Please try again later.")
|
|
1840
|
+
missing = [p for p in _SCAFFOLD_REQUIRED if p not in template]
|
|
1841
|
+
if missing:
|
|
1842
|
+
_log("prompt scaffold unusable: missing " + ", ".join(missing))
|
|
1843
|
+
raise _AgentConfigError(
|
|
1844
|
+
"The agent couldn't start: gu received instructions it can't use from the server. "
|
|
1845
|
+
"Please try again later.")
|
|
1846
|
+
_PROMPT_SCAFFOLD_VERSION["v"] = version
|
|
1847
|
+
return template, version
|
|
1798
1848
|
|
|
1799
1849
|
|
|
1800
1850
|
def _usage_field(obj, name):
|
|
@@ -2300,6 +2350,15 @@ def _render_prompt(messages, max_chars: int = 400_000) -> str:
|
|
|
2300
2350
|
out = []
|
|
2301
2351
|
for m in messages or []:
|
|
2302
2352
|
role, text = _message_role_text(m)
|
|
2353
|
+
if role == "system":
|
|
2354
|
+
# THE SCAFFOLD IS NEVER RECORDED (task #246). The system message IS the API-served
|
|
2355
|
+
# prompt scaffold (rendered with the tool signatures), and this record is stored by
|
|
2356
|
+
# the API and shown on the token-usage page — so it would hand the hidden text to
|
|
2357
|
+
# anyone who opens a step. Its size stays visible, the text does not.
|
|
2358
|
+
v = _PROMPT_SCAFFOLD_VERSION["v"]
|
|
2359
|
+
label = " (your local file)" if v == "local" else (f" v{v}" if v is not None else "")
|
|
2360
|
+
text = (f"[prompt scaffold{label} with the tool signatures — "
|
|
2361
|
+
f"{len(text)} characters, not shown]")
|
|
2303
2362
|
out.append(f"───── {role or 'message'} ─────\n{text}")
|
|
2304
2363
|
rendered = "\n\n".join(out)
|
|
2305
2364
|
if len(rendered) > max_chars:
|
|
@@ -12111,21 +12170,30 @@ def run_agent_chat(cfg):
|
|
|
12111
12170
|
agent_kwargs["additional_authorized_imports"] = [
|
|
12112
12171
|
"json", "base64", "urllib", "urllib.request", "urllib.error"
|
|
12113
12172
|
]
|
|
12114
|
-
#
|
|
12115
|
-
#
|
|
12116
|
-
#
|
|
12117
|
-
#
|
|
12173
|
+
# THE SYSTEM PROMPT IS THE API'S SCAFFOLD (task #246): the stock templates are loaded
|
|
12174
|
+
# for everything else (planning, final-answer, managed agents) and ONLY system_prompt
|
|
12175
|
+
# is swapped. No fallback in either direction — a missing scaffold, or stock
|
|
12176
|
+
# templates that will not load, stop the turn with the reason (_AgentConfigError →
|
|
12177
|
+
# TURN_FAILED) instead of quietly running on a different prompt. The version is
|
|
12178
|
+
# logged; the text never is.
|
|
12179
|
+
_scaffold, _scaffold_version, _scaffold_source = _load_prompt_scaffold(worker_api_base, worker_api_key)
|
|
12180
|
+
if _scaffold_source != "api":
|
|
12181
|
+
# SAID, every turn it applies: an old file left lying around must never quietly
|
|
12182
|
+
# change the agent. One line in the thinking area, not a banner.
|
|
12183
|
+
_emit({"type": "step", "text": f"using your local prompt scaffold ({_scaffold_source})"})
|
|
12118
12184
|
try:
|
|
12119
12185
|
import yaml as _yaml
|
|
12120
12186
|
import os as _os
|
|
12121
12187
|
import smolagents as _sm
|
|
12122
|
-
|
|
12123
|
-
|
|
12124
|
-
_tpl["system_prompt"] = _COMPACT_CODE_SYSTEM_PROMPT
|
|
12125
|
-
agent_kwargs["prompt_templates"] = _tpl
|
|
12126
|
-
_log("using compact CodeAgent system prompt (trimmed few-shot examples)")
|
|
12188
|
+
with open(_os.path.join(_os.path.dirname(_sm.__file__), "prompts", "code_agent.yaml")) as _f:
|
|
12189
|
+
_tpl = _yaml.safe_load(_f)
|
|
12127
12190
|
except Exception as _e: # noqa: BLE001
|
|
12128
|
-
|
|
12191
|
+
raise _AgentConfigError(
|
|
12192
|
+
"The agent couldn't start: part of gu's install is damaged "
|
|
12193
|
+
f"({type(_e).__name__}). Please reinstall gu.") from None
|
|
12194
|
+
_tpl["system_prompt"] = _scaffold
|
|
12195
|
+
agent_kwargs["prompt_templates"] = _tpl
|
|
12196
|
+
_log(f"prompt scaffold code-agent {'local file' if _scaffold_source != 'api' else f'v{_scaffold_version}'}")
|
|
12129
12197
|
agent = _ToolAgent(**agent_kwargs)
|
|
12130
12198
|
_agent_ref["a"] = agent # let step_callback trim this agent's step memory (#63)
|
|
12131
12199
|
# At the step budget the run ends WITHOUT final_answer (_maxsteps_partial hit). Offer
|
package/gu-cli.mjs
CHANGED
|
@@ -198,19 +198,14 @@ if (args[0] === "token") {
|
|
|
198
198
|
process.exit(pasted ? 1 : 0);
|
|
199
199
|
}
|
|
200
200
|
if (pasted) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
: check.why === "unknown-version" ? "it is from a newer gu — update this one first"
|
|
206
|
-
: "it is not a whole token — copy it again from the Account page";
|
|
207
|
-
console.error(`That token was not saved: ${why}.`);
|
|
201
|
+
// The same check-and-save the terminal's own token prompt uses (saveTokenFromPaste).
|
|
202
|
+
const saved = await ct.saveTokenFromPaste(dir, pasted, pk);
|
|
203
|
+
if (!saved.ok) {
|
|
204
|
+
console.error(`That token was not saved: ${saved.reason}.`);
|
|
208
205
|
process.exit(1);
|
|
209
206
|
}
|
|
210
|
-
// lastAttemptAt null: the next gu start renews at once, which records THIS computer.
|
|
211
|
-
await ct.writeState(dir, { token: pasted, lastAttemptAt: null });
|
|
212
207
|
console.log(accent("Saved.") + " It renews itself from now on.");
|
|
213
|
-
for (const l of ct.tokenSummary(
|
|
208
|
+
for (const l of ct.tokenSummary(saved.payload)) console.log(" " + l);
|
|
214
209
|
process.exit(0);
|
|
215
210
|
}
|
|
216
211
|
const state = await ct.readState(dir);
|
package/gu-repl.mjs
CHANGED
|
@@ -8743,6 +8743,99 @@ const wrapThought = (s, width, maxLines) => {
|
|
|
8743
8743
|
* turn's accounting (see the guards below), and its caller must not adopt the answer as its
|
|
8744
8744
|
* own conversation.
|
|
8745
8745
|
*/
|
|
8746
|
+
/**
|
|
8747
|
+
* WHERE THIS SESSION'S SCAFFOLD COMES FROM, said at `gu` start (task #246) — "scaffold loaded" from
|
|
8748
|
+
* the user's own ~/.gonext/prompt-scaffolds/code-agent.txt, or "scaffold fetched" from the gu
|
|
8749
|
+
* service. Background: startup never waits on it (an offline private-mode machine would otherwise
|
|
8750
|
+
* sit for 10s); the line prints when the answer arrives, or at the next prompt if a turn is running.
|
|
8751
|
+
*
|
|
8752
|
+
* · A LOCAL FILE is checked with the worker's OWN checker (prompt_scaffold_check.py — size,
|
|
8753
|
+
* placeholders, a trial render), so "loaded" means it passed, not merely that it exists. The API
|
|
8754
|
+
* is not asked: turns use the file.
|
|
8755
|
+
* · Otherwise the API is asked to REFRESH — the fetch that the rest of the session then reuses
|
|
8756
|
+
* ("keep in cache until the user type gu").
|
|
8757
|
+
*/
|
|
8758
|
+
let scaffoldNotice = null;
|
|
8759
|
+
|
|
8760
|
+
async function checkPromptScaffoldAtStart() {
|
|
8761
|
+
const tl = await import("./terminal-layout.mjs");
|
|
8762
|
+
let local = null;
|
|
8763
|
+
let refresh = null;
|
|
8764
|
+
try {
|
|
8765
|
+
local = await localScaffoldState();
|
|
8766
|
+
if (!local?.exists) {
|
|
8767
|
+
const r = await fetch(`${apiBase}/api/worker/prompt-scaffold/refresh?name=code-agent`, {
|
|
8768
|
+
method: "POST",
|
|
8769
|
+
headers: { "X-Worker-Key": workerKey },
|
|
8770
|
+
signal: AbortSignal.timeout(20_000),
|
|
8771
|
+
});
|
|
8772
|
+
const body = await r.json().catch(() => ({}));
|
|
8773
|
+
refresh = { status: r.status, ...(body && typeof body === "object" ? body : {}) };
|
|
8774
|
+
}
|
|
8775
|
+
} catch {
|
|
8776
|
+
/* refresh stays null → "couldn't ask the API" */
|
|
8777
|
+
}
|
|
8778
|
+
scaffoldNotice = tl.scaffoldStartupNotice({ local, refresh, private: isClientExpert() });
|
|
8779
|
+
announceScaffold();
|
|
8780
|
+
}
|
|
8781
|
+
|
|
8782
|
+
/** The local override's state from prompt_scaffold_check.py, or null when it could not be run. */
|
|
8783
|
+
async function localScaffoldState() {
|
|
8784
|
+
const { resolvePython } = await import("./platform-tools.mjs");
|
|
8785
|
+
const { execFile } = await import("node:child_process");
|
|
8786
|
+
const py = await resolvePython();
|
|
8787
|
+
return new Promise((done) => {
|
|
8788
|
+
execFile(py.cmd, [...py.args, join(DIR, "prompt_scaffold_check.py")], { timeout: 15_000, windowsHide: true },
|
|
8789
|
+
(err, stdout) => {
|
|
8790
|
+
try {
|
|
8791
|
+
const last = String(stdout ?? "").trim().split("\n").pop() ?? "";
|
|
8792
|
+
done(JSON.parse(last));
|
|
8793
|
+
} catch {
|
|
8794
|
+
done(null);
|
|
8795
|
+
}
|
|
8796
|
+
});
|
|
8797
|
+
});
|
|
8798
|
+
}
|
|
8799
|
+
|
|
8800
|
+
/** Print the startup scaffold line — deferred while a turn owns the transcript. */
|
|
8801
|
+
function announceScaffold() {
|
|
8802
|
+
if (!scaffoldNotice || following) return;
|
|
8803
|
+
const { kind, text } = scaffoldNotice;
|
|
8804
|
+
scaffoldNotice = null;
|
|
8805
|
+
console.log(GUTTER + (kind === "ok" ? accent("✓ " + text) : kind === "warn" ? yellow("⚠ " + text) : red("✗ " + text)));
|
|
8806
|
+
if (barPinned()) paintBar();
|
|
8807
|
+
}
|
|
8808
|
+
|
|
8809
|
+
/**
|
|
8810
|
+
* Private mode only: make sure this computer has a valid gu token before a turn is sent (task
|
|
8811
|
+
* #246), asking for it in place. Returns true when the turn may go ahead. Hosted mode, and a gu
|
|
8812
|
+
* built without token support, return true at once.
|
|
8813
|
+
*/
|
|
8814
|
+
async function ensureTokenForTurn() {
|
|
8815
|
+
if (!isClientExpert()) return true;
|
|
8816
|
+
const ct = await import("./cli-token.mjs");
|
|
8817
|
+
const dir = join(homedir(), ".gonext");
|
|
8818
|
+
const state = await ct.readState(dir);
|
|
8819
|
+
if (!ct.turnNeedsToken({ privateMode: true, token: state.token })) return true;
|
|
8820
|
+
console.log("");
|
|
8821
|
+
for (const l of ct.tokenAskLines()) console.log(GUTTER + (l.startsWith(" ") ? dim(l) : yellow(l)));
|
|
8822
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
8823
|
+
const pasted = String((await ask(" gu token: ")) ?? "").trim();
|
|
8824
|
+
if (!pasted) {
|
|
8825
|
+
console.log(GUTTER + dim("Not sent. Run `gu token <paste>` any time, then send your message again.") + "\n");
|
|
8826
|
+
return false;
|
|
8827
|
+
}
|
|
8828
|
+
const saved = await ct.saveTokenFromPaste(dir, pasted);
|
|
8829
|
+
if (saved.ok) {
|
|
8830
|
+
console.log(GUTTER + accent("✓ token saved") + dim(" — it renews itself from now on.") + "\n");
|
|
8831
|
+
return true;
|
|
8832
|
+
}
|
|
8833
|
+
console.log(GUTTER + yellow(`That token was not saved: ${saved.reason}.`));
|
|
8834
|
+
}
|
|
8835
|
+
console.log(GUTTER + dim("Not sent. Run `gu token <paste>` any time, then send your message again.") + "\n");
|
|
8836
|
+
return false;
|
|
8837
|
+
}
|
|
8838
|
+
|
|
8746
8839
|
async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
8747
8840
|
// #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
|
|
8748
8841
|
// startup (see licenceBlock) — never re-checked here, so a turn cannot die halfway through.
|
|
@@ -8751,6 +8844,10 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
8751
8844
|
return;
|
|
8752
8845
|
}
|
|
8753
8846
|
const attached = Boolean(attachJobId);
|
|
8847
|
+
// PRIVATE MODE NEEDS THE gu TOKEN (task #246): the local API fetches the agent's instructions
|
|
8848
|
+
// from the gu service with it. Asked for HERE, before anything is sent, with the steps — rather
|
|
8849
|
+
// than letting the turn fail and leaving the user to decode why.
|
|
8850
|
+
if (!attached && !(await ensureTokenForTurn())) return;
|
|
8754
8851
|
inputVisible = false; // the frozen zone shows an empty prompt for the duration (#141)
|
|
8755
8852
|
// Something on screen from the FIRST moment (task #140): Enter has just wiped the bar, and
|
|
8756
8853
|
// the next thing to draw itself is the turn's ticker — which stays silent until the daemon
|
|
@@ -10252,6 +10349,9 @@ async function main() {
|
|
|
10252
10349
|
// Show which models will answer, straight from user settings (also validates auth).
|
|
10253
10350
|
try {
|
|
10254
10351
|
const probe = await probeWithRecovery();
|
|
10352
|
+
// A NEW gu SESSION: where does its scaffold come from? (task #246) Your file ("loaded") or the
|
|
10353
|
+
// gu service ("fetched" — the refresh the session then reuses). Background: never delays startup.
|
|
10354
|
+
void checkPromptScaffoldAtStart();
|
|
10255
10355
|
const p = probe?.payload ?? {};
|
|
10256
10356
|
// Remember the default coder id for the token footer's global-total label, and the
|
|
10257
10357
|
// default backend's coding URL so the GLOBAL output-token total is keyed to it (task
|
|
@@ -10792,6 +10892,7 @@ async function main() {
|
|
|
10792
10892
|
// what lets the frozen zone survive scrolling.
|
|
10793
10893
|
enableClickToPosition();
|
|
10794
10894
|
announceCliUpdate(); // deferred if a 6h refresh landed mid-turn; harmless once shown
|
|
10895
|
+
announceScaffold(); // the startup scaffold line, if it arrived while a turn was running
|
|
10795
10896
|
rl.prompt();
|
|
10796
10897
|
paintBar();
|
|
10797
10898
|
if (!barPinned()) probeCaretRow(); // fallback path still needs the caret's absolute row
|
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.725",
|
|
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",
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"install-path.mjs",
|
|
90
90
|
"gonext_probe_agent.py",
|
|
91
91
|
"gonext_agent_chat.py",
|
|
92
|
+
"prompt_scaffold_check.py",
|
|
92
93
|
"gonext_transcribe.py",
|
|
93
94
|
"gonext_mlx_embed.py",
|
|
94
95
|
"thinking_words.txt",
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""THE AGENT'S PROMPT SCAFFOLD — where the local override lives, and the THREE CHECKS (task #246).
|
|
3
|
+
|
|
4
|
+
One checker, used twice: gonext_agent_chat.py imports it before every agent turn, and `gu` runs it
|
|
5
|
+
at startup (`python3 prompt_scaffold_check.py`) so the "scaffold loaded" line it prints means the
|
|
6
|
+
file really passed — not just that a file exists. Needs only jinja2 (a smolagents dependency), so
|
|
7
|
+
the startup check is quick.
|
|
8
|
+
|
|
9
|
+
python3 prompt_scaffold_check.py [path] → one JSON line: {"exists", "path", "chars", "problem"}
|
|
10
|
+
|
|
11
|
+
It never prints the scaffold text.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
# A scaffold YOU put here wins over the database version, on this machine only. gu never WRITES this
|
|
19
|
+
# file — the scaffold stays off users' disks unless they put it there themselves — so it is an
|
|
20
|
+
# override for trying a wording before inserting it into production, not a cache.
|
|
21
|
+
LOCAL_SCAFFOLD_PATH = os.path.join(os.path.expanduser("~"), ".gonext", "prompt-scaffolds", "code-agent.txt")
|
|
22
|
+
LOCAL_SCAFFOLD_LABEL = "~/.gonext/prompt-scaffolds/code-agent.txt"
|
|
23
|
+
|
|
24
|
+
# What a scaffold must keep — the same list the API and the deploy app use.
|
|
25
|
+
SCAFFOLD_REQUIRED = (
|
|
26
|
+
"{{code_block_opening_tag}}",
|
|
27
|
+
"{{code_block_closing_tag}}",
|
|
28
|
+
"tool.to_code_prompt()",
|
|
29
|
+
"{{authorized_imports}}",
|
|
30
|
+
"custom_instructions",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _SampleTool:
|
|
35
|
+
"""Stands in for a real tool in the trial render — the attributes a template may touch."""
|
|
36
|
+
name = "sample_tool"
|
|
37
|
+
description = "A sample tool."
|
|
38
|
+
inputs = {"query": {"type": "string", "description": "a sample input"}}
|
|
39
|
+
output_type = "string"
|
|
40
|
+
|
|
41
|
+
def to_code_prompt(self):
|
|
42
|
+
return 'def sample_tool(query: string) -> string:\n """A sample tool."""'
|
|
43
|
+
|
|
44
|
+
def to_tool_calling_prompt(self):
|
|
45
|
+
return "sample_tool: A sample tool."
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _SampleAgent(_SampleTool):
|
|
49
|
+
name = "sample_agent"
|
|
50
|
+
description = "A sample managed agent."
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def scaffold_problem(template, source: str) -> str:
|
|
54
|
+
""""" when the scaffold is usable; else what is wrong, in words that name `source`. THREE checks:
|
|
55
|
+
|
|
56
|
+
1. not empty, at most 64,000 characters;
|
|
57
|
+
2. the five placeholders the agent loop needs;
|
|
58
|
+
3. a TRIAL RENDER exactly as smolagents renders it — jinja2 with StrictUndefined and the same
|
|
59
|
+
six variables — once bare and once with a sample tool, a sample managed agent and custom
|
|
60
|
+
instructions, so every {% if %}/{% for %} branch is exercised. Catches what 1–2 cannot: an
|
|
61
|
+
unclosed {% if %}, a stray brace, a misspelled {{ tool.nmae }}."""
|
|
62
|
+
if not isinstance(template, str) or not template.strip():
|
|
63
|
+
return f"{source} is empty"
|
|
64
|
+
if len(template) > 64_000:
|
|
65
|
+
return f"{source} is over 64,000 characters"
|
|
66
|
+
missing = [p for p in SCAFFOLD_REQUIRED if p not in template]
|
|
67
|
+
if missing:
|
|
68
|
+
return f"{source} is missing " + ", ".join(missing)
|
|
69
|
+
try:
|
|
70
|
+
from jinja2 import StrictUndefined, Template, TemplateSyntaxError
|
|
71
|
+
except Exception: # noqa: BLE001
|
|
72
|
+
return "" # no jinja2 here → smolagents cannot run either; the agent build will say so
|
|
73
|
+
try:
|
|
74
|
+
compiled = Template(template, undefined=StrictUndefined)
|
|
75
|
+
except TemplateSyntaxError as e:
|
|
76
|
+
return f"{source} has a template error on line {e.lineno}: {e.message}"
|
|
77
|
+
tool, agent = _SampleTool(), _SampleAgent()
|
|
78
|
+
for variables in (
|
|
79
|
+
{"tools": {}, "managed_agents": {}, "custom_instructions": ""},
|
|
80
|
+
{"tools": {tool.name: tool}, "managed_agents": {agent.name: agent},
|
|
81
|
+
"custom_instructions": "Sample instructions."},
|
|
82
|
+
):
|
|
83
|
+
try:
|
|
84
|
+
compiled.render(authorized_imports="['json']", code_block_opening_tag="<code>",
|
|
85
|
+
code_block_closing_tag="</code>", **variables)
|
|
86
|
+
except Exception as e: # noqa: BLE001
|
|
87
|
+
line = getattr(e, "lineno", None)
|
|
88
|
+
# The sample objects are ours, not the reader's: name them for what they stand for.
|
|
89
|
+
why = re.sub(r"'[\w.]*_SampleAgent object'", "a managed agent", str(e))
|
|
90
|
+
why = re.sub(r"'[\w.]*_SampleTool object'", "a tool", why)
|
|
91
|
+
return (f"{source} fails to render"
|
|
92
|
+
+ (f" on line {line}" if line else "")
|
|
93
|
+
+ f": {why}")
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def check_file(path: str = LOCAL_SCAFFOLD_PATH, label: str = LOCAL_SCAFFOLD_LABEL) -> dict:
|
|
98
|
+
"""The local override's state, for `gu` at startup. Never includes the text."""
|
|
99
|
+
if not os.path.isfile(path):
|
|
100
|
+
return {"exists": False, "path": label}
|
|
101
|
+
try:
|
|
102
|
+
with open(path, encoding="utf-8") as f:
|
|
103
|
+
text = f.read()
|
|
104
|
+
except Exception as e: # noqa: BLE001
|
|
105
|
+
return {"exists": True, "path": label, "chars": 0,
|
|
106
|
+
"problem": f"your local scaffold {label} could not be read ({type(e).__name__})"}
|
|
107
|
+
return {"exists": True, "path": label, "chars": len(text),
|
|
108
|
+
"problem": scaffold_problem(text, f"your local scaffold {label}")}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
target = sys.argv[1] if len(sys.argv) > 1 else LOCAL_SCAFFOLD_PATH
|
|
113
|
+
label = LOCAL_SCAFFOLD_LABEL if len(sys.argv) <= 1 else target
|
|
114
|
+
print(json.dumps(check_file(target, label)))
|
package/terminal-layout.mjs
CHANGED
|
@@ -1291,6 +1291,14 @@ export function classifyTurnFailure(message) {
|
|
|
1291
1291
|
) {
|
|
1292
1292
|
return "infra";
|
|
1293
1293
|
}
|
|
1294
|
+
// SETUP — the agent could not START: its instructions (the prompt scaffold, task #246) could not
|
|
1295
|
+
// be loaded, so no model was ever called. REPORTED: a 503 from the scaffold route matched the
|
|
1296
|
+
// backend patterns below and was captioned "the coding model's backend failed … /model to switch
|
|
1297
|
+
// coder" — wrong on both counts, and switching coder cannot help. The message itself already
|
|
1298
|
+
// says the next step. Both wordings: this gu's, and 1.0.724's ("could not be loaded (HTTP 503)").
|
|
1299
|
+
if (/The agent couldn't start|The agent's prompt (could not be loaded|could not be used)|agent's prompt templates could not be loaded/i.test(t)) {
|
|
1300
|
+
return "setup";
|
|
1301
|
+
}
|
|
1294
1302
|
// CONFIG — the server is up and answering; it just has no model by that name (task #213).
|
|
1295
1303
|
// Ollama says: model 'gemma4:e4b' not found (and "…, try pulling it first" for embeddings).
|
|
1296
1304
|
// Checked BEFORE the backend patterns because it arrives as a 404 from a perfectly healthy
|
|
@@ -1388,6 +1396,11 @@ export function retryAdvice({ resumable = true, failures = 1, kind = "task", min
|
|
|
1388
1396
|
? `gu's own machinery has failed ${failures} times in a row — not your question, and not the coding model. Check it before retrying: gu-cli api status, and gu-cli start if the worker is not running.`
|
|
1389
1397
|
: 'that was gu\'s own machinery stopping, not your question — "continue" runs it again';
|
|
1390
1398
|
}
|
|
1399
|
+
if (kind === "setup") {
|
|
1400
|
+
// Nothing ran, so there is nothing to diagnose in the model or the question — only the step the
|
|
1401
|
+
// message above names. "continue" is right AFTER that step, never before.
|
|
1402
|
+
return 'nothing ran yet — not your question, and not the coding model. Do the step above, then "continue" sends your question again.';
|
|
1403
|
+
}
|
|
1391
1404
|
if (kind === "config") {
|
|
1392
1405
|
// Not resumable by repetition, and saying "continue" here would be a lie — the same
|
|
1393
1406
|
// question with the same model name fails identically every time. Retry count is
|
|
@@ -1755,6 +1768,39 @@ export function lazyModuleNames(text) {
|
|
|
1755
1768
|
.filter((m) => m !== "gu-cli.mjs");
|
|
1756
1769
|
}
|
|
1757
1770
|
|
|
1771
|
+
/**
|
|
1772
|
+
* The one line `gu` prints at startup about the agent's prompt scaffold (task #246) — asked for so
|
|
1773
|
+
* the user KNOWS where this session's scaffold came from: "scaffold is loaded" (their own file) or
|
|
1774
|
+
* "scaffold is fetched" (the gu service). Pure; gu-repl gathers the facts.
|
|
1775
|
+
*
|
|
1776
|
+
* local — prompt_scaffold_check.py's answer: { exists, path, chars, problem } (null = not run)
|
|
1777
|
+
* refresh — POST /api/worker/prompt-scaffold/refresh: { status, ok, version, error, kept }
|
|
1778
|
+
* private — private mode (the local API), which decides what "update" advice means
|
|
1779
|
+
* Returns { kind: "ok" | "warn" | "err", text }.
|
|
1780
|
+
*/
|
|
1781
|
+
export function scaffoldStartupNotice({ local = null, refresh = null, private: privateMode = false } = {}) {
|
|
1782
|
+
const n = (x) => Number(x ?? 0).toLocaleString("en-US");
|
|
1783
|
+
if (local?.exists) {
|
|
1784
|
+
return local.problem
|
|
1785
|
+
? { kind: "err", text: `scaffold: ${local.problem} — fix it, or delete it to use the gu service's` }
|
|
1786
|
+
: { kind: "ok", text: `scaffold loaded from ${local.path} (${n(local.chars)} chars)` };
|
|
1787
|
+
}
|
|
1788
|
+
if (!refresh) return { kind: "warn", text: "scaffold: couldn't ask the API — the first turn will try again" };
|
|
1789
|
+
if (refresh.status === 404) {
|
|
1790
|
+
return {
|
|
1791
|
+
kind: "warn",
|
|
1792
|
+
text: privateMode
|
|
1793
|
+
? "scaffold: your local gu API is older than this gu — run `gu api upgrade`"
|
|
1794
|
+
: "scaffold: the gu service doesn't serve it yet — agent turns will fail until it does",
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
if (refresh.ok) return { kind: "ok", text: `scaffold fetched from the gu service (v${refresh.version})` };
|
|
1798
|
+
const why = String(refresh.error ?? "unknown error").replace(/\.$/, "");
|
|
1799
|
+
return refresh.kept !== undefined
|
|
1800
|
+
? { kind: "warn", text: `scaffold: couldn't fetch — ${why}. Still using v${refresh.kept}.` }
|
|
1801
|
+
: { kind: "err", text: `scaffold: ${why}.` };
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1758
1804
|
/** One line, bounded — a reason is for reading, and npm's can carry a whole stack frame. */
|
|
1759
1805
|
function _clipReason(s, max = 160) {
|
|
1760
1806
|
const one = String(s).replace(/\s+/g, " ").trim();
|