Преглед изворни кода

feat: skill-agent-log-s3 数字生命自报日志技能v1.0

- collect_metrics.py: CPU/内存/磁盘/网络/GPU/并发/服务/错误计数
- upload_log.py: 华为OBS官方SDK(绕开boto3的XAmzContentSHA256Mismatch坑)
- report.sh: 一键上报(cron入口)
- 路径: user/<userid>/log/<YYYYMMDD>/<HHMM>.json · 30天滚动清理
- 三生命首报验证通过(兔爷1424/芯葶1425/雨飏1430)
fmode пре 3 недеља
комит
2530920b6d
4 измењених фајлова са 210 додато и 0 уклоњено
  1. 48 0
      SKILL.md
  2. 104 0
      scripts/collect_metrics.py
  3. 9 0
      scripts/report.sh
  4. 49 0
      scripts/upload_log.py

+ 48 - 0
SKILL.md

@@ -0,0 +1,48 @@
+---
+name: skill-agent-log-s3
+description: 触发词:日志上报、性能记录、运行状态。数字生命自报运行日志到个人S3空间log/目录。
+---
+
+# 数字生命自报日志 (skill-agent-log-s3)
+
+> 命名规则: skill-agent-log-s3 = agent领域的log功能,存到S3
+> 核心逻辑: 每个生命**用自己的密钥**把运行状态写进**自己的**S3空间(log/目录),
+> 主协调者(雨飏001)每天只需下载各家日志做分析,不消耗监控资源。
+
+## 我上报什么
+
+| 维度 | 指标 |
+|------|------|
+| 并发 | 活跃 profile 数、活跃 session 数、运行中 sub-agent 数 |
+| 资源 | CPU%、内存 used/total、磁盘 used/total、GPU(有则记) |
+| 网络 | 累计上行/下行字节 |
+| 进程 | gateway/dashboard/studio 存活状态 |
+| 事件 | 自上次上报以来的异常/重启/错误计数 |
+
+## 怎么上报
+
+```bash
+bash ~/.fmode-harness-agent/skills/agent-log-s3/report.sh
+```
+- 脚本采集本机指标 → JSON
+- 写入 `~/.fmode-harness-agent/tmp/agent-log.json`
+- 用**自己的**身份(FEME_USERID + S3凭证)上传到:
+  `user/<我的userid>/log/<YYYYMMDD>/<HHMM>.json`
+- 保留最近N份,自然滚动
+
+## 上报频率建议
+
+- 常态: 每 6 小时一次(cron)
+- 重任务执行时: 任务开始/结束各加一条
+- 手动: 随时跑 report.sh
+
+## 主协调者的收集
+
+雨飏001每天跑 collect-all.sh:
+- 逐个生命的 S3 前缀 `user/<userid>/log/` 列举最新文件
+- 下载汇总 → 分析 → 生成日报(容量/异常/趋势)
+
+## 依赖
+
+- boto3 (S3协议)
+- 本身份 fmode-identity.json (userid) + S3凭证(env或credentials API)

+ 104 - 0
scripts/collect_metrics.py

@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+"""采集本机运行指标 → JSON (跨平台: Linux/macOS/Windows-WSL)"""
+import json, os, subprocess, datetime, socket, sys
+
+WORK = os.path.expanduser("~/.fmode-harness-agent")
+
+def sh(cmd, timeout=15):
+    try:
+        return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout).stdout.strip()
+    except Exception as e:
+        return f"ERR:{e}"
+
+def collect():
+    now = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+08:00")
+    data = {
+        "timestamp": now,
+        "agent": os.environ.get("AGENT_NAME", socket.gethostname()),
+        "host": {
+            "platform": sys.platform,
+            "hostname": socket.gethostname(),
+        },
+        "resources": {},
+        "concurrency": {},
+        "services": {},
+        "events": {}
+    }
+
+    # --- CPU/内存 (跨平台: /proc/meminfo + uptime) ---
+    try:
+        meminfo = {}
+        for line in open("/proc/meminfo"):
+            k, v = line.split(":")
+            meminfo[k.strip()] = int(v.strip().split()[0])  # kB
+        total = meminfo["MemTotal"]
+        avail = meminfo["MemAvailable"]
+        data["resources"]["memory"] = {
+            "total_mb": round(total / 1024),
+            "used_mb": round((total - avail) / 1024),
+            "percent": round((total - avail) / total * 100, 1)
+        }
+        load = open("/proc/loadavg").read().split()
+        data["resources"]["cpu_load"] = {"m1": float(load[0]), "m5": float(load[1]), "m15": float(load[2])}
+        # CPU%
+        cpu = sh("top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'")
+        data["resources"]["cpu_percent"] = round(float(cpu), 1) if cpu and not cpu.startswith("ERR") else None
+    except Exception as e:
+        data["resources"]["error"] = str(e)[:100]
+
+    # --- 磁盘 (工作区所在分区) ---
+    disk = sh(f"df -B1 {WORK} | tail -1 | awk '{{print $2, $3, $5}}'")
+    if disk and not disk.startswith("ERR"):
+        t, u, p = disk.split()
+        data["resources"]["disk"] = {"total_gb": round(int(t)/1e9,1), "used_gb": round(int(u)/1e9,1), "percent": p}
+
+    # --- 网络 (累计上下行, 取主网卡) ---
+    net = sh("cat /proc/net/dev | awk 'NR>2 && $1!~/lo:/ {gsub(\":\",\"\",$1); print $1, $2, $10}' | sort -k2 -rn | head -1")
+    if net and not net.startswith("ERR"):
+        iface, rx, tx = net.split()
+        data["resources"]["network"] = {"iface": iface, "rx_total_mb": round(int(rx)/1e6,1), "tx_total_mb": round(int(tx)/1e6,1)}
+
+    # --- GPU (有则记) ---
+    gpu = sh("nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total --format=csv,noheader,nounits 2>/dev/null")
+    if gpu:
+        u, mu, mt = gpu.split(", ")
+        data["resources"]["gpu"] = {"percent": int(u), "mem_used_mb": int(mu), "mem_total_mb": int(mt)}
+
+    # --- 并发: profile / session / subagent ---
+    profiles = sh("ls /opt/data/profiles/ 2>/dev/null | wc -l")
+    data["concurrency"]["profiles_total"] = int(profiles) if profiles.isdigit() else None
+    # 活跃session(hermes state.db最近30分钟)
+    try:
+        import sqlite3
+        conn = sqlite3.connect("file:/opt/data/state.db?mode=ro", uri=True, timeout=5)
+        cur = conn.cursor()
+        cur.execute("SELECT COUNT(DISTINCT session_id) FROM messages WHERE timestamp > ?", (time.time()-1800,))
+        data["concurrency"]["active_sessions_30min"] = cur.fetchone()[0]
+        cur.execute("SELECT COUNT(*) FROM async_delegations WHERE status='running'")
+        data["concurrency"]["running_subagents"] = cur.fetchone()[0]
+        conn.close()
+    except Exception:
+        data["concurrency"]["active_sessions_30min"] = None
+
+    # --- 服务存活 ---
+    for svc, pat in [("gateway", "hermes gateway"), ("dashboard", "hermes-dashboard"), ("studio", "fmode-studio")]:
+        out = sh(f"pgrep -f '{pat}' | wc -l")
+        data["services"][svc] = "up" if out.isdigit() and int(out) > 0 else "down"
+
+    # --- 事件: 最近1小时错误日志计数 ---
+    errc = sh("find /opt/data/logs -name '*.log' -mmin -60 -exec grep -ci 'error\\|fatal' {} + 2>/dev/null | awk -F: '{s+=$2} END {print s}'")
+    data["events"]["errors_last_1h"] = int(errc) if errc.isdigit() else None
+    rest = sh("grep -c 'Reconnected' /opt/data/logs/gateway.log 2>/dev/null")
+    data["events"]["wecom_reconnects_total"] = int(rest) if rest.isdigit() else None
+
+    return data
+
+if __name__ == "__main__":
+    import time
+    out = os.path.join(WORK, "tmp")
+    os.makedirs(out, exist_ok=True)
+    d = collect()
+    path = os.path.join(out, "agent-log.json")
+    json.dump(d, open(path, "w"), ensure_ascii=False, indent=2)
+    print(json.dumps(d, ensure_ascii=False)[:300])
+    print(f"\nsaved: {path}")

+ 9 - 0
scripts/report.sh

@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# 一键上报: 采集 + 上传 (cron入口)
+set -euo pipefail
+WORK="$HOME/.fmode-harness-agent"
+SKILL="$WORK/skills/agent-log-s3/scripts"
+export AGENT_NAME="${AGENT_NAME:-$(hostname)}"
+
+python3 "$SKILL/collect_metrics.py" > /dev/null
+python3 "$SKILL/upload_log.py"

+ 49 - 0
scripts/upload_log.py

@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+"""上传日志到自己S3空间(华为OBS官方SDK): user/<userid>/log/<YYYYMMDD>/<HHMM>.json"""
+import json, os, sys, datetime
+
+WORK = os.path.expanduser("~/.fmode-harness-agent")
+
+def upload():
+    ident_path = "/opt/data/fmode-identity.json"
+    if not os.path.exists(ident_path):
+        ident_path = os.path.join(WORK, "fmode-identity.json")
+    ident = json.load(open(ident_path))
+    userid = ident["userid"]
+
+    log_file = os.path.join(WORK, "tmp", "agent-log.json")
+    if not os.path.exists(log_file):
+        print("先跑 collect_metrics.py"); sys.exit(1)
+
+    ak = os.environ.get("S3_AK") or os.environ.get("CLOUD_SDK_AK")
+    sk = os.environ.get("S3_SK") or os.environ.get("CLOUD_SDK_SK")
+    if not ak:
+        print("缺少S3凭证"); sys.exit(1)
+
+    from obs import ObsClient
+    obs = ObsClient(access_key_id=ak, secret_access_key=sk,
+                    server="https://obs.cn-north-4.myhuaweicloud.com")
+
+    now = datetime.datetime.now()
+    key = f"user/{userid}/log/{now:%Y%m%d}/{now:%H%M}.json"
+    r = obs.putObject("storage-s3-nkkj", key, content=open(log_file, "rb").read())
+    if r.status < 300:
+        print(f"uploaded: {key} (status {r.status})")
+    else:
+        print(f"upload FAILED: {r.status} {r.reason}"); sys.exit(1)
+
+    # 清理30天前旧日志
+    cutoff = (now - datetime.timedelta(days=30)).strftime("%Y%m%d")
+    resp = obs.listObjects("storage-s3-nkkj", prefix=f"user/{userid}/log/")
+    cleaned = 0
+    if resp.status < 300 and resp.body.contents:
+        for obj in resp.body.contents:
+            day = obj.key.split("/")[3]
+            if day < cutoff:
+                obs.deleteObject("storage-s3-nkkj", obj.key)
+                cleaned += 1
+    if cleaned:
+        print(f"cleaned {cleaned} old logs")
+
+if __name__ == "__main__":
+    upload()