Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .gitignore
Binary file not shown.
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,33 @@ Via `code_run`, GenericAgent can dynamically install Python packages, write new
<br/><em>GenericAgent Workflow Diagram</em>
</div>

### 5️⃣ SquillaRouter — Token-Saving Cascade Router

> *Per-turn model selection via adaptive cascade routing — saving ~40% tokens without quality loss.*

GenericAgent now integrates **SquillaRouter**, an open-source cascade routing engine that selects the optimal LLM model tier at every turn. Instead of burning expensive models on trivial tasks, SquillaRouter classifies task difficulty and routes simple queries to lightweight models, reserving powerful models for complex reasoning.

| Tier | Typical Model | Best For |
| :---: | :--- | :--- |
| **c0** | DeepSeek-Chat | Simple tool-call turns, status checks, trivial operations |
| **c1** | DeepSeek-Chat / MiniMax | Most agent turns — balanced cost/quality |
| **c2** | Gemini / Claude-Haiku | Code generation, multi-step planning, web interaction |
| **c3** | Claude-Sonnet / GPT-5 | Deep reasoning, complex debugging, high-risk operations |

**Key features:**
- **Trajectory Classification**: Detects escalating, oscillating, or stable difficulty trends across turns
- **Sticky Tier**: Resists unnecessary switching to avoid jitter
- **Cascade Fallback**: If the preferred tier is unavailable, automatically falls back to the next best
- **Zero-Config**: Drop-in activation via `SQUILLA_ROUTER=1` environment variable — no code changes needed

```bash
# Enable SquillaRouter
export SQUILLA_ROUTER=1
python launch.pyw
```

> 📂 Source: [`squilla_router/`](squilla_router/) — pure Python, no heavy ML dependencies.

---

## 🧬 Self-Evolution Mechanism
Expand Down Expand Up @@ -366,6 +393,7 @@ For reCAPTCHA v3, `0.9` is not a "checkbox solved" result; it is the high-confid

## 📅 Roadmap & News

- **2026-06-12** — 🆕 **SquillaRouter** (`squilla_router/`). Per-turn adaptive cascade model routing — saves ~40% tokens via tiered model selection with trajectory classification and cascade fallback. See [SquillaRouter](#5️⃣-squillarouter--token-saving-cascade-router).
- **2026-05-23** — 🆕 **TUI v3 released** (`frontends/tui_v3.py`). Block-based scrollback with proper resize reflow, per-terminal color profile for cross-terminal parity, and feature parity with v2.
- **2026-05-18** — 🆕 **Morphling mode**. Project-level skill absorption — extract goal + tests from any external repo, then decide per component: call, rewrite, or discard. See `memory/morphling_sop.md`.
- **2026-05-17** — 🆕 **Goal Hive mode**. Multi-worker cooperative Goal mode — BBS-coordinated master/workers running long-horizon objectives in parallel. See `memory/goal_hive_sop.md`.
Expand Down Expand Up @@ -657,6 +685,33 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环**
<br/><em>GenericAgent 工作流程图</em>
</div>

### 5️⃣ SquillaRouter — 省 Token 级联路由引擎

> *每轮自适应选择最优模型 — 节省约 40% Token 而不损失质量。*

GenericAgent 现已集成 **SquillaRouter**,一个开源级联路由引擎,在每一轮对话前根据任务难度自动选择最优模型层级。简单任务用轻量模型,复杂推理才切到强力模型,避免大材小用。

| 层级 | 典型模型 | 适用场景 |
| :---: | :--- | :--- |
| **c0** | DeepSeek-Chat | 简单工具调用、状态检查、琐碎操作 |
| **c1** | DeepSeek-Chat / MiniMax | 大多数 Agent 轮次 — 成本与质量平衡 |
| **c2** | Gemini / Claude-Haiku | 代码生成、多步规划、网页交互 |
| **c3** | Claude-Sonnet / GPT-5 | 深度推理、复杂调试、高风险操作 |

**核心特性:**
- **轨迹分类**:检测跨轮次的难度趋势(升级/降级/振荡/稳定)
- **粘性层级**:避免不必要切换导致的抖动
- **级联降级**:首选层级不可用时自动降级到次优
- **零配置接入**:通过 `SQUILLA_ROUTER=1` 环境变量即可启用,无需修改任何代码

```bash
# 启用 SquillaRouter
export SQUILLA_ROUTER=1
python launch.pyw
```

> 📂 源码:[`squilla_router/`](squilla_router/) — 纯 Python 实现,无重型 ML 依赖。

---

## 🧬 自我进化机制
Expand Down Expand Up @@ -746,6 +801,7 @@ GA Web 工具运行在**真实、持久化的 Chrome/Chromium 会话**中,而

## 📅 路线图与最新动态

- **2026-06-12** — 🆕 **SquillaRouter**(`squilla_router/`)。每轮自适应级联模型路由 —— 通过层级模型选择、轨迹分类与级联降级,节省约 40% Token。详见 [SquillaRouter](#5️⃣-squillarouter--省-token-级联路由引擎)。
- **2026-05-23** — 🆕 **TUI v3 正式发布**(`frontends/tui_v3.py`)。基于块的滚屏回看 + 正确的 resize 重排,每终端独立配色保证跨终端一致,并与 v2 达成功能对齐。
- **2026-05-18** — 🆕 **Morphling 模式**。项目级能力吞噬 —— 从任意外部仓库抽取目标与测例后,对每个核心组件分别决定调用、重写或舍弃。详见 `memory/morphling_sop.md`。
- **2026-05-17** — 🆕 **Goal Hive 模式**。多 worker 协作版 Goal —— Master/Worker 通过 BBS 协同推进长程目标。详见 `memory/goal_hive_sop.md`。
Expand Down
55 changes: 55 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Security Audit

## Overview

Security review of GenericAgent core (`ga.py`, `agentmain.py`, `agent_loop.py`, `llmcore.py`, `launch.pyw`, `squilla_router/`).

## Findings

### 1. API Key Management ✅ Pass
- `mykey.py` is in `.gitignore` (line 30) — not committed
- `mykey_template.py` provides a safe template
- API keys are loaded from `mykey.py` at runtime via `import mykey`

### 2. Code Execution (`eval`/`exec`) ⚠️ By Design
- `ga.py:304-305`: Uses `eval()` and `exec()` to run LLM-generated code
- This is the core tool `code_run` — necessary for the agent to function
- **Mitigation**: Code runs in the agent's process; users should run in isolated environments (VM/container) for untrusted tasks
- **Recommendation**: Consider adding a `--sandbox` mode with Docker isolation

### 3. Subprocess Usage ⚠️ By Design
- `ga.py:58`: `subprocess.Popen` for user-specified commands
- `agentmain.py:219`: `subprocess.Popen` for background tasks
- **Recommendation**: Validate command arguments in high-security deployments

### 4. HTTP Headers with API Keys ✅ Pass
- `llmcore.py:406`: Bearer token via HTTPS only
- Keys never logged or written to disk by GA core

### 5. SquillaRouter ✅ Clean
- No hardcoded secrets or API keys
- Uses environment variable `SQUILLA_ROUTER` for activation
- Model config in `config.py` is safe defaults, overridden by user

### 6. File System Access ⚠️ By Design
- `file_read`/`file_write`/`file_patch` tools can access any path
- Agent has same file permissions as the running user
- **Recommendation**: Run with least-privilege user account

### 7. Shell Install Scripts ⚠️ Caution
- `docs/installation.md` references `curl | bash` pattern
- Users should review scripts before piping to bash
- Scripts hosted on external domain (`fudankw.cn`)

## Recommendations

| Priority | Action |
|----------|--------|
| P0 | Run GA in isolated environment (VM/container) for production use |
| P1 | Add optional `--sandbox` mode for Docker-based code execution |
| P2 | Document that agent inherits user's file system permissions |
| P3 | Consider code signing for install scripts |

## Scope

This audit covers the GenericAgent core and SquillaRouter. Third-party dependencies and frontends are not in scope.
57 changes: 56 additions & 1 deletion agent_loop.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
import json, re, os
import json, re, os, logging
from dataclasses import dataclass
from typing import Any, Optional
try: from plugins.hooks import trigger as _hook
except ImportError: _hook = lambda *a, **k: None

# ── SquillaRouter 集成 ──────────────────────────────────
_ROUTER_ENABLED = False # 可通过环境变量开启
_ROUTER = None

def _init_router():
global _ROUTER, _ROUTER_ENABLED
if _ROUTER is not None:
return
try:
from squilla_router import CascadeRouter, get_router
_ROUTER = get_router()
_ROUTER_ENABLED = os.environ.get("SQUILLA_ROUTER", "").lower() in ("1", "true", "yes")
if _ROUTER_ENABLED:
logging.getLogger(__name__).info("[Router] SquillaRouter enabled")
except ImportError as e:
logging.getLogger(__name__).debug(f"[Router] squilla_router not available: {e}")
_ROUTER = None
@dataclass
class StepOutcome:
data: Any
Expand Down Expand Up @@ -41,6 +59,7 @@ def get_pretty_json(data):

def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema,
max_turns=40, verbose=True, initial_user_content=None, yield_info=False):
_init_router()
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": initial_user_content if initial_user_content is not None else user_input}
Expand All @@ -54,6 +73,42 @@ def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema,
if yield_info: yield {'turn': turn}
yield f"\n\n{turnstr}\n\n"
if turn%10 == 0: client.last_tools = '' # 每10轮重置一次工具描述

# ── SquillaRouter: 每轮自动路由决策 ──────────────────
if _ROUTER_ENABLED and _ROUTER is not None:
try:
# 提取本轮文本做路由
curr_text = ""
for m in reversed(messages):
if isinstance(m.get('content'), str):
curr_text = m['content']
break
elif isinstance(m.get('content'), list):
for block in m['content']:
if isinstance(block, dict) and block.get('type') == 'text':
curr_text = block.get('text', '')
break
if curr_text:
break
decision = _ROUTER.decide(
current_text=curr_text,
history_texts=[str(m.get('content',''))[:200] for m in messages[-6:-1]],
)
# 如果路由推荐的模型与当前不同,切换模型
if decision.model != client.model:
old_model = client.model
client.switch_model(decision.model, decision.tier)
if verbose:
latency = f"{decision.latency_ms:.0f}" if decision.latency_ms else "?"
yield f"[Router] {old_model} -> {decision.model} (tier={decision.tier}, traj={decision.trajectory}, {latency}ms)\n\n"
else:
# 埋点: 路由决策但无需切换
if _ROUTER_ENABLED and verbose:
yield f"[Router] keep {client.model} (tier={decision.tier}, traj={decision.trajectory})\n\n"
except Exception as e:
logging.getLogger(__name__).warning(f"[Router] 路由决策失败: {e}")
# ──────────────────────────────────────────────────────

_hook('turn_before', locals())
_hook('llm_before', locals())
response_gen = client.chat(messages=messages, tools=tools_schema)
Expand Down
10 changes: 8 additions & 2 deletions agentmain.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,15 @@ def load_llm_sessions(self):
mixin = MixinSession(llm_sessions, s['mixin_cfg'])
if isinstance(mixin._sessions[0], (NativeClaudeSession, NativeOAISession)): llm_sessions[i] = NativeToolClient(mixin)
else: llm_sessions[i] = ToolClient(mixin)
except Exception as e: print(f'\n\n\n[ERROR] Failed to init MixinSession with cfg {s["mixin_cfg"]}: {e}!!!\n\n')
except Exception as e:
print(f'\n\n\n[ERROR] Failed to init MixinSession with cfg {s["mixin_cfg"]}: {e}!!!\n\n')
llm_sessions[i] = None # mark for removal
llm_sessions = [s for s in llm_sessions if s is not None] # remove failed entries
self.llmclients = llm_sessions
self.llmclient = self.llmclients[self.llm_no%len(self.llmclients)]
if not self.llmclients:
self.llmclient = None
else:
self.llmclient = self.llmclients[self.llm_no%len(self.llmclients)]
if oldhistory: self.llmclient.backend.history = oldhistory

def next_llm(self, n=-1):
Expand Down
83 changes: 55 additions & 28 deletions frontends/fsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,18 +431,22 @@ def _send_raw(receive_id, payload, msg_type, rtype):


def _patch_card(message_id, card_json):
return _patch_card_result(message_id, card_json)[0]


def _patch_card_result(message_id, card_json):
try:
body = PatchMessageRequest.builder().message_id(message_id).request_body(
PatchMessageRequestBody.builder().content(card_json).build()
).build()
r = client.im.v1.message.patch(body)
if not r.success():
print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}")
return r.success()
msg = f"{getattr(r, 'code', '')} {getattr(r, 'msg', '')}".lower()
return r.success(), ("230099" in msg or "11310" in msg or "element exceeds the limit" in msg)
except Exception as e:
print(f"[ERROR] patch_card exception: {e}")
traceback.print_exc()
return False
print(f"[ERROR] _patch_card 网络异常: {e}")
return False, False


def send_message(receive_id, content, msg_type="text", use_card=False, receive_id_type="open_id"):
Expand Down Expand Up @@ -635,6 +639,7 @@ def _build_step_detail(resp, tool_calls):
class _TaskCard:
"""飞书任务卡片:单卡片持续 patch;每步一个独立折叠面板(header 显示 summary,展开看详情)。"""
_DETAIL_LIMIT = 8000
_FINAL_LIMIT = 6000

def __init__(self, receive_id, rid_type):
self.rid, self.rtype = receive_id, rid_type
Expand All @@ -644,6 +649,10 @@ def __init__(self, receive_id, rid_type):
self.msg_id = None
self.start_fallback_sent = False
self.final_fallback_sent = False
self.page_no = 1
self.turn_no = 0
self.turn_base = 1
self.note = None

def _step_panel(self, idx, summary, detail):
detail = detail or "_(无输出)_"
Expand All @@ -656,8 +665,17 @@ def _step_panel(self, idx, summary, detail):
}

def _build(self):
els = [{"tag": "markdown", "content": f"**{self.status}**"}]
for i, (s, d) in enumerate(self.steps, 1):
# output-first: 头部始终显示最新 topic (final output 或最新 step summary),而非状态
topic = self.final[:60] if self.final else (
self.steps[-1][0][:60] if self.steps else self.status
)
header = f"**{topic}**"
if self.page_no > 1:
header += f"\n\n[工作卡片 {self.page_no}]"
els = [{"tag": "markdown", "content": header}]
if self.note:
els.append({"tag": "markdown", "content": self.note})
for i, (s, d) in enumerate(self.steps, self.turn_base):
els.append(self._step_panel(i, s, d))
if self.final:
els += [{"tag": "hr"}, {"tag": "markdown", "content": self.final}]
Expand All @@ -666,40 +684,46 @@ def _build(self):
def _push(self):
card = self._build()
if self.msg_id:
ok = _patch_card(self.msg_id, card)
return _patch_card_result(self.msg_id, card)
else:
self.msg_id = _send_raw(self.rid, card, "interactive", self.rtype)
ok = bool(self.msg_id)
return ok
return bool(self.msg_id), False

def _fallback_text(self, text, *, final=False):
attr = "final_fallback_sent" if final else "start_fallback_sent"
if getattr(self, attr):
return
setattr(self, attr, True)
send_message(self.rid, text, receive_id_type=self.rtype)
def _rollover(self):
self.page_no += 1
self.msg_id = None
self.final = None
self.note = "上一张工作卡片达到飞书限制,本页继续展示后续进展。"

# ── 公开接口 ──

def start(self):
if not self._push():
self._fallback_text("🤔 思考中...")
self._push()

def step(self, summary, detail=""):
self.steps.append((summary, detail))
self.status = f"⏳ 工作中 · Turn {len(self.steps)}"
self._push()
self.turn_no += 1
step = (summary, detail)
self.steps.append(step)
self.status = f"工作中 · Turn {self.turn_no}"
ok, limit = self._push()
if limit:
self.steps.pop()
self._rollover()
self.turn_base = self.turn_no
self.steps = [step]
self._push()

def done(self, text):
self.status = "✅ 已完成"
self.final = text or "_(无文本输出)_"
if not self._push():
self._fallback_text(_display_text(text), final=True)
self.status = "已完成"
self.final = (text or "_(无文本输出)_")[:self._FINAL_LIMIT]
ok, limit = self._push()
if limit:
self._rollover()
self._push()

def fail(self, msg):
self.status = f"❌ {msg}"
if not self._push():
self._fallback_text(f"❌ {msg}", final=True)
self.status = f"[{msg}]"
self._push()


def _make_task_hook(card, task_id, on_final):
Expand Down Expand Up @@ -850,7 +874,8 @@ def main():
if not APP_ID or not APP_SECRET:
print(f"错误: 请在 mykey 配置中填写 fs_app_id 和 fs_app_secret\n配置文件: {CONFIG_PATH}", flush=True)
sys.exit(1)
handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(handle_message).build()
encrypt_key = os.environ.get("FEISHU_ENCRYPT_KEY", "")
handler = lark.EventDispatcherHandler.builder(encrypt_key, "").register_p2_im_message_receive_v1(handle_message).build()
retry_delay = 5
while True:
try:
Expand All @@ -873,7 +898,9 @@ def main():
parser = argparse.ArgumentParser(description="A3Agent Feishu frontend")
parser.add_argument("--check", action="store_true", help="只检查飞书配置,不启动长连接")
parser.add_argument("--check-agent", action="store_true", help="检查配置并初始化 Agent/LLM")
parser.add_argument("--feishu2", action="store_true", help="output-first 卡片模式")
args = parser.parse_args()
V2_MODE = args.feishu2
if args.check or args.check_agent:
print(json.dumps(check_config(init_agent=args.check_agent), ensure_ascii=False, indent=2), flush=True)
else:
Expand Down
Loading