@geravant/sinain 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +33 -27
- package/cli.js +30 -14
- package/config-shared.js +173 -30
- package/launcher.js +38 -21
- package/onboard.js +36 -20
- package/package.json +4 -1
- package/sinain-agent/run.sh +600 -127
- package/sinain-core/src/agents-loader.ts +254 -0
- package/sinain-core/src/buffers/feed-buffer.ts +6 -4
- package/sinain-core/src/config.ts +77 -15
- package/sinain-core/src/escalation/escalator.ts +178 -18
- package/sinain-core/src/index.ts +218 -31
- package/sinain-core/src/learning/local-curation.ts +81 -27
- package/sinain-core/src/overlay/commands.ts +25 -0
- package/sinain-core/src/overlay/ws-handler.ts +3 -0
- package/sinain-core/src/server.ts +101 -10
- package/sinain-core/src/types.ts +29 -3
- package/sinain-memory/graph_query.py +12 -3
- package/sinain-memory/knowledge_integrator.py +194 -10
- package/sinain-memory/__pycache__/common.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/embed_client.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/graph_query.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/knowledge_integrator.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/session_distiller.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/triplestore.cpython-312.pyc +0 -0
- package/sinain-memory/eval/__init__.py +0 -0
- package/sinain-memory/eval/__pycache__/__init__.cpython-312.pyc +0 -0
- package/sinain-memory/eval/assertions.py +0 -267
- package/sinain-memory/eval/benchmarks/__init__.py +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/base_adapter.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/config.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/evaluate.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/ingest.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/longmemeval_adapter.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/meeting_adapter.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/meeting_runner.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/query.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/report.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/runner.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/base_adapter.py +0 -43
- package/sinain-memory/eval/benchmarks/config.py +0 -23
- package/sinain-memory/eval/benchmarks/evaluate.py +0 -146
- package/sinain-memory/eval/benchmarks/ingest.py +0 -152
- package/sinain-memory/eval/benchmarks/judges/__init__.py +0 -0
- package/sinain-memory/eval/benchmarks/judges/__pycache__/__init__.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/judges/__pycache__/qa_judge.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/judges/qa_judge.py +0 -81
- package/sinain-memory/eval/benchmarks/longmemeval_adapter.py +0 -177
- package/sinain-memory/eval/benchmarks/meeting_adapter.py +0 -81
- package/sinain-memory/eval/benchmarks/meeting_runner.py +0 -230
- package/sinain-memory/eval/benchmarks/query.py +0 -193
- package/sinain-memory/eval/benchmarks/report.py +0 -87
- package/sinain-memory/eval/benchmarks/run_meeting_bench.sh +0 -318
- package/sinain-memory/eval/benchmarks/runner.py +0 -283
- package/sinain-memory/eval/judges/__init__.py +0 -0
- package/sinain-memory/eval/judges/base_judge.py +0 -61
- package/sinain-memory/eval/judges/curation_judge.py +0 -46
- package/sinain-memory/eval/judges/insight_judge.py +0 -48
- package/sinain-memory/eval/judges/mining_judge.py +0 -42
- package/sinain-memory/eval/judges/signal_judge.py +0 -45
- package/sinain-memory/eval/retrieval_benchmark.jsonl +0 -12
- package/sinain-memory/eval/retrieval_evaluator.py +0 -186
- package/sinain-memory/eval/schemas.py +0 -247
- package/sinain-memory/tests/__init__.py +0 -0
- package/sinain-memory/tests/conftest.py +0 -189
- package/sinain-memory/tests/test_curator_helpers.py +0 -94
- package/sinain-memory/tests/test_embedder.py +0 -210
- package/sinain-memory/tests/test_extract_json.py +0 -124
- package/sinain-memory/tests/test_feedback_computation.py +0 -121
- package/sinain-memory/tests/test_miner_helpers.py +0 -71
- package/sinain-memory/tests/test_module_management.py +0 -458
- package/sinain-memory/tests/test_parsers.py +0 -96
- package/sinain-memory/tests/test_tick_evaluator.py +0 -430
- package/sinain-memory/tests/test_triple_extractor.py +0 -255
- package/sinain-memory/tests/test_triple_ingest.py +0 -191
- package/sinain-memory/tests/test_triple_migrate.py +0 -138
- package/sinain-memory/tests/test_triplestore.py +0 -248
package/sinain-agent/run.sh
CHANGED
|
@@ -26,101 +26,309 @@ fi
|
|
|
26
26
|
|
|
27
27
|
MCP_CONFIG="${MCP_CONFIG:-$SCRIPT_DIR/mcp-config.json}"
|
|
28
28
|
CORE_URL="${SINAIN_CORE_URL:-http://localhost:9500}"
|
|
29
|
-
POLL_INTERVAL="${SINAIN_POLL_INTERVAL:-2}"
|
|
30
|
-
HEARTBEAT_INTERVAL="${SINAIN_HEARTBEAT_INTERVAL:-900}" # 15 minutes
|
|
31
|
-
AGENT="${SINAIN_AGENT:-claude}"
|
|
32
29
|
WORKSPACE="${SINAIN_WORKSPACE:-$HOME/.openclaw/workspace}"
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
|
|
31
|
+
# --- agents.json early bootstrap + top-level helper ---
|
|
32
|
+
# We need top-level fields (default agent, turns, allowed tools, poll
|
|
33
|
+
# interval) BEFORE the per-profile loader runs further down. So compute
|
|
34
|
+
# the file path here and define a helper to read individual fields.
|
|
35
|
+
#
|
|
36
|
+
# Path priority (highest first):
|
|
37
|
+
# 1. $AGENTS_CONFIG_PATH (explicit env override)
|
|
38
|
+
# 2. ~/.sinain/agents.json (wizard write target — works on npm installs
|
|
39
|
+
# where the package dir is read-only)
|
|
40
|
+
# 3. $SCRIPT_DIR/agents.json (legacy/dev-repo location)
|
|
41
|
+
# agents.example.json (committed template) is the bootstrap source if
|
|
42
|
+
# none of the above exists yet.
|
|
43
|
+
AGENTS_EXAMPLE="$SCRIPT_DIR/agents.example.json"
|
|
44
|
+
USER_AGENTS_FILE="$HOME/.sinain/agents.json"
|
|
45
|
+
if [ -n "${AGENTS_CONFIG_PATH:-}" ]; then
|
|
46
|
+
AGENTS_FILE="$AGENTS_CONFIG_PATH"
|
|
47
|
+
elif [ -f "$USER_AGENTS_FILE" ]; then
|
|
48
|
+
AGENTS_FILE="$USER_AGENTS_FILE"
|
|
49
|
+
else
|
|
50
|
+
AGENTS_FILE="$SCRIPT_DIR/agents.json"
|
|
51
|
+
fi
|
|
52
|
+
# First-run bootstrap: prefer dev location ($SCRIPT_DIR/agents.json) when
|
|
53
|
+
# the package dir is writable; otherwise seed user home (~/.sinain).
|
|
54
|
+
if [ ! -f "$AGENTS_FILE" ] && [ -f "$AGENTS_EXAMPLE" ]; then
|
|
55
|
+
if [ -w "$SCRIPT_DIR" ] && [ "$AGENTS_FILE" = "$SCRIPT_DIR/agents.json" ]; then
|
|
56
|
+
echo " Creating $AGENTS_FILE from agents.example.json (first-run bootstrap)"
|
|
57
|
+
cp "$AGENTS_EXAMPLE" "$AGENTS_FILE"
|
|
58
|
+
else
|
|
59
|
+
mkdir -p "$HOME/.sinain"
|
|
60
|
+
AGENTS_FILE="$USER_AGENTS_FILE"
|
|
61
|
+
echo " Creating $AGENTS_FILE from agents.example.json (first-run bootstrap)"
|
|
62
|
+
cp "$AGENTS_EXAMPLE" "$AGENTS_FILE"
|
|
63
|
+
fi
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# agents_get <key> [default]
|
|
67
|
+
# Reads a top-level scalar field from agents.json. Supports cross-field
|
|
68
|
+
# substitution: a value like "${allowedTools} Bash(git:*)" resolves
|
|
69
|
+
# `${allowedTools}` against the same JSON before being returned. Env-var
|
|
70
|
+
# expansion happens via apply_profile_env later (or by shell when the
|
|
71
|
+
# value is exported), so we stop at one substitution pass here.
|
|
72
|
+
agents_get() {
|
|
73
|
+
local key="$1" def="${2:-}"
|
|
74
|
+
local val
|
|
75
|
+
if [ ! -f "$AGENTS_FILE" ]; then
|
|
76
|
+
printf '%s' "$def"
|
|
77
|
+
return 0
|
|
78
|
+
fi
|
|
79
|
+
val=$(python3 - "$AGENTS_FILE" "$key" <<'PY' 2>/dev/null
|
|
80
|
+
import json, sys, re
|
|
81
|
+
path, key = sys.argv[1], sys.argv[2]
|
|
82
|
+
try:
|
|
83
|
+
with open(path) as f:
|
|
84
|
+
data = json.load(f)
|
|
85
|
+
except Exception:
|
|
86
|
+
sys.exit(0)
|
|
87
|
+
val = data.get(key)
|
|
88
|
+
if val is None:
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
if isinstance(val, str):
|
|
91
|
+
def repl(m):
|
|
92
|
+
other = data.get(m.group(1))
|
|
93
|
+
return other if isinstance(other, str) else m.group(0)
|
|
94
|
+
val = re.sub(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}', repl, val)
|
|
95
|
+
print(val)
|
|
96
|
+
PY
|
|
97
|
+
)
|
|
98
|
+
if [ -z "$val" ]; then
|
|
99
|
+
printf '%s' "$def"
|
|
100
|
+
else
|
|
101
|
+
printf '%s' "$val"
|
|
102
|
+
fi
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Top-level config: agents.json wins, env vars are honored as a fallback
|
|
106
|
+
# (during the migration window — once .env is fully cleaned up, the
|
|
107
|
+
# env reads become dead code).
|
|
108
|
+
POLL_INTERVAL="${SINAIN_POLL_INTERVAL:-$(agents_get pollIntervalSec 2)}"
|
|
109
|
+
AGENT="${SINAIN_AGENT:-$(agents_get default claude)}"
|
|
110
|
+
AGENT_MAX_TURNS="${SINAIN_AGENT_MAX_TURNS:-$(agents_get agentMaxTurns 5)}"
|
|
111
|
+
SPAWN_MAX_TURNS="${SINAIN_SPAWN_MAX_TURNS:-$(agents_get spawnMaxTurns 25)}"
|
|
35
112
|
|
|
36
113
|
# Build allowed tools list for Claude's --allowedTools flag.
|
|
37
|
-
# SINAIN_ALLOWED_TOOLS
|
|
114
|
+
# Priority: SINAIN_ALLOWED_TOOLS env > agents.json `allowedTools` >
|
|
115
|
+
# auto-derive from MCP config > "mcp__sinain" hardcoded default.
|
|
38
116
|
if [ -n "${SINAIN_ALLOWED_TOOLS:-}" ]; then
|
|
39
117
|
ALLOWED_TOOLS="$SINAIN_ALLOWED_TOOLS"
|
|
40
|
-
|
|
41
|
-
ALLOWED_TOOLS=$(
|
|
118
|
+
else
|
|
119
|
+
ALLOWED_TOOLS=$(agents_get allowedTools "")
|
|
120
|
+
if [ -z "$ALLOWED_TOOLS" ] && [ -f "$MCP_CONFIG" ]; then
|
|
121
|
+
ALLOWED_TOOLS=$(python3 -c "
|
|
42
122
|
import json
|
|
43
123
|
with open('$MCP_CONFIG') as f:
|
|
44
124
|
cfg = json.load(f)
|
|
45
125
|
print(' '.join('mcp__' + s for s in cfg.get('mcpServers', {})))
|
|
46
126
|
" 2>/dev/null || echo "mcp__sinain")
|
|
47
|
-
|
|
48
|
-
ALLOWED_TOOLS="mcp__sinain"
|
|
127
|
+
fi
|
|
128
|
+
[ -z "$ALLOWED_TOOLS" ] && ALLOWED_TOOLS="mcp__sinain"
|
|
49
129
|
fi
|
|
50
130
|
|
|
131
|
+
# Per-lane allowed tools — agents.json wins, env vars override per-host.
|
|
132
|
+
# Note: agents.json's `escAllowedTools` already substitutes ${allowedTools}
|
|
133
|
+
# during agents_get, so the value is fully composed by the time we read it.
|
|
134
|
+
SINAIN_ESC_ALLOWED_TOOLS="${SINAIN_ESC_ALLOWED_TOOLS:-$(agents_get escAllowedTools "")}"
|
|
135
|
+
SINAIN_SPAWN_ALLOWED_TOOLS="${SINAIN_SPAWN_ALLOWED_TOOLS:-$(agents_get spawnAllowedTools "")}"
|
|
136
|
+
export SINAIN_ESC_ALLOWED_TOOLS SINAIN_SPAWN_ALLOWED_TOOLS
|
|
137
|
+
|
|
51
138
|
# --- Agent profiles ---
|
|
52
139
|
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
|
|
140
|
+
# --- Agent profile registry (loaded from agents.json at startup) ---
|
|
141
|
+
# Each profile maps a roster name → {bin, type, settings, model, env}.
|
|
142
|
+
# The roster (AVAILABLE_AGENTS) is profile names whose bin is on PATH;
|
|
143
|
+
# invoke_agent dispatches by profile.type and substitutes profile.bin.
|
|
144
|
+
# This lets users add custom names like "pclaude" pointing at a binary
|
|
145
|
+
# of the same kind as claude but with different env/settings.
|
|
146
|
+
#
|
|
147
|
+
# Implemented with namespaced variables (PROFILE_<UPPER_NAME>_<FIELD>)
|
|
148
|
+
# instead of bash-4 associative arrays so the script runs on macOS's
|
|
149
|
+
# default bash 3.2.
|
|
150
|
+
ALL_PROFILES=()
|
|
151
|
+
JUNIE_HAS_MCP=false # set during startup checks for the junie-typed profile
|
|
152
|
+
|
|
153
|
+
# Internal: build the namespaced variable name for (profile, field).
|
|
154
|
+
# Profile names like "pclaude" → "PCLAUDE"; "openclaude-spawn" → "OPENCLAUDE_SPAWN".
|
|
155
|
+
_prof_var() {
|
|
156
|
+
local name="$1" field="$2"
|
|
157
|
+
local safe_name
|
|
158
|
+
safe_name=$(echo "$name" | tr 'a-z-' 'A-Z_')
|
|
159
|
+
local field_upper
|
|
160
|
+
field_upper=$(echo "$field" | tr 'a-z' 'A-Z')
|
|
161
|
+
echo "PROFILE_${safe_name}_${field_upper}"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
prof_set() {
|
|
165
|
+
local name="$1" field="$2" value="$3"
|
|
166
|
+
local var
|
|
167
|
+
var=$(_prof_var "$name" "$field")
|
|
168
|
+
printf -v "$var" '%s' "$value"
|
|
169
|
+
# Track distinct profile names in ALL_PROFILES (no duplicates).
|
|
170
|
+
local found=false p
|
|
171
|
+
for p in "${ALL_PROFILES[@]:-}"; do
|
|
172
|
+
[ "$p" = "$name" ] && { found=true; break; }
|
|
173
|
+
done
|
|
174
|
+
$found || ALL_PROFILES+=("$name")
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
prof_get() {
|
|
178
|
+
local name="$1" field="$2"
|
|
179
|
+
local var
|
|
180
|
+
var=$(_prof_var "$name" "$field")
|
|
181
|
+
echo "${!var:-}"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# Read with a fallback default (last arg).
|
|
185
|
+
prof_get_or() {
|
|
186
|
+
local val
|
|
187
|
+
val=$(prof_get "$1" "$2")
|
|
188
|
+
echo "${val:-$3}"
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Returns 0 if the selected profile's TYPE supports MCP tools natively.
|
|
192
|
+
# Optional arg: profile name. Defaults to $AGENT for back-compat with
|
|
193
|
+
# startup-time usage. Main loop passes the lane-specific choice.
|
|
56
194
|
agent_has_mcp() {
|
|
57
|
-
|
|
58
|
-
|
|
195
|
+
local check="${1:-$AGENT}"
|
|
196
|
+
local type
|
|
197
|
+
type=$(prof_get_or "$check" type "$check")
|
|
198
|
+
case "$type" in
|
|
199
|
+
claude|openclaude|codex|goose) return 0 ;;
|
|
59
200
|
junie) $JUNIE_HAS_MCP ;;
|
|
60
201
|
*) return 1 ;;
|
|
61
202
|
esac
|
|
62
203
|
}
|
|
63
204
|
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
205
|
+
# Apply per-profile env overrides in the current (sub)shell. Values may
|
|
206
|
+
# use ${VAR} indirection (anywhere in the string, e.g. "${HOME}/sub/path"
|
|
207
|
+
# or "${PERSONAL_OPENAI_API_KEY}") to pull from the parent's environment
|
|
208
|
+
# so secrets stay in .env, not in agents.json. Only ${...} braced refs are
|
|
209
|
+
# expanded — bare $VAR and $(cmd) are left literal to avoid surprises and
|
|
210
|
+
# command-injection risk from a typo'd config.
|
|
211
|
+
apply_profile_env() {
|
|
212
|
+
local profile="$1"
|
|
213
|
+
local env_str
|
|
214
|
+
env_str=$(prof_get "$profile" env)
|
|
215
|
+
[ -z "$env_str" ] && return 0
|
|
216
|
+
while IFS= read -r line; do
|
|
217
|
+
[ -z "$line" ] && continue
|
|
218
|
+
local k="${line%%=*}"
|
|
219
|
+
local v="${line#*=}"
|
|
220
|
+
# Inline ${VAR} expansion via python — handles both whole-value and
|
|
221
|
+
# embedded references. python sees env via os.environ, so we don't
|
|
222
|
+
# need to shell-export anything ahead of time.
|
|
223
|
+
if [[ "$v" == *'${'* ]]; then
|
|
224
|
+
v=$(printf '%s' "$v" | python3 -c 'import os,re,sys; sys.stdout.write(re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", lambda m: os.environ.get(m.group(1), ""), sys.stdin.read()))')
|
|
225
|
+
fi
|
|
226
|
+
export "$k=$v"
|
|
227
|
+
done <<< "$env_str"
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Invoke a profile with a prompt. First arg is the profile NAME (looked up
|
|
231
|
+
# in PROFILE_*); second is the prompt; third optional is turns. Profile's
|
|
232
|
+
# bin/type/settings/model/env apply per-invocation. Body runs in a subshell
|
|
233
|
+
# so apply_profile_env's exports don't leak into the parent.
|
|
234
|
+
# Returns text on stdout. Exit code 1 means "type doesn't support MCP —
|
|
235
|
+
# use pipe mode instead".
|
|
67
236
|
invoke_agent() {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
237
|
+
(
|
|
238
|
+
local profile="$1"
|
|
239
|
+
local prompt="$2"
|
|
240
|
+
local turns="${3:-$AGENT_MAX_TURNS}"
|
|
241
|
+
local bin type settings model
|
|
242
|
+
bin=$(prof_get_or "$profile" bin "$profile")
|
|
243
|
+
type=$(prof_get_or "$profile" type "$profile")
|
|
244
|
+
settings=$(prof_get_or "$profile" settings "$SCRIPT_DIR/.claude/settings.json")
|
|
245
|
+
model=$(prof_get "$profile" model)
|
|
246
|
+
apply_profile_env "$profile"
|
|
247
|
+
# If the profile pinned a model, override OPENAI_MODEL for this call only.
|
|
248
|
+
[ -n "$model" ] && export OPENAI_MODEL="$model"
|
|
249
|
+
|
|
250
|
+
case "$type" in
|
|
251
|
+
claude|openclaude)
|
|
252
|
+
# Stderr filter: drops openclaude's repeated "not in context window table"
|
|
253
|
+
# warnings (one per LLM call, ~40/escalation). All other stderr passes through.
|
|
254
|
+
# No-op for claude (it doesn't emit that line). Toggle with QUIET_OPENCLAUDE=false.
|
|
255
|
+
local quiet="${QUIET_OPENCLAUDE:-true}"
|
|
256
|
+
if [ -n "${SINAIN_SPAWN:-}" ]; then
|
|
257
|
+
# Spawn path: user-initiated tasks often need git/edit/write. The
|
|
258
|
+
# --allowedTools whitelist is a pre-invocation gate; PreToolUse hook
|
|
259
|
+
# still routes each call to the overlay for user Allow/Deny. Widen the
|
|
260
|
+
# whitelist so the hook can do its job. Override via SINAIN_SPAWN_ALLOWED_TOOLS.
|
|
261
|
+
local spawn_allowed="${SINAIN_SPAWN_ALLOWED_TOOLS:-${ALLOWED_TOOLS} Bash(git:*) Edit Write Read Glob Grep LS}"
|
|
262
|
+
if [ "$quiet" = "true" ]; then
|
|
263
|
+
"$bin" \
|
|
264
|
+
--mcp-config "$MCP_CONFIG" \
|
|
265
|
+
--settings "$settings" \
|
|
266
|
+
--allowedTools $spawn_allowed \
|
|
267
|
+
--max-turns "$turns" --output-format text \
|
|
268
|
+
-p "$prompt" \
|
|
269
|
+
2> >(grep -v "not in context window table" >&2)
|
|
270
|
+
else
|
|
271
|
+
"$bin" \
|
|
272
|
+
--mcp-config "$MCP_CONFIG" \
|
|
273
|
+
--settings "$settings" \
|
|
274
|
+
--allowedTools $spawn_allowed \
|
|
275
|
+
--max-turns "$turns" --output-format text \
|
|
276
|
+
-p "$prompt"
|
|
277
|
+
fi
|
|
278
|
+
else
|
|
279
|
+
# Escalation path. Override via SINAIN_ESC_ALLOWED_TOOLS.
|
|
280
|
+
local esc_allowed="${SINAIN_ESC_ALLOWED_TOOLS:-${ALLOWED_TOOLS} Bash(git:*) Edit Write Read Glob Grep LS}"
|
|
281
|
+
if [ "$quiet" = "true" ]; then
|
|
282
|
+
"$bin" \
|
|
283
|
+
--mcp-config "$MCP_CONFIG" \
|
|
284
|
+
--settings "$settings" \
|
|
285
|
+
--allowedTools $esc_allowed \
|
|
286
|
+
--max-turns "$turns" --output-format text \
|
|
287
|
+
-p "$prompt" \
|
|
288
|
+
2> >(grep -v "not in context window table" >&2)
|
|
289
|
+
else
|
|
290
|
+
"$bin" \
|
|
291
|
+
--mcp-config "$MCP_CONFIG" \
|
|
292
|
+
--settings "$settings" \
|
|
293
|
+
--allowedTools $esc_allowed \
|
|
294
|
+
--max-turns "$turns" --output-format text \
|
|
295
|
+
-p "$prompt"
|
|
296
|
+
fi
|
|
99
297
|
fi
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
298
|
+
;;
|
|
299
|
+
codex)
|
|
300
|
+
"$bin" exec -s danger-full-access \
|
|
301
|
+
--dangerously-bypass-approvals-and-sandbox \
|
|
302
|
+
--skip-git-repo-check \
|
|
303
|
+
"$prompt"
|
|
304
|
+
;;
|
|
305
|
+
junie)
|
|
306
|
+
if $JUNIE_HAS_MCP; then
|
|
307
|
+
if [ ! -f "$HOME/.junie/allowlist.json" ]; then
|
|
308
|
+
echo " ⚠ Junie: no allowlist.json — MCP tools may prompt. Run junie --brave once to create it." >&2
|
|
309
|
+
fi
|
|
310
|
+
"$bin" --output-format text \
|
|
311
|
+
--mcp-location "$JUNIE_MCP_DIR" \
|
|
312
|
+
--task "$prompt"
|
|
313
|
+
else
|
|
314
|
+
return 1
|
|
315
|
+
fi
|
|
316
|
+
;;
|
|
317
|
+
goose)
|
|
318
|
+
GOOSE_MODE=auto "$bin" run --text "$prompt" \
|
|
319
|
+
--output-format text \
|
|
320
|
+
--quiet \
|
|
321
|
+
--no-session \
|
|
322
|
+
--max-turns "$turns"
|
|
323
|
+
;;
|
|
324
|
+
aider)
|
|
325
|
+
return 1 # No MCP support — caller falls back to invoke_pipe
|
|
326
|
+
;;
|
|
327
|
+
*)
|
|
328
|
+
return 1 # Unknown type — caller falls back to invoke_pipe
|
|
329
|
+
;;
|
|
330
|
+
esac
|
|
331
|
+
)
|
|
124
332
|
}
|
|
125
333
|
|
|
126
334
|
# --- Pipe-mode helpers (for agents without MCP) ---
|
|
@@ -141,19 +349,30 @@ post_response() {
|
|
|
141
349
|
# Invoke a pipe-mode agent with escalation message text.
|
|
142
350
|
# Some agents take the message as an argument, others via stdin.
|
|
143
351
|
invoke_pipe() {
|
|
144
|
-
|
|
352
|
+
(
|
|
353
|
+
local profile="$1"
|
|
354
|
+
local msg="$2"
|
|
355
|
+
local bin type
|
|
356
|
+
bin=$(prof_get_or "$profile" bin "$profile")
|
|
357
|
+
type=$(prof_get_or "$profile" type "$profile")
|
|
358
|
+
apply_profile_env "$profile"
|
|
359
|
+
# Below uses $bin instead of $AGENT and $type for the case-dispatch
|
|
360
|
+
# selector. Variable named AGENT is preserved as alias for type-only
|
|
361
|
+
# lookups to keep the body diff minimal.
|
|
362
|
+
local AGENT="$type"
|
|
145
363
|
case "$AGENT" in
|
|
146
364
|
junie)
|
|
147
|
-
|
|
365
|
+
"$bin" --output-format text --task "$msg"
|
|
148
366
|
;;
|
|
149
367
|
aider)
|
|
150
|
-
|
|
368
|
+
"$bin" --yes -m "$msg"
|
|
151
369
|
;;
|
|
152
370
|
*)
|
|
153
|
-
# Generic: pipe message to stdin
|
|
154
|
-
echo "$msg" | $
|
|
371
|
+
# Generic: pipe message to stdin to whatever binary the profile names
|
|
372
|
+
echo "$msg" | "$bin" 2>/dev/null
|
|
155
373
|
;;
|
|
156
374
|
esac
|
|
375
|
+
)
|
|
157
376
|
}
|
|
158
377
|
|
|
159
378
|
# --- Startup checks ---
|
|
@@ -227,6 +446,38 @@ print(' sinain extension added to ' + config_path)
|
|
|
227
446
|
fi
|
|
228
447
|
fi
|
|
229
448
|
|
|
449
|
+
# Ollama warmup — pin the backing model so each agent invocation hits hot weights.
|
|
450
|
+
# openclaude + Ollama via the OpenAI-compat endpoint does NOT forward keep_alive,
|
|
451
|
+
# so we ping Ollama's native /api/generate once with keep_alive=-1 (persistent).
|
|
452
|
+
# Applies to any agent pointed at an Ollama-compatible endpoint via OPENAI_BASE_URL.
|
|
453
|
+
OLLAMA_WARMUP="${OLLAMA_WARMUP:-true}"
|
|
454
|
+
if [ "$OLLAMA_WARMUP" = "true" ] && [ -n "${OPENAI_BASE_URL:-}" ]; then
|
|
455
|
+
if [[ "$OPENAI_BASE_URL" == *"11434"* ]] || [[ "$OPENAI_BASE_URL" == *"ollama"* ]]; then
|
|
456
|
+
# Derive Ollama host by stripping /v1 suffix from OPENAI_BASE_URL
|
|
457
|
+
OLLAMA_HOST="${OLLAMA_HOST:-${OPENAI_BASE_URL%/v1*}}"
|
|
458
|
+
OLLAMA_MODEL="${OLLAMA_MODEL:-${OPENAI_MODEL:-}}"
|
|
459
|
+
OLLAMA_KEEP_ALIVE="${OLLAMA_KEEP_ALIVE:--1}" # -1 = persistent, or "24h", "30m", etc.
|
|
460
|
+
if [ -n "$OLLAMA_MODEL" ]; then
|
|
461
|
+
echo "Warming Ollama model $OLLAMA_MODEL at $OLLAMA_HOST (keep_alive=$OLLAMA_KEEP_ALIVE)..."
|
|
462
|
+
# Ollama accepts keep_alive as int (-1 = persistent) or duration string ("24h", "30m").
|
|
463
|
+
if [[ "$OLLAMA_KEEP_ALIVE" =~ ^-?[0-9]+$ ]]; then
|
|
464
|
+
WARMUP_PAYLOAD="{\"model\":\"$OLLAMA_MODEL\",\"prompt\":\"\",\"keep_alive\":$OLLAMA_KEEP_ALIVE,\"stream\":false}"
|
|
465
|
+
else
|
|
466
|
+
WARMUP_PAYLOAD="{\"model\":\"$OLLAMA_MODEL\",\"prompt\":\"\",\"keep_alive\":\"$OLLAMA_KEEP_ALIVE\",\"stream\":false}"
|
|
467
|
+
fi
|
|
468
|
+
if curl -sf -m 60 -X POST "$OLLAMA_HOST/api/generate" \
|
|
469
|
+
-H 'Content-Type: application/json' \
|
|
470
|
+
-d "$WARMUP_PAYLOAD" >/dev/null 2>&1; then
|
|
471
|
+
echo " ✓ Model pinned in memory"
|
|
472
|
+
else
|
|
473
|
+
echo " ⚠ Warmup failed — first request will cold-start the model"
|
|
474
|
+
fi
|
|
475
|
+
else
|
|
476
|
+
echo " ⚠ OLLAMA_WARMUP=true but OPENAI_MODEL not set — skipping warmup"
|
|
477
|
+
fi
|
|
478
|
+
fi
|
|
479
|
+
fi
|
|
480
|
+
|
|
230
481
|
# Agent mode label
|
|
231
482
|
if agent_has_mcp; then
|
|
232
483
|
AGENT_MODE="MCP"
|
|
@@ -234,21 +485,219 @@ else
|
|
|
234
485
|
AGENT_MODE="pipe"
|
|
235
486
|
fi
|
|
236
487
|
|
|
488
|
+
# --- Load agent profiles from agents.json ---
|
|
489
|
+
# Built-in defaults are 1:1 (profile name == binary == type). Users can
|
|
490
|
+
# override fields or add custom profiles by editing sinain-agent/agents.json.
|
|
491
|
+
# Profiles whose binaries aren't in PATH are silently skipped.
|
|
492
|
+
for default_name in claude openclaude codex goose junie aider; do
|
|
493
|
+
prof_set "$default_name" bin "$default_name"
|
|
494
|
+
prof_set "$default_name" type "$default_name"
|
|
495
|
+
done
|
|
496
|
+
|
|
497
|
+
# Path + bootstrap for AGENTS_FILE happen earlier in this script (right
|
|
498
|
+
# after CORE_URL is set) so top-level scalars can be read before the
|
|
499
|
+
# default-AGENT block. The full per-profile flatten still happens below.
|
|
500
|
+
if [ -f "$AGENTS_FILE" ]; then
|
|
501
|
+
# Python flattens profiles into "name|field|value" lines; we ingest in
|
|
502
|
+
# the parent shell via process substitution so prof_set writes (which
|
|
503
|
+
# mutate ALL_PROFILES) persist — a piped `while` would run in a
|
|
504
|
+
# subshell and lose them.
|
|
505
|
+
while IFS='|' read -r p_name p_field p_value; do
|
|
506
|
+
[ -z "$p_name" ] && continue
|
|
507
|
+
case "$p_field" in
|
|
508
|
+
bin) prof_set "$p_name" bin "$p_value" ;;
|
|
509
|
+
type) prof_set "$p_name" type "$p_value" ;;
|
|
510
|
+
settings) prof_set "$p_name" settings "${p_value/#\~/$HOME}" ;;
|
|
511
|
+
model) prof_set "$p_name" model "$p_value" ;;
|
|
512
|
+
env)
|
|
513
|
+
# env is multi-line: each "k=v" pair on its own line. Append to
|
|
514
|
+
# any existing env block so multiple env entries accumulate.
|
|
515
|
+
existing_env=$(prof_get "$p_name" env)
|
|
516
|
+
if [ -n "$existing_env" ]; then
|
|
517
|
+
prof_set "$p_name" env "$existing_env"$'\n'"$p_value"
|
|
518
|
+
else
|
|
519
|
+
prof_set "$p_name" env "$p_value"
|
|
520
|
+
fi
|
|
521
|
+
;;
|
|
522
|
+
esac
|
|
523
|
+
done < <(python3 -c '
|
|
524
|
+
import json, sys
|
|
525
|
+
try:
|
|
526
|
+
with open(sys.argv[1]) as f:
|
|
527
|
+
data = json.load(f)
|
|
528
|
+
except Exception as e:
|
|
529
|
+
sys.stderr.write(f"agents.json parse failed: {e}\n")
|
|
530
|
+
sys.exit(0)
|
|
531
|
+
profiles = data.get("profiles", {}) or {}
|
|
532
|
+
for name, prof in profiles.items():
|
|
533
|
+
if not isinstance(prof, dict): continue
|
|
534
|
+
for field in ("bin", "type", "settings", "model"):
|
|
535
|
+
val = prof.get(field)
|
|
536
|
+
if val: print(f"{name}|{field}|{val}")
|
|
537
|
+
env = prof.get("env") or {}
|
|
538
|
+
if isinstance(env, dict):
|
|
539
|
+
for k, v in env.items():
|
|
540
|
+
print(f"{name}|env|{k}={v}")
|
|
541
|
+
' "$AGENTS_FILE")
|
|
542
|
+
fi
|
|
543
|
+
|
|
544
|
+
# Fill in defaults for any profile that didn't specify bin or type.
|
|
545
|
+
# (A profile defined with only env/settings/model still needs bin/type;
|
|
546
|
+
# default both to the profile name for the 1:1 case.)
|
|
547
|
+
for p in "${ALL_PROFILES[@]:-}"; do
|
|
548
|
+
[ -z "$(prof_get "$p" bin)" ] && prof_set "$p" bin "$p"
|
|
549
|
+
[ -z "$(prof_get "$p" type)" ] && prof_set "$p" type "$p"
|
|
550
|
+
done
|
|
551
|
+
|
|
552
|
+
# AVAILABLE_AGENTS = profile names whose configured bin is on PATH.
|
|
553
|
+
# This is what gets POSTed to /bareagent/register and shown in the
|
|
554
|
+
# overlay selector. Lane-specific choices (ESC_AGENT, SPAWN_AGENT)
|
|
555
|
+
# default to $AGENT and are refreshed per-iteration from the config
|
|
556
|
+
# piggyback field on /escalation/pending and /spawn/pending responses.
|
|
557
|
+
AVAILABLE_AGENTS=()
|
|
558
|
+
for p in "${ALL_PROFILES[@]:-}"; do
|
|
559
|
+
# Gateway-style profiles (type=openclaw) have no local binary — they're
|
|
560
|
+
# dispatched by sinain-core via WS RPC. Include them in the roster
|
|
561
|
+
# regardless of PATH so they show up in the overlay's agent selector.
|
|
562
|
+
ptype=$(prof_get_or "$p" type "$p")
|
|
563
|
+
if [ "$ptype" = "openclaw" ]; then
|
|
564
|
+
AVAILABLE_AGENTS+=("$p")
|
|
565
|
+
continue
|
|
566
|
+
fi
|
|
567
|
+
if command -v "$(prof_get_or "$p" bin "$p")" >/dev/null 2>&1; then
|
|
568
|
+
AVAILABLE_AGENTS+=("$p")
|
|
569
|
+
fi
|
|
570
|
+
done
|
|
571
|
+
|
|
572
|
+
# Sanity check the configured default agent
|
|
573
|
+
AGENT_BIN=$(prof_get_or "$AGENT" bin "$AGENT")
|
|
574
|
+
if ! command -v "$AGENT_BIN" >/dev/null 2>&1; then
|
|
575
|
+
echo " ⚠ configured agent '$AGENT' (bin=$AGENT_BIN) not installed — waiting for overlay override"
|
|
576
|
+
fi
|
|
577
|
+
|
|
578
|
+
ESC_AGENT="$AGENT"
|
|
579
|
+
SPAWN_AGENT="$AGENT"
|
|
580
|
+
|
|
581
|
+
# Register roster with sinain-core (fire-and-forget; core may not be ready)
|
|
582
|
+
if [ ${#AVAILABLE_AGENTS[@]} -gt 0 ]; then
|
|
583
|
+
REGISTER_PAYLOAD=$(python3 -c "
|
|
584
|
+
import json, sys
|
|
585
|
+
available = sys.argv[1].split(' ') if sys.argv[1] else []
|
|
586
|
+
print(json.dumps({'available': available, 'current': sys.argv[2]}))
|
|
587
|
+
" "${AVAILABLE_AGENTS[*]}" "$AGENT")
|
|
588
|
+
curl -sf -m 2 -X POST "$CORE_URL/bareagent/register" \
|
|
589
|
+
-H 'Content-Type: application/json' \
|
|
590
|
+
-d "$REGISTER_PAYLOAD" >/dev/null 2>&1 || true
|
|
591
|
+
fi
|
|
592
|
+
echo " Agents available: ${AVAILABLE_AGENTS[*]:-<none>}"
|
|
593
|
+
echo " Lanes: escalation=$ESC_AGENT spawn=$SPAWN_AGENT"
|
|
594
|
+
|
|
595
|
+
# --- Apply config piggybacked on escalation/spawn poll responses ---
|
|
596
|
+
# No separate polling — parses the `config` field from /escalation/pending
|
|
597
|
+
# and /spawn/pending response JSONs. Heals only when core explicitly signals
|
|
598
|
+
# `registered: false` (core forgot our roster, probably restarted) — NOT
|
|
599
|
+
# when the user legitimately picks Off/Off from the selector.
|
|
600
|
+
apply_config_from_response() {
|
|
601
|
+
local json="$1"
|
|
602
|
+
# Short-circuit: only process if the response actually includes a config.
|
|
603
|
+
echo "$json" | grep -q '"config"' || return 0
|
|
604
|
+
local new_esc new_spawn registered
|
|
605
|
+
new_esc=$(echo "$json" | python3 -c "import sys,json; d=json.load(sys.stdin); c=d.get('config') or {}; print(c.get('escalationAgent',''))" 2>/dev/null)
|
|
606
|
+
new_spawn=$(echo "$json" | python3 -c "import sys,json; d=json.load(sys.stdin); c=d.get('config') or {}; print(c.get('spawnAgent',''))" 2>/dev/null)
|
|
607
|
+
registered=$(echo "$json" | python3 -c "import sys,json; d=json.load(sys.stdin); c=d.get('config') or {}; print('true' if c.get('registered') else 'false')" 2>/dev/null)
|
|
608
|
+
|
|
609
|
+
# Healing: core says it doesn't have our roster. Re-register (fire-and-
|
|
610
|
+
# forget). Distinct from "user selected Off/Off" because in that case
|
|
611
|
+
# registered is still true — core knows us, lanes are just blank.
|
|
612
|
+
if [ "$registered" = "false" ] && [ ${#AVAILABLE_AGENTS[@]} -gt 0 ]; then
|
|
613
|
+
echo "[$(date +%H:%M:%S)] core unregistered — re-registering roster"
|
|
614
|
+
local heal_payload
|
|
615
|
+
heal_payload=$(python3 -c "
|
|
616
|
+
import json, sys
|
|
617
|
+
print(json.dumps({'available': sys.argv[1].split(' '), 'current': sys.argv[2]}))
|
|
618
|
+
" "${AVAILABLE_AGENTS[*]}" "${ESC_AGENT:-$AGENT}")
|
|
619
|
+
curl -sf -m 2 -X POST "$CORE_URL/bareagent/register" \
|
|
620
|
+
-H 'Content-Type: application/json' \
|
|
621
|
+
-d "$heal_payload" >/dev/null 2>&1 || true
|
|
622
|
+
return 0
|
|
623
|
+
fi
|
|
624
|
+
|
|
625
|
+
if [ "$new_esc" != "$ESC_AGENT" ]; then
|
|
626
|
+
echo "[$(date +%H:%M:%S)] escalation agent: ${ESC_AGENT:-<off>} → ${new_esc:-<off>}"
|
|
627
|
+
ESC_AGENT="$new_esc"
|
|
628
|
+
fi
|
|
629
|
+
if [ "$new_spawn" != "$SPAWN_AGENT" ]; then
|
|
630
|
+
echo "[$(date +%H:%M:%S)] spawn agent: ${SPAWN_AGENT:-<off>} → ${new_spawn:-<off>}"
|
|
631
|
+
SPAWN_AGENT="$new_spawn"
|
|
632
|
+
fi
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
# --- OpenRouter reasoning-preserving proxy autolaunch ---
|
|
636
|
+
# Starts sinain-agent/openrouter-proxy.mjs when any code path will need it.
|
|
637
|
+
# The proxy preserves reasoning_content across multi-turn MCP flows so
|
|
638
|
+
# DeepSeek V4 Flash (and other thinking models) don't 400 on turn-2.
|
|
639
|
+
#
|
|
640
|
+
# Detection: triggers if (a) the parent shell already has OPENAI_BASE_URL
|
|
641
|
+
# pointed at the proxy port (legacy .env-based config), OR (b) any loaded
|
|
642
|
+
# profile's env block references the proxy port (new agents.json-based
|
|
643
|
+
# config). The second case is what makes "all openclaude routing in
|
|
644
|
+
# agents.json" work without forcing a duplicate OPENAI_BASE_URL in .env.
|
|
645
|
+
PROXY_PID=""
|
|
646
|
+
PROXY_PORT="${OPENROUTER_PROXY_PORT:-11435}"
|
|
647
|
+
PROXY_NEEDED=false
|
|
648
|
+
if [[ "${OPENAI_BASE_URL:-}" == *":${PROXY_PORT}"* ]]; then
|
|
649
|
+
PROXY_NEEDED=true
|
|
650
|
+
else
|
|
651
|
+
for _p in "${ALL_PROFILES[@]:-}"; do
|
|
652
|
+
_env=$(prof_get "$_p" env)
|
|
653
|
+
if [[ "$_env" == *":${PROXY_PORT}"* ]]; then
|
|
654
|
+
PROXY_NEEDED=true; break
|
|
655
|
+
fi
|
|
656
|
+
done
|
|
657
|
+
fi
|
|
658
|
+
if $PROXY_NEEDED; then
|
|
659
|
+
if lsof -iTCP:"$PROXY_PORT" -sTCP:LISTEN >/dev/null 2>&1; then
|
|
660
|
+
echo "OpenRouter proxy already running on :$PROXY_PORT — reusing"
|
|
661
|
+
else
|
|
662
|
+
PROXY_SCRIPT="$SCRIPT_DIR/openrouter-proxy.mjs"
|
|
663
|
+
if [ -f "$PROXY_SCRIPT" ]; then
|
|
664
|
+
PROXY_STDOUT="/tmp/openrouter-proxy.stdout.log"
|
|
665
|
+
echo "Starting OpenRouter proxy (mode=${REASONING_MODE:-preserve})..."
|
|
666
|
+
node "$PROXY_SCRIPT" > "$PROXY_STDOUT" 2>&1 &
|
|
667
|
+
PROXY_PID=$!
|
|
668
|
+
# Wait up to 2s for the proxy to accept connections before proceeding
|
|
669
|
+
for i in 1 2 3 4 5 6 7 8; do
|
|
670
|
+
if lsof -iTCP:"$PROXY_PORT" -sTCP:LISTEN >/dev/null 2>&1; then break; fi
|
|
671
|
+
sleep 0.25
|
|
672
|
+
done
|
|
673
|
+
if kill -0 "$PROXY_PID" 2>/dev/null && lsof -iTCP:"$PROXY_PORT" -sTCP:LISTEN >/dev/null 2>&1; then
|
|
674
|
+
echo " ✓ proxy listening (pid=$PROXY_PID, logs=/tmp/openrouter-proxy.log)"
|
|
675
|
+
else
|
|
676
|
+
echo " ⚠ proxy failed to start — see $PROXY_STDOUT"
|
|
677
|
+
PROXY_PID=""
|
|
678
|
+
fi
|
|
679
|
+
else
|
|
680
|
+
echo " ⚠ OPENAI_BASE_URL points at :$PROXY_PORT but $PROXY_SCRIPT missing"
|
|
681
|
+
fi
|
|
682
|
+
fi
|
|
683
|
+
fi
|
|
684
|
+
|
|
237
685
|
echo "sinain bare agent started"
|
|
238
686
|
echo " Agent: $AGENT ($AGENT_MODE)"
|
|
239
687
|
echo " Core: $CORE_URL"
|
|
240
688
|
echo " Allowed: ${ALLOWED_TOOLS:-<none>}"
|
|
241
689
|
echo " Poll: every ${POLL_INTERVAL}s"
|
|
242
|
-
echo " Heartbeat: every ${HEARTBEAT_INTERVAL}s"
|
|
243
690
|
echo " Press Ctrl+C to stop"
|
|
244
691
|
echo ""
|
|
245
692
|
|
|
246
|
-
LAST_HEARTBEAT=$(date +%s)
|
|
247
693
|
ESCALATION_COUNT=0
|
|
248
694
|
|
|
249
695
|
cleanup() {
|
|
250
696
|
echo ""
|
|
251
697
|
echo "Agent stopped. Escalations handled: $ESCALATION_COUNT"
|
|
698
|
+
if [ -n "${PROXY_PID:-}" ]; then
|
|
699
|
+
kill "$PROXY_PID" 2>/dev/null && echo " stopped OpenRouter proxy (pid=$PROXY_PID)"
|
|
700
|
+
fi
|
|
252
701
|
exit 0
|
|
253
702
|
}
|
|
254
703
|
trap cleanup INT TERM
|
|
@@ -261,20 +710,13 @@ Call sinain_get_escalation to see the full context, then call sinain_respond wit
|
|
|
261
710
|
|
|
262
711
|
Response guidelines: 5-10 sentences, address errors first, reference specific screen/audio context, never NO_REPLY. Max 4000 chars for coding context, 3000 otherwise.'
|
|
263
712
|
|
|
264
|
-
HEARTBEAT_PROMPT='You are the sinain HUD agent. Run the heartbeat cycle:
|
|
265
|
-
1. Call sinain_heartbeat_tick with a brief session summary (runs signal analysis, session distillation, knowledge integration, insight synthesis)
|
|
266
|
-
2. If the result contains a suggestion or insight, post it to HUD via sinain_post_feed
|
|
267
|
-
3. Call sinain_get_knowledge to review the merged knowledge document (draws from both local and workspace databases)
|
|
268
|
-
4. Optionally call sinain_knowledge_query with relevant entities to check long-term knowledge state
|
|
269
|
-
5. Call sinain_get_feedback to review recent escalation scores
|
|
270
|
-
|
|
271
|
-
Knowledge context: sinain-core maintains two knowledge databases — local (session distillation) and workspace (heartbeat curation). The knowledge tools query both via the sinain-core API. Facts have confidence decay (60-day half-life).'
|
|
272
|
-
|
|
273
713
|
# --- Main loop ---
|
|
274
714
|
|
|
275
715
|
while true; do
|
|
276
716
|
# Poll for pending escalation
|
|
277
717
|
ESC=$(curl -sf "$CORE_URL/escalation/pending" 2>/dev/null || echo '{"ok":false}')
|
|
718
|
+
# Pick up per-lane agent choices from the piggybacked config field
|
|
719
|
+
apply_config_from_response "$ESC"
|
|
278
720
|
ESC_PAUSED=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); print('true' if d.get('paused') else '')" 2>/dev/null || true)
|
|
279
721
|
if [ -n "$ESC_PAUSED" ]; then
|
|
280
722
|
sleep 10 # Slow polling when paused
|
|
@@ -282,6 +724,21 @@ while true; do
|
|
|
282
724
|
fi
|
|
283
725
|
ESC_ID=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); e=d.get('escalation'); print(e['id'] if e else '')" 2>/dev/null || true)
|
|
284
726
|
|
|
727
|
+
# Escalation lane guard: skip when the lane is Off (empty) OR routed to
|
|
728
|
+
# openclaw (gateway-handled, not local). Posting an "ack-skip" tells
|
|
729
|
+
# sinain-core to drop the pending entry so we don't loop on it forever
|
|
730
|
+
# if the gateway WS is down — the alternative (no response) leaves the
|
|
731
|
+
# escalation in httpPending and we re-poll it every iteration.
|
|
732
|
+
if [ -n "$ESC_ID" ] && [ -z "$ESC_AGENT" ]; then
|
|
733
|
+
echo "[$(date +%H:%M:%S)] Escalation $ESC_ID skipped — lane is Off"
|
|
734
|
+
post_response "$ESC_ID" "[skipped: lane is Off]" 2>/dev/null || true
|
|
735
|
+
ESC_ID=""
|
|
736
|
+
elif [ -n "$ESC_ID" ] && [ "$(prof_get_or "$ESC_AGENT" type "$ESC_AGENT")" = "openclaw" ]; then
|
|
737
|
+
echo "[$(date +%H:%M:%S)] Escalation $ESC_ID skipped — gateway agent '$ESC_AGENT' (type=openclaw) is WS-routed, not a local CLI"
|
|
738
|
+
post_response "$ESC_ID" "[skipped: $ESC_AGENT is gateway-routed; sinain-core should have dispatched via WS]" 2>/dev/null || true
|
|
739
|
+
ESC_ID=""
|
|
740
|
+
fi
|
|
741
|
+
|
|
285
742
|
if [ -n "$ESC_ID" ]; then
|
|
286
743
|
ESC_MSG=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['escalation']['message'])" 2>/dev/null)
|
|
287
744
|
ESC_SCORE=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['escalation'].get('score','?'))" 2>/dev/null)
|
|
@@ -289,28 +746,52 @@ while true; do
|
|
|
289
746
|
|
|
290
747
|
echo "[$(date +%H:%M:%S)] Escalation $ESC_ID (score=$ESC_SCORE, coding=$ESC_CODING)"
|
|
291
748
|
|
|
292
|
-
if agent_has_mcp; then
|
|
293
|
-
# MCP path: agent calls sinain tools directly
|
|
749
|
+
if agent_has_mcp "$ESC_AGENT"; then
|
|
750
|
+
# MCP path: agent calls sinain tools directly. Export a correlation id
|
|
751
|
+
# so the PreToolUse hook can key YOLO on this invocation when the agent
|
|
752
|
+
# runtime doesn't emit session_id in hook input.
|
|
753
|
+
export SINAIN_ESC_TASK_ID="esc-$ESC_ID"
|
|
294
754
|
PROMPT=$(printf "$ESC_PROMPT_TEMPLATE" "$ESC_ID")
|
|
295
|
-
RESPONSE=$(invoke_agent "$PROMPT" || echo "ERROR: $
|
|
755
|
+
RESPONSE=$(invoke_agent "$ESC_AGENT" "$PROMPT" || echo "ERROR: $ESC_AGENT invocation failed")
|
|
756
|
+
unset SINAIN_ESC_TASK_ID
|
|
296
757
|
else
|
|
297
758
|
# Pipe path: bash handles HTTP, agent just generates text
|
|
298
|
-
RESPONSE=$(invoke_pipe "$ESC_MSG" || true)
|
|
759
|
+
RESPONSE=$(invoke_pipe "$ESC_AGENT" "$ESC_MSG" || true)
|
|
299
760
|
if [ -n "$RESPONSE" ]; then
|
|
300
761
|
post_response "$ESC_ID" "$RESPONSE"
|
|
301
762
|
else
|
|
302
|
-
echo "[$(date +%H:%M:%S)] WARNING: $
|
|
763
|
+
echo "[$(date +%H:%M:%S)] WARNING: $ESC_AGENT returned empty response"
|
|
303
764
|
fi
|
|
304
765
|
fi
|
|
305
766
|
|
|
306
767
|
ESCALATION_COUNT=$((ESCALATION_COUNT + 1))
|
|
307
|
-
|
|
768
|
+
# Detect cases where the agent's tool call didn't land on HUD (ID race, max turns, API errors, crashes).
|
|
769
|
+
# On drop: print a short inline summary + append the full response to /tmp/sinain-drops.log for diagnosis.
|
|
770
|
+
if echo "$RESPONSE" | grep -qiE "no pending escalation|id mismatch|Reached max turns|invocation failed|API Error|^Error:"; then
|
|
771
|
+
echo "[$(date +%H:%M:%S)] ⚠ DROP ($ESC_ID) ─────────────────────────────"
|
|
772
|
+
echo "$RESPONSE"
|
|
773
|
+
echo "─────────────────────────────────────────────────────────────"
|
|
774
|
+
{
|
|
775
|
+
echo "===== $(date -u +%Y-%m-%dT%H:%M:%SZ) DROP ($ESC_ID) ====="
|
|
776
|
+
echo "$RESPONSE"
|
|
777
|
+
echo ""
|
|
778
|
+
} >> /tmp/sinain-drops.log
|
|
779
|
+
else
|
|
780
|
+
echo "[$(date +%H:%M:%S)] Responded ($ESCALATION_COUNT total): ${RESPONSE:0:120}..."
|
|
781
|
+
fi
|
|
308
782
|
echo ""
|
|
309
783
|
fi
|
|
310
784
|
|
|
311
|
-
# Poll for pending spawn task (queued via HUD Shift+Enter or POST /spawn)
|
|
312
|
-
|
|
313
|
-
|
|
785
|
+
# Poll for pending spawn task (queued via HUD Shift+Enter or POST /spawn).
|
|
786
|
+
# Skip entirely when the spawn lane is Off — queued tasks will TTL on
|
|
787
|
+
# the server side. This prevents fetching + throwing away task bodies.
|
|
788
|
+
if [ -n "$SPAWN_AGENT" ]; then
|
|
789
|
+
SPAWN=$(curl -sf "$CORE_URL/spawn/pending" 2>/dev/null || echo '{"ok":false}')
|
|
790
|
+
apply_config_from_response "$SPAWN"
|
|
791
|
+
SPAWN_ID=$(echo "$SPAWN" | python3 -c "import sys,json; d=json.load(sys.stdin); t=d.get('task'); print(t['id'] if t else '')" 2>/dev/null || true)
|
|
792
|
+
else
|
|
793
|
+
SPAWN_ID=""
|
|
794
|
+
fi
|
|
314
795
|
|
|
315
796
|
if [ -n "$SPAWN_ID" ]; then
|
|
316
797
|
SPAWN_TASK=$(echo "$SPAWN" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['task']['task'])" 2>/dev/null)
|
|
@@ -318,7 +799,7 @@ while true; do
|
|
|
318
799
|
|
|
319
800
|
echo "[$(date +%H:%M:%S)] Spawn task $SPAWN_ID ($SPAWN_LABEL)"
|
|
320
801
|
|
|
321
|
-
if agent_has_mcp; then
|
|
802
|
+
if agent_has_mcp "$SPAWN_AGENT"; then
|
|
322
803
|
# MCP path: agent runs task with sinain tools available
|
|
323
804
|
# Pre-fetch knowledge context so the spawn doesn't waste turns calling tools
|
|
324
805
|
SPAWN_KNOWLEDGE=$(curl -sf "$CORE_URL/knowledge" 2>/dev/null | python3 -c "
|
|
@@ -335,11 +816,11 @@ $SPAWN_KNOWLEDGE
|
|
|
335
816
|
}
|
|
336
817
|
Complete this task thoroughly. You also have sinain_get_knowledge and sinain_knowledge_query tools available for additional context. Summarize your findings concisely."
|
|
337
818
|
export SINAIN_SPAWN=1 SINAIN_SPAWN_TASK_ID="$SPAWN_ID"
|
|
338
|
-
SPAWN_RESULT=$(invoke_agent "$SPAWN_PROMPT" "$SPAWN_MAX_TURNS" || echo "ERROR:
|
|
819
|
+
SPAWN_RESULT=$(invoke_agent "$SPAWN_AGENT" "$SPAWN_PROMPT" "$SPAWN_MAX_TURNS" || echo "ERROR: $SPAWN_AGENT invocation failed")
|
|
339
820
|
unset SINAIN_SPAWN SINAIN_SPAWN_TASK_ID
|
|
340
821
|
else
|
|
341
822
|
# Pipe path: agent gets task text directly
|
|
342
|
-
SPAWN_RESULT=$(invoke_pipe "Background task: $SPAWN_TASK" || echo "No output")
|
|
823
|
+
SPAWN_RESULT=$(invoke_pipe "$SPAWN_AGENT" "Background task: $SPAWN_TASK" || echo "No output")
|
|
343
824
|
fi
|
|
344
825
|
|
|
345
826
|
# Post result back
|
|
@@ -347,37 +828,29 @@ Complete this task thoroughly. You also have sinain_get_knowledge and sinain_kno
|
|
|
347
828
|
curl -sf -X POST "$CORE_URL/spawn/respond" \
|
|
348
829
|
-H 'Content-Type: application/json' \
|
|
349
830
|
-d "{\"id\":\"$SPAWN_ID\",\"result\":$(echo "$SPAWN_RESULT" | json_encode)}" >/dev/null 2>&1 || true
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
# MCP path: agent runs heartbeat tools
|
|
363
|
-
invoke_agent "$HEARTBEAT_PROMPT" || true
|
|
364
|
-
else
|
|
365
|
-
# Pipe path: run curation scripts directly
|
|
366
|
-
SCRIPTS_DIR="$WORKSPACE/sinain-memory"
|
|
367
|
-
MEMORY_DIR="$WORKSPACE/memory"
|
|
368
|
-
if [ -d "$SCRIPTS_DIR" ]; then
|
|
369
|
-
python3 "$SCRIPTS_DIR/signal_analyzer.py" --memory-dir "$MEMORY_DIR" 2>/dev/null || true
|
|
370
|
-
python3 "$SCRIPTS_DIR/playbook_curator.py" --memory-dir "$MEMORY_DIR" 2>/dev/null || true
|
|
371
|
-
echo "[$(date +%H:%M:%S)] Heartbeat: ran signal_analyzer + playbook_curator"
|
|
831
|
+
# Detect spawn-side errors (401/403/500, auth failures, max-turns,
|
|
832
|
+
# crashes). Print full body inline between dividers + append to
|
|
833
|
+
# /tmp/sinain-drops.log for post-hoc diagnosis, same UX as escalation.
|
|
834
|
+
if echo "$SPAWN_RESULT" | grep -qiE "API Error|unauthorized|401|403|invalid_api_key|Reached max turns|invocation failed|^Error:"; then
|
|
835
|
+
echo "[$(date +%H:%M:%S)] ⚠ SPAWN DROP ($SPAWN_ID) ──────────────────────"
|
|
836
|
+
echo "$SPAWN_RESULT"
|
|
837
|
+
echo "─────────────────────────────────────────────────────────────"
|
|
838
|
+
{
|
|
839
|
+
echo "===== $(date -u +%Y-%m-%dT%H:%M:%SZ) SPAWN DROP ($SPAWN_ID, agent=$SPAWN_AGENT) ====="
|
|
840
|
+
echo "$SPAWN_RESULT"
|
|
841
|
+
echo ""
|
|
842
|
+
} >> /tmp/sinain-drops.log
|
|
372
843
|
else
|
|
373
|
-
echo "[$(date +%H:%M:%S)]
|
|
844
|
+
echo "[$(date +%H:%M:%S)] Spawn $SPAWN_ID completed: ${SPAWN_RESULT:0:120}..."
|
|
374
845
|
fi
|
|
375
846
|
fi
|
|
376
|
-
|
|
377
|
-
LAST_HEARTBEAT=$NOW
|
|
378
|
-
echo "[$(date +%H:%M:%S)] Heartbeat complete"
|
|
379
847
|
echo ""
|
|
380
848
|
fi
|
|
381
849
|
|
|
850
|
+
# Heartbeat moved server-side: sinain-core's LocalCurationService runs the
|
|
851
|
+
# full pipeline (signal_analyzer, insight_synthesizer, feedback_analyzer,
|
|
852
|
+
# memory_miner, playbook_curator) every 30 min natively, and broadcasts
|
|
853
|
+
# insights to the HUD directly. No LLM roundtrip needed here.
|
|
854
|
+
|
|
382
855
|
sleep "$POLL_INTERVAL"
|
|
383
856
|
done
|