@tiens.nguyen/gu-cli 1.0.728 → 1.0.730
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 +107 -36
- package/gu-cli.mjs +48 -0
- package/gu-repl.mjs +73 -10
- package/log-policy.mjs +206 -0
- package/package.json +2 -1
- package/terminal-layout.mjs +59 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -528,7 +528,7 @@ def _get_json(url, timeout=15):
|
|
|
528
528
|
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context_insecure()) as resp:
|
|
529
529
|
return json.loads(resp.read().decode("utf-8", errors="replace"))
|
|
530
530
|
except Exception as e: # noqa: BLE001
|
|
531
|
-
_log(f"web_search fetch failed {url}: {e}")
|
|
531
|
+
_log(f"web_search fetch failed {_u(url)}: {e}")
|
|
532
532
|
return None
|
|
533
533
|
|
|
534
534
|
|
|
@@ -558,7 +558,7 @@ def _get_text(url, timeout=6, ua=None, headers=None):
|
|
|
558
558
|
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context_insecure()) as resp:
|
|
559
559
|
return resp.read(600_000).decode("utf-8", errors="replace")
|
|
560
560
|
except Exception as e: # noqa: BLE001
|
|
561
|
-
_log(f"web_search GET failed {url
|
|
561
|
+
_log(f"web_search GET failed {_u(url, 80)}: {e}")
|
|
562
562
|
return ""
|
|
563
563
|
|
|
564
564
|
|
|
@@ -1028,7 +1028,7 @@ def _list_model_ids(base_url, api_key=""):
|
|
|
1028
1028
|
with urllib.request.urlopen(req, timeout=10, context=_ssl_context_insecure()) as resp:
|
|
1029
1029
|
payload = json.loads(resp.read().decode("utf-8", errors="replace"))
|
|
1030
1030
|
except Exception as e: # noqa: BLE001
|
|
1031
|
-
_log(f"model list failed {url}: {e}")
|
|
1031
|
+
_log(f"model list failed {_u(url)}: {e}")
|
|
1032
1032
|
return []
|
|
1033
1033
|
data = payload.get("data") if isinstance(payload, dict) else None
|
|
1034
1034
|
if not isinstance(data, list):
|
|
@@ -2621,7 +2621,7 @@ def _summarize_result(task_text: str, agent_output: str,
|
|
|
2621
2621
|
temperature=0.3,
|
|
2622
2622
|
)
|
|
2623
2623
|
summary = (resp.choices[0].message.content or "").strip()
|
|
2624
|
-
_log(f"summary: {summary
|
|
2624
|
+
_log(f"summary: {_c(summary, 120)}")
|
|
2625
2625
|
return summary or agent_output
|
|
2626
2626
|
except Exception as e: # noqa: BLE001
|
|
2627
2627
|
_log(f"summarize error: {e}")
|
|
@@ -2655,6 +2655,12 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
|
|
|
2655
2655
|
content = _THINK_RE_LOCAL.sub("", content).strip()
|
|
2656
2656
|
if not content:
|
|
2657
2657
|
continue
|
|
2658
|
+
# An unanswered question stays in history (task #247), so two user turns can be
|
|
2659
|
+
# adjacent. Some chat templates (Gemma's, as served by MLX / vLLM) reject a
|
|
2660
|
+
# conversation whose roles do not alternate, so adjacent turns are joined into one.
|
|
2661
|
+
if chat_messages[-1]["role"] == role:
|
|
2662
|
+
chat_messages[-1]["content"] = f"{chat_messages[-1]['content']}\n\n{content}"
|
|
2663
|
+
continue
|
|
2658
2664
|
chat_messages.append({"role": role, "content": content})
|
|
2659
2665
|
|
|
2660
2666
|
# Each target carries ITS OWN key. They are usually different services — the chat model
|
|
@@ -3214,6 +3220,11 @@ def _is_billing_failure(err) -> bool:
|
|
|
3214
3220
|
or "out of credit" in s
|
|
3215
3221
|
or ("suspended" in s and ("balance" in s or "billing" in s or "payment" in s))
|
|
3216
3222
|
or ("quota" in s and "exhaust" in s)
|
|
3223
|
+
# ollama.com (task #247): "you have reached your monthly usage limit, upgrade … or add
|
|
3224
|
+
# usage credits". A USAGE limit is an allowance for the period, not a request rate — a
|
|
3225
|
+
# "rate limit … per minute" never says "usage limit", so the transient 429s are untouched.
|
|
3226
|
+
or "usage limit" in s
|
|
3227
|
+
or "usage credits" in s
|
|
3217
3228
|
)
|
|
3218
3229
|
|
|
3219
3230
|
|
|
@@ -6796,6 +6807,12 @@ def _rag_s3_client(region: str, akid: str, secret: str):
|
|
|
6796
6807
|
|
|
6797
6808
|
|
|
6798
6809
|
def run_agent_chat(cfg):
|
|
6810
|
+
# Task #249: what this run may log — set FIRST, before anything is logged (the history and the
|
|
6811
|
+
# current task are logged early, and a real turn caught them leaking when this came later).
|
|
6812
|
+
# "production" (the account has no Enable Full Log): content becomes <content, N chars> and the
|
|
6813
|
+
# model-request dump is only what is new per step. "full" (or absent — an older worker): as before.
|
|
6814
|
+
global _LOG_PRODUCTION
|
|
6815
|
+
_LOG_PRODUCTION = str(cfg.get("logProfile") or "full").strip().lower() == "production"
|
|
6799
6816
|
# One turn, one ledger (task #142): the process is per-turn today, but a stale entry would
|
|
6800
6817
|
# credit RAG for tokens it never cost, and a wrong number is worse than none.
|
|
6801
6818
|
_rag_ledger_reset()
|
|
@@ -7023,8 +7040,8 @@ def run_agent_chat(cfg):
|
|
|
7023
7040
|
f"(budget {HISTORY_CHAR_BUDGET}) — exact turns sent to the agent below:"
|
|
7024
7041
|
)
|
|
7025
7042
|
for j, ln in enumerate(history_lines):
|
|
7026
|
-
_log(f" history[{j}]: {ln
|
|
7027
|
-
_log(f"current task (latest user message): {task_text.rsplit('Current task: ', 1)[-1]
|
|
7043
|
+
_log(f" history[{j}]: {_c(ln, 240)}")
|
|
7044
|
+
_log(f"current task (latest user message): {_c(task_text.rsplit('Current task: ', 1)[-1], 240, r=True)}")
|
|
7028
7045
|
|
|
7029
7046
|
# ---- send_email config (per-user email API, e.g. a Lambda the user set up) ----
|
|
7030
7047
|
email_enabled = bool(cfg.get("emailEnabled"))
|
|
@@ -7715,8 +7732,8 @@ def run_agent_chat(cfg):
|
|
|
7715
7732
|
else:
|
|
7716
7733
|
doc_title = _derive_pdf_title(doc_text)
|
|
7717
7734
|
_log(
|
|
7718
|
-
f"PDF fast-path: title={doc_title
|
|
7719
|
-
+ (f" edit={edit_instruction
|
|
7735
|
+
f"PDF fast-path: title={_c(doc_title, r=True)} doc_chars={len(doc_text)}"
|
|
7736
|
+
+ (f" edit={_c(edit_instruction, 60, r=True)}" if edit_instruction else "")
|
|
7720
7737
|
)
|
|
7721
7738
|
|
|
7722
7739
|
_emit({"type": "step", "text": "Formatting document…"})
|
|
@@ -7761,7 +7778,7 @@ def run_agent_chat(cfg):
|
|
|
7761
7778
|
)})
|
|
7762
7779
|
return
|
|
7763
7780
|
|
|
7764
|
-
_log(f"PDF fast-path ok title={doc_title
|
|
7781
|
+
_log(f"PDF fast-path ok title={_c(doc_title, r=True)} bytes={len(pdf_bytes)}")
|
|
7765
7782
|
_emit({"type": "final", "text": out})
|
|
7766
7783
|
return
|
|
7767
7784
|
# ---------------------------------------------------------------------------------
|
|
@@ -8296,7 +8313,7 @@ def run_agent_chat(cfg):
|
|
|
8296
8313
|
sig = f"{tool_name}::{key}"
|
|
8297
8314
|
prior = _seen_calls.get(sig)
|
|
8298
8315
|
if prior is not None:
|
|
8299
|
-
_log(f"{tool_name} REPEAT suppressed ({arg
|
|
8316
|
+
_log(f"{tool_name} REPEAT suppressed ({_c(arg, 60, r=True)}) → nudging model forward")
|
|
8300
8317
|
return True, (
|
|
8301
8318
|
f"You already ran {tool_name}({arg!r}) (or an equivalent query) — the "
|
|
8302
8319
|
f"result would be the same as shown above. Do NOT call it again. {next_hint}"
|
|
@@ -8332,12 +8349,12 @@ def run_agent_chat(cfg):
|
|
|
8332
8349
|
if url_key in _failed_urls:
|
|
8333
8350
|
msg = f"Error: {url} already failed — try a different URL or use Python stdlib."
|
|
8334
8351
|
_emit({"type": "step", "text": f"HTTP {method.upper()} {url} → (skipped, already failed)"})
|
|
8335
|
-
_log(f"http_request skipped (already failed): {url_key}")
|
|
8352
|
+
_log(f"http_request skipped (already failed): {_u(url_key)}")
|
|
8336
8353
|
return msg
|
|
8337
8354
|
result = _http_request_impl(method, url, parsed_headers, body or None)
|
|
8338
8355
|
if result.startswith("Error:"):
|
|
8339
8356
|
# Retry once for flaky connections (e.g. gorok tunnels).
|
|
8340
|
-
_log(f"http_request retry {method.upper()} {url}")
|
|
8357
|
+
_log(f"http_request retry {method.upper()} {_u(url)}")
|
|
8341
8358
|
result = _http_request_impl(method, url, parsed_headers, body or None)
|
|
8342
8359
|
if result.startswith("Error:"):
|
|
8343
8360
|
# Both attempts failed — mark URL as dead so model tries something else.
|
|
@@ -8377,7 +8394,7 @@ def run_agent_chat(cfg):
|
|
|
8377
8394
|
"server log above, or call final_answer honestly stating what's failing.]"
|
|
8378
8395
|
)
|
|
8379
8396
|
_emit({"type": "step", "text": f"HTTP {method.upper()} {url} → {status_line}"})
|
|
8380
|
-
_log(f"http_request {method.upper()} {url} → {result
|
|
8397
|
+
_log(f"http_request {method.upper()} {_u(url)} → {_c(result, 80)}")
|
|
8381
8398
|
_last_obs["text"] = result
|
|
8382
8399
|
return result
|
|
8383
8400
|
|
|
@@ -8399,7 +8416,7 @@ def run_agent_chat(cfg):
|
|
|
8399
8416
|
now = _dtl.now().astimezone()
|
|
8400
8417
|
out = now.strftime("%A, %d %B %Y, %H:%M:%S %Z")
|
|
8401
8418
|
_emit({"type": "step", "text": f"Current date/time → {out}"})
|
|
8402
|
-
_log(f"get_current_datetime({timezone!r}) → {out}")
|
|
8419
|
+
_log(f"get_current_datetime({timezone!r}) → {_c(out)}")
|
|
8403
8420
|
_last_obs["text"] = out
|
|
8404
8421
|
return out
|
|
8405
8422
|
|
|
@@ -8436,7 +8453,7 @@ def run_agent_chat(cfg):
|
|
|
8436
8453
|
search_base_url=(cfg.get("searchBaseURL") or "").strip(),
|
|
8437
8454
|
search_model_id=(cfg.get("searchModelId") or "").strip(),
|
|
8438
8455
|
)
|
|
8439
|
-
_log(f"web_search {query
|
|
8456
|
+
_log(f"web_search {_c(query, 60, r=True)} → {_c(result, 80)}")
|
|
8440
8457
|
_last_obs["text"] = result
|
|
8441
8458
|
if result and not result.startswith("Error"):
|
|
8442
8459
|
_gathered.append(result)
|
|
@@ -8466,19 +8483,19 @@ def run_agent_chat(cfg):
|
|
|
8466
8483
|
status, ctype, raw = _fetch_page_impl(url)
|
|
8467
8484
|
if status is None:
|
|
8468
8485
|
msg = f"Error: could not fetch {url}: {raw.decode('utf-8', 'replace')[:200]}"
|
|
8469
|
-
_log(f"fetch_url error {url} → {msg[:80]}")
|
|
8486
|
+
_log(f"fetch_url error {_u(url)} → {msg[:80]}")
|
|
8470
8487
|
_last_obs["text"] = msg
|
|
8471
8488
|
return msg
|
|
8472
8489
|
if isinstance(status, int) and status >= 400:
|
|
8473
8490
|
msg = f"Error: {url} returned HTTP {status}. Try a different URL or use web_search()."
|
|
8474
|
-
_log(f"fetch_url {url} → HTTP {status}")
|
|
8491
|
+
_log(f"fetch_url {_u(url)} → HTTP {status}")
|
|
8475
8492
|
_last_obs["text"] = msg
|
|
8476
8493
|
return msg
|
|
8477
8494
|
# PDFs / binaries don't strip to useful text.
|
|
8478
8495
|
if "application/pdf" in ctype or raw[:5].lstrip().startswith(b"%PDF"):
|
|
8479
8496
|
msg = (f"{url} is a PDF, not an HTML page — fetch_url cannot read PDFs. "
|
|
8480
8497
|
"Tell the user this needs a PDF-reading tool.")
|
|
8481
|
-
_log(f"fetch_url {url} → PDF, cannot read")
|
|
8498
|
+
_log(f"fetch_url {_u(url)} → PDF, cannot read")
|
|
8482
8499
|
_last_obs["text"] = msg
|
|
8483
8500
|
return msg
|
|
8484
8501
|
# 8000, not the 3000 default: a research page's real content (fixture tables,
|
|
@@ -8488,7 +8505,7 @@ def run_agent_chat(cfg):
|
|
|
8488
8505
|
text = (_html_to_text(raw.decode("utf-8", errors="replace"), limit=8000)
|
|
8489
8506
|
or "(page had no readable text)")
|
|
8490
8507
|
out = f"{url}\n{text}"
|
|
8491
|
-
_log(f"fetch_url {url} → {len(text)} chars")
|
|
8508
|
+
_log(f"fetch_url {_u(url)} → {len(text)} chars")
|
|
8492
8509
|
_last_obs["text"] = out
|
|
8493
8510
|
_gathered.append(out)
|
|
8494
8511
|
return out
|
|
@@ -8506,7 +8523,7 @@ def run_agent_chat(cfg):
|
|
|
8506
8523
|
"""
|
|
8507
8524
|
_emit({"type": "step", "text": f"Calculating → {expression[:80]}"})
|
|
8508
8525
|
result = _calc_impl(expression)
|
|
8509
|
-
_log(f"calculate({expression
|
|
8526
|
+
_log(f"calculate({_c(expression, r=True)}) → {_c(result, 80)}")
|
|
8510
8527
|
_last_obs["text"] = result
|
|
8511
8528
|
return result
|
|
8512
8529
|
|
|
@@ -8531,7 +8548,7 @@ def run_agent_chat(cfg):
|
|
|
8531
8548
|
if email_allow and not _email_allowed(to_addr, email_allow):
|
|
8532
8549
|
msg = (f"Error: {to_addr} is not in the allowed recipients list — refusing to "
|
|
8533
8550
|
"send. Add it to the allow-list in Settings if this is intended.")
|
|
8534
|
-
_log(f"send_email blocked by allow-list: {to_addr}")
|
|
8551
|
+
_log(f"send_email blocked by allow-list: {_c(to_addr)}")
|
|
8535
8552
|
_last_obs["text"] = msg
|
|
8536
8553
|
return msg
|
|
8537
8554
|
preview = (
|
|
@@ -8544,7 +8561,7 @@ def run_agent_chat(cfg):
|
|
|
8544
8561
|
# shown — otherwise just preview (never send on the first call).
|
|
8545
8562
|
if not _email_confirm_pending():
|
|
8546
8563
|
_emit({"type": "step", "text": f"Email preview → {to_addr} (awaiting confirm)"})
|
|
8547
|
-
_log(f"send_email preview (awaiting confirm) → {to_addr}")
|
|
8564
|
+
_log(f"send_email preview (awaiting confirm) → {_c(to_addr)}")
|
|
8548
8565
|
_last_obs["text"] = preview
|
|
8549
8566
|
return preview
|
|
8550
8567
|
filled = _email_fill_template(email_body_template, {
|
|
@@ -8566,7 +8583,7 @@ def run_agent_chat(cfg):
|
|
|
8566
8583
|
out = f"✅ Email sent to {to_addr} (Subject: {subject or '(no subject)'})."
|
|
8567
8584
|
else:
|
|
8568
8585
|
out = f"Email send failed ({first}). Check the email API settings."
|
|
8569
|
-
_log(f"send_email {to_addr} → {first}")
|
|
8586
|
+
_log(f"send_email {_c(to_addr)} → {_c(first)}")
|
|
8570
8587
|
_last_obs["text"] = out
|
|
8571
8588
|
return out
|
|
8572
8589
|
|
|
@@ -8582,7 +8599,7 @@ def run_agent_chat(cfg):
|
|
|
8582
8599
|
"""
|
|
8583
8600
|
_emit({"type": "step", "text": f"Reading PDF → {url[:80]}"})
|
|
8584
8601
|
result = _extract_pdf_text_impl(url)
|
|
8585
|
-
_log(f"extract_text_from_pdf {url} → {result
|
|
8602
|
+
_log(f"extract_text_from_pdf {_u(url)} → {_c(result, 80)}")
|
|
8586
8603
|
_last_obs["text"] = result
|
|
8587
8604
|
return result
|
|
8588
8605
|
|
|
@@ -8624,7 +8641,7 @@ def run_agent_chat(cfg):
|
|
|
8624
8641
|
or (_pdf_state.get("timeout_pending")
|
|
8625
8642
|
and _pdf_titles_similar(prev["title"], doc_title))
|
|
8626
8643
|
):
|
|
8627
|
-
_log(f"create_pdf retry detected (prev title={prev['title']
|
|
8644
|
+
_log(f"create_pdf retry detected (prev title={_c(prev['title'], r=True)}) "
|
|
8628
8645
|
"→ returning the existing PDF instead of re-rendering")
|
|
8629
8646
|
_emit({"type": "step",
|
|
8630
8647
|
"text": f"PDF already created → {prev['title']}.pdf (reusing link)"})
|
|
@@ -8697,7 +8714,7 @@ def run_agent_chat(cfg):
|
|
|
8697
8714
|
f"Download it here (link valid for a limited time):\n{download_url}"
|
|
8698
8715
|
)
|
|
8699
8716
|
_emit({"type": "step", "text": f"PDF ready → {doc_title}.pdf"})
|
|
8700
|
-
_log(f"create_pdf ok title={doc_title
|
|
8717
|
+
_log(f"create_pdf ok title={_c(doc_title, r=True)} bytes={len(pdf_bytes)}")
|
|
8701
8718
|
_pdf_state["last"] = {
|
|
8702
8719
|
"sig": _pdf_sig(doc_title, text), "title": doc_title, "msg": out,
|
|
8703
8720
|
}
|
|
@@ -9121,12 +9138,12 @@ def run_agent_chat(cfg):
|
|
|
9121
9138
|
raw = getattr(last, "content", "")
|
|
9122
9139
|
if isinstance(raw, list):
|
|
9123
9140
|
raw = " ".join(p.get("text", "") for p in raw if isinstance(p, dict))
|
|
9124
|
-
_log(f"step {step_num} → model input (tail): {
|
|
9141
|
+
_log(f"step {step_num} → model input (tail): {_c(raw, 400)}")
|
|
9125
9142
|
|
|
9126
9143
|
# Log what the model generated (the Python code block).
|
|
9127
9144
|
model_output = getattr(step_log, "model_output", None)
|
|
9128
9145
|
if model_output:
|
|
9129
|
-
_log(f"step {step_num} ← model output: {
|
|
9146
|
+
_log(f"step {step_num} ← model output: {_c(model_output, 400)}")
|
|
9130
9147
|
|
|
9131
9148
|
try:
|
|
9132
9149
|
text = _summarise_step(step_log)
|
|
@@ -9153,7 +9170,7 @@ def run_agent_chat(cfg):
|
|
|
9153
9170
|
if not text or text.rstrip().endswith("| →"):
|
|
9154
9171
|
_log(f"step {step_num} (empty obs, skipped)")
|
|
9155
9172
|
return
|
|
9156
|
-
_log(f"step {step_num}: {text
|
|
9173
|
+
_log(f"step {step_num}: {_c(text, 200)}")
|
|
9157
9174
|
_emit({"type": "step", "text": text})
|
|
9158
9175
|
|
|
9159
9176
|
# Wrap the model so we can see EXACTLY what smolagents posts to the model
|
|
@@ -9928,6 +9945,7 @@ def run_agent_chat(cfg):
|
|
|
9928
9945
|
"text": "Coding model is busy (429) — retrying…"})
|
|
9929
9946
|
_log(f"generate {kind} error (attempt {attempt}/{max_attempts}): "
|
|
9930
9947
|
f"{_clip(emsg, 160)} — retrying in {delay:.0f}s")
|
|
9948
|
+
_DUMP_SEEN["n"] = 0 # the retried request is logged in full (#249)
|
|
9931
9949
|
time.sleep(delay)
|
|
9932
9950
|
elif timed_out:
|
|
9933
9951
|
# Out of attempts on OUR deadline. Nothing about this is a
|
|
@@ -10351,8 +10369,11 @@ def run_agent_chat(cfg):
|
|
|
10351
10369
|
_log(f"/no_think inject error: {e}")
|
|
10352
10370
|
try:
|
|
10353
10371
|
msgs = ck.get("messages", []) or []
|
|
10354
|
-
|
|
10372
|
+
_start, _suffix = _request_dump_range(len(msgs))
|
|
10373
|
+
_log(f"=== MODEL REQUEST: {len(msgs)} message(s) sent to the model{_suffix} ===")
|
|
10355
10374
|
for i, m in enumerate(msgs):
|
|
10375
|
+
if i < _start:
|
|
10376
|
+
continue
|
|
10356
10377
|
role = m.get("role") if isinstance(m, dict) else getattr(m, "role", "?")
|
|
10357
10378
|
content = (
|
|
10358
10379
|
m.get("content") if isinstance(m, dict)
|
|
@@ -10366,7 +10387,7 @@ def run_agent_chat(cfg):
|
|
|
10366
10387
|
else:
|
|
10367
10388
|
text = str(content)
|
|
10368
10389
|
text = text.replace("\n", " ")
|
|
10369
|
-
_log(f" [{i}] {role} ({len(text)} chars): {text
|
|
10390
|
+
_log(f" [{i}] {role} ({len(text)} chars): {_c(text, 600)}")
|
|
10370
10391
|
_log("=== END MODEL REQUEST ===")
|
|
10371
10392
|
except Exception as e: # noqa: BLE001
|
|
10372
10393
|
_log(f"MODEL REQUEST log error: {e}")
|
|
@@ -10426,13 +10447,13 @@ def run_agent_chat(cfg):
|
|
|
10426
10447
|
)
|
|
10427
10448
|
if isinstance(out, str) and out.startswith("✅"):
|
|
10428
10449
|
return ChatMessage(role=MessageRole.ASSISTANT, content=out)
|
|
10429
|
-
_log(f"deterministic create_pdf did not succeed: {
|
|
10450
|
+
_log(f"deterministic create_pdf did not succeed: {_c(out, 120)}")
|
|
10430
10451
|
except Exception as e: # noqa: BLE001
|
|
10431
10452
|
_log(f"deterministic create_pdf error: {type(e).__name__}: {e}")
|
|
10432
10453
|
_maxsteps_partial["hit"] = True
|
|
10433
10454
|
text = (_last_obs.get("text") or "").strip()
|
|
10434
10455
|
if text:
|
|
10435
|
-
_log(f"max-steps fallback (last tool obs) → {text
|
|
10456
|
+
_log(f"max-steps fallback (last tool obs) → {_c(text, 80)}")
|
|
10436
10457
|
# Summarize WHAT WAS TRIED + the current blocker, not just the last tool
|
|
10437
10458
|
# observation (task #79): a bare last obs ('Stopped npm run dev') reads as a
|
|
10438
10459
|
# non-answer and hides the loop the run got stuck in. Build a compact bullet
|
|
@@ -11067,7 +11088,7 @@ def run_agent_chat(cfg):
|
|
|
11067
11088
|
topk = scored[: (k if k and k > 0 else rag_top_k)]
|
|
11068
11089
|
# Log WHAT matched and HOW WELL: a search that returns junk looks identical to
|
|
11069
11090
|
# one that returns nothing useful unless the scores and files are visible.
|
|
11070
|
-
_log(f"RAG search {query
|
|
11091
|
+
_log(f"RAG search {_c(query, 60, r=True)} over {len(chunks)} chunk(s) "
|
|
11071
11092
|
f"in {_t.time() - _s0:.1f}s → top {len(topk)}: "
|
|
11072
11093
|
+ ", ".join(f"{c.get('file','?')}({score:.2f})" for score, c in topk))
|
|
11073
11094
|
if topk and topk[0][0] < 0.25:
|
|
@@ -11720,7 +11741,7 @@ def run_agent_chat(cfg):
|
|
|
11720
11741
|
# alternative cost two hour-long turns and a false report to the user.
|
|
11721
11742
|
_shell_err = _shell_syntax_error(command, argv)
|
|
11722
11743
|
if _shell_err:
|
|
11723
|
-
_log(f"run_command refused shell syntax: {_clip(command, 120)}")
|
|
11744
|
+
_log(f"run_command refused shell syntax: {_c(_clip(command, 120))}")
|
|
11724
11745
|
_last_obs["text"] = _shell_err
|
|
11725
11746
|
return _shell_err
|
|
11726
11747
|
# Allow-by-default policy. Match on the runner's basename too, so
|
|
@@ -12309,7 +12330,7 @@ def run_agent_chat(cfg):
|
|
|
12309
12330
|
_retr = _retrieval_summary(_SEARCH_TALLY, _RAG_STATE.get("s", ""), True, _SEARCH_LOG)
|
|
12310
12331
|
if _retr:
|
|
12311
12332
|
_emit({"type": "step", "text": _retr})
|
|
12312
|
-
_log(f"retrieval summary: {_retr}")
|
|
12333
|
+
_log(f"retrieval summary: {_c(_retr)}")
|
|
12313
12334
|
_emit({"type": "step", "text": "Composing answer…"})
|
|
12314
12335
|
final_text = _strip_tool_tags(str(result).strip()) or "[No result]"
|
|
12315
12336
|
_log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
|
|
@@ -12434,6 +12455,56 @@ def _log(text: str):
|
|
|
12434
12455
|
|
|
12435
12456
|
|
|
12436
12457
|
_CLIENT_MODE = False # task #134: agent runs here, models live on another machine
|
|
12458
|
+
_LOG_PRODUCTION = False # task #249: set from cfg.logProfile; False = log as always
|
|
12459
|
+
# How many messages of THIS run's model requests are already in the log (task #249, production
|
|
12460
|
+
# only). One python process runs one job, so module state is per turn.
|
|
12461
|
+
_DUMP_SEEN = {"n": 0}
|
|
12462
|
+
|
|
12463
|
+
|
|
12464
|
+
def _c(text, limit=None, r=False) -> str:
|
|
12465
|
+
"""Conversation CONTENT inside a log line (task #249).
|
|
12466
|
+
|
|
12467
|
+
Content = the user's words, the model's words, and what tools were given or returned (search
|
|
12468
|
+
queries, expressions, commands, email addresses, page text, observations, document titles).
|
|
12469
|
+
Under the production profile (the account has no "Enable Full Log") it becomes a placeholder
|
|
12470
|
+
that keeps only the size: `<content, 2227 chars>`. Under Full Log it is the text, clipped to
|
|
12471
|
+
`limit` as before, and repr()'d when `r` (what the old `!r` did).
|
|
12472
|
+
|
|
12473
|
+
MARKED AT THE CALL SITE, never filtered afterwards: a pattern that tries to spot content in
|
|
12474
|
+
finished lines misses one eventually, and one miss is a leak.
|
|
12475
|
+
"""
|
|
12476
|
+
t = "" if text is None else str(text)
|
|
12477
|
+
if _LOG_PRODUCTION:
|
|
12478
|
+
return f"<content, {len(t)} chars>"
|
|
12479
|
+
t = t[:limit] if limit else t
|
|
12480
|
+
return repr(t) if r else t
|
|
12481
|
+
|
|
12482
|
+
|
|
12483
|
+
def _u(url, limit=None) -> str:
|
|
12484
|
+
"""A URL inside a log line (task #249): the host only under production — a path or a query
|
|
12485
|
+
string can carry a token or the user's own data — and the URL as before under Full Log."""
|
|
12486
|
+
t = "" if url is None else str(url)
|
|
12487
|
+
if _LOG_PRODUCTION:
|
|
12488
|
+
m = re.match(r"^([a-z][a-z0-9+.-]*://[^/?#]+)", t, re.I)
|
|
12489
|
+
return (m.group(1) + "/…") if m else _c(t)
|
|
12490
|
+
return t[:limit] if limit else t
|
|
12491
|
+
|
|
12492
|
+
|
|
12493
|
+
def _request_dump_range(n_msgs: int) -> tuple:
|
|
12494
|
+
"""Which messages of a model request to log: (start, header_suffix) — task #249.
|
|
12495
|
+
|
|
12496
|
+
MEASURED: this dump was 94% of a 161 MB worker.log, because every step re-logged EVERY message
|
|
12497
|
+
(the agent's memory only grows), so a turn cost O(steps²). In production each step logs only
|
|
12498
|
+
the messages added since the last request. The whole list is logged again when there is no
|
|
12499
|
+
earlier dump, when the list SHRANK (older steps trimmed or compacted — the old indices no
|
|
12500
|
+
longer line up), and on the request after an error (_DUMP_SEEN is reset there). Full Log logs
|
|
12501
|
+
everything, every step, exactly as before.
|
|
12502
|
+
"""
|
|
12503
|
+
prev = _DUMP_SEEN["n"]
|
|
12504
|
+
_DUMP_SEEN["n"] = n_msgs
|
|
12505
|
+
if not _LOG_PRODUCTION or prev <= 0 or n_msgs < prev:
|
|
12506
|
+
return 0, ""
|
|
12507
|
+
return prev, f" — {n_msgs - prev} new; [0]..[{prev - 1}] were logged at an earlier step"
|
|
12437
12508
|
_EMIT_LOCK = threading.Lock()
|
|
12438
12509
|
|
|
12439
12510
|
|
package/gu-cli.mjs
CHANGED
|
@@ -3761,6 +3761,9 @@ async function runAgentChatJob(job) {
|
|
|
3761
3761
|
// Session opt-in (task #80): auto-extend past the step budget without re-prompting
|
|
3762
3762
|
// (the REPL sets this once the user picks "yes, don't ask again").
|
|
3763
3763
|
alwaysExtendOnMaxStep: payload?.alwaysExtendOnMaxStep === true,
|
|
3764
|
+
// What the agent may log (task #249): "production" = no conversation content, and the
|
|
3765
|
+
// model-request dump only what is new per step. "full" = as it always has.
|
|
3766
|
+
logProfile: logPolicy?.profile() ?? "full",
|
|
3764
3767
|
// Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
|
|
3765
3768
|
autoTest: payload?.autoTest === true,
|
|
3766
3769
|
// Deploy target chosen via the terminal /server picker (task #69). Host/user only.
|
|
@@ -5033,6 +5036,50 @@ async function pollOnce() {
|
|
|
5033
5036
|
}
|
|
5034
5037
|
}
|
|
5035
5038
|
|
|
5039
|
+
/**
|
|
5040
|
+
* WHAT THIS MACHINE LOGS (task #249). Read from the account's API at startup and every 5 minutes;
|
|
5041
|
+
* "production" (the default once the setting exists) timestamps every line, rotates worker.log and
|
|
5042
|
+
* prunes run-logs/, and tells each agent run to log no conversation content. "full" (Enable Full
|
|
5043
|
+
* Log, or the setting not readable yet) is exactly today's logging. See log-policy.mjs.
|
|
5044
|
+
*/
|
|
5045
|
+
let logPolicy = null;
|
|
5046
|
+
async function startLogPolicy() {
|
|
5047
|
+
try {
|
|
5048
|
+
const lp = await import("./log-policy.mjs");
|
|
5049
|
+
const { LOG_FILE } = await import("./daemon-control.mjs");
|
|
5050
|
+
logPolicy = lp.createLogPolicy({
|
|
5051
|
+
apiBase,
|
|
5052
|
+
workerKey,
|
|
5053
|
+
onChange: (profile) =>
|
|
5054
|
+
console.log(
|
|
5055
|
+
profile === "production"
|
|
5056
|
+
? "[gu-worker] log profile: production — Full Log is off for this account (no conversation content, timestamps, rotation)"
|
|
5057
|
+
: "[gu-worker] log profile: full — Enable Full Log is on for this account"
|
|
5058
|
+
),
|
|
5059
|
+
});
|
|
5060
|
+
lp.installTimestamps(() => logPolicy.profile() === "production");
|
|
5061
|
+
await logPolicy.refresh();
|
|
5062
|
+
const runLogs = join(homedir(), ".gonext", "run-logs");
|
|
5063
|
+
const housekeep = async () => {
|
|
5064
|
+
if (logPolicy.profile() !== "production") return;
|
|
5065
|
+
await lp.rotateIfLarge(LOG_FILE);
|
|
5066
|
+
await lp.pruneRunLogs(runLogs);
|
|
5067
|
+
};
|
|
5068
|
+
await housekeep();
|
|
5069
|
+
let ticks = 0;
|
|
5070
|
+
setInterval(() => {
|
|
5071
|
+
ticks++;
|
|
5072
|
+
void (async () => {
|
|
5073
|
+
if (ticks % Math.round(lp.REFRESH_MS / 60_000) === 0) await logPolicy.refresh();
|
|
5074
|
+
await housekeep();
|
|
5075
|
+
})().catch(() => {});
|
|
5076
|
+
}, 60_000).unref();
|
|
5077
|
+
} catch (e) {
|
|
5078
|
+
// Never fatal: without a policy the worker keeps today's logging.
|
|
5079
|
+
console.log(`[gu-worker] log policy unavailable (${e?.message ?? e}) — logging as before`);
|
|
5080
|
+
}
|
|
5081
|
+
}
|
|
5082
|
+
|
|
5036
5083
|
async function main() {
|
|
5037
5084
|
// Task #127, Phase 2: record our own pidfile so `status`/`stop` find us whether we were
|
|
5038
5085
|
// launched by `start` (detached), by the REPL's auto-ensure, or run in the foreground.
|
|
@@ -5060,6 +5107,7 @@ async function main() {
|
|
|
5060
5107
|
/* pidfile is a convenience; polling works without it */
|
|
5061
5108
|
}
|
|
5062
5109
|
MY_INSTANCE_ID = await (await import("./daemon-control.mjs")).getInstanceId();
|
|
5110
|
+
await startLogPolicy();
|
|
5063
5111
|
console.log(
|
|
5064
5112
|
`[gu-worker] polling ${apiBase} every ${pollMs}ms (local_health concurrency=${localHealthConcurrency}` +
|
|
5065
5113
|
`, agent concurrency=${agentConcurrency})` +
|
package/gu-repl.mjs
CHANGED
|
@@ -47,6 +47,9 @@ import {
|
|
|
47
47
|
promptTotal,
|
|
48
48
|
resumeTask,
|
|
49
49
|
isResumeWord,
|
|
50
|
+
isPendingQuestion,
|
|
51
|
+
interruptStatus,
|
|
52
|
+
messagesForModel,
|
|
50
53
|
retryAdvice,
|
|
51
54
|
classifyTurnFailure,
|
|
52
55
|
budgetMinutes,
|
|
@@ -4405,6 +4408,16 @@ async function updateSessionFile(cwd, mutate) {
|
|
|
4405
4408
|
return queued;
|
|
4406
4409
|
}
|
|
4407
4410
|
|
|
4411
|
+
/** Tag the unanswered question with how its turn ended and persist it (task #247). */
|
|
4412
|
+
async function markInterrupted(cwd, history, task, how) {
|
|
4413
|
+
const last = history[history.length - 1];
|
|
4414
|
+
if (!isPendingQuestion(history, task)) return;
|
|
4415
|
+
const status = interruptStatus(how);
|
|
4416
|
+
if (!status) return;
|
|
4417
|
+
last.status = status;
|
|
4418
|
+
await saveSession(cwd, history, { unfinishedTask: sessionUnfinishedTask || task });
|
|
4419
|
+
}
|
|
4420
|
+
|
|
4408
4421
|
async function rememberUnfinishedTask(cwd, task) {
|
|
4409
4422
|
// NEVER remember a resume word as the task (task #201). The invariant is enforced at the
|
|
4410
4423
|
// point of WRITING rather than at each of the three failure paths that call this: storing
|
|
@@ -4789,7 +4802,9 @@ async function saveSessionSkip(cwd, key, on) {
|
|
|
4789
4802
|
}
|
|
4790
4803
|
}
|
|
4791
4804
|
|
|
4792
|
-
|
|
4805
|
+
// `unfinishedTask` overrides what goes to DISK only: the question saved before its turn runs
|
|
4806
|
+
// (task #247) must read as pending there, while the in-memory resume stays one-shot.
|
|
4807
|
+
async function saveSession(cwd, history, { unfinishedTask = sessionUnfinishedTask } = {}) {
|
|
4793
4808
|
try {
|
|
4794
4809
|
const trimmed = history.slice(-MAX_PERSISTED_MESSAGES);
|
|
4795
4810
|
// MERGE, DO NOT REPLACE. This used to write a whole fresh object, which silently dropped
|
|
@@ -4806,7 +4821,7 @@ async function saveSession(cwd, history) {
|
|
|
4806
4821
|
maxCodeTokens: sessionMaxCodeTokens, // peak per-request tokens (task #84)
|
|
4807
4822
|
ragStorage: sessionRagStorage, // where this folder's RAG index lives (#133/#180)
|
|
4808
4823
|
showThought: sessionShowThought, // print the model's reasoning (task #148)
|
|
4809
|
-
unfinishedTask
|
|
4824
|
+
unfinishedTask, // what a bare "continue" resumes (#150/#189)
|
|
4810
4825
|
// The /model pick, so it survives an exit (see loadSessionCoding). Empty means "the
|
|
4811
4826
|
// account default" and is written as such rather than omitted, so choosing the
|
|
4812
4827
|
// default again CLEARS a previous override instead of leaving it stored.
|
|
@@ -6333,6 +6348,14 @@ function listJsonFiles(dir) {
|
|
|
6333
6348
|
}
|
|
6334
6349
|
}
|
|
6335
6350
|
|
|
6351
|
+
/** The email of the account this worker key belongs to, or "" (older API, none on record, offline). */
|
|
6352
|
+
let accountEmailPromise = null;
|
|
6353
|
+
async function fetchAccountEmail() {
|
|
6354
|
+
const s = await fetchWorkerSettings();
|
|
6355
|
+
const e = typeof s?.userEmail === "string" ? s.userEmail.trim() : "";
|
|
6356
|
+
return /^[^\s@]+@[^\s@]+$/.test(e) ? e : "";
|
|
6357
|
+
}
|
|
6358
|
+
|
|
6336
6359
|
async function fetchWorkerSettings() {
|
|
6337
6360
|
try {
|
|
6338
6361
|
const r = await fetch(`${apiBase}/api/worker/settings`, {
|
|
@@ -8859,7 +8882,8 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
8859
8882
|
// cwd = this terminal's folder → agent puts download/unzip output here (when it's
|
|
8860
8883
|
// a registered workspace). codingModelOverride = the /model choice for this session.
|
|
8861
8884
|
body: JSON.stringify({
|
|
8862
|
-
|
|
8885
|
+
// Earlier questions that were stopped or failed carry their status tag (task #247).
|
|
8886
|
+
messages: messagesForModel(history),
|
|
8863
8887
|
cwd: resolve(process.cwd()),
|
|
8864
8888
|
// The terminal can show a Yes/No picker → the agent PAUSES on a risky command and
|
|
8865
8889
|
// asks instead of hard-blocking it (interactive command-approval gate).
|
|
@@ -8979,6 +9003,9 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
8979
9003
|
let pollFailures = 0;
|
|
8980
9004
|
const MAX_POLL_FAILURES = 6; // ~6 tries with backoff before conceding the follow
|
|
8981
9005
|
let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
|
|
9006
|
+
// Sticky: the agent RAN, so the coding model was called ("thinking…" was on screen). A
|
|
9007
|
+
// Ctrl+C after this point is "stopped", never "changed my mind" (task #247).
|
|
9008
|
+
let reachedModel = false;
|
|
8982
9009
|
let answerShownLive = false; // plain-reply content already printed — don't re-print it
|
|
8983
9010
|
// True once we've SPECIFICALLY seen "→ Chat reply" (the plain-reply/trivial-chat
|
|
8984
9011
|
// branch), as opposed to "→ Agent mode…" (the tool-use branch). Only the plain-reply
|
|
@@ -9825,6 +9852,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
9825
9852
|
continue;
|
|
9826
9853
|
}
|
|
9827
9854
|
jobRunning = job.jobStatus === "running";
|
|
9855
|
+
if (jobRunning) reachedModel = true;
|
|
9828
9856
|
// Claimed → the turn's own ticker takes the screen from here (task #140). Also drop the
|
|
9829
9857
|
// pre-turn block for ANY non-pending status, so a job that goes straight to completed
|
|
9830
9858
|
// (a fast plain reply) can't print its answer over it.
|
|
@@ -10047,7 +10075,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
10047
10075
|
// turn (main() drops it from history).
|
|
10048
10076
|
clearStatus();
|
|
10049
10077
|
process.stdout.write("\n" + GUTTER + dim("(cancelled — the agent was stopped)") + "\n");
|
|
10050
|
-
return { text: "", shown: false, cancelled: true };
|
|
10078
|
+
return { text: "", shown: false, cancelled: true, started: reachedModel };
|
|
10051
10079
|
}
|
|
10052
10080
|
if (job.jobStatus === "failed") {
|
|
10053
10081
|
clearStatus();
|
|
@@ -10349,6 +10377,9 @@ async function main() {
|
|
|
10349
10377
|
// Show which models will answer, straight from user settings (also validates auth).
|
|
10350
10378
|
try {
|
|
10351
10379
|
const probe = await probeWithRecovery();
|
|
10380
|
+
// WHICH ACCOUNT, shown under the key in the banner (client mode). Asked for now and read when
|
|
10381
|
+
// the banner is drawn, so startup never waits on it; private mode has no web account to name.
|
|
10382
|
+
accountEmailPromise = isClientExpert() ? Promise.resolve("") : fetchAccountEmail();
|
|
10352
10383
|
// A NEW gu SESSION: where does its scaffold come from? (task #246) Your file ("loaded") or the
|
|
10353
10384
|
// gu service ("fetched" — the refresh the session then reuses). Background: never delays startup.
|
|
10354
10385
|
void checkPromptScaffoldAtStart();
|
|
@@ -10478,6 +10509,11 @@ async function main() {
|
|
|
10478
10509
|
// shown when it is actually routing, and never the credentials — host and user only.
|
|
10479
10510
|
if (bannerProxy) statusRows.push(lbl("proxy") + bannerProxy);
|
|
10480
10511
|
statusRows.push(dim(`key ${workerKey.slice(0, 8)}…`));
|
|
10512
|
+
// Under the key, in client mode: the account this machine signs in as — so a machine paired
|
|
10513
|
+
// to the wrong account is obvious at a glance. Nothing when the API does not say (older API,
|
|
10514
|
+
// or no email on record): an absent line is better than a guessed one.
|
|
10515
|
+
const accountEmail = await (accountEmailPromise ?? Promise.resolve(""));
|
|
10516
|
+
if (accountEmail) statusRows.push(dim(`account ${accountEmail}`));
|
|
10481
10517
|
console.log(renderBanner(statusRows));
|
|
10482
10518
|
}
|
|
10483
10519
|
// Task #127, Phase 3: if the base chat model server isn't answering, offer to set it up
|
|
@@ -11285,10 +11321,32 @@ async function main() {
|
|
|
11285
11321
|
const shown = taskLine.length > 80 ? taskLine.slice(0, 80) + "…" : taskLine;
|
|
11286
11322
|
console.log(GUTTER + dim(`↻ retrying: ${shown}\n`));
|
|
11287
11323
|
}
|
|
11288
|
-
|
|
11324
|
+
// SAVE THE QUESTION BEFORE THE MODEL IS ASKED (task #247). It stays in history however the
|
|
11325
|
+
// turn ends — answered, failed, cancelled, or the terminal killed — because it WAS asked.
|
|
11326
|
+
// It used to be popped on every failure and kept only as a one-shot resume, so "retry" and
|
|
11327
|
+
// "where were we?" reached the model with no history and it said nothing had been asked.
|
|
11328
|
+
// Only the failure NOTICE stays out of history (task #144). A resume, or the same question
|
|
11329
|
+
// typed again after a failure, is already the last entry and is not pushed a second time.
|
|
11330
|
+
if (isPendingQuestion(history, taskLine)) {
|
|
11331
|
+
delete history[history.length - 1].status; // it is the current task again, not a stopped one
|
|
11332
|
+
} else {
|
|
11333
|
+
history.push({ role: "user", content: taskLine });
|
|
11334
|
+
}
|
|
11335
|
+
await saveSession(cwd, history, { unfinishedTask: isResumeWord(taskLine) ? "" : taskLine });
|
|
11289
11336
|
try {
|
|
11290
|
-
const
|
|
11291
|
-
|
|
11337
|
+
const turn = await runAgentTurn(history);
|
|
11338
|
+
// NOT SENT (licence block, or the token prompt declined): runAgentTurn already said so.
|
|
11339
|
+
// Nothing began, and "continue" can send it once the cause is fixed (task #247).
|
|
11340
|
+
if (!turn) {
|
|
11341
|
+
await rememberUnfinishedTask(cwd, taskLine);
|
|
11342
|
+
await markInterrupted(cwd, history, taskLine, "cancelled");
|
|
11343
|
+
continue;
|
|
11344
|
+
}
|
|
11345
|
+
const { text: answer, shown, cancelled, started, detached, inputTokens, outputTokens,
|
|
11346
|
+
turnOutputTokens, elapsedMs } = turn;
|
|
11347
|
+
// Detached: the agent keeps running on the worker, so the question is neither answered
|
|
11348
|
+
// nor stopped — leave it untagged, and say nothing contradicting "the job keeps running".
|
|
11349
|
+
if (detached) continue;
|
|
11292
11350
|
// Feed the bottom bar's token row (task #104). Assigned for EVERY turn — including one
|
|
11293
11351
|
// that ended without an answer — so the row never shows a stale count from an older
|
|
11294
11352
|
// turn. It replaces the print-once-per-turn footer that used to live here: the bar is
|
|
@@ -11302,7 +11360,9 @@ async function main() {
|
|
|
11302
11360
|
// it as one and DROP it — persisting it would re-send "⚠️ your account is suspended" to
|
|
11303
11361
|
// the model as an assistant turn for the rest of the session (task #144).
|
|
11304
11362
|
if (answer && answer.startsWith(TURN_FAILED_PREFIX)) {
|
|
11305
|
-
history
|
|
11363
|
+
// The question stays in history (saved before the turn, task #247); only this notice
|
|
11364
|
+
// is kept out of it. Its status says it was not done, and still stands.
|
|
11365
|
+
await markInterrupted(cwd, history, taskLine, "failed");
|
|
11306
11366
|
if (_lineQueue.length > 0) _lineQueue.length = 0;
|
|
11307
11367
|
// …but REMEMBER it, so "continue" retries the real question once the cause is fixed
|
|
11308
11368
|
// (typically /model to a working coder). Deliberately not the same thing as putting it
|
|
@@ -11357,7 +11417,6 @@ async function main() {
|
|
|
11357
11417
|
registerCopyBlock(block.rows, plain, 1);
|
|
11358
11418
|
}
|
|
11359
11419
|
} else {
|
|
11360
|
-
history.pop(); // aborted/failed/cancelled turn — don't poison the next request
|
|
11361
11420
|
// A turn that ended WITHOUT an answer (Ctrl+C cancel, or an abort/failure) must not
|
|
11362
11421
|
// silently launch another turn. Any keystrokes the user typed DURING the dead turn
|
|
11363
11422
|
// sit in `_lineQueue` — they land there (not ignored) whenever `following` was still
|
|
@@ -11377,13 +11436,17 @@ async function main() {
|
|
|
11377
11436
|
// next bare "continue" resumes it — even if it was cancelled during step 1,
|
|
11378
11437
|
// before any worker-side checkpoint step was written.
|
|
11379
11438
|
await rememberUnfinishedTask(cwd, taskLine);
|
|
11439
|
+
// Stopped AFTER the model was called = too slow or stuck, never a change of mind: the
|
|
11440
|
+
// request still stands (task #247, the user's rule). Before that, nothing had begun.
|
|
11441
|
+
await markInterrupted(cwd, history, taskLine, started ? "stopped" : "cancelled");
|
|
11380
11442
|
} else {
|
|
11443
|
+
await markInterrupted(cwd, history, taskLine, "failed");
|
|
11381
11444
|
// The turn ended cleanly but empty — no exception to summarize, so say exactly that.
|
|
11382
11445
|
printTurnError("the run ended without an answer (aborted or failed)");
|
|
11383
11446
|
}
|
|
11384
11447
|
}
|
|
11385
11448
|
} catch (err) {
|
|
11386
|
-
history
|
|
11449
|
+
await markInterrupted(cwd, history, taskLine, "failed");
|
|
11387
11450
|
// Same reasoning as the no-answer branch (bug #92): don't let input buffered during a
|
|
11388
11451
|
// turn that just threw auto-fire the next one.
|
|
11389
11452
|
if (_lineQueue.length > 0) _lineQueue.length = 0;
|
package/log-policy.mjs
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT THIS MACHINE LOGS — "Enable Full Log" per account (task #249).
|
|
3
|
+
*
|
|
4
|
+
* Two profiles:
|
|
5
|
+
* · "full" — exactly what gu has always logged. For accounts an administrator turned
|
|
6
|
+
* "Enable Full Log" ON for (developers; a customer while their issue is traced).
|
|
7
|
+
* · "production" — the default for every account: the model-request dump reduced to what is NEW
|
|
8
|
+
* per step, a timestamp on every worker.log line, worker.log rotated, run-logs/
|
|
9
|
+
* kept 7 days / 200 files, and no conversation content.
|
|
10
|
+
*
|
|
11
|
+
* THE ROLLOUT GUARD. Only an explicit `fullLog: false` from the account's API selects "production".
|
|
12
|
+
* Never asked yet, an API that does not know the route (404 — an older gu service), a network
|
|
13
|
+
* failure with nothing cached: all keep "full", i.e. TODAY's behaviour. Nothing changes for anyone
|
|
14
|
+
* until the setting exists and has been read — the owner's rule, so a developer machine does not
|
|
15
|
+
* silently lose its logs mid-development. Once read, the answer is cached on disk, and a later
|
|
16
|
+
* failure keeps the last answer (a network hiccup never flips a machine either way).
|
|
17
|
+
*/
|
|
18
|
+
import { copyFile, readdir, readFile, rename, stat, truncate, unlink, writeFile } from "node:fs/promises";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
export const POLICY_FILE = join(homedir(), ".gonext", "log-policy.json");
|
|
23
|
+
export const REFRESH_MS = 5 * 60_000;
|
|
24
|
+
export const ROTATE = { maxBytes: 20 * 1024 * 1024, keep: 3 };
|
|
25
|
+
export const RUN_LOGS = { maxAgeMs: 7 * 86_400_000, maxFiles: 200 };
|
|
26
|
+
|
|
27
|
+
/** The profile a known-or-unknown `fullLog` selects. Only an explicit false is production. */
|
|
28
|
+
export function profileFor(fullLog) {
|
|
29
|
+
return fullLog === false ? "production" : "full";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Ask this machine's API. → { ok: true, fullLog } | { ok: false, status } — never throws. */
|
|
33
|
+
export async function fetchLogPolicy({ apiBase, workerKey, fetchImpl = fetch, timeoutMs = 8000 }) {
|
|
34
|
+
const base = String(apiBase ?? "").replace(/\/+$/, "");
|
|
35
|
+
if (!base || !workerKey) return { ok: false, status: 0 };
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetchImpl(`${base}/api/worker/log-policy`, {
|
|
38
|
+
headers: { "X-Worker-Key": workerKey },
|
|
39
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok) return { ok: false, status: res.status };
|
|
42
|
+
const body = await res.json().catch(() => null);
|
|
43
|
+
return typeof body?.fullLog === "boolean" ? { ok: true, fullLog: body.fullLog } : { ok: false, status: 502 };
|
|
44
|
+
} catch {
|
|
45
|
+
return { ok: false, status: 0 };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function readCachedPolicy(file = POLICY_FILE) {
|
|
50
|
+
try {
|
|
51
|
+
const s = JSON.parse(await readFile(file, "utf8"));
|
|
52
|
+
return typeof s?.fullLog === "boolean" ? s.fullLog : null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function writeCachedPolicy(file, fullLog, now) {
|
|
59
|
+
try {
|
|
60
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
61
|
+
await writeFile(tmp, JSON.stringify({ fullLog, fetchedAt: new Date(now).toISOString() }) + "\n", "utf8");
|
|
62
|
+
await rename(tmp, file);
|
|
63
|
+
} catch {
|
|
64
|
+
/* the cache is a convenience: the next refresh asks again */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The machine's live policy: `profile()` answers now, `refresh()` asks the API (startup, then every
|
|
70
|
+
* REFRESH_MS). `onChange(profile)` fires when the profile actually changes.
|
|
71
|
+
*/
|
|
72
|
+
export function createLogPolicy({ apiBase, workerKey, file = POLICY_FILE, fetchImpl = fetch, now = () => Date.now(), onChange = () => {} }) {
|
|
73
|
+
let fullLog = null; // null = unknown
|
|
74
|
+
let loaded = false;
|
|
75
|
+
const set = (value) => {
|
|
76
|
+
const before = profileFor(fullLog);
|
|
77
|
+
fullLog = value;
|
|
78
|
+
const after = profileFor(fullLog);
|
|
79
|
+
if (after !== before) onChange(after);
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
profile: () => profileFor(fullLog),
|
|
83
|
+
fullLog: () => fullLog,
|
|
84
|
+
async refresh() {
|
|
85
|
+
if (!loaded) {
|
|
86
|
+
loaded = true;
|
|
87
|
+
const cached = await readCachedPolicy(file);
|
|
88
|
+
if (cached !== null) set(cached);
|
|
89
|
+
}
|
|
90
|
+
const r = await fetchLogPolicy({ apiBase, workerKey, fetchImpl });
|
|
91
|
+
if (r.ok) {
|
|
92
|
+
set(r.fullLog);
|
|
93
|
+
await writeCachedPolicy(file, r.fullLog, now());
|
|
94
|
+
}
|
|
95
|
+
return r;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** "2026-09-24 06:31:02" — local time, sortable, and what a person asks the log about. */
|
|
101
|
+
export function stamp(date = new Date()) {
|
|
102
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
103
|
+
return (
|
|
104
|
+
`${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())} ` +
|
|
105
|
+
`${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Put the time in front of EVERY line of a message — continuation lines too, so any line can be placed. */
|
|
110
|
+
export function stampLines(text, date = new Date()) {
|
|
111
|
+
const t = stamp(date);
|
|
112
|
+
return String(text)
|
|
113
|
+
.split("\n")
|
|
114
|
+
.map((line) => (line ? `${t} ${line}` : line))
|
|
115
|
+
.join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Timestamp everything the process prints, while `enabled()` says so. worker.log IS this process's
|
|
120
|
+
* stdout/stderr (the launcher appends them to the file), so this is the one place every line —
|
|
121
|
+
* [gu-worker] and relayed [gu-agent] alike — passes through.
|
|
122
|
+
*/
|
|
123
|
+
export function installTimestamps(enabled, { out = process.stdout, err = process.stderr, now = () => new Date() } = {}) {
|
|
124
|
+
const wrap = (stream) => {
|
|
125
|
+
const write = stream.write.bind(stream);
|
|
126
|
+
let atLineStart = true;
|
|
127
|
+
stream.write = (chunk, encoding, cb) => {
|
|
128
|
+
if (!enabled() || typeof chunk !== "string" && !Buffer.isBuffer(chunk)) return write(chunk, encoding, cb);
|
|
129
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
|
|
130
|
+
const t = stamp(now());
|
|
131
|
+
let outText = "";
|
|
132
|
+
for (const piece of text.split(/(?<=\n)/)) {
|
|
133
|
+
outText += (atLineStart && piece !== "\n" ? `${t} ` : "") + piece;
|
|
134
|
+
atLineStart = piece.endsWith("\n");
|
|
135
|
+
}
|
|
136
|
+
return write(outText, typeof encoding === "string" ? encoding : undefined, typeof encoding === "function" ? encoding : cb);
|
|
137
|
+
};
|
|
138
|
+
return () => {
|
|
139
|
+
stream.write = write;
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
const undoOut = wrap(out);
|
|
143
|
+
const undoErr = wrap(err);
|
|
144
|
+
return () => {
|
|
145
|
+
undoOut();
|
|
146
|
+
undoErr();
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Rotate `file` once it passes maxBytes: file.2 → file.3, file.1 → file.2, file → file.1, then the
|
|
152
|
+
* file is TRUNCATED IN PLACE. Copy-then-truncate, not rename, because the launcher holds the file
|
|
153
|
+
* open for appending: a renamed file would keep receiving every line. Returns true if it rotated.
|
|
154
|
+
*/
|
|
155
|
+
export async function rotateIfLarge(file, { maxBytes = ROTATE.maxBytes, keep = ROTATE.keep } = {}) {
|
|
156
|
+
let size;
|
|
157
|
+
try {
|
|
158
|
+
size = (await stat(file)).size;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
if (size < maxBytes) return false;
|
|
163
|
+
try {
|
|
164
|
+
await unlink(`${file}.${keep}`).catch(() => {});
|
|
165
|
+
for (let i = keep - 1; i >= 1; i--) await rename(`${file}.${i}`, `${file}.${i + 1}`).catch(() => {});
|
|
166
|
+
await copyFile(file, `${file}.1`);
|
|
167
|
+
await truncate(file, 0);
|
|
168
|
+
return true;
|
|
169
|
+
} catch {
|
|
170
|
+
// Windows can refuse to truncate a file another process holds; try again next time.
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Keep run-logs/ to maxFiles files, none older than maxAgeMs. Returns how many were removed. */
|
|
176
|
+
export async function pruneRunLogs(dir, { maxAgeMs = RUN_LOGS.maxAgeMs, maxFiles = RUN_LOGS.maxFiles, now = Date.now() } = {}) {
|
|
177
|
+
let names;
|
|
178
|
+
try {
|
|
179
|
+
names = await readdir(dir);
|
|
180
|
+
} catch {
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
const files = [];
|
|
184
|
+
for (const name of names) {
|
|
185
|
+
if (!name.endsWith(".log")) continue;
|
|
186
|
+
try {
|
|
187
|
+
const s = await stat(join(dir, name));
|
|
188
|
+
if (s.isFile()) files.push({ name, mtime: s.mtimeMs });
|
|
189
|
+
} catch {
|
|
190
|
+
/* vanished meanwhile */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
files.sort((a, b) => b.mtime - a.mtime); // newest first
|
|
194
|
+
let removed = 0;
|
|
195
|
+
for (const [i, f] of files.entries()) {
|
|
196
|
+
if (i < maxFiles && now - f.mtime <= maxAgeMs) continue;
|
|
197
|
+
try {
|
|
198
|
+
await unlink(join(dir, f.name));
|
|
199
|
+
removed++;
|
|
200
|
+
} catch {
|
|
201
|
+
/* in use or gone */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return removed;
|
|
205
|
+
}
|
|
206
|
+
|
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.730",
|
|
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",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"settings-transfer.mjs",
|
|
57
57
|
"device-login.mjs",
|
|
58
58
|
"daemon-control.mjs",
|
|
59
|
+
"log-policy.mjs",
|
|
59
60
|
"poll-errors.mjs",
|
|
60
61
|
"node-version.mjs",
|
|
61
62
|
"clear-state.mjs",
|
package/terminal-layout.mjs
CHANGED
|
@@ -1059,7 +1059,9 @@ export function thoughtBlockRows(prose, { width = 76, collapsed = true, lines =
|
|
|
1059
1059
|
* with nothing behind it now runs nothing, and `pending` is re-checked here as well — a
|
|
1060
1060
|
* session file poisoned by the old build must not be able to restart the loop.
|
|
1061
1061
|
*/
|
|
1062
|
-
|
|
1062
|
+
// "retry" / "try again" joined in task #247: the reported user typed "retry" after a cancel, it ran
|
|
1063
|
+
// as a new task with no history, and the model answered that nothing had ever been asked.
|
|
1064
|
+
const RESUME_WORDS = /^(continue|resume|go on|keep going|carry on|proceed|retry|try again)\.?$/i;
|
|
1063
1065
|
|
|
1064
1066
|
/** Is this line a bare resume word? Shared so the guards cannot drift from the reader. */
|
|
1065
1067
|
export function isResumeWord(line) {
|
|
@@ -1075,6 +1077,62 @@ export function resumeTask(line, pending) {
|
|
|
1075
1077
|
return { task: real, resumed: true, nothingToResume: false };
|
|
1076
1078
|
}
|
|
1077
1079
|
|
|
1080
|
+
/**
|
|
1081
|
+
* Is `task` already the unanswered last turn of `history`? (task #247)
|
|
1082
|
+
*
|
|
1083
|
+
* The question is saved to history BEFORE its turn runs, and a turn that fails leaves it there.
|
|
1084
|
+
* So when the next turn resumes it ("continue", "retry") or the user types it again, it is
|
|
1085
|
+
* already the last entry, and pushing it again would send the model the same request twice.
|
|
1086
|
+
*/
|
|
1087
|
+
export function isPendingQuestion(history, task) {
|
|
1088
|
+
const last = Array.isArray(history) ? history[history.length - 1] : null;
|
|
1089
|
+
return last?.role === "user" && String(last.content ?? "").trim() === String(task ?? "").trim();
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* How an unanswered question's turn ended, as stored on its history entry (task #247).
|
|
1094
|
+
*
|
|
1095
|
+
* stopped — Ctrl+C AFTER the coding model was called. THE USER'S RULE: that is "too slow or
|
|
1096
|
+
* stuck", never "I changed my mind", so the request still stands.
|
|
1097
|
+
* failed — the backend refused or the turn threw. Also still stands.
|
|
1098
|
+
* cancelled — Ctrl+C before the model was called: nothing began.
|
|
1099
|
+
*/
|
|
1100
|
+
const INTERRUPT_STATUSES = new Set(["stopped", "failed", "cancelled"]);
|
|
1101
|
+
export function interruptStatus(how) {
|
|
1102
|
+
return INTERRUPT_STATUSES.has(how) ? how : "";
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Every tag starts with RECEIVED (the user's rule, task #247): a question that is in history is one
|
|
1106
|
+
// the terminal received from the user. The reported model, seeing no answer, told the user "It looks
|
|
1107
|
+
// like the earlier request didn't come through". It did; only the answer is missing.
|
|
1108
|
+
const INTERRUPT_TAGS = {
|
|
1109
|
+
stopped:
|
|
1110
|
+
"[RECEIVED by the terminal and started, then STOPPED before it finished: the user interrupted " +
|
|
1111
|
+
"it because it was taking too long, NOT because they changed their mind. It still stands and " +
|
|
1112
|
+
"is not finished; some steps may already have run, so check what exists before redoing anything.]",
|
|
1113
|
+
failed:
|
|
1114
|
+
"[RECEIVED by the terminal, but NOT FINISHED: it failed (the model backend errored) before it " +
|
|
1115
|
+
"was answered. It still stands; some steps may already have run.]",
|
|
1116
|
+
cancelled:
|
|
1117
|
+
"[RECEIVED by the terminal, then cancelled by the user before any work began: nothing was done.]",
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* The history as the model should see it (task #247): each earlier unanswered question carries a
|
|
1122
|
+
* one-line tag saying how it ended. Without one, an unanswered question reads exactly like an
|
|
1123
|
+
* answered one, and the model may claim it was done.
|
|
1124
|
+
*
|
|
1125
|
+
* The tag goes only into what is SENT; the stored text stays the user's own words. The LAST entry
|
|
1126
|
+
* is the current task and is never tagged. Returns new objects of {role, content} only.
|
|
1127
|
+
*/
|
|
1128
|
+
export function messagesForModel(history) {
|
|
1129
|
+
if (!Array.isArray(history)) return [];
|
|
1130
|
+
return history.map((m, i) => {
|
|
1131
|
+
const tag = i < history.length - 1 && m?.role === "user" ? INTERRUPT_TAGS[m.status] : "";
|
|
1132
|
+
return { role: m?.role, content: tag ? `${m.content}\n${tag}` : m?.content };
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1078
1136
|
|
|
1079
1137
|
/**
|
|
1080
1138
|
* Commands that still WORK but are no longer offered (task #153).
|