--- name: harnessed-module-development description: Development patterns, pitfalls, and conventions for Sage modules — URL paths, JSON config, TabPanel syntax, API patterns, and uapi gateway integration for vendor APIs author: Hermes Agent related_skills: [] linked_files: references: - common-pitfalls.md - cross-database-etl-pattern.md --- # Harnessed Module Development Guide ## Workflow: Extending Existing Modules **Before modifying any existing Sage module, read and understand the complete existing implementation first.** Pitfall: When asked to add a feature to an existing module (e.g., credit limit to accounting), the agent may: - Invent new logic instead of extending existing patterns - Modify the wrong layer (e.g., sageapi vs sage/pkgs/accounting) - Miss integration points that already exist Correct workflow: 1. Load relevant skills (e.g., `accounting-module-example`) to understand architecture 2. Read ALL related source files to understand existing patterns 3. Identify where the new feature hooks into existing code (e.g., `leg_accounting()` for credit limit) 4. Follow existing patterns exactly — don't invent new approaches 5. After implementation, verify against a checklist of all integration points User correction example: "accounting有一套完整的记账功能,只是需要你读懂并增加信用额度能力" — the agent should have read the existing accounting module first, not invented new balance-update logic. ## External Vendor API Integration (uapi Gateway) **When a module needs to call external vendor/third-party APIs, use the Sage uapi gateway (`UpAppApi`) instead of writing direct HTTP client code.** This is the platform-native pattern. Details: `references/uapi-gateway-integration.md` Key pattern: - Module config table maps `vendor → upappid + api_mapping(JSON)` - `UpAppApi.call(upappid, apiname, callerid, params)` routes through uapi templates - AK/SK managed via uapi's `upappkey` table, not in the calling module - Different vendors have different API counts/logic — `api_mapping` is flexible JSON, zero code changes to add vendors **Do NOT write direct HTTP clients** (e.g., `volcengine_client.py`) — they duplicate uapi's signing, templating, and streaming capabilities. ## Module Architecture - Reasoning layer handles context analysis, task decomposition, safety checks, planning - Execution layer handles tool calls, memory, skills, workflows, remote skills - Reasoning feeds execution plans to agent module - Shared database schema, common RBAC auth, complementary APIs ## Module Permission Registration: scripts/load_path.py Pattern **CRITICAL: Each module's RBAC permissions are managed in the module's own `scripts/load_path.py` file, NOT in `sage/load_path.py`.** The `sage/load_path.py` is a legacy artifact. All new modules (and existing ones migrated) register permissions via their own `scripts/load_path.py`: ``` module_name/ ├── scripts/ │ └── load_path.py ← RBAC permission registration (run from sage dir) ├── json/ ├── models/ └── wwwroot/ ``` ### scripts/load_path.py Template Follow the `product_management/scripts/load_path.py` pattern: ```python #!/usr/bin/env python3 """ module_name 模块 RBAC 权限管理脚本 使用方法: cd ~/repos/sage ./py3/bin/python ~/repos/module_name/scripts/load_path.py """ import subprocess, os, sys def find_sage_root(): candidates = [ os.path.expanduser("~/repos/sage"), os.path.expanduser("~/sage"), ] for c in candidates: if os.path.isdir(os.path.join(c, "py3")) and os.path.isdir(os.path.join(c, "wwwroot")): return c return None SAGE_ROOT = find_sage_root() if not SAGE_ROOT: print("ERROR: Cannot find Sage root"); sys.exit(1) PYTHON = os.path.join(SAGE_ROOT, "py3", "bin", "python") SET_PERM_SCRIPT = os.path.join(SAGE_ROOT, "set_role_perm.py") MOD = "module_name" PATHS_ANY = [f"/{MOD}/menu.ui"] PATHS_LOGINED = [ f"/{MOD}", f"/{MOD}/page.ui", f"/{MOD}/api/endpoint.dspy", # ... all module paths ] def run_set_perm(role, path): cmd = [PYTHON, SET_PERM_SCRIPT, role, path] return subprocess.run(cmd, capture_output=True, text=True).returncode == 0 def register_role_paths(role, paths): count = sum(1 for p in paths if run_set_perm(role, p)) print(f" {role}: {count}/{len(paths)} paths registered") return count def main(): total = 0 total += register_role_paths("any", PATHS_ANY) total += register_role_paths("logined", PATHS_LOGINED) print(f"Done. Total {total} permission entries registered.") if __name__ == "__main__": main() ``` ### Running the Script ```bash cd ~/repos/sage ./py3/bin/python ~/repos/module_name/scripts/load_path.py ``` ### Rules - **NEVER modify `sage/load_path.py`** for module permissions — use `module/scripts/load_path.py` - Every new page, API, CRUD directory, and `.dspy` file needs a corresponding path entry - Paths follow URL convention: `/modulename/path` (no `wwwroot` in URLs) - Use `logined` for authenticated endpoints, `any` for public ones - After running, restart Sage to reload RBAC cache ## CRUD JSON: data_filter Pattern When a CRUD list needs search/filter functionality, define `data_filter` in the `params` section: ```json { "tblname": "llm", "params": { "data_url": "{{entire_url('../api/llm_list.dspy')}}", "data_filter": { "AND": [ {"field": "name", "op": "LIKE", "var": "name_input"}, {"field": "model", "op": "LIKE", "var": "model_input"}, {"field": "providerid", "op": "=", "var": "providerid_input"}, {"field": "upappid", "op": "=", "var": "upappid_input"} ] }, "filter_labels": { "name_input": "名称", "model_input": "识别名", "providerid_input": "供应商", "upappid_input": "上位系统" } } } ``` ### Backend .dspy: DBFilter Integration ```python #!/usr/bin/env python3 import json from sqlor.filter import DBFilter result = {'success': False, 'rows': [], 'total': 0} try: dbname = get_module_dbname('module_name') page = int(params_kw.get('page', 1)) rows_per_page = int(params_kw.get('rows', 20)) offset = (page - 1) * rows_per_page # Parse data_filter JSON string from frontend filterjson_str = params_kw.get('data_filter') filterjson = None if filterjson_str: try: filterjson = json.loads(filterjson_str) except (json.JSONDecodeError, TypeError): filterjson = None async with DBPools().sqlorContext(dbname) as sor: where_clause = '' filterdic = {} if filterjson: # Preprocess LIKE values: add % wildcards if not already present ns = dict(params_kw) for key, val in ns.items(): if _is_like_var(filterjson, key) and val and '%' not in val: ns[key] = f'%{val}%' dbf = DBFilter(filterjson) conds = dbf.gen(ns) if conds: where_clause = f' WHERE {conds}' filterdic = ns # Count + paginated query using where_clause + filterdic count_sql = f"select count(*) as cnt from tablename{where_clause}" # ... execute count and data queries result['success'] = True except Exception as e: result['error'] = str(e) return json.dumps(result, ensure_ascii=False, default=str) def _is_like_var(filterjson, varname): """Check if a var is used with LIKE operator in the filter tree.""" if not filterjson: return False for key, val in filterjson.items(): if key.upper() in ('AND', 'OR') and isinstance(val, list): for item in val: if _is_like_var(item, varname): return True elif key.upper() == 'NOT' and isinstance(val, dict): if _is_like_var(val, varname): return True elif isinstance(val, dict) and val.get('var') == varname: if val.get('op', '').upper() == 'LIKE': return True return False ``` ### data_filter Rules 1. `data_filter` lives under `params` in the CRUD JSON 2. Uses `AND`/`OR`/`NOT` tree structure matching `sqlor.filter.DBFilter` 3. `var` names map to URL params sent by the frontend search form 4. `filter_labels` provides display labels for the search form fields 5. Backend `.dspy` receives `data_filter` as a JSON string, parses it, feeds to `DBFilter` 6. LIKE fields need `%` wildcards added on the backend (auto-add if not present) 7. `DBFilter.gen(ns)` returns the WHERE clause string; `ns` contains variable values 8. Dropdown fields (providerid, upappid) use `browserfields.alters` with `uitype: "code"` + `dataurl` for code table data ## CRITICAL: menu.ui URL Must Match JSON Alias The `menu.ui` URLs must match the `alias` (or `tblname` if no alias) defined in the JSON CRUD files: | Menu URL | Must match JSON `alias` or `tblname` | |---|---| | `{{entire_url('/module/alias_name')}}` | JSON file must have `"alias": "alias_name"` or `"tblname": "alias_name"` | **Common mistake**: Using a short name in menu URL but a different name in JSON alias: - WRONG: menu URL `/harnessed_agent/sessions` but JSON alias is `hermes_sessions` - CORRECT: menu URL `/harnessed_agent/hermes_sessions` matching JSON alias `hermes_sessions` Also, `.ui` wrapper files that use `entire_url('crud_alias')` to load CRUD pages must use the exact alias from the JSON definition, not a different name. ## CRITICAL: WSS WebSocket URL Routing — ALL paths include `/wss/` prefix Server logs confirm RBAC checks the FULL path including `/wss/` prefix: ``` [debug] userid=None, path='/wss/harnessed_reasoning/reasoning_console.wss' permission check failed ``` All paths MUST include `/wss/`: - **Frontend/UI `entire_url()`**: `{{entire_url('/wss/harnessed_reasoning/reasoning_console.wss')}}` - **RBAC permission paths**: `/wss/harnessed_reasoning/reasoning_console.wss` - **`set_role_perm.py` path arg**: `/wss/harnessed_reasoning/reasoning_console.wss` | WRONG | CORRECT | |-------|---------| | `{{entire_url('reasoning_console.wss')}}` (in UI) | `{{entire_url('/wss/harnessed_reasoning/reasoning_console.wss')}}` | | `/harnessed_reasoning/reasoning_console.wss` (in RBAC) | `/wss/harnessed_reasoning/reasoning_console.wss` | ## CRITICAL: .ui File References Must Not Include Module Prefix When a `.ui` file in `wwwroot/` references another `.ui` file in the same `wwwroot/`, use just the filename: - WRONG: `{{entire_url('harnessed_agent/memory.ui')}}` (resolves to double-prefixed path) - CORRECT: `{{entire_url('memory.ui')}}` (resolves correctly relative to current module) ## CRITICAL: URL Path Rules in JSON Config ### `wwwroot` is INVISIBLE in URLs The `wwwroot` directory is the document root — it NEVER appears in URL paths. | WRONG | CORRECT | |-------|---------| | `{{entire_url('../wwwroot/api/xxx.dspy')}}` | `{{entire_url('../api/xxx.dspy')}}` | | `/module/wwwroot/page.ui` | `/module/page.ui` | ### Relative paths from `json/` directory When a JSON CRUD file in `json/` references files in `wwwroot/`: - Same module: `../api/endpoint.dspy` or `../crud_alias` - Cross module: absolute path `/module_name/api/endpoint.dspy` ### CRUD subtables `url` pattern ```json "subtables": [{ "field": "handover_id", "title": "明细", "url": "{{entire_url('../handover_items_list')}}", "subtable": "customer_handover_items" }] ``` The URL must use `../` prefix to escape the `json/` directory, targeting the CRUD alias defined in another JSON file. ## CRITICAL: `editable` Section Required Every JSON list/crud definition MUST have an `editable` section. Without it, the framework doesn't know where to submit forms for create/update/delete operations. ```json { "tblname": "table_name", "alias": "table_crud", "params": { "editable": { "new_data_url": "{{entire_url('../api/table_create.dspy')}}", "update_data_url": "{{entire_url('../api/table_update.dspy')}}", "delete_data_url": "{{entire_url('../api/table_delete.dspy')}}" }, "browserfields": { ... }, "editexclouded": [ ... ] } } ``` **Pitfall**: `wwwroot` directories contain `.ui` and `.dspy` files, but `json/` CRUD configs must reference them via relative paths that skip `wwwroot`. The framework resolves `../api/xxx.dspy` from the module root, not from `json/`. ## CRITICAL: TabPanel Correct Syntax ### Wrong: Using `Tab` widgettype with `tabs` parameter ```json // WRONG — "Tab" widgettype doesn't exist, "tabs" is invalid { "widgettype": "Tab", "options": { "tabs": [{"title": "Sessions"}, {"title": "Config"}] } } ``` ### Correct: Using `TabPanel` widgettype with `items` parameter ```json // CORRECT { "widgettype": "TabPanel", "options": { "tab_pos": "top", "items": [ { "name": "sessions", "label": "推理会话", "icon": "history", "content": { "widgettype": "urlwidget", "options": { "url": "{{entire_url('crud_alias_or_file.ui')}}" } } } ] } } ``` Key rules: - Widgettype: `"TabPanel"` (NOT `"Tab"`) - Parameter: `items` array (NOT `tabs`) - Each item: `name`, `label`, `content` - `content`: directly embeds a widget description object (e.g., urlwidget) - `tab_pos`: `"top"` (default), `"bottom"`, `"left"`, `"right"` ## CRUD UI File Pattern Tabular for list views: ```json { "widgettype": "Tabular", "options": { "width": "100%", "height": "100%", "data_url": "{{entire_url('api/list_endpoint.dspy')}}", "data_method": "GET", "page_rows": 20, "row_options": { "fields": [ {"name": "id", "width": 80, "frozen": true}, {"name": "field_name", "title": "中文标题", "width": 150}, { "name": "status", "title": "状态", "width": 100, "uitype": "code", "data": [ {"value": "active", "text": "活跃"}, {"value": "inactive", "text": "非活跃"} ] } ], "editexclouded": ["id", "created_at"] }, "editable": { "new_data_url": null, "update_data_url": null, "delete_data_url": null } } } ``` Form for config/edit views: ```json { "widgettype": "Form", "id": "form_id", "options": { "data_url": "{{entire_url('api/get_endpoint.dspy')}}", "data_method": "GET", "submit_url": "{{entire_url('api/save_endpoint.dspy')}}", "method": "POST", "layout": "vertical", "fields": [...], "buttons": [ {"type": "submit", "label": "保存", "variant": "primary"} ], "maxWidth": "500px" }, "binds": [ { "wid": "self", "event": "submited", "actiontype": "script", "script": "await bricks.show_resp_message_or_error(event.params)" } ] } ``` ## .dspy API Pattern **CRITICAL: ahserver `.dspy` files return data via `return`, NOT `print()`.** Using `print()` sends output to stdout which ahserver does not capture — the caller receives `NoneType`. Always use `return json.dumps(...)`. ```python # WRONG — print sends to stdout, ahserver receives None: print(json.dumps(result)) # CORRECT — ahserver captures the return value: return json.dumps(result, ensure_ascii=False, default=str) ``` List endpoint: ```python #!/usr/bin/env python3 import json result = {'success': False, 'rows': [], 'total': 0} try: dbname = get_module_dbname('module_name') user_id = await get_user() sql = """SELECT id, name, status, created_at FROM table_name WHERE user_id = ${user_id}$ ORDER BY created_at DESC""" async with DBPools().sqlorContext(dbname) as sor: data = await sor.sqlExe(sql, {'user_id': user_id}) if isinstance(data, dict): result['total'] = data.get('total', 0) result['rows'] = [dict(r) for r in data.get('rows', [])] else: result['rows'] = [dict(r) for r in (data or [])] result['total'] = len(result['rows']) result['success'] = True except Exception as e: result['error'] = str(e) return json.dumps(result, ensure_ascii=False, default=str) ``` Get config endpoint: ```python #!/usr/bin/env python3 import json result = {'success': False, 'config': {}} try: dbname = get_module_dbname('module_name') user_id = await get_user() sql = """SELECT * FROM config_table WHERE user_id = ${user_id}$ LIMIT 1""" async with DBPools().sqlorContext(dbname) as sor: rows = await sor.sqlExe(sql, {'user_id': user_id}) if rows and len(rows) > 0: config = dict(rows[0]) result['config'] = config else: result['config'] = {...default values...} result['success'] = True except Exception as e: result['error'] = str(e) return json.dumps(result, ensure_ascii=False, default=str) ``` Save config endpoint (returns Message widget): ```python #!/usr/bin/env python3 import json, uuid, time result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}} try: dbname = get_module_dbname('module_name') user_id = await get_user() now = time.strftime('%Y-%m-%d %H:%M:%S') async with DBPools().sqlorContext(dbname) as sor: rows = await sor.sqlExe("SELECT id FROM table WHERE user_id = ${user_id}$", {'user_id': user_id}) if rows: await sor.sqlExe("UPDATE table SET ... WHERE id = ${id}$", {...}) else: await sor.sqlExe("INSERT INTO table ... VALUES (...)", {...}) result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '保存成功', 'type': 'success'}} except Exception as e: result['options'] = {'title': 'Error', 'message': '保存失败: ' + str(e), 'type': 'error'} return json.dumps(result, ensure_ascii=False) ``` ## Menu.ui Pattern Simplified menu with auth check: ```json { "widgettype": "Menu", "options": { "target": "PopupWindow", "popup_options": {"archor": "cc", "width": "70%", "height": "70%"}, "cwidth": 10, "items": [ {% if get_user() %} {"name": "entry_name", "label": "入口名", "url": "{{entire_url('page.ui')}}"} {% endif %} ] } } ``` Note: JSON validation will fail on `.ui` files with Jinja2 templates — this is expected and normal. ## File Organization ``` module_name/ ├── json/ # CRUD metadata definitions │ ├── table_crud.json # List/CRUD config with editable section │ └── table_edit.json # Edit form config ├── models/ # Database table definitions │ ├── table.json # Canonical JSON format (for CRUD + DDL) │ └── table.xlsx # Original Excel source (multi-sheet) ├── wwwroot/ ``` **Note**: `models/` may contain both `.json` and `.xlsx` files. JSON is the canonical format used by CRUD and DDL generation. Convert xlsx→json with `~/repos/sage/xlsx2json_models.py`. See `database-table-definition-spec` skill for format details. │ ├── menu.ui # Module menu │ ├── index.ui # Main page │ ├── page.ui # UI components │ ├── table_crud.ui # Generated or manual CRUD UI │ └── api/ │ ├── table_list.dspy # Data list API │ ├── table_create.dspy # Create API │ ├── table_update.dspy # Update API │ └── table_delete.dspy # Delete API └── module_name/ ├── __init__.py # Empty ├── init.py # load_module() function, registers to ServerEnv └── core.py # Business logic ``` ## Debugging Reference See `references/module-init-deferred-io.md` for robust module initialization patterns — deferred I/O, graceful degradation, and per-subsystem try/except wrapping. See `references/websocket-ui-debugging.md` for: - Step-by-step WebSocket UI diagnosis (log verification, curl template check, widget conflict detection) - Server log pattern recognition: `WS Registered` in logs means backend is fine — problem is frontend - Common root cause table for "等待连接" symptoms - Browser console error decoding: `wid not find`, `HTML not registered`, `ReferenceError` patterns See `references/sage-login-debugging.md` for: - Login flow: rfexe('password', params_kw) RC4 encryption, users table query, remember_user - Common failures: commented-out rf.register, silent password mismatch, RBAC 401 - RBAC cache requires Sage restart after set_role_perm.py - Debugging checklist: app/rf.py, conf/config.json password_key, server log patterns See `references/bricks-ui-pitfalls.md` for: - Widgettype casing: `Html` NOT `HTML`; `Scroll` does not exist (use VBox + CSS) - Form buttons cannot have external binds — use standalone Button widgets - Multi-line input via `uitype: "text"` + `height` on Form fields - Raw JS WebSocket preferred over `bricks.WebSocket` widget (avoids ReferenceError timing issues) - Session passing: `bricks.app.get_session()` → WebSocket sub-protocol See `references/sage-session-debugging.md` for: - Session diagnosis checklist: auth_api.py truncation, Redis, wwwroot diff, Jinja2 template calls, URL resolution in user_panel.ui, RBAC permissions - Architecture overview: remember_user() → auth.remember() → ticket → Redis session storage - Known root causes: user_panel.ui path change, global_menu.ui get_user_roles() at template level See `references/sage-auth-and-websocket.md` for: - Cookie-based session flow and Redis storage - WebSocket authentication via Sec-WebSocket-Protocol - RBAC permission path formats (with/without /wss/) - Common auth pitfalls (cookie secure flag, RBAC blocking, field names) - Users table schema and login field names - Role types (anonymous, any, logined, owner) - Debugging commands for auth issues See `references/sage-deployment-architecture.md` for: - sage/wwwroot/ is gitignored — all content is symlinks from module repos - global_menu.ui architecture (lives in dashboard_for_sage, symlinked to sage/wwwroot/) - Module discovery technique for global_menu (scanning wwwroot/index.ui) - bricks/ static files deployment (header.tmpl, footer.tmpl, JS, CSS) - Error diagnosis: `'NoneType' object has no attribute 'be_call'` = missing bricks static file - Production deployment checklist for new module content See `references/sage-background-coroutines.md` for: - Registry of all modules using `add_cleanupctx` / `add_startup` hooks - Multi-process safety matrix (which hooks must be extracted) - Standalone program pattern for background tasks - Cookie secure/samesite pitfall for HTTP development - RBAC permission for login endpoints - Login form field names and database schema - **Sage wwwroot symlink requirement** — new module files must be linked - **Git branch strategy** — main branch only, no feature branches - **Self-testing requirement** — verify before reporting See `references/sage-testing-environment.md` for: - Remote and local test environment details (URLs, credentials, database) - Browser testing prerequisites (Chrome CDP, Redis, server status) - 502 Bad Gateway troubleshooting flowchart - RBAC permission testing and cache refresh - Password encoding and LLM configuration patterns See `references/sage-i18n-system.md` for: - Sage i18n architecture (MiniI18N backend + bricks.js frontend) - msg.txt encoding rules and file locations - Scanning strategy: MUST use regex fallback for template-containing .ui files - CRUD, .dspy, and dashboard string extraction patterns See `references/sage-environment.md` for: - Password encoding/decoding patterns - Common Sage commands (startup, RBAC, DDL) - harnessed_agent and harnessed_reasoning required tables - Known issues and workarounds See `references/vendor-callback-pattern.md` for: - External vendor webhook/callback endpoint patterns (`.dspy` + Python handler) - Vendor POST parsing (JSON body + query params fallback) - Idempotency handling for retried callbacks - RBAC `paths_any` registration for vendor endpoints - Client query API pattern for authenticated resource lookups - **Bearer token auth model**: dapi auto-resolves user_id/org_id, no manual `downapp_id` needed - **Never expose internal IDs** to client APIs — only vendor-side identifiers See `references/debugging-guide.md` for: - Deployed code vs source code identification - Debug logging patterns (`info()` vs `debug()`) - Common error: `'NoneType' object has no attribute 'get'` - Non-JSON LLM API response handling - Encrypted API key decryption **Verifying Jinja2 template rendering via curl** When a `.ui` file uses Jinja2 templates like `{{ get_user() }}`, verify they render correctly: ```bash # Unauthenticated request (no session cookie) — get_user() renders as 'None' curl -s http://localhost:9180/module/page.ui | grep -o "user_id:.*" | head -1 # The rendered output should show the template was processed: # user_id: 'None'})); <-- correct (not logged in, get_user() returns None) # user_id: '{{ get_user() }}' <-- WRONG (template not processed at all) # user_id: 'current_user' <-- WRONG (hardcoded string, not a template) ``` If the raw `{{ get_user() }}` appears in the curl output, the template is not being processed — check that the file is served through ahserver's template renderer, not as a static file. See `references/event-dispatcher-cache-pattern.md` for: - EventDispatcher lifecycle events dispatched by sqlor (C/U/D before/after) - Module cache status: RBAC (partial + bug), Pricing (partial), Llmage (none) - LRU cache implementation with async-safe locks and TTL - Event handler registration pattern in module init.py - Known bug: RBAC uses `this` instead of `self` in userperm.py - Naming convention: `{dbname}:{tablename}:{c|u|d}:{before|after}` - **CRITICAL: EventDispatcher is process-scoped — in SO_REUSEPORT multi-process deployment, events fired in one worker are invisible to all other workers. Migrate to Redis Pub/Sub for cross-process cache invalidation. See `references/multi-process-cache-invalidation.md`.** See `references/user-isolation-pattern.md` for: - Context propagation chain (reasoning -> execute_tool -> tool wrapper) - User-isolated directory structure (~/.hermes/users/{user_id}/) - `_get_user_dir()` helper pattern for tool wrappers - Per-user WebSocket callbacks (ws_push_callbacks dict) - Table name fix: `hermes_skills` (NOT `harnessed_skills`) See `references/reasoning-visualization.md` for: - Complete event flow for reasoning visualization (context/plan/safety/execution) - Per-user WebSocket callback registration pattern in .wss endpoint - File-based skill search across user and shared directories **Symptom: "等待连接" / WebSocket never connects** If the reasoning console shows "等待连接..." and never transitions to "已连接": 1. Check HTML widget JavaScript for hardcoded `user_id: 'current_user'` — must be `{{ get_user() }}` 2. Verify RBAC permission exists for the `.wss` path (without `/wss/` prefix) 3. Check Redis is running (Sage sessions depend on `redis://127.0.0.1:6379`) 4. Verify `.wss` file exists at `wwwroot/endpoint.wss` and defines `async def myfunc(request, **kwargs)` ## Testing: Use Browser Tools, NOT curl **CRITICAL: When testing Sage web features, ALWAYS use browser tools (`browser_navigate`, `browser_click`, `browser_type`, etc.) — NEVER curl.** curl cannot carry the browser's session cookies through the RBAC authentication flow, so curl tests produce false negatives. The browser automatically handles: - Cookie-based session management (AIOHTTP_SESSION) - JavaScript widget rendering (bricks framework) - Form submissions via the bricks UI layer **Correct testing pattern:** ```python # Navigate to the page browser_navigate(url='http://localhost:9180/module/page.ui') browser_snapshot() # Check what's rendered # For login-required pages, interact with the login form: browser_navigate(url='http://localhost:9180/index.ui') browser_type(ref='@e38', text='superuser') # username field browser_type(ref='@e39', text='Kyy@123456') # password field browser_click(ref='@e26') # submit button sleep(2) # Then navigate to the target page browser_navigate(url='http://localhost:9180/harnessed_reasoning/reasoning_console.ui') browser_snapshot() ``` **Wrong: Using curl for authentication testing:** ```bash # WRONG - curl cannot maintain session state through RBAC login curl -b /tmp/cookies.txt https://sage.example.com/module/page.ui ``` ## Common Pitfalls Checklist - [ ] **Stay focused on the current module** — when the user says "你跑飞啦,停,聚焦在XX模块", immediately stop all cross-module investigation and return to the current module's issues. Do not jump to other modules, dashboards, or unrelated features until the current module's problems are resolved. The user will explicitly redirect you when ready. - [ ] **Understand the requirement before coding** — if the user says "完全错误" (completely wrong), you have fundamentally misunderstood the request. Stop immediately, re-read the user's message, and ask clarifying questions rather than continuing down the wrong path - [ ] **Be proactive with configuration** — when you have the necessary credentials or paths (API keys, model URLs, database configs), configure them immediately without waiting for user confirmation. The user expects you to "该配什么配什么" (configure what needs configuring) rather than asking permission for each step. - [ ] **Use existing RBAC modules, don't recreate** — never write custom login logic in `sage/wwwroot/` when RBAC provides `/rbac/user/login.ui`. Never duplicate files that already exist in module directories. - [ ] **Follow established development standards** — the user has zero tolerance for errors at critical moments. Always load relevant skill docs (`bricks-framework`, `crud-definition-spec`, `module-development-spec`) before making changes. Systematically verify all affected files, not just the ones you touched. - [ ] `entire_url` paths must NOT include `wwwroot` - [ ] JSON CRUD files MUST have `editable` section with `new_data_url`, `update_data_url`, `delete_data_url` - [ ] TabPanel uses `items` NOT `tabs`, widgettype is `TabPanel` NOT `Tab` - [ ] TabPanel `content` directly embeds widget description (e.g., urlwidget) - [ ] SQL parameters use `${param}$` format in `.dspy` files - [ ] `sor.sqlExe(sql, ns)` ALWAYS requires the second `ns` argument — use `{}` when no parameters - [ ] `.dspy` files return `json.dumps()` strings - [ ] CRUD save endpoints return Message widget format - [ ] `get_module_dbname('module_name')` to get database name - [ ] `await get_user()` for current user ID - [ ] JSON files in `json/` directory reference wwwroot files via `../` relative paths - [ ] `.ui` files' `entire_url()` arguments MUST be quoted strings, NOT bare variables - [ ] Same rule applies to JSON subtables `url` fields - [ ] .dspy files are wrapped by framework in `async def myfunc()` — py_compile will show "await outside function" errors, which is expected and normal - [ ] CRUD .dspy API files go in `wwwroot/api/` directory - [ ] DELETE operations must include `AND user_id = ${user_id}$` for multi-user isolation - [ ] UPDATE operations must include `AND user_id = ${user_id}$` for multi-user isolation - [ ] **Python backend: NEVER use `sor.sqlExe()` with ORDER BY or LIMIT** — use `sor.R(table, ns_dict)` with Python slicing instead - [ ] **Python backend: ALL queries must filter by user_id** — `sor.R('table', {'user_id': user_id, 'sort': 'field desc'})` - [ ] **Python backend: `sor.R()` signature is `R(tablename, ns, filters=None)`** — `ns` (2nd arg) is a SINGLE dict containing BOTH filter conditions AND sort/page options. NO `ns=` keyword, NO 3rd-arg filters for normal queries - [ ] **Python backend: NEVER create `DBPools()` in `__init__()`** — create it locally in each function that needs database access - [ ] **Python backend: NEVER use `db.sqlorContext('default')`** — always pass the actual module name (e.g., `'harnessed_agent'`, `'customer_management'`) - [ ] **Debugging: `rf.register('password', ...)` in `app/rf.py` must be uncommented** — login `.dspy` calls `await rfexe('password', params_kw)` to RC4-encrypt the password before DB lookup. If commented out, login silently fails with "user name or password error". After uncommenting, restart Sage - [ ] **Debugging: `decode_password` in `app/rf.py` has typo `config.getConfig()`** — should be `config = getConfig()` - [ ] **Debugging: always report the root cause when something fails** — user has zero tolerance for silent failures. When a test or fix doesn't work, immediately explain WHY (log output, curl verification, specific error message), not just "it didn't work". User will explicitly ask "出错了不报告出错原因吗" if you skip this - [ ] **Debugging: test features yourself using available tools before asking the user** — use curl, server logs, or programmatic probes to verify fixes. User expects self-testing ("你自己操作浏览器测试"), not "please try it yourself". When browser_navigate or other UI tools are unavailable (Chrome zombie processes, etc.), use curl + log analysis as alternative verification - [ ] **Debugging: when server logs show `WS Registered` but UI shows "等待连接", check RBAC permission path** — server logs reveal the exact path RBAC checks (e.g., `path='/wss/.../xxx.wss'`). If the permission is registered without `/wss/`, it will fail with `permission check failed` - [ ] **Debugging: deployed code runs from site-packages, not repo** — use log line numbers to identify version - [ ] **Debugging: config.website.ssl may be None even when hasattr returns True** — `hasattr(config.website, 'ssl')` returns True if the key exists in JSON, but the value may be `None`. Always check `if self.conf.website.ssl:` before accessing attributes - [ ] **Debugging: use `info()` for debug output, not `debug()`** — debug may be filtered by log level - [ ] **Tool wrappers: core tools must execute real operations, not return mock dicts** — `read_file`, `write_file`, `terminal`, `execute_code`, `memory`, `skill_manage`, `todo` must all do real work - [ ] **Tool wrappers: accept `context` param for user isolation** — memory, skills, todo, execute_code wrappers must use `_get_user_dir()` - [ ] **Widgettype casing matters**: use `Html` (mixed case), NOT `HTML` (all caps). `HTML` will fail with "widgetBuild(): HTML not registered". Also: `Scroll` widgettype does NOT exist — use `VBox` with `style: "overflow-y: auto;"` instead - [ ] **Form internal buttons cannot have external binds**: Buttons inside `Form.options.buttons` are handled by Form's submit mechanism. If you need custom click handlers (not form submit), use standalone `Button` widgets OUTSIDE the Form. Otherwise you get "desc wid not find" errors - [ ] **Frontend JS in HTML widgets: use `{{ get_user() }}` for user_id, NOT hardcoded `'current_user'` string** — JavaScript inside `.ui` HTML widgets runs client-side and has no server session; user_id must be injected via Jinja2 template rendering - [ ] **Raw JS WebSocket is more reliable than `bricks.WebSocket` widget**: The widget's event binds require handler functions to exist before widget initialization. If `Html` widget renders after `WebSocket` widget, you get `ReferenceError`. Use a single `Html` widget with raw JS `new WebSocket(url, session)` instead ## Python Backend Code: Database Query Patterns **CRITICAL: `sor.R()` signature is `R(tablename, ns, filters=None)`**. The 2nd arg `ns` is a SINGLE dict containing BOTH filter conditions AND sort/page options. No `ns=` keyword argument needed. ### Sorting: Put `sort` in the same dict as filters ```python # WRONG -- ns= keyword argument: rows = await sor.R('users', {'status': 'active'}, ns={'sort': 'created_at desc'}) # WRONG -- sqlExe with ORDER BY: sql = "SELECT * FROM users WHERE status = :status ORDER BY created_at DESC" rows = await sor.sqlExe(sql, {'status': 'active'}) # CORRECT -- filter conditions + sort in ONE dict (2nd arg): rows = await sor.R('users', {'status': 'active', 'sort': 'created_at desc'}) ``` ### Limiting: Use Python Slicing NOT `sqlExe("LIMIT n")` ```python # WRONG -- sqlExe with LIMIT: sql = "SELECT * FROM users WHERE status = :status LIMIT 10" rows = await sor.sqlExe(sql, {'status': 'active'}) # CORRECT -- sor.R with sort, then Python slicing: rows = await sor.R('users', {'status': 'active', 'sort': 'created_at desc'}) rows = (rows or [])[:10] ``` ### OFFSET: Use Python Slicing ```python # CORRECT: rows = await sor.R('users', {'status': 'active', 'sort': 'created_at desc'}) rows = (rows or [])[20:20 + 10] # [offset:offset+limit] ``` ### Multi-user Isolation: Every Query Needs user_id ```python # CORRECT -- user isolation + sort in one dict: rows = await sor.R('hermes_sessions', {'user_id': user_id, 'sort': 'started_at desc'}) rows = (rows or [])[:50] ``` ### $or Conditions: Put everything in the ns dict ```python rows = await sor.R('hermes_skills', { 'user_id': user_id, '$or': [ {'name': {'$like': '%keyword%'}}, {'description': {'$like': '%keyword%'}} ] }) rows = (rows or [])[:2] ``` ### sor.R vs sor.sqlExe Decision | Use `sor.R(table, ns_dict)` | Use `sor.sqlExe(sql, params)` | |---|---| | Simple CRUD reads with filtering | INSERT/UPDATE/DELETE operations | | Need sorting via `'sort': '...'` in ns dict | Complex joins or subqueries | | Multi-user isolation with user_id filter | When sor.R can't express the query | ## LLM Client Pattern (harnessed_agent) **CRITICAL: harnessed_agent is an LLM CLIENT, not a server.** It calls external LLM provider APIs — it does NOT serve `/v1/chat/completions` endpoints to others. ### Architecture ``` harnessed_agent (client) --aiohttp POST--> LLM Provider (OpenAI/DashScope/DeepSeek/SiliconFlow) /v1/chat/completions ``` The `llm_client.py` module provides 5 functions registered to ServerEnv: - `llm_chat(messages, model, temperature, ...)` -> OpenAI response dict - `llm_chat_stream(messages, ...)` -> async generator yielding {delta, finish_reason, raw} - `llm_simple(prompt, system)` -> plain text string - `llm_list_models()` -> provider model list - `llm_get_config()` -> current config (key masked) ### Usage in .dspy files ```python # Standard chat call result = await llm_chat( messages=[ {"role": "system", "content": "You are helpful"}, {"role": "user", "content": "Hello"} ], model="qwen3-max", temperature=0.7 ) # result matches OpenAI format: {"choices": [{"message": {"content": "..."}}], "usage": {...}} # Stream mode async for chunk in llm_chat_stream(messages=[...]): text = chunk['delta'] # accumulated text # Simple text-only call answer = await llm_simple("What is 2+2?", system="Answer briefly") ``` ### Provider Presets Configured in `harnessed_agent_config` table: | Field | Description | |---|---| | `llm_provider` | Preset name: `dashscope` (default), `openai`, `deepseek`, `siliconflow`, or empty for custom | | `llm_service_url` | Base URL (auto-filled from preset, or custom URL) | | `llm_api_key` | Bearer token for authentication | | `default_model` | Default model name (e.g. `qwen-plus`) | | `default_temperature` | Default temperature (float, length=5, dec=2) | | `top_p` | Default top_p (float, length=5, dec=2) | Preset URLs: - dashscope: `https://dashscope.aliyuncs.com/compatible-mode/v1` - openai: `https://api.openai.com/v1` - deepseek: `https://api.deepseek.com/v1` - siliconflow: `https://api.siliconflow.cn/v1` ### Resilience Requirements LLM client MUST implement: - **Retry with exponential backoff**: 3 attempts for transient errors (timeout, 500, connection failure) - **429 rate limit handling**: Read `Retry-After` header, wait and retry - **Structured logging**: Use `appPublic.log` (info/warning/error) for request params, response timing, token counts - **Error propagation**: Return OpenAI-compatible error dict `{"error": {"message": "...", "type": "...", "code": N}}` ## harnessed_reasoning Pattern: LLM-Based Reasoning Engine **harnessed_reasoning** is a REAL reasoning engine, not a mock. It uses harnessed_agent's LLM client and tool execution system to perform actual AI reasoning and task execution. ### Architecture ``` User Input -> reasoning_console.ui (Form) | v reasoning_submit.dspy | v hermes_reason_and_execute() / \ / \ LLM Planning Tool Execution (llm_chat) (harnessed_execute_tool) \ / \ / v v Execution Plan + Results -> stored in DB ``` ### How It Works 1. **Context Gathering**: Calls `harnessed_get_intelligent_memory_context` + session search + skill search to build real context 2. **LLM Planning**: Calls `llm_chat()` with a reasoning system prompt that includes available tool descriptions, asking LLM to return a JSON execution plan 3. **Safety Check**: Validates the plan against configurable safety rules (strict/moderate/lenient) — blocks dangerous commands like `rm -rf /` 4. **Tool Execution**: If safe and `execute_immediately=True`, calls `harnessed_execute_tool()` for each action in the plan 5. **Error Recovery**: Auto-recovers from common failures (e.g., read_file not found -> search_files; permission denied -> strip sudo prefix) 6. **Session Storage**: Stores all reasoning sessions in `harnessed_reasoning_sessions` table ### Available Tools (17) read_file, write_file, search_files, patch, terminal, process, execute_code, memory, skill_manage, skill_view, todo, session_search, cronjob, clarify, delegate_task, text_to_speech, vision_analyze ### Reasoning Config Configured in `harnessed_reasoning_config` table: - `model_name`: LLM model for planning (default: `qwen3-max`) - `temperature` / `top_p`: LLM parameters - `system_prompt`: Custom reasoning system prompt (overrides default) - `safety_mode`: `strict` / `moderate` / `lenient` - `max_reasoning_steps`, `max_tool_calls_per_step`: Execution limits - `enable_error_recovery`: Auto-recovery on tool failures ## Sage Multi-Process Deployment Architecture ### SO_REUSEPORT: Multiple Workers Share One Port ahserver's `ConfiguredServer.run()` sets `reuse_port=True` on Linux, allowing multiple `sage.py` processes to bind to the same port. The kernel distributes incoming connections across workers (similar to nginx/gunicorn worker model). **Pattern in `start.sh`:** ```bash WORKERS=$(nproc) # auto-detect CPU cores for (( i=0; i "logs/sage_worker_${i}.log" 2>&1 & done ``` All workers listen on the same `$PORT`. No load balancer or port range needed. ### Background Coroutines Must NOT Run in Every Worker **The Problem**: Sage modules register background coroutines via `add_cleanupctx()` and `add_startup()`. These are attached to aiohttp's `app.cleanup_ctx` and `app.on_startup` hooks. In multi-process mode, EVERY worker would start its own copy of the background task — causing duplicate work, double-charging, race conditions on DB records. **Identify background coroutines**: Search module `init.py` files for: - `add_cleanupctx(coro)` — runs at server startup, cleaned up on shutdown - `add_startup(coro)` — runs on aiohttp app startup - `asyncio.create_task(...)` inside init or startup hooks **Known modules using these patterns:** | Module | Hook | Background Task | What It Does | |--------|------|-----------------|--------------| | `llmage` | `add_cleanupctx(start_backend)` | `backend_accounting()` | Periodic LLM usage billing loop (every 10s) | | `unipay` | `add_startup(setup_callback_path)` | Registers payment callback routes | (route registration, safe to duplicate) | **The Fix**: Extract background coroutines into standalone programs, start them once in `start.sh` BEFORE the sage.py workers, and remove the `add_cleanupctx`/`add_startup` calls from module `init.py`. **Example: Extract `backend_accounting` from llmage:** 1. Create standalone program `bin/backend_accounting.py`: ```python #!/usr/bin/env python import os, sys, asyncio, signal os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, 'py3/lib/python3.10/site-packages') sys.path.insert(0, 'pkgs') from appPublic.folderUtils import ProgramPath from appPublic.jsonConfig import getConfig from sqlor.dbpools import DBPools from appPublic.log import MyLogger, info, exception from llmage.accounting import get_accounting_llmusages, llm_accounting, llm_accoung_failed # Init config + DB p = ProgramPath() config = getConfig(NS={'workdir': os.getcwd(), 'ProgramPath': p}) DBPools(config.databases) async def backend_accounting(): info('backend accounting started ...') while True: try: lus = await get_accounting_llmusages() except Exception as e: exception(f'{e}') lus = [] for lu in lus: try: await llm_accounting(lu) except Exception as e: exception(f'{e}, {lu.id=}') await llm_accoung_failed(lu.id) await asyncio.sleep(10) def main(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) signal.signal(signal.SIGTERM, lambda s, f: [t.cancel() for t in asyncio.all_tasks(loop)] or loop.stop()) try: loop.run_until_complete(backend_accounting()) except asyncio.CancelledError: pass finally: loop.close() if __name__ == '__main__': main() ``` 2. Remove from `llmage/init.py`: ```python # REMOVE these lines: # from ahserver.configuredServer import add_cleanupctx # async def start_backend(app): ... # add_cleanupctx(start_backend) ``` 3. Update `start.sh` to start it first: ```bash # Step 1: Start independent background programs nohup $PYTHON bin/backend_accounting.py > logs/backend_accounting.log 2>&1 & echo "backend_accounting:$!" >> sage_backend.pid # Step 2: Start Sage web workers (SO_REUSEPORT) for (( i=0; i "logs/sage_worker_${i}.log" 2>&1 & echo "$!" >> sage.pid done ``` 4. Update `stop.sh` to stop both: ```bash # Stop workers from sage.pid while read pid; do kill $pid 2>/dev/null; done < sage.pid # Stop background programs from sage_backend.pid (format: name:pid) while IFS=: read name pid; do kill $pid 2>/dev/null; done < sage_backend.pid ``` ### Sage standalone scripts — DBPools pattern When writing standalone scripts (data migration, seeding, export) that need database access outside the Sage server context: ```python from appPublic.jsonConfig import getConfig # NOT appPublic.getConfig from sqlor.dbpools import DBPools import asyncio config = getConfig('.') db = DBPools(config.databases) dbname = list(config.databases.keys())[0] async def run(): async with db.sqlorContext(dbname) as sor: # use sor asyncio.run(run()) ``` **Inside Sage server context** (`.dspy`, module code): use `get_sor_context(env, 'modulename')` instead. ## CRITICAL: uapi.headers Template Rendering — No Helper Functions Needed The `uapi.headers` field is a **JSON template string**. Template variables are rendered at runtime before each HTTP request: | Template variable | Renders to | |---|---| | `{{apikey}}` | The decrypted API key from `upappkey` table | | `{{jsondata}}` | The JSON request body | | `{{response}}` | Response transformation template | **For Vidu-style Token auth, use `{{apikey}}` directly in the headers JSON — NO helper function needed:** ```json {"Content-Type": "application/json", "Authorization": "Token {{apikey}}"} ``` Do NOT add a `token()` or similar helper function to `uapi/appapi.py` — the template engine handles `{{apikey}}` substitution natively. Other auth patterns: `Bearer {{apikey}}` (OpenAI-compatible), `{{bearer(apikey)}}` (with bearer() helper if needed for complex signing). ## Pitfalls - **`add_cleanupctx` vs `add_startup`**: `add_cleanupctx` is an async context manager (`yield` pattern) that runs on startup and cleanup. `add_startup` runs on app startup only. Both are per-process hooks. - **Route registration via `add_startup`** (like unipay's `setup_callback_path`) is technically safe to duplicate across workers since aiohttp route registration is idempotent within each process. But background *tasks* (loops, periodic jobs, consumers) MUST be extracted. - **PID file format**: Use `name:pid` format for background program PIDs to enable named stop commands. Use plain `pid` format for worker PIDs (one per line). - **Signal handling**: Background programs should handle `SIGTERM` to gracefully cancel asyncio tasks. Without this, `kill` will leave DB connections in inconsistent state. - **DBPools in standalone programs**: Must initialize `DBPools(config.databases)` before importing module functions that use it. Unlike sage.py workers (where `webapp()` does this), standalone programs must do it manually. - **Logging**: Use `MyLogger` with a dedicated log file path. Standalone programs don't inherit Sage's log config. ### EventDispatcher Cache Invalidation is Process-Scoped — Use Redis Pub/Sub for Multi-Process **EventDispatcher events from sqlor C/U/D lifecycle hooks only fire within the current process.** In Sage's SO_REUSEPORT multi-process deployment, when worker A modifies data and fires a cache invalidation event, workers B through N never receive it — they continue serving stale cached data. **Fix:** Use Redis Pub/Sub as the cross-process invalidation channel. Each worker maintains a local cache (fast reads) and subscribes to a shared `sage:cache:invalidate` channel. When any worker publishes an invalidation message, all workers evict the corresponding local cache entry. Add a TTL fallback to handle missed messages. See `references/multi-process-cache-invalidation.md` for the complete pattern and reference implementation. ### External API Design Patterns - **DO NOT write direct HTTP clients for vendor APIs — use Sage's uapi gateway instead.** Sage provides a complete external API routing system via the `uapi` module (`pkgs/uapi/`). Write a direct HTTP client (like `volcengine_client.py`) only when the uapi gateway cannot express the protocol. For standard REST/JSON APIs, use uapi. #### uapi Gateway Pattern **Tables**: `upapp` (vendor definition), `uapi` (API endpoints per vendor), `upappkey` (API credentials), `uapiio` (I/O schemas). ``` Client → dapi(Bearer) → your_module/api/xxx.dspy ↓ vendor_id → lookup upappid + apiname in your module's config table ↓ from uapi.uapi import UpAppApi ua = UpAppApi(request) resp = await ua.call(upappid, apiname, callerid, params) ↓ uapi gateway → renders headers/data/response templates → HTTP → vendor ``` **Module's config table should register per-vendor API mappings:** ```python # In your module's vendor_config table: # vendor: "volcengine" # upappid: "upapp-volcengine-01" # references upapp.id # apiname_create_session: "CreateVisualValidateSession" # apiname_get_result: "GetVisualValidateResult" # apiname_create_asset: "CreateAsset" # ... one apiname per vendor operation ``` **Calling a vendor API from your module's .dspy or init.py:** ```python from uapi.uapi import UpAppApi async def call_vendor_api(vendor_id, action, params): # 1. Look up the vendor config to get upappid + apiname config = await get_vendor_config(vendor_id) upappid = config.upappid apiname = getattr(config, f'apiname_{action}') callerid = await get_user() # 2. Call through uapi gateway ua = UpAppApi(request) resp = await ua.call(upappid, apiname, callerid, params) # 3. Parse response (uapi.response template may have transformed it) return json.loads(resp.decode('utf-8')) ``` **uapi configuration (set up once per vendor via Sage admin):** - `upapp`: Define the vendor (name, base URL, auth type) - `uapi`: Register each API endpoint. The `upappid + name` combo is unique. Contains `path`, `httpmethod`, `headers` (JSON template with `{{apikey}}`), `data` (JSON template with params), `response` (output transformation template) - `upappkey`: Store `apikey`/`secretkey` per upapp (encrypted) **When to use direct HTTP vs uapi:** | Use uapi gateway | Use direct HTTP client | |---|---| | Standard REST/JSON APIs | Non-HTTP protocols (gRPC, WebSocket to vendor) | | Vendor has OpenAPI/REST interface | Vendor requires complex HMAC signing not expressible in templates | | Multiple vendors for same operation (easy to add via config) | Vendor API is a one-off with no reuse pattern | | Need response templating/transformation | Need streaming/chunked responses with custom parsing | - **Bearer token auth: dapi module auto-resolves identity** — all client-facing `.dspy` APIs get `user_id` via `await get_user()` and `org_id` via `await get_userorgid()`. Never add `downapp_id`, `client_id`, or manual identification parameters to API endpoints. The Bearer token IS the identifier. - **Never expose internal DB IDs to clients** — client API responses and parameters should only use vendor-side identifiers (e.g., `vendor_group_id` not `local_group_id`). Internal IDs are meaningless to downstream systems and create unnecessary coupling. - **Client-facing upload endpoints accept vendor-side IDs** — validate ownership by looking up `rl_org_group(org_id, vendor_group_id)`, then use the internal `local_group_id` only for FK relationships in local tables. - **Vendor callbacks are `paths_any`** — vendor POSTs have no session, cannot authenticate. Register callback endpoints in `paths_any`, not `paths_logined`. - **Callback idempotency is mandatory** — vendors may retry; check for existing mapping/status before inserting. ### Schema Migration Pattern (xlsx models) When modifying model fields (adding/removing columns): 1. Update the `.xlsx` file in `models/` 2. Create a production migration script that: (a) checks if the old column exists, (b) creates new tables/indexes, (c) migrates data, (d) optionally drops the old column 3. Make migration idempotent — safe to run multiple times 4. Copy updated files to `pkgs/` directory and reinstall: `cd pkgs/module && pip install -e .` 5. Restart Sage after code changes ### pyproject.toml Dependencies ONLY declare `sqlor` and `bricks_for_python`. Do NOT declare `ahserver`, `apppublic`, `appbase`, `rbac` — these are installed by `build.sh`, not pip. ```toml dependencies = [ "sqlor", "bricks_for_python", ] ``` ### Python Backend: DBPools() Lifecycle, Singleton Fork Safety, and sqlorContext() Module Name Three critical rules for all Python backend code (core.py, etc.): **Rule 1: DBPools() must be created in function scope, NEVER in `__init__()`** ```python # WRONG: class MyClass: def __init__(self): self.db = DBPools() # CORRECT: class MyClass: def __init__(self): pass async def query(self): db = DBPools() async with db.sqlorContext(dbname) as sor: ... ``` **Rule 2: DBPools is a Singleton — in forked child processes, must manually set `db.databases` AND use `env.get_module_dbname()`** `DBPools` is decorated with `@SingletonDecorator`. The decorator caches the first instance: `__call__` returns the cached instance if it exists, completely ignoring new arguments. In forked child processes (where `.dspy` files execute), the inherited parent instance persists, so `DBPools(config.databases)` returns the old instance with empty/stale `databases`. The passed `config.databases` argument is silently discarded. Additionally, `sqlorContext()` receives a database **key** (e.g., `'crm_db'`), NOT a module name. Hardcoding `'harnessed_agent'` as the key fails because that key doesn't exist in `config.databases`. The main app's `get_module_dbname()` resolves all modules to the actual database key. **Complete required pattern for ALL database access in Python backend code:** ```python from ahserver.serverenv import ServerEnv from appPublic.jsonConfig import getConfig from sqlor.dbpools import DBPools async def my_query(): env = ServerEnv() dbname = env.get_module_dbname('my_module') # resolves DB key dynamically (e.g., 'crm_db') config = getConfig() db = DBPools() # Returns Singleton instance (fork-safe) db.databases = config.databases # MUST force-set to override inherited empty dict async with db.sqlorContext(dbname) as sor: rows = await sor.R('table', {...}) ``` This 5-line template handles: 1. Singleton fork safety (`db.databases = config.databases` overwrites inherited empty dict) 2. Dynamic database resolution (`env.get_module_dbname()` returns the actual DB key like `'crm_db'`) 3. Proper context management **NEVER** hardcode a database name string in `sqlorContext()`. **NEVER** pass `config.databases` as a constructor argument to `DBPools()` — it will be silently ignored due to the Singleton. Always use the 5-line pattern above. ### harnessed_agent Tool Permission: Internal Calls Must Not Be Blocked The `_get_user_permissions()` method in `harnessed_agent/core.py` must NOT restrict permissions for empty/missing context. Internal workflow calls (e.g., reasoning engine executing tools) often pass `context=None`, and if the method returns only read-only permissions for anonymous users, tools like `write_file`, `memory`, `clarify` will fail with `"Insufficient permissions to execute tool 'X'"`. **Fix**: Grant full permissions unconditionally regardless of whether `context` is present: ```python def _get_user_permissions(self, context: Dict[str, Any]) -> List[str]: # Internal system calls should not be blocked by permission checks return [ 'file_read', 'file_write', 'system_execute', 'system_manage', 'browser_access', 'ai_vision', 'ai_tts', 'memory_manage', 'memory_read', 'skill_read', 'skill_manage', 'task_manage', 'task_delegate', 'user_interact', 'schedule_manage', 'config_read' ] ``` ### CRUD JSON Strict Validation Checklist When creating or modifying CRUD JSON files in `json/`, validate EVERY field reference against the model definition in `models/`. Common field name mismatches found: | File | Wrong Field | Correct Field (from model) | |------|-------------|----------------------------| | opportunities_list.json | `org_id` | (does not exist — remove) | | opportunities_list.json | `sales_stage` | `current_stage` | | opportunities_list.json | `source` | `source_type` | | sales_stages_list.json | `is_active` | `is_won_stage` / `is_lost_stage` | | stage_history_list.json | `changed_by` | `changed_by_id` / `changed_by_name` | **Every CRUD JSON file MUST have:** 1. `tblname` root key matching a table in `models/` 2. `params` dict with at least `sortby` and `browserfields` 3. `editable` dict with `new_data_url`, `update_data_url`, `delete_data_url` (even if read-only, provide the URLs) 4. All field names in `browserfields.exclouded`, `browserfields.alters`, and `editexclouded` must exist in the model 5. `alters` entries must use `uitype: "code"` with `data` array — never nest `style` objects 6. `subtables[].url` must use `{{entire_url('../alias')}}` format with `../` prefix 7. `editor.binds[].actiontype` must be one of: `urlwidget`, `method`, `script`, `registerfunction`, `event` When adding new fields to model definitions, ALWAYS update `init/data.json` seed data with the new fields. Missing fields in seed data cause configuration gaps after fresh deployment. ### Model float/decimal Fields float and decimal fields in model JSON MUST have BOTH `length` (int) and `dec` (int) as separate numeric keys. WRONG: `"length": "15,2"` (string). CORRECT: `"length": 15, "dec": 2`. ### ID Generation: Always Use `getID()`, Never `uuid.uuid4()` Database `id` columns are VARCHAR(32). `uuid.uuid4().replace('-', '')` produces a 32-char hex string that can exceed the column length and cause `DataError: (1406, "Data too long for column 'id' at row 1")`. **Always use `appPublic.uniqueID.getID()` for ID generation:** ```python # WRONG - produces 32-char hex string, often too long: import uuid new_id = str(uuid.uuid4()).replace('-', '') # CORRECT - produces compatible ID: from appPublic.uniqueID import getID new_id = getID() ``` This applies to ALL Python backend code (core.py) AND `.dspy` API files. The `getID()` function uses the same ID generation scheme as the framework's `uniqueID` module, ensuring compatibility with all database column definitions. ### CRITICAL: Reuse Existing RBAC Login — Do NOT Write Your Own When a Sage module needs login/authentication, ALWAYS use the existing RBAC user login system. Do NOT create a new `up_login.dspy` or `login.ui` in `sage/wwwroot/` or any module's `wwwroot/`. | WRONG | CORRECT | |-------|---------| | Create `sage/wwwroot/up_login.dspy` | Use `rbac/user/up_login.dspy` | | Create `sage/wwwroot/login.ui` | Use `rbac/user/login.ui` | | Write custom password hashing | Use `ServerEnv.password_encode()` / `password_decode()` | RBAC login at `/rbac/user/login.ui` already handles: RC4 password encryption with `config.password_key`, account lockout detection, session management via `remember_user()`, and redirect to userinfo. All other modules authenticate via this shared session. ### Password Handling: Use ServerEnv, NOT rf (RegisterFunction) In `.dspy` files that need password encryption (e.g., login forms, user creation): ```python # CORRECT — uses ServerEnv's password_encode from ahserver.globalEnv: from ahserver.globalEnv import password_encode encrypted_pw = password_encode(params_kw.password) # WRONG — do NOT use rfexe('password', ...) or app/rf.py: await rfexe('password', params_kw) # This is deprecated/legacy ``` The `rf` (RegisterFunction) pattern with `rf.register('password', ...)` is legacy. All new code must use `ServerEnv.password_encode()` / `password_decode()` which properly reads the encryption key from `config.password_key`. ### User ID Retrieval Patterns **In `.dspy` files:** ```python userid = await get_user() # Returns user ID string ``` ### Password Encoding in .dspy Files When a `.dspy` file needs to encrypt a password (e.g., for login forms), use `password_encode()` from `ahserver.globalEnv`, NOT the RF (register function) pattern: ```python # CORRECT - use ServerEnv's password_encode directly: from ahserver.globalEnv import password_encode params_kw['password'] = password_encode(params_kw.password) # WRONG - don't use rfexe('password', params_kw): await rfexe('password', params_kw) # RF mode may not be registered ``` The `password_encode()` function automatically retrieves the password key from config and uses RC4 encryption. Always use this function rather than manually calling RC4 or relying on registered functions. ### RBAC Login Convention **NEVER create custom login files in `sage/wwwroot/`.** The RBAC module provides a complete, battle-tested login system at `/rbac/user/login.ui` with: - Password encryption via `password_encode()` - Account lockout detection (failed attempt tracking) - Session management via `remember_user()` - Multiple login methods (password, SMS code, WeChat) If you need login functionality, always use the existing RBAC login endpoint: - Login page: `/rbac/user/login.ui` - Login handler: `/rbac/user/up_login.dspy` Creating duplicate login logic in `sage/wwwroot/` breaks the RBAC authentication flow and causes session inconsistencies. **CRITICAL Pitfall: User ID in JavaScript within HTML widgets of `.ui` files** When a `.ui` file contains an `HTML` widget with JavaScript that needs the current user ID (e.g., for WebSocket messages), you MUST use Jinja2 template injection `{{ get_user() }}`. JavaScript has no access to server-side session — it only sees the rendered HTML string. ```javascript // WRONG — hardcoded string literal; backend receives "current_user" not the real user ID ws.send(JSON.stringify({cmd: 'connect', user_id: '{{ get_user() }}'})); ws.send(JSON.stringify({cmd: 'start_reasoning', request: text, user_id: 'current_user'})); // CORRECT — Jinja2 template rendered server-side into the actual user ID ws.send(JSON.stringify({cmd: 'connect', user_id: '{{ get_user() }}'})); ws.send(JSON.stringify({cmd: 'start_reasoning', request: text, user_id: '{{ get_user() }}'})); ``` **Symptom**: WebSocket connects but shows "等待连接" status, reasoning requests fail silently, or all operations run as `user_id='anonymous'` because the backend receives the literal string `"current_user"`. **Rule**: Every JavaScript string that passes `user_id` to the server (WebSocket connect, command messages, AJAX calls) must use `{{ get_user() }}` template syntax when inside a `.ui` file's HTML widget. ## CRITICAL: WebSocket UI Pattern — Reasoning Console Layout **Working pattern for reasoning console UI** (multi-line input + WebSocket + step timeline): ### Recommended Layout Structure **APPROACH A: WebSocket widget + Html event handlers** (framework-managed connection) ```json { "subwidgets": [ { "widgettype": "WebSocket", "id": "reasoning_ws", "options": {"ws_url": "{{entire_url('/wss/module/endpoint.wss')}}", "with_session": true}, "binds": [ {"wid": "self", "event": "onopen", "actiontype": "script", "script": "onWsOpen()"}, {"wid": "self", "event": "ontext", "actiontype": "script", "script": "onWsMessage(event.params)"} ] }, {"widgettype": "Html", "id": "ws_logic", "options": {"html": ""}} ] } ``` **APPROACH B: Pure Html widget with raw JS WebSocket** (full control, recommended) ```json { "subwidgets": [ {"widgettype": "Html", "id": "ws_logic", "options": {"html": ""}} ] } ``` ### Key rules 1. **Form with uitype "text" + height** gives multi-line textarea (not single-line Input) 2. **Form buttons** with `binds` on specific button names — buttons inside Form.options.buttons are handled by Form's internal submit mechanism. If you need custom click handlers (not form submit), use standalone `Button` widgets OUTSIDE the Form instead 3. **WebSocket binds on `bricks.WebSocket` are fragile**: event handler functions must exist in global scope BEFORE widget initialization. If the Html widget defining them appears after the WebSocket widget, you get "ReferenceError: onWsOpen is not defined". **APPROACH B (raw JS)** avoids this entirely 4. **Widgettype casing**: `Html` (mixed case), NOT `HTML`. `Scroll` does not exist — use VBox + `style: "overflow-y: auto;"` 5. **Session passing**: With `with_session: true`, bricks passes session via WebSocket protocol header (Sec-WebSocket-Protocol), which ahserver's WebsocketProcessor reads to identify the user 6. **JavaScript reads Form value** via `bricks.getWidgetById('input_form', bricks.app).get_value('user_input')` 7. **User ID in JS**: Use `{{ get_user() }}` Jinja2 template for server-side injection ### RBAC permissions for WebSocket ```bash # Path MUST include /wss/ prefix (server logs confirm RBAC checks full path) python set_role_perm.py "logined" "/wss/harnessed_reasoning/reasoning_console.wss" ``` ## WebSocket-Based Real-Time Process Visualization ### CRITICAL: WSS URL Paths — RBAC Checks Full Path with `/wss/` Prefix Server logs confirm RBAC receives the path WITH `/wss/` prefix. All permissions must include it. | Context | Path | Why | |---------|------|-----| | UI frontend `{{entire_url(...)}}` | `/wss/harnessed_reasoning/endpoint.wss` | nginx needs `/wss/` to route to WebSocket handler | | JavaScript WebSocket URL | `/wss/harnessed_reasoning/endpoint.wss` | Same — nginx routing | | RBAC permission registration | `/wss/harnessed_reasoning/endpoint.wss` | RBAC checks the full path including `/wss/` | | `set_role_perm.py` path arg | `/wss/harnessed_reasoning/endpoint.wss` | Must match what RBAC actually receives | **Rule**: All paths include `/wss/` prefix. The server log shows the exact path RBAC checks — use that verbatim. When a module needs to stream real-time events (reasoning steps, tool calls, progress) to a bricks frontend, use WebSocket via `.wss` endpoint. ### Architecture ``` Frontend (HTML/JS WebSocket) <--WebSocket--> module/wwwroot/endpoint.wss <--ws_push callback--> Python core.py ``` ### Backend: .wss WebSocket Endpoint Create `wwwroot/your_endpoint.wss` — it defines `async def myfunc(request, **kwargs)`: ```python """Module WebSocket endpoint for real-time event push.""" import json import asyncio import time from appPublic.uniqueID import getID from appPublic.log import info, debug, error, exception # Global store for active ws_pool references _module_ws_sessions = {} async def myfunc(request, **kwargs): ws_pool = kwargs.get('ws_pool') ws_data = kwargs.get('ws_data') try: data = json.loads(ws_data) if ws_data else {} except: data = {} cmd = data.get('cmd', '') if cmd == 'connect': user_id = data.get('user_id', 'anonymous') session_id = data.get('session_id', getID()) _module_ws_sessions[user_id] = {'ws_pool': ws_pool, 'session_id': session_id} await ws_pool.sendto(json.dumps({ 'type': 'connected', 'session_id': session_id, 'message': 'WebSocket connected' })) elif cmd == 'start_action': user_id = data.get('user_id', 'anonymous') request_text = data.get('request', '') if not request_text: await ws_pool.sendto(json.dumps({'type': 'error', 'message': 'Empty request'})) return # Push start event await _ws_push(user_id, {'type': 'action_start', 'data': {'request': request_text}}) # Run async task (non-blocking) asyncio.create_task(_run_action(user_id, request_text)) elif cmd == 'ping': await ws_pool.sendto(json.dumps({'type': 'pong', 'timestamp': time.time()})) async def _ws_push(user_id, message): """Push message to specific user's websocket connection.""" session = _module_ws_sessions.get(user_id) if session and session.get('ws_pool'): try: await session['ws_pool'].sendto(json.dumps(message)) except Exception as e: error(f"WS push failed for user {user_id}: {e}") async def _run_action(user_id, request_text): """Async task that pushes events at each step.""" from your_module.core import get_engine engine = get_engine() engine.ws_push = lambda msg: _ws_push(user_id, msg) # inject callback try: result = await engine.run(request_text, user_id=user_id) await _ws_push(user_id, {'type': 'action_complete', 'data': {'result': result}}) except Exception as e: await _ws_push(user_id, {'type': 'error', 'data': {'message': str(e)}}) finally: engine.ws_push = None ``` ### Python core.py: Event Push Points Add `ws_push` as a class attribute and `_push` method: ```python class MyEngine: ws_push = None # Async callback, injected by .wss endpoint async def _push(self, event_type: str, data: dict = None): """Push event via websocket if callback is set.""" if self.ws_push: msg = {'event': event_type, 'data': data or {}, 'timestamp': time.time()} try: await self.ws_push(msg) except Exception as e: error(f"ws_push failed: {e}") async def run(self, request: str, user_id: str = None): self._push('start', {'request': request, 'message': 'Starting...'}) # ... do work ... self._push('step_1', {'message': 'Collecting context'}) # ... more steps ... self._push('complete', {'message': 'Done', 'result': result}) ``` **Critical rules:** 1. `_push` MUST be `async def` and called with `await self._push(...)` 2. Push at every meaningful state transition (start, step start/complete, error, finish) 3. `ws_push` callback is injected by the `.wss` endpoint, not by the engine itself 4. Use `asyncio.create_task()` in `.wss` to run the action without blocking the websocket ### Frontend: HTML UI with WebSocket When using raw JavaScript WebSocket (Approach B — recommended for full control): ```javascript // Get WebSocket URL var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; var url = protocol + '//' + window.location.host + '/wss/module_name/endpoint.wss'; // Pass session via protocol header (bricks.app.get_session() returns the session cookie) var session = ''; try { if (window.bricks && window.bricks.app && window.bricks.app.get_session) { session = window.bricks.app.get_session(); } } catch(e) {} // Connect with or without session var ws = session ? new WebSocket(url, session) : new WebSocket(url); ``` **Note**: `WebSocket(url, protocol)` — the second parameter is the sub-protocol string. ahserver's `WebsocketProcessor` reads this from `Sec-WebSocket-Protocol` header to identify the user's session. **CRITICAL Pitfall: Bricks widgets may not be ready when HTML widget's script runs** When an HTML widget containing JavaScript tries to find other bricks widgets via `bricks.getWidgetById()`, those widgets may not have been rendered yet. This causes WebSocket connection to appear "stuck" at initial status text. **Fix**: Delay the `connect()` call and add retry logic: ```javascript // Delay connect to let bricks finish rendering setTimeout(function() { window.reasoningWS.connect(); }, 500); ``` Use an `HTML` widgettype for full control over the WebSocket client: ```json { "widgettype": "HTML", "options": { "html": "\n\n\n\n\n\n
\n\n\n" } } ``` ### Event Flow Pattern ``` action_start -> step_context -> step_plan -> step_safety -> execution_start -> step_1_start -> tool_call -> tool_result -> step_1_complete -> step_2_start -> tool_call -> tool_result -> step_2_complete -> execution_complete -> action_complete ``` ### Message Format Server pushes messages with this structure: ```json {"event": "step_name", "data": {"message": "Description", "...": "..."}, "timestamp": 1234567890} ``` Or for type-based routing: ```json {"type": "error", "data": {"message": "Error details"}} ``` ### User ID in WebSocket The `user_id` is passed from the frontend in the `connect` and command messages. For production, extract the real user ID from the websocket handshake headers (cookie/session) rather than trusting the frontend-provided value. ### URL for WebSocket Connection In JavaScript, construct the WebSocket URL dynamically — **include `/wss/` prefix**: ```javascript var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; var url = protocol + '//' + window.location.host + '/wss/module_name/endpoint.wss'; ``` **Note**: RBAC permission registration for this endpoint uses the path WITH `/wss/`: ```bash python set_role_perm.py "logined" "/wss/harnessed_reasoning/reasoning_console.wss" ``` Server logs confirm RBAC receives the full path including `/wss/` prefix. The `.wss` extension is handled by ahserver's `WebsocketProcessor` which wraps the endpoint in `myfunc(request, **kwargs)` and provides `ws_pool` and `ws_data` in kwargs. ### Sage Module Navigation: menu.ui Pattern Sage modules are navigated through `menu.ui` files, not standalone `index.ui` pages. The main Sage `wwwroot/menu.ui` references module submenus: ```json { "name": "llmage", "label": "模型管理", "submenu": "{{entire_url('/llmage/menu.ui')}}" } ``` ### Module menu.ui Structure Each module has its own `menu.ui` with items linking to feature pages: ```json { "widgettype": "Menu", "options": { "target": "PopupWindow", "popup_options": {"width": "60%", "height": "75%"}, "items": [ {"name": "feature1", "label": "功能1", "url": "{{entire_url('/module/feature.ui')}}"}, {"name": "feature2", "label": "功能2", "url": "{{entire_url('/module/alias_name')}}"} ] } } ``` - `"url"`: Direct navigation to a page or CRUD alias - `"submenu"`: Nested submenu loading another menu.ui - `"items"`: Inline submenu items (alternative to submenu URL) - Menu items use `{{entire_url()}}` for all URL values ### Adding New Module Features When adding a new feature page to a module: 1. Create the `.ui` file in module's `wwwroot/` 2. **Symlink to Sage wwwroot** (critical for local development): ```bash cd /home/hermesai/repos/sage/wwwroot/module_name ln -sf /home/hermesai/repos/module_name/wwwroot/new_feature.ui . ``` 3. Add menu entry in `module_name/wwwroot/menu.ui` 4. Register RBAC permissions: ```bash ./py3/bin/python set_role_perm.py logined /module_name/new_feature.ui ``` 5. For API `.dspy` files, also symlink and register permissions: ```bash cd /home/hermesai/repos/sage/wwwroot/module_name/api ln -sf /home/hermesai/repos/module_name/wwwroot/api/new_api.dspy . ./py3/bin/python set_role_perm.py logined /module_name/api/new_api.dspy ``` ## Sage Authentication and Cookie Pitfalls ### Cookie Secure Flag for HTTP Development `ahserver/auth_api.py`'s `EncryptedCookieStorage` defaults to `secure=True`, which blocks cookies on HTTP (localhost). For local HTTP development: ```python # Check if SSL is actually enabled (not just if key exists) ssl_enabled = False if hasattr(self.conf.website, 'ssl') and self.conf.website.ssl: ssl_cfg = self.conf.website.ssl if hasattr(ssl_cfg, 'crtfile') and hasattr(ssl_cfg, 'keyfile'): ssl_enabled = True storage = EncryptedCookieStorage(secret, secure=ssl_enabled, # False for HTTP, True for HTTPS samesite='Lax', # Lax for same-site, None for cross-domain httponly=True, max_age=24*60*60 ) ``` **Pitfall**: `hasattr(config.website, 'ssl')` returns `True` even when the value is `None`. Always check `if self.conf.website.ssl:` before accessing attributes. ### Login Form Field Names Sage login uses `username` and `passwd` fields (NOT `loginid` and `password`): - Form field: `username` (maps to `users.username` column) - Form field: `passwd` (encrypted via `password_encode()`) - Database table: `users` (NOT `user`) - `username` column stores the login identifier (e.g., `superuser`) - `id` column is the user ID (e.g., `user-01`) ### RBAC Permission Roles | Role | Description | |------|-------------| | `anonymous` | Unauthenticated users | | `any` | All users (including anonymous) | | `logined` | Authenticated users only | | `owner.*` | Owner organization roles | For module feature pages that require login, use `logined` role. For public pages (login, registration), use `any` or `anonymous`. ### WebSocket RBAC Paths: ALWAYS Include /wss/ Prefix **CRITICAL:** Server logs confirm RBAC checks the FULL path INCLUDING the `/wss/` prefix. Always include `/wss/` in RBAC permission registration. | Context | Path | Why | |---------|------|-----| | Frontend `{{entire_url(...)}}` | `/wss/module/endpoint.wss` | Nginx needs `/wss/` to route to WebSocket handler | | RBAC permission registration | `/wss/module/endpoint.wss` | **MUST include `/wss/`** — server logs confirm RBAC receives full path | | `set_role_perm.py` path arg | `/wss/module/endpoint.wss` | **MUST include `/wss/`** — must match what RBAC actually checks | **Example:** ```bash # CORRECT - register WITH /wss/ python set_role_perm.py "logined" "/wss/harnessed_reasoning/reasoning_console.wss" # WRONG - without /wss/ will fail RBAC check python set_role_perm.py "logined" "/harnessed_reasoning/reasoning_console.wss" ``` **Verification**: Check server logs for the exact path RBAC receives: ``` [debug] userid=None, path='/wss/harnessed_reasoning/reasoning_console.wss' permission check failed ``` Use this path verbatim in `set_role_perm.py`. ### Sage Multi-Process Deployment Architecture ### Users Table Schema ```sql CREATE TABLE users ( id VARCHAR(32) PRIMARY KEY, -- User ID (e.g., 'user-01') username VARCHAR(100), -- Login name (e.g., 'superuser') password VARCHAR(255), -- Encrypted password orgid VARCHAR(32), -- Organization ID user_status VARCHAR(1), -- '0'=active login_fail_count INT, last_login_fail DATETIME, ... ); ``` **Important**: Login matches on `username`, not `id`. The `id` is used for RBAC and session tracking. Fields stored encrypted in the database (like `llm_api_key`, `api_key`) must be decrypted before use. Use `ServerEnv.password_decode()`: ```python api_key = row.get('llm_api_key', '') if api_key: env = ServerEnv() api_key = env.password_decode(api_key) ``` ### harnessed_reasoning LLM Call: Do NOT Pass `model` Parameter `harnessed_reasoning/core.py`'s `_llm_call()` must NOT pass `model=` to `llm_chat()`. Let `llm_chat` use `default_model` from `harnessed_agent_config` table: ```python # WRONG — passes hardcoded/override model, overriding harnessed_agent_config.default_model: result = await env.llm_chat(messages=messages, model=model, ...) # CORRECT — let llm_chat resolve model from config: result = await env.llm_chat(messages=messages, temperature=temperature, max_tokens=max_tokens, **extra) ``` ### execute_immediately Parameter Parsing Frontend may send `true` (boolean or string), not `'1'`. Must support multiple truthy values: ```python # WRONG — only accepts string '1': execute_immediately = params_kw.get('execute_immediately', '1') == '1' # CORRECT — supports '1', 'true', 'yes', 'on': execute_val = str(params_kw.get('execute_immediately', '1')).lower() execute_immediately = execute_val in ('1', 'true', 'yes', 'on') ``` ### Module Function Signature Consistency When a `.dspy` file passes `user_id` to a Python module function, ALL functions in the call chain must accept it as a parameter: ```python # .dspy: user_id = await get_user() result = await hermes_reason_and_execute(request=text, user_id=user_id, ...) # Python entry function: async def hermes_reason_and_execute(request: str, execute_immediately: bool = True, user_id: str = None): engine = get_harnessed_reasoning_engine() return await engine.reason_and_execute(request, execute_immediately=execute_immediately, user_id=user_id) # Python method: async def reason_and_execute(self, request: str, execute_immediately: bool = True, user_id: str = None): if not user_id: user_id = "anonymous" ``` **Pitfall**: If any function in the chain doesn't accept `user_id`, you get `unexpected keyword argument 'user_id'`. ### harnessed_agent Tool Implementation: No Mocks The tool wrappers in `harnessed_agent/tools/base_tools.py` **must execute real operations**, not return `status: "mock_implementation"` dicts. If tools return mock results, the reasoning engine will report fake success and the LLM will hallucinate file locations and content. **Verify real implementations exist for core tools:** - `read_file` / `write_file` / `search_files` / `patch` — actual Python file I/O - `terminal` — `asyncio.create_subprocess_shell` - `execute_code` — writes to temp `.py` file, runs via `python3` - `memory` — reads/writes `~/.hermes/memory.json` - `skill_view` / `skills_list` — scans `~/.hermes/skills/` directory - `todo` — reads/writes `~/.hermes/todo.json` Browser tools (`browser_*`), vision (`vision_analyze`), and TTS (`text_to_speech`) require external drivers/APIs and can legitimately return structured `note` responses. ### harnessed_execute_tool: Must Accept and Pass `context` Parameter The global entry function `harnessed_execute_tool` MUST accept `context` and pass it through to `agent.execute_tool_call()`. If `context` is omitted, the `user_id` embedded in it is lost and tools execute as `"anonymous"`. ```python # WRONG — context parameter missing, user_id dropped: async def harnessed_execute_tool(tool_name: str, parameters: Dict[str, Any]): agent = get_harnessed_agent() return await agent.execute_tool_call(tool_name, parameters) # CORRECT — accepts and forwards context: async def harnessed_execute_tool(tool_name: str, parameters: Dict[str, Any], context: Dict[str, Any] = None): agent = get_harnessed_agent() return await agent.execute_tool_call(tool_name, parameters, context) ``` ### harnessed_agent Tool Registration: Keep base_tools.py Exports in Sync `base_tools.py` defines tool dictionaries (`file_tools`, `system_tools`, `skill_tools`, etc.) that are imported by both `__init__.py` and `registration.py`. If you remove or rename a dictionary in `base_tools.py` but forget to update the other two files, you get `ImportError` or `KeyError` at tool registration time. **Rule**: After modifying `base_tools.py`, verify these three files are consistent: - `base_tools.py` — dictionary definitions at the bottom - `__init__.py` — import statements - `registration.py` — import statements + `_register_*_tools` function calls A common pitfall is merging two dictionaries (e.g., putting `skill_tools` entries into `memory_tools`) and then `registration.py` still expects `skill_tools` to exist independently. ### Tool Wrappers Must Accept `context` Parameter for User Isolation Tool wrappers in `harnessed_agent/tools/base_tools.py` that access file state (memory, skills, todo, temp files) **MUST accept an optional `context` parameter** and use `_get_user_dir()` to resolve user-isolated paths. **Complete pattern:** ```python # In base_tools.py: HERMES_DIR = os.path.expanduser("~/.hermes") def _get_user_dir(base_dir: str, context: Optional[Dict[str, Any]] = None) -> str: """Get user-isolated subdirectory. Falls back to global dir if no user context.""" user_id = None if context: user_id = context.get('user_id') or context.get('userid') if user_id: return os.path.join(base_dir, "users", str(user_id)) return base_dir # Tool wrapper example: async def wrapped_skill_manage(action: str, name: str, context: Optional[Dict[str, Any]] = None, **kwargs): user_dir = _get_user_dir(HERMES_DIR, context) skills_dir = os.path.join(user_dir, "skills", name) # ... file operations in user-isolated directory ``` **Context propagation chain** (every step must pass context): 1. `reasoning_console.wss` -> `engine.reason_and_execute(user_id=X)` 2. `_execute_tool()` -> `harnessed_execute_tool(tool, params, context={user_id: X})` 3. `harnessed_execute_tool()` -> `agent.execute_tool_call(tool, params, context)` 4. `_execute_tool_with_retry()` -> injects `context` into params via inspect.signature 5. Tool wrapper receives `context` kwarg, uses `_get_user_dir()` **CRITICAL**: `_execute_tool_with_retry` MUST check `inspect.signature` before injecting context, otherwise tools that don't accept context will fail with "unexpected keyword argument". **User-isolated directory structure:** ``` ~/.hermes/users/{user_id}/ ├── skills/ # User-created skills (SKILL.md per subdirectory) ├── memory.json # User-specific memory ├── todo.json # User-specific todo list └── tmp/ # User-specific temp files (execute_code) ``` ### _get_current_user_id Must NOT Raise ValueError `HermesAgent._get_current_user_id()` must return `"anonymous"` when context is missing, NOT raise `ValueError`. Internal tool calls and system workflows often don't pass full context. ```python def _get_current_user_id(self, context: Dict[str, Any]) -> str: user_id = None if context: user_id = context.get('user_id') or context.get('userid') if not user_id: return "anonymous" # NOT raise ValueError return str(user_id) ``` ### Table Name: hermes_skills (NOT harnessed_skills) The skills table is named `hermes_skills` in the model definition (`models/hermes_skills.json`). All SQL operations in `core.py` must use `hermes_skills`. The table name `harnessed_skills` does NOT exist and will cause "table not found" errors. ### Per-User WebSocket Callbacks for Reasoning Engine `HermesReasoningEngine` must use per-user WebSocket callbacks, not a shared `ws_push` attribute. Multiple concurrent users will overwrite each other's callbacks if using a single shared attribute. **Correct pattern:** ```python class HermesReasoningEngine: ws_push_callbacks: Dict[str, callable] = {} # Per-user callbacks _current_user_id = None # Set during execution async def _push(self, event_type, data=None, user_id=None): if user_id and user_id in self.ws_push_callbacks: await self.ws_push_callbacks[user_id]({'event': event_type, 'data': data}) ``` In `reason_and_execute()`: set `self._current_user_id = user_id`, pass to all `_push()` calls. Clean up in `finally: self._current_user_id = None`. In `.wss` endpoint: use `engine.ws_push_callbacks[user_id] = callback`, cleanup with `engine.ws_push_callbacks.pop(user_id, None)`. ### harnessed_reasoning Tool Execution: context Must Be Passed Through Entire Chain The reasoning engine's `_execute_tool` MUST pass `context` to `harnessed_execute_tool`. Without it, tool wrappers receive no user context and all file operations (skills, memory, todo, temp files) go to the global `~/.hermes/` directory instead of user-isolated paths. **Critical chain (all links required):** ``` reasoning/core.py _execute_tool() -> env.harnessed_execute_tool(tool, params, context) -> harnessed_agent/core.py execute_tool_call(tool, params, context) -> _execute_tool_with_retry(func, params, ..., context) -> inspects function signature; if 'context' in params, injects it -> wrapped_skill_manage(..., context) # user isolation activated ``` **Tool wrappers that accept `context` parameter:** - `wrapped_skill_manage`, `wrapped_skill_view`, `wrapped_skills_list` -> `~/.hermes/users/{user_id}/skills/` - `wrapped_memory` -> `~/.hermes/users/{user_id}/memory.json` - `wrapped_todo` -> `~/.hermes/users/{user_id}/todo.json` - `wrapped_execute_code` -> `~/.hermes/users/{user_id}/tmp/` ### Reasoning Engine: WebSocket Push Must Be Per-User The reasoning engine MUST NOT use a single shared `ws_push` callback. Use `ws_push_callbacks: Dict[str, callable]` keyed by `user_id` to prevent cross-user event leakage: ```python # Class attribute (not instance): ws_push_callbacks: Dict[str, callable] = {} # Set during _run_reasoning: engine.ws_push_callbacks[user_id] = lambda msg: _ws_push(user_id, msg) # In _push(): if user_id and user_id in self.ws_push_callbacks: await self.ws_push_callbacks[user_id](msg) # Cleanup in finally: engine.ws_push_callbacks.pop(user_id, None) ``` ### Shared Skills Permission: Owner Org Only Shared skills (`~/.hermes/skills/`) are readable by ALL users but writable ONLY by owner organization users (org_id='0'). Non-owner attempts to modify shared skills receive `"共享技能仅允许所有者机构用户修改"`. ### Reasoning Engine: org_id Must Be in Context for Shared Skill Checks `reason_and_execute()` must set `self._current_org_id` from ServerEnv's `orgid`/`org_id` attribute, then include it in the context dict passed to tool execution: ```python context = {"user_id": user_id, "org_id": self._current_org_id, ...} ``` ### harnessed_reasoning Tool Execution: user_id Must Be in Context The reasoning engine's `_execute_plan` passes a `context` dict to `harnessed_execute_tool`. If `user_id` is missing from this context, tools execute as `user_id='anonymous'`, causing permission checks and data isolation failures. **Ensure `user_id` is injected into the context dict before tool execution:** ```python # In hermes_reason_and_execute (reasoning entry point): context = await self._get_memory_context(user_id, request, config) context['user_id'] = user_id # CRITICAL: must be in context for tool execution # Then in _execute_plan, this context is passed to each tool call: tool_result = await self._execute_tool(tool, params, context) ``` Also ensure `_get_memory_context` initializes context with `user_id`: ```python context = {"user_id": user_id, "memory_entries": [], "recent_sessions": [], "skills": []} ``` ### Non-JSON LLM Response Handling When LLM API returns non-JSON (e.g., HTML error page, proxy block), check content-type before parsing and log the body: ```python if resp.status == 200: content_type = resp.content_type if 'json' not in content_type: err_text = await resp.text() error(f"[llm_response] Non-JSON from {url}, Content-Type={content_type}") error(f"[llm_response] Body (first 2000): {err_text[:2000]}") return {'error': {'message': f'Non-JSON response ({content_type})', 'type': 'content_type_error'}} return await resp.json() ``` ### Store Session: JSON Serialization Safety When storing reasoning sessions with `json.dumps(plan)`, the plan object may contain non-serializable types (datetime, custom objects). Clean the plan before serialization: ```python def clean_plan(obj): if isinstance(obj, dict): return {k: clean_plan(v) for k, v in obj.items()} elif isinstance(obj, list): return [clean_plan(i) for i in obj] elif isinstance(obj, datetime): return obj.isoformat() return obj clean_plan_obj = clean_plan(plan) data['execution_plan_json'] = json.dumps(clean_plan_obj, ensure_ascii=False) await sor.C('harnessed_reasoning_sessions', data) ``` ### Database Table Must Exist Before Use `Failed to store session: 'NoneType' object has no attribute 'get'` often means the database table doesn't exist. `sqlor`'s `getTableDesc()` returns `None` if the table is missing, and `C()` crashes when accessing `None['fields']`. Always ensure tables are created via `build.sh` before running the module. ## LLM Config Database Isolation & Debugging **LLM configuration lookup must correctly handle the caller module's database context and user isolation.** ### The Bug Pattern `harnessed_agent`'s `llm_client.py` uses `_get_llm_config()` to read from `harnessed_agent_config` table. When called by another module (e.g., `integrated_crm_app` calling `llm_chat()`), the config lookup may fail: ``` Failed to fetch LLM config from DB 'default': 'NoneType' object has no attribute 'get' LLM config not found in any database LLM chat: model=qwen3-max <-- hardcoded fallback, NOT the user's configured value ``` ### Root Cause Analysis 1. **Database context**: `_get_llm_config()` uses `env.get_module_dbname('harnessed_agent')` to find the correct database. If this throws, it falls back to `default`. 2. **Missing data**: The table may not exist in the queried database. 3. **Encrypted fields**: `llm_api_key` is stored encrypted and must be decrypted via `env.password_decode()`. ### Correct Implementation ```python async def _get_llm_config() -> Dict[str, Any]: dbnames_to_try = ['default'] try: env = ServerEnv() module_db = env.get_module_dbname('harnessed_agent') if module_db and module_db not in dbnames_to_try: dbnames_to_try.insert(0, module_db) except Exception as e: error(f"[llm_config] Exception: {e}") for dbname in dbnames_to_try: try: async with DBPools().sqlorContext(dbname) as sor: ns = {'sort': 'updated_at desc'} if user_id: ns['user_id'] = user_id rows = await sor.R('harnessed_agent_config', ns) rows = rows or [] if rows: row = rows[0] api_key = row.get('llm_api_key', '') if api_key: env = ServerEnv() api_key = env.password_decode(api_key) return row except Exception as e: error(f"Failed to fetch LLM config from DB '{dbname}': {e}") return {} ``` ### harnessed_reasoning LLM Call: Do NOT Pass `model` Parameter, ns) rows = rows or [] if rows: return rows[0] else: warning(f"No rows in DB '{dbname}' for user_id={repr(user_id)}") except Exception as e: error(f"Failed to fetch LLM config from DB '{dbname}': {type(e).__name__}: {e}") error("LLM config not found in any database") return {} ``` ### Encrypted API Key Decryption Fields stored encrypted in the database (like `llm_api_key`) must be decrypted before use: ```python api_key = row.get('llm_api_key', '') if api_key: env = ServerEnv() api_key = env.password_decode(api_key) ``` ### harnessed_reasoning LLM Call: Do NOT Pass `model` Parameter `harnessed_reasoning/core.py`'s `_llm_call()` must NOT pass `model=` to `llm_chat()`. Let `llm_chat` use `default_model` from `harnessed_agent_config` table: ```python # WRONG — passes hardcoded/override model, overriding harnessed_agent_config.default_model: result = await env.llm_chat(messages=messages, model=model, ...) # CORRECT — let llm_chat resolve model from config: result = await env.llm_chat(messages=messages, temperature=temperature, max_tokens=max_tokens, **extra) ``` ### User ID Retrieval Patterns ### harnessed_reasoning LLM Call: Do NOT Pass `model` Parameterllm_response] Non-JSON from {url}, Content-Type={content_type}") error(f"[llm_response] Body (first 2000): {err_text[:2000]}") return {'error': {'message': f'Non-JSON response ({content_type})', 'type': 'content_type_error'}} return await resp.json() ``` ### Store Session: JSON Serialization Safety When storing reasoning sessions with `json.dumps(plan)`, the plan object may contain non-serializable types (datetime, custom objects). Always use `default=str`: ```python plan_str = json.dumps(plan, ensure_ascii=False, default=str) data = { 'execution_plan_json': plan_str, ... } await sor.C('harnessed_reasoning_sessions', data) ``` ### Module Function Signature: Accept user_id Parameter When a `.dspy` file passes `user_id` to a Python module function, the module function MUST accept it as a parameter: ```python # Python module function - accept user_id explicitly: async def reason_and_execute(self, request: str, execute_immediately: bool = True, user_id: str = None): if not user_id: user_id = "anonymous" ... ``` **Pitfall**: If the entry function passes `user_id` but the underlying method doesn't accept it, you get `unexpected keyword argument 'user_id'`.