98 KiB
Raw Blame History

name description author related_skills linked_files
harnessed-module-development Development patterns, pitfalls, and conventions for Sage modules — URL paths, JSON config, TabPanel syntax, API patterns, and uapi gateway integration for vendor APIs Hermes Agent
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:

#!/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

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:

{
    "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

#!/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

"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.

{
    "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

// WRONG — "Tab" widgettype doesn't exist, "tabs" is invalid
{
    "widgettype": "Tab",
    "options": {
        "tabs": [{"title": "Sessions"}, {"title": "Config"}]
    }
}

Correct: Using TabPanel widgettype with items parameter

// 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:

{
    "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:

{
    "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(...).

# 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:

#!/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:

#!/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):

#!/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:

{
    "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:

# 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:

# 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

# 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")

# 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

# 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

# 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

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

# 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:

WORKERS=$(nproc)  # auto-detect CPU cores
for (( i=0; i<WORKERS; i++ ))
do
    nohup ./py3/bin/python app/sage.py --workdir "$WORKDIR" --port $PORT > "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:
#!/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()
  1. Remove from llmage/init.py:
# REMOVE these lines:
# from ahserver.configuredServer import add_cleanupctx
# async def start_backend(app): ...
# add_cleanupctx(start_backend)
  1. Update start.sh to start it first:
# 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<WORKERS; i++ ))
do
    nohup $PYTHON app/sage.py --workdir "$WORKDIR" --port $PORT > "logs/sage_worker_${i}.log" 2>&1 &
    echo "$!" >> sage.pid
done
  1. Update stop.sh to stop both:
# 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:

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:

{"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:

# 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:

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.

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__()

# 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:

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:

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:

# 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):

# 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:

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:

# 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.

// 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):

APPROACH A: WebSocket widget + Html event handlers (framework-managed connection)

{
  "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": "<script>\nwindow.onWsOpen = function() { ... };\nwindow.onWsMessage = function(data) { ... };\n</script>"}}
  ]
}

APPROACH B: Pure Html widget with raw JS WebSocket (full control, recommended)

{
  "subwidgets": [
    {"widgettype": "Html", "id": "ws_logic", "options": {"html": "<script>\nvar ws = new WebSocket(wsUrl, session);\nws.onopen = function() { ... };\n</script>"}}
  ]
}

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

# 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):

"""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:

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):

// 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:

// Delay connect to let bricks finish rendering
setTimeout(function() { window.reasoningWS.connect(); }, 500);

Use an HTML widgettype for full control over the WebSocket client:

{
  "widgettype": "HTML",
  "options": {
    "html": "<!DOCTYPE html>\n<html>\n<head>\n<style>\n/* dark theme, timeline layout */\n</style>\n</head>\n<body>\n<div id=\"timeline\"></div>\n<script>\nlet ws = null;\n\nfunction getWsUrl() {\n  var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n  return protocol + '//' + window.location.host + '/module_name/endpoint.wss';\n}\n\nfunction connect() {\n  ws = new WebSocket(getWsUrl());\n  ws.onopen = function() {\n    ws.send(JSON.stringify({cmd: 'connect', user_id: '{{ get_user() }}'}));\n  };\n  ws.onmessage = function(e) {\n    var msg = JSON.parse(e.data);\n    if (msg.type === 'connected') return;\n    if (msg.type === 'error') { addError(msg); return; }\n    addStep(msg.event, msg.data);\n  };\n  ws.onclose = function() { setTimeout(connect, 3000); };\n}\n\nfunction addStep(event, data) {\n  var el = document.createElement('div');\n  el.textContent = (data.message || event) + ' [' + new Date().toLocaleTimeString() + ']';\n  document.getElementById('timeline').appendChild(el);\n}\n\nfunction sendRequest(text) {\n  ws.send(JSON.stringify({cmd: 'start_action', request: text, user_id: '{{ get_user() }}'}));\n}\n\nconnect();\n</script>\n</body>\n</html>"
  }
}

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:

{"event": "step_name", "data": {"message": "Description", "...": "..."}, "timestamp": 1234567890}

Or for type-based routing:

{"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:

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/:

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:

{
    "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:

{
    "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):
    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:
    ./py3/bin/python set_role_perm.py logined /module_name/new_feature.ui
    
  5. For API .dspy files, also symlink and register permissions:
    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
    

ahserver/auth_api.py's EncryptedCookieStorage defaults to secure=True, which blocks cookies on HTTP (localhost). For local HTTP development:

# 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:

# 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

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():

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:

# 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:

# 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:

# .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".

# 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:

# 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.

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:

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:

# 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:

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:

# 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:

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:

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:

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

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:

# 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 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'.