Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10cb4e4075 | ||
|
|
b47a5df1b2 | ||
|
|
d84f87e7bb | ||
|
|
bd3e3c0512 |
@@ -1,3 +1,6 @@
|
|||||||
/target
|
/target
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
# stray build dir (not source)
|
||||||
|
/src/CUBELinux-build/
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ which persists to `$CUBE_NOTES_DIR` (default ~/.cubelinux-notes).
|
|||||||
"""
|
"""
|
||||||
import sys, json, subprocess
|
import sys, json, subprocess
|
||||||
|
|
||||||
CUBE = "/home/CUBELinux/CUBELinux-2/target/release/cube"
|
CUBE = "/home/CUBEdb/target/release/cube"
|
||||||
SERVER_NAME = "cube-notes-mcp"
|
SERVER_NAME = "cube-notes-mcp"
|
||||||
SERVER_VERSION = "0.2.0"
|
SERVER_VERSION = "0.2.0"
|
||||||
PROTOCOL_VERSION = "2024-11-05"
|
PROTOCOL_VERSION = "2024-11-05"
|
||||||
|
|||||||
@@ -486,6 +486,24 @@ pub fn note_command(store: &ConcurrentStore, line: &str) -> Result<String, Strin
|
|||||||
let notes = search_notes(store, &pos, Some(&f));
|
let notes = search_notes(store, &pos, Some(&f));
|
||||||
Ok(format!("search '{pos}':\n{}", format_notes(¬es)))
|
Ok(format!("search '{pos}':\n{}", format_notes(¬es)))
|
||||||
}
|
}
|
||||||
|
"tag" => {
|
||||||
|
let mut parts = rest.split_whitespace();
|
||||||
|
let cs = parts.next().unwrap_or("");
|
||||||
|
let cat = parts.next().unwrap_or("");
|
||||||
|
let coord = crate::commands::parse_coord(cs)
|
||||||
|
.ok_or_else(|| format!("bad coordinate '{cs}' (want C.Z.Y.X)"))?;
|
||||||
|
if cat.is_empty() {
|
||||||
|
return Err("note tag needs <coord> <cat>".to_string());
|
||||||
|
}
|
||||||
|
match store.get_record(&coord) {
|
||||||
|
Some((mut header, body)) => {
|
||||||
|
header.doc_type = Some(format!("{NOTE_DOC_TYPE}:{cat}"));
|
||||||
|
store.put_record(coord, &header, &body);
|
||||||
|
Ok(format!("note {} tagged {cat}", coord_str(coord)))
|
||||||
|
}
|
||||||
|
None => Ok(format!("note {} -> (no note)", coord_str(coord))),
|
||||||
|
}
|
||||||
|
}
|
||||||
"show" => {
|
"show" => {
|
||||||
let cs = rest.trim();
|
let cs = rest.trim();
|
||||||
let coord = crate::commands::parse_coord(cs)
|
let coord = crate::commands::parse_coord(cs)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# CUBE notes ↔ model integration: `cube-notes-agent`
|
||||||
|
|
||||||
|
The CUBELinux-2 note/message-save-path is a durable, searchable log that a small
|
||||||
|
local LLM reads, reviews, and writes back to — a closed loop that is **grounded
|
||||||
|
in CUBE** (the model never invents findings) and **incremental** (each run reviews
|
||||||
|
only what has not been seen).
|
||||||
|
|
||||||
|
## The notes system (`cubesys::notes`)
|
||||||
|
- WordFlags-tagged **CZYX** note log. Each note has `doc_type = note:<category>`
|
||||||
|
(categories like `finding`, `action`, `checkpoint`, `resume`, `task`, …) and an
|
||||||
|
optional **project** association via `linked_records`.
|
||||||
|
- `cube note add|list|search|show`; `cube project list|show|resume|context`.
|
||||||
|
- `cube note tag <coord> <cat>` reclassifies a note in place (Puts are durable;
|
||||||
|
`delete_raw` is not — the checkpoint/delta only records Puts of surviving keys,
|
||||||
|
so a deleted key resurrects from the base snapshot on reload).
|
||||||
|
- Durable `ConcurrentStore` (WAL + delta + checkpoint). Short-lived processes must
|
||||||
|
`store.checkpoint()` before exit or buffered writes are lost.
|
||||||
|
|
||||||
|
## Model ↔ CUBE
|
||||||
|
- `cube-notes-mcp.py` (stdio MCP server) exposes `mcp__cubenotes__note_*` /
|
||||||
|
`project_*` tools to the DeepSeek Harness. The harness registers the mcp-client at
|
||||||
|
the **bundle** layer (`inject: [tools]`); profile-layer rows can't inject the tools
|
||||||
|
service, so the plugin silently never mounts — the bundle registration was the fix
|
||||||
|
that made `mcp__cubenotes__note_*` live.
|
||||||
|
- `tools/cube-notes-agent.py` (headless, cron-driven, no control panel) treats CUBE as
|
||||||
|
the agent's memory:
|
||||||
|
- every action is written as a note (`category=action`) so a run can be replayed;
|
||||||
|
- a review run reads **only uncovered** findings (`category=finding`), asks the model
|
||||||
|
to rank them, then writes a grounded report and marks them covered with a
|
||||||
|
`checkpoint` note — true incremental, non-destructive review;
|
||||||
|
- the report is composed in Python from the finding notes **verbatim**, with a
|
||||||
|
deterministic severity (HIGH/MEDIUM/LOW) and priority order, so no content is
|
||||||
|
invented. Report ⇒ a review `.txt` (`/root/workspace/cube-agent/report.txt`).
|
||||||
|
|
||||||
|
## Success: tracing errors from prior run data saved in CUBE
|
||||||
|
- The findings/action trace already stored in CUBE (from prior runs) is the source of
|
||||||
|
truth. The broad scan surfaced **7 un-categorized logs** that had been logged but
|
||||||
|
never tagged `finding`: `GIT-SHALLOW-CLONE`, `POSTFIX-TLS-CERTS`,
|
||||||
|
`SELINUX-RESTORECON`, `DSH-WEB-EADDRINUSE`, `CONCURRENT-STORE-CHECKPOINT`,
|
||||||
|
`MCP-BUNDLE-LAYER`, `CUBES-TWO-TREES`. See `git log` entry `bd3e3c0`.
|
||||||
|
- Coverage is precise: the report marks exactly the findings it reviewed, so the next
|
||||||
|
run reports only new ones. The notes store is also the harness GUI's CUBE surface
|
||||||
|
(`mcp-cubenotes note_list`), so everything the agent writes is visible and reviewable.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
```
|
||||||
|
python3 tools/cube-notes-agent.py --max-turns 6 --report /path/report.txt
|
||||||
|
```
|
||||||
|
Runs single-instance via `flock`; a cron schedule (e.g. `0 6,18 * * *`) drives it.
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Headless CUBE notes agent — NO control panel. Reads a task from the CUBE
|
||||||
|
notes (a project), uses the cube note tools (note_search/note_list/
|
||||||
|
project_context + read_file), and writes a report. Designed to be driven by
|
||||||
|
cron (e.g. build an error-log report every hour); the interval is the cron
|
||||||
|
schedule, so it is fully configurable without touching the agent.
|
||||||
|
|
||||||
|
Usage: python3 agent.py --project NAME [--max-turns N] [--report PATH]
|
||||||
|
"""
|
||||||
|
import argparse, json, os, subprocess, sys, time
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
|
def _now_ts():
|
||||||
|
return _dt.datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
MODEL = "/home/luulu/models/gemma-3-4b-it-Q4_K_M.gguf"
|
||||||
|
LLAMA = "/home/luulu/llama.cpp/build/bin/llama-server"
|
||||||
|
CUBE = "/home/CUBELinux/CUBELinux-2/target/release/cube"
|
||||||
|
PORT = 8080
|
||||||
|
BASE = f"http://127.0.0.1:{PORT}"
|
||||||
|
LOG = "/root/workspace/cube-agent/llama.log" if os.path.isdir("/root/workspace/cube-agent") else "/tmp/local-agent-llama.log"
|
||||||
|
TRACE_PROJECT = "agent-trace" # every action the agent takes is logged here (replay/tuning)
|
||||||
|
|
||||||
|
def env():
|
||||||
|
e = dict(os.environ)
|
||||||
|
e["CUBE_NOTES_DIR"] = "/root/.cubelinux-notes"
|
||||||
|
return e
|
||||||
|
|
||||||
|
def start_llama():
|
||||||
|
p = subprocess.Popen(
|
||||||
|
[LLAMA, "--model", MODEL, "--host", "127.0.0.1", "--port", str(PORT),
|
||||||
|
"--alias", "gemma-3-4b", "--ctx-size", "8192", "-np", "1",
|
||||||
|
"--temp", "0.2", "--repeat-penalty", "1.1"],
|
||||||
|
stdout=open(LOG, "w"), stderr=subprocess.STDOUT, env=env())
|
||||||
|
import requests
|
||||||
|
for _ in range(180):
|
||||||
|
try:
|
||||||
|
if requests.get(BASE + "/v1/models", timeout=2).status_code == 200:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(2)
|
||||||
|
return p
|
||||||
|
|
||||||
|
def chat(messages, max_tokens=600):
|
||||||
|
import requests
|
||||||
|
r = requests.post(BASE + "/v1/chat/completions", json={
|
||||||
|
"model": "gemma-3-4b", "messages": messages,
|
||||||
|
"max_tokens": max_tokens, "temperature": 0.08}, timeout=600)
|
||||||
|
return r.json()["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
def run_cube(args):
|
||||||
|
try:
|
||||||
|
r = subprocess.run([CUBE] + args, capture_output=True, text=True,
|
||||||
|
env=env(), timeout=30)
|
||||||
|
return (r.stdout or r.stderr).strip()
|
||||||
|
except Exception as e:
|
||||||
|
return f"error: {e}"
|
||||||
|
|
||||||
|
def log_action(step, text):
|
||||||
|
"""Write an agent action to the CUBE notes trace (category=action) so the
|
||||||
|
run can be replayed and fine-tuned."""
|
||||||
|
body = f"step {step}: {text}"[:1600]
|
||||||
|
subprocess.run([CUBE, "note", "add", "--project", TRACE_PROJECT,
|
||||||
|
"--cat", "action", body], capture_output=True, text=True, env=env())
|
||||||
|
|
||||||
|
def open_file(path):
|
||||||
|
try:
|
||||||
|
return open(path).read()[:2000]
|
||||||
|
except Exception as e:
|
||||||
|
return f"error: {e}"
|
||||||
|
|
||||||
|
# ---- incremental coverage (deterministic; offloaded from the small model) ----
|
||||||
|
import re as _re
|
||||||
|
COORD_RE = _re.compile(r'\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b')
|
||||||
|
|
||||||
|
def _coords(text):
|
||||||
|
return set(COORD_RE.findall(text))
|
||||||
|
|
||||||
|
# Bookkeeping categories that are NOT error-log findings (agent trace, coverage
|
||||||
|
# markers, resume checkpoints, test fixtures, design/impl notes, misc).
|
||||||
|
NONLOG_CATS = {"action", "checkpoint", "resume", "task", "debug", "impl", "design", "misc"}
|
||||||
|
# A finding-like title: a SHORT-UPPERCASE-HYPHEN/UNDERSCORE token then ':' or ' — '
|
||||||
|
FLAG_RE = _re.compile(r'\s*([A-Z][A-Z0-9_-]{2,})\s*[:—-]')
|
||||||
|
ERR_KW = ("error", "failed", "failure", "denied", "rejected", "cannot", "cannot be",
|
||||||
|
"must ", "timeout", "times out", "not registered", "broken", "corrupt",
|
||||||
|
"integrity", "permission denied", "selinux", "restorecon", "orphan",
|
||||||
|
"eadrinuse", "shallow", "user_tmp_t", "mcp-client", "mcp plugin")
|
||||||
|
|
||||||
|
def _scan(cat, project=None):
|
||||||
|
"""Run `cube note list`, optionally filtered by category/project. With no cat
|
||||||
|
and no project this scans the WHOLE store (every session), so the agent
|
||||||
|
reviews ALL CUBE error logs, not one project or one category."""
|
||||||
|
args = ["note", "list"]
|
||||||
|
if cat:
|
||||||
|
args += ["--cat", cat]
|
||||||
|
if project:
|
||||||
|
args += ["--project", project]
|
||||||
|
return run_cube(args)
|
||||||
|
|
||||||
|
def _parse_note_line(line):
|
||||||
|
"""-> (coord, cat_or_None, subject) for a `note list` line, or None if the
|
||||||
|
line is not a note (e.g. the 'N note(s):' header)."""
|
||||||
|
m = _re.match(r'\s*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+\[[^\]]+\]', line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
coord = m.group(1)
|
||||||
|
rest = line[m.end():].strip()
|
||||||
|
cat = None
|
||||||
|
cm = _re.match(r'\[([a-z]+)\]\s*', rest)
|
||||||
|
if cm:
|
||||||
|
cat = cm.group(1)
|
||||||
|
rest = rest[cm.end():].strip()
|
||||||
|
return coord, cat, rest
|
||||||
|
|
||||||
|
def _looks_like_finding(subj):
|
||||||
|
"""True if an un-categorized note looks like an error-log finding."""
|
||||||
|
if FLAG_RE.match(subj):
|
||||||
|
return True
|
||||||
|
low = subj.lower()
|
||||||
|
return any(k in low for k in ERR_KW)
|
||||||
|
|
||||||
|
def _log_findings(project=None):
|
||||||
|
"""[(coord, cat, subject)] for every finding-like note in the store, across ALL
|
||||||
|
categories and projects. This includes `finding` notes and any other note with a
|
||||||
|
FLAG-like / error title (the un-categorized logs), while excluding bookkeeping
|
||||||
|
categories (action / checkpoint / resume / task / debug / impl / design / misc)."""
|
||||||
|
out = []
|
||||||
|
for line in _scan(None, project).splitlines():
|
||||||
|
parsed = _parse_note_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
coord, cat, subj = parsed
|
||||||
|
if cat in NONLOG_CATS:
|
||||||
|
continue
|
||||||
|
if cat == "finding" or _looks_like_finding(subj):
|
||||||
|
out.append((coord, cat, subj))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _checkpoint_coords(project=None):
|
||||||
|
"""Own coord (leftmost) of every checkpoint note (optionally in a project)."""
|
||||||
|
coords = []
|
||||||
|
for line in _scan("checkpoint", project).splitlines():
|
||||||
|
m = _re.match(r'\s*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', line)
|
||||||
|
if m:
|
||||||
|
coords.append(m.group(1))
|
||||||
|
return coords
|
||||||
|
|
||||||
|
def _covered(project=None):
|
||||||
|
"""Covered = each checkpoint note's own coord + the coords in its FULL body.
|
||||||
|
(note list only shows the truncated subject, so use note show to read the body.)"""
|
||||||
|
out = set()
|
||||||
|
for c in _checkpoint_coords(project):
|
||||||
|
out.add(c)
|
||||||
|
out |= _coords(run_cube(["note", "show", c]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def uncovered_findings(project=None):
|
||||||
|
covered = _covered(project)
|
||||||
|
un = "".join(f"{coord} {subj}\n" for coord, cat, subj in _log_findings(project) if coord not in covered)
|
||||||
|
return un.strip() or "(no new findings)"
|
||||||
|
|
||||||
|
# Canonical issue key: collapse duplicate representations of the same root cause
|
||||||
|
# (e.g. FK1-SELINUX_RESTORECON vs SELINUX-RESTORECON) so the report dedupes by ISSUE,
|
||||||
|
# not just by coordinate. Prefixes (FK, FK#, FA, FB, FLAG, FIX) are stripped and
|
||||||
|
# separators normalized; a small alias map unifies differently-worded duplicates.
|
||||||
|
_ISSUE_PREFIX_RE = _re.compile(r'^(FK\d*|FA|FB|FLAG|FIX)[-_]?', _re.I)
|
||||||
|
_ISSUE_ALIAS = {
|
||||||
|
"wal_checkpoint": "checkpoint_before_exit",
|
||||||
|
"concurrent_store_checkpoint": "checkpoint_before_exit",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _canonical_issue(flag):
|
||||||
|
f = _ISSUE_PREFIX_RE.sub('', flag)
|
||||||
|
f = _re.sub(r'[-_/\s]+', '_', f).strip('_').lower()
|
||||||
|
return _ISSUE_ALIAS.get(f, f)
|
||||||
|
|
||||||
|
def _uncovered_meta(project=None):
|
||||||
|
"""[(coord, flag, desc)] for the currently-UNCOVERED findings, DEDUPED BY ISSUE.
|
||||||
|
desc is the FULL note body; the most-detailed note per canonical issue is kept."""
|
||||||
|
covered = _covered(project)
|
||||||
|
best = {} # issue -> (coord, flag, desc, len)
|
||||||
|
for coord, cat, subj in _log_findings(project):
|
||||||
|
if coord in covered:
|
||||||
|
continue
|
||||||
|
show = run_cube(["note", "show", coord])
|
||||||
|
body_text = show.split("\n", 1)[1].strip() if "\n" in show else ""
|
||||||
|
if not body_text:
|
||||||
|
body_text = subj
|
||||||
|
flag, sep, rest = body_text.partition(":")
|
||||||
|
flag = flag.strip()
|
||||||
|
desc = rest.strip() if sep else body_text
|
||||||
|
issue = _canonical_issue(flag)
|
||||||
|
cur = best.get(issue)
|
||||||
|
if cur is None or len(desc) > cur[3]:
|
||||||
|
best[issue] = (coord, flag, desc, len(desc))
|
||||||
|
return [(c, f, d) for c, f, d, _ in best.values()]
|
||||||
|
|
||||||
|
# Snapshot of the uncovered findings captured when read_findings ran, so the
|
||||||
|
# report can be composed faithfully even after mark_covered has covered them.
|
||||||
|
CURRENT_META = []
|
||||||
|
|
||||||
|
def read_findings(project=None):
|
||||||
|
"""Deterministic, grounded input for the model: the currently-UNCOVERED CUBE
|
||||||
|
findings, numbered with their FLAG. The model replies with ONLY the FLAGs in
|
||||||
|
priority order; Python composes the report from the grounded text, so it is
|
||||||
|
impossible for any finding to be invented."""
|
||||||
|
global CURRENT_META
|
||||||
|
meta = _uncovered_meta(project)
|
||||||
|
CURRENT_META = meta
|
||||||
|
if not meta:
|
||||||
|
return "(no new findings)"
|
||||||
|
lines = []
|
||||||
|
for i, (coord, flag, desc) in enumerate(meta, 1):
|
||||||
|
descf = (desc[:120] + "…") if len(desc) > 120 else desc
|
||||||
|
lines.append(f"{i}. {flag}: {descf} (coord {coord})")
|
||||||
|
return ("Uncovered CUBE findings (use ONLY these; do not invent, add, or guess):\n"
|
||||||
|
+ "\n".join(lines)
|
||||||
|
+ "\n\nReply with the finding FLAGs in priority order, most critical first, "
|
||||||
|
"comma-separated, using exactly the FLAGs above and nothing else — for example:\n"
|
||||||
|
+ ", ".join(f for _, f, _ in meta)
|
||||||
|
+ "\nThen call mark_covered, then reply done (no report body needed).")
|
||||||
|
|
||||||
|
_SEV_CRIT = ["data integrity", "security", "tls", "cert", "denied", "cannot",
|
||||||
|
"corrupt", "lost", "inaccessible", "integrity", "breach"]
|
||||||
|
_SEV_HIGH = ["timeout", "persist", "reload", "shallow", "must", "fail", "broken"]
|
||||||
|
|
||||||
|
def _severity(flag, desc):
|
||||||
|
"""Deterministic severity label from the finding's FLAG + description. Used to
|
||||||
|
give the report a meaningful priority order when the model provides no ranking."""
|
||||||
|
t = (flag + " " + desc).lower()
|
||||||
|
if any(k in t for k in _SEV_CRIT):
|
||||||
|
return "HIGH"
|
||||||
|
if any(k in t for k in _SEV_HIGH):
|
||||||
|
return "MEDIUM"
|
||||||
|
return "LOW"
|
||||||
|
|
||||||
|
def compose_report(project=None, model_text=""):
|
||||||
|
"""Build the grounded, prioritized report. The model supplies ONLY the priority
|
||||||
|
ORDER of the FLAGs (honored when present); every finding's text is taken verbatim
|
||||||
|
from CUBE, so any hallucinated finding is impossible. Unknown/duplicate flags are
|
||||||
|
dropped; flags the model did not rank are ordered by a deterministic severity
|
||||||
|
heuristic (HIGH → MEDIUM → LOW), then CUBE order. Uses the snapshot from read_findings."""
|
||||||
|
meta = CURRENT_META or _uncovered_meta(project)
|
||||||
|
if not meta:
|
||||||
|
return "ERROR LOG REPORT (ALL)\n\nNo new findings since the last checkpoint."
|
||||||
|
text_low = model_text.lower()
|
||||||
|
listed = {}
|
||||||
|
for coord, flag, desc in meta:
|
||||||
|
listed.setdefault(flag.lower(), (coord, flag, desc))
|
||||||
|
# Model's ranking first (by first-flag-appearance order in its reply).
|
||||||
|
positions = []
|
||||||
|
for key in listed:
|
||||||
|
idx = text_low.find(key)
|
||||||
|
if idx >= 0:
|
||||||
|
positions.append((idx, key))
|
||||||
|
positions.sort()
|
||||||
|
order = [key for _, key in positions]
|
||||||
|
remaining = [key for key in listed if key not in order]
|
||||||
|
sev_rank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
||||||
|
remaining.sort(key=lambda k: sev_rank.get(_severity(listed[k][1], listed[k][2]), 3))
|
||||||
|
order += remaining
|
||||||
|
scope = project if project else "ALL"
|
||||||
|
lines = [f"ERROR LOG REPORT (scope: {scope})",
|
||||||
|
f"Generated: {_now_ts()}", ""]
|
||||||
|
for rank, key in enumerate(order, 1):
|
||||||
|
coord, flag, desc = listed[key]
|
||||||
|
sev = _severity(flag, desc)
|
||||||
|
lines.append(f"{rank}. [{sev}] {flag}: {desc if desc else '(no description)'}")
|
||||||
|
lines.append(f" coord: {coord}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def mark_covered(project=None, text=""):
|
||||||
|
covered = _covered(project)
|
||||||
|
coords = [c for c, cat, subj in _log_findings(project) if c not in covered]
|
||||||
|
if not coords:
|
||||||
|
return "nothing to cover"
|
||||||
|
body = "covered coords:\n" + "\n".join(coords)
|
||||||
|
args = [CUBE, "note", "add", "--cat", "checkpoint"]
|
||||||
|
if project:
|
||||||
|
args += ["--project", project]
|
||||||
|
args.append(body[:1400])
|
||||||
|
subprocess.run(args, capture_output=True, text=True, env=env())
|
||||||
|
return "marked covered"
|
||||||
|
|
||||||
|
def tool(kw, args, project):
|
||||||
|
if kw == "read_findings":
|
||||||
|
return read_findings(project)
|
||||||
|
if kw == "mark_covered":
|
||||||
|
return mark_covered(project, args)
|
||||||
|
if kw == "note_search":
|
||||||
|
return run_cube(["note", "search", args])
|
||||||
|
if kw == "note_list":
|
||||||
|
return run_cube(["note", "list"] + (args.split() if args else []))
|
||||||
|
if kw == "project_context":
|
||||||
|
return run_cube(["project", "context", args])
|
||||||
|
if kw == "read_file":
|
||||||
|
return open_file(args)
|
||||||
|
return f"unknown tool: {kw}"
|
||||||
|
|
||||||
|
ACTIONS = ("read_findings", "mark_covered", "note_search",
|
||||||
|
"note_list", "project_context", "read_file", "done")
|
||||||
|
|
||||||
|
def parse_action(text):
|
||||||
|
"""Parse an action word out of the model's reply. Tolerant of markdown code
|
||||||
|
fences, ```tool_code``` wrappers, and prose preamble (the small gemma model
|
||||||
|
tends to wrap calls like this). Scans lines and stops once it sees "done",
|
||||||
|
so the trailing report is never misread as another tool call."""
|
||||||
|
best = (None, "")
|
||||||
|
for raw in text.strip().splitlines():
|
||||||
|
s = raw.replace("`", "").replace("tool_code", " ").strip()
|
||||||
|
low = s.lower()
|
||||||
|
for kw in ACTIONS:
|
||||||
|
idx = low.find(kw)
|
||||||
|
if idx >= 0:
|
||||||
|
best = (kw, s[idx + len(kw):].strip())
|
||||||
|
break
|
||||||
|
if best[0] == "done":
|
||||||
|
break
|
||||||
|
return best
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--project", default=None,
|
||||||
|
help="Restrict the report to one CUBE project (default: ALL finding notes across the store)")
|
||||||
|
ap.add_argument("--max-turns", type=int, default=6)
|
||||||
|
ap.add_argument("--report", default="/root/workspace/cube-agent/report.txt")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
p = start_llama()
|
||||||
|
scope = a.project if a.project else "ALL"
|
||||||
|
# Ground the report in CUBE up front: snapshot the uncovered findings and hand
|
||||||
|
# them to the model so it only has to RANK them (it never writes finding text).
|
||||||
|
findings_text = read_findings(a.project)
|
||||||
|
if findings_text == "(no new findings)":
|
||||||
|
initial_context = "There are no uncovered findings to report right now."
|
||||||
|
task = "Reply done (no report body needed)."
|
||||||
|
else:
|
||||||
|
initial_context = findings_text
|
||||||
|
task = ("Reply with the finding FLAGs in priority order (most critical first), "
|
||||||
|
"comma-separated, using exactly the FLAGs below and nothing else — then reply done.")
|
||||||
|
sys_prompt = (
|
||||||
|
f"You are a headless error-log reviewer for the CUBE notes store (scope: {scope}).\n"
|
||||||
|
"The findings are provided below. Order them by priority/severity, most critical first.\n"
|
||||||
|
"Reply with the FLAGs in that order, comma-separated, using exactly those FLAGs and nothing else.\n"
|
||||||
|
"Then reply: done. "
|
||||||
|
"Your ranked FLAG list sets the priority; the report body is assembled for you, so do not invent findings."
|
||||||
|
)
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": sys_prompt},
|
||||||
|
{"role": "user", "content": f"{task}\n\n{initial_context}"},
|
||||||
|
]
|
||||||
|
trace = []
|
||||||
|
report_text = ""
|
||||||
|
last_assistant = ""
|
||||||
|
for turn in range(a.max_turns):
|
||||||
|
try:
|
||||||
|
text = chat(messages)
|
||||||
|
except Exception as e:
|
||||||
|
trace.append(f"chat error: {e}")
|
||||||
|
break
|
||||||
|
kw, args = parse_action(text)
|
||||||
|
if kw == "done":
|
||||||
|
# The model supplies ONLY the priority order of the FLAGs; Python
|
||||||
|
# composes the grounded report (impossible to invent a finding).
|
||||||
|
di = text.lower().find("done")
|
||||||
|
model_text = text[di + 4:].strip() if di >= 0 else last_assistant
|
||||||
|
report_text = compose_report(a.project, model_text)
|
||||||
|
log_action(turn, f"DONE\n\n~flags: {model_text[:400]}")
|
||||||
|
break
|
||||||
|
if kw:
|
||||||
|
result = tool(kw, args, a.project)
|
||||||
|
trace.append(f"turn{turn}: {kw}({args})")
|
||||||
|
log_action(turn, f"{kw}({args})\n\n{result[:1400]}")
|
||||||
|
messages.append({"role": "assistant", "content": text})
|
||||||
|
messages.append({"role": "user",
|
||||||
|
"content": f"Tool result:\n{result}\n\nContinue — output exactly one "
|
||||||
|
"action line (read_findings / mark_covered / note_search / "
|
||||||
|
"note_list / project_context / read_file / done)."})
|
||||||
|
else:
|
||||||
|
log_action(turn, f"(no action) {text[:300]}")
|
||||||
|
messages.append({"role": "assistant", "content": text})
|
||||||
|
messages.append({"role": "user",
|
||||||
|
"content": "Output exactly ONE action line starting with a tool name."})
|
||||||
|
trace.append(f"turn{turn}: (no action)")
|
||||||
|
last_assistant = text
|
||||||
|
if not report_text:
|
||||||
|
# Run ended without an explicit "done" — still produce a grounded report.
|
||||||
|
report_text = compose_report(a.project, last_assistant)
|
||||||
|
# Deterministic coverage: mark all uncovered findings covered (including any
|
||||||
|
# deduped-away duplicates) regardless of whether the model did it, so the next
|
||||||
|
# run is genuinely incremental. No-op when there are no uncovered findings.
|
||||||
|
mark_covered(a.project)
|
||||||
|
with open(a.report, "w") as f:
|
||||||
|
f.write(report_text)
|
||||||
|
os.makedirs(os.path.dirname(a.report), exist_ok=True) if os.path.dirname(a.report) else None
|
||||||
|
print("AGENT_DONE max_turns=", a.max_turns, "report=", a.report,
|
||||||
|
"bytes=", os.path.getsize(a.report) if os.path.exists(a.report) else 0)
|
||||||
|
try:
|
||||||
|
p.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user